perf(i18n): split en.json shell + lazy rest chunk (#3535)#3563
Conversation
Static-importing the full 131KB English bundle put all of it in the
main JS chunk on every page-load — including ~84KB of per-panel keys
that the user may never trigger by opening that panel.
Splits en.json into:
- en.shell.json (~25KB raw / ~5KB gzipped): header, panel chrome,
common toasts, command palette, premium gates, region picker.
Statically imported, inlined into the i18n chunk.
- en.rest.json (~84KB raw / ~30KB gzipped): per-panel content,
modals, popups, signals, country brief. Dynamic-imported — Vite
emits it as a separate locale-en-rest chunk (matching the
existing /assets/locale-*.js/ SW caching rule).
initI18n() awaits both before resolving, so every t() call sees the
full merged dictionary by the time any panel renders. Source of truth
stays at en.json; build:i18n-shell regenerates the two derived files
on every prebuild and a parity test (tests/i18n-shell-split.test.mts)
blocks drift.
Measured chunk sizes after build (VITE_VARIANT=full):
i18n chunk: 52KB (was ~183KB with full en.json inlined)
locale-en-rest: 83KB (new, lazy)
Closes #3535
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummarySplits the 131 KB
Confidence Score: 3/5Hold for the shallow-merge fix in One P1 bug (shallow merge in
Important Files Changed
Sequence DiagramsequenceDiagram
participant App
participant i18n as i18n.ts (initI18n)
participant Shell as en.shell.json (eager, main chunk)
participant Rest as en.rest.json (lazy chunk)
participant i18next
App->>i18n: initI18n()
i18n->>Shell: static import (already in bundle)
i18n->>i18next: init({ resources: { en: enShell } })
Note over i18next: Chrome renders with shell keys
i18n->>Rest: dynamic import('../locales/en.rest.json')
Rest-->>i18n: rest dict
i18n->>i18n: loadEnFull() → shallow { ...enShell, ...rest }<br/>⚠ drops components.panel/deckgl/map
i18n->>i18next: addResourceBundle('en', fullEn, deep=true, overwrite=true)
Note over i18next: deep=true rescues missing keys<br/>from prior shell registration
i18n->>i18next: ensureLanguageLoaded(detectedLang)
i18next-->>App: initI18n() resolves — full dict available
|
| enFullPromise = import('../locales/en.rest.json').then((mod) => { | ||
| const rest = (mod.default ?? mod) as TranslationDictionary; | ||
| return { ...(enShell as TranslationDictionary), ...rest }; | ||
| }); |
There was a problem hiding this comment.
Shallow spread drops
components.panel/deckgl/map from loadEnFull()
{ ...enShell, ...rest } is a shallow merge. Because components is a top-level key in both en.shell.json (containing panel, deckgl, map) and en.rest.json (containing all other sub-panels), rest.components silently clobbers enShell.components, making loadEnFull() return a dict that is missing the three carved-out subkeys.
At initI18n() this is rescued by i18next's addResourceBundle(..., true, true) deep-merge path (the shell was already registered first), but the cached enFullPromise itself is incorrect. Any future caller that reads the resolved dict directly — or any re-init path that calls addResourceBundle with a fresh i18next instance — would lose components.panel.*, components.deckgl.*, and components.map.*.
| enFullPromise = import('../locales/en.rest.json').then((mod) => { | |
| const rest = (mod.default ?? mod) as TranslationDictionary; | |
| return { ...(enShell as TranslationDictionary), ...rest }; | |
| }); | |
| enFullPromise = import('../locales/en.rest.json').then((mod) => { | |
| const rest = (mod.default ?? mod) as TranslationDictionary; | |
| // Shallow spread is insufficient: `components` exists in both shell and | |
| // rest, so rest.components would clobber shell.components (panel/deckgl/map). | |
| // Merge the two components sub-objects explicitly. | |
| const shellDict = enShell as TranslationDictionary; | |
| const merged: TranslationDictionary = { ...shellDict, ...rest }; | |
| if (shellDict.components && rest.components) { | |
| merged.components = { | |
| ...(shellDict.components as Record<string, unknown>), | |
| ...(rest.components as Record<string, unknown>), | |
| }; | |
| } | |
| return merged; | |
| }); |
There was a problem hiding this comment.
Fixed in f3d871d. Confirmed the bug is real — both files have a top-level components key (shell: panel/deckgl/map; rest: everything else), so shallow spread silently dropped the three shell carve-outs from the cached enFullPromise. Production was being rescued by i18next's deep-merge at init order (shell-then-rest), but the cached value itself was wrong — any fresh-i18next path or direct caller would lose the keys. Added explicit components-level merge + regression test in tests/i18n-shell-split.test.mts that asserts shallow spread WOULD drop the keys, so future "simplify" PRs fail loudly.
The previous chunk name locale-en-rest matched the SW's globIgnores '**/locale-*.js' rule, which excluded it from the precache. But initI18n() awaits the rest chunk before any panel renders, so an offline boot (PWA installed, network drops before the chunk is runtime-cached) would hang on the dynamic import. Before the split, those strings shipped inside the precached i18n-*.js chunk, so offline-after-install worked. Renaming the new chunk to i18n-en-rest keeps it inside the default **/*.js precache glob and out of the locale-only exclusion, restoring offline parity. The user-selected per-language chunks stay as locale-<lang> — those legitimately don't need to be precached and are runtime-cached by the existing /assets/locale-.*\.js/ CacheFirst rule. Confirmed via build: dist/sw.js precache manifest now includes assets/i18n-en-rest-*.js (108 entries, up from 107) while the per-language locale chunks remain runtime-only.
|
Fixed in 60f217e — thanks for catching this. Root cause: chunk name Fix: renamed the chunk to Verified: |
…401 crashes (#3566) * fix(seeders): warm-pings send X-WorldMonitor-Key — stop relying on Origin-trust alone Sweep companion to PR #3563 (c9cb13c — ais-relay) for the four remaining seed scripts that POST to api.worldmonitor.app with `Origin: https://worldmonitor.app` as their only auth credential. On 2026-05-02 all three seed-infra warm-pings started returning HTTP 401 simultaneously; process.exit(ok > 0 ? 0 : 1) made it look like a Railway container crash. Root cause is identical to ais-relay's: Origin-trust via `api/_api-key.js` BROWSER_ORIGIN_PATTERNS is fragile because CF/Vercel intermediaries can strip the Origin header (S2S calls, WAF rules, cache hits) and CF can cache the resulting 401 for the full s-maxage, poisoning a POP for ~30 min. Local curl reproduction with identical headers returned `HTTP/2 401` + `cache-control: public, max-age=1800` — the smoking gun for per-POP cache poisoning. Defense-in-depth: send X-WorldMonitor-Key in addition to Origin. The gateway already accepts it via validateApiKey's explicit-key branch (api/_api-key.js:62-66). When the env var is unset the script falls through to the existing Origin-only path — preserves backward compatibility for local dev and for Railway services before the env var is provisioned. Failure-log lines explicitly note when the key is missing so future incidents are debuggable in 30 seconds. Affected scripts: - scripts/seed-infra.mjs (was crashing — exits 1 on all-401) - scripts/seed-military-maritime-news.mjs (same warm-ping shape; would crash next CDN-poisoning event) - scripts/seed-service-statuses.mjs (degrades to TTL-extension; caches drift silently) - scripts/seed-insights.mjs (warmDigestCache was sending NO Origin at all → always 401, silent LKG fallback) REQUIRED OPS STEP after merge (per Railway service): 1. Pick a value from Vercel project env WORLDMONITOR_VALID_KEYS. 2. Set WORLDMONITOR_RELAY_KEY=<that-value> on the Railway service. 3. Redeploy. The PR ships only the code change. Without the env var the scripts continue current Origin-only behaviour (no regression). With it, auth survives any future Origin-stripping intermediary. Sweep recipe to catch this class of bug going forward: grep -ln "api.worldmonitor.app" scripts/*.{mjs,cjs} \ | xargs grep -L "X-WorldMonitor-Key" * fix(seed-insights): hoist RELAY_API_KEY to module scope to match siblings Round-2 review on PR #3566: the three other scripts in this PR (seed-infra.mjs, seed-military-maritime-news.mjs, seed-service-statuses.mjs) all declare `const RELAY_API_KEY = process.env.WORLDMONITOR_RELAY_KEY || ''` at module scope. seed-insights.mjs read the env var inside warmDigestCache() on every call. Hoist for consistency so future copies of this pattern inherit the right shape.
… footgun Greptile P1 on #3563: en.shell.json and en.rest.json both have a top-level `components` key (shell holds panel/deckgl/map; rest holds everything else). The previous `{ ...shell, ...rest }` shallow spread silently clobbered shell.components, dropping panel/deckgl/map from the cached loadEnFull() result. Production was rescued by i18next's deep-merge `addResourceBundle(..., true, true)` because the shell was registered FIRST at init — but the cached promise's value was wrong, and any future fresh-i18next instance or direct caller would lose the carved-out keys. - Explicit components-level merge in loadEnFull(). - Regression test in tests/i18n-shell-split.test.mts: pins the trap so a future "simplify back to shallow spread" PR will fail loudly.
Greptile residual-risk note on #3563: every web-build entrypoint already runs `build:i18n-shell` (via `prebuild` for plain `npm run build`, and inline in build:full/tech/finance/happy/commodity), but `build:desktop` did not. Since `src-tauri/tauri.conf.json:beforeBuildCommand` invokes `npm run build:desktop` for every Tauri target (desktop:build:full/ tech/finance + tauri dev), an `en.json` edit followed by a desktop build would ship stale en.shell.json / en.rest.json against the new en.json — a future bundle-drift trap. One edit on `build:desktop` covers every desktop entrypoint because all of them route through this script via Tauri's beforeBuildCommand chain.
|
Both Greptile findings addressed:
|
…401 crashes (koala73#3566) * fix(seeders): warm-pings send X-WorldMonitor-Key — stop relying on Origin-trust alone Sweep companion to PR koala73#3563 (c9cb13c — ais-relay) for the four remaining seed scripts that POST to api.worldmonitor.app with `Origin: https://worldmonitor.app` as their only auth credential. On 2026-05-02 all three seed-infra warm-pings started returning HTTP 401 simultaneously; process.exit(ok > 0 ? 0 : 1) made it look like a Railway container crash. Root cause is identical to ais-relay's: Origin-trust via `api/_api-key.js` BROWSER_ORIGIN_PATTERNS is fragile because CF/Vercel intermediaries can strip the Origin header (S2S calls, WAF rules, cache hits) and CF can cache the resulting 401 for the full s-maxage, poisoning a POP for ~30 min. Local curl reproduction with identical headers returned `HTTP/2 401` + `cache-control: public, max-age=1800` — the smoking gun for per-POP cache poisoning. Defense-in-depth: send X-WorldMonitor-Key in addition to Origin. The gateway already accepts it via validateApiKey's explicit-key branch (api/_api-key.js:62-66). When the env var is unset the script falls through to the existing Origin-only path — preserves backward compatibility for local dev and for Railway services before the env var is provisioned. Failure-log lines explicitly note when the key is missing so future incidents are debuggable in 30 seconds. Affected scripts: - scripts/seed-infra.mjs (was crashing — exits 1 on all-401) - scripts/seed-military-maritime-news.mjs (same warm-ping shape; would crash next CDN-poisoning event) - scripts/seed-service-statuses.mjs (degrades to TTL-extension; caches drift silently) - scripts/seed-insights.mjs (warmDigestCache was sending NO Origin at all → always 401, silent LKG fallback) REQUIRED OPS STEP after merge (per Railway service): 1. Pick a value from Vercel project env WORLDMONITOR_VALID_KEYS. 2. Set WORLDMONITOR_RELAY_KEY=<that-value> on the Railway service. 3. Redeploy. The PR ships only the code change. Without the env var the scripts continue current Origin-only behaviour (no regression). With it, auth survives any future Origin-stripping intermediary. Sweep recipe to catch this class of bug going forward: grep -ln "api.worldmonitor.app" scripts/*.{mjs,cjs} \ | xargs grep -L "X-WorldMonitor-Key" * fix(seed-insights): hoist RELAY_API_KEY to module scope to match siblings Round-2 review on PR koala73#3566: the three other scripts in this PR (seed-infra.mjs, seed-military-maritime-news.mjs, seed-service-statuses.mjs) all declare `const RELAY_API_KEY = process.env.WORLDMONITOR_RELAY_KEY || ''` at module scope. seed-insights.mjs read the env var inside warmDigestCache() on every call. Hoist for consistency so future copies of this pattern inherit the right shape.
Closes #3535.
Summary
src/locales/en.json(131KB raw / ~30-40KB gzipped) into a small shell subset that stays in the main JS chunk and a larger rest chunk that loads as a separate Vite chunk duringinitI18n().en.json— the two derived files are regenerated byscripts/build-i18n-shell.mjs(wired into everyprebuild/build:variantscript). A parity test intests/i18n-shell-split.test.mtsblocks drift if a contributor editsen.jsonwithout re-running the script.initI18n()boots i18next with the shell so chrome paints immediately, then awaits the rest before resolving so everyt()call sees the full merged dictionary by the time any panel renders. No "rawt('key.path')flash" tradeoff to worry about.Partition
Shell (eager, ~25KB raw / ~5KB gzipped):
app,auth,common,connectivity,contextMenu,header,panels,widgets,premium,preferences,alerts,commandscomponents:panel(Panel.ts chrome),deckgl(region picker labels),map(header show/hide toggle)Rest (lazy, ~84KB raw / ~30KB gzipped):
components.*(per-panel content), pluspopups,modals,signals,countryBrief,intel,mcpMeasured chunk sizes
VITE_VARIANT=full npm run build:i18n-*.js(i18next core + en payload)locale-en-rest-*.js(new, lazy)The lazy chunk fetches in parallel with everything else after
initI18n()triggers it; the main JS chunk drops by ~130KB raw, which is what #3535 was after.Test plan
npm run build:i18n-shellregeneratesen.shell.json+en.rest.jsonfromen.jsondeterministically.npx tsx --test tests/i18n-shell-split.test.mts— 7/7 pass (round-trip parity, partition disjointness, byte cap, leaf-key reachability).npx tsx --test tests/*.test.{mjs,mts}— 7836 tests pass.npx tsc --noEmitclean.VITE_VARIANT=full vite buildsucceeds and emitslocale-en-rest-*.jsas a distinct chunk that matches the existing SW/assets/locale-*.js/CacheFirst rule.