Skip to content

P7: compile messages to data, not to functions - #110

Open
k0d13 wants to merge 1 commit into
mainfrom
compile-messages
Open

P7: compile messages to data, not to functions#110
k0d13 wants to merge 1 commit into
mainfrom
compile-messages

Conversation

@k0d13

@k0d13 k0d13 commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Closes #109.

The plugin emitted ICU strings and the runtime parsed and formatted them on demand, so the client got messageformat, its parser, both skeleton parsers, and a format cache whose only job was to avoid paying for the same parse twice. Everything about a message is known at build time, so none of that has to ship.

saykit compile now writes one module per locale, and that module is data:

// 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 that message is called, and memoises it. What ships is four wrappers over Intl and a ~40 line evaluator, and nothing parses anything.

What changed

  • @saykit/config compiles ICU to a Message tree instead of to function source. Styles still resolve at build time, into the Intl options bag they stand for
  • The generated module has no imports, no bound locale and no executable code. The message it came from is written above each entry, as before
  • saykit gains Message and compileMessage(message, locale). View.Messages is a record of nodes, and view.call() compiles on first use and caches per view
  • SayProvider is back to store, or locale and messages, and <SayProvider> on the server takes no props again — the react-server build reads the established view and hands both across
  • saykit/runtime is no longer a public entry point. Nothing imports it now that generated modules are data, so it is bundled into the main entry

Why an AST, and not one function per message

#95 proposed emitting the function directly:

export const a1b2 = (v) => `Salut ${v._name}`;

That was the first pass of this branch. It is smaller and faster, and it has one problem with no workaround worth having: a function cannot cross the RSC boundary.

With compiled functions a server component cannot hand its messages to a client tree at all. The app has to import the catalogue in a client module and pass only the locale, which means a providers.tsx per app, a catalogue reachable from a module that is not server-only, and every locale imported eagerly on the client so hydration matches. That branch existed, the nextjs example carried all of it, and it is a real tax for something the compiler is supposed to remove.

Data crosses. So the provider goes back to taking locale and messages, <SayProvider> on the server takes no props, and no example needs a providers module.

Two things fall out of it that are worth having on their own:

  • Distance from ICU. Nothing at runtime knows the nodes came from ICU. Another input format compiles to the same nodes, which is what Idea: MF2 (ICU MessageFormat 2) as an opt-in output format #75 would need
  • An inert module. No imports and no bound locale in the file a bundler reads, 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, after a compile that happens once per message per locale and only for messages that render. A message with no placeholders is emitted as a bare string and skips all of it.

Examples

  • nextjs: <SayProvider> with no props inside the withSay-wrapped layout, getSay() for lang. No providers module
  • tanstack-start: unchanged, locale and messages from the loader is still the right form
  • Every example's committed .d.ts is now Record<string, Message>

@greptile-apps

greptile-apps Bot commented Sep 6, 2026

Copy link
Copy Markdown

Too many files changed for review (151 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

@changeset-bot

changeset-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a7669f4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
saykit Minor
@saykit/config Minor
@saykit/react Minor
babel-plugin-saykit Minor
unplugin-saykit Minor
@saykit/carbon Minor
@saykit/format-json Minor
@saykit/format-po Minor
@saykit/transform-js Minor
@saykit/transform-jsx Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
saykit Ready Ready Preview Sep 6, 2026 8:50am UTC

@github-actions github-actions Bot added dependencies Updates or changes related to project dependencies examples Updates or additions to example apps tests Modifications, additions, or fixes related to testing package: core Related to the core saykit package package: react Related to @saykit/react package: config Related to @saykit/config and the CLI package: babel-plugin Related to babel-plugin-saykit package: unplugin Related to unplugin-saykit website Updates to the documentation website labels Sep 6, 2026
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Too many files!

This PR contains 150 files, which is 50 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 5c3daa74-5a7c-4662-a839-d8c5357b552a

📥 Commits

Reviewing files that changed from the base of the PR and between 43f8f1a and a7669f4.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (150)
  • .changeset/lucky-moons-shave.md
  • .gitignore
  • README.md
  • examples/babel/README.md
  • examples/babel/package.json
  • examples/babel/src/locales/en.d.po.ts
  • examples/babel/src/locales/en.d.ts
  • examples/babel/src/locales/fr.d.po.ts
  • examples/babel/src/locales/fr.d.ts
  • examples/babel/src/main.ts
  • examples/browser-extension/_locales/de/messages.d.json.ts
  • examples/browser-extension/_locales/de/messages.d.ts
  • examples/browser-extension/_locales/en/messages.d.json.ts
  • examples/browser-extension/_locales/en/messages.d.ts
  • examples/browser-extension/_locales/fr/messages.d.json.ts
  • examples/browser-extension/_locales/fr/messages.d.ts
  • examples/browser-extension/package.json
  • examples/browser-extension/src/i18n.ts
  • examples/carbon/package.json
  • examples/carbon/src/i18n.ts
  • examples/carbon/src/locales/de.d.json.ts
  • examples/carbon/src/locales/de.d.ts
  • examples/carbon/src/locales/en-US.d.json.ts
  • examples/carbon/src/locales/en-US.d.ts
  • examples/carbon/src/locales/fr.d.json.ts
  • examples/carbon/src/locales/fr.d.ts
  • examples/carbon/src/locales/ja.d.json.ts
  • examples/carbon/src/locales/ja.d.ts
  • examples/custom-formatter/package.json
  • examples/custom-formatter/src/i18n.ts
  • examples/custom-formatter/src/locales/de.d.ts
  • examples/custom-formatter/src/locales/de.d.yml.ts
  • examples/custom-formatter/src/locales/en.d.ts
  • examples/custom-formatter/src/locales/en.d.yml.ts
  • examples/custom-formatter/src/locales/fr.d.ts
  • examples/custom-formatter/src/locales/fr.d.yml.ts
  • examples/expo/README.md
  • examples/expo/babel.config.js
  • examples/expo/metro.config.js
  • examples/expo/package.json
  • examples/expo/src/i18n.ts
  • examples/expo/src/locales/en.d.json.ts
  • examples/expo/src/locales/en.d.ts
  • examples/expo/src/locales/fr.d.json.ts
  • examples/expo/src/locales/fr.d.ts
  • examples/expo/src/locales/ja.d.json.ts
  • examples/expo/src/locales/ja.d.ts
  • examples/nextjs/README.md
  • examples/nextjs/next.config.mjs
  • examples/nextjs/package.json
  • examples/nextjs/src/i18n.ts
  • examples/nextjs/src/locales/en.d.po.ts
  • examples/nextjs/src/locales/en.d.ts
  • examples/nextjs/src/locales/fr.d.po.ts
  • examples/nextjs/src/locales/fr.d.ts
  • examples/nextjs/src/locales/pl.d.po.ts
  • examples/nextjs/src/locales/pl.d.ts
  • examples/react/package.json
  • examples/react/src/i18n.ts
  • examples/react/src/locales/en.d.po.ts
  • examples/react/src/locales/en.d.ts
  • examples/react/src/locales/fr.d.po.ts
  • examples/react/src/locales/fr.d.ts
  • examples/react/src/locales/ja.d.po.ts
  • examples/react/src/locales/ja.d.ts
  • examples/react/src/locales/pl.d.po.ts
  • examples/react/src/locales/pl.d.ts
  • examples/tanstack-start/package.json
  • examples/tanstack-start/src/i18n.ts
  • examples/tanstack-start/src/locales/en-GB.d.json.ts
  • examples/tanstack-start/src/locales/en-GB.d.ts
  • examples/tanstack-start/src/locales/en-NZ.d.json.ts
  • examples/tanstack-start/src/locales/en-NZ.d.ts
  • examples/tanstack-start/src/locales/en.d.json.ts
  • examples/tanstack-start/src/locales/en.d.ts
  • examples/tanstack-start/src/locales/fr.d.json.ts
  • examples/tanstack-start/src/locales/fr.d.ts
  • examples/vanilla/README.md
  • examples/vanilla/package.json
  • examples/vanilla/src/i18n.ts
  • examples/vanilla/src/locales/en.d.po.ts
  • examples/vanilla/src/locales/en.d.ts
  • examples/vanilla/src/locales/fr.d.po.ts
  • examples/vanilla/src/locales/fr.d.ts
  • examples/vanilla/src/locales/ja.d.po.ts
  • examples/vanilla/src/locales/ja.d.ts
  • examples/vanilla/src/locales/pl.d.po.ts
  • examples/vanilla/src/locales/pl.d.ts
  • packages/config/package.json
  • packages/config/src/commands/compile.ts
  • packages/config/src/commands/index.ts
  • packages/config/src/features/catalogue/emit.test.ts
  • packages/config/src/features/catalogue/emit.ts
  • packages/config/src/features/catalogue/index.ts
  • packages/config/src/features/catalogue/path.ts
  • packages/config/src/features/catalogue/storage.test.ts
  • packages/config/src/features/catalogue/storage.ts
  • packages/config/src/features/messages/compile.test.ts
  • packages/config/src/features/messages/compile.ts
  • packages/config/src/features/messages/index.ts
  • packages/config/src/features/messages/styles.ts
  • packages/config/src/features/workers/extract-worker.test.ts
  • packages/config/src/features/workers/extract-worker.ts
  • packages/integration-react/src/runtime/client.server.ts
  • packages/integration-react/src/runtime/client.test.tsx
  • packages/integration-react/src/runtime/client.ts
  • packages/integration-react/src/runtime/server.test.tsx
  • packages/integration-react/src/runtime/server.ts
  • packages/integration/package.json
  • packages/integration/src/catalogue.test.ts
  • packages/integration/src/index.ts
  • packages/integration/src/message.test.ts
  • packages/integration/src/message.ts
  • packages/integration/src/messageformat/convert.ts
  • packages/integration/src/messageformat/functions.ts
  • packages/integration/src/messageformat/index.test.ts
  • packages/integration/src/messageformat/index.ts
  • packages/integration/src/messageformat/options.ts
  • packages/integration/src/messageformat/values.ts
  • packages/integration/src/runtime.test.ts
  • packages/integration/src/runtime.ts
  • packages/integration/src/store.test.ts
  • packages/integration/src/view.test.ts
  • packages/integration/src/view.ts
  • packages/plugin-babel/package.json
  • packages/plugin-babel/src/catalogue.ts
  • packages/plugin-babel/src/index.test.ts
  • packages/plugin-babel/src/index.ts
  • packages/plugin-babel/src/metro/index.ts
  • packages/plugin-babel/src/metro/transformer.ts
  • packages/plugin-babel/src/next/index.ts
  • packages/plugin-babel/src/next/loader.ts
  • packages/plugin-babel/tsdown.config.ts
  • packages/plugin-unplugin/src/index.test.ts
  • packages/plugin-unplugin/src/index.ts
  • website/content/core-concepts/architecture.mdx
  • website/content/core-concepts/extraction.mdx
  • website/content/core-concepts/runtime.mdx
  • website/content/getting-started/introduction.mdx
  • website/content/getting-started/quickstart.mdx
  • website/content/guides/custom-formatter.mdx
  • website/content/guides/dynamic-loading.mdx
  • website/content/guides/typed-messages.mdx
  • website/content/integrations/babel.mdx
  • website/content/integrations/carbon.mdx
  • website/content/integrations/react.mdx
  • website/content/integrations/vite.mdx
  • website/content/reference/api/react.mdx
  • website/content/reference/api/saykit.mdx
  • website/content/reference/cli.mdx

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

Labels

dependencies Updates or changes related to project dependencies examples Updates or additions to example apps package: babel-plugin Related to babel-plugin-saykit package: config Related to @saykit/config and the CLI package: core Related to the core saykit package package: react Related to @saykit/react package: unplugin Related to unplugin-saykit tests Modifications, additions, or fixes related to testing website Updates to the documentation website

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P7: compile messages to data, not to functions

1 participant