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:
peerDependenciesis 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.devDependencieswithlink:is purely your local dev loop (typecheck, tests, build). It symlinksnode_modules/@madenowhere/phazein your library’s repo to the workspace copy. Published metadata is unaffected —npm publishskips 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.
Local dev works. But the public contract doesn’t declare Phaze at all, so consumers’ package managers don’t warn on missing or mismatched Phaze. Looks fine until the consumer ships.
Public contract is the peer-dep declaration; private dev loop is the link:. Each plays its role, no interference.
Consumer side — the three-legged workspace-link contract
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-astro → phaze-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).
After ANY change to pnpm.overrides
Section titled “After ANY change to pnpm.overrides”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.
rm -rf node_modules/.vite node_modules/.astropnpm installpnpm devPhaze-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.
Validating the install
Section titled “Validating the install”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/:
# In the consumer app:for p in node_modules/@madenowhere/*; do echo "$(basename $p): $(realpath $p)"doneExpected 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:
realpath your-lib/node_modules/@madenowhere/phazerealpath /path/to/phaze/packages/core# → /…/phaze/packages/core (×2, identical)Symptoms of a duplicate runtime
Section titled “Symptoms of a duplicate runtime”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-sideeffect()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 prevents | Override 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/phazeso consumers don’t auto-install a duplicate.devDependencies: addlink:to a workspace path for your local dev loop.- Document any required
ssr.noExternalentry consumers need for your library if it’s imported at SSR.
Consumer using workspace links (not published versions):
- Leg 1:
pnpm.overridesfor every@madenowhere/*package (the comprehensive list above). - Leg 2:
vite.optimizeDeps.excludefor every workspace-linked package and its subpaths. - Leg 3:
vite.server.fs.allowfor every workspace root outside the app. - After any
pnpm.overrideschange:rm -rf node_modules/.vite node_modules/.astro+ restart dev server. - Run the
realpathcheck to verify resolution.