410 lines
14 KiB
Markdown
410 lines
14 KiB
Markdown
# 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.
|