Hydration & SSR
บท Fine-Grained DOM พูดถึงการ mount เข้า target ที่ว่างเปล่า บทนี้พูดถึงอีกกรณี: DOM ที่มีอยู่แล้ว ส่งลงมาจาก server ซึ่ง Svelte ต้องต่อ signal graph ของมันเข้าไปโดยไม่ทิ้งแล้วสร้างใหม่ — แล้วจริง ๆ เกิดอะไรขึ้นเมื่อทั้งสองฝั่งไม่ตรงกัน
render() จาก svelte/server: marker จริง ไม่ใช่คำเปรียบเทียบ
หัวข้อที่มีชื่อว่า “render() จาก svelte/server: marker จริง ไม่ใช่คำเปรียบเทียบ”import { compile } from 'svelte/compiler';import { render } from 'svelte/server';import { writeFileSync, mkdirSync } from 'node:fs';import { fileURLToPath } from 'node:url';import path from 'node:path';import { expect, test } from 'vitest';
const dir = path.dirname(fileURLToPath(import.meta.url));const compiledDir = path.join(dir, '.compiled');mkdirSync(compiledDir, { recursive: true });
async function compileAndImport(src: string, generate: 'client' | 'server', tag: string) { const result = compile(src, { generate, runes: true, filename: 'Playground.svelte' }); const file = path.join(compiledDir, `${tag}-${generate}-${Date.now()}-${Math.random()}.mjs`); writeFileSync(file, result.js.code); const mod = await import(/* @vite-ignore */ file); return mod.default;}
test('render() from svelte/server produces real hydration marker comments', async () => { const Server = await compileAndImport( `<script> let count = $state(0); </script> <button onclick={() => count++}>clicks: {count}</button>`, 'server', 'markers', ); const { body } = render(Server); expect(body).toBe('<!--[--><button>clicks: 0</button><!--]-->');});✓ render() from svelte/server produces real hydration marker comments
Test Files 1 passed (1) Tests 1 passed (1)<!--[-->/<!--]--> ไม่ใช่คำย่อของ documentation — มันคือ HTML comment node จริงที่ Svelte emit รอบ output ของทุก component/block เพื่อให้ client หาจุดเริ่มและจุดจบของ block ได้เป๊ะโดยไม่ต้องรู้อะไรเลยเกี่ยวกับเนื้อหาระหว่างกลาง hydrate_node ของ dom/hydration.js เป็น pointer ตัวเดียวที่ mutate ได้ เริ่มที่ comment เปิดนั้นแล้วเดินหน้าด้วย get_next_sibling ตามที่ compiled client code เรียก $.child(...)/$.sibling(...) — ฟังก์ชันตัวเดียวกันเป๊ะกับที่มันจะเรียกตอนเดิน template ที่เพิ่ง clone ใหม่ (ดู from_html ใน Fine-Grained DOM) hydrating เป็น boolean ระดับ module ที่สลับฟังก์ชันพวกนั้นจาก “clone node ใหม่” เป็น “เดิน node ที่มีอยู่แล้ว”:
// packages/svelte/src/internal/client/dom/template.js — from_html, the hydrating branchreturn () => { if (hydrating) { assign_nodes(hydrate_node, null); return hydrate_node; // reuse what the server sent, don't clone anything } // ...cloneNode(true) path, covered in Fine-Grained DOM};hydrate(): reuse node ของ server ไม่ mount ใหม่
หัวข้อที่มีชื่อว่า “hydrate(): reuse node ของ server ไม่ mount ใหม่”import { compile } from 'svelte/compiler';import { render } from 'svelte/server';import { hydrate, flushSync } from 'svelte';import { writeFileSync, mkdirSync } from 'node:fs';import { fileURLToPath } from 'node:url';import path from 'node:path';import { expect, test, vi } from 'vitest';
const dir = path.dirname(fileURLToPath(import.meta.url));const compiledDir = path.join(dir, '.compiled');mkdirSync(compiledDir, { recursive: true });
async function compileAndImport(src: string, generate: 'client' | 'server', tag: string) { const result = compile(src, { generate, runes: true, filename: 'Playground.svelte' }); const file = path.join(compiledDir, `${tag}-${generate}-${Date.now()}-${Math.random()}.mjs`); writeFileSync(file, result.js.code); const mod = await import(/* @vite-ignore */ file); return mod.default;}
const SRC = `<script> let count = $state(0);</script><button onclick={() => count++}>clicks: {count}</button>`;
test('hydrate() reuses the SAME node the server sent, and it is immediately interactive', async () => { const Server = await compileAndImport(SRC, 'server', 'reuse'); document.body.innerHTML = render(Server).body; const serverButton = document.querySelector('button')!;
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const Client = await compileAndImport(SRC, 'client', 'reuse'); hydrate(Client, { target: document.body });
expect(document.querySelector('button')).toBe(serverButton); // same node, not replaced expect(warn).not.toHaveBeenCalled();
serverButton.click(); flushSync(); expect(serverButton.textContent).toBe('clicks: 1'); warn.mockRestore();});✓ hydrate() reuses the SAME node the server sent, and it is immediately interactive
Test Files 1 passed (1) Tests 1 passed (1)Mismatch: สิ่งที่เกิดขึ้นจริงน่าประหลาดใจกว่า “มัน warn แล้ว re-render”
หัวข้อที่มีชื่อว่า “Mismatch: สิ่งที่เกิดขึ้นจริงน่าประหลาดใจกว่า “มัน warn แล้ว re-render””การเดาตามธรรมชาติคือ “Svelte เทียบ HTML ของ server กับสิ่งที่ client จะ render แล้ว warn ถ้าต่างกัน” พฤติกรรมจริง — พิสูจน์สามแบบข้างล่าง — แคบกว่าและน่าสนใจกว่านั้น
1. Mismatch ข้างใน reactive block ({#if}/{#each}) ถูกแก้ไขแบบเงียบ ๆ โดย reactivity ธรรมดา — ไม่มี warning เลย Server render {#if} branch true; state จริงของ client บอกว่า false:
import { compile } from 'svelte/compiler';import { render } from 'svelte/server';import { hydrate } from 'svelte';import { writeFileSync, mkdirSync } from 'node:fs';import { fileURLToPath } from 'node:url';import path from 'node:path';import { expect, test, vi } from 'vitest';
const dir = path.dirname(fileURLToPath(import.meta.url));const compiledDir = path.join(dir, '.compiled');mkdirSync(compiledDir, { recursive: true });
async function compileAndImport(src: string, generate: 'client' | 'server', tag: string) { const result = compile(src, { generate, runes: true, filename: 'Playground.svelte' }); const file = path.join(compiledDir, `${tag}-${generate}-${Date.now()}-${Math.random()}.mjs`); writeFileSync(file, result.js.code); const mod = await import(/* @vite-ignore */ file); return mod.default;}
test('{#if} branch mismatch: no warning — the block\'s own reactivity switches to the correct branch', async () => { const trueSrc = `<script>let flag = $state(true);</script>\n{#if flag}<p>yes</p>{:else}<p>no</p>{/if}`; const falseSrc = `<script>let flag = $state(false);</script>\n{#if flag}<p>yes</p>{:else}<p>no</p>{/if}`;
const Server = await compileAndImport(trueSrc, 'server', 'ifm'); const { body } = render(Server); expect(body).toBe('<!--[--><!--[0--><p>yes</p><!--]--><!--]-->'); // server committed to the "yes" branch document.body.innerHTML = body;
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const Client = await compileAndImport(falseSrc, 'client', 'ifm'); // client's real flag is false hydrate(Client, { target: document.body });
expect(warn).not.toHaveBeenCalled(); // no hydration warning fires expect(document.body.textContent).toBe('no'); // {#if}'s own reactivity corrected the branch warn.mockRestore();});✓ {#if} branch mismatch: no warning — the block's own reactivity switches to the correct branch
Test Files 1 passed (1) Tests 1 passed (1)2. เรื่องเดียวกันสำหรับ {#each}: server render 2 item, array จริงของ client มี 1 — DOM node ส่วนเกินถูกลบแบบเงียบ ๆ โดย reconciliation ของ each block เอง ไม่มี warning อีกเช่นกัน (บท Fine-Grained DOM พูดถึง reconciliation นั้นอย่างละเอียด)
3. Mismatch แบบstatic(ไม่เคย reassign) ไม่เคยถูกแก้ และไม่เคยถูก warn — output ผิดของ server อยู่ตลอดไป:
import { compile } from 'svelte/compiler';import { render } from 'svelte/server';import { hydrate } from 'svelte';import { writeFileSync, mkdirSync } from 'node:fs';import { fileURLToPath } from 'node:url';import path from 'node:path';import { expect, test, vi } from 'vitest';
const dir = path.dirname(fileURLToPath(import.meta.url));const compiledDir = path.join(dir, '.compiled');mkdirSync(compiledDir, { recursive: true });
async function compileAndImport(src: string, generate: 'client' | 'server', tag: string) { const result = compile(src, { generate, runes: true, filename: 'Playground.svelte' }); const file = path.join(compiledDir, `${tag}-${generate}-${Date.now()}-${Math.random()}.mjs`); writeFileSync(file, result.js.code); const mod = await import(/* @vite-ignore */ file); return mod.default;}
test('static text mismatch: no warning, and the WRONG server text survives forever', async () => { // Simulating something like `typeof window === 'undefined'` baked into static markup // differently on each side. const Server = await compileAndImport(`<p>rendered on: server</p>`, 'server', 'statictext'); document.body.innerHTML = render(Server).body;
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const Client = await compileAndImport(`<p>rendered on: client</p>`, 'client', 'statictext'); hydrate(Client, { target: document.body });
expect(warn).not.toHaveBeenCalled(); expect(document.querySelector('p')!.textContent).toBe('rendered on: server'); // never fixed warn.mockRestore();});✓ static text mismatch: no warning, and the WRONG server text survives forever
Test Files 1 passed (1) Tests 1 passed (1)เหตุผลที่ทั้งสามกรณีทำงานแบบนี้คือเหตุผลเดียวกับ Fine-Grained DOM: hydration ไม่ได้ “เทียบ” output ของ server กับ output ของ client เลย — hydrating branch ของ from_html แค่ส่ง node ที่อยู่ตรงนั้นกลับไป (assign_nodes(hydrate_node, null); return hydrate_node;) โดยไม่มีการเช็คว่า tag เนื้อหา หรือ shape ตรงกับที่ client จะ produce เลย ถ้าเนื้อหาที่ mismatch อยู่ข้างในอะไรที่ reactive path การ update ปกติของสิ่งนั้น ($.set_text/{#if}/{#each} reconciliation ตัวเดียวกับที่โมดูลนี้พิสูจน์ไปแล้ว) จะแก้ไขมันในรอบ flush ถัดไป เป็น side effect ธรรมดาของการเป็น reactive — ไม่ใช่เพราะ hydration ตรวจจับอะไรได้ ถ้าเนื้อหาที่ mismatch เป็น static ไม่มีอะไรถูก schedule ให้แตะมันเลย มันเลยผิดอยู่แบบนั้น
มี hydration mismatch warning ที่ชัดเจนจริงอยู่ใน source — w.hydration_mismatch() ใน packages/svelte/src/internal/client/warnings.js ที่ console.warn ข้อความ [svelte] hydration_mismatch ใน dev — แต่มันจะ fire แค่ตอนที่การเดิน hydration เองพังในเชิงโครงสร้าง (set_hydrate_node(null) เช่น comment sibling ที่คาดไว้ไม่มีอยู่จริง) ไม่ใช่ตอนเนื้อหาไม่ตรงกันแต่ shape เดียวกันข้างใน block:
// packages/svelte/src/internal/client/warnings.js — hydration_mismatch (trimmed)export function hydration_mismatch(location) { console.warn( `[svelte] hydration_mismatch\nHydration failed because the initial UI does not match what was rendered on the server` + (location ? ` The error occurred near ${location}` : ''), );}และเมื่อการ throw เชิงโครงสร้างนั้นเกิดขึ้นจริง try/catch ของ hydrate() เอง (packages/svelte/src/internal/client/render.js) จะกู้คืนด้วยการล้าง target แล้วทำ mount() ธรรมดาแบบปกติ:
// packages/svelte/src/internal/client/render.js — hydrate's recovery path (trimmed)} catch (error) { if (error !== HYDRATION_ERROR) { console.warn('Failed to hydrate: ', error); } clear_text_content(target); set_hydrating(false); return mount(component, options); // fresh client-side mount, server DOM discarded}Request pipeline ของ SvelteKit ตั้งแต่ต้นจนจบ
หัวข้อที่มีชื่อว่า “Request pipeline ของ SvelteKit ตั้งแต่ต้นจนจบ”Hydration คือครึ่งฝั่ง client ของ request หนึ่งอัน โมดูล SvelteKit พูดถึงแต่ละชิ้นไปแล้ว นี่คือ diagram เดียวที่ผูกทุกอย่างเข้าด้วยกัน อ้างอิง slug จริง:
sequenceDiagram participant B as Browser participant H as hooks.server: handle participant L as load functions participant R as render (SSR) participant C as Client: hydrate() B->>H: GET /route H->>H: sequence(...) middleware (server-and-api) H->>L: resolve(event) L->>L: universal + server load (loading-data) L->>R: data R->>R: render() -> HTML + hydration markers (this lesson) R-->>B: streamed HTML response B->>C: parse + hydrate() walk <!--[--> markers, reuse DOM (this lesson) C-->>B: interactive page Note over B,C: later, a link click: B->>B: client-side navigation $app/navigation (loading-data) Note over B,C: a form submit: B->>H: POST /route (form action) H->>H: action runs, fail`()`/redirect`()` (forms-and-actions) H-->>B: re-render or redirect
ทุกกล่องใน diagram นี้เป็นบทเรียนจริง: Hooks: handle/sequence, universal เทียบ server load, streaming promise ที่ไม่ await, $app/navigation และ form action — บทนี้คือสอง arrow เจาะจงที่ข้าม server/client boundary: render() ที่ผลิต marker และ hydrate() ที่กินมัน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- comment สมมติว่า Svelte “จะ warn ถ้า server กับ client ไม่ตรงกัน” หรือเปล่า? พิสูจน์ข้างบน: content mismatch ข้างใน reactive block ถูกแก้ไขแบบเงียบ ๆ โดยไม่มี warning เลย และ mismatch ใน static content ก็ไม่เคยถูกแก้และไม่เคยถูก warn เช่นกัน อย่าพึ่ง console warning เพื่อจับบั๊กประเภทนี้
- ข้อมูลที่ควรมีแค่ฝั่ง server (random ID,
Date.now(),typeof window) ถูกใช้ตรง ๆ ใน template output โดยไม่ถูกส่งลงมาเป็นloaddata ที่ serialize ได้ด้วยหรือเปล่า? นี่คือ shape เดียวกับการพิสูจน์ static-mismatch ข้างบนเป๊ะ — ค่าของ server ชนะแบบเงียบ ๆ และถาวรบน client เพราะไม่มีอะไร schedule การแก้ไขสำหรับเนื้อหาที่ไม่ reactive - โค้ดสมมติว่า
hydrate()จะสำเร็จเต็มที่หรือ fallback ไป mount ใหม่เต็มที่เสมอสองแบบนี้เท่านั้นหรือเปล่า? ทั้งสองแบบเป็นจริง (พิสูจน์ข้างบน) แต่ก็มีกรณีที่สามที่พบบ่อยกว่า: มัน “สำเร็จ” (ไม่ throw error) แต่แอบ reuse branch/count ผิดจนกว่า reactivity จะบังเอิญแก้ให้ — หรือไม่เคยแก้เลยสำหรับ static content throw redirect(...)/error(...)ข้างในloadหรือ action ถูกห่อด้วยtry/catchที่ log “unexpected error” แล้วกลืนมันไปหรือเปล่า? นี่คือบั๊กคลาสสิกที่server-and-apiพิสูจน์ด้วยcurl— redirect/error ของ SvelteKit เป็น throw ที่ต้อง propagate ไม่ใช่ถูกจับเป็น failure
🐛 หา bug ในโค้ดนี้
<script>// "the user's timezone offset, computed once, server or client, same result"const offsetLabel = `UTC${new Date().getTimezoneOffset() > 0 ? '-' : '+'}${Math.abs(new Date().getTimezoneOffset() / 60)}`;</script>
<p>Your timezone: {offsetLabel}</p>เฉลย
offsetLabel คำนวณด้วย const ธรรมดา ไม่ใช่ $state/$derived — มันเป็น static ในสายตาของ reactivity graph บน server new Date().getTimezoneOffset() รันในไทม์โซนที่ server process อยู่ (ส่วนใหญ่คือ UTC) ในเบราว์เซอร์มันรันในไทม์โซนจริงของผู้เยี่ยมชม สองอย่างนี้จะไม่ตรงกันบ่อยมาก เพราะค่านี้เป็น static hydration จึงไม่เทียบมันกับอะไรเลย และไม่มีอะไรถูก schedule ให้แก้มัน — พิสูจน์ข้างบนด้วย shape เดียวกันเป๊ะ (static mismatch อยู่รอดโดยไม่มี warning ตลอดไป) — หน้านี้จึงโชว์ label ไทม์โซนของserverให้ทุกคนเห็นถาวร ไม่ว่าไทม์โซนจริงของเขาจะเป็นอะไร โดยไม่มี error หรือ warning ที่ไหนเลย ทางแก้คือคำนวณค่านี้แค่ฝั่ง client เท่านั้น (ข้างใน $effect/onMount ให้มันรันหลัง hydration ด้วยข้อมูลเบราว์เซอร์จริง) หรือส่งค่าที่ถูกต้องลงมาตรง ๆ เป็น load data แทนที่จะคำนวณต่างกันคนละฝั่ง