ui:next - lets rattle the cage :)
This commit is contained in:
parent
89606d1b00
commit
7aa76ec55c
314
packages/ui-next/docs/build.md
Normal file
314
packages/ui-next/docs/build.md
Normal 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.
|
||||
1515
packages/ui-next/package-lock.json
generated
1515
packages/ui-next/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
|
||||
3
packages/ui-next/public/serve.json
Normal file
3
packages/ui-next/public/serve.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"rewrites": [{ "source": "/**", "destination": "/index.html" }]
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
|
||||
@ -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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user