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

410 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
```bash
npm run preview
```
### `serve` (or any other static server)
The `-s` / `--single` flag enables the fallback:
```bash
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:
```json
{ "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
```bash
npm run build:analyze
# opens dist/stats.html automatically (Windows: `start`)
```
[`rollup-plugin-visualizer`](https://github.com/btd/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`:
```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:
```ts
// 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()`:
```ts
// 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`](https://rollupjs.org/configuration-options/#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](https://github.com/TanStack/router/blob/main/packages/router-core/package.json)
alongside `dist/`:
```json
"files": ["dist", "src", "skills", "bin", "!skills/_artifacts"]
```
Additionally, every TanStack package declares
[`"sideEffects": false`](https://github.com/TanStack/router/blob/main/packages/router-core/package.json)
in its `package.json`, which is the official signal to bundlers — per the
[webpack tree-shaking spec](https://webpack.js.org/guides/tree-shaking/#mark-the-file-as-side-effect-free)
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**
```bash
ls node_modules/@tanstack/router-core/src/
```
Check the package's [`"files"` field](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#files)
— 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](https://nodejs.org/api/packages.html#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](https://vitejs.dev/config/shared-options.html#resolve-alias)):
```ts
{ 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](https://github.com/TanStack/router/blob/main/packages/router-core/package.json)).
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`](https://vitejs.dev/config/dep-optimization-options.html#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:
```ts
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`](https://rollupjs.org/configuration-options/#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](https://rolldown.rs) 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:
```json
"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](https://vite.dev/guide/rolldown) for the
full compatibility matrix.
---
## Build targets and module format
```ts
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:
```css
/* src/styles.css */
@import "tailwindcss";
```
Plugin in `vite.config.ts`:
```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.