ข้ามไปยังเนื้อหา

รีวิวเคสจริง 2: แดชบอร์ดที่มีการค้นหา กราฟ และข้อมูล async

โจทย์ที่ให้ AI คือ “สร้างแดชบอร์ดที่มี slider สำหรับกรองข้อมูลตามเกณฑ์ (threshold), chart widget ที่โชว์จุดข้อมูลสูงสุด, tooltip บน slider, สถิติ ‘ยอดรวม’ แบบ async, และการตั้งค่า legend ที่ผู้ใช้สลับได้” แบบร่างนี้พึ่งพา 4 ฟีเจอร์ของ Svelte 5 ที่ AI มักหยิบมาใช้บ่อย ๆ — bind:, action use:, $state.raw, และ await ในเทมเพลต — แล้วทำ*สัญญา (contract)*ของแต่ละตัวผิดไปนิดหน่อย อ่านตามลำดับการอ่านของโมดูลนี้: หลังจาก runes เทียบกับ legacy และเจ้าของ state (รีวิวก่อนหน้านี้) สองขั้นถัดไปคือ props/bindings/snippets และ event/DOM — ซึ่งเป็นที่อยู่ของ 4 ใน บั๊กจริง 5 จุด นี้พอดี สองจุดเป็น compile error จริง

// src/lib/dashboard/types.ts — as generated
export type DataPoint = { label: string; value: number };
<!-- src/lib/dashboard/RangeFilter.svelte — as generated -->
<script lang="ts">
let { min }: { min: number } = $props();
</script>
<input type="range" bind:value={min} min="0" max="100" />
// src/lib/dashboard/tooltip.ts — as generated
export function tooltip(node: HTMLElement, text: string) {
node.title = text; // set once at mount — nothing notices a later change
}
<!-- src/lib/dashboard/ChartWidget.svelte — as generated -->
<script lang="ts">
import type { DataPoint } from './types';
let { points }: { points: DataPoint[] } = $props();
let max = $derived(Math.max(...points.map((p) => p.value)));
let top = $derived(points.find((p) => p.value === max)!); // undefined when points is empty
</script>
<p>{top.label}</p>
<!-- src/lib/dashboard/ChartSettings.svelte — as generated -->
<script lang="ts">
let options = $state.raw({ color: 'teal', showLegend: true });
function toggleLegend() {
options.showLegend = !options.showLegend; // mutation — $state.raw is NOT deep-reactive
}
</script>
<button onclick={toggleLegend}>Toggle legend</button>
<p>{options.showLegend ? 'legend on' : 'legend off'}</p>
<!-- src/lib/dashboard/AsyncStat.svelte — as generated -->
<script lang="ts">
let { load }: { load: () => Promise<number> } = $props();
</script>
<p>Total: {await load()}</p>
<!-- src/lib/dashboard/Dashboard.svelte — as generated -->
<script lang="ts">
import type { DataPoint } from './types';
import { tooltip } from './tooltip';
import RangeFilter from './RangeFilter.svelte';
import ChartWidget from './ChartWidget.svelte';
import ChartSettings from './ChartSettings.svelte';
import AsyncStat from './AsyncStat.svelte';
let { points, load }: { points: DataPoint[]; load: () => Promise<number> } = $props();
let threshold = $state(0);
let visible = $derived(points.filter((p) => p.value >= threshold));
</script>
<h2>Dashboard</h2>
<div use:tooltip={`${visible.length} of ${points.length} points shown`}>
<RangeFilter bind:min={threshold} />
</div>
<ChartWidget points={visible} />
<ChartSettings />
<AsyncStat {load} />

RangeFilter ไม่เคยให้ prop min ของมัน opt-in เป็น two-way binding แต่ Dashboard กลับ bind เข้าไปเฉย ๆ

🐛 หา bug ในโค้ดนี้

RangeFilter.svelte
let { min }: { min: number } = $props();
<!-- Dashboard.svelte -->
<RangeFilter bind:min={threshold} />
เฉลย

prop ไหลทางเดียวเสมอ เว้นแต่ child จะเปิดช่องทาง two-way ให้ชัดเจนด้วย $bindable() นี่คือ svelte-check error จริงที่บอกวิธีแก้ตรง ๆ — ไม่ใช่ no-op เงียบ ๆ:

Error: Cannot use 'bind:' with this property. It is declared as non-bindable inside the component.
To mark a property as bindable: 'let { min = $bindable() } = $props()' (ts)

วิธีแก้คือ let { min = $bindable() }: { min: number } = $props(); ข้างใน RangeFilter — พิสูจน์แล้วในบท Bindings ว่าจำเป็นก่อนที่ bind: ไปยัง prop ของ component จะถูกกฎหมาย

tooltip ตั้ง node.title แค่ครั้งเดียวตอน mount จาก text ที่เป็นอยู่ ณ ตอนนั้น

🐛 หา bug ในโค้ดนี้

export function tooltip(node: HTMLElement, text: string) {
node.title = text; // set once at mount
}
เฉลย

function body ของ action รันแค่ครั้งเดียวเป๊ะ ๆ ตอน element mount นอกจากมันจะ return method update(newArg) ไม่งั้น Svelte ไม่มีทางรู้เลยว่า argumentเปลี่ยนได้ด้วยซ้ำ — และ visible.length ตรงนี้เปลี่ยนทุกครั้งที่ slider threshold ขยับ

✓ TooltipButtonBad: never updates node.title after the text prop changes — no update() on the action
Test Files 1 passed (1)
Tests 1 passed (1)

วิธีแก้คือ return { update(newText) { node.title = newText; } } พิสูจน์แล้วในบท Actions ว่าเป็นสิ่งเดียวที่ทำให้ argument ของ action reactive จริง (attachment @attach ได้สิ่งนี้มาฟรี ๆ รันซ้ำทุกครั้งที่ค่า reactive ที่มันอ่านเปลี่ยน ซึ่งเป็นเหตุผลที่บท Actions ของคอร์สนี้แนะนำ attachment สำหรับโค้ดใหม่ — use: ยังคงไว้สำหรับ interop กับ library)

AsyncStat await ตรง ๆ ใน template expression

🐛 หา bug ในโค้ดนี้

<p>Total: {await load()}</p>
เฉลย

await ในเทมเพลต, ใน $derived, หรือระดับบนสุดของ component เป็น compile error จริงเว้นแต่โปรเจกต์จะเปิด compiler flag แบบทดลอง (experimental) — โค้ดที่ AI สร้างส่วนใหญ่ไม่เคยพูดถึงมันเลย เพราะมันเกิดก่อนฟีเจอร์นี้จะมี:

Error: 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_async

พอเปิด experimental.async: true (vite.config.ts ของคอร์สนี้เองเปิดไว้ทั้งโปรเจกต์อยู่แล้ว เหมือนที่บท Async Svelte พิสูจน์ไว้) source เดียวกันเป๊ะก็คอมไพล์ผ่านโดยไม่มี error หรือ warning เลย วิธีแก้ตรงนี้ไม่ใช่การแก้ component เลย — แต่คือการทำให้แน่ใจว่าโปรเจกต์ที่ AI สร้างโค้ดนี้ให้เปิด flag ไว้จริง ๆ แล้วครอบ await ด้วย <svelte:boundary pending> สำหรับ loading UI (แสดงในโค้ดที่แก้ไขแล้ว)

ChartWidget throw เมื่อ points ว่างเปล่า (Math.max() ของอาเรย์ว่างคือ -Infinity ไม่มี point ไหน value เท่านั้น เลย .find() ได้ undefined แล้ว top.label ก็ throw) Dashboard render มันโดยไม่มี boundary เลย

🐛 หา bug ในโค้ดนี้

<h2>Dashboard</h2>
<ChartWidget points={visible} />
<!-- no <svelte:boundary> anywhere -->
เฉลย

โดย default, error ที่ถูก throw ระหว่าง render จะ unmount root ที่ใกล้ที่สุดทั้งก้อน ไม่ใช่แค่ widget ที่พัง — ผลการกรองที่ว่างเปล่า (สถานะปกติที่เข้าถึงได้จริง ไม่ใช่ edge case) จะดึงทั้งแดชบอร์ดลงไปด้วย รวมถึง slider กับ async stat ที่ไม่เกี่ยวอะไรกับกราฟเลย

✓ DashboardNoBoundaryBad: an empty points array crashes the WHOLE dashboard, uncaught
Test Files 1 passed (1)
Tests 1 passed (1)

วิธีแก้ พิสูจน์แล้วในบท Lifecycle ที่ครอบคลุม <svelte:boundary> แบบเต็ม คือครอบแค่ ChartWidget ด้วย boundary ที่มี {#snippet failed} — ส่วนที่เหลือของแดชบอร์ดรอดไปตามปกติ

ChartSettings mutate property ของ object $state.raw ตรง ๆ

🐛 หา bug ในโค้ดนี้

let options = $state.raw({ color: 'teal', showLegend: true });
function toggleLegend() {
options.showLegend = !options.showLegend; // mutation
}
เฉลย

$state.raw ปฏิเสธ Proxy แบบ deep-reactive — พิสูจน์แล้วในบท $state Rune ว่ามีแค่การreassign ทั้ง cellเท่านั้นที่นับเป็นการเขียน การ mutate เนื้อหาข้างในเปลี่ยนข้อมูลในหน่วยความจำจริง แต่ไม่แจ้งอะไรเลย UI จึงไม่อัปเดตเงียบ ๆ:

✓ ChartSettingsBad: mutating $state.raw in place does not trigger a re-render
Test Files 1 passed (1)
Tests 1 passed (1)

วิธีแก้คือ options = { ...options, showLegend: !options.showLegend } — object ใหม่เอี่ยม ซึ่ง $state.raw ตอบสนองด้วย

src/lib/dashboard/types.ts
export type DataPoint = { label: string; value: number };
src/lib/dashboard/ThresholdSlider.svelte
<script lang="ts">
// FIX (bindings): $bindable() grants the two-way surface bind: assumes.
let { min = $bindable() }: { min: number } = $props();
</script>
<input type="range" data-testid="threshold" min="0" max="100" bind:value={min} />
src/lib/dashboard/tooltip.ts
export function tooltip(node: HTMLElement, text: string) {
node.title = text;
// FIX (events & DOM): update() is what makes the argument reactive.
return {
update(newText: string) {
node.title = newText;
},
};
}
src/lib/dashboard/ChartWidget.svelte
<script lang="ts">
import type { DataPoint } from './types';
let { points }: { points: DataPoint[] } = $props();
let max = $derived(Math.max(...points.map((p) => p.value)));
let top = $derived(points.find((p) => p.value === max)!);
</script>
<p data-testid="top-label">{top.label}</p>
src/lib/dashboard/ChartSettings.svelte
<script lang="ts">
let options = $state.raw({ color: 'teal', showLegend: true });
function toggleLegend() {
// FIX (state ownership): reassign a NEW object — $state.raw only reacts
// to reassignment, never to a mutation of the existing object.
options = { ...options, showLegend: !options.showLegend };
}
</script>
<button data-testid="toggle" onclick={toggleLegend}>Toggle legend</button>
<p data-testid="legend">{options.showLegend ? 'legend on' : 'legend off'}</p>
src/lib/dashboard/AsyncStat.svelte
<script lang="ts">
let { load }: { load: () => Promise<number> } = $props();
</script>
<!-- FIX (props/bindings/snippets + keys/boundaries): a boundary with a
pending snippet gates the await — needs experimental.async: true,
already set project-wide in this course's vite.config.ts. -->
<svelte:boundary>
<p data-testid="total">Total: {await load()}</p>
{#snippet pending()}
<p data-testid="loading">Loading…</p>
{/snippet}
</svelte:boundary>
src/lib/dashboard/Dashboard.svelte
<script lang="ts">
import type { DataPoint } from './types';
import { tooltip } from './tooltip';
import ThresholdSlider from './ThresholdSlider.svelte';
import ChartWidget from './ChartWidget.svelte';
import ChartSettings from './ChartSettings.svelte';
import AsyncStat from './AsyncStat.svelte';
let { points, load }: { points: DataPoint[]; load: () => Promise<number> } = $props();
let threshold = $state(0);
let visible = $derived(points.filter((p) => p.value >= threshold));
</script>
<h2>Dashboard</h2>
<div use:tooltip={`${visible.length} of ${points.length} points shown`}>
<ThresholdSlider bind:min={threshold} />
</div>
<!-- FIX (keys/transitions/boundaries): only the widget that can throw is
inside the boundary — the rest of the dashboard survives its failure. -->
<svelte:boundary onerror={() => {}}>
<ChartWidget points={visible} />
{#snippet failed(error: unknown, reset: () => void)}
<p data-testid="chart-error">Could not display the chart.</p>
{/snippet}
</svelte:boundary>
<ChartSettings />
<AsyncStat {load} />
src/lib/dashboard/dashboard.svelte.test.ts
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import ThresholdSlider from './ThresholdSlider.svelte';
import ChartWidget from './ChartWidget.svelte';
import ChartSettings from './ChartSettings.svelte';
import AsyncStat from './AsyncStat.svelte';
describe('ThresholdSlider (fixed): $bindable() lets the parent bind: to it', () => {
it('a slider change propagates back to the bound parent variable', async () => {
let min = $state(0);
render(ThresholdSlider, { props: { get min() { return min; }, set min(v) { min = v; } } });
await fireEvent.input(screen.getByTestId('threshold'), { target: { value: '42' } });
expect(min).toBe(42);
});
});
describe('tooltip action (fixed): update() re-runs when the argument changes', () => {
it('node.title tracks the latest text via update()', async () => {
const { tooltip } = await import('./tooltip');
const node = document.createElement('div');
const action = tooltip(node, 'first');
expect(node.title).toBe('first');
action?.update?.('second');
expect(node.title).toBe('second');
});
});
describe('ChartSettings (fixed): reassigning $state.raw triggers a re-render', () => {
it('toggling legend flips the rendered text', async () => {
render(ChartSettings);
expect(screen.getByTestId('legend').textContent).toBe('legend on');
await fireEvent.click(screen.getByTestId('toggle'));
expect(screen.getByTestId('legend').textContent).toBe('legend off');
});
});
describe('ChartWidget inside a <svelte:boundary>: contained, not fatal', () => {
it('an empty points array still throws (the widget itself is unchanged)', () => {
expect(() => render(ChartWidget, { props: { points: [] } })).toThrow();
});
});
describe('AsyncStat (fixed): await in the template, gated by experimental.async', () => {
it('shows the pending snippet, then the resolved total', async () => {
let resolveLoad!: (v: number) => void;
const load = () => new Promise<number>((resolve) => { resolveLoad = resolve; });
render(AsyncStat, { props: { load } });
expect(screen.getByTestId('loading')).toBeInTheDocument();
resolveLoad(123);
await vi.waitFor(() => expect(screen.getByTestId('total').textContent).toBe('Total: 123'));
});
});
✓ ThresholdSlider (fixed): $bindable() lets the parent bind: to it > a slider change propagates back to the bound parent variable
✓ tooltip action (fixed): update() re-runs when the argument changes > node.title tracks the latest text via update()
✓ ChartSettings (fixed): reassigning $state.raw triggers a re-render > toggling legend flips the rendered text
✓ ChartWidget inside a <svelte:boundary>: contained, not fatal > an empty points array still throws (the widget itself is unchanged)
✓ AsyncStat (fixed): await in the template, gated by experimental.async > shows the pending snippet, then the resolved total
Test Files 1 passed (1)
Tests 5 passed (5)

svelte-check บนทุกไฟล์ที่แก้ไขแล้วด้านบน: 0 errors and 0 warnings

#บั๊กหมวดวิธีแก้
1bind:min ไปที่ prop ที่ไม่เคยเป็น $bindable()Props/bindings/snippetslet { min = $bindable() } = $props()
2use:tooltip={text} ไม่มี update()Events & DOMreturn { update(newText) {...} } จาก action
3await ในเทมเพลตโดยไม่มี experimental.asyncProps/bindings/snippetsเปิด flag ทั้งโปรเจกต์; ครอบ await ด้วย <svelte:boundary pending>
4ไม่มี <svelte:boundary> ครอบ widget ที่ throw ได้Keys/transitions/boundariesครอบแค่ ChartWidget ด้วย boundary ที่มี {#snippet failed}
5object $state.raw ถูก mutate ตรง ๆState ownershipreassign object ใหม่แทนที่จะ mutate ตัวเดิม

ทุกบั๊กในรีวิวนี้คือการละเมิดสัญญา (contract) ไม่ใช่ความผิดพลาดด้าน syntax: bind: สมมติว่า child opt-in แล้ว, use: สมมติว่า action รันซ้ำเหมือน prop reactive, await ในเทมเพลตสมมติว่า experimental flag เปิดอยู่ และการ mutate $state.raw สมมติว่ามันได้ reactivity แบบเดียวกับ $state มาฟรี ๆ สองในห้าสัญญาถูกบังคับโดย compiler เอง (bind, await) อีกสามที่เหลือเงียบสนิท มีแค่ probe ที่รันจริงเท่านั้น — reorder จริง, นับ re-render จริง, throw จริง — ถึงจะเผยว่าสัญญาไหนถูกละเมิด

  • “bind: ใช้ได้เสมอถ้า child มี prop ชื่อนั้น” พิสูจน์แล้วว่าผิดด้วย svelte-check error จริง: prop ไหลทางเดียวโดย default และ bind: ไปยังมันจะถูกปฏิเสธเว้นแต่ child จะเขียน $bindable()
  • “argument ของ use: action reactive อัตโนมัติเหมือน prop” พิสูจน์แล้วว่าผิด: ถ้าไม่มี method update() function body ของ action จะรันแค่ครั้งเดียวตอน mount แล้วไม่รันอีกเลย
  • “await ในเทมเพลตใช้ได้เลยในทุกโปรเจกต์ Svelte 5” พิสูจน์แล้วว่าผิดด้วย compiler error จริง: ต้องเปิด experimental.async: true อย่างชัดเจน และยังถูกระบุว่าเป็น experimental อยู่
  • “ถ้า widget throw ได้ ครอบทั้งหน้าด้วย <svelte:boundary> เดียวก็พอ” จริงในทางเทคนิค แต่หมายความว่าความล้มเหลวที่ไหนก็ได้จะ unmount ทั้งหน้า วิธีแก้ในรีวิวนี้จำกัดขอบเขต boundary ไว้แค่ widget ที่พังได้จริง ๆ
  • “$state.raw ยัง mutate nested property ได้ แค่ข้าม deep proxy เพื่อ performance” พิสูจน์แล้วว่าผิด: การ mutate มันไม่ใช่แค่ “optimize น้อยกว่า” แต่ระบบ reactivity เพิกเฉยต่อมันโดยสิ้นเชิง
flowchart TD
  A["3. Props, bindings, snippets"] --> A1["bind: to a non-$bindable prop"]
  A --> A2["await in a template, no experimental.async"]
  B["4. Events and the DOM"] --> B1["use: action with no update()"]
  C["6. Keys, transitions, boundaries"] --> C1["no <svelte:boundary> around a throwing widget"]
  D["2. State ownership"] --> D1["$state.raw mutated instead of reassigned"]
แต่ละบั๊กอยู่ตรงไหนในลำดับการอ่าน
เกิดอะไรขึ้นเมื่อคอมไพล์ ถ้า RangeFilter ไม่เคยเขียน $bindable()?
<!-- RangeFilter.svelte -->
let { min } = $props();

<!-- Dashboard.svelte -->
<RangeFilter bind:min={threshold} />
เกิดอะไรขึ้นกับ node.title หลังจาก text เปลี่ยนใน render ถัดไป?
export function tooltip(node, text) {
  node.title = text;
}
<p>Total: {await load()}</p> ถูกคอมไพล์โดยไม่มี experimental.async เปิดไว้ที่ไหนเลย ผลลัพธ์คืออะไร?
ChartWidget ที่ throw เมื่อ points ว่างเปล่า ถูก render โดยไม่มี <svelte:boundary> เลยบนหน้า เกิดอะไรขึ้น?
เกิดอะไรขึ้นกับ UI หลังเรียก toggle()?
let options = $state.raw({ showLegend: true });
function toggle() {
  options.showLegend = !options.showLegend;
}
ทำไมรีวิวนี้ถึงบอกว่าสองในห้าบั๊กเป็น "compile error จริง" ส่วนอีกสามจุดเป็น "เงียบสนิท"?