รีวิวเคสจริง 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 จริง
โค้ดที่ AI สร้างให้
หัวข้อที่มีชื่อว่า “โค้ดที่ AI สร้างให้”// src/lib/dashboard/types.ts — as generatedexport 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 generatedexport 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} />ไล่รีวิวทีละบั๊ก
หัวข้อที่มีชื่อว่า “ไล่รีวิวทีละบั๊ก”Props, bindings, snippets: bind: ไปที่ prop ที่ไม่ใช่ $bindable
หัวข้อที่มีชื่อว่า “Props, bindings, snippets: bind: ไปที่ prop ที่ไม่ใช่ $bindable”RangeFilter ไม่เคยให้ prop min ของมัน opt-in เป็น two-way binding แต่ Dashboard กลับ bind เข้าไปเฉย ๆ
🐛 หา bug ในโค้ดนี้
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 จะถูกกฎหมาย
Event กับ DOM: use: action ที่ไม่เคยรันซ้ำ
หัวข้อที่มีชื่อว่า “Event กับ DOM: use: action ที่ไม่เคยรันซ้ำ”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)
Props, bindings, snippets: await ในเทมเพลตโดยไม่มี experimental.async
หัวข้อที่มีชื่อว่า “Props, bindings, snippets: await ในเทมเพลตโดยไม่มี experimental.async”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 (แสดงในโค้ดที่แก้ไขแล้ว)
Key, transition, boundary: ไม่มี <svelte:boundary> ครอบ widget ที่ throw
หัวข้อที่มีชื่อว่า “Key, transition, boundary: ไม่มี <svelte:boundary> ครอบ widget ที่ throw”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} — ส่วนที่เหลือของแดชบอร์ดรอดไปตามปกติ
เจ้าของ state: $state.raw ถูก mutate แทนที่จะ reassign
หัวข้อที่มีชื่อว่า “เจ้าของ state: $state.raw ถูก mutate แทนที่จะ reassign”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 ตอบสนองด้วย
โค้ดที่แก้ไขแล้ว
หัวข้อที่มีชื่อว่า “โค้ดที่แก้ไขแล้ว”export type DataPoint = { label: string; value: number };<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} />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; }, };}<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><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><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><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} />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
สิ่งที่เปลี่ยนและเหตุผล
หัวข้อที่มีชื่อว่า “สิ่งที่เปลี่ยนและเหตุผล”| # | บั๊ก | หมวด | วิธีแก้ |
|---|---|---|---|
| 1 | bind:min ไปที่ prop ที่ไม่เคยเป็น $bindable() | Props/bindings/snippets | let { min = $bindable() } = $props() |
| 2 | use:tooltip={text} ไม่มี update() | Events & DOM | return { update(newText) {...} } จาก action |
| 3 | await ในเทมเพลตโดยไม่มี experimental.async | Props/bindings/snippets | เปิด flag ทั้งโปรเจกต์; ครอบ await ด้วย <svelte:boundary pending> |
| 4 | ไม่มี <svelte:boundary> ครอบ widget ที่ throw ได้ | Keys/transitions/boundaries | ครอบแค่ ChartWidget ด้วย boundary ที่มี {#snippet failed} |
| 5 | object $state.raw ถูก mutate ตรง ๆ | State ownership | reassign object ใหม่แทนที่จะ mutate ตัวเดิม |
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกบั๊กในรีวิวนี้คือการละเมิดสัญญา (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-checkerror จริง: prop ไหลทางเดียวโดย default และbind:ไปยังมันจะถูกปฏิเสธเว้นแต่ child จะเขียน$bindable() - “argument ของ
use:action reactive อัตโนมัติเหมือน prop” พิสูจน์แล้วว่าผิด: ถ้าไม่มี methodupdate()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"]