Skip to content

SayKit DX Proposals #95

Description

@k0d13

I wrote the core of saykit a while ago, and having lived with it since, I am no longer happy with a lot of it. The API works, but the DX is not what I want, and several early decisions have turned into things I work around rather than build on. This issue lists what I am considering, and is also the place to suggest your own.

P1(core): catalogues and views

Say does too many things with too many states. Sometimes it holds every locale, its messages and its loaders; sometimes it is a frozen clone that knows one locale. activate() mutates, so every integration defends differently (Carbon clones per interaction, React client builds a second Say in the provider, React server clones into a React.cache() cell).

This idea is to separate the two roles Say currently mixes. A catalogue owns the locales, the messages and the loaders, and never formats anything. A view is one locale bound to a catalogue: callable, immutable, memoised, and the only thing application code ever holds. Nothing mutates, so nothing downstream has to clone or freeze to protect itself.

const say = new Say({ locales, messages });
say.activate('fr'); // mutates; everyone downstream clones and freezes
const view = say.clone(); // fresh, empty #formats

// proposed
const catalogue = createCatalogue({ locales: ['en', 'fr', 'pl'], messages: { en, fr, pl } });
const say = catalogue.locale('en'); // bound view: callable, immutable, memoised
say`Hello, ${name}!`;

P2(core): locale switching belongs to a store, not to a view

A view under P1 holds no public reference back to the catalogue, so it cannot switch locale, and it should not be able to. Switching is a third role, belonging to a reactive store that owns which view is current and says when that changed.

const store = createStore(catalogue, 'en');

store.current; // a view, new identity after every successful set
await store.set('fr'); // loads if needed, swaps, notifies
store.subscribe(render); // returns unsubscribe

P3(core,carbon): scoped say, backed by AsyncLocalStorage

A server handles several locales at once, so any instance reachable from more than one request has to be defended, and Carbon defends by cloning per interaction on top of a SayPlugin that writes to globalThis and patches two prototypes. AsyncLocalStorage is the mechanism that actually fits: a request establishes a scope holding one view, everything inside reads it, concurrent requests cannot see each other's. That makes say something saykit can export directly, backed by the scope on a server and by a module-scope store in the browser, where there is one locale at a time and nothing to isolate.

runWithSay(createView(catalogue, locale), handler); // per request

import { say } from 'saykit'; // anywhere inside, any depth
say`Hello, ${name}!`;

P4(react): useSyncExternalStore over the store

SayProvider takes locale and messages, mirrors both into useState, builds a third Say from them and freezes it. With P2 it holds the store and nothing else, and useSay becomes a subscription, which deletes the rebuild, the SayRef indirection and both props.

<SayProvider store={store}>

const say = useSay(); // re-renders on locale change

I am still deciding how to handle the server->client boundary, as only serialisable values can be passed, P5 is related.

P5(react): one server scope instead of three primitives

setSay, getSay and the createWithSay HOC all exist to get an instance to a server component without threading it through props. With P3, one boundary does it for a whole subtree, and SayProvider can read the enclosing scope for its initial value rather than being handed one.

<SayScope catalogue={catalogue} locale={locale}>
  <SayProvider>{children}</SayProvider>
</SayScope>

P6(build): locale metadata as one virtual module

Currently it is necessary to list all locales in at least two places, the build config and the Say instance initialisation. There is the option of having the plugin emit a virtual module intended to access the basic values from the config.

import { locales, defaultLocale, fallbackLocales } from 'virtual:saykit/config';

P7(build,core): compile messages to functions

The plugin emits ICU strings and the runtime parses and formats them on demand, so the client gets messageformat, its parser and both skeleton parsers, plus a format cache whose only job is to avoid paying for the same parse twice. This idea is to have the plugin emit JavaScript instead: one function per message, per locale, already specialised to what that message does. Part of the goal is to distance saykit from ICU, and open the door to supporting other formats as outputs.

say`Hi ${name}`; // today: say.call({ id: 'a1b2', _name: name }) against { a1b2: 'Hi {name}' }

// proposed, generated per locale and called directly
export const a1b2 = (v) => `Salut ${v._name}`;

These may also be virtual modules, still things to consider.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions