diff --git a/.changeset/site-kit-status-banner-children.md b/.changeset/site-kit-status-banner-children.md
new file mode 100644
index 0000000..49fafef
--- /dev/null
+++ b/.changeset/site-kit-status-banner-children.md
@@ -0,0 +1,7 @@
+---
+"@devslab/site-kit": patch
+---
+
+`StatusBanner` reads its `children` once. `
…
` 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 `
` 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`을 한 번만 읽는다. `
…
`는 게터로 컴파일되는데, 배너가 진위 검사 뒤 삽입으로 두 번 읽어서 첫 읽기가 만든 목록(과 그 아래 `
` 전부)이 버려지면서 서버 HTML엔 없는 하이드레이션 키를 각각 소모했다. Solid 프로덕션 빌드는 없는 키에 템플릿을 복제해 아무것도 안 보였지만, 개발 빌드는 `Hydration Mismatch`를 던져 소비자의 상태 페이지가 `vite dev`에서 에러 경계로 떨어졌다(BookLinq, 0.12.1). 이제 메모 하나가 검사와 삽입을 겸하고 양쪽에서 트리의 같은 지점에 한 번 평가된다. 하이드레이션 테스트가 배너 둘을 인라인 children으로 렌더해 개발 빌드로 하이드레이션한다.
diff --git a/docs/decisions.md b/docs/decisions.md
index ddc56a7..d2be1ef 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -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`가
+배너 둘을 인라인 `
…
` 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 &&
{props.children}
}` — 컴파일된 JSX
+children은 게터라 첫 읽기가 목록과 그 아래 `
` 전부를 만들어 버렸고, 그 요소들은
+서버 HTML에 없는 하이드레이션 키를 소모했다. BookLinq는 목록을 const로 한 번 만들어
+넘기는 우회로 출하하고 kit 건으로 넘겼다.
+
+**재현이 가르쳐 준 것.** 프로덕션 빌드로는 이 버그가 *보이지 않는다*. 서버와
+클라이언트가 버려지는 읽기에서 같은 수의 키를 낭비하므로 카운터는 어긋나지 않고,
+클라이언트가 없는 키를 조회하면 `getNextElement`가 조용히 템플릿을 복제한다 —
+잃은 키 0, 화면 정상. 첫 두 판의 테스트(평문 `
`, 그다음 BookLinq와 같은 `For`
+목록)가 미수정 kit에서 초록이었던 이유다. 개발 빌드는 같은 자리에서 `Hydration
+Mismatch`를 던진다(`web/dist/dev.js`). 그래서 이 테스트는 개발 빌드로 하이드레이션한다
+— 낭비된 키에 대해 진실을 말하는 쪽이 그쪽이고, 모든 소비자의 dev 서버가 도는 빌드도
+그쪽이다. 수정 전 빨강(`hydration key: 0010`), 수정 후 초록 확인.
+
+**대안.** ① 소비자가 children을 const로 만들어 넘기기(BookLinq의 우회) — 규율은
+잊히고, 부품이 한 번만 읽으면 규율 없이도 맞다. ② `children()` 헬퍼(solid-js) — 배열
+평탄화까지 하지만 여기선 필요 없고, 셸과 같은 `createMemo` 관용구 하나가 낫다.
+③ 진위 검사 제거(항상 `
` 렌더) — 자식 없는 배너에 빈 상자가 남는다.
+
+**트레이드오프.** 메모는 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`로 한 번
diff --git a/package.json b/package.json
index 2720eca..3a4bd21 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/site-kit/src/solid/layouts.tsx b/packages/site-kit/src/solid/layouts.tsx
index 96d6911..03a67de 100644
--- a/packages/site-kit/src/solid/layouts.tsx
+++ b/packages/site-kit/src/solid/layouts.tsx
@@ -49,8 +49,17 @@ export interface StatusBannerProps {
}
export function StatusBanner(props: StatusBannerProps) {
+ // Read children once. `
…
` compiles to a
+ // getter, and checking it for truthiness then inserting it read it twice:
+ // the first read built a list (and every
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
- {props.title}{props.children &&
{props.children}
}{props.action && }
+ {props.title}{children() &&
{children()}
}{props.action && }
;
}
diff --git a/tests/site-kit-status-banner-hydration.test.mjs b/tests/site-kit-status-banner-hydration.test.mjs
new file mode 100644
index 0000000..5a5524c
--- /dev/null
+++ b/tests/site-kit-status-banner-hydration.test.mjs
@@ -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
+ * `
…
` child is a getter, so the first
+ * read minted a list (and every
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 ,
+// each banner's children an inline `
…
` — a compiled getter
+// that builds the list, and every
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(["
", "
"], ssrHydrationKey(), escape(body));
+const li = (text) => ssr(["
", "
"], 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("" + bootstrap + "