diff --git a/.changeset/site-kit-flag-sprite.md b/.changeset/site-kit-flag-sprite.md new file mode 100644 index 0000000..bb5cf37 --- /dev/null +++ b/.changeset/site-kit-flag-sprite.md @@ -0,0 +1,15 @@ +--- +"@devslab/site-kit": minor +--- + +The flag artwork leaves the browser bundle. `@devslab/site-kit/solid` was one 138 KB file, and a consumer that imported only `SiteHeader` shipped nearly all of it — not because tree-shaking failed (importing every export adds just 13 KB) but because the menu imported the fourteen vendored flag SVGs statically (~115 KB; Spain's coat of arms alone is 85 KB) and re-wrote them into the DOM on the client, hydration included. `LocaleMenu` decides `select` vs `flag` at runtime, so every header reached them. + +Now each flag menu renders one `` sprite and every flag is an `` of it, so a locale change on the client only swaps an `href`. The server build writes the sprite; hydration adopts it; the browser build reaches the bodies only through a dynamic `import()` — emitted as its own chunk, `dist/flag-bodies.js` — taken when a flag menu renders with no server HTML (a client-only app, a jsdom test). The generated data splits into `flag-countries.mjs` (the map the menu needs) and `flag-bodies.mjs`; `@devslab/site-kit/flags` keeps its API and gains `flagCountryFor` and `FLAG_VIEWBOX`. `pnpm check` now builds a minimal consumer (`fixtures/bundle-probe`) with Vite + vite-plugin-solid and fails if a flag body ever returns to the main chunk. No consumer code changes. + +Measured, gzip in parentheses. `dist/solid.js`: 138.2 KB (32.5) → 28.7 KB (8.2). Minimal consumer importing `SiteHeader` only: 147.5 KB (38.8) → 42.8 KB (15.1). TraceLinq landing (`packages/landing`, TanStack Start, main client chunk): 291.0 KB (86.5) → 186.3 KB (62.1); the 105 KB (23.7) flag chunk is emitted beside it and is not requested by a hydrated page. Server-rendered HTML still carries the bodies, once per country per menu instead of the current locale twice. + +국기 아트워크가 브라우저 번들에서 빠집니다. `@devslab/site-kit/solid`는 138 KB 파일 하나였고 `SiteHeader`만 import한 소비자도 거의 통째로 실었습니다 — tree-shaking 실패가 아니라(전부 import해도 13 KB 차이) 메뉴가 벤더링한 국기 SVG 14개(~115 KB, 스페인 문장 하나가 85 KB)를 정적으로 import하고 클라이언트에서, 하이드레이션 중에도, DOM에 다시 쓰고 있었기 때문입니다. `LocaleMenu`가 `select`/`flag`를 런타임에 고르므로 모든 헤더가 거기에 닿았습니다. + +이제 국기 메뉴마다 `` 스프라이트 하나를 렌더링하고 모든 국기는 그것을 ``로 참조하므로, 클라이언트에서 로케일이 바뀌어도 `href`만 바뀝니다. 스프라이트는 서버 빌드가 쓰고, 하이드레이션은 그대로 인수하며, 브라우저 빌드는 본문을 동적 `import()`(자기 청크 `dist/flag-bodies.js`)로만 닿습니다 — 서버 HTML 없이 국기 메뉴가 렌더링될 때(클라이언트 전용 앱, jsdom 테스트)에만. 생성 데이터는 `flag-countries.mjs`(메뉴가 필요한 지도)와 `flag-bodies.mjs`로 나뉘고 `@devslab/site-kit/flags`는 API를 유지한 채 `flagCountryFor`·`FLAG_VIEWBOX`를 얻습니다. `pnpm check`가 최소 소비자(`fixtures/bundle-probe`)를 Vite + vite-plugin-solid로 빌드해 국기 본문이 메인 청크로 되돌아오면 실패합니다. 소비자 코드 변경은 없습니다. + +측정(괄호는 gzip). `dist/solid.js`: 138.2 KB (32.5) → 28.7 KB (8.2). `SiteHeader`만 import한 최소 소비자: 147.5 KB (38.8) → 42.8 KB (15.1). TraceLinq 랜딩(`packages/landing`, TanStack Start, 클라이언트 메인 청크): 291.0 KB (86.5) → 186.3 KB (62.1); 105 KB (23.7) 국기 청크는 옆에 생성되지만 하이드레이션된 페이지는 요청하지 않습니다. 서버 렌더 HTML에는 본문이 여전히 실립니다 — 현재 로케일 두 번 대신 메뉴당 나라마다 한 번. diff --git a/docs/backlog.md b/docs/backlog.md index 67c98da..65b2d19 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -154,6 +154,14 @@ Geist/Geist Mono 셀프호스팅 여부, Pretendard 서브셋, RN 번들 전략. ### 12. site-kit 국기 로케일 피커 — `완료` (2026-09-02) D-017. `LocaleMenu variant="flag"` + 국기 데이터 14개. 첫 소비자 TraceLinq 랜딩. +### 13. site-kit 브라우저 번들에서 국기 아트워크 분리 — `완료` (2026-09-08) +D-020. TraceLinq 랜딩의 클라이언트 메인 청크가 `dist/solid.js` 통째(138 KB)만큼 +컸던 것 — tree-shaking이 아니라 국기 SVG 115 KB가 `SiteHeader`에 딸려 온 것. +스프라이트 + ``, 서버만 본문 작성, 브라우저는 동적 청크(서버 HTML 없을 +때만). `pnpm check`의 최소 소비자 게이트(`fixtures/bundle-probe`)가 회귀를 막는다. +- 남은 것: 서버 HTML의 본문 ~115 KB/페이지 → `` + 소비자 정적 서빙 + (API 변경, D-020 재검토 시점). + --- ## P3 — 모바일 이후 diff --git a/docs/decisions.md b/docs/decisions.md index 6c8cb31..b0d8a62 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,6 +5,47 @@ --- +## D-020 — 국기 아트워크는 서버가 쓰고 브라우저는 ``한다 (2026-09-08) + +**결정.** `LocaleMenu variant="flag"`는 메뉴당 `` 스프라이트 하나(나라당 +본문 하나)를 렌더링하고 모든 국기를 ``로 참조한다. 스프라이트 +마크업은 **서버 빌드만** 쓴다(`src/solid/flag-bodies.server.ts`, 서버 Vite·Vitest +설정이 alias). 브라우저 빌드(`dist/solid.js`)는 본문을 정적으로 import하지 않고 +동적 `import()`(자기 청크 `dist/flag-bodies.js`)로만 닿으며, 그마저 서버 HTML 없이 +렌더링될 때(하이드레이션이 아닐 때 — `sharedConfig.context` 부재)만 실행한다. +생성기는 `flags.mjs` 하나 대신 `flag-countries.mjs`(로케일→나라, 300바이트)와 +`flag-bodies.mjs`(본문 110 KB)로 나누고 `flags.mjs`는 둘을 합친 공개 서브패스로 +남는다(API 무변경, `flagCountryFor`·`FLAG_VIEWBOX` 추가). `pnpm check`가 최소 +소비자(`fixtures/bundle-probe`)를 실제 Vite로 빌드해 본문이 메인 청크로 돌아오면 +실패한다. + +**근거.** TraceLinq 랜딩(D-017 첫 소비자)의 클라이언트 메인 청크가 빈 스캐폴드 +대비 ~147 KB 커졌고, 그 크기가 `dist/solid.js` 통째(138 KB)와 같아 tree-shaking +실패로 보였다. 최소 소비자로 재현하니 **tree-shaking은 되고 있었다** — 전부 +import해도 헤더만 import한 것보다 13 KB 클 뿐. 남은 115 KB는 국기 SVG 14개 +(스페인 문장 하나가 85 KB)로, `LocaleMenu`가 `variant`를 **런타임에** 분기하므로 +`select` 변형을 쓰는 소비자도 헤더 하나로 국기 전부를 실었고, `Flag`가 effect +안에서 `innerHTML`을 다시 써서 하이드레이션에도 본문이 필요했다. 즉 패키징이 +아니라 **데이터가 있는 자리**의 문제다. `preserveModules`나 컴포넌트별 +서브패스는 이 바이트를 한 개도 못 뺀다 — 헤더가 정당하게 그 분기에 닿기 때문. + +**측정.** 최소 소비자(Vite + vite-plugin-solid, `SiteHeader`만): 147.5 KB / +gzip 38.8 KB → 42.8 KB / gzip 15.1 KB. `dist/solid.js`: 138.2 KB / gzip 32.5 KB → +28.7 KB / gzip 8.2 KB. TraceLinq 랜딩 실측은 changeset에. + +**트레이드오프.** ① 스프라이트는 `display:none`이 아니라 0×0 절대배치 — 참조된 +clipPath·그라디언트가 `display:none` 트리에서는 해석되지 않는 브라우저가 있다. +② 클라이언트 전용 렌더(서버 HTML 없음)는 국기가 한 박자 늦게 찬다 — 가족 제품은 +전부 SSR이라 실제 경로가 아니며, jsdom 테스트는 `await`한다. ③ 서버 HTML에는 +본문이 여전히 실린다(메뉴당 나라마다 한 번 — 이전엔 현재 로케일이 두 번). +페이지 무게 ~115 KB는 그대로다; 국기를 **가져오는 파일**(``, 브라우저 +캐시)로 옮기면 그것도 사라지지만 소비자마다 정적 파일 서빙이 필요한 API 변경이라 +별도 결정. ④ D-017의 "제품은 아트워크를 싣지 않는다"는 유지 — 여전히 site-kit +데이터, 자리만 바뀜. + +**재검토 시점.** 서버 HTML 무게가 문제로 측정되거나 SSR 없는 소비자가 생기면 +(그때 `` + 소비자 정적 서빙 헬퍼를 검토한다). + ## D-019 — SSR에서 선택 상태는 `