Skip to content

feat: runtime core macro - #2618

Open
timofei-iatsenko wants to merge 19 commits into
mainfrom
feat/runtime-core-macro
Open

timofei-iatsenko wants to merge 19 commits into
mainfrom
feat/runtime-core-macro

Conversation

@timofei-iatsenko

@timofei-iatsenko timofei-iatsenko commented Jul 14, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Adds an experimental runtime macro: msg (alias defineMessage), plural, select and selectOrdinal as plain functions in @lingui/core/runtime-macro. They need no Babel, SWC or native transform, so Lingui works where there is no build step: Node.js scripts, serverless functions, CLI tools, test runners, or a backend that shares a catalog with the frontend.

They return the same message descriptor, with the same generated ID, as the compile-time macros, so catalogs stay the same.

import { i18n } from "@lingui/core"
import { msg, plural } from "@lingui/core/runtime-macro"

i18n._(msg`Hello ${{ name }}`)
i18n._(msg`You have ${plural({ count }, { one: "# message", other: "# messages" })}`)
i18n._(msg({ message: "Welcome back", context: "homepage" }))

Extraction is opt-in:

// lingui.config
export default defineConfig({
  // ...
  experimental: { runtimeMacro: true },
})

What's in this PR

  • @lingui/core/runtime-macro subpath export, built as a separate entry. The main @lingui/core bundle doesn't change.
  • experimental.runtimeMacro config option, so the extractor picks up imports from the new path.
  • Parity tests: every case runs through both the runtime helpers and the Babel macro plugin, and the results must match.
  • Docs: a runtime macro section in the core reference, the new config option, tips on the Macros page and in the JavaScript tutorial, and an announcement blog post.

Differences from the compile-time macros

  • Placeholders must be named: ${{ name }}, not ${name}. Variable names can't be read at runtime, and positional placeholders would change the message and its ID. A plain value throws an error that explains the fix.
  • Option values can't be plain template literals. Nest msg instead.
  • No t and no JSX. Define a message with msg, translate it with i18n._.
  • Without a compile step, the source message stays in the bundle.
  • Use plural, select and selectOrdinal inside msg. With the option enabled, a build step (the Babel plugin or the Vite native transform) also compiles these imports, and a standalone compiled plural returns a translated string instead of a descriptor.
  • The SWC plugin doesn't read the Lingui config. To compile these imports there too, add the path to its corePackage option.

Experimental

The API and the import path may change in a minor release. We'll revisit the name before the next major.

Related

Requests to use Lingui without a build step:

Compiler plugin friction (context only, not solved here since JSX isn't covered):

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Examples update

Checklist

  • I have read the CONTRIBUTING and CODE_OF_CONDUCT docs
  • I have added tests that prove my fix is effective or that my feature works
  • I have added the necessary documentation (if appropriate)

@vercel

vercel Bot commented Jul 14, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
js-lingui Ready Ready Preview Oct 14, 2026 1:21pm UTC

Request Review

@github-actions

github-actions Bot commented Jul 14, 2026 •

Copy link
Copy Markdown

size-limit report 📦

Path Size
packages/core/dist/index.mjs 1.83 KB (0%)
packages/detect-locale/dist/index.mjs 633 B (0%)
packages/react/dist/index.mjs 1.91 KB (0%)

@timofei-iatsenko timofei-iatsenko changed the title Feat/runtime core macro feat: runtime core macro Jul 14, 2026

@andrii-bodnar andrii-bodnar left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PoC, the direction looks right to me. My main concern for taking this out of draft is a parity test suite against the compile-time macro: identical inputs -> identical id/message, to avoid silent drift between compile and runtime macro. Is it possible to tape it with unit tests?

I'd ship it as a subpath export @lingui/core/runtime-macro (is this a valid name? I recall something from our previous releases about the requirement for macro to end with the /macro suffix. So, not sure about this). BTW, is it really a macro now? A macro is by definition compile-time expansion. It could be a good idea for another terminology and eventually decoupling these things.

@MurzNN

MurzNN commented Jul 19, 2026 •

Copy link
Copy Markdown

@timofei-iatsenko thank you for the feature, let's move it forward!

Some of my projects require a native declaration of the translation messages in pure JS without any macro magic, so I started to invent workarounds like this #2586

But the current implementation works much better! As I can now declare Lingui messages in any script without depending on a JS transpiler, this makes possible to use defined strings not only with Lingui, but with any other parts of the application, where using Lingui is not possible!

For example, I use Payload CMS, which uses it's own self-implemented translation system and no ability to integrate other translation systems. I submitted a feature request to fix this payloadcms/payload#17332 but afraid that it will take too long time.

So, for now, using the runtime-macro and help from @timofei-iatsenko (thank you a lot for help!) I am able to define translation strings only once and reuse them in both parts - Lingui-powered frontend, and Payload CMS backend!

Here is an example of defining a list of translatable labels for a request status:

import { msg } from "@lingui/runtime-macro";

export const RequestStatuses = {
  collecting: msg`Collecting`,
  choosing: msg`Choosing`,
  fulfilled: msg`Fulfilled`,
  canceled: msg`Canceled`,
}

const msgObjectToFallback = (obj: Record<string, MessageDescriptor>) => {
  return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, value.message!]))
}

export const RequestStatusesFallback = msgObjectToFallback(RequestStatuses)

export type RequestStatus = keyof typeof RequestStatuses

Using this way, I can pass the RequestStatusesFallback object as a list of labels for the "requestStatus" field and it works well, without any errors and dependencies on transpilers!

@MurzNN

MurzNN commented Jul 19, 2026 •

Copy link
Copy Markdown

And about the name of this feature runtime-macro: I believe the macro term is wrong, and runtime sounds too complex and unclear.

Actually, Timofei invented a purge JS function that declares a message for translation, that works on vanilla JS without any transpilers, that unlocks usage of Lingui with any pure JS project!

So, let's maybe name it just as @lingui/js?

@MurzNN

MurzNN commented Jul 19, 2026

Copy link
Copy Markdown

Also, this feature makes possible to use Lingui in Playwright tests! This is especially convenient with the pseudo locale, reusing in the tests the same source strings and expression as in the app code!

@timofei-iatsenko

Copy link
Copy Markdown
Collaborator Author

parity test suite against the compile-time macro: identical inputs -> identical id/message

i can add additional check validating id's to existing cases in runtime version. Since the runtimve version is a subset from a compile time using full test suite of compile time version is not feasible.

I'd ship it as a subpath export @lingui/core/runtime-macro (is this a valid name? I recall something from our previous releases about the requirement for macro to end with the /macro suffix. So, not sure about this). BTW, is it really a macro now? A macro is by definition compile-time expansion. It could be a good idea for another terminology and eventually decoupling these things.

I could not agree more that naming should be adjusted, actually naming is the hardest part.

Let's break it all:

  1. Macro in the name

There are 2 things which are called macro used in the Lingui project.

  • Historically, you should write an ICU messages directly, like You have {count, plural, =1 {1 message} other {# messaged}}. Then macro helpers to write ICU messaged were added. So macro in this context means shortcuts to write an ICU messages, disregarding the implementation (compile time, runtime). (utility method, expanded to icu message)
  • However, this macro was implemented using babel-plugin-macros, hence the macro name, so in that context it means macro as compile time thingy processed by this plugin (piece of code expanded to another piece of code).

Over the years and iterations all this blurred a little bit. The main focus in the guides and APIs was shifted from writing ICU messages directly - to using helpers only, from using runtime methods - to using a compile time plugin. Basically, ICU messages became an "implementation detail", the developer quite rarely interacting with them directly, instead uses convenient API methods.

So looking at the context - this runtime macro - is still a macro, because it expands to an icu message + variables. However i could agree that we can shift away from this terminology and invent a new one.

We no longer enforced by any convention from the babel-plugin-macros, because it's deprecated and i'm not going to consider supporting it in a new features, so naming could be whatever suits best.

Basically, this "runtime" version could just become a new default api.

  1. Runtime in the name

After adding this we would have a 3 api surfaces:

  1. low level runtime - message lookup and placeholders evaluation
  2. compile time macro - transpiling to (1)
  3. runtime macro - currently not using anything from (1)

And here is a question, should we keep message defintion api surface at all from (1)? eq:

// this basically uses 2 functions: message definition + message lookup
i18n._("Hello world!")

Here functions are clearly divided, definition and lookup:

import { msg } from "@lingui/core/runtime-macro"

// defintion
const def = msg`Hello World`
// → { id: "...", message: "Hello World" }

// lookup
i18n._(def)

Because both (1) and (2) technically a runtime-something, it could bring a lot of confusion. Maybe we should deprecate and drop all the ambigous methods signatures from i18n in favour of (3) ?

We should also consider that JSX macro would also follow the same pattern. So naming pattern should be scalable for jsx as well.


Regarding moving forward - I would like to hide it behind a feature flag, so we can iterate on this without introducing a new major version every time. Feature flag would be on the lingui config, and basically would just toggle extractor support for this methods.

@andrii-bodnar

Copy link
Copy Markdown
Contributor

Thanks @timofei-iatsenko for the write-up and @MurzNN for the real-world use cases. Here's my thinking on a direction to get this out of PoC:

  • Scope: additive sibling to the compile-time macro, behind a lingui-config feature flag, marked experimental. No changes to existing i18n APIs. The bigger idea - making this the canonical define/lookup API and deprecating i18n._("literal") / global t - is worth doing, but as a separate project.

  • Name: @lingui/core/runtime-macro, kept next to /macro on purpose. The distinction users need is "import from /macro -> needs the plugin; from /runtime-macro -> just works", and the parallel names teach that. It's not a contradiction under the "ICU-authoring shorthand" sense of macro, and renaming only the runtime side would just increase asymmetry. It's flagged/experimental, so renamable later. (@MurzNN - holding off on @lingui/js: a standalone package is heavy here, over-commits to a product identity, and reads oddly inside js-lingui.)

  • i18n: define-only. Export msg/plural/select/selectOrdinal, look up via i18n._(msg`…`). No global-bound t.

  • JSX out of scope: JSX requires transpilation, so JSX authors already have a build pipeline and can use the compile-time macro - the "no build step" motivation doesn't apply. Every use case here is non-JSX. Revisit if someone has JSX without a transpiler.

  • De-draft bar: a bounded parity test. id = generateMessageId(message, context) is pure, so matching message+context -> matching id. So we only assert the runtime helpers emit the same message as the compile-time macro over the supported subset (reusing the existing babel-transform test setup) - not the full suite.

  • Deferred: context/property sugar - msg({ id, message, context }) already covers it.

Next steps: (1) move to the /runtime-macro subpath, (2) bounded parity test, (3) wire the flag to extractor support. What do you guys think?

@timofei-iatsenko

timofei-iatsenko commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

JSX out of scope: JSX requires transpilation, so JSX authors already have a build pipeline and can use the compile-time macro - the "no build step" motivation doesn't apply. Every use case here is non-JSX. Revisit if someone has JSX without a transpiler.

JSX is out of scope by other reason - for incremental delivery. JSX without compilation macro still has a lot of sense:

  1. JSX supported natively by almost all current transplers/bundlers at native speed, eq roldown, esbuild, rsbuild, swc, etc. Adding a plugin to them to just transpile lingui macro may slowdown transpilation a lot. Not all of them allowing executing native plugins, so adding plugins basically de-optimizing them significantly and also complicates the configuration.
  2. Usage with react-compiler. Looking at how things developed over time, it seems react compiler would be deeply integrated into tooling (mostly vite + rolldown) and it seems i't will not have a way to override an order (lingui first, than react compiler) so only one way to use lingui then - is a runtime jsx.

Name: @lingui/core/runtime-macro, kept next to /macro on purpose. The distinction users need is "import from /macro -> needs the plugin; from /runtime-macro -> just works", and the parallel names teach that. It's not a contradiction under the "ICU-authoring shorthand" sense of macro, and renaming only the runtime side would just increase asymmetry. It's flagged/experimental, so renamable later. (@MurzNN - holding off on @lingui/js: a standalone package is heavy here, over-commits to a product identity, and reads oddly inside js-lingui.)

BTW, adding a compile step will also optimize this runtime macro calls as well. So maybe some day we will just narrow api to that subset of runtime macro and offer compiler step just as optional improvement. (similarly to what formatjs offers https://formatjs.github.io/docs/tooling/swc-plugin)

@MurzNN

MurzNN commented Jul 24, 2026 •

Copy link
Copy Markdown

Thank you folks for the details, yes - agree with most of them! But about "macro" term - it is still not clear to me, why it is relevant to the pure JS function msg() aka defineMessage()?

The "macro" term means a short expression, that will be exposed to a full expression on the compile time, here is an explanation of this term:

In programming, a macro is a rule or pattern that tells the computer to map a short name or symbol to a larger sequence of code or instructions. Before the program runs or compiles, a tool replaces the macro name with its actual defined output, a process called macro expansion.

So, the "macro" term is actually relevant to only t and msg expressions that will be replaced to the actual code on the transpiling stage.

I am not against this term, I am good to go with the "runtime-macro", just worry about clear understanding for new users of the feature that this package provide. Seeing the "macro" word in the name I immediately associate the function with transpiling, babel, vite, or some other magic, and don't even expect the pure vanilla JS functionality there.

If we take a fresh look to the project, with introducing this feature we actually provide a "missing" core part of the Lingui:
before:

  • nothing native -> transpiling macro t -> React component <Trans>
    and now:
  • native JS msg -> transpiling macro t -> React component <Trans>

So, I believe the best place for this function msg() is in the "@lingui/core" package, actually.

And, by the way, can we somehow reuse the same native JS function msg() in the "upper" components like React component and transpiling macro, instead of keeping almost the same logic in several places?

So, from the fresh look, the function msg() is the main core concept of the whole Lingui project: it defines a translatable string as an unique identifier. This concrete main idea stands out the Lingui project from other approaches.

And all other functions are just additional features on top of this main concept.

And now, when we have the pure JS implementation of the msg(), we start to provide really a simple way of integrating this core translation approach to any JS project without any "macro" magic! And not only to JS, I want to implement the same Lingui approach for Drupal CMS on PHP too, because I have mixed projects and want to make them use the single .po translations file for the backend and the frontend.

I don't want to slow-down or sabotage the naming process, just sharing my thoughts as a kind-of newcomer to the Lingui world :) So, feel free to name it as you see it right.

@MurzNN

MurzNN commented Jul 24, 2026

Copy link
Copy Markdown

By the way, about tests - I see that we have a new separate test for the JS implementation of the msg() and friends. But actually they should behave the same as the functions for the macro and provide the feature parity, right?

So, maybe instead of writing a separate test, we can reuse the existing test to make it test both implementation with the same test data?

@MurzNN

MurzNN commented Oct 5, 2026 •

Copy link
Copy Markdown

Winter is coming! 🕙 😉 I've already started to use this feature in 4 different apps, and it works awesome! So, maybe let's try to release this as is, hidden by a feature flag with alpha label, before my retirement? 😄

@timofei-iatsenko

Copy link
Copy Markdown
Collaborator Author

@andrii-bodnar i wont be available to finish this. Could you take it over from here? I think almost everethying i terms of a code is in place, what's left is a proper naming, documentation support, and promotion.

@MurzNN could help you to identify a gaps not covered in this implementation, if they are.

@timofei-iatsenko

Copy link
Copy Markdown
Collaborator Author

Looked into the code once again, here what i've found missing:

  • Once decided on naming - this import path should be added into default lingui configuration:
 macro: {
    corePackage: ['@mycomp/native-core-macro'],
  },

And "What's left" section.

@andrii-bodnar

Copy link
Copy Markdown
Contributor

I'll take a closer look at how we can move forward.

andrii-bodnar and others added 3 commits October 13, 2026 16:44
- export `@lingui/core/runtime-macro` (built as a separate entry, core index unchanged)
- `experimental.runtimeMacro` adds the path to `macro.corePackage` so the extractor picks it up
- docs: core reference section and config option, marked experimental

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ement post

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@andrii-bodnar

Copy link
Copy Markdown
Contributor

I added the packaging, an experimental config option for extraction, and the docs, so it’s ready for a final look. I suggest shipping it as experimental and revisit the naming before the next major.

@MurzNN

MurzNN commented Oct 14, 2026 •

Copy link
Copy Markdown

Nice to see progress there, thank you Andrii! Agree that it's better to release it for now with the "experimental" label, to get testing in the wild and gather more feedback.

I still believe that the name "runtime macro" is not clear for the community, so created a public discussion here #2720 to gather more opinions about this. Let's maybe put a link to this discussion in the blog post "Lingui Without a Build Step: the Runtime Macro" to involve more users in this discussion?


Variable names can't be read at runtime, so placeholders must be named: `${{ name }}` instead of `${name}`. A plain value throws an error that tells you what to write instead. This is what keeps the messages identical to the compile-time output.

The runtime macro also has no `t` and no JSX: you define a message with `msg` and translate it with `i18n._`. Without a compile step, the source message stays in your bundle. The [reference](/ref/core#runtime-macro) covers the details.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add here an example of defining the own function t like this?

Suggested change
The runtime macro also has no `t` and no JSX: you define a message with `msg` and translate it with `i18n._`. Without a compile step, the source message stays in your bundle. The [reference](/ref/core#runtime-macro) covers the details.
The runtime macro also has no `t` out of the box: you define a message with `msg` and translate it with `i18n._`, because the source messages stays in your source code as is, and the target language is defined only in the runtime. The [reference](/ref/core#runtime-macro) covers the details.
But you still can define your own function `t` to use in the code by loading the right `messages` object in the runtime using the current application language, like this:
````js
import { i18n } from "@lingui/core";
import { messages } from "path-to-locale/en/messages.js";
i18n.load("en", messages);
i18n.activate("en");
const t = i18n._
console.log(t(msg`Hello ${{ name }}`));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked it and const t = i18n._ throws because _ isn’t bound, but const t = i18n.t works. I’d suggest leaving the post as is to keep it short. A built-in t is still an open question, so any feedback on it is welcome

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated that sentence in the 3260ef2 a bit

…post

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@andrii-bodnar

Copy link
Copy Markdown
Contributor

@MurzNN good idea, I added a link to the discussion in the blog post

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MurzNN

MurzNN commented Oct 14, 2026

Copy link
Copy Markdown

By the way, about tests - as transpile macro and native js functions should work the same, maybe we can use the same test assets (input and expected output) for both tests, to have one source of truth?

So, just place them in one folder and include from both tests, what do you think?

This branch was successfully deployed

1 active deployment
Preview — 3260ef20 Deployed Oct 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants