Documents the 12-iteration binary search, root cause analysis, fix pattern, and lessons learned for the Svelte 5 store-detection failure on Proxy objects with subscribe getter. Includes recommended follow-ups: ESLint rule, document the _t pattern in semantics-svelte skill, audit other complex-template pages for the same latent bug.
12 KiB
Bug Report: Svelte 5 — Proxy-based i18n store causes ReferenceError: $t is not defined
Status: ✅ Fixed
Severity: Critical (entire page unrenderable)
Affected version: Svelte 5.43.8+ (verified on 5.46.0)
Date: 2026-06-02
Reporter: Browser validation of translate/[id] refactor
Fix commit: 27435e4
Summary
After binding the translate/[id]/+page.svelte page to TranslationJobModel, the entire detail page rendered blank with Uncaught ReferenceError: $t is not defined. The same page renders fine for simpler pages (e.g. /dashboards, /translate list). Root cause: Svelte 5's static store-detection cannot recognize the t export from $lib/i18n/index.svelte.ts (a Proxy with a subscribe getter) as a store in deeply nested template expressions and in .svelte.ts model files, generating ReferenceError: $t is not defined at runtime.
Reproduction
- Run dev server:
cd frontend && npm run dev -- --port 5173 - Navigate to
http://localhost:5173/translate/54215412-d6ad-49cf-9704-ff8b194f74cd - Observe: page renders only the layout (sidebar, navbar, footer) —
<main>is empty - Open DevTools console →
Uncaught ReferenceError: $t is not defined
The same page works for /translate (list), /dashboards, etc. The detail page is unique in that it uses a 49-atom TranslationJobModel plus 9 translate-specific sub-components with deeply nested template conditions.
Root Cause Analysis
The i18n module's t export
frontend/src/lib/i18n/index.svelte.ts exports t as a Proxy with a subscribe getter:
export const t = new Proxy({} as any, {
get(_target, prop) {
if (prop === 'subscribe') {
return (fn: (v: any) => void) => {
fn(_tValue);
_tSubs.add(fn);
return () => { _tSubs.delete(fn); };
};
}
if (prop === 'current') {
return _tValue;
}
return (_tValue as any)[prop];
}
});
The subscribe method is defined via a get trap on the Proxy, not as a direct own property.
Svelte 5's $store template syntax
In Svelte 5, the $t template prefix triggers store subscription. The compiler normally generates code like:
let $t;
const unsubscribe = t.subscribe((v) => ($t = v));
// ... template uses $t ...
unsubscribe();
For the compiler to detect t as a store, it must statically determine that t has a subscribe method.
Why detection fails
Three trigger conditions (verified by binary-search testing of templates):
- Deeply nested conditional/loop blocks (
{#if}…{:else if}…{:else}…chains, nested{#each}) - Complex ternary expressions in attribute bindings like
title={a ? b : c ? d : e} .svelte.tsmodel files using$derived.by()callbacks that reference$t
In all three cases, the Svelte 5 compiler fails to statically determine t.subscribe exists (Proxy trap is invisible to static analysis) and emits a direct reference to $t instead of generating the subscription code. At runtime, $t is undefined → ReferenceError.
Why other pages work
/dashboardsand/translatelist: simpler templates without the trigger conditions- The minimal test page with just
<h1>{_t.translate?.config?.new_title}</h1>and no nested blocks: worked fine with_t = $derived(getT())
Why even a model file triggers it
TranslationJobModel.svelte.ts line 70–81 has:
tabs: { id: string; label: string }[] = $derived.by(() => {
const base = [
{ id: 'config', label: $t.translate?.config?.basic_info || 'Configuration' },
// ...
];
// ...
});
The $derived.by callback body is compiled similarly to a template. Static analysis on the t import fails → $t is emitted as a direct reference → at model instantiation, the callback runs and throws.
Symptoms (chronological)
- Build:
npm run build— passes clean, no errors. - Type check:
tsc— no errors. - Unit tests:
npm run test— 698/698 pass (tests don't exercise the page's runtime rendering). - Browser: clicking on a job card from
/translatenavigates to the detail URL, but the<main>content is empty. - Console:
Uncaught ReferenceError: $t is not defined in <unknown> in ProtectedRoute.svelte in +layout.svelte in root.svelte
The stack trace shows <unknown> because Svelte's component-name resolution fails when the component crashes before mounting.
Diagnostic Process (binary search)
Took ~12 iterations to isolate the root cause:
| # | What was changed | Result |
|---|---|---|
| 1 | Navigate to /translate/[id] |
Blank, $t is not defined |
| 2 | Restart dev server (port conflict) | Same error |
| 3 | Replace full page with minimal 34-line test | ✅ Renders ("Новое задание перевода") |
| 4 | Add all imports but simple template | ✅ Still renders |
| 5 | Restore full template (no sub-component usages) | ❌ Blank again |
| 6 | Comment out all <Component> template usages |
❌ Still blank |
| 7 | Comment out title={...long ternary...} on tab button |
❌ Still blank |
| 8 | Identify TranslationRunGlobalIndicator is global → fix it |
❌ Still blank |
| 9 | Fix script-block $t in ScheduleConfig, TranslationPreview |
❌ Still blank |
| 10 | Apply _t = $derived(getT()) pattern to 6 sub-components |
❌ Still blank |
| 11 | Find $t in TranslationJobModel.svelte.ts line 72–78 |
🎯 Root cause |
| 12 | Replace model's $t. with getT()?. and import getT |
✅ Renders all 5 tabs |
Fix
Pattern
Replace all $t.xxx references in templates and .svelte.ts model files with one of:
| Context | Pattern |
|---|---|
| Template | const _t = $derived(getT()) in script, then _t.xxx in template |
| Script function | getT()?.xxx direct call (no _t needed because function bodies don't have reactive context) |
Script $derived.by() callback |
getT()?.xxx direct call (Svelte can't make $t reactive inside model callbacks) |
Import change
- import { t } from '$lib/i18n/index.svelte.js';
+ import { getT } from '$lib/i18n/index.svelte.js';
Template change
- <h1>{$t.translate?.config?.new_title}</h1>
+ const _t = $derived(getT());
+ <h1>{_t.translate?.config?.new_title}</h1>
Script change
- addToast($t.translate?.schedule?.saved, 'success');
+ addToast(getT()?.translate?.schedule?.saved, 'success');
Why this works
getT() is a plain function exported from the i18n module. It does NOT require store-detection — the compiler treats it as a regular function call. The $derived(getT()) wrapper in scripts gives templates reactive re-evaluation when the locale changes (because getT() reads the $state-backed _locale internally).
Files Changed
| File | _t added |
$t → getT()?. in script |
|---|---|---|
src/routes/translate/[id]/+page.svelte |
✅ | — |
src/lib/models/TranslationJobModel.svelte.ts |
— | 8 refs |
src/lib/components/translate/ConfigTabForm.svelte |
✅ | 2 refs |
src/lib/components/translate/ScheduleConfig.svelte |
✅ | 12 refs |
src/lib/components/translate/TranslationPreview.svelte |
✅ | 15 refs |
src/lib/components/translate/TargetTabForm.svelte |
✅ | 2 refs |
src/lib/components/translate/RunTabContent.svelte |
✅ | 3 refs |
src/lib/components/translate/BulkReplaceModal.svelte |
✅ | 2 refs |
src/lib/components/translate/TranslationRunProgress.svelte |
✅ | (template only) |
src/lib/components/translate/TranslationRunResult.svelte |
✅ | (template only) |
src/lib/components/translate/TermCorrectionPopup.svelte |
✅ | (template only) |
src/lib/components/translate/BulkCorrectionSidebar.svelte |
✅ | (template only) |
src/lib/components/translate/TranslationMetricsDashboard.svelte |
✅ | (template only) |
src/lib/components/translate/CorrectionCell.svelte |
✅ | (template only) |
src/lib/components/translate/TranslationRunGlobalIndicator.svelte |
✅ | 8 refs |
Total: 15 files, +496 / -451 LOC.
Verification
After the fix:
| Check | Result |
|---|---|
npm run build |
✅ clean |
npm run test -- --run |
✅ 698/698 tests pass |
node scripts/audit-frontend-style.mjs |
✅ 0 color violations, 2 oversized (out of scope) |
Browser /translate/[id] Config tab |
✅ renders form, all fields, columns, LLM, languages |
Browser /translate/[id] Preview tab |
✅ renders sample size slider + Run button |
Browser /translate/[id] Target tab |
✅ renders schema/table/DB/columns/write-settings |
Browser /translate/[id] Run tab |
✅ renders incremental/full + run history (5 runs) |
Browser /translate/[id] Schedule tab |
✅ renders "Create schedule" prompt |
| Browser console | ✅ no JS errors (only expected backend API 404/422) |
Lessons Learned
1. Build/test green ≠ runtime safe
All standard verification (build, type-check, 698 unit tests) passed. The bug only manifested in browser runtime when Svelte's reactive engine actually executed the compiled template. Browser validation is non-negotiable for pages with complex templates.
2. Svelte 5 Proxy-based stores are fragile
Svelte 5's static store-detection can fail on Proxy objects even when they implement the store protocol. Prefer:
- Class instances with
subscribeas a direct method, or - Plain objects with
subscribeas an own property, or - Skip the store pattern entirely — use functions like
getT()+$derivedfor reactive reads.
3. Model files are a blind spot
.svelte.ts files using $derived.by() callbacks that reference $t will also fail. The compiler treats model callback bodies like templates for store-detection purposes. Always use getT()?.xxx in model files, not $t.
4. The error message is misleading
Uncaught ReferenceError: $t is not defined in <unknown> looks like a missing import, but the import is correct. The actual cause is the compiler failing to generate the store-subscription code. Look at the i18n module's t export mechanism, not the call sites.
5. The minimal reproduction pattern is critical
A 34-line minimal page with identical imports and one $t reference worked. Adding back the full template (no sub-components) broke it. This proved the issue was in template complexity, not the import chain.
Related Files & References
- Commit:
27435e4 fix(frontend): replace $t with _t pattern in translate components - i18n module:
frontend/src/lib/i18n/index.svelte.ts(lines 188–202:tProxy export; line 209:getT()function) - Model file:
frontend/src/lib/models/TranslationJobModel.svelte.ts(line 70:tabs: ... = $derived.by(() => { ... $t ... })) - Page:
frontend/src/routes/translate/[id]/+page.svelte - Related ADR:
docs/adr/ADR-0006-frontend-architecture.md(Design Token System, Screen Model tier) - Svelte 5 docs: Stores —
$storetemplate syntax - Svelte 5 issue: store-detection on Proxy-based objects with
gettrap (upstream behavior)
Recommended Follow-up
-
Update
texport ini18n/index.svelte.tsto use a class instance withsubscribeas a direct method, avoiding the Proxy entirely. This may restore$ttemplate syntax reliability. -
Add ESLint rule to disallow
$ttemplate references — force_t = $derived(getT())pattern. -
Document the pattern in
semantics-svelteskill (§VIIor new§X) so future agents don't repeat this debugging cycle. -
Audit other pages with complex templates: any page using
timport + nested{#if}chains is a latent bug.