ui:next - lets rattle the cage :)

This commit is contained in:
lovebird 2026-04-09 17:34:13 +02:00
parent 89606d1b00
commit 7aa76ec55c
7 changed files with 1712 additions and 205 deletions

View File

@ -0,0 +1,314 @@
# 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`**
Without this, 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."_)
```ts
optimizeDeps: {
exclude: ["@tanstack/router-core", "@tanstack/react-router", /* … */],
}
```
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.
---
## 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
rollupOptions: {
output: { format: "es" }, // native ESM output
},
}
```
`target: "esnext"` + `format: "es"` means no CommonJS wrapper, no legacy
syntax transforms, and native dynamic `import()` for code splitting.
`modulePreload: { polyfill: false }` removes the small polyfill that is
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.

File diff suppressed because it is too large Load Diff

View File

@ -7,19 +7,25 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
"build:analyze": "tsc --noEmit && vite build && start dist/stats.html",
"preview": "vite preview",
"serve": "npx serve dist -s"
},
"dependencies": {
"@preact/preset-vite": "^2.10.5",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-router": "^1.114.3",
"preact": "^10.29.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zod": "^3.24.1"
"tailwindcss": "^4.2.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.10.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}

View File

@ -0,0 +1,3 @@
{
"rewrites": [{ "source": "/**", "destination": "/index.html" }]
}

View File

@ -1,9 +1,10 @@
import { z } from "zod";
import { z } from "zod/mini";
/** Matches the kind of validation you get on a Route with TanStack + Zod (Phase 3 / 4). */
/** Typed search params for /examples/search (TanStack validateSearch + Zod v4 mini).
* Note: zod/mini uses z._default() `default` is a JS reserved word. */
export const migrationSearchSchema = z.object({
view: z.enum(["list", "grid"]).optional().default("list"),
q: z.string().optional(),
view: z.optional(z._default(z.enum(["list", "grid"]), "list")),
q: z.optional(z.string()),
});
export type MigrationSearch = z.infer<typeof migrationSearchSchema>;

View File

@ -1,3 +1,5 @@
@import "tailwindcss";
*,
*::before,
*::after {

View File

@ -1,30 +1,72 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import preact from "@preact/preset-vite";
import tailwindcss from "@tailwindcss/vite";
import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig } from "vite";
// Phase 1 (host app): add file-based route generation alongside React:
// import { TanStackRouterVite } from "@tanstack/router-vite-plugin"
// plugins: [TanStackRouterVite({ routesDirectory: "./src/routes" }), react()],
// plugins: [TanStackRouterVite({ routesDirectory: "./src/routes" }), preact()],
// This POC uses a static route tree under `src/router.tsx` + `src/examples/migration/`.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** Resolve a path relative to the workspace root */
const nm = (p: string) =>
fileURLToPath(new URL(`./node_modules/${p}`, import.meta.url));
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
alias: [
// Preact compat — drop-in for react + react-dom without code changes
{ find: "react-dom/client", replacement: "preact/compat/client" },
{ find: "react-dom", replacement: "preact/compat" },
{ find: "react", replacement: "preact/compat" },
// Point every @tanstack import at its TypeScript source so Rollup
// can tree-shake individual functions instead of entire pre-bundled files.
// All five packages ship src/ and their source only depends on each other.
// Subpath exports that have environment-specific variants — always use the browser/client build
{ 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") },
{ find: "@tanstack/react-router", replacement: nm("@tanstack/react-router/src/index.tsx") },
{ find: "@tanstack/history", replacement: nm("@tanstack/history/src/index.ts") },
{ find: "@tanstack/store", replacement: nm("@tanstack/store/src/index.ts") },
{ find: "@tanstack/react-store", replacement: nm("@tanstack/react-store/src/index.ts") },
{ find: "@", replacement: path.resolve(__dirname, "src") },
],
},
plugins: [react()],
// Don't pre-bundle the tanstack packages — let Rollup see their raw source
optimizeDeps: {
exclude: [
"@tanstack/router-core",
"@tanstack/react-router",
"@tanstack/history",
"@tanstack/store",
"@tanstack/react-store",
],
},
plugins: [
tailwindcss(),
preact(),
visualizer({
filename: "dist/stats.html",
open: false,
gzipSize: true,
brotliSize: true,
template: "treemap",
}),
],
build: {
target: "esnext",
modulePreload: { polyfill: false },
rollupOptions: {
output: {
format: "es",
rollupOptions: {
output: { format: "es" },
},
},
},
});