Skip to content

Phaze as a peer dependency

Phaze’s reactivity is module-state — the active computation, subscriber sets on each Subscribable, the scheduler queue all live at module scope. A signal created in one Phaze module instance can only notify subscribers in the same instance. Two physical copies of @madenowhere/phaze on the same page = two independent reactivity universes. A signal.set(…) in one is invisible to a computed() registered in the other. No error fires — just a silent reactivity break.

This isn’t a Phaze-specific bug; React, Preact, MobX, and Solid have the same module-state constraint. The Phaze-specific part is how to declare the dependency from a library, and how a consumer wires up workspace links, so the constraint can’t be violated by accident at install time.

Library author side — declare phaze as a peer dep

Section titled “Library author side — declare phaze as a peer dep”

If you’re writing a library that imports anything from @madenowhere/phaze (signal, effect, computed, cleanup, listen, abortSignal, etc.), declare Phaze both as a peer dependency and as a local link: in dev dependencies:

{
"name": "@yourscope/your-lib",
"peerDependencies": {
"@madenowhere/phaze": ">=0.0.16"
},
"peerDependenciesMeta": {
"@madenowhere/phaze": { "optional": false }
},
"devDependencies": {
"@madenowhere/phaze": "link:../path/to/phaze/packages/core"
}
}

Each declaration has a different job:

  • peerDependencies is your published contract: “the app installing me must provide Phaze; don’t bundle your own.” Without this, consumers’ package managers may auto-install a second copy.
  • devDependencies with link: is purely your local dev loop (typecheck, tests, build). It symlinks node_modules/@madenowhere/phaze in your library’s repo to the workspace copy. Published metadata is unaffectednpm publish skips devDependencies for downstream consumers.

The link: target is relative to your library’s package.json. If your library lives at /Users/you/code/your-lib/ and phaze lives at /Users/you/code/phaze/packages/core/, the target is link:../phaze/packages/core.

Your library publishes correctly, but local tsc / vitest / bundler runs fail — no Phaze in your node_modules to import from. Older pnpm versions “helpfully” install the published Phaze to fix the missing peer; now your local dev loop uses a different Phaze version than the workspace. When a consumer link:s your repo, that stale node_modules/@madenowhere/phaze is what their bundler resolves through the symlink — duplicate-runtime bug at runtime, no install-time warning.

Section titled “Consumer side — the three-legged workspace-link contract”

If a consumer app wants to develop against the workspace copies of Phaze packages (not the published-to-npm versions), three independent Vite/pnpm surfaces must be configured in sync. Setting one without the others looks like it works in some scenarios and silently fails in others. The three legs:

Leg 1: pnpm.overrides for every @madenowhere/* workspace package

Section titled “Leg 1: pnpm.overrides for every @madenowhere/* workspace package”

Phaze ships twelve workspace packages, plus two sibling repos (photon, graviton) that depend on phaze. Even if your app only imports @madenowhere/phaze-astro directly, transitive deps (e.g. phaze-astrophaze-compile + phaze-render-to-string) need overrides too — otherwise pnpm pulls the published version from cache for the transitive references, and workspace patches to those packages never reach your build.

Override all of them proactively. Unused entries cost nothing (pnpm only applies overrides for packages actually in the graph):

{
"pnpm": {
"overrides": {
"@madenowhere/phaze": "link:/absolute/path/to/phaze/packages/core",
"@madenowhere/phaze-compile": "link:/absolute/path/to/phaze/packages/compile",
"@madenowhere/phaze-astro": "link:/absolute/path/to/phaze/packages/astro",
"@madenowhere/phaze-vite": "link:/absolute/path/to/phaze/packages/vite",
"@madenowhere/phaze-render-to-string": "link:/absolute/path/to/phaze/packages/render-to-string",
"@madenowhere/phaze-directives": "link:/absolute/path/to/phaze/packages/phaze-directives",
"@madenowhere/phaze-tsplugin": "link:/absolute/path/to/phaze/packages/phaze-tsplugin",
"@madenowhere/phaze-cloudflare": "link:/absolute/path/to/phaze/packages/phaze-cloudflare",
"@madenowhere/vite-plugin-phaze": "link:/absolute/path/to/phaze/packages/vite-plugin-phaze",
"@madenowhere/infrared": "link:/absolute/path/to/phaze/packages/infrared",
"@madenowhere/zod-constraints": "link:/absolute/path/to/phaze/packages/zod-constraints",
"@madenowhere/eslint-plugin-phaze": "link:/absolute/path/to/phaze/packages/eslint-plugin-phaze",
"@madenowhere/photon": "link:/absolute/path/to/photon",
"@madenowhere/graviton": "link:/absolute/path/to/graviton"
}
}
}

When a new @madenowhere/* package is added to the workspace, add it here at the same time. The list is the source of truth for “every package in this constellation that should resolve to workspace.”

Leg 2: vite.optimizeDeps.exclude for every workspace-linked package + subpaths

Section titled “Leg 2: vite.optimizeDeps.exclude for every workspace-linked package + subpaths”

Vite pre-bundles node_modules deps for dev-server perf, and caches them keyed on the published version they were installed as. Once a package is overridden to a link: symlink, Vite’s cache no longer matches the symlinked path. Symptom: GET http://localhost:4321/@id/@madenowhere/phaze-astro/client 404 (Not Found) when the dev server tries to fetch the dynamic-imported module.

optimizeDeps.exclude tells Vite to skip pre-bundling and resolve via Node module resolution, which DOES follow the link: symlink.

// astro.config.mjs (or vite.config.ts)
defineConfig({
vite: {
optimizeDeps: {
exclude: [
'@madenowhere/phaze',
'@madenowhere/phaze/jsx-runtime',
'@madenowhere/phaze/store',
'@madenowhere/phaze/dsl',
'@madenowhere/phaze/match',
'@madenowhere/phaze/hydrate',
'@madenowhere/phaze/dom',
'@madenowhere/phaze-astro',
'@madenowhere/phaze-astro/client',
'@madenowhere/phaze-astro/server',
'@madenowhere/phaze-astro/actions',
'@madenowhere/phaze-vite',
'@madenowhere/phaze-vite/chunks',
'@madenowhere/phaze-vite/runtime',
'@madenowhere/phaze-compile',
'@madenowhere/phaze-render-to-string',
'@madenowhere/phaze-directives',
'@madenowhere/phaze-cloudflare',
'@madenowhere/phaze-tsplugin',
'@madenowhere/infrared',
'@madenowhere/zod-constraints',
'@madenowhere/photon',
'@madenowhere/photon/phaze',
],
},
},
})

List every workspace-overridden package, and each exports subpath that any code in the dep graph imports. @madenowhere/phaze-astro/client is a separate Vite resolution from @madenowhere/phaze-astro (the exports field’s ./client entry); same goes for /server, /actions, /chunks, /runtime. Subpaths your app doesn’t currently import — e.g. @madenowhere/phaze/numeric, @madenowhere/phaze/time, @madenowhere/phaze-directives/in-view, @madenowhere/graviton/phaze — can be added later when you start using them; their absence here just means Vite will refuse to pre-bundle when first encountered, giving you a clean 404 to fix rather than a silent stale-cache trap.

Leg 3: vite.server.fs.allow permits workspace paths outside the app root

Section titled “Leg 3: vite.server.fs.allow permits workspace paths outside the app root”

Vite’s dev server has a server.fs.allow allow-list that defaults to the consumer’s project root + detected workspace roots. When pnpm.overrides link: symlinks point to a phaze workspace outside the app (e.g. /Users/you/code/phaze/...), the dev server rejects file reads outside the allow-list with a 404 — even when optimizeDeps.exclude is correctly configured and the symlink target exists. Production builds aren’t affected (no dev-server fs gate); only dev breaks.

defineConfig({
vite: {
server: {
fs: {
allow: [
'/absolute/path/to/phaze',
'/absolute/path/to/photon',
'.', // the app root (default — re-list explicitly when overriding)
],
},
},
},
})

Add another path here whenever you link:-override a new workspace outside the app root (e.g. /absolute/path/to/graviton once your app actually imports graviton-sourced modules during dev — until then the override resolves through Node module resolution without the dev server needing fs access).

Clear Vite’s pre-bundle cache and restart the dev server. Vite reads optimizeDeps.exclude once on startup AND caches pre-bundled deps in .vite/ keyed on the previous resolution.

Terminal window
rm -rf node_modules/.vite node_modules/.astro
pnpm install
pnpm dev

Phaze-astro consumers get one extra leg automatically — ssr.noExternal

Section titled “Phaze-astro consumers get one extra leg automatically — ssr.noExternal”

If you use @madenowhere/phaze-astro as an integration in your Astro app, it adds ssr.noExternal for every phaze package automatically:

ssr: {
noExternal: [
'@madenowhere/phaze',
'@madenowhere/phaze-astro',
'@madenowhere/phaze-render-to-string',
'@madenowhere/phaze-compile',
],
}

Without this, Astro 6’s default SSR externalization loads phaze packages as raw Node ESM. import.meta.env is undefined in Node ESM, and the first import.meta.env.DEV read in createComputation (called from render()) crashes the SSR with Cannot read properties of undefined (reading 'DEV'). The integration prevents that for you — but if you ship a phaze-aware library that’s also imported at SSR, the consumer’s ssr.noExternal needs to include your library too, or your code crashes the same way. Document this in your library’s README.

After pnpm install in either the library repo or the consumer app, run this — every entry should resolve to a path inside the workspace, not under node_modules/.pnpm/:

Terminal window
# In the consumer app:
for p in node_modules/@madenowhere/*; do
echo "$(basename $p): $(realpath $p)"
done

Expected output: all paths under your phaze workspace root (/Users/you/code/phaze/packages/...). Any entry under node_modules/.pnpm/@madenowhere+...@<version>/... is the next regression — add an override.

For the library repo specifically, realpath should match the workspace phaze:

Terminal window
realpath your-lib/node_modules/@madenowhere/phaze
realpath /path/to/phaze/packages/core
# → /…/phaze/packages/core (×2, identical)

These all point at the same root cause — two physical @madenowhere/phaze modules in the bundle graph:

  • A signal.set(…) in the consumer fires consumer-side effect()s but never library-side ones.
  • A class:cond={signal()} written in the consumer’s JSX updates, but the same signal threaded into a helper from your library doesn’t propagate.
  • use:-directive bodies you wrote run their initial setup (you can see the initial DOM write) but never re-run on signal changes.

The smoking-gun diagnostic: add a computed(() => sig()) in the consumer body AND the same shape inside your library, subscribe to both, then signal.set(…). If only the consumer-side fires, it’s two runtimes. Confirm via realpath on both node_modules/@madenowhere/phaze paths.

Symptom each override preventsOverride that fixes it
Signals don’t talk between app and library code@madenowhere/phaze
phaze-compile patches (auto-thunks, DSL macros) don’t reach JSX@madenowhere/phaze-compile
phaze chunk missing from prod build (runtime folded into wrong chunk → blows 3 KB budget)@madenowhere/phaze-vite
Astro Action client / SSR mount uses stale logic@madenowhere/phaze-astro
SSR diagnostics (render-failure augmented error, empty-output probe) don’t fire@madenowhere/phaze-render-to-string
Editor TS6133 false positives on use:NAME imports@madenowhere/phaze-tsplugin

Library author:

  • peerDependencies: declare @madenowhere/phaze so consumers don’t auto-install a duplicate.
  • devDependencies: add link: to a workspace path for your local dev loop.
  • Document any required ssr.noExternal entry consumers need for your library if it’s imported at SSR.

Consumer using workspace links (not published versions):

  • Leg 1: pnpm.overrides for every @madenowhere/* package (the comprehensive list above).
  • Leg 2: vite.optimizeDeps.exclude for every workspace-linked package and its subpaths.
  • Leg 3: vite.server.fs.allow for every workspace root outside the app.
  • After any pnpm.overrides change: rm -rf node_modules/.vite node_modules/.astro + restart dev server.
  • Run the realpath check to verify resolution.