Skip to content

.phaze: Structure & state placement

.phaze exposes phaze-cloudflare’s existing three-layer chrome composition without inventing new machinery:

LayerWherePersists acrossSlots
App shellsrc/app.phaze (no ---page, just an arrow)Every navigationchildren
Section layoutlayout: X label in ---pageSame-section navschildren
Per-page layout<Layout> wrapped in the trailing region’s JSXNothing — just the page’s own compositionchildren + <Fragment slot="X"> named slots

The three nest:

App shell ← persistent across all nav
└─ Section layout (from `layout:`) ← persistent within same-section nav
└─ Per-page <Layout> wrap ← per-page composition with named slots
├─ <Fragment slot="header">
├─ <Fragment slot="sidebar">
└─ default content

.phaze doesn’t need to invent slot composition — phaze-compile’s existing extractSlots pass handles <Fragment slot="X"> lifting at the inline <Layout>…</Layout> call site in the trailing region.

Every signal has exactly one of two lifetimes:

  • Local — one copy per component mount; disposed on unmount.
  • Global (module) — one copy per page load; shared across every mount of this component (and every other importer if exported).

The decision flow:

  1. Does each component instance need its own copy?
    • YesLocal. Declare in ---state without a decorator. Compiles to const X = … inside the component body.
    • No → continue.
  2. Is THIS component the only consumer (it owns the signal, even if shared across its own mounts)?
    • YesGlobal (module), inline. Declare in ---state with @global. Compiles to const X = … at module scope, above the component.
    • No → continue.
  3. Do multiple unrelated components read or write this signal?
    • YesGlobal (module), shared file. Declare in src/store.ts (or another shared module) and import it into each consumer.

Where each kind of declaration lives:

DeclarationRegionLifetimeMarker
ImportsTop of fileModule load
Inline subcomponents, lookup tables, helpersTop of file (above ---page)Module load
state: s('s1') (per-mount state machine)---stateLocalnone
CARD_ANIM: { … } (per-mount constant alongside state)---stateLocalnone
if (sig.current() === 'X') sig.set(…) (mount-time setup)---stateLocalnone — passthrough
@global selectedPost: signal('landing') (this component’s shared selection)---stateGlobal (module)@global
drafts: s<Item[]>([]) (typed Local decl)---stateLocalnone — typed via factory <T>
const products = s.async(actions.X()) (module-scope decl with TS-side type)Above ---pageGlobal (module)— — uses regular const + factory <T>
Cross-component shared signalssrc/store.tsGlobal (module), project-wide— — explicit imports per consumer
Effects, refs, use:-directives bound to local stateTrailing regionLocal (live alongside the JSX they touch)

Each entry below names the anti-pattern, why it’s wrong, and the canonical replacement.

// ❌ Wrong — head queries the DB; duplicates the loader's work
head: ({ params, env }) => env.DB.get(params.id).then(row => ({ title: row.name }))

---page is for static config only. Anything that needs PageContext belongs in ---data. For dynamic titles, write <title> in the component body — the compiler lifts it to a synthesized head that derives from data.

// ❌ Wrong — the `() =>` is the compiled output
const data = signal.async(() => fetcher())
const doubled = c(() => count() * 2)
watch(() => log(count()))
// ✅ Right — the compiler adds the arrow
const data = s.async(fetcher())
const doubled = c(count() * 2)
watch(log(count()))

s.async / c / watch / phaze from /dsl auto-thunk their argument. Writing () => defeats the entire subpath.

// ❌ Wrong — re-introduces the boilerplate the format drops
({ data }) => {
const state = s('s1')
return <>…</>
}
// ✅ Right — page trailing region IS the function body
const state = s('s1')
<></>

The page’s trailing region is statements + a JSX expression. The compiler wraps it.

Method-form predicates that collide with s.async

Section titled “Method-form predicates that collide with s.async”
// ❌ Wrong — `s` from `/match` has `.is`/`.not` but blocks `s.async` auto-thunking
import { s } from '@madenowhere/phaze/match'
state.is('X')
const data = s.async(fetcher()) // NOT auto-thunked; needs explicit () =>
// ✅ Right — single `s` source, free-function predicates
import { s } from '@madenowhere/phaze/dsl'
import { is, not } from '@madenowhere/phaze/match'
is(state, 'X')
const data = s.async(fetcher()) // auto-thunked

CARD_ANIM lifted to @global or above ---page

Section titled “CARD_ANIM lifted to @global or above ---page”

The canonical phaze idiom keeps lookup tables Local — colocated with the state signal they drive. Lifting CARD_ANIM to @global (or above ---page) makes the reader scroll across boundaries to find the state→pose mapping. Put it right under the state signal in ---state, both Local:

---state
state: s<'s1' | 's2' | 's3'>('s1')
CARD_ANIM: { s1: { y: 0 }, s2: { y: -8 }, s3: { y: 0 } }
---

That’s one Local block, two consecutive lines, lifetime obvious.

This project uses verbatimModuleSyntax: false in tsconfig.json — type-only names elide from plain import automatically. Writing import type { X } from '…' is redundant and conflicts with the project’s commit 43125e8 convention.

Field-style fields can’t reference each other — they’re strictly parallel:

// ❌ Wrong — `related` can't reference `products`
---data
products: env.DB.list()
related: env.DB.list({ category: products[0]?.category }) // products is undefined here
---
// ✅ Right — switch to function-body form
---data
const products = await env.DB.list()
return { products, related: await env.DB.list({ category: products[0]?.category }) }
---