Skip to content

P7: compile messages to data, not to functions #109

Description

@k0d13

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.

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.
export default {
  // {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:

export const a1b2 = (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.

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