The AI bug catalog
Every pattern below was written to look exactly like the Svelte an AI assistant actually produces, then type-checked or run against a real SvelteKit + svelte-check + Vitest + @testing-library/svelte probe (Svelte 5.57.1, SvelteKit 2.70.3). The quoted svelte-check, compiler, and test output is copied verbatim from those runs, not reconstructed from memory. The 28 patterns are grouped in the same reading order as How to read Svelte you did not write: runes vs legacy mode, state ownership, props/bindings/snippets, events and DOM, the SvelteKit boundary, and keys/transitions/boundaries. See the Review Checklist that follows this lesson for a one-page version of the same questions.
Runes vs legacy mode
Section titled “Runes vs legacy mode”1. on:click used in a runes-mode component
Section titled “1. on:click used in a runes-mode component”<!-- src/lib/LegacyClick.svelte — as generated --><script> let count = $state(0);</script>
<button on:click={() => count++}>{count}</button>on:click is Svelte 4’s event directive. The file already uses $state, so it’s in runes mode — but unlike export let/$: (entry 2), the event-directive syntax is a softer deprecation, not a hard error: it still compiles and runs correctly.
Symptom — the real compiler warning:
Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` insteadhttps://svelte.dev/e/event_directive_deprecatedWith eslint-plugin-svelte’s svelte/valid-compile rule explicitly enabled (verified against [email protected] — see Verification Tools for why recommended alone does not catch this), the same warning surfaces as a real lint error:
4:9 error Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` insteadhttps://svelte.dev/e/event_directive_deprecated(event_directive_deprecated) svelte/valid-compileFix: the plain-property form:
<script> let count = $state(0);</script>
<button onclick={() => count++}>{count}</button>Caught by: the compiler’s own deprecation warning, always — but only fails a build if something (like svelte/valid-compile explicitly enabled) is configured to treat warnings as errors. See Events & Attributes for the full four-pattern legacy table this entry is the first row of.
2. export let and $: mixed into a runes-mode component
Section titled “2. export let and $: mixed into a runes-mode component”<!-- @expect-error two real compile errors — see prose --><!-- src/lib/HalfMigrated.svelte — as generated --><script> export let step = 1; let count = $state(0);
$: total = count * step;</script>
<button onclick={() => (count += step)}>{total}</button>This is the single most common shape of half-migrated AI-generated Svelte: count was correctly upgraded to $state, but step is still export let, and total is still computed with $:. The moment any rune appears anywhere in the file, the whole file is in runes mode — and both export let and $: are Svelte 4’s reactivity primitives, with nothing left to fall back to.
Symptom — real, verbatim compiler errors (identical whether triggered via svelte-check or the compiler’s own compile()):
Cannot use `export let` in runes mode — use `$props()` insteadhttps://svelte.dev/e/legacy_export_invalid
`$:` is not allowed in runes mode, use `$derived` or `$effect` insteadhttps://svelte.dev/e/legacy_reactive_statement_invalidFix: upgrade both forms to their rune equivalents, not just the one already touched:
<script lang="ts"> let { step = 1 }: { step?: number } = $props(); let count = $state(0); let total = $derived(count * step);</script>
<button onclick={() => (count += step)}>{total}</button>Caught by: svelte-check/the compiler, immediately — a hard error, not a lint nitpick. svelte/compiler’s own migrate() function performs exactly this rewrite automatically — proven with real input/output in Verification Tools. See Events & Attributes for why these two forms are hard errors while <slot>/on:click (entries 1 and 3) are not.
3. <slot> mixed into a runes-mode component
Section titled “3. <slot> mixed into a runes-mode component”<!-- src/lib/CardWithSlot.svelte — as generated --><script> let highlighted = $state(false);</script>
<div class:highlighted> <slot /></div>Like on:click, <slot> still compiles and renders correctly in a runes-mode file — it’s backward-compatible content projection, not a removed feature — but it’s a real deprecation warning, and mixing Svelte 4’s slot with Svelte 5 runes in the same file is inconsistent.
Symptom — the real compiler warning:
Using `<slot>` to render parent content is deprecated. Use `{@render ...}` tags insteadhttps://svelte.dev/e/slot_element_deprecatedFix: a children snippet prop, rendered with {@render}:
<script lang="ts"> import type { Snippet } from 'svelte'; let { children, highlighted = false }: { children?: Snippet; highlighted?: boolean } = $props();</script>
<div class:highlighted> {@render children?.()}</div>Caught by: the compiler’s own deprecation warning, always — never a hard error. See Snippets & Render for the full slot-to-snippet migration, including the runtime error a different snippet mistake produces (entry 17).
4. createEventDispatcher instead of a callback prop
Section titled “4. createEventDispatcher instead of a callback prop”<!-- src/lib/OldDispatch.svelte — as generated --><script> import { createEventDispatcher } from 'svelte'; let count = $state(0); const dispatch = createEventDispatcher();
function bump() { count += 1; dispatch('change', { count }); }</script>
<button onclick={bump}>{count}</button>Unlike the first three entries, this one produces no warning of any kind — createEventDispatcher is still fully supported in Svelte 5, and code compiles clean either way. It’s purely an idiom question: a generated component reaching for dispatch(...) plus a parent listening with on:change (itself possibly the deprecated directive from entry 1) is doing in two deprecated-adjacent steps what a plain, statically-typed callback prop does in one.
Symptom — proven with a real compile check, zero warnings either way:
✓ createEventDispatcher in a Svelte 5 runes-mode component compiles with NO warning either (still fully supported)Fix: a typed callback prop:
<script lang="ts"> let { onchange }: { onchange?: (detail: { count: number }) => void } = $props(); let count = $state(0);
function bump() { count += 1; onchange?.({ count }); }</script>
<button onclick={bump}>{count}</button>import { test, expect, vi } from 'vitest';import { render, screen, fireEvent } from '@testing-library/svelte';import NewCallback from './NewCallback.svelte';
test('a callback prop replaces createEventDispatcher with a plain, typed function call', async () => { const onchange = vi.fn(); render(NewCallback, { props: { onchange } }); await fireEvent.click(screen.getByRole('button')); expect(onchange).toHaveBeenCalledWith({ count: 1 });});Caught by: nothing automatic — proven above to compile identically either way. Only a style/idiom read (or the callback prop’s own TypeScript signature, which a mistyped dispatch('change', ...) string has none of) distinguishes them. See Events & Attributes’s legacy table, last row.
🐛 Spot the bug
<script>import { createEventDispatcher } from 'svelte';let { items } = $props();const dispatch = createEventDispatcher();</script>{#each items as item (item.id)}<button on:click={() => dispatch('select', item)}>{item.name}</button>{/each}Show the answer
Two entries from this group stacked in one file: on:click (entry 1) compiles with a real deprecation warning — event_directive_deprecated — and createEventDispatcher (entry 4) compiles with no warning at all, but is no longer the idiomatic way to send data up in Svelte 5. Both point at the same fix: replace the pair with a single typed callback prop and the plain onclick attribute, which needs neither a directive nor a dispatcher:
<script> let { items, onselect } = $props();</script>{#each items as item (item.id)} <button onclick={() => onselect?.(item)}>{item.name}</button>{/each}State ownership
Section titled “State ownership”5. Mirroring a prop into $state then syncing with $effect
Section titled “5. Mirroring a prop into $state then syncing with $effect”<!-- @expect-error compiles and runs, but total lags by a beat — see prose --><!-- src/lib/PriceTag.svelte — as generated --><script lang="ts"> let { price, taxRate }: { price: number; taxRate: number } = $props(); let total = $state(0);
$effect(() => { total = price * (1 + taxRate); });</script>
<p>{total}</p>total is mirrored from price/taxRate through an $effect instead of being $derived directly — so it only updates on the next flush, not synchronously, the instant price/taxRate change.
Symptom — real test output, proving the exact lag:
✓ anti-pattern proven: an $effect that mirrors state into another $state variable LAGS by a flush✓ the fix: $derived recomputes synchronously on read — no lag, no flush neededConcretely: after price changes from 2 to 3 (with taxRate fixed), reading the effect-mirrored total synchronously, right after the write still returns the value computed from the old price — only correct after an explicit flush (or, in real usage, the browser’s next microtask).
Fix: derive it directly:
<script lang="ts"> let { price, taxRate }: { price: number; taxRate: number } = $props(); let total = $derived(price * (1 + taxRate));</script>
<p>{total}</p>Caught by: nothing automatic — no compiler error, no lint rule for an effect mirroring computable state. Only a synchronous-read test (proven in the probe) surfaces the lag. See The $effect Rune for the full running proof.
6. $effect writing derived state instead of $derived
Section titled “6. $effect writing derived state instead of $derived”This is entry 5’s pattern in its most literal form — worth calling out on its own because it’s so easy to write without any prop involved at all:
<!-- @expect-error compiles and runs, but double lags by a beat — see prose --><!-- src/lib/DoubleCounter.svelte — as generated --><script> let count = $state(2); let double = $state(0);
$effect(() => { double = count * 2; });</script>
<button onclick={() => count++}>{double}</button>Every extra $state variable an $effect writes to is a second source of truth that can (and, proven above, provably does) briefly disagree with the value it’s derived from.
Symptom — the exact same lag, isolated with no props involved:
✓ anti-pattern proven: an $effect that mirrors state into another $state variable LAGS by a flushFix:
<script> let count = $state(2); let double = $derived(count * 2);</script>
<button onclick={() => count++}>{double}</button>Caught by: nothing automatic — see entry 5’s evidence for the full running test. See The $derived Rune’s “Derive, do not sync with an effect” section for the rule of thumb this entry and entry 5 both violate.
7. Reading $state inside setTimeout expecting it to be tracked
Section titled “7. Reading $state inside setTimeout expecting it to be tracked”<!-- @expect-error compiles and runs, but the effect never re-runs on `count` changes — see prose --><!-- src/lib/DelayedLogger.svelte — as generated --><script lang="ts"> let { onLog }: { onLog: (n: number) => void } = $props(); let count = $state(0);
$effect(() => { setTimeout(() => { onLog(count); // read INSIDE the callback, after the effect body already returned }, 0); });</script>An $effect’s dependency set is exactly what its body reads synchronously, on its last run. A read that happens later — inside a setTimeout/Promise/any callback scheduled by the effect and executed after the effect function has already returned — is not part of that synchronous pass, so it never registers as a dependency, no matter how “obviously reactive” the read looks.
Symptom — proven directly, contrasted with the same value read synchronously:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('a $state read inside a setTimeout scheduled by an effect is NOT a tracked dependency of that effect', () => { const runs: number[] = []; const destroy = $effect.root(() => { let count = $state(0); let other = $state(0);
$effect(() => { other; // real, synchronous, tracked dependency setTimeout(() => { runs.push(count); // read happens LATER, outside the effect's synchronous execution }, 0); });
flushSync(); count = 1; // changing count must NOT re-run the effect: it was never read synchronously flushSync(); count = 2; flushSync(); }); destroy(); // Only `other` (read synchronously) is a dependency; changing `count` (read // only inside the async setTimeout callback) never re-ran the effect at all. expect(runs.length).toBeLessThanOrEqual(1);});
test('control: reading the SAME value synchronously inside the effect body IS tracked and re-runs it', () => { const runs: number[] = []; const destroy = $effect.root(() => { let count = $state(0); $effect(() => { runs.push(count); // synchronous read — tracked }); flushSync(); count = 1; flushSync(); count = 2; flushSync(); }); destroy(); expect(runs).toEqual([0, 1, 2]);});✓ a $state read inside a setTimeout scheduled by an effect is NOT a tracked dependency of that effect✓ control: reading the SAME value synchronously inside the effect body IS tracked and re-runs itFix: read the value the effect actually depends on synchronously, and pass the already-read value into the callback instead of re-reading it later:
<script lang="ts"> let { onLog }: { onLog: (n: number) => void } = $props(); let count = $state(0);
$effect(() => { const current = count; // read SYNCHRONOUSLY — this line makes it a real dependency setTimeout(() => { onLog(current); // use the captured value, not a later re-read }, 0); });</script>Caught by: nothing automatic — no compiler error, no lint rule for a read hidden inside an async callback. Only a running test (proven above) surfaces the missing dependency. See The $effect Rune for the general “dependencies are exactly what the last run read” proof this entry is a specific, async-flavored case of.
8. Mutating $state.raw expecting reactivity
Section titled “8. Mutating $state.raw expecting reactivity”<!-- @expect-error compiles and runs, but the UI never updates — see prose --><!-- src/lib/ThemeSwitcher.svelte — as generated --><script lang="ts"> let settings = $state.raw({ theme: 'dark', density: 'cozy' });
function setLightTheme() { settings.theme = 'light'; // mutating raw state — silently not reactive }</script>
<button onclick={setLightTheme}>{settings.theme}</button>$state.raw opts out of Svelte’s deep-proxy reactivity on purpose (for large, replace-wholesale data) — it’s reactive on reassignment only. A generated component that mutates a raw-state object’s property, exactly the way it would with plain (deep) $state, silently does nothing observable: the mutation genuinely happens, but nothing that reads settings reactively is notified.
Symptom — real test output, contrasted directly against deep $state:
✓ $state.raw: mutation is NOT tracked, only reassignment is✓ control: the same mutation on deep (proxied) $state DOES trigger the effectConcretely: settings.theme = 'light' genuinely changes the underlying object (settings.theme reads back as 'light' afterwards) — the button’s own displayed text just never updates, because no proxy is watching for that write.
Fix: reassign the whole object:
<script lang="ts"> let settings = $state.raw({ theme: 'dark', density: 'cozy' });
function setLightTheme() { settings = { ...settings, theme: 'light' }; // reassignment IS tracked }</script>
<button onclick={setLightTheme}>{settings.theme}</button>Caught by: nothing automatic — no compiler error, no lint rule for a mutated $state.raw value; only a running test surfaces the missing update. See The $state Rune for the full proof, including why “raw” doesn’t mean “frozen.”
9. $derived with a side effect
Section titled “9. $derived with a side effect”<!-- @expect-error compiles and runs, but the tracking call fires an unpredictable number of times — see prose --><!-- src/lib/AnalyticsList.svelte — as generated --><script lang="ts"> let { events }: { events: { type: string }[] } = $props();
let filtered = $derived.by(() => { const result = events.filter((e) => e.type === 'click'); trackAnalytics('filtered-recomputed', result.length); // side effect, inside a derived return result; });
function trackAnalytics(name: string, count: number) {}</script>$derived is push-pull and lazy: a dependency change only marks it dirty, and the function only actually re-runs the next time something reads the value. Code that assumes a $derived’s body runs exactly once per dependency change — which an analytics call, a console.log, or any other side effect implicitly assumes — is relying on a guarantee $derived never makes.
Symptom — real test output, proving the skip:
✓ $derived is push-pull: dependency changes mark it dirty, but it only re-evaluates on next READ✓ an unread $derived never evaluates at all, even across many dependency changesConcretely: changing events five times with no read of filtered in between produces zero evaluations of the derived function — trackAnalytics never fires for any of those five changes, and a single subsequent read produces exactly one evaluation reflecting only the latest state, silently skipping the intermediate ones.
Fix: move the side effect into a real $effect, which is the rune that guarantees “run when this changes”:
<script lang="ts"> let { events }: { events: { type: string }[] } = $props();
let filtered = $derived(events.filter((e) => e.type === 'click'));
$effect(() => { trackAnalytics('filtered-recomputed', filtered.length); });
function trackAnalytics(name: string, count: number) {}</script>Caught by: nothing automatic — no compiler error, no lint rule for a side effect inside $derived. Only a running test (proven above) surfaces the skipped/unpredictable evaluation count. See The $derived Rune for the full push-pull laziness proof.
10. onMount used to compute a value that should be $derived
Section titled “10. onMount used to compute a value that should be $derived”<!-- @expect-error compiles and runs, but displayName goes stale the moment userId changes — see prose --><!-- src/lib/UserLabel.svelte — as generated --><script lang="ts"> import { onMount } from 'svelte';
let { userId }: { userId: string } = $props(); let displayName = $state('');
onMount(() => { displayName = formatName(userId); // computed once, on mount });
function formatName(id: string) { return `user-${id}`; }</script>
<p>{displayName}</p>onMount runs exactly once, after the component’s first render — proven directly in this course. userId is a prop, and props change over the component’s lifetime; because this computation only ever runs on mount, displayName silently goes stale the moment a parent passes a different userId, with nothing re-running it.
Symptom — proven: onMount genuinely runs once, never again:
✓ order on first render: script body, then onMount, then $effectThat test proves onMount fires exactly once per mount, in a fixed order relative to $effect — it never re-fires on a later prop change the way an $effect/$derived reading that prop would.
Fix: a $derived, which recomputes automatically whenever userId changes:
<script lang="ts"> let { userId }: { userId: string } = $props(); let displayName = $derived(formatName(userId));
function formatName(id: string) { return `user-${id}`; }</script>
<p>{displayName}</p>import { test, expect } from 'vitest';import { render, screen } from '@testing-library/svelte';import UserLabelFixed from './UserLabelFixed.svelte';
test('a $derived recomputes when the prop it reads changes; onMount never would', async () => { const { rerender } = render(UserLabelFixed, { props: { userId: 'a' } }); expect(screen.getByText('user-a')).toBeTruthy(); await rerender({ userId: 'b' }); expect(screen.getByText('user-b')).toBeTruthy();});Caught by: nothing automatic — no compiler error, no lint rule for a stale one-time computation. See Lifecycle for the full onMount-runs-once-before-the-first-$effect proof.
11. Module-level $state holding per-user data (SSR leak)
Section titled “11. Module-level $state holding per-user data (SSR leak)”// @expect-error compiles and runs, but leaks between concurrent server requests — see prose// src/lib/current-user.svelte.ts — as generatedclass CurrentUser { name = $state(''); isAdmin = $state(false);}
export const currentUser = new CurrentUser();// src/routes/dashboard/+page.server.js — as generateddeclare function getCurrentUser(locals: unknown): { name: string; isAdmin: boolean };import { currentUser } from '$lib/current-user.svelte.ts';
export async function load({ locals }: { locals: unknown }) { const user = getCurrentUser(locals); currentUser.name = user.name; currentUser.isAdmin = user.isAdmin; return {};}currentUser is a class instance exported directly from a module — a singleton shared by the whole server process, for exactly the same reason a bare module-level $state({...}) export is. Mutating currentUser.name/currentUser.isAdmin inside load — server-side, per request — writes into that one shared instance, so a second, concurrent request for a different user can see the first user’s name and admin flag before its own load finishes.
Symptom — proven: one shared instance across every importer, for the whole process:
✓ module-scope $state is a SINGLE shared instance across every importer — proves the SSR leak riskThe official Svelte docs are direct about the risk this proves: “if you mutate the state during server-side rendering (which is discouraged, but entirely possible!)… then the data may be accessible by the next user.” — svelte.dev/docs/svelte/context#Replacing-global-state.
Fix: create the user’s state fresh per request instead of holding it in a shared module export — a factory called inside load, or setContext/getContext per component-tree request:
export function createCurrentUser() { return $state({ name: '', isAdmin: false });}import { createCurrentUser } from '$lib/current-user.svelte.ts';
/** @param {unknown} locals */function getCurrentUser(locals) { return /** @type {{ name: string; isAdmin: boolean }} */ (locals);}
export async function load({ locals }) { const user = getCurrentUser(locals); const currentUser = createCurrentUser(); // fresh instance, this request only currentUser.name = user.name; currentUser.isAdmin = user.isAdmin; return { user: currentUser };}Caught by: nothing automatic — no compiler error, no lint rule for a shared module singleton. See The $state Rune and Runes vs Stores for the identical trap proven against a plain object and a class instance respectively.
12. $store read inside a function, not at the component top level
Section titled “12. $store read inside a function, not at the component top level”<!-- @expect-error real compile error — see prose --><!-- src/lib/BadNestedStore.svelte — as generated --><script lang="ts"> import { writable } from 'svelte/store';
function makeIt() { const count = writable(0); // declared INSIDE a function, not at the component top level console.log($count); } makeIt();</script>The $store shorthand is compiled by rewriting $name into a managed subscription set up once, at the component’s top level. When the store variable itself is declared inside a nested scope, Svelte has no static top-level binding to attach that subscription to — a real compile error, not a runtime warning.
Symptom — the real, verbatim svelte-check error:
$ svelte-check --tsconfig ./tsconfig.jsonsrc/lib/BadNestedStore.svelte:6:17Cannot subscribe to stores that are not declared at the top level of the componenthttps://svelte.dev/e/store_invalid_scoped_subscriptionFix: declare the store at the component’s top level; reading $store inside a nested callback is completely fine — it’s where the store variable is declared that matters, not where it’s read:
<script lang="ts"> import { writable } from 'svelte/store';
const count = writable(0); // declared at the top level
function makeIt() { console.log($count); // reading it inside a nested callback is fine } makeIt();</script>Caught by: svelte-check/the compiler, immediately. See The Store Contract for the full proof, including the compiler’s own compile() call throwing the identical error.
13. get(store) called in a hot loop
Section titled “13. get(store) called in a hot loop”// @expect-error real, measurable performance cost — see prose// src/lib/hot-loop.ts — as generatedimport { get, type Writable } from 'svelte/store';
function sumEach(store: Writable<number>, times: number): number { let total = 0; for (let i = 0; i < times; i++) { total += get(store); // reads the CURRENT value on every iteration } return total;}get(store) looks like a cheap property read. It isn’t — under the hood, it calls store.subscribe(...) and immediately calls the returned unsubscribe function, paying the store’s full start → stop lifecycle on every single call. For a writable/readable with no start function this cost is trivial; for anything wrapping something expensive to start (a WebSocket, a setInterval), calling get() in a loop opens and tears it down every iteration.
Symptom — real test output, measuring the exact cost:
✓ get(store) subscribes AND immediately unsubscribes — proven with a counting start function✓ cost in a hot loop: N calls to get() cause N start/stop cycles, not oneConcretely: 50 calls to get(store) in a loop produced 50 separate start/stop cycles on the store’s start function — not one shared subscription paying the cost once.
Fix: subscribe once, outside the loop, and read the kept-up-to-date value:
import { type Writable } from 'svelte/store';
function sumEach(store: Writable<number>, times: number): number { let current = 0; const unsubscribe = store.subscribe((v) => { current = v; }); let total = 0; for (let i = 0; i < times; i++) { total += current; // no per-iteration subscribe/unsubscribe cost } unsubscribe(); return total;}Caught by: nothing automatic — no compiler error, no lint rule for get() in a loop. See The Store Contract for the full proof, including a $effect version of this same mistake.
14. writable wrapped in $state
Section titled “14. writable wrapped in $state”<!-- @expect-error compiles and runs, but neither reactivity system actually tracks anything — see prose --><!-- src/lib/TagsStore.svelte — as generated --><script lang="ts"> import { writable } from 'svelte/store';
let tags = $state(writable<Set<string>>(new Set()));
function add(tag: string) { $tags.update((set) => { set.add(tag); return set; }); }</script>writable(new Set()) is itself a perfectly good store — but wrapping the store object in $state mixes two reactivity systems in a way that tracks nothing useful: a store’s own identity never changes when you call set/update on it (it’s the same object throughout, with the same subscribe method), so $state around it has no reassignment to ever notice.
Symptom — the underlying fact this depends on, proven directly: a store’s identity is stable across set/update calls (it’s always the same subscribe-bearing object), so wrapping it in $state — which only tracks reassignment of the variable holding it — can never observe a store mutating its own internal value. Combined with $state’s own proxy not instrumenting Map/Set methods at all (proven for Map in Built-in Stores), a Set stored this way is doubly un-reactive.
Fix: pick one system — either a plain store with no $state involved, or a rune-native SvelteSet with no store involved at all:
<script lang="ts"> import { SvelteSet } from 'svelte/reactivity';
let tags = new SvelteSet<string>();
function add(tag: string) { tags.add(tag); // reactive at the method level — no store, no $state wrapper }</script>Caught by: nothing automatic — no compiler error, no lint rule for double-wrapped reactivity. See Built-in Stores for the proven method-level reactivity SvelteMap/SvelteSet give you that $state around a plain collection does not.
🐛 Spot the bug
let list = $state.raw([1, 2, 3]);
function addItem(n) {// "arrays have a push method, this should just work"list.push(n);}Show the answer
Same trap as entry 8, on an array instead of a plain object: list.push(n) genuinely appends to the underlying array (list.length really does grow), but $state.raw only tracks whole-value reassignment — no proxy is watching for the array mutation, so nothing that reads list reactively (an $effect, a $derived, the template) is notified. The fix is either to reassign a new array — list = [...list, n] — or to declare list with plain (deep) $state, which proxies array mutations like .push() directly, if per-mutation reactivity is actually what this code needs.
Props, bindings, and snippets
Section titled “Props, bindings, and snippets”15. Missing $bindable() before a parent bind:
Section titled “15. Missing $bindable() before a parent bind:”<!-- @expect-error bind: to a prop the child never declared $bindable() --><!-- src/lib/Toggle.svelte — as generated --><script lang="ts"> let { open }: { open: boolean } = $props();</script>
<button onclick={() => (open = !open)}>{open ? 'Close' : 'Open'}</button><!-- src/lib/TogglePanel.svelte — as generated --><script lang="ts"> import Toggle from './Toggle.svelte'; let panelOpen = $state(false);</script>
<Toggle bind:open={panelOpen} />{#if panelOpen}<p>Panel content</p>{/if}Toggle never declares open as $bindable() — it’s a plain destructured prop. A parent can only bind: to a child’s prop when the child opts that specific prop into two-way binding.
Symptom — the real, verbatim svelte-check error:
$ svelte-check --tsconfig ./tsconfig.jsonsrc/lib/TogglePanel.svelte:5:8Cannot use 'bind:' with this property. It is declared as non-bindable insidethe component.To mark a property as bindable: 'let { open = $bindable() } = $props()'Fix: exactly what the message says:
<script lang="ts"> let { open = $bindable() }: { open: boolean } = $props();</script>
<button onclick={() => (open = !open)}>{open ? 'Close' : 'Open'}</button>Caught by: svelte-check/the compiler, immediately — a real error naming the exact fix. See Bindings for the full proof.
16. A {#snippet} defined but never {@render}ed
Section titled “16. A {#snippet} defined but never {@render}ed”<!-- src/lib/OrphanSnippet.svelte — as generated --><script lang="ts"> let count = $state(0);</script>
{#snippet label(prefix: string)} <span>{prefix}: {count}</span>{/snippet}
<button onclick={() => count++}>{count}</button>label is defined but never rendered anywhere — probably a typo in a later {@render} call, or dead code left over from a refactor. There is no signal of any kind: no compiler warning, no svelte-check error, nothing — the snippet is silently unused, exactly like an unused local function.
Symptom — proven directly, contrasted with a control case:
import { test, expect } from 'vitest';import { compile } from 'svelte/compiler';
test('a {#snippet} defined but never {@render}ed compiles with NO warning or error at all — silent dead code', () => { const source = `<script> let count = $state(0);</script>{#snippet unused(label)} <p>{label}: {count}</p>{/snippet}<button onclick={() => count++}>{count}</button>`; const result = compile(source, { filename: 'Unused.svelte' }); expect(result.warnings).toEqual([]);});✓ a {#snippet} defined but never {@render}ed compiles with NO warning or error at all — silent dead codeFix: either render it, or delete it — both are one-line changes, and neither is something the compiler will ever prompt you to make:
<script lang="ts"> let count = $state(0);</script>
{#snippet label(prefix: string)} <span>{prefix}: {count}</span>{/snippet}
{@render label('count')}<button onclick={() => count++}>{count}</button>Caught by: nothing automatic, proven above — grep the snippet’s own name to confirm every definition has a matching {@render} call somewhere in the file. See Snippets & Render for the closely related, but differently-detected, bug in entry 17.
17. Calling a snippet directly instead of {@render name()}
Section titled “17. Calling a snippet directly instead of {@render name()}”<!-- @expect-error real runtime error — see prose --><!-- src/lib/NoRender.svelte — as generated --><script lang="ts"> let items = [1, 2, 3];</script>{#snippet row(item: number)} <li>{item}</li>{/snippet}<ul> {#each items as item} {row(item)} {/each}</ul>Writing a snippet’s name as a plain call — {row(item)} — instead of {@render row(item)} compiles without any error or warning, but throws at runtime the moment that line actually executes. A snippet compiles to a function whose real first parameter is an internal DOM anchor that only {@render} supplies; calling it directly hands your own argument into that slot instead.
Symptom — the real runtime error:
invalid_snippet_argumentsA snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}`https://svelte.dev/e/invalid_snippet_argumentsFix: go through {@render}:
<script lang="ts"> let items = [1, 2, 3];</script>{#snippet row(item: number)} <li>{item}</li>{/snippet}<ul> {#each items as item} {@render row(item)} {/each}</ul>Caught by: nothing at compile time — only a real render (proven end to end in the linked lesson) surfaces the runtime error. See Snippets & Render for the full proof, including why the same mistake is invisible until that exact line runs.
18. bind:value on a number input, code still assumes a string
Section titled “18. bind:value on a number input, code still assumes a string”<!-- @expect-error real tsc error — see prose --><!-- src/lib/NumberBug.svelte — as generated --><script lang="ts"> let age: number = $state(0);
function isBlank() { return age.trim() === ''; // assumes age is still a string }</script><input type="number" bind:value={age} min="0" max="120" />For type="number" inputs, bind:value coerces the bound value to a real number — not a string, unlike every other input type. Code carried over from a text-input pattern (or written by a model that defaults to string handling for form fields) calling .trim()/.length/other string-only methods on it is a real, caught type error, not a runtime surprise.
Symptom — the real, verbatim svelte-check error:
$ svelte-check --tsconfig ./tsconfig.jsonsrc/lib/NumberBug.svelte:7:16Property 'trim' does not exist on type 'number'.Fix: treat age as the real number it is:
<script lang="ts"> let age: number = $state(0);
function isBlank() { return Number.isNaN(age); // a number input's empty state is NaN, not '' }</script><input type="number" bind:value={age} min="0" max="120" />Caught by: tsc/svelte-check, immediately, in a typed component — a real error, not a lint nitpick. In a plain <script> (no lang="ts"), this same mistake would run and throw a genuine TypeError: age.trim is not a function the first time isBlank() is called instead. See Forms & Inputs for the full number/range coercion proof.
Events and the DOM
Section titled “Events and the DOM”19. use: action expecting re-run on argument change
Section titled “19. use: action expecting re-run on argument change”<!-- src/lib/Tooltip.svelte — as generated --><script lang="ts"> function tooltip(node: HTMLElement, text: string) { node.title = text; // set once, at mount — no update() method }
let label = $state('Save your work');</script>
<button use:tooltip={label}>Save</button><button onclick={() => (label = 'Saved!')}>Mark saved</button>tooltip is a use: action with no update method, so Svelte calls it exactly once, at mount, with the initial value of label. Every later change to label is silently ignored — a generated component assuming use:tooltip={label} “just works” like a reactive prop misses that only an explicit update method (or, in new code, an attachment) makes that true.
Symptom — real test output, proving the argument change is ignored:
✓ an attachment factory re-runs when its argument changes; a use: action does notConcretely: after label changes, the attachment-based equivalent’s run counter goes from [1] to [1, 2]; the use: action’s counter stays at [1] — the change never took effect on the running action.
Fix: since Svelte 5.29, prefer an attachment factory, which re-runs automatically when the value it reads changes:
<script lang="ts"> function tooltip(text: string) { return (node: HTMLElement) => { node.title = text; }; }
let label = $state('Save your work');</script>
<button {@attach tooltip(label)}>Save</button><button onclick={() => (label = 'Saved!')}>Mark saved</button>Caught by: nothing automatic — no compiler error, no lint rule for a missing update method. See Actions for the full proof, including the fromAction bridge for existing action-shaped libraries.
20. An ancestor’s stopPropagation() silently eating a delegated onclick
Section titled “20. An ancestor’s stopPropagation() silently eating a delegated onclick”<!-- src/lib/ModalBackdrop.svelte — as generated --><script lang="ts"> let { onSave }: { onSave: () => void } = $props();
function blockBubble(node: HTMLElement) { function handle(e: MouseEvent) { e.stopPropagation(); // stops NATIVE bubbling before it reaches Svelte's delegated root listener } node.addEventListener('click', handle); return () => node.removeEventListener('click', handle); }</script>
<div {@attach blockBubble}> <button onclick={onSave}>Save</button></div>Svelte doesn’t attach a native listener to every element with an onclick — most events are delegated to a single root listener. A vanilla addEventListener between the root and a Svelte-managed button that calls stopPropagation() prevents the click from ever reaching that root, silently defeating the delegated onclick — with no error anywhere, because the click visibly landed on the button.
Symptom — proven with a real ancestor listener:
✓ a vanilla stopPropagation() on an ancestor prevents the delegated Svelte onclick from firingConcretely: clicking the button logs only 'ancestor-vanilla-listener' — the button’s own onclick never fires at all.
Fix: use on from svelte/events instead of raw addEventListener, which the docs specifically recommend for correct ordering relative to Svelte’s own dispatch:
<script lang="ts"> import { on } from 'svelte/events'; let { onSave }: { onSave: () => void } = $props();
function blockBubble(node: HTMLElement) { return on(node, 'click', (e) => { e.stopPropagation(); }); }</script>
<div {@attach blockBubble}> <button onclick={onSave}>Save</button></div>Caught by: nothing automatic — no compiler error, no warning for a stopPropagation() that happens to sit between the root and a Svelte handler. See Events & Attributes for the full proof, including why on() avoids the trap.
The SvelteKit boundary
Section titled “The SvelteKit boundary”21. $app/stores’s $page instead of $app/state
Section titled “21. $app/stores’s $page instead of $app/state”<!-- src/routes/+layout.svelte — as generated --><script lang="ts"> import { page } from '$app/stores';
let pathname = $derived($page.url.pathname);</script>
<h1>{pathname}</h1>This compiles and runs today — $app/stores hasn’t been removed. But every one of its exports is marked deprecated straight from the installed SvelteKit’s own source, and it’s a strong signal the code (or its training data) predates the 2.12 $app/state migration.
Symptom — the real, verbatim deprecation notice, straight from the installed package:
$ grep -n "deprecated" node_modules/@sveltejs/kit/src/runtime/app/stores.js9: * @deprecated Use `$app/state` instead (requires Svelte 5, see docs for more info)33: * @deprecated Use `page` from `$app/state` instead (requires Svelte 5, see docs for more info)The migration guide states it plainly: “$app/stores is deprecated and subject to be removed in SvelteKit 3.”
Fix: $app/state’s page is already a reactive object, not a store — drop the $ prefix entirely:
<script lang="ts"> import { page } from '$app/state';
let pathname = $derived(page.url.pathname);</script>
<h1>{pathname}</h1>Caught by: nothing automatic — the deprecated module still compiles and runs with no warning surfaced to svelte-check. See Loading Data and Runes vs Stores for the full proof.
22. throw redirect()/error() swallowed by try/catch
Section titled “22. throw redirect()/error() swallowed by try/catch”// @expect-error real bug, proven with curl — see prose// src/routes/catch-bug/+page.server.js — as generatedimport { redirect } from '@sveltejs/kit';
async function riskyFetch() { return { ok: true };}
export const load = async () => { try { await riskyFetch(); redirect(303, '/dashboard'); // throws — but the catch below swallows it } catch (e) { console.error('load failed', e); return { fallback: true }; // 200 with fallback data instead of a 303 }};redirect()/error() work by throwing a special object that SvelteKit’s own request-handling loop is specifically listening for, several stack frames up. A try/catch written to handle a different kind of failure (a flaky fetch) catches that special throw too, indistinguishably from a real error.
Symptom — proven against a real running app:
$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:5195/catch-bugHTTP 200$ curl -s http://localhost:5195/catch-bug | grep -o '<h1[^<]*</h1>'<h1 data-testid="fallback">fallback=true</h1>A request that should redirect returns 200 with the fallback data instead — no thrown exception reaching the user, no type error, just silently wrong behavior.
Fix: check whether the caught value is a redirect/HTTP error before treating it as a real failure, and rethrow it if so:
import { redirect, isRedirect, isHttpError } from '@sveltejs/kit';
async function riskyFetch() { return { ok: true };}
export const load = async () => { try { await riskyFetch(); redirect(303, '/dashboard'); } catch (e) { if (isRedirect(e) || isHttpError(e)) throw e; // let SvelteKit's control-flow throws through console.error('load failed', e); return { fallback: true }; }};Proven fixed against the same running app:
$ curl -s -D - -o /dev/null http://localhost:5195/catch-fixed | grep -i "^\(HTTP\|location\)"HTTP/1.1 303 See Otherlocation: /dashboardCaught by: nothing automatic — both are just JavaScript throws as far as the type system is concerned. See Server & API for the full proof.
23. Awaiting everything in load (no streaming)
Section titled “23. Awaiting everything in load (no streaming)”// src/routes/blog/[slug]/+page.server.js — as generatedfunction loadComment(slug) { return new Promise((resolve) => { setTimeout(() => resolve(`a slow comment on ${slug}, arrived late`), 2000); });}
export async function load({ params }) { return { post: await getPost(params.slug), comment: await loadComment(params.slug), // awaited — blocks the WHOLE page on a 2-second comment };}
declare function getPost(slug: string): Promise<{ title: string }>;Every field a load awaits delays the entire response by however long the slowest one takes — a generated load that reflexively awaits every value it returns, including a slow, non-critical comment thread, ships nothing to the browser until the slowest piece resolves.
Symptom — real timing proof, from returning the promise unawaited instead (see the fix):
$ node stream-timing.mjst=36ms first bytes arrivedt=36ms saw "loading comment…" in the stream (pending state)t=2034ms saw resolved comment text in the streamt=2035ms stream endedWith everything awaited as in the buggy version above, none of that shell arrives until t=2034ms — the initial HTML is held hostage by the slowest field.
Fix: return the non-critical promise unawaited, and consume it with {#await}:
/** @param {string} slug */function loadComment(slug) { return new Promise((resolve) => { setTimeout(() => resolve(`a slow comment on ${slug}, arrived late`), 2000); });}
/** @param {string} slug */async function getPost(slug) { return { title: `post ${slug}` }; // stand-in for a real lookup}
export async function load({ params }) { return { post: await getPost(params.slug), // needed for the first paint — still awaited comment: loadComment(params.slug), // NOT awaited — streams in later };}<script lang="ts"> let { data } = $props();</script>
<h1>{data.post.title}</h1>
{#await data.comment} <p>Loading comment…</p>{:then comment} <p>{comment}</p>{/await}Caught by: nothing automatic — a needlessly awaited field is not a compiler concern; only real timing (proven above) surfaces the cost. See Loading Data for the full streaming proof, including a curl measurement of bytes-on-the-wire at 300ms.
24. $env/static/private imported into a universal +page.js load
Section titled “24. $env/static/private imported into a universal +page.js load”// @expect-error real build error — see prose// src/routes/secret-leak/+page.js — as generatedimport { SECRET_API_KEY } from '$env/static/private';
export function load() { return { key: SECRET_API_KEY };}+page.js is a universal load — it runs on the server for the first request, but also runs again in the browser on every later client-side navigation. $env/static/private only exists for server-only code; a universal load importing it is not a runtime undefined, it’s a real, build-time failure the moment the client bundle tries to include it.
Symptom — the real, verbatim build error, from a real vite build:
[plugin vite-plugin-sveltekit-guard]Error: Cannot import $env/static/private into code that runs in the browser, as this could leak sensitive information.
src/routes/secret-leak/+page.js imports $env/static/private
If you're only using the import as a type, change it to `import type`.Fix: move the code to a +page.server.js, which never ships to the browser:
// src/routes/secret-leak/+page.server.js — illustrative (the shared probe's own .env has no SECRET_API_KEY)import { SECRET_API_KEY } from '$env/static/private';
export function load() { return { key: SECRET_API_KEY };}Caught by: SvelteKit’s own Vite plugin, at build time — proven above with a real build failure, not a lint warning. svelte-check alone does not catch this (it type-checks fine either way); only an actual vite build/astro-style production build surfaces it. See Server & API for the static-vs-dynamic env split this entry depends on, and Loading Data for the universal-vs-server-only load distinction.
25. A form action returning a plain object instead of fail()
Section titled “25. A form action returning a plain object instead of fail()”// src/routes/settings/+page.server.js — as generatedexport const actions = { default: async ({ request }: { request: Request }) => { const data = await request.formData(); const name = data.get('name');
if (!name) { return { error: 'name is required' }; // looks like a validation error... }
return { ok: true }; },};This lands in the page’s form prop exactly the way fail() data would — {#if form?.error} renders correctly — so this bug is easy to miss in a quick read. But the HTTP response status stays 200 OK instead of the 400 a validation failure should carry, breaking anything that branches on status (use:enhance’s own result.type checks, which expect "failure" only from a real fail() call, an analytics pipeline, a retry policy).
Symptom — the shape looks identical in the UI, but the status does not: a plain object returned from an action reaches the form prop the same way fail() data would, but the response status is 200, not 400 — proven by contrast with the fix below, which is the only thing that changes.
Fix: fail(status, data) — same shape in the form prop, correct HTTP semantics underneath:
import { fail } from '@sveltejs/kit';
export const actions = { /** @param {{ request: Request }} event */ default: async ({ request }) => { const data = await request.formData(); const name = data.get('name');
if (!name) { return fail(400, { error: 'name is required' }); }
return { ok: true }; },};Caught by: nothing automatic — no type error, no lint rule distinguishes a plain object from fail() data; both satisfy the same loose return type. See Forms & Actions for the full proof.
🐛 Spot the bug
export const actions = {default: async ({ request, cookies }) => { const data = await request.formData(); try { const user = await login(data.get('email'), data.get('password')); cookies.set('session', user.token, { path: '/' }); redirect(303, '/dashboard'); } catch (e) { return fail(401, { error: 'invalid credentials' }); }},};Show the answer
This combines entry 22’s bug with a login action: redirect(303, ...) is called inside the same try block as the login call it’s meant to follow on success — so the moment login succeeds and redirect() throws its special control-flow object, this catch block swallows it exactly like it would a real login failure, and reports “invalid credentials” to a user who actually logged in correctly. The fix is the same rethrow proven in entry 22: import { isRedirect } from '@sveltejs/kit', and if (isRedirect(e)) throw e; as the very first line of the catch block — or, simpler here, move redirect(303, '/dashboard') outside the try/catch entirely, after the cookie is set, so only the login() call itself is ever subject to this catch.
Keys, transitions, and boundaries
Section titled “Keys, transitions, and boundaries”26. Keyless {#each} with stateful children
Section titled “26. Keyless {#each} with stateful children”<script lang="ts"> import Row from './Row.svelte'; let { items }: { items: string[] } = $props();</script><ul> {#each items as item} <Row label={item} /> {/each}</ul><script lang="ts"> let { label }: { label: string } = $props(); let expanded = $state(false);</script><li> <button data-testid="toggle" onclick={() => (expanded = !expanded)}>{label}</button> {#if expanded}<span data-testid="detail">detail for {label}</span>{/if}</li>Without a key, {#each} reuses each list position’s existing component instance — and whatever local state it holds — by position, not by the data’s identity. Row owns its own expanded toggle; removing an item from the front of the list doesn’t destroy that item’s Row instance, it just feeds a different label into the instance that used to be at that position, dragging the stale expanded state along with it.
Symptom — proven directly, contrasted with the fix:
import { test, expect } from 'vitest';import { render, fireEvent } from '@testing-library/svelte';import NoKeyList from './NoKeyList.svelte';import KeyedList from './KeyedList.svelte';
test('BUG: without a key, removing the first item shifts each Row\'s internal `expanded` state to the WRONG row', async () => { const { getAllByTestId, rerender } = render(NoKeyList, { items: ['a', 'b', 'c'] }); // Expand the FIRST row ("a")'s detail. await fireEvent.click(getAllByTestId('toggle')[0]); expect(getAllByTestId('detail')).toHaveLength(1); expect(getAllByTestId('toggle')[0].textContent).toBe('a');
// Remove "a" from the front of the list — "b" is now first. await rerender({ items: ['b', 'c'] });
// Without a key, Svelte reuses the Row COMPONENT INSTANCE at each list // position by index, not by identity — so the instance that was already // `expanded` (position 0) stays expanded, but now renders label "b". const toggles = getAllByTestId('toggle'); expect(toggles[0].textContent).toBe('b'); // "b" is now at position 0... expect(getAllByTestId('detail')).toHaveLength(1); // ...and INHERITED the stale expanded=true from "a"'s instance});
test('FIX: with a stable key `(item)`, the same case removes the right row\'s state cleanly, not the wrong one', async () => { const { getAllByTestId, queryAllByTestId, rerender } = render(KeyedList, { items: ['a', 'b', 'c'] }); await fireEvent.click(getAllByTestId('toggle')[0]); expect(getAllByTestId('detail')).toHaveLength(1);
await rerender({ items: ['b', 'c'] });
// "a"'s whole component instance (identity-keyed) was destroyed along with // its state; "b"'s own instance never had `expanded` set, so nothing is expanded. const toggles = getAllByTestId('toggle'); expect(toggles[0].textContent).toBe('b'); expect(queryAllByTestId('detail')).toHaveLength(0);});✓ BUG: without a key, removing the first item shifts each Row's internal `expanded` state to the WRONG row✓ FIX: with a stable key `(item)`, the same case removes the right row's state cleanly, not the wrong oneFix: a stable, data-derived key:
<script lang="ts"> import Row from './Row.svelte'; let { items }: { items: string[] } = $props();</script><ul> {#each items as item (item)} <Row label={item} /> {/each}</ul>Caught by: nothing automatic — no compiler error, no warning for a keyless {#each} over components with local state; only a running test (proven above) surfaces the swap. See Template Syntax for keyed lists in the general case.
27. await in a template/$derived without experimental.async
Section titled “27. await in a template/$derived without experimental.async”<!-- @expect-error real compile error — see prose --><!-- src/lib/AsyncSumBug.svelte — as generated --><script lang="ts"> let a = $state(1); let b = $state(2);
async function add(x: number, y: number) { await new Promise((r) => setTimeout(r, 300)); return x + y; }</script>
<p>{a} + {b} = {await add(a, b)}</p>await directly inside a template expression is valid, real Svelte async syntax — but only behind an explicit, experimental compiler flag. Nothing about this file’s own source signals that the flag is missing; a generated snippet that pattern-matches on “Svelte supports await in templates” without checking the project’s svelte.config.js produces a real compile error, not a silent synchronous fallback.
Symptom — the real, verbatim compiler error:
code: experimental_asyncmessage: Cannot use `await` in deriveds and template expressions, or at the top level of a component, unless the `experimental.async` compiler option is `true`https://svelte.dev/e/experimental_asyncFix: the code itself doesn’t change — the project’s compiler options do:
export default { compilerOptions: { experimental: { async: true } }};<script lang="ts"> let a = $state(1); let b = $state(2);
async function add(x: number, y: number) { await new Promise((r) => setTimeout(r, 300)); return x + y; }</script>
<p>{a} + {b} = {await add(a, b)}</p>Caught by: svelte-check/the compiler, immediately — a real compile error naming the exact missing flag. See Async Svelte for the full proof, including the explicit “this is still experimental” caveat and the <svelte:boundary pending>/$effect.pending() split.
28. reset() called synchronously inside <svelte:boundary onerror>
Section titled “28. reset() called synchronously inside <svelte:boundary onerror>”<!-- @expect-error real, thrown error — see prose --><!-- src/lib/ResetSyncHost.svelte — as generated --><script lang="ts"> import Flaky from './Flaky.svelte'; let shouldThrow = $state(true);</script><svelte:boundary onerror={(error, reset) => { shouldThrow = false; reset(); // BUG: called synchronously inside onerror}}> <Flaky {shouldThrow} /></svelte:boundary>reset() cannot be called synchronously inside onerror, while the boundary is still mid-handling the error the callback was invoked for. A generated retry-on-error pattern that “fixes the cause, then immediately resets” inside the same callback throws instead of recovering.
Symptom — the real, verbatim runtime error:
svelte_boundary_reset_onerrorA `<svelte:boundary>` `reset` function cannot be called while an error is still being handledhttps://svelte.dev/e/svelte_boundary_reset_onerrorFix: resolve the cause somewhere else — a button click, or await tick() first if the fix truly has to happen inside onerror:
<script lang="ts"> import Flaky from './Flaky.svelte'; let shouldThrow = $state(true); let events: string[] = [];</script><svelte:boundary onerror={() => events.push('onerror')}> <Flaky {shouldThrow} {events} />
{#snippet failed(error, reset)} <p>{(error as Error).message}</p> <button onclick={() => { shouldThrow = false; reset(); }}>retry</button> {/snippet}</svelte:boundary>Caught by: <svelte:boundary>’s own runtime check, immediately — a real, named error, not a silent no-op. See Lifecycle for the full proof, including the working failed/reset() pattern this fix is built on.
Mental Model
Section titled “Mental Model”Reading order for the 28 patterns above:
1-4 Runes vs legacy mode -> is every reactivity primitive even legal here?5-14 State ownership -> does $state/$derived/$effect/a store each do its own job?15-18 Props/bindings/snippets -> does bind:/{@render} match the contract the other side assumes?19-20 Events and DOM -> does an action/listener actually re-run or fire when it should?21-25 SvelteKit boundary -> does server-only code leak client-side, or a throw get swallowed?26-28 Keys, transitions, boundaries -> does list/async/error state follow the right subject?
Almost none of these are caught by svelte-check alone — most compile clean, and several(entries 4, 7, 9, 16, 20, 23, 25) produce no compiler signal of any kind.