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-shell-hydration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@devslab/site-kit": patch
---

`MarketingShell` reads its `header` and `footer` once. `header={{ … }}` compiles to a getter, and spreading `props.header` straight into `SiteHeader` re-evaluated that literal on every prop read — any JSX built eagerly inside it was built again each time and consumed hydration keys, a different number of times on the server than on the client. From the first drift the client rebuilt the whole header from templates, and the flag sprite (0.11.0) came back empty: the first consumer to ship the sprite showed a blank flag box in every browser while its server HTML carried all the symbols. A memo evaluates the literal exactly once per side, at the same point in the tree. And a sprite that reaches the client empty now loads its bodies whatever the reason — it looks at the element, not at whether hydration is running.

`MarketingShell`이 `header`·`footer`를 한 번만 읽는다. `header={{ … }}`는 게터로 컴파일되는데 `props.header`를 `SiteHeader`에 그대로 펼치면 prop을 읽을 때마다 그 리터럴이 다시 평가됐다 — 안에서 즉시 만들어진 JSX가 매번 다시 만들어지며 하이드레이션 키를 소모했고, 서버와 클라이언트의 횟수가 달랐다. 첫 어긋남부터 클라이언트가 헤더 전체를 템플릿에서 다시 만들었고 국기 스프라이트(0.11.0)는 빈 채로 돌아왔다: 스프라이트를 처음 출하한 소비자의 서버 HTML엔 심볼이 다 있었는데 모든 브라우저에서 국기 칸이 비어 있었다. 메모는 리터럴을 양쪽에서 정확히 한 번, 트리의 같은 지점에서 평가한다. 그리고 클라이언트에 빈 채로 도착한 스프라이트는 이유가 무엇이든 본문을 로드한다 — 하이드레이션 여부가 아니라 엘리먼트를 본다.
38 changes: 38 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,44 @@

---

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

**결정.** `MarketingShell`이 `props.header`·`props.footer`를 `createMemo`로 한 번
읽어 `SiteHeader`/`SiteFooter`에 펼친다. `FlagSprite`의 브라우저 분기는
"하이드레이션 중인가"(`sharedConfig.context`)가 아니라 "엘리먼트에 자식이
있는가"로 서버 HTML 유무를 판정하고, 비어 있으면 `loadFlagBodies`를 부른다.
`tests/site-kit-hydration.test.mjs`가 서버 빌드로 렌더 → 브라우저 빌드로 jsdom
하이드레이션까지 돌려 헤더 요소가 하나도 재생성되지 않고 심볼 14개가 살아남는지
고정한다(stage3-4 게이트).

**계기.** AskLinq가 site-kit 0.12.0을 소비하자(D-026 파비콘 작업의 부수 bump)
국기 메뉴가 모든 브라우저에서 빈 칸이 됐다. 서버 HTML엔 심볼 10개가 있었고,
라이브 DOM에선 `<details class="site-locale-flag">`만이 아니라 헤더 요소 대부분에
`data-hk`가 없었다 — 즉시 만들어진 `actions` 앵커 둘만 키가 맞았다. 원인:
`header={{ …, actions: <>…</> }}`는 게터로 컴파일되고, 셸이 `{...props.header}`로
펼치면 prop을 읽을 때마다 리터럴이 재평가되며 그 안의 즉시 JSX가 매번 다시
만들어져 하이드레이션 키를 소모한다. 서버(문자열, 소수 읽기)와 클라이언트(반응형,
다수 읽기)의 횟수가 달라 그 뒤 키가 전부 어긋났다. 스프라이트 이전(≤0.9,
`<select>` 변형)에서도 같은 재생성이 있었을 테지만 재생성된 `<select>`는
멀쩡히 동작하므로 아무도 못 봤다 — 스프라이트는 서버 HTML에만 사는 첫 부품이었다.

**대안.** ① 소비자에게 `get actions()` 규율 요구(AskLinq 셸은 `logo`에 이미 그
주석을 달아 두고 `actions`에서 어겼다 — 규율은 잊히고, 셸이 한 번만 읽으면 규율
없이도 맞다). ② `FlagSprite`의 uid를 국가 목록 해시로 결정적으로 만들기 — 재생성
자체를 막지 못한다. ③ D-020을 되돌려 브라우저 번들에 본문 재탑재 — 115KB를
되돌리는 데다 근본 원인(키 드리프트)은 남는다.

**트레이드오프.** 메모는 `props.header`가 신호에 의존하면 그때 재평가되고 즉시
JSX도 다시 만들어진다(소비자 패턴의 원래 비용). 빈 스프라이트 로드는 드리프트가
남은 소비자에게 110KB 청크 fetch로 국기를 살린다 — 증상 완화이지 드리프트 해소는
아니라서, 테스트는 "재생성 0"을 별도로 단언한다.

**재검토.** Solid 2/dom-expressions가 하이드레이션 키 부여 방식을 바꾸면 테스트가
먼저 알린다. 셸 밖에서 `SiteHeader`를 직접 쓰는 소비자는 여전히 자기 props를
한 번만 읽어야 한다(README).

---

## D-026 — 파비콘 `<link>` 묶음은 site-kit이 정하고, 파일은 linq-brand가 만든다 (2026-09-14)

**결정.** `@devslab/site-kit`에 `brandIconLinks({ basePath })`와 `BRAND_ICON_FILES`를
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 && 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 && 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
4 changes: 4 additions & 0 deletions packages/site-kit/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ claim leaf가 검증된 사실 레지스트리를 참조하도록 강제한다.
기존 environment-only 출력은 유지되며, 선택적 `policies`로 검색 인덱싱,
인용 crawler, 모델 학습 crawler를 각각 제어할 수 있다.

## 하이드레이션

`MarketingShell`은 아무것도 렌더하기 전에 `header`·`footer`를 한 번(메모) 읽는다. `header={{ … }}`는 게터로 컴파일되므로 셸이 prop마다 다시 읽으면 리터럴 안에서 즉시 만들어진 JSX(`actions` 앵커, 로고)가 읽을 때마다 다시 만들어져 하이드레이션 키를 소모하고 — 서버와 브라우저의 횟수가 다르다 — 클라이언트는 헤더를 템플릿에서 다시 만든다. 셸 밖에서 `SiteHeader`·`SiteFooter`를 직접 마운트하는 제품은 같은 방식으로 자기 props를 한 번만 읽어야 한다. 국기 스프라이트는 이유가 무엇이든 클라이언트에 빈 채로 도착하면 본문을 로드한다.

## 브랜드 아이콘

모든 제품의 아이콘 파일은 `@devslab/linq-brand`(`dist/<product>/`)에서 온다. `brandIconLinks()`는 그중 어떤 파일을 페이지 head가 어떤 순서로 링크하는지 정하는 유일한 자리다.
Expand Down
4 changes: 4 additions & 0 deletions packages/site-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ still references the verified-fact registry. `buildRobots` keeps its legacy
environment-only output, while an optional `policies` object can independently
control search indexing, citation crawlers, and model-training crawlers.

## Hydration

`MarketingShell` reads `header` and `footer` once (a memo) before it renders anything. `header={{ … }}` compiles to a getter; if the shell re-read it per prop, any JSX built eagerly inside the literal (an `actions` anchor, a logo) would be built again on each read and consume hydration keys — a different number of times on the server than in the browser — and the client would rebuild the header from templates. A product that mounts `SiteHeader` or `SiteFooter` directly, outside the shell, has to read its own props once the same way. The flag sprite loads its bodies whenever it reaches the client empty, whatever the reason.

## Brand icons

Every product's icon files come from `@devslab/linq-brand` (`dist/<product>/`); `brandIconLinks()` is the one place that says which of them a page head links, and in what order:
Expand Down
15 changes: 13 additions & 2 deletions packages/site-kit/src/solid/layouts.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Button, type ButtonTone } from "@devslab/dds-solid";
import type { JSX } from "solid-js";
import { createMemo, type JSX } from "solid-js";

import { SiteFooter, SiteHeader, type SiteFooterProps, type SiteHeaderProps } from "./chrome";
import type { SiteMessages } from "./types";
Expand All @@ -16,7 +16,18 @@ export interface MarketingShellProps {

export function MarketingShell(props: MarketingShellProps) {
const mainClass = () => (props.mainWidth === "bleed" ? "site-main site-main--bleed" : "site-main");
return <div class="site-shell"><SiteHeader {...props.header} /><main id="main-content" class={mainClass()} tabIndex={-1}>{props.children}</main><SiteFooter {...props.footer} /></div>;
// Read header and footer once, here, before any element of the shell exists.
// `header={{ … }}` compiles to a getter, so spreading `props.header` straight
// into SiteHeader re-evaluated the literal on every prop read — and any JSX
// built eagerly inside it (an `actions` anchor, a logo) was built again each
// time, consuming hydration keys. The server and the client read a different
// number of times, so from the first extra read every key after it was off,
// and the client rebuilt the whole header from templates: the flag sprite
// came back empty (AskLinq, site-kit 0.12.0). A memo evaluates the literal
// exactly once per side, at the same point in the tree.
const header = createMemo(() => props.header);
const footer = createMemo(() => props.footer);
return <div class="site-shell"><SiteHeader {...header()} /><main id="main-content" class={mainClass()} tabIndex={-1}>{props.children}</main><SiteFooter {...footer()} /></div>;
}

export interface LegalLayoutProps {
Expand Down
10 changes: 8 additions & 2 deletions packages/site-kit/src/solid/locale-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { For, createUniqueId, sharedConfig, type JSX } from "solid-js";
import { For, createUniqueId, type JSX } from "solid-js";

import { FAMILY_LOCALES, type LocaleRegistry, type SiteLocale } from "../core/locales.mjs";
import { FLAG_VIEWBOX, flagCountryFor } from "../core/flag-countries.mjs";
Expand Down Expand Up @@ -58,6 +58,12 @@ function spriteMarkup(countries: readonly string[], bodies: FlagBodies, uid: str
* the bodies through loadFlagBodies, which the browser build emits as its
* own chunk. Zero-sized rather than display:none so the referenced clip
* paths and gradients still resolve.
*
* "No server HTML" is decided by looking at the element, not at whether
* hydration is running: a hydration that drifted upstream recreates this
* element from the template, empty, while the hydration context is still
* set. Trusting the context left the first consumer with a blank flag
* box; an empty sprite loads its bodies whatever the reason it is empty.
*/
function FlagSprite(props: { countries: readonly string[]; uid: string }) {
const bodies = flagBodiesNow();
Expand All @@ -69,7 +75,7 @@ function FlagSprite(props: { countries: readonly string[]; uid: string }) {
class="site-flag-sprite"
aria-hidden="true"
ref={(element) => {
if (sharedConfig.context) return; // hydrating: the server already drew the sprite
if (element.childElementCount > 0) return; // adopted from server HTML: the sprite is already drawn
void loadFlagBodies().then((loaded) => { element.innerHTML = spriteMarkup(props.countries, loaded, props.uid); });
}}
/>
Expand Down
6 changes: 5 additions & 1 deletion tests/site-kit-contracts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,11 @@ test("flag artwork stays out of the browser bundle: server writes the sprite, th
assert.doesNotMatch(menu, /flag-bodies\.mjs|core\/flags\.mjs|FLAGS_BY_COUNTRY|LOCALE_FLAGS|flagFor\b/, "the menu must not reach the bodies statically");
assert.match(menu, /<symbol id=/);
assert.match(menu, /<use href=/);
assert.match(menu, /sharedConfig\.context/, "hydration must adopt the server sprite instead of loading");
// Adoption is decided by the element, not the hydration context: a drifted
// hydration recreates the sprite empty while the context is still set, and
// trusting the context left the first consumer with a blank flag box (D-027).
assert.match(menu, /childElementCount > 0\) return;/, "a sprite the server drew is adopted, not reloaded");
assert.doesNotMatch(menu, /sharedConfig\.context/, "the hydration context is not what decides whether the sprite loads");
const loader = await read("packages/site-kit/src/solid/flag-bodies.ts");
assert.match(loader, /import\("\.\.\/core\/flag-bodies\.mjs"\)/, "the browser loader is a dynamic import");
assert.doesNotMatch(loader, /^import \{[^}]*FLAGS_BY_COUNTRY/m);
Expand Down
Loading