ReviewOS

stacks/stx

feat: static and production builds inline the whole crosswind stylesheet into every page — emit one content-hashed asset, like the serve path already does

#1878
Closed glennmichael123 opened this 10 days ago ·
10 days ago

Summary

injectCrosswindCSS already has two output modes: a content-hashed <link> to /_stx/crosswind.<hash>.css, and an inline <style data-crosswind="generated">. Only the dev/serve path passes the flag that selects the first. Every static and production render path calls it with the default, so each page carries its own full copy of the generated CSS — preflight included — and no two pages share a cache entry. There is also no per-layout preflight control, so a page that supplies its own reset gets a second one it cannot turn off.

Impact (real app)

analyticshq builds to 35 HTML pages and exactly one .css file, and that one is the app's own hand-written marketing.css — nothing crosswind generated is ever written to disk. Measured on the built output:

pageinline <style data-crosswind>
dist/index.html2,863 bytes
dist/features.html2,813 bytes
dist/pricing.html4,623 bytes
dist/dashboard.html4,641 bytes

grep -c 'data-crosswind="generated" rel="stylesheet"' dist/index.html → 0. Every page took the inline branch.

On the 27 marketing pages that block is almost entirely preflight, because those pages use no crosswind utilities at all — their styling comes from the hand-written sheet. So the reset is re-downloaded on every navigation and never cached across pages.

And because preflight is project-wide or nothing, the app ends up with two resets. config/crosswind.ts:21 sets preflight: true globally, and then public/marketing.css:25-55 hand-writes its own on top:

* { box-sizing: border-box; }
html { background: var(--void); scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
body { margin: 0; … }
a { color: inherit; text-decoration: none; }

That second reset exists only because the first one is not controllable per layout.

Evidence in stx

packages/stx/src/dev-server/crosswind.ts (v0.2.155, origin/main 1c053f2):

781  export async function injectCrosswindCSS(htmlContent: string, appDir?: string, serveMode = false)
…
800  const assetTag = serveMode
801    ? `<link data-crosswind="generated" rel="stylesheet" href="/_stx/crosswind.${registerServeCss(css)}.css">`
802    : `<style data-crosswind="generated">\n${css}\n</style>`

The hashed-asset path is already built and already public: registerServeCss at :95-103 (xxHash64 via Bun.hash, LRU-bounded), getCrosswindServeAsset at :105-113, exported from packages/stx/src/index.ts:176 and served by packages/bun-plugin/dist/serve.js:10055. Nothing new needs inventing — it just isn't reachable from a build.

Every static/production caller takes the inline branch:

  • packages/stx/src/ssg.ts:687 — html = await injectCrosswindCSS(html)
  • packages/stx/src/production-builder.ts:275 — compiled.html = await injectCrosswindCSS(compiled.html)
  • packages/stx/src/site-builder/build.ts:144 — html = await injectCrosswindCSS(html, process.cwd())
  • packages/stx/src/render.ts:409 — output = await injectCrosswindCSS(output)

Only packages/stx/src/process.ts:650 passes a value: injectCrosswindCSS(result, undefined, options.buildMode === 'serve').

Preflight is one boolean for the whole project — packages/stx/src/dev-server/crosswind.ts:687-688:

const includePreflight = (userConfig as any).preflight !== false

applied identically to every page, and crosswind's own surface is likewise a single flag (includePreflight, crosswind packages/crosswind/src/types.ts:95, consumed in generator.ts:2793-2818). Neither package exposes a per-layout or per-route hook.

Prior art

  • Nuxt / Vite emit one content-hashed CSS chunk per entry, shared across navigations and served with long-lived cache headers.
  • Next.js does per-route CSS chunking on top of a shared base chunk, so common CSS is fetched once.
  • Astro emits shared stylesheets and exposes build.inlineStylesheets: 'always' | 'auto' | 'never' so inlining is a decision, not a default.
  • Tailwind corePlugins: { preflight: false } (v3) and importing tailwindcss/utilities alone (v4) let a project or a specific entry opt out of the reset.

Ask

  1. Write the generated CSS to a content-hashed file in the build output (dist/_stx/crosswind.<hash>.css) and reference it with a <link>, on the SSG (ssg.ts:687), production-builder (production-builder.ts:275) and site-builder (site-builder/build.ts:144) paths — the same shape serveMode already produces at dev-server/crosswind.ts:801.
  2. Emit one shared asset for the union of pages where the class sets overlap, rather than one file per page, so cross-page navigation is a cache hit rather than a fresh parse.
  3. Keep an explicit escape hatch (inlineStylesheets: 'auto' | 'always' | 'never', or a crosswindAsset mode on the build options) so small pages can still inline — flipping the default with no opt-out would be its own regression.
  4. Allow preflight to be set per layout or per route (e.g. a layout-level preflight: false), not only project-wide, so the pages that ship their own reset stop getting two.

Related: #1822 (which made the project-wide preflight flag actually take effect)

9 days ago

Fixed in 18ce3c8.

externalizeSharedAssets() (packages/stx/src/build-externalize.ts) runs as a post-pass over emitted HTML in both ssg.ts and site-builder/build.ts, lifting the signals runtime, the router and the generated stylesheet into content-hashed files under _stx/. Content-addressed, so the runtime — byte-identical on every page — collapses to a single asset regardless of page count.

Measured on an SSG build:

BEFORE  app.html 168,019 bytes   index.html 38,380 bytes   (no .js/.css emitted at all)
AFTER   app.html   1,817 bytes   index.html    465 bytes
        _stx/runtime.15ac127c.js  163KB
        _stx/router.d5edb93b.js    38KB
        _stx/crosswind.586ccf05.css 3KB

Two things worth recording from the fix:

  • The router shipped inline on every page, including pages with no client script at all — on index.html it was the only large blob present, which is why that page was 38KB of nothing.
  • The obvious fix — setting buildMode: 'compile' on the SSG path — is a trap. That branch emits /__stx/runtime.__STX_HASH__.js, a placeholder only the production builder knows how to rewrite, and it also suppresses the document shell. You'd get pages referencing a 404 with no <html> wrapper. The post-pass avoids both, and the tests assert every emitted href resolves to a real file.

The pass runs before the asset copies so it only walks pages the build rendered, and it's idempotent (an already-external script has no inline body to match). Covered by packages/stx/test/build/externalize-shared-assets.test.ts.

Sign in to comment on this issue.