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.
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
Saydoes 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 secondSayin the provider, React server clones into aReact.cache()cell).This idea is to separate the two roles
Saycurrently 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.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.
P3(core,carbon): scoped
say, backed byAsyncLocalStorageA 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
SayPluginthat writes toglobalThisand patches two prototypes.AsyncLocalStorageis 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 makessaysomethingsaykitcan 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.P4(react):
useSyncExternalStoreover the storeSayProvidertakeslocaleandmessages, mirrors both intouseState, builds a thirdSayfrom them and freezes it. With P2 it holds the store and nothing else, anduseSaybecomes a subscription, which deletes the rebuild, theSayRefindirection and both props.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,getSayand thecreateWithSayHOC 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, andSayProvidercan read the enclosing scope for its initial value rather than being handed one.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
Sayinstance initialisation. There is the option of having the plugin emit a virtual module intended to access the basic values from the 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.These may also be virtual modules, still things to consider.