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 — ”| Diagnostic | Signature | Trigger |
|---|---|---|
use:NAME unbound | use: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:onXxx | phaze: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 input | bind: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-checkbox | bind:checked is only supported on <input type="checkbox"> … | Existing — bind:checked is checkbox-only. |
bind:Xother | bind: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 child | for: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”| Diagnostic | Signature | Trigger | Where |
|---|---|---|---|
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”| Diagnostic | Signature | Trigger | Where |
|---|---|---|---|
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. |
Why this matters for the bundle budget
Section titled “Why this matters for the bundle budget”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/.
Bundle impact
Section titled “Bundle impact”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.
| Source | Compiled | Delta 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-creation | slightly less than an equivalent ref callback |
class:name={cond} | effect(() => el.classList.toggle('name', !!cond)) post-creation | 0 (uses existing effect + native classList.toggle) |
bind:value={signal} | signal pass-through + listen(el, 'input', …) post-creation | 0 (uses existing listen) |
bind:checked={signal} | signal pass-through + listen(el, 'change', …) post-creation | 0 (uses existing listen) |
s / c / watch aliases | signal / computed / effect calls | 0 |
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.