Skip to content

fix(i18n): scope the active locale to the SSR request store instead o… - #60

Merged
hexplus merged 3 commits into
mainfrom
chore/60-framework-hardening
Aug 28, 2026
Merged

fix(i18n): scope the active locale to the SSR request store instead o…#60
hexplus merged 3 commits into
mainfrom
chore/60-framework-hardening

Conversation

@hexplus

@hexplus hexplus commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Description

The active i18n locale lived in a process-global signal:

const _i18n = globalSingleton(Symbol.for("sibujs.i18n.v1"), () => ({
  locale: signal("en"),
  locales: {} as LocaleMap,
}));

That is exactly right in a browser — one page, one active locale, shared across duplicated bundle copies so setLocale() in one reaches t() in another — and exactly wrong on a server, where the locale is per-visitor and therefore per-request. Two overlapping renders overwrote each other:

A enters its request context, sets "en", awaits
B enters its own context, sets "es", renders "Hola", finishes
A resumes and renders  →  Spanish

The framework already carries an AsyncLocalStorage-backed per-request store (core/ssr-context.ts). i18n simply was not participating in it.

Ownership

translation dictionaries → APPLICATION-GLOBAL, always
active locale            → the CURRENT REQUEST inside an SSR context
                         → the client otherwise

getRequestStore() returns the request's store or null — deliberately not the process-global fallback, because silently writing request state there is the bleed this exists to prevent. It is also distinct from isSSR(): enableSSR() flips a flag on whatever store is current, whereas a request scope is exactly what runInSSRContext establishes.

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

  • Removed the TODO.md §C reference (that file does not exist) and replaced the stale "this state is still process-global" note with the real ownership model.
  • patterns/optimistic.ts still 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

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

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

  • I have read CONTRIBUTING.md
  • My code builds without errors
  • I have tested my changes
  • I have updated documentation if needed
build · typecheck · typecheck:tests · lint     PASS   (655 files, 0 errors)
vitest run          444 files / 6197 passed / 1 skipped / 0 failed
test:soak           25 passed / 1 skipped
certify:rc          14 PASS / 0 FAIL
Browser matrix      Chromium 103 · Firefox 103 · WebKit 103 = 309
Node 22.3.0 · 22.14.0 · 24.19.0                PASS  6195 each
Packed package + subpaths  112/112, 16 subpaths (ESM + CJS)
Bundlers            Vite / Rollup / esbuild / Webpack — 12/12 builds, 12/12 runtime

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-AsyncLocalStorage fallback (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:

  • My fallback tests were not testing the fallback. Stubbing process.getBuiltinModule was insufficient — the CommonJS require branch still loaded node:async_hooks, so five "fallback" tests were silently re-testing the ALS path. The block now pre-seeds the shared registry with als: null, and the guard asserts the documented warning actually fires.
  • My assumption about fallback bleed was wrong. I expected two interleaved async scopes to share state; in fact runInSSRContext is synchronous, so on the fallback path an async scope ends at its first await. 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 Trans test 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) runInSSRContext saves and restores one shared store — correct for a fully synchronous render, with an async callback's scope ending at its first await. 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.

@hexplus
hexplus merged commit cc54c28 into main Aug 28, 2026
5 checks passed
@hexplus
hexplus deleted the chore/60-framework-hardening branch August 28, 2026 22:14
@hexplus hexplus mentioned this pull request Aug 28, 2026
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.

1 participant