DEV Community

Cover image for I shipped one SDK for React, Vue, Angular, Svelte and vanilla JS. Each framework broke it differently
Ilia Kolebaev
Ilia Kolebaev

Posted on

I shipped one SDK for React, Vue, Angular, Svelte and vanilla JS. Each framework broke it differently

I maintain an analytics SDK for Figma plugins. Plugin authors use whatever UI framework they like, so the SDK has five front doors: React, Vue, Angular, Svelte and plain TypeScript.

I did not want five SDKs. I wanted one, with five small wrappers. This post is about where the line between them ended up, and the one thing in each framework that would not fit behind it.

What the core owns

Everything that is hard lives in @tode-sdk/core:

  • a singleton that holds the config and sends events
  • a queue for events tracked before the config arrives
  • the handshake that fetches the config from the plugin's sandbox
  • a WebSocket that measures session length, with reconnect and backoff

None of that code imports a framework. It is two files, about 900 lines together, and its tests run without any framework installed.

What an adapter is allowed to do

Each adapter does the same four things:

  1. Listen for the config message from the plugin.
  2. Ask for the config, in case it was sent before the listener existed.
  3. When the config arrives, take a handle on the shared session socket.
  4. Expose two values, isReady and wsState, in the shape the framework expects, and clean up when the framework says so.

Step 4 is the only part that differs. Here is the Vue version of steps 1 to 3, which is the plainest of the five:

onMounted(() => {
  window.addEventListener('message', handleMessage);
  requestConfigFromPlugin();
});

onUnmounted(() => {
  window.removeEventListener('message', handleMessage);
  sessionSocket?.destroy();
});
Enter fullscreen mode Exit fullscreen mode

The adapters run from 184 lines (Vue) to 336 (vanilla), comments included. If I find myself writing an if about reconnects inside an adapter, it is in the wrong package.

React: function identity and stale callbacks

The hook returns trackAction. People put it in dependency arrays:

const { trackAction } = useTode();

useEffect(() => {
  trackAction('settings_opened');
}, [trackAction]);
Enter fullscreen mode Exit fullscreen mode

If the hook builds a new function on each render, that effect runs on each render, and the plugin sends an event every time anything changes on screen. So the functions are defined once at module level and the hook hands out the same ones every time:

const trackAction = (name, options) => tode.trackAction(name, options);

export function useTode() {
  // ...
  return { tode, isReady, wsState, trackAction, trackActionWithMetric };
}
Enter fullscreen mode Exit fullscreen mode

The second React problem runs the other way. The hook accepts an onWebSocketStateChange callback, and people pass an inline arrow. The socket outlives renders, so it would keep calling the arrow from the first render forever, with the first render's state inside it. The hook reads the callback through a ref that it updates on every render:

const onStateChangeRef = useRef(onWebSocketStateChange);
onStateChangeRef.current = onWebSocketStateChange;
Enter fullscreen mode Exit fullscreen mode

React is also the reason the socket is reference-counted in the core. Five components calling useTode() should be one session, so each hook instance takes a handle and the connection closes when the last handle is released.

Vue: nothing

ref, readonly, onMounted, onUnmounted. The Vue adapter is the shortest of the five and has no workaround in it. The only choice was to return the refs wrapped in readonly() so nobody sets isReady by hand.

Angular: two things

The first is the build. I build every package with tsup, and the Angular compiler is not part of that. A plain @Injectable({ providedIn: 'root' }) decorator, left uncompiled, needs @angular/compiler at runtime. Apps built ahead of time do not ship it, so the service fails to inject.

The fix is to write by hand what the decorator would have compiled to:

export class TodeService implements OnDestroy {
  static ɵprov = ɵɵdefineInjectable({
    token: TodeService,
    providedIn: 'root',
    factory: () => new TodeService(ɵɵinject(NgZone)),
  });
}
Enter fullscreen mode Exit fullscreen mode

I am not happy about this one. The ɵ prefix is how Angular marks its private API. It works on the version I test against, and it is the part of the SDK I expect to break first.

The second thing is the zone. The session socket pings every 5 seconds. Inside Angular's zone, every one of those timers triggers change detection across the whole plugin UI, for a ping the UI does not care about. So the socket is created outside the zone, and only real state changes step back in:

this.ngZone.runOutsideAngular(() => {
  this.sessionSocket = acquireSessionSocket({
    onStateChange: (state) =>
      this.ngZone.run(() => this.wsStateSubject.next(state)),
  });
  this.sessionSocket.start();
});
Enter fullscreen mode Exit fullscreen mode

Svelte: a store has no lifecycle

The Svelte adapter is a store, because a store works in any component and in plain .ts files. A store does not know when the component using it is destroyed, so the caller has to say so:

<script>
  import { createTodeStore } from '@tode-sdk/figma-svelte';
  import { onDestroy } from 'svelte';

  const todeStore = createTodeStore();
  onDestroy(() => todeStore.destroy());
</script>
Enter fullscreen mode Exit fullscreen mode

That line is easy to forget. A forgotten destroy() means a ping timer and a reconnect loop that keep running. As a safety net the store also listens for beforeunload and destroys itself there.

Vanilla: the listener that arrives too late

The vanilla adapter is a class with on('ready', ...). It starts itself in the constructor by default, and that created a race with its own API:

const client = new TodeClient();
client.on('ready', () => { /* never called */ });
Enter fullscreen mode Exit fullscreen mode

If the config was already available, ready fired inside the constructor, before the next line had a chance to subscribe. The start is now deferred by one microtask:

if (this.config.autoStart) {
  queueMicrotask(() => {
    if (!this._destroyed) this.start();
  });
}
Enter fullscreen mode Exit fullscreen mode

One microtask is enough for the synchronous code after new to attach its listeners.

Releasing six packages

All six packages share one version number and go out together. The build runs core first and the adapters after it, since they compile against it.

Independent versions would let each adapter move at its own pace. I kept one number anyway. With one number, "which version are you on" has one answer, and nobody can combine a new adapter with an old core.

What I cannot promise

The peer dependency ranges are wide: React 16.8 and up, Vue 3, Angular 14 and up, Svelte 3 and up. Those floors come from the oldest API each adapter uses.

The tests only run against the versions pinned in each package, currently Svelte 5 and Angular 19. Nothing automated exercises the older versions inside those ranges. The README says so, and I would rather say it than claim support I have not checked.

Was the split worth it

A bug in the reconnect logic gets fixed once, in one file, for all five frameworks. That alone pays for the split.

The cost is that the core has to offer hooks that only one framework needs. Reference counting exists because of React. The logPrefix option exists so each adapter can label its own console output. The core is a little more general than any single adapter would want.

The code is MIT licensed: github.com/kolebayev/tode-sdk. The service it reports to is tode, analytics for Figma plugins.

If you maintain a library with adapters for several frameworks, I am curious which framework gave you the most trouble, and whether you version the packages together.

Top comments (0)