Skip to content

DSL: Diagnostics

phaze-compile and the runtime emit named, actionable diagnostics for the common footguns. Every runtime warning is wrapped in import.meta.env.DEV (or import.meta.env.SSR for server-only ones), so production bundles strip them entirely — no shipped-byte cost.

Compile-time errors — phaze-compile

Section titled “Compile-time errors — ”
DiagnosticSignatureTrigger
use:NAME unbounduse:foo: `foo` is not in scope. Import the directive — e.g. `import { foo } from '…'` — so phaze-compile can emit `foo(__el, () => value)`. If you meant a native DOM event, use `on:foo` instead.use:NAME references an identifier that path.scope.getBinding(NAME) can’t resolve. Catches missing imports + the common use:-vs-on: namespace mistake.
phaze:onXxxphaze:onClick is not valid — `phaze:` is for reactive *attributes*, not event handlers. Use `on:click={fn}` for native DOM events…phaze:onXxx={fn} (where local starts with on+letter). The naïve compile output would wire a getter that returns the function value — the browser never sees the handler. Throws at compile time.
bind:value on a non-text inputbind:value on <input type="number"> is not supported. Use the manual form with explicit coercion: `<input type="number" value={signal} on:input={(e) => signal.set(+e.currentTarget.value)} />` Existing — bind:value only supports text-like inputs and <textarea>. Error includes a hand-rolled equivalent with the right coercion for the input’s type.
bind:checked on a non-checkboxbind:checked is only supported on <input type="checkbox"> …Existing — bind:checked is checkbox-only.
bind:Xotherbind:disabled is not supported. Phaze's bind: namespace handles bind:value (text inputs, textarea) and bind:checked (checkboxes) only.Existing — bind: is intentionally minimal-scope.
for:NAME on non-<For>for:item is only valid on the <For> component — `for:NAME={signal}` declares the per-item binding name. On other components, write the renderer explicitly: `<C children={(item) => …}/>` or `<C>{(item) => …}</C>`.for:NAME namespace appeared on a component that isn’t named For. Each-binding-via-namespace is For-specific; other components don’t use the runtime contract that consumes it.
for:NAME missing value or childfor:item requires an expression value: `for:item={signal}`. / <For for:item={…}> has no child to render — provide an inline JSX element: `<For for:item={signal}><X/></For>`.for:NAME was written valueless (<For for:item>) or with no body. Two narrowly-targeted messages instead of a generic “missing input” so the fix is in the error text.

All compile errors point at the source location and include a fix in the message. They surface during build (pnpm build) and during dev-server transforms.

Runtime warnings — DEV-gated, dead-stripped in production

Section titled “Runtime warnings — DEV-gated, dead-stripped in production”
DiagnosticSignatureTriggerWhere
on: handler factory[phaze on:] handler returned a function — did you mean to invoke a factory? Bind the factory result to a const first: const handler = factory(args); on:click={handler}on:click={callExpression} auto-thunked, the call returned a function (handler-factory pattern). Fires on the click that “did nothing”.Auto-thunked handler body (emitted by phaze-compile).
Dynamic-child shape mismatch[phaze] dynamic-child shape mismatch: peek=DIV apply=SPAN. Likely cause: a JSX-shape-changing ternary at a {…}-child position …The untrack(fn) peek of a {() => …} JSX child returns one shape; the first effect-tracked call returns a different one. The hazard the appendDynamicChild first-run pattern was designed to handle but which the user usually didn’t mean. Fix: class:hidden={cond} toggles instead of JSX-shape ternaries.appendDynamicChild in @madenowhere/phaze’s jsx-runtime.

SSR-only diagnostics — server bundle only, stripped from client

Section titled “SSR-only diagnostics — server bundle only, stripped from client”
DiagnosticSignatureTriggerWhere
signal.async() at SSR[phaze SSR] signal.async() called during SSR — the loader Promise will not resolve before the response is sent. The SSR-rendered HTML shows .pending() = true; the loader fires AGAIN on the client at hydrate-time, doubling the request. …signal.async() invoked during a phaze-render-to-string render. Fires once per SSR process. Fix: gate with if (!import.meta.env.SSR) or fetch via Astro Action / server endpoint and pass via props.@madenowhere/phaze signal.ts.
SSR render failure with augmented error[phaze SSR] render failed: <original message>. Common causes: …A use: directive body / component scope throws synchronously during phaze-render-to-string. Wraps the original Error via .cause (so stack traces still point at the offending line) and prepends the four common root-causes (unguarded window/document access, browser-only module side effects, missing directive import, etc.).@madenowhere/phaze-render-to-string.
Empty-output probe[phaze SSR] render() returned an empty body. Possible causes: 1) root component returned null/false/undefined; 2) workerd silent-abort during JSX construction — known to fire when a post-creation IIFE executes code workerd's runner-worker rejects during the streamed response. …phaze-render-to-string produced empty innerHTML despite no thrown error. Catches the workerd silent-abort signature without leaving the developer staring at a blank page.@madenowhere/phaze-render-to-string.

Every runtime warning is gated by if (import.meta.env.DEV && …) console.warn(…). Vite (and any constants-folding bundler) replaces import.meta.env.DEV with false in production builds, the minifier dead-strips the if (false && …) block, then strips the unused locals — leaving the production output byte-identical to the hand-written form without the warning. Same for import.meta.env.SSR: client bundles strip the SSR-only warnings; server bundles keep them. Verified end-to-end via grep on the production dist/.

Every transform on this page is compile-time. phaze-compile ships zero bytes to the runtime; the macros and namespaces translate to plain props or function calls before bundling.

SourceCompiledDelta vs hand-written
phaze:class={expr}class={() => expr}0
class={phaze(expr)}class={() => expr}0
{phaze(expr)}{() => expr}0
on:click={fn}onClick={fn}0
use:dir={value}dir(el, () => value) post-creationslightly less than an equivalent ref callback
class:name={cond}effect(() => el.classList.toggle('name', !!cond)) post-creation0 (uses existing effect + native classList.toggle)
bind:value={signal}signal pass-through + listen(el, 'input', …) post-creation0 (uses existing listen)
bind:checked={signal}signal pass-through + listen(el, 'change', …) post-creation0 (uses existing listen)
s / c / watch aliasessignal / computed / effect calls0

What ships: the Phaze runtime (paid once), your component code, and the directive function bodies you actually use (tree-shaken otherwise). What never ships: phaze-compile, the phaze macro body, namespace recognition logic.

For class: and bind: specifically, the phaze-compiler auto-injects the effect / listen imports from @madenowhere/phaze if they aren’t already present. Existing imports are merged rather than duplicated.