Skip to content

3. phaze-vite

1. phaze-tsplugin ← editor (TS Language Service)
2. phaze-compile ← build-time AST rewriting
├── babel-plugin.ts ← the actual Babel plugin (all the visitors live here)
└── vite-plugin.ts ← thin wrapper that adapts it for Vite
3. phaze-vite ← island HMR + chunking helpers ← you are here
4. phaze-astro ← Astro integration (island model)
5. phaze-cloudflare ← native Cloudflare Workers adapter (whole-page)

@madenowhere/phaze-vite is a small Vite-only package that does two unrelated things:

  1. Per-component HMR for .tsx/.jsx files in dev mode.
  2. phazeChunks() — a builder that classifies phaze-owned modules into named chunks. On Vite 8 you feed it to Rolldown’s codeSplitting.groups; on Vite ≤7 (current Astro) it’s the manualChunks callback Rollup uses.

They’re both Vite-specific (couldn’t apply to a plain Babel consumer), they’re both pure build/dev tooling (no runtime), and they’re both small enough that splitting them into two packages would be more friction than they’re worth. So they share a package.

This is completely separate from phaze-compile (page #2). phaze-compile does AST rewriting; phaze-vite does HMR injection and chunk-layout decisions. Different concerns, different files, different code.

A tempting alternative is one mega-package: “phaze-build” that includes AST rewriting + HMR + chunking. The reason phaze splits them:

Concernphaze-compilephaze-vite
What it doesAST rewritingHMR injection + chunk-grouping builder
What it needsBabel (@babel/core)Vite’s Plugin type + the bundler’s chunk types
Bundler scopeAny (raw Babel, Vite, esbuild via babel-jest, …)Vite-only
When it runsOnce per file per buildHMR runs at dev-time only; phazeChunks() runs at config-resolve time

A non-Vite Babel consumer (Storybook with the babel-jest preset, a custom Rollup build that doesn’t use Vite, etc.) wants phaze-compile but NOT the HMR machinery — and shouldn’t have to install Vite’s typing-only dep just to use phaze. Conversely, the HMR plugin pulls in Vite types but doesn’t need Babel. Keeping them separate means each install footprint is minimal.

In a Vite dev server — Astro’s astro dev, or phaze-cloudflare’s pnpm dev:hmr — Vite serves .tsx modules with an import.meta.hot HMR API. phaze-vite’s plugin appends an HMR boilerplate snippet to every user-authored .tsx/.jsx file under src/ (skipping node_modules). The snippet:

  1. Tracks the default-exported component function by its module URL.
  2. Registers an import.meta.hot.accept(...) callback.
  3. When the module re-loads (because you saved the file), the callback calls phaze’s replace(url, newDefaultExport) runtime helper.
  4. The runtime disposes each active mount of that component and re-renders it with the new function, reusing the mount’s cached props — no full-page reload.

This is Tier 1 HMR: because step 4 is a dispose + re-render, it is a re-mount — the component body runs again, so component-local signal state resets. Module-scope signals survive (Vite re-evaluates only the changed module). Value-preserving (Tier 2) HMR — diffing signal shapes and transferring values across the swap — is not implemented.

The plugin is opt-in: phaze-vite’s HMR only kicks in when you call phazeVite() in vite.plugins. Without it, a saved component triggers Vite’s default full-page reload (works, just less ergonomic).

What this is NOT: general-purpose HMR. It targets only default-exported functions (components). Non-component edits (a utility-helper module, a CSS module, an .astro page) follow Vite’s standard HMR or full-page-reload behavior — phaze-vite doesn’t touch them.

Production builds: the HMR boilerplate is dropped. import.meta.hot is undefined in production, the if (import.meta.hot) … guard dead-strips, and the production bundle never ships the registration code.

Surface 2 — phazeChunks() for chunk grouping

Section titled “Surface 2 — phazeChunks() for chunk grouping”

The other half of phaze-vite is phazeChunks(opts?) — a function that returns a module-id → chunk-name classifier (the manualChunks callback shape). It decides which output chunk each module ends up in. The default heuristics work fine for app code, but phaze-owned modules have specific layout preferences:

  • The phaze runtime (@madenowhere/phaze core + JSX runtime + flow components) should stay together in a chunk named phaze — that’s the sub-3-KB-brotli budget headline.
  • Opt-in directive packages (@madenowhere/phaze-directives + subpaths) can either get their own phaze-directives chunk (better caching granularity, ~0.36 KB brotli first-paint cost) or fold into the consuming component’s chunk (smaller eager-total bytes, no cache reuse).
  • The Astro actions bridge (@madenowhere/phaze-astro/actions) can also split or fold.

phazeChunks({ chunkDirectives, chunkActions, chunkSubpaths }) builds the classifier that implements these decisions:

import { phazeChunks } from '@madenowhere/phaze-vite/chunks'
const phazeChunkOf = phazeChunks({
chunkDirectives: false, // fold directives into consuming components
chunkActions: false, // fold actions into consuming components
chunkSubpaths: false, // fold /store, /portal, /catch, /time, /match, /list, /defer, /revalidate into consuming components
})

On Vite 8 (Rolldown — the phaze-cloudflare path) feed the classifier to codeSplitting.groups. A bare manualChunks function is non-authoritative for shared modules on Rolldown — its auto-splitter can still merge a shared chunk away (the sub-3 KB phaze runtime collapses into a consumer chunk), so the runtime needs an explicit, high-priority group of its own:

defineConfig({
environments: {
client: {
build: {
rolldownOptions: {
output: {
codeSplitting: {
minSize: 0, // don't let the tiny phaze chunk get merged away
groups: [
// Explicit high-priority group claims the shared runtime first.
{ name: 'phaze', test: (id) => phazeChunkOf(id) === 'phaze', priority: 100 },
// Everything else: reuse the same classifier as a `name(id)` fn.
{ name: (id) => phazeChunkOf(id) ?? null },
],
},
},
},
},
},
},
})

On Vite ≤7 (current Astro) the same classifier is the Rollup manualChunks callback directly — no codeSplitting.groups, no shared-module caveat:

defineConfig({
build: { rollupOptions: { output: { manualChunks: phazeChunkOf } } }, // Astro / Vite ≤7
})

The flags are independent — you can split some and fold the others. phaze ALWAYS gets its own chunk regardless of the flags (that’s the runtime budget headline) — via the high-priority group on Vite 8, or directly via the classifier on Vite ≤7.

FlagDefaultWhat true doesWhat false does
chunkDirectivestrueShared phaze-directives chunk for @madenowhere/phaze-directives + legacy phaze-in-view / phaze-scrambleFold each directive into the chunk of the component that imports it. Saves chunk-boundary overhead when a directive has a single importer.
chunkActionstruephaze-actions chunk for @madenowhere/phaze-astro/actions (useAction)Fold useAction into the action-using component’s chunk
chunkSubpathstrueEach opt-in core subpath (/store, /portal, /catch, /time, /match, /list, /defer, /revalidate) gets its own named chunk — better caching when multiple components share the subpathFold each subpath into the consuming component’s chunk. Saves the ~150–200 B brotli of chunk-boundary overhead (the bundler’s ESM wrapper + lost brotli dedup across chunks) when only one or two components use the subpath — the typical case. For /store specifically, real-app measurement shows ~580 B brotli with true vs. ~400 B brotli with false. Note that for compile-time-stripped subpaths (/numeric, /match, /list) the chunk is empty in production when phaze-compile is in the pipeline — the rewrite drops the import.
chunkComponentsfalseEvery /src/components/<Name>.tsx direct child gets its own kebab-case chunk (FabricCanvas.tsxfabric-canvas, etc.). Or pass a string[] to split only the named ones.The consumer’s own chunking config decides where each component lands. Default — the CWV-optimal shape for almost every app. See “Why the default is false” below.

Per-component splitting LOOKS like a win in isolation — the bundler correctly marks dynamically-imported components as ⊘ lazy, and the eager-brotli total drops by a kilobyte or two. Real CWV measurement contradicts that on live deploys. The bundled ui-components-style shape consistently loads faster.

Mechanisms:

  • Brotli inter-chunk dictionary dedup is lost across chunk boundaries. BR-per-chunk in isolation is smaller, but combined waterfall delivers more bytes than the bundled form because each chunk rebuilds its dictionary from scratch.
  • HTTP/2/3 per-stream overhead is amortized over many tiny chunks. Each header (0.14 KB), contact-email (0.25 KB), layout (0.36 KB) is its own stream with its own headers, slow-start interaction, and server-side dispatch. Ten small streams cost more wall-clock than one big stream.
  • “lazy: dynamic import” is honest about Rollup’s view, not user-perceived lazy. A component that mounts on first paint still loads regardless of chunk classification — chunkComponents: true just shifts WHERE in the waterfall the request happens, not whether it happens. The ⊘ lazy tag means “the bundler CAN defer it” — not “the user perceives no delay.”
  • Parse-time and module-instantiate overhead multiplied across many small scripts vs one bundle. Combined parse time can exceed the single-chunk case even when combined byte count is smaller.
  • Hydration waterfall complexity. Fewer stream resolutions before phaze can attach bindings to the SSR’d DOM means faster TTI. Bundling collapses several waterfall rounds into one.

The eager-byte-count comparison is a LOCAL maximum. The GLOBAL maximum is “minimum delivered bytes AND minimum execution-time-to-interactive AND simplest waterfall.” Bundling wins on the last two even when it loses on the first.

When chunkComponents is the right reach: a component that’s genuinely route-conditional — mounted on a subset of routes the user might not visit at all (admin panel, settings UI, billing flow). Use the array form chunkComponents: ['AdminPanel'] so only the route-conditional pieces split; everything else stays bundled. Don’t reach for it for an always-mounted small component that’s imported via import() for code-style reasons — the dynamic-import gesture doesn’t change whether the component loads on first paint, only when its module resolves.

Always measure on a live deploy. pnpm build’s eager-brotli totals are necessary but not sufficient. Run LCP / INP / CLS measurements on the real network conditions your users have (Chrome DevTools network throttling, real-device WebPageTest, Cloudflare Web Analytics RUM). The signal you trust for chunking decisions is user-perceived speed, not bytes per chunk in the build report.

The chunk name is the component basename converted to kebab-case (FabricCanvasfabric-canvas, Footer_Bfooter-b, OTPInputotp-input). Only direct children of /src/components/ with .tsx/.jsx extension are claimed; subfolders and .ts helpers fall through to your own chunking config.

You wantUse
Edit a .tsx component during pnpm dev and see it live-swap without losing statephazeVite() in vite.plugins (HMR)
Inspect / control which phaze modules ship in which output chunkphazeChunks(opts)codeSplitting.groups on Vite 8, or rollupOptions.output.manualChunks on Astro/Vite ≤7
Verify your app’s phaze chunk is under 3 KB brotli(build first, then inspect — phazeChunks is the configuration that makes the chunk possible)

Most users only configure phaze-vite once per project (in astro.config.mjs or vite.config.ts) and forget about it. The HMR side has no API surface — it Just Works once the plugin is loaded. The chunking side has two boolean flags whose default is “split into separate chunks” (better caching) and which you only need to override if you’ve measured the first-paint trade-off and prefer the smaller eager-total.

For the canonical Astro setup with both phaze-vite halves wired in, see phaze-astro (next page) — the integration ships a sensible default and the app author rarely needs to touch the lower-layer phaze-vite config directly.