[Perf/i18n] Split Transloco into scoped, lazily-loaded translation files #303

Closed
opened 2026-07-01 15:28:21 +00:00 by spikerj · 1 comment
Owner

Problem

UI strings ship as one monolithic Transloco file per language, HTTP-fetched at runtime:

  • projects/spikersoft/src/assets/i18n/en.json568,871 bytes
  • projects/spikersoft/src/assets/i18n/es.json603,890 bytes

Loaded via TranslocoHttpLoader (assets/i18n/${lang}.json). Every visitor downloads the entire ~600 KB blob for their language up front, and each release invalidates the hash so it re-downloads. The file only grows as features/languages are added — this is the frontend's clearest O(strings × languages) payload.

Current state (measured)

  • Transloco @jsverse/transloco 8.4.0 (supports scopes / lazy scoped translations natively).
  • Global config in projects/spikersoft/src/main.ts: single loader, availableLangs, reRenderOnLangChange: true, provideTranslocoMissingHandler(LoggingMissingHandler).
  • Routes are already lazy (loadComponent) in projects/spikersoft/src/routes.ts — but translations are not split to match.
  • Service worker caches /assets/** lazily, so repeat visits are cheap; the cost is first-load + per-release re-fetch of the whole blob.

Proposal

Adopt Transloco scopes (provideTranslocoScope) so each lazy feature loads only its own translation chunk, aligning i18n payload with the existing route code-splitting:

  1. Keep a small global/common file (nav, shared buttons, errors) loaded at startup.
  2. Extract per-feature scopes (e.g. playground, profile, blog, games, contact, …) into assets/i18n/<scope>/<lang>.json, provided at the lazy route/component level.
  3. Migrate keys incrementally — a scope can be carved out one feature at a time while the global file continues to serve everything not yet migrated.

Why this is non-breaking

  • Scoped translations are additive; unmigrated keys keep resolving from the global file, so the app never regresses mid-migration.
  • The existing LoggingMissingHandler + the test that asserts every key exists in every language give an immediate signal if a key is dropped during extraction.
  • No change to how components call transloco.translate / the transloco pipe for global keys; scoped keys just gain a scope prefix.

Acceptance criteria

  • Common/global i18n file reduced to shared strings only; measured initial i18n payload materially smaller than the current ~600 KB.
  • At least the playground scope (largest surface) split out and loaded with its lazy route.
  • Missing-key test updated to cover per-scope files; CI green.
  • No visible regression in language switching (reRenderOnLangChange still works across scopes).
  • Docs note the scope convention so new features add scoped keys by default.

Priority

High among the frontend-scaling items — biggest, safest payoff. Do before the i18n files cross ~1 MB.

(Filed from an architecture review of content/translation delivery. Local-hosting only — no CDN.)

## Problem UI strings ship as **one monolithic Transloco file per language**, HTTP-fetched at runtime: - `projects/spikersoft/src/assets/i18n/en.json` — **568,871 bytes** - `projects/spikersoft/src/assets/i18n/es.json` — **603,890 bytes** Loaded via `TranslocoHttpLoader` (`assets/i18n/${lang}.json`). Every visitor downloads the entire ~600 KB blob for their language up front, and each release invalidates the hash so it re-downloads. The file only grows as features/languages are added — this is the frontend's clearest O(strings × languages) payload. ## Current state (measured) - Transloco `@jsverse/transloco` **8.4.0** (supports scopes / lazy scoped translations natively). - Global config in `projects/spikersoft/src/main.ts`: single loader, `availableLangs`, `reRenderOnLangChange: true`, `provideTranslocoMissingHandler(LoggingMissingHandler)`. - Routes are already lazy (`loadComponent`) in `projects/spikersoft/src/routes.ts` — but translations are not split to match. - Service worker caches `/assets/**` lazily, so repeat visits are cheap; the cost is first-load + per-release re-fetch of the whole blob. ## Proposal Adopt Transloco **scopes** (`provideTranslocoScope`) so each lazy feature loads only its own translation chunk, aligning i18n payload with the existing route code-splitting: 1. Keep a small **global/common** file (nav, shared buttons, errors) loaded at startup. 2. Extract per-feature scopes (e.g. `playground`, `profile`, `blog`, `games`, `contact`, …) into `assets/i18n/<scope>/<lang>.json`, provided at the lazy route/component level. 3. Migrate keys **incrementally** — a scope can be carved out one feature at a time while the global file continues to serve everything not yet migrated. ## Why this is non-breaking - Scoped translations are additive; unmigrated keys keep resolving from the global file, so the app never regresses mid-migration. - The existing `LoggingMissingHandler` + the test that asserts every key exists in every language give an immediate signal if a key is dropped during extraction. - No change to how components call `transloco.translate` / the `transloco` pipe for global keys; scoped keys just gain a scope prefix. ## Acceptance criteria - [ ] Common/global i18n file reduced to shared strings only; measured initial i18n payload materially smaller than the current ~600 KB. - [ ] At least the `playground` scope (largest surface) split out and loaded with its lazy route. - [ ] Missing-key test updated to cover per-scope files; CI green. - [ ] No visible regression in language switching (`reRenderOnLangChange` still works across scopes). - [ ] Docs note the scope convention so new features add scoped keys by default. ## Priority High among the frontend-scaling items — biggest, safest payoff. Do before the i18n files cross ~1 MB. _(Filed from an architecture review of content/translation delivery. Local-hosting only — no CDN.)_
spikerj added the enhancement label 2026-07-01 15:28:21 +00:00
Author
Owner

Done — closing after a full verification pass against the acceptance criteria.

Delivered (spikersoft-angular PRs #88, #89, #218):

  • Global bundle materially smaller: en.json 568,871 → 390,160 bytes (−31%), es.json 603,890 → 424,833 (−30%). Scopes carved out: devTools (172/202 KB) + keycloakAdmin (35/39 KB) — together ~34% of the old monolith.
  • Largest surface split with its lazy route: devTools (all dev-tools playgrounds) provided via provideTranslocoScope("devTools") on /tools (inherited by every tool child) and /clue-for-sql; keycloakAdmin on /keycloak-admin.
  • Missing-key testing covers per-scope files: testing/transloco-test-setup.ts merges scoped files back under their namespaces, and PR #218 adds i18n-key-parity.spec.ts — CI-enforced en↔es key parity for the global bundle and every scope, with a documented allowlist for the Spanish-overlay namespaces (employment.*, gamesClueForSql.mysteries.*) whose English source is backend seed data / in-code catalogs. It also swept the 6 genuinely stale es-only keys it flagged. CI green (test-and-lint, build, Sonar, e2e-smoke, e2e-anonymous).
  • No language-switch regression across scopes: e2e/playwright/anonymous-locale-toggle.spec.ts toggles en→es→en on the C# playground, which lives on a devTools-scoped route.
  • Docs: assets/i18n/README.md has a full "global bundle vs. lazily-loaded scopes" section — decision rubric, new-scope checklist (incl. registering scopes in the parity spec + test harness), and the explicit rationale for why games/admin/profile deliberately stay global.

Remaining namespaces in the global bundle are a documented, deliberate stopping point (cross-route sprawl / eager-consumer reuse) — see the README before re-litigating.

Done — closing after a full verification pass against the acceptance criteria. **Delivered** (spikersoft-angular PRs #88, #89, #218): - [x] **Global bundle materially smaller**: `en.json` 568,871 → 390,160 bytes (−31%), `es.json` 603,890 → 424,833 (−30%). Scopes carved out: `devTools` (172/202 KB) + `keycloakAdmin` (35/39 KB) — together ~34% of the old monolith. - [x] **Largest surface split with its lazy route**: `devTools` (all dev-tools playgrounds) provided via `provideTranslocoScope("devTools")` on `/tools` (inherited by every tool child) and `/clue-for-sql`; `keycloakAdmin` on `/keycloak-admin`. - [x] **Missing-key testing covers per-scope files**: `testing/transloco-test-setup.ts` merges scoped files back under their namespaces, and PR #218 adds `i18n-key-parity.spec.ts` — CI-enforced en↔es key parity for the global bundle and every scope, with a documented allowlist for the Spanish-overlay namespaces (`employment.*`, `gamesClueForSql.mysteries.*`) whose English source is backend seed data / in-code catalogs. It also swept the 6 genuinely stale es-only keys it flagged. CI green (test-and-lint, build, Sonar, e2e-smoke, e2e-anonymous). - [x] **No language-switch regression across scopes**: `e2e/playwright/anonymous-locale-toggle.spec.ts` toggles en→es→en on the C# playground, which lives on a `devTools`-scoped route. - [x] **Docs**: `assets/i18n/README.md` has a full "global bundle vs. lazily-loaded scopes" section — decision rubric, new-scope checklist (incl. registering scopes in the parity spec + test harness), and the explicit rationale for why `games`/`admin`/`profile` deliberately stay global. Remaining namespaces in the global bundle are a documented, deliberate stopping point (cross-route sprawl / eager-consumer reuse) — see the README before re-litigating.
Sign in to join this conversation.