docs: add bug report for Svelte 5 Proxy-based i18n store $t undefined
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.
This commit is contained in:
263
docs/bug-reports/2026-06-02-svelte5-proxy-store-t-undefined.md
Normal file
263
docs/bug-reports/2026-06-02-svelte5-proxy-store-t-undefined.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# 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
|
||||
|
||||
1. Run dev server: `cd frontend && npm run dev -- --port 5173`
|
||||
2. Navigate to `http://localhost:5173/translate/54215412-d6ad-49cf-9704-ff8b194f74cd`
|
||||
3. Observe: page renders only the layout (sidebar, navbar, footer) — `<main>` is empty
|
||||
4. 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:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```js
|
||||
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):
|
||||
|
||||
1. **Deeply nested conditional/loop blocks** (`{#if}…{:else if}…{:else}…` chains, nested `{#each}`)
|
||||
2. **Complex ternary expressions** in attribute bindings like `title={a ? b : c ? d : e}`
|
||||
3. **`.svelte.ts` model 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
|
||||
|
||||
- `/dashboards` and `/translate` list: 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:
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
1. Build: `npm run build` — passes clean, no errors.
|
||||
2. Type check: `tsc` — no errors.
|
||||
3. Unit tests: `npm run test` — 698/698 pass (tests don't exercise the page's runtime rendering).
|
||||
4. Browser: clicking on a job card from `/translate` navigates to the detail URL, but the `<main>` content is empty.
|
||||
5. 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
|
||||
|
||||
```diff
|
||||
- import { t } from '$lib/i18n/index.svelte.js';
|
||||
+ import { getT } from '$lib/i18n/index.svelte.js';
|
||||
```
|
||||
|
||||
### Template change
|
||||
|
||||
```diff
|
||||
- <h1>{$t.translate?.config?.new_title}</h1>
|
||||
+ const _t = $derived(getT());
|
||||
+ <h1>{_t.translate?.config?.new_title}</h1>
|
||||
```
|
||||
|
||||
### Script change
|
||||
|
||||
```diff
|
||||
- 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 `subscribe` as a direct method, or
|
||||
- **Plain objects** with `subscribe` as an own property, or
|
||||
- **Skip the store pattern entirely** — use functions like `getT()` + `$derived` for 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: `t` Proxy 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](https://svelte.dev/docs/svelte/stores) — `$store` template syntax
|
||||
- **Svelte 5 issue:** store-detection on Proxy-based objects with `get` trap (upstream behavior)
|
||||
|
||||
---
|
||||
|
||||
## Recommended Follow-up
|
||||
|
||||
1. **Update `t` export** in `i18n/index.svelte.ts` to use a class instance with `subscribe` as a direct method, avoiding the Proxy entirely. This may restore `$t` template syntax reliability.
|
||||
|
||||
2. **Add ESLint rule** to disallow `$t` template references — force `_t = $derived(getT())` pattern.
|
||||
|
||||
3. **Document the pattern** in `semantics-svelte` skill (`§VII` or new `§X`) so future agents don't repeat this debugging cycle.
|
||||
|
||||
4. **Audit other pages** with complex templates: any page using `t` import + nested `{#if}` chains is a latent bug.
|
||||
Reference in New Issue
Block a user