Skip to content

fix(router): reset cached tViews between SSR requests for correct i18n locale switching#2295

Merged
brandonroberts merged 2 commits into
betafrom
feat/i18n-ssr-tview-reset
Apr 16, 2026
Merged

fix(router): reset cached tViews between SSR requests for correct i18n locale switching#2295
brandonroberts merged 2 commits into
betafrom
feat/i18n-ssr-tview-reset

Conversation

@brandonroberts

@brandonroberts brandonroberts commented Apr 16, 2026

Copy link
Copy Markdown
Member

PR Checklist

Fixes three issues that prevented provideI18n() from switching locales correctly across SSR requests in a single Node process.

Closes #

Affected scope

  • Primary scope: router
  • Secondary scopes: platform

Recommended merge strategy for maintainer [optional]

  • Squash merge
  • Rebase merge
  • Other

What is the new behavior?

Three related problems were fixed, plus the public API was trimmed:

1. Server-context LOCALE is no longer shadowed. Previously provideI18n() unconditionally provided LOCALE at environment level using a value computed at module-load time (detectClientLocale), which returned defaultLocale on the server because typeof window === 'undefined'. That shadowed the platform-level LOCALE set by provideServerContext(), so injectLocale() always returned the default locale during SSR regardless of the URL. The provider is now registered only on the client; on the server, injection falls through to the platform-level value.

2. loadTranslationsRuntime stores parsed translations. The previous implementation set raw strings on $localize.TRANSLATIONS, which $localize.translate() cannot consume — it expects the { text, messageParts, placeholderNames } shape produced by parseTranslation(). The helper now delegates to @angular/localize's loadTranslations() via dynamic import, with a fallback for environments where the package is not installed.

3. APP_INITIALIZER built into provideI18n(). The old ENVIRONMENT_INITIALIZER returned an unawaited Promise, so translations could be in flight while components began rendering. Replaced with provideAppInitializer so bootstrap blocks on translation loading. The initializer detects locale via inject(LOCALE, { optional: true }) (which now correctly reads the server-context value) with fallbacks to REQUEST.originalUrl and window.location.pathname.

Per-request tView reset. Added a process-level registry of component defs plus a reset helper, both ɵ-prefixed. The registry is populated at module load time by a new Vite plugin in @analogjs/platform (i18nComponentRegistryPlugin) that instruments compiled component output under SSR transforms, injecting a ɵregisterI18nComponentDef(ClassName) call at module scope. The plugin uses this.parse() from the Rollup plugin context rather than regex matching and produces correctly anchored source maps via magic-string. The server renderer calls ɵresetI18nComponentDefCache() before each renderApplication() so the next render re-evaluates consts() with the locale that its APP_INITIALIZER just loaded.

Public API reduction. The i18n surface is now 4 names (provideI18n, I18nConfig, injectSwitchLocale, loadTranslationsRuntime). Framework plumbing is ɵ-prefixed following Angular's convention (ɵregisterI18nComponentDef, ɵresetI18nComponentDefCache). I18N_CONFIG and clearTranslationsRuntime are no longer exported from the package entry — they have no external consumers.

Test plan

  • Unit: packages/router/src/lib/i18n/provide-i18n.spec.ts updated for the new behavior (parsed translation shape, clearTranslationsRuntime side effects, component def registry)
  • Manual dev server: sequential requests for /fr, /de, /en against a running vite dev server each return their own translations with the correct <html lang>
  • Manual built server: node dist/analog/server/index.mjs exercised with the same sequence — every request handled correctly from a single process
  • Prerendering: 3 routes × 3 locales produces 9 HTML files, each with locale-appropriate translations and <html lang>
  • nx format:check
  • pnpm build
  • pnpm test

Does this PR introduce a breaking change?

  • Yes
  • No

The runtime i18n API is still in beta. registerI18nComponentDef and resetI18nComponentDefCache have been renamed to ɵregisterI18nComponentDef / ɵresetI18nComponentDefCache to mark them as framework plumbing, and I18N_CONFIG / clearTranslationsRuntime are no longer exported from @analogjs/router — all four were internal and unlikely to be called directly.

Other information

Why clear tView between SSR requests

Angular caches the result of a component def's consts() factory on def.tView the first time the component renders. The factory is where $localize tagged templates are evaluated, so once a tView is created under one locale, every later render in the same process reuses those strings regardless of what $localize.TRANSLATIONS currently holds. In a single-build multi-locale SSR setup, the first locale to render wins for the lifetime of the process.

Angular's canonical i18n story is build-time --localize, which avoids this by producing one bundle per locale — the cache-forever behavior is correct in that model. Runtime loadTranslations() was introduced primarily for early-bootstrap test fixtures and for whole-app locale switches where a page reload is acceptable (which is why injectSwitchLocale() does a window.location.href assignment — the reload resets the process and the tView caches along with it). The missing quadrant is "one build, shared Node process, locale per request," which is the workflow provideI18n() invites.

Nulling def.tView before each render forces getOrCreateComponentTView() to rebuild — running consts() again, picking up whichever translations are currently loaded. The reset is safe across requests in this codebase because it runs before bootstrapApplication(), by which point the previous request's LViews have been destroyed; there are no in-flight references to the old tView to invalidate. Component IDs come from def.id, not tView, so they remain stable across rebuilds — SSR → client hydration keeps working as long as the same translations are loaded on both sides (they are; the client runs the same APP_INITIALIZER).

The trade-offs worth documenting:

  • Allocation cost. Each request rebuilds tView.blueprint, tView.data, directiveRegistry, and pipeRegistry for every rendered component rather than amortizing those allocations across the process lifetime. At high QPS this is measurable GC pressure. Users serving public traffic should still prefer build-time --localize if their deployment model allows per-locale bundles.
  • firstCreatePass runs every request. Directive/pipe registry extraction, host binding setup, content query setup, node-level DI, and component feature callbacks (ɵɵNgOnChangesFeature, ɵɵHostDirectivesFeature) fire each render instead of once. The contract for these is idempotent population of tView slots, but features with setup side effects outside that contract will fire repeatedly.
  • Incremental hydration is untested. Deferred blocks (@defer hydrate on …) rely on serialized tView identity between server and client. I haven't verified that rebuilding tView keeps the ssrId and dehydrated-node layout byte-identical across requests for the same locale. Worth an E2E before relying on this combination.

Upstream Angular

The ɵregisterI18nComponentDef / ɵresetI18nComponentDefCache pair is doing work that ideally lives in Angular itself. Angular already maintains a dev-only client-side component registry (GENERATED_COMP_IDS), but it's gated off when ngServerMode === true, so we can't reuse it on the built Node server. If Angular extended that registry to server mode and exposed ɵresetAllComponentTViews(), Analog could drop the Vite plugin and the registry entirely. Filing an RFC against angular/angular is the long-term path; this PR is the pragmatic floor.

[optional] What gif best describes this PR or how it makes you feel?

…orrect i18n locale switching

Angular caches component consts() results on def.tView, which bakes in
$localize translations from the first render. This adds a component def
registry and tView reset mechanism so each SSR request re-evaluates
templates with the correct locale's translations.

- router: new registerI18nComponentDef / resetI18nComponentDefCache
  registry, called from provideI18n's app initializer and from a
  BEFORE_APP_SERIALIZED hook in the server render path
- router: switch to @angular/localize loadTranslations/clearTranslations
  for the parsed translation format $localize.translate expects
- router: resolveActiveLocale reads LOCALE / REQUEST fallbacks via
  provideAppInitializer instead of ENVIRONMENT_INITIALIZER
- platform: new i18nComponentRegistryPlugin that instruments compiled
  SSR output to register every ɵcmp definition at load time (covers
  route-loaded page components that the runtime walker misses)
- root: add @angular/localize 21.2.7 dev dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Apr 16, 2026

Copy link
Copy Markdown

Deploy Preview for analog-blog ready!

Name Link
🔨 Latest commit 795a809
🔍 Latest deploy log https://app.netlify.com/projects/analog-blog/deploys/69e0d332e93a7500083c14bc
😎 Deploy Preview https://deploy-preview-2295--analog-blog.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Apr 16, 2026

Copy link
Copy Markdown

Deploy Preview for analog-app ready!

Name Link
🔨 Latest commit 795a809
🔍 Latest deploy log https://app.netlify.com/projects/analog-app/deploys/69e0d33124b23a000867db50
😎 Deploy Preview https://deploy-preview-2295--analog-app.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Apr 16, 2026

Copy link
Copy Markdown

Deploy Preview for analog-docs ready!

Name Link
🔨 Latest commit 795a809
🔍 Latest deploy log https://app.netlify.com/projects/analog-docs/deploys/69e0d332e93a7500083c14ba
😎 Deploy Preview https://deploy-preview-2295--analog-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions github-actions Bot added scope:platform Changes in @analogjs/platform scope:repo Repository metadata and tooling scope:router Changes in @analogjs/router labels Apr 16, 2026
@github-actions

Copy link
Copy Markdown

This PR touches multiple package scopes: platform, router.

Please confirm the changes are closely related. Squash merge is highly preferred. If you recommend a non-squash merge, add a brief note explaining why the commit boundaries matter and why this PR should bypass focused changes per package.

@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds runtime i18n lifecycle and SSR component-def registry plumbing: a new Vite plugin instruments compiled Angular SSR modules to register component defs; the platform plugin conditionally includes it when i18n is enabled. provideI18n was refactored to use a provideAppInitializer that resolves the active locale, clears runtime translations before loading them asynchronously (preferring @angular/localize.loadTranslations), and conditionally provides LOCALE on the server only when window exists. New internal APIs expose component-def registry operations and a cache reset that is invoked prior to non-server-component SSR renders. @angular/localize added to dependencies.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

BREAKING CHANGE

  • I18N_CONFIG: now internal (not exported)

    • Before: export const I18N_CONFIG = new InjectionToken(...)
    • After: const I18N_CONFIG = new InjectionToken(...)
  • loadTranslationsRuntime: synchronous → asynchronous

    • Before: loadTranslationsRuntime(translations: Record<string, string>): void
    • After: loadTranslationsRuntime(translations: Record<string, string>): Promise

Review callers for synchronous assumptions and any external imports of I18N_CONFIG.

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title follows Conventional Commit style with scope(router) and clearly summarizes the main change: resetting cached tViews for i18n locale switching in SSR.
Description check ✅ Passed The PR description thoroughly explains the rationale, implementation, and trade-offs for fixing three interconnected SSR i18n locale-switching issues, with clear mapping to code changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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 and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/platform/src/lib/i18n-component-registry-plugin.ts (1)

113-145: Minor: classHasCmpDef continues walking after finding a match.

Once found = true, the walker still traverses the rest of the class body. For large classes, this is wasteful.

♻️ Optional: early-exit from walk when found

One option is to throw a sentinel value to break out of the recursion, or refactor walk to support a return value that signals "stop". For most components this is negligible, so it's a nice-to-have.

 function classHasCmpDef(classNode: AstNode): boolean {
-  let found = false;
-
-  walk(classNode, (node: any) => {
-    if (found) return;
+  const FOUND = Symbol('found');
+  try {
+    walk(classNode, (node: any) => {
       // ... detection logic ...
-      found = true;
+      throw FOUND;
+    });
+  } catch (e) {
+    if (e === FOUND) return true;
+    throw e;
+  }
+  return false;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/platform/src/lib/i18n-component-registry-plugin.ts` around lines 113
- 145, classHasCmpDef currently continues traversing after setting found = true
which wastes time for large classes; modify the walker callback to early-exit
once a match is found by signalling stop to walk: either throw a sentinel (e.g.,
a unique StopWalk Error) immediately after setting found = true or, if walk can
be modified, have the callback return a boolean/unused value that walk treats as
"stop" and implement that in walk; update the callback in classHasCmpDef (the
places that set found = true for PropertyDefinition and AssignmentExpression) to
trigger this stop mechanism so traversal halts as soon as ɵcmp is detected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/router/src/index.ts`:
- Around line 21-31: The export list removed I18N_CONFIG causing a breaking
change for consumers; update the project's release notes to include a BREAKING
CHANGE footer per CONTRIBUTING.md stating that `I18N_CONFIG` is no longer
exported from `@analogjs/router` and show the before/after usage (importing
I18N_CONFIG and inject(I18N_CONFIG) -> use `injectSwitchLocale()` instead),
referencing the affected symbol names `I18N_CONFIG` and `injectSwitchLocale()`
so users know to migrate; ensure the exact phrasing matches the provided snippet
and is included in the changelog/release notes for this release.

In `@packages/router/src/lib/i18n/provide-i18n.spec.ts`:
- Around line 10-14: The test import names use the wrong `ɵɵ` prefix; update the
imports in provide-i18n.spec.ts so they match the exported symbols from
provide-i18n.ts — replace ɵɵregisterI18nComponentDef with
ɵregisterI18nComponentDef and ɵɵresetI18nComponentDefCache with
ɵresetI18nComponentDefCache (leave getI18nComponentDefRegistrySize and
clearI18nComponentDefRegistry as-is) so the test imports exactly match the
module exports.

---

Nitpick comments:
In `@packages/platform/src/lib/i18n-component-registry-plugin.ts`:
- Around line 113-145: classHasCmpDef currently continues traversing after
setting found = true which wastes time for large classes; modify the walker
callback to early-exit once a match is found by signalling stop to walk: either
throw a sentinel (e.g., a unique StopWalk Error) immediately after setting found
= true or, if walk can be modified, have the callback return a boolean/unused
value that walk treats as "stop" and implement that in walk; update the callback
in classHasCmpDef (the places that set found = true for PropertyDefinition and
AssignmentExpression) to trigger this stop mechanism so traversal halts as soon
as ɵcmp is detected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3a3e28d5-2692-4b8b-823d-000700b8b8dd

📥 Commits

Reviewing files that changed from the base of the PR and between 8383209 and a37d63f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml and included by none
📒 Files selected for processing (7)
  • package.json
  • packages/platform/src/lib/i18n-component-registry-plugin.ts
  • packages/platform/src/lib/platform-plugin.ts
  • packages/router/server/src/render.ts
  • packages/router/src/index.ts
  • packages/router/src/lib/i18n/provide-i18n.spec.ts
  • packages/router/src/lib/i18n/provide-i18n.ts

Comment thread packages/router/src/index.ts
Comment thread packages/router/src/lib/i18n/provide-i18n.spec.ts Outdated
…sformResult

magic-string types `sourcesContent` as optional while Rolldown's
TransformResult requires `string[]`. With `includeContent: true` the
content is always populated, so cast to the expected type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@brandonroberts brandonroberts force-pushed the feat/i18n-ssr-tview-reset branch from ce2a167 to 795a809 Compare April 16, 2026 12:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/router/src/lib/i18n/provide-i18n.spec.ts`:
- Line 71: The empty mock implementation for console.warn triggers the
no-empty-function lint rule; update the spy setup (vi.spyOn(console,
'warn').mockImplementation(...)) to provide a non-empty return expression such
as returning undefined (e.g., mockImplementation(() => undefined) or () => void
0) so the mock is not an empty function while preserving behavior; ensure you
update the declaration for warnSpy accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 892e59e0-c10a-4dc7-9be4-9a96cf14565c

📥 Commits

Reviewing files that changed from the base of the PR and between ce2a167 and 795a809.

📒 Files selected for processing (2)
  • packages/platform/src/lib/i18n-component-registry-plugin.ts
  • packages/router/src/lib/i18n/provide-i18n.spec.ts

Comment thread packages/router/src/lib/i18n/provide-i18n.spec.ts
@brandonroberts brandonroberts merged commit d2ce3e5 into beta Apr 16, 2026
34 checks passed
@brandonroberts brandonroberts deleted the feat/i18n-ssr-tview-reset branch April 16, 2026 12:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope:platform Changes in @analogjs/platform scope:repo Repository metadata and tooling scope:router Changes in @analogjs/router

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant