mono/packages/ui-next/docs/build.md
2026-04-09 17:54:54 +02:00

14 KiB
Raw Blame History

Build system

This document explains the Vite configuration decisions, the optimizations applied, and the day-to-day scripts for this package.


Scripts

Command What it does
npm run dev Vite dev server with HMR
npm run build Type-check (tsc --noEmit) then produce dist/
npm run build:analyze Same as build, then opens dist/stats.html (bundle treemap)
npm run preview Vite's built-in preview server — SPA-aware, no config needed
npm run serve npx serve dist -s — static server with SPA fallback (see below)

SPA routing and static servers

TanStack Router (like all client-side routers) ships a single index.html. Every URL — /examples/search, /app/settings, etc. — is handled by JavaScript after the browser loads that one file.

What breaks: a plain static file server gets a request for /examples/search, finds no file at that path, and returns 404 before the JS ever runs.

The fix: fall back to index.html for every unknown path.

vite preview

Always SPA-aware out of the box. Use this for quick post-build checks.

npm run preview

serve (or any other static server)

The -s / --single flag enables the fallback:

npx serve dist -s
# or
npm run serve

public/serve.json (committed, copied to dist/ on every build) carries the same config for when serve is invoked without the flag:

{ "rewrites": [{ "source": "/**", "destination": "/index.html" }] }

Other servers

Server SPA config
nginx try_files $uri $uri/ /index.html; in the location / block
Apache FallbackResource /index.html in .htaccess
Caddy try_files {path} /index.html
Express app.get('*', (_, res) => res.sendFile('dist/index.html'))

Bundle analysis

npm run build:analyze
# opens dist/stats.html automatically (Windows: `start`)

rollup-plugin-visualizer writes dist/stats.html — an interactive treemap of every module in the bundle, with rendered, gzip, and brotli sizes.

Config in vite.config.ts:

visualizer({
  filename: "dist/stats.html",
  gzipSize: true,
  brotliSize: true,
  template: "treemap",   // alternatives: sunburst, network, raw-data, list
})

Optimizations

All three optimizations are cumulative and independent — they can be applied separately to any Vite + React project.

Baseline

Fresh scaffold with react-dom, Zod v3, and pre-bundled TanStack Router:

index.js   299.6 kB │ gzip: 90.5 kB

1. Preact instead of react-dom

react-dom was ~133 kB rendered / 42 kB gzip. Preact ships the same React 18 API in ~10 kB gzip via its compatibility layer (preact/compat).

No application code changes — only Vite aliases redirect the imports:

// vite.config.ts
{ find: "react-dom/client", replacement: "preact/compat/client" },
{ find: "react-dom",        replacement: "preact/compat" },
{ find: "react",            replacement: "preact/compat" },

The @preact/preset-vite plugin handles JSX transform and prefresh (HMR). preact itself is listed as a dependency alongside the nominal react / react-dom stubs (kept to satisfy peer-dep requirements from other packages).

Saving: ~36 kB gzip


2. zod/mini instead of zod

Full Zod ships ~115 kB rendered / 15 kB gzip for Zod v3. Zod v4's zod/mini sub-package drops the full schema builder for a tree-shakeable API, landing at ~5 kB gzip for our usage.

One API difference to be aware of: default is a reserved JavaScript keyword so zod/mini exposes it as z._default():

// searchSchema.ts
import { z } from "zod/mini";

export const migrationSearchSchema = z.object({
  view: z.optional(z._default(z.enum(["list", "grid"]), "list")),
  q:    z.optional(z.string()),
});

Saving: ~10 kB gzip


3. Mounting library TypeScript source

The largest remaining chunk was TanStack Router at ~30 kB gzip. Its npm package ships a single pre-bundled ESM file (dist/esm/index.js) per package. Even though Rollup tree-shakes at module-file boundaries, everything inside that one big file is opaque — unused exports of router.ts, link.ts, etc. survive. (Rollup docs — treeshake.moduleSideEffects: "code from imported modules will only be retained if at least one exported value is used" — but that still requires Rollup to see individual modules, not a single pre-bundled blob.)

The insight: all five TanStack packages also ship their raw TypeScript source in src/. This is not accidental — src/ is in the package's published "files" list alongside dist/:

"files": ["dist", "src", "skills", "bin", "!skills/_artifacts"]

Additionally, every TanStack package declares "sideEffects": false in its package.json, which is the official signal to bundlers — per the webpack tree-shaking spec adopted by Rollup — that every module in the package is pure and can be pruned if its exports are unused.

Pointing Vite's alias at src/index.ts gives Rollup 194 individual source modules to work with instead of 5 pre-bundled blobs — function-level tree-shaking becomes possible, and the "sideEffects": false declaration is now honoured at source granularity.

Prerequisite checks

Before applying this technique to any library, verify:

  1. Source is present in the npm package

    ls node_modules/@tanstack/router-core/src/
    

    Check the package's "files" field — if "src" is not listed, the directory is absent from the installed package and there is nothing to alias.

  2. External deps inside the source are resolvable

    Scan for non-relative imports in src/. For TanStack the graph is closed:

    @tanstack/react-router/src
      → @tanstack/router-core/src     (aliased)
      → @tanstack/history/src         (aliased)
      → @tanstack/react-store/src     (aliased)
      → react / react-dom             (already → preact/compat)
    
  3. Subpath exports exist and have correct variants

    Some packages export environment-specific files via the Node.js package subpath exports map (e.g. browser vs node vs development conditions). Without explicit aliases Vite appends the subpath to the file alias and produces an invalid path like src/index.ts/isServer. Fix: declare subpath aliases before the base-package alias (Vite resolves aliases in array order):

    { find: "@tanstack/router-core/isServer",
      replacement: nm("@tanstack/router-core/src/isServer/client.ts") },
    { find: "@tanstack/router-core/scroll-restoration-script",
      replacement: nm("@tanstack/router-core/src/scroll-restoration-script/client.ts") },
    { find: "@tanstack/router-core",
      replacement: nm("@tanstack/router-core/src/index.ts") },
    

    Use the client.ts variant for browser builds (browser / import condition in the exports map — confirmed in the official exports map).

  4. Exclude from optimizeDeps — but skip packages with CJS transitive deps

    Without exclude, Vite's dev pre-bundler re-bundles the source into a single opaque chunk and defeats the purpose in dev mode. (Vite docs — optimizeDeps.exclude: "Dependencies to exclude from pre-bundling.")

    The Vite docs warn:

    "CommonJS dependencies should not be excluded from optimization. If an ESM dependency is excluded from optimization, but has a nested CommonJS dependency, the CommonJS dependency should be added to optimizeDeps.include."

    @tanstack/react-store is the exception here. Its source file src/useStore.ts imports use-sync-external-store/shim/with-selector which is a CJS-only file (module.exports = ...). If that package is excluded from optimizeDeps, Vite serves the raw CJS as ESM and the named import breaks at runtime in dev:

    Uncaught SyntaxError: The requested module
    '.../use-sync-external-store/shim/with-selector.js'
    does not provide an export named 'useSyncExternalStoreWithSelector'
    

    The fix requires including the exact import subpath, not the package root. @tanstack/react-store/dist/esm/useStore.js imports use-sync-external-store/shim/with-selector — that is the string Vite must match to serve the pre-bundled ESM wrapper:

    optimizeDeps: {
      exclude: ["@tanstack/router-core", "@tanstack/react-router",
                "@tanstack/history", "@tanstack/store"],
      include: ["use-sync-external-store/shim/with-selector"],
    }
    

    Including "use-sync-external-store" (the root) does not fix it — Vite pre-bundles at subpath granularity and the pre-bundled root is never served for the subpath import.

    General rule: before source-aliasing a package, scan its src/ for non-relative imports and check whether any of those are CJS-only (no "type": "module", no ESM exports map, uses module.exports). If so, add the exact import path to optimizeDeps.include.

  5. Do not set treeshake.moduleSideEffects: false globally

    Setting it globally tells Rollup every module is pure and eliminates any module whose exports are unused. App entry points (main.tsx calls createRoot().render() — a side effect with no importer) are silently dropped. Keep the default (true) or scope the override to known-pure library packages only. (Rollup docs — treeshake.moduleSideEffects)

Saving: ~10 kB gzip (on top of the Preact and zod/mini savings)


Combined result

Stage Rendered Gzip
Baseline (react-dom, zod v3, pre-bundled) 299.6 kB 90.5 kB
+ Preact ~210 kB ~54 kB
+ zod/mini ~175 kB ~46 kB
+ TanStack source aliases 139.9 kB 46.4 kB
Total reduction 53 % 49 %

The gzip delta for the source-alias step is small because gzip compresses repeated patterns well — the visible win is in the raw rendered size (35 kB) and in better code-splitting potential, since Rollup can now split individual TanStack modules across async chunks.


rolldown-vite

Rolldown is a Rust-powered bundler designed as a drop-in replacement for Rollup. Vite is migrating to it officially; in the meantime it is available as the separate package rolldown-vite.

Installing

Use the npm: package alias so vite resolves to rolldown-vite everywhere — plugins and scripts continue to import from 'vite' unchanged:

"devDependencies": {
  "vite": "npm:rolldown-vite@7.3.1"
}

Pin the version. rolldown-vite is experimental and may introduce breaking changes in patch releases. Do not use @latest in committed config.

Do not use "overrides" alongside a direct vite devDependency — npm rejects the conflict.

What changes

Area Before (Rollup/esbuild) After (Rolldown)
Production bundler Rollup Rolldown (Rust)
Dep pre-bundler esbuild Rolldown
CJS interop @rollup/plugin-commonjs Rolldown native
JS minifier esbuild Oxc
CSS minifier esbuild Lightning CSS

Config notes

rollupOptions.output.format is a Rollup-only option not recognised by Rolldown — remove it. Rolldown outputs ES modules by default, so no replacement is needed.

Benchmark

Build time Main JS gzip
Vite 5 + Rollup ~1100 ms 46.4 kB
rolldown-vite 7 331 ms 45.8 kB

3× faster build, marginally smaller output. Dev server cold-start also drops from ~730 ms to ~557 ms.

Known limitations

  • Some Rollup output options produce validation warnings (unknown keys). Remove options that Rolldown handles by default (format: "es", etc.).
  • manualChunks is deprecated in favour of Rolldown's advancedChunks.
  • The transformWithEsbuild Vite helper now requires esbuild installed separately (Rolldown uses Oxc internally).

See the official rolldown-vite docs for the full compatibility matrix.


Build targets and module format

build: {
  target: "esnext",               // no transpilation downgrade, modern browsers only
  modulePreload: { polyfill: false }, // drop the ~1.6 kB modulepreload polyfill
  // format defaults to "es" in rolldown-vite — no rollupOptions needed
}

target: "esnext" means no legacy syntax transforms and native dynamic import() for code splitting. modulePreload: { polyfill: false } removes the small polyfill irrelevant for modern browsers.


Tailwind CSS v4

Tailwind v4 ships a dedicated Vite plugin that runs as a PostCSS-free pipeline, replacing the @tailwindcss/postcss approach from v3. The import in CSS is also simplified:

/* src/styles.css */
@import "tailwindcss";

Plugin in vite.config.ts:

import tailwindcss from "@tailwindcss/vite";
// …
plugins: [tailwindcss(), preact(), visualizer()]

The Tailwind plugin must be listed first so it processes CSS before the JSX transform runs.