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 + "
    " + 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('