Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/site-kit-status-banner-children.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@devslab/site-kit": patch
---

`StatusBanner` reads its `children` once. `<StatusBanner><ul>…</ul></StatusBanner>` compiles to a getter, and the banner checked it for truthiness and then inserted it — two reads, so the first built a list (and every `<li>` under it) that was thrown away, each with a hydration key the server never wrote into the HTML. Solid's production build clones a template when a key is missing, so nothing showed; the development build throws `Hydration Mismatch`, so a consumer's status page fell to its error boundary in `vite dev` (BookLinq, 0.12.1). A memo is now both the check and the insert, evaluated at the same point in the tree on both sides. A hydration test renders two banners with inline children and hydrates them with the development build.

`StatusBanner`가 `children`을 한 번만 읽는다. `<StatusBanner><ul>…</ul></StatusBanner>`는 게터로 컴파일되는데, 배너가 진위 검사 뒤 삽입으로 두 번 읽어서 첫 읽기가 만든 목록(과 그 아래 `<li>` 전부)이 버려지면서 서버 HTML엔 없는 하이드레이션 키를 각각 소모했다. Solid 프로덕션 빌드는 없는 키에 템플릿을 복제해 아무것도 안 보였지만, 개발 빌드는 `Hydration Mismatch`를 던져 소비자의 상태 페이지가 `vite dev`에서 에러 경계로 떨어졌다(BookLinq, 0.12.1). 이제 메모 하나가 검사와 삽입을 겸하고 양쪽에서 트리의 같은 지점에 한 번 평가된다. 하이드레이션 테스트가 배너 둘을 인라인 children으로 렌더해 개발 빌드로 하이드레이션한다.
41 changes: 41 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,47 @@

---

## D-028 — 부품은 `children`도 한 번만 읽는다: `StatusBanner`의 이중 읽기 (2026-09-16)

**결정.** `StatusBanner`가 `props.children`을 `createMemo`로 한 번 읽어, 진위 검사와
삽입이 같은 해석값을 쓴다. D-027이 셸의 `header`/`footer`에 세운 규칙("kit은 받은
것을 한 번만 읽는다")을 자식에도 적용한 것이다. `tests/site-kit-status-banner-hydration.test.mjs`가
배너 둘을 인라인 `<ul><For>…</For></ul>` children으로 서버 렌더 → **Solid 개발 빌드**
(`--conditions=browser --conditions=development`)로 jsdom 하이드레이션까지 돌려
예외 0·잃은 서버 키 0을 고정한다(`verify:site-kit:ui`).

**계기.** BookLinq가 D-027 규율을 자기 셸에 적용하고 브라우저 하이드레이션 게이트를
새로 돌리자(jlc488/booklinq#51) `/status`가 `vite dev`에서 site-kit의 에러 레이아웃을
그렸다. 원인은 `{props.children && <div>{props.children}</div>}` — 컴파일된 JSX
children은 게터라 첫 읽기가 목록과 그 아래 `<li>` 전부를 만들어 버렸고, 그 요소들은
서버 HTML에 없는 하이드레이션 키를 소모했다. BookLinq는 목록을 const로 한 번 만들어
넘기는 우회로 출하하고 kit 건으로 넘겼다.

**재현이 가르쳐 준 것.** 프로덕션 빌드로는 이 버그가 *보이지 않는다*. 서버와
클라이언트가 버려지는 읽기에서 같은 수의 키를 낭비하므로 카운터는 어긋나지 않고,
클라이언트가 없는 키를 조회하면 `getNextElement`가 조용히 템플릿을 복제한다 —
잃은 키 0, 화면 정상. 첫 두 판의 테스트(평문 `<p>`, 그다음 BookLinq와 같은 `For`
목록)가 미수정 kit에서 초록이었던 이유다. 개발 빌드는 같은 자리에서 `Hydration
Mismatch`를 던진다(`web/dist/dev.js`). 그래서 이 테스트는 개발 빌드로 하이드레이션한다
— 낭비된 키에 대해 진실을 말하는 쪽이 그쪽이고, 모든 소비자의 dev 서버가 도는 빌드도
그쪽이다. 수정 전 빨강(`hydration key: 0010`), 수정 후 초록 확인.

**대안.** ① 소비자가 children을 const로 만들어 넘기기(BookLinq의 우회) — 규율은
잊히고, 부품이 한 번만 읽으면 규율 없이도 맞다. ② `children()` 헬퍼(solid-js) — 배열
평탄화까지 하지만 여기선 필요 없고, 셸과 같은 `createMemo` 관용구 하나가 낫다.
③ 진위 검사 제거(항상 `<div>` 렌더) — 자식 없는 배너에 빈 상자가 남는다.

**트레이드오프.** 메모는 children이 신호에 의존하면 그때 재평가된다(소비자 패턴의
원래 비용). 기존 `tests/site-kit-hydration.test.mjs`는 여전히 프로덕션 빌드로
돈다 — D-027의 결함은 프로덕션에서도 키가 어긋나는 종류라 그쪽이 맞고, 이 테스트는
개발 빌드만이 잡는 종류라 그쪽이 맞다.

**재검토.** `NotFoundLayout`·`ErrorLayout`·`LegalLayout`은 children을 한 번만 읽는다
(삽입만). 조건부 렌더가 새로 붙는 부품은 같은 메모 관용구를 쓰고 이 테스트 모양으로
고정한다. npm 발행이 OIDC E404로 막혀 있어(소유자 액션) 이 수정은 0.12.2에 실린다.

---

## D-027 — 셸은 header/footer를 한 번만 읽고, 빈 스프라이트는 스스로 본문을 로드한다 (2026-09-16)

**결정.** `MarketingShell`이 `props.header`·`props.footer`를 `createMemo`로 한 번
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"verify:table:a11y": "pnpm --filter @devslab/dds-table run test:a11y",
"verify:table:release": "pnpm run verify:foundation:core && pnpm --filter @devslab/dds-table run build && node scripts/verify-table-release.mjs",
"verify:site-kit:i18n": "node --test tests/site-kit-core.test.mjs tests/site-kit-publisher.test.mjs",
"verify:site-kit:ui": "node --test tests/site-kit-contracts.test.mjs tests/site-kit-worker.test.mjs && pnpm --filter @devslab/site-kit run test && pnpm --filter @devslab/site-kit run check && pnpm --filter @devslab/site-kit run build && node --test tests/site-kit-hydration.test.mjs && pnpm --filter @devslab/site-kit run test:worker",
"verify:site-kit:ui": "node --test tests/site-kit-contracts.test.mjs tests/site-kit-worker.test.mjs && pnpm --filter @devslab/site-kit run test && pnpm --filter @devslab/site-kit run check && pnpm --filter @devslab/site-kit run build && node --test tests/site-kit-hydration.test.mjs tests/site-kit-status-banner-hydration.test.mjs && pnpm --filter @devslab/site-kit run test:worker",
"verify:site-kit:seo": "node --test tests/site-kit-core.test.mjs tests/site-kit-publisher.test.mjs",
"verify:site-kit:browser": "playwright test --config playwright.site-kit.config.ts",
"verify:site-kit:release": "pnpm run verify:foundation:core && pnpm --filter @devslab/dds-solid run build && pnpm --filter @devslab/site-kit run build && node scripts/verify-site-kit-release.mjs",
Expand Down
11 changes: 10 additions & 1 deletion packages/site-kit/src/solid/layouts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,17 @@ export interface StatusBannerProps {
}

export function StatusBanner(props: StatusBannerProps) {
// Read children once. `<StatusBanner><ul>…</ul></StatusBanner>` compiles to a
// getter, and checking it for truthiness then inserting it read it twice:
// the first read built a list (and every <li> under it) that was thrown
// away, each with a hydration key the server never wrote. Solid's dev build
// throws `Hydration Mismatch` for those keys, so a consumer's status page
// fell to its error boundary in `vite dev` (BookLinq, site-kit 0.12.1). One
// memo, evaluated at the same point in the tree on both sides, is the
// check and the insert (D-027, D-028).
const children = createMemo(() => props.children);
return <section class={`site-status site-status--${props.tone}`} role={props.tone === "danger" ? "alert" : "status"} aria-live={props.tone === "danger" ? "assertive" : "polite"}>
<strong>{props.title}</strong>{props.children && <div>{props.children}</div>}{props.action && <Button tone={props.action.tone ?? "secondary"} onClick={props.action.onClick}>{props.action.label}</Button>}
<strong>{props.title}</strong>{children() && <div>{children()}</div>}{props.action && <Button tone={props.action.tone ?? "secondary"} onClick={props.action.onClick}>{props.action.label}</Button>}
</section>;
}

Expand Down
133 changes: 133 additions & 0 deletions tests/site-kit-status-banner-hydration.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";

/**
* Server-render two StatusBanners whose children are inline JSX, then hydrate
* that HTML with the browser build in jsdom and count the server keys that
* survived.
*
* BookLinq's status page (jlc488/booklinq#51) rendered site-kit's error
* layout in `vite dev`. The banner read `props.children` twice — once for
* the truthiness check, once to insert — and a compiled
* `<StatusBanner><ul>…</ul></StatusBanner>` child is a getter, so the first
* read minted a list (and every <li> under it) that was thrown away, each
* with a hydration key the server never wrote into the HTML. On the client
* the same discarded read asks the registry for those keys. Solid's
* production build quietly clones a template when a key is missing, so the
* counters stay aligned and nothing visible breaks; the development build —
* what every consumer's dev server runs — throws `Hydration Mismatch` and
* the error boundary swallows the page. BookLinq worked around it by
* building the list into a const; the kit is where it belongs (D-027: the
* kit reads what it is handed once).
*
* So this hydrates with `--conditions=browser --conditions=development`:
* the strict build is the one that tells the truth about a wasted key.
* Otherwise the harness is site-kit-hydration.test.mjs: SSR in this process
* (server build), hydration in a child, temp scripts under the package so
* bare imports resolve. Runs from verify:site-kit:ui after the build.
*/

const root = fileURLToPath(new URL("..", import.meta.url));
const kit = join(root, "packages", "site-kit");

// The page the way BookLinq's /status reaches the kit: sections in a <For>,
// each banner's children an inline `<ul><For>…</For></ul>` — a compiled getter
// that builds the list, and every <li> under it, on each read.
const SECTIONS = [
{ tone: "success", title: "API", items: ["Responding", "Booking flow"] },
{ tone: "warning", title: "Queue", items: ["Delayed", "Retrying"] },
];
const PAGE_SOURCE = `
export function page({ createComponent, For, ul, li, insert, StatusBanner, sections }) {
return createComponent(For, { each: sections, children: (section) =>
createComponent(StatusBanner, {
get tone() { return section.tone; },
get title() { return section.title; },
get children() { return ul(createComponent(For, { each: section.items, children: (item) => li(item) })); },
}),
});
}
`;

const ssrScript = () => `
import { renderToString, generateHydrationScript, ssr, ssrHydrationKey, escape } from "solid-js/web";
import { createComponent, For } from "solid-js";
import { StatusBanner } from ${JSON.stringify(pathToFileURL(join(kit, "dist", "solid.server.js")).href)};
import { page } from "./page.mjs";
const ul = (body) => ssr(["<ul", " class=\\"detail\\">", "</ul>"], ssrHydrationKey(), escape(body));
const li = (text) => ssr(["<li", ">", "</li>"], ssrHydrationKey(), escape(text));
const html = renderToString(() => page({ createComponent, For, ul, li, StatusBanner, sections: ${JSON.stringify(SECTIONS)} }));
process.stdout.write(JSON.stringify({ bootstrap: generateHydrationScript(), html }));
`;

const hydrateScript = () => `
process.on("uncaughtException", (e) => { process.stderr.write("UNCAUGHT " + (e && e.stack || e) + String.fromCharCode(10)); process.exit(1); });
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
const require = createRequire(${JSON.stringify(pathToFileURL(join(kit, "package.json")).href)});
const { JSDOM } = require("jsdom");
const { bootstrap, html } = JSON.parse(readFileSync(new URL("./ssr.json", import.meta.url), "utf8"));
const dom = new JSDOM("<!doctype html><html><head>" + bootstrap + "</head><body><div id=\\"root\\">" + html + "</div></body></html>", { runScripts: "dangerously", pretendToBeVisual: true, url: "https://example.test/" });
for (const key of ["window", "document", "Node", "HTMLElement", "SVGElement", "MutationObserver", "navigator", "requestAnimationFrame", "localStorage", "matchMedia"]) {
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true, writable: true });
}
Object.defineProperty(globalThis, "_$HY", { value: dom.window._$HY, configurable: true, writable: true });
const host = document.querySelector("#root");
const serverKeys = [...host.querySelectorAll("[data-hk]")].map((element) => element.getAttribute("data-hk"));
const { hydrate, template, getNextElement, insert } = await import("solid-js/web");
const { createComponent, For } = await import("solid-js");
const { StatusBanner } = await import(${JSON.stringify(pathToFileURL(join(kit, "dist", "solid.js")).href)});
const { page } = await import("./page.mjs");
const ulTemplate = template('<ul class="detail">'), liTemplate = template("<li>");
const ul = (body) => { const element = getNextElement(ulTemplate); insert(element, body); return element; };
const li = (text) => { const element = getNextElement(liTemplate); insert(element, text); return element; };
const diagnostics = [];
const warn = console.warn, error = console.error;
console.warn = (...v) => diagnostics.push(v.join(" "));
console.error = (...v) => diagnostics.push(v.join(" "));
hydrate(() => page({ createComponent, For, ul, li, insert, StatusBanner, sections: ${JSON.stringify(SECTIONS)} }), host);
await new Promise((resolve) => setTimeout(resolve, 100));
console.warn = warn; console.error = error;
process.stdout.write(JSON.stringify({
diagnostics,
serverKeyed: serverKeys.length,
lostKeys: serverKeys.filter((key) => !host.querySelector('[data-hk="' + key + '"]')).length,
banners: host.querySelectorAll(".site-status").length,
details: [...host.querySelectorAll(".site-status .detail")].map((element) => [...element.querySelectorAll("li")].map((item) => item.textContent)),
detailsKeyed: [...host.querySelectorAll(".site-status .detail, .site-status li")].every((element) => element.hasAttribute("data-hk")),
}));
`;

function runBanners() {
const dir = mkdtempSync(join(kit, ".status-banner-test-"));
try {
writeFileSync(join(dir, "package.json"), JSON.stringify({ type: "module" }));
writeFileSync(join(dir, "page.mjs"), PAGE_SOURCE);
writeFileSync(join(dir, "ssr.mjs"), ssrScript());
writeFileSync(join(dir, "hydrate.mjs"), hydrateScript());
const env = { ...process.env, NODE_PATH: join(kit, "node_modules") };
const server = spawnSync(process.execPath, [join(dir, "ssr.mjs")], { cwd: kit, encoding: "utf8", env });
assert.equal(server.status, 0, server.stderr);
writeFileSync(join(dir, "ssr.json"), server.stdout);
const client = spawnSync(process.execPath, ["--conditions=browser", "--conditions=development", join(dir, "hydrate.mjs")], { cwd: kit, encoding: "utf8", env });
assert.equal(client.status, 0, `the development build must hydrate without throwing:\n${client.stderr}`);
return { html: JSON.parse(server.stdout).html, ...JSON.parse(client.stdout) };
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

test("two status banners with inline JSX children hydrate in place under the development build — no mismatch, no server key lost", () => {
const result = runBanners();
assert.deepEqual(result.diagnostics, []);
assert.equal((result.html.match(/class="detail"/g) ?? []).length, 2, "the server wrote each list exactly once");
assert.ok(result.serverKeyed >= 8, "the server keyed both banners, both lists and every item");
assert.equal(result.banners, 2);
assert.deepEqual(result.details, [["Responding", "Booking flow"], ["Delayed", "Retrying"]]);
assert.equal(result.lostKeys, 0, "every server hydration key is still in the document");
assert.ok(result.detailsKeyed, "both lists and every item kept their server keys");
});