You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The plugin emits ICU strings and the runtime parses and formats them on demand, so the client gets messageformat, its parser, both skeleton parsers, and a format cache whose only job is to avoid paying for the same parse twice. Everything about a message is known at build time, so none of that has to ship.
say`Hi ${name}`;// today: say.call({ id: 'a1b2', _name: name }) against { a1b2: 'Hi {name}' }
What the CLI should emit
saykit compile writes one module per locale, and that module is data: every style already resolved to the Intl options bag it stands for, and the message it came from written above it for review.
// Generated by saykit. Do not edit.exportdefault{// {count, plural, one {# article} other {# articles}}"c4Ef_2": ["?",["=",["f","plural",["v","_count"]],"one"],["c",["f","number",["v","_count"]]," article"],["c",["f","number",["v","_count"]]," articles"]],};
A view compiles a node into a closure the first time it is called, and memoises it. The walk is one pass over a small array, per message, per locale, and only for messages that actually render. A message with no placeholders is emitted as a bare string and costs nothing at all.
The runtime is then four small wrappers over Intl (number, datetime, plural, duration) and a ~40 line evaluator. No parser, and no format cache: the compiled closure is the cache.
Why an AST, and not one function per message
The obvious version of this proposal is to emit the function directly:
exportconsta1b2=(v)=>`Salut ${v._name}`;
That is smaller and faster still, and it was the first pass. It has one problem that has no workaround worth having: a function cannot cross the RSC boundary. A server component that wants to hand its locale to a client tree can send data and nothing else, so with compiled functions the messages simply cannot go, and the app is pushed into importing the catalogue in a client module and passing only the locale — a providers.tsx per app, a catalogue that has to be reachable from a module that is not server-only, and every locale imported eagerly on the client so hydration matches. Next.js is where this hurts most, and it is not a small tax.
Data crosses. An array of arrays is JSON, so <SayProvider> on the server keeps reading the established view and handing over locale and messages, and the application writes no boilerplate at all.
Two more things fall out of it, which are the reasons to prefer this even without RSC:
Distance from ICU. The nodes are not ICU, and nothing at runtime knows they came from it. Another input format compiles to the same nodes, which is what Idea: MF2 (ICU MessageFormat 2) as an opt-in output format #75 (MF2) would need, and what a non-ICU authoring format would need
A generated module stays inert. No imports, no bound locale, no executable code in the file a bundler reads. The locale is bound where the view is built, so the same messages can serve any locale binding
The cost is one indirection per node at format time, against a template literal the engine JITs. For a message rendered in a render pass, after the compile is memoised, that is not a cost anyone can measure. Correctness at the boundary is worth more.
Breaking
Breaking for saykit and @saykit/config: View.Messages is compiled data rather than ICU strings, and the generated module changes shape. saykit/runtime stops being a public entry point, since nothing imports it any more.
Tracking issue for P7 from #95, on top of #96, #98, #101, #103 and #107.
The plugin emits ICU strings and the runtime parses and formats them on demand, so the client gets
messageformat, its parser, both skeleton parsers, and a format cache whose only job is to avoid paying for the same parse twice. Everything about a message is known at build time, so none of that has to ship.What the CLI should emit
saykit compilewrites one module per locale, and that module is data: every style already resolved to theIntloptions bag it stands for, and the message it came from written above it for review.A view compiles a node into a closure the first time it is called, and memoises it. The walk is one pass over a small array, per message, per locale, and only for messages that actually render. A message with no placeholders is emitted as a bare string and costs nothing at all.
The runtime is then four small wrappers over
Intl(number,datetime,plural,duration) and a ~40 line evaluator. No parser, and no format cache: the compiled closure is the cache.Why an AST, and not one function per message
The obvious version of this proposal is to emit the function directly:
That is smaller and faster still, and it was the first pass. It has one problem that has no workaround worth having: a function cannot cross the RSC boundary. A server component that wants to hand its locale to a client tree can send data and nothing else, so with compiled functions the messages simply cannot go, and the app is pushed into importing the catalogue in a client module and passing only the locale — a
providers.tsxper app, a catalogue that has to be reachable from a module that is notserver-only, and every locale imported eagerly on the client so hydration matches. Next.js is where this hurts most, and it is not a small tax.Data crosses. An array of arrays is JSON, so
<SayProvider>on the server keeps reading the established view and handing overlocaleandmessages, and the application writes no boilerplate at all.Two more things fall out of it, which are the reasons to prefer this even without RSC:
The cost is one indirection per node at format time, against a template literal the engine JITs. For a message rendered in a render pass, after the compile is memoised, that is not a cost anyone can measure. Correctness at the boundary is worth more.
Breaking
Breaking for
saykitand@saykit/config:View.Messagesis compiled data rather than ICU strings, and the generated module changes shape.saykit/runtimestops being a public entry point, since nothing imports it any more.