diff --git a/.changeset/site-kit-shell-hydration.md b/.changeset/site-kit-shell-hydration.md new file mode 100644 index 0000000..52ead62 --- /dev/null +++ b/.changeset/site-kit-shell-hydration.md @@ -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엔 심볼이 다 있었는데 모든 브라우저에서 국기 칸이 비어 있었다. 메모는 리터럴을 양쪽에서 정확히 한 번, 트리의 같은 지점에서 평가한다. 그리고 클라이언트에 빈 채로 도착한 스프라이트는 이유가 무엇이든 본문을 로드한다 — 하이드레이션 여부가 아니라 엘리먼트를 본다. diff --git a/docs/decisions.md b/docs/decisions.md index d549a02..ddc56a7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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에선 `
`만이 아니라 헤더 요소 대부분에 +`data-hk`가 없었다 — 즉시 만들어진 `actions` 앵커 둘만 키가 맞았다. 원인: +`header={{ …, actions: <>… }}`는 게터로 컴파일되고, 셸이 `{...props.header}`로 +펼치면 prop을 읽을 때마다 리터럴이 재평가되며 그 안의 즉시 JSX가 매번 다시 +만들어져 하이드레이션 키를 소모한다. 서버(문자열, 소수 읽기)와 클라이언트(반응형, +다수 읽기)의 횟수가 달라 그 뒤 키가 전부 어긋났다. 스프라이트 이전(≤0.9, +``는 +멀쩡히 동작하므로 아무도 못 봤다 — 스프라이트는 서버 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 — 파비콘 `` 묶음은 site-kit이 정하고, 파일은 linq-brand가 만든다 (2026-09-14) **결정.** `@devslab/site-kit`에 `brandIconLinks({ basePath })`와 `BRAND_ICON_FILES`를 diff --git a/package.json b/package.json index 847e3b3..2720eca 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 && 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", diff --git a/packages/site-kit/README.ko.md b/packages/site-kit/README.ko.md index 178b3c6..7419333 100644 --- a/packages/site-kit/README.ko.md +++ b/packages/site-kit/README.ko.md @@ -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//`)에서 온다. `brandIconLinks()`는 그중 어떤 파일을 페이지 head가 어떤 순서로 링크하는지 정하는 유일한 자리다. diff --git a/packages/site-kit/README.md b/packages/site-kit/README.md index 8adaea7..4bf7129 100644 --- a/packages/site-kit/README.md +++ b/packages/site-kit/README.md @@ -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//`); `brandIconLinks()` is the one place that says which of them a page head links, and in what order: diff --git a/packages/site-kit/src/solid/layouts.tsx b/packages/site-kit/src/solid/layouts.tsx index e1b0163..96d6911 100644 --- a/packages/site-kit/src/solid/layouts.tsx +++ b/packages/site-kit/src/solid/layouts.tsx @@ -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"; @@ -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
{props.children}
; + // 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
{props.children}
; } export interface LegalLayoutProps { diff --git a/packages/site-kit/src/solid/locale-menu.tsx b/packages/site-kit/src/solid/locale-menu.tsx index fc1b1fa..0c9a908 100644 --- a/packages/site-kit/src/solid/locale-menu.tsx +++ b/packages/site-kit/src/solid/locale-menu.tsx @@ -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"; @@ -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(); @@ -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); }); }} /> diff --git a/tests/site-kit-contracts.test.mjs b/tests/site-kit-contracts.test.mjs index 00cfc3a..bbb1b00 100644 --- a/tests/site-kit-contracts.test.mjs +++ b/tests/site-kit-contracts.test.mjs @@ -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, / 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); diff --git a/tests/site-kit-hydration.test.mjs b/tests/site-kit-hydration.test.mjs new file mode 100644 index 0000000..0bdd64c --- /dev/null +++ b/tests/site-kit-hydration.test.mjs @@ -0,0 +1,212 @@ +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 the marketing shell with the flag menu, then hydrate that + * HTML with the browser build in jsdom and check what survived. + * + * The first consumer of the flag sprite (AskLinq, site-kit 0.12.0) shipped + * a header whose flag box was empty in every browser while the server HTML + * carried all the symbols. The shell spread `{...props.header}` straight + * into SiteHeader; `header={{ …, actions: }}` compiles to a getter, so + * every prop read rebuilt the literal and its eager JSX, and each rebuild + * consumed hydration keys — a different number of times on the server than + * on the client. From the first drift, the client recreated the header from + * templates: a fresh, empty sprite, and a pointing at a new uid. + * + * Two guards, both exercised here. The shell reads its header and footer + * once (createMemo), so an eagerly built prop costs the same keys on both + * sides. And a sprite that ends up empty on the client — whatever the + * reason — loads the bodies instead of trusting that hydration adopted it. + * + * SSR runs in this process (Node's default conditions pick solid-js's server + * build); hydration runs in a child with --conditions=browser, as + * scripts/verify-solid-release.mjs does. It needs the installed workspace + * and a built site-kit, so it runs from verify:site-kit:ui after the build — + * not from the source-only stage3-4 gate. + */ + +const root = fileURLToPath(new URL("..", import.meta.url)); +const kit = join(root, "packages", "site-kit"); // temp scripts live under the package so bare imports (solid-js, jsdom) resolve + +const MESSAGES = { + localeLabel: "Language", skipToContent: "Skip to content", themeLabel: "Theme", themeLight: "Light", + themeDark: "Dark", themeSystem: "System", menuOpen: "Menu", menuClose: "Close", navigationLabel: "Site", + localeSuggestionAccept: "Yes", localeSuggestionDismiss: "No", notFoundTitle: "Not found", + notFoundBody: "Nothing here", errorTitle: "Error", errorBody: "Something broke", retry: "Retry", home: "Home", +}; + +// The shell props the way a compiled `header={{ … }}` reaches the kit: a +// getter that rebuilds the literal — and its eager JSX — on every read. +const SHELL_SOURCE = (mode) => ` +export function shellProps({ createElement, eagerAnchor, FAMILY_LOCALES, MESSAGES }) { + const state = { locale: "ko", hrefForLocale: (code) => "/?lang=" + code }; + const props = { + messages: MESSAGES, + get children() { return createElement("p", "main-copy", "Body"); }, + get header() { + return { + brand: { name: "Product", href: "/" }, + navigation: [{ href: "#how", label: "How" }, { href: "#who", label: "Who" }], + locale: state, + localeVariant: "flag", + localeRegistry: FAMILY_LOCALES, + messages: MESSAGES, + actions: ${mode === "plain" ? "undefined" : "eagerAnchor()"}, + }; + }, + get footer() { + return { + brand: { name: "Product", href: "/" }, + locale: state, + localeRegistry: FAMILY_LOCALES, + links: [{ href: "/privacy", label: "Privacy" }], + copyright: "© Product", + messages: MESSAGES, + }; + }, + }; + return props; +} +`; + +function ssr() { + const script = ` +import { renderToString, generateHydrationScript, ssr, ssrHydrationKey, escape } from "solid-js/web"; +import { createComponent } from "solid-js"; +import { MarketingShell } from ${JSON.stringify(pathToFileURL(join(kit, "dist", "solid.server.js")).href)}; +import { FAMILY_LOCALES } from ${JSON.stringify(pathToFileURL(join(kit, "src", "core", "locales.mjs")).href)}; +import { shellProps } from "./shell.mjs"; +const MESSAGES = ${JSON.stringify(MESSAGES)}; +const createElement = (tag, cls, text) => ssr(["<" + tag, " class=\\"" + cls + "\\">" + escape(text) + ""], ssrHydrationKey()); +const eagerAnchor = () => ssr(["Login"], ssrHydrationKey()); +const html = renderToString(() => createComponent(MarketingShell, shellProps({ createElement, eagerAnchor, FAMILY_LOCALES, MESSAGES }))); +process.stdout.write(JSON.stringify({ bootstrap: generateHydrationScript(), html })); +`; + return script; +} + +function hydrateScript() { + return ` +process.on("uncaughtException", (e) => { console.error("UNCAUGHT", e && e.name, e && e.message, e && e.stack); 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"); +if (process.env.STRIP_SPRITE === "1") host.querySelector(".site-flag-sprite").innerHTML = ""; +const serverKeys = [...host.querySelectorAll("header [data-hk]")].map((element) => element.getAttribute("data-hk")); +const { hydrate, template, getNextElement } = await import("solid-js/web"); +const { createComponent } = await import("solid-js"); +const { MarketingShell } = await import(${JSON.stringify(pathToFileURL(join(kit, "dist", "solid.js")).href)}); +const { FAMILY_LOCALES } = await import(${JSON.stringify(pathToFileURL(join(kit, "src", "core", "locales.mjs")).href)}); +const { shellProps } = await import("./shell.mjs"); +const MESSAGES = ${JSON.stringify(MESSAGES)}; +const tmpl = (markup) => { const t = template(markup); return () => getNextElement(t); }; +const createElement = (tag, cls, text) => tmpl("<" + tag + " class=\\"" + cls + "\\">" + text + "")(); +const eagerAnchor = tmpl(''); +const diagnostics = []; +const warn = console.warn, error = console.error; +console.warn = (...v) => diagnostics.push(v.join(" ")); +console.error = (...v) => diagnostics.push(v.join(" ")); +hydrate(() => createComponent(MarketingShell, shellProps({ createElement, eagerAnchor, FAMILY_LOCALES, MESSAGES })), host); +await new Promise((resolve) => setTimeout(resolve, 100)); +console.warn = warn; console.error = error; +const sprite = host.querySelector(".site-flag-sprite"); +const use = host.querySelector(".site-locale-flag__svg use"); +const href = use && (use.getAttribute("href") || use.getAttribute("xlink:href")); +const headerElements = [...host.querySelectorAll("header *")]; +process.stdout.write(JSON.stringify({ + diagnostics, + serverKeyed: serverKeys.length, + // Only template roots carry data-hk (nested static nodes never do), so the + // question is whether every key the server wrote is still in the document: + // a key that vanished means the client threw that subtree away and rebuilt it. + lostKeys: serverKeys.filter((key) => !host.querySelector('[data-hk="' + key + '"]')).length, + recreated: headerElements.filter((element) => !element.hasAttribute("data-hk") && element.getAttribute("class")).map((element) => element.tagName + "." + element.getAttribute("class")).slice(0, 8), + symbols: sprite ? sprite.querySelectorAll("symbol").length : -1, + useResolves: Boolean(href && document.getElementById(href.slice(1))), + loginKeyed: host.querySelector(".site-header__login")?.hasAttribute("data-hk") ?? false, +})); +`; +} + +function runShell(mode, extraEnv = {}) { + const dir = mkdtempSync(join(kit, ".hydration-test-")); + try { + writeFileSync(join(dir, "package.json"), JSON.stringify({ type: "module" })); + writeFileSync(join(dir, "shell.mjs"), SHELL_SOURCE(mode)); + writeFileSync(join(dir, "ssr.mjs"), ssr()); + writeFileSync(join(dir, "hydrate.mjs"), hydrateScript()); + const env = { ...process.env, NODE_PATH: join(kit, "node_modules"), ...extraEnv }; + 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", join(dir, "hydrate.mjs")], { cwd: kit, encoding: "utf8", env }); + assert.equal(client.status, 0, client.stderr); + return { html: JSON.parse(server.stdout).html, ...JSON.parse(client.stdout) }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test("a shell whose header prop rebuilds eager JSX on every read still hydrates in place", () => { + const result = runShell("eager"); + assert.deepEqual(result.diagnostics, []); + // The server wrote the sprite; the client must adopt the header rather than rebuild it. + assert.ok((result.html.match(/= 14, "server writes every flag symbol"); + assert.ok(result.serverKeyed >= 10, "the server keyed the header's template roots"); + assert.equal(result.lostKeys, 0, "every server hydration key in the header is still in the document"); + assert.ok(result.loginKeyed, "the eagerly built action kept its server key"); + assert.equal(result.symbols, 14); + assert.ok(result.useResolves, "the trigger's points at a symbol that exists"); +}); + +test("a sprite that reaches the client empty loads the bodies instead of staying blank", () => { + const dir = mkdtempSync(join(kit, ".sprite-test-")); + try { + writeFileSync(join(dir, "package.json"), JSON.stringify({ type: "module" })); + writeFileSync(join(dir, "empty.mjs"), ` +import { createRequire } from "node:module"; +const require = createRequire(${JSON.stringify(pathToFileURL(join(kit, "package.json")).href)}); +const { JSDOM } = require("jsdom"); +const dom = new JSDOM("
", { pretendToBeVisual: true }); +for (const key of ["window", "document", "Node", "HTMLElement", "SVGElement", "MutationObserver", "navigator", "requestAnimationFrame"]) { + Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true, writable: true }); +} +const { render } = await import("solid-js/web"); +const { createComponent } = await import("solid-js"); +const { LocaleMenu } = await import(${JSON.stringify(pathToFileURL(join(kit, "dist", "solid.js")).href)}); +const { FAMILY_LOCALES } = await import(${JSON.stringify(pathToFileURL(join(kit, "src", "core", "locales.mjs")).href)}); +const state = { locale: "ko", hrefForLocale: (code) => "/?lang=" + code }; +render(() => createComponent(LocaleMenu, { variant: "flag", state, messages: ${JSON.stringify(MESSAGES)}, registry: FAMILY_LOCALES }), document.querySelector("#root")); +await new Promise((resolve) => setTimeout(resolve, 300)); +const sprite = document.querySelector(".site-flag-sprite"); +process.stdout.write(JSON.stringify({ symbols: sprite ? sprite.querySelectorAll("symbol").length : -1 })); +`); + const client = spawnSync(process.execPath, ["--conditions=browser", join(dir, "empty.mjs")], { cwd: kit, encoding: "utf8" }); + assert.equal(client.status, 0, client.stderr); + assert.equal(JSON.parse(client.stdout).symbols, 14); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + + +test("a sprite emptied before hydration is refilled from the bodies chunk, not left blank", () => { + // Stands in for a hydration that drifted upstream and recreated the sprite + // from its template: hydration is running, the element is empty. + const result = runShell("eager", { STRIP_SPRITE: "1" }); + assert.equal(result.symbols, 14); + assert.ok(result.useResolves); +});