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 for the manualChunks callback Rollup uses to lay out the build’s output chunks.

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 + manualChunks builder
What it needsBabel (@babel/core)Vite’s Plugin type, Rollup’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 manualChunks

Section titled “Surface 2 — phazeChunks() for manualChunks”

The other half of phaze-vite is phazeChunks(opts?) — a function that returns a Rollup manualChunks callback. Rollup’s manualChunks 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 callback 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 into consuming components
})
defineConfig({
build: {
rollupOptions: {
output: { manualChunks: phazeChunkOf },
},
},
})

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).

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) 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 (Rollup 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.
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) as build.rollupOptions.output.manualChunks
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.