Files
ss-tools/docs/bug-reports/2026-06-02-svelte5-proxy-store-t-undefined.md
busya 37fe425c59 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.
2026-06-02 20:45:06 +03:00

264 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 7081 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 7278 | **🎯 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 188202: `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.