From 70610d96a894cb53220d7f897e1b3c7217769c76 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Tue, 8 Sep 2026 02:27:25 +0900 Subject: [PATCH] feat(site-kit): the flag artwork leaves the browser bundle `@devslab/site-kit/solid` was one 138 KB file and a consumer importing only SiteHeader shipped nearly all of it. Reproduced with a minimal Vite + vite-plugin-solid consumer (fixtures/bundle-probe): importing every export costs 13 KB more than the header alone, so tree-shaking was fine. The bytes were the fourteen vendored flag SVGs (~115 KB, Spain alone 85 KB): LocaleMenu imported them statically, the select/flag choice is a runtime prop so every header reached them, and Flag re-set innerHTML in an effect, hydration included, so the client genuinely needed them. Each flag menu now renders one sprite and every flag is a of it, so a client locale change only swaps an href. The server build writes the sprite (flag-bodies.server.ts, aliased by the server Vite and Vitest configs); hydration adopts it; the browser build reaches the bodies only through a dynamic import() emitted as dist/flag-bodies.js, taken when a menu renders with no server HTML. The generator splits flags.mjs into flag-countries.mjs and flag-bodies.mjs; the ./flags subpath keeps its API and gains flagCountryFor and FLAG_VIEWBOX. The sprite is zero-sized rather than display:none so referenced clip paths and gradients still resolve. `pnpm check` builds the probe and fails if a body returns to the header-only main chunk; tests/site-kit-contracts.test.mjs pins the packaging rules. Measured (gzip): dist/solid.js 138.2 KB (32.5) -> 28.7 KB (8.2); header-only consumer 147.5 KB (38.8) -> 42.8 KB (15.1); TraceLinq landing main client chunk 291.0 KB (86.5) -> 186.3 KB (62.1), its landing Playwright suite 24/24 against the packed tarball, and a Chromium probe on /en, /ko, /ar: sprite in the HTML, zero flag-bodies requests, trigger rendered from the sprite, console clean. D-020. --- .changeset/site-kit-flag-sprite.md | 15 +++ docs/backlog.md | 8 ++ docs/decisions.md | 41 ++++++++ packages/site-kit/README.ko.md | 26 +++-- packages/site-kit/README.md | 4 +- .../site-kit/fixtures/bundle-probe/.gitignore | 1 + .../fixtures/bundle-probe/everything.tsx | 8 ++ .../fixtures/bundle-probe/header-only.tsx | 8 ++ .../fixtures/bundle-probe/tsconfig.json | 9 ++ packages/site-kit/package.json | 2 +- packages/site-kit/scripts/build-flags.mjs | 81 ++++++++++++++-- .../site-kit/scripts/check-client-bundle.mjs | 92 ++++++++++++++++++ packages/site-kit/src/core/flag-bodies.d.mts | 3 + packages/site-kit/src/core/flag-bodies.mjs | 25 +++++ .../site-kit/src/core/flag-countries.d.mts | 6 ++ packages/site-kit/src/core/flag-countries.mjs | 42 ++++++++ packages/site-kit/src/core/flags.d.mts | 7 +- packages/site-kit/src/core/flags.mjs | 43 +------- .../src/solid/__tests__/locale-menu.test.tsx | 97 ++++++++++++++++--- .../site-kit/src/solid/__tests__/ssr.test.tsx | 21 ++++ .../site-kit/src/solid/flag-bodies.server.ts | 14 +++ packages/site-kit/src/solid/flag-bodies.ts | 22 +++++ packages/site-kit/src/solid/locale-menu.tsx | 60 ++++++++++-- packages/site-kit/styles.css | 2 + packages/site-kit/vite.config.ts | 7 +- packages/site-kit/vite.server.config.ts | 7 ++ packages/site-kit/vitest.ssr.config.ts | 8 ++ tests/site-kit-contracts.test.mjs | 31 ++++++ 28 files changed, 608 insertions(+), 82 deletions(-) create mode 100644 .changeset/site-kit-flag-sprite.md create mode 100644 packages/site-kit/fixtures/bundle-probe/.gitignore create mode 100644 packages/site-kit/fixtures/bundle-probe/everything.tsx create mode 100644 packages/site-kit/fixtures/bundle-probe/header-only.tsx create mode 100644 packages/site-kit/fixtures/bundle-probe/tsconfig.json create mode 100644 packages/site-kit/scripts/check-client-bundle.mjs create mode 100644 packages/site-kit/src/core/flag-bodies.d.mts create mode 100644 packages/site-kit/src/core/flag-bodies.mjs create mode 100644 packages/site-kit/src/core/flag-countries.d.mts create mode 100644 packages/site-kit/src/core/flag-countries.mjs create mode 100644 packages/site-kit/src/solid/flag-bodies.server.ts create mode 100644 packages/site-kit/src/solid/flag-bodies.ts 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에서 선택 상태는 `