fix(i18n): scope the active locale to the SSR request store instead o… - #60
Merged
Conversation
…f a process global
…tStore from the public barrel
…operties, and detect declared exports exactly
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The active i18n locale lived in a process-global signal:
That is exactly right in a browser — one page, one active locale, shared across duplicated bundle copies so
setLocale()in one reachest()in another — and exactly wrong on a server, where the locale is per-visitor and therefore per-request. Two overlapping renders overwrote each other:The framework already carries an
AsyncLocalStorage-backed per-request store (core/ssr-context.ts). i18n simply was not participating in it.Ownership
getRequestStore()returns the request's store ornull— deliberately not the process-global fallback, because silently writing request state there is the bleed this exists to prevent. It is also distinct fromisSSR():enableSSR()flips a flag on whatever store is current, whereas a request scope is exactly whatrunInSSRContextestablishes.The store holds a plain string, not a signal. Reactive switching is a client concern; a server render reads once and never re-renders, so giving every request its own signal would allocate subscriber machinery nothing will use.
Dictionaries stay global because they are static data read identically by every request — copying them per request would duplicate every message and force each request to re-register before it could translate anything.
registerTranslations()merges, so it never drops earlier messages, and concurrent registration of different locales writes different keys.A request that never calls
setLocale()follows the application default. That preserves the established"en"behaviour while still honouring an app that sets a different default at startup, and an SSR request never writes that default — it cannot change what a concurrent request, or the client, renders.Two documentation corrections
TODO.md §Creference (that file does not exist) and replaced the stale "this state is still process-global" note with the real ownership model.patterns/optimistic.tsstill described each row as storing "the id of the operation that last claimed its value". That was superseded by the confirmed base + ordered claim chain. Comment-only — zero non-comment lines changed, verified mechanically.Related Issue
Closes #
Type of Change
Not breaking: outside a request scope every public function behaves exactly as before. Inside one,
setLocale()stops mutating process-wide state — which is the defect.Checklist
43 new tests, of which 31 fail against the pre-fix head. Ordering is forced with explicit deferred barriers, never sleeps, and every assertion goes through the public API — asserting store fields would pass even if
t()still read the global. One test pins the exact interleaving trace (A:start → B:start → B:render=Hola → A:resume → A:render=Hello) so it cannot pass by accidental serialization.Coverage: basic isolation (5), failure/cleanup (6), nested ownership (4), public API (9), registry ownership (5), retention (2), non-request scopes (3), interleaving proof (1), duplicate module copies (3), no-
AsyncLocalStoragefallback (5).Two findings came out of the adversarial loop, both self-inflicted and both caught by a guard test rather than by luck — worth reviewer attention:
process.getBuiltinModulewas insufficient — the CommonJSrequirebranch still loadednode:async_hooks, so five "fallback" tests were silently re-testing the ALS path. The block now pre-seeds the shared registry withals: null, and the guard asserts the documented warning actually fires.runInSSRContextis synchronous, so on the fallback path an async scope ends at its firstawait. That is pre-existing behaviour for the SSR flag and suspense counter; the locale now follows the same rule, and the test pins what actually happens.Also worth noting: the pre-existing
Transtest asserted only the tag name, so client reactivity was never actually proven. It is now.Unchanged limitation, now documented explicitly: on runtimes without
AsyncLocalStorage(browsers, some edge runtimes, Node < 22.3 under ESM)runInSSRContextsaves and restores one shared store — correct for a fully synchronous render, with anasynccallback's scope ending at its firstawait. The locale behaves exactly as the SSR flag and suspense counter do there, and the existing one-time Node warning still fires.No version bump, no CI changes, and no router / SSR-render / hydration / reactive-core / security / WASM / service-worker / chunk-loader / wake-lock / view-transition code touched.