Phase-Aware Prop Resolution
Design Log #189 — Phase-Aware Prop Resolution (runtime + builder)
Written for AI agents. See Log Methodology Note below for details.
Background
DL#152 established that headless-component contract props have a phase (slow default, fast, fast+interactive) and that a binding is only valid when its source phase ≤ the prop's phase — checked at compile time. DL#187 (pure headfull / Tier 2) relies on that guarantee: a Tier 2 passthrough has no .ts, so its ViewState is exactly the props the usage site binds, and those bindings must resolve at the right phase or the SSR output is wrong.
Prior art — client-only Jay. Jay without Jay Stack (the client-only framework, makeJayComponent) already supports interactive props, and it hands them to the component as signals — a prop is a reactive () => T the parent can change over time. So "a changing prop is a signal on the consuming side" is not a new idea introduced here; Jay Stack's interactive phase already inherits it. This log's job is to make the server phases (slow/fast) resolve props at the right time so they line up with that already-signal-based interactive behavior — not to invent the signal model.
DL#152 shipped two of the three layers this needs:
- Contract schema —
ContractProp.phaseexists (contract.ts:32), parser accepts it. - Validation —
jay-stack validateenforces source-phase ≤ prop-phase (validate.ts:756–841).
What DL#152 did not build, and what this log designs:
- Runtime resolution never honors prop phase. All instance prop bindings resolve once at slow (
slowRenderInstances,dev-server.ts:1214, scope =slowViewState), store the resolved literals ininstancePhaseData.discovered[].props, and the fast phase merely re-normalizes those already-resolved literals (fast-changing-runner.ts:99). Afastprop bound to afasttag resolves to""at slow and is never re-resolved from its raw binding. - The builder uses a single
PropsTfor all phases (jay-stack-types.ts:190,200,221) — soslowlyRenderis typed to see props that only exist at fast, andwithInteractivecannot receive a changing prop as a reactive signal. - DL#152 collapsed
fast === fast+interactive(its A2/A6). The distinction we actually need — afastprop is a constant known at request time, afast+interactiveprop is a reactive signal the parent can change on the client — was never modeled.
Related
- DL#50 — Rendering phases in contracts
- DL#84 — Headless component props and repeater support (props-only channel, ViewState isolation)
- DL#109 — Unified dev server phase pipeline (where instances resolve today)
- DL#124 — Contract props / params consistency
- DL#152 — Phase-aware contract props (annotation + compile-time validation — this log's predecessor)
- DL#156 — Keyed headless component props
- DL#187 — Pure headfull components (Tier 2 passthrough — the main beneficiary)
Problem
A page tag that becomes available at fast or fast+interactive cannot drive a child instance's prop at SSR, even though the binding is semantically valid.
# page.jay-contract — currentStatus is set at request time / changes on the client
- tag: currentStatus
type: variant
dataType: enum (success | warning | error)
phase: fast+interactive
<jay:badge status="{currentStatus}" label="Dynamic" count="7" featured="false" />
Observed (verified against the smoke test): the badge instance's fast ViewState carries status: "" — {currentStatus} was resolved at slow, where currentStatus does not yet exist, and the raw binding was discarded before the fast phase ran. The hydration bootstrap does ship currentStatus: 1 in the interactive ViewState, so the client eventually renders correctly — producing an SSR-vs-hydrate mismatch (SSR: no icon; client: warning icon).
The general case is the product-card-in-a-dynamic-grid: a card that only receives its productId at fast+interactive (the grid item is a client-side signal) can never render that prop at SSR today.
The tension raised in review: we must not force components to be defensive (loading a prop at every phase in case it's absent). A component guarantees when it consumes a prop — markdown-content.ts reads props.markdown at slow (withSlowlyRender only), markdown-live.ts reads it at fast and as a signal at interactive (withFastRender + withInteractive, no slow). The runtime must uphold that guarantee, not undermine it.
Questions & Answers
Q1: Per-prop phase or per-component phase?
A1: Per-prop. More flexible and already how the contract models it (DL#152 — phase is on each ContractProp, not the component). A component can legitimately take a slow config prop (available at build) and a fast+interactive data prop (changes on the client) at once — e.g. a product card with a slow layout and an interactive productId. Per-component phase can't express that.
Q2: What are the exact per-prop semantics? (refines DL#152's fast === fast+interactive)
A2: Three levels, differing in when the value is available and whether it changes. Crucially, at the interactive phase every prop is a signal (() => T) — matching client-only Jay (Background) and OQ3 — so the consuming side always sees the same shape; the only difference is whether that signal's value ever changes:
| Prop phase | Resolved at | At slow | Available in (same value) | At interactive it is… | Binding source may be |
|---|---|---|---|---|---|
slow (default) |
slow (build) | present | slow, fast, interactive | a signal () => T, constant value |
literal / route param / slow |
fast |
fast (request) | undefined/'' |
fast, interactive | a signal () => T, constant value |
above + fast |
fast+interactive |
fast (initial) | undefined/'' |
fast, interactive | a reactive signal () => T |
above + fast+interactive |
Two consequences to state plainly:
- A
fast/fast+interactiveprop isundefined(or''for a string binding) at the slow phase — it isn't resolvable yet. Components must not read such a prop inslowlyRender(Q3/Q5). - Even a
fastprop is a signal at interactive — it just never fires a change. Uniform shape beats special-casing (OQ3).
This is a strict refinement of DL#152, not a contradiction: DL#152's ordering slow < fast ≤ fast+interactive still holds for the validation rule; we split the ≤ into "constant" (fast) vs. "reactive" (fast+interactive).
Q3: How does a component "declare" which phase it consumes a prop?
A3: It already does, two ways:
- The contract declares each prop's
phase(DL#152) — the authoritative source for resolution timing and validation. - The
.tsdeclares it structurally by which render functions it implements (markdown-content= slow-only, readsprops.markdownat slow → its props are allslow;markdown-live= fast + interactive, readsprops.markdownat fast andprops.markdown()at interactive →markdownisfast+interactive).
The framework does not enforce this pairing with generated per-phase prop types (Q5): the prop type stays a single in-code declaration, and it's the author's responsibility (backed by validation and documentation) not to read a fast/fast+interactive prop in slowlyRender, where it is undefined.
For Tier 2 (DL#187) there is no .ts; props ≡ tags (DL#187 consistency rule), so prop phase = the matching tag's phase, and the synthetic passthrough echoes per phase.
Q4: What is the runtime change?
A4: Resolve bindings at each phase against that phase's merged ViewState, keeping the raw binding text alive across phases:
- slow: resolve only
slowprops (scope = slow VS). Fast / interactive props are absent here — and their types keep them out ofslowlyRender. - fast: resolve
slow+fast(+fast+interactiveinitial) props (scope = merged slow+fast VS). This is the fix for the SSR case. - interactive (client):
fast+interactiveprops are wired as reactive references to the parent's interactive ViewState field — this path already exists in the compiled client prop getter (status: vs.currentStatus, no coercion for dynamic bindings). Only the server phases need new work.
Concretely: instancePhaseData.discovered[] must store raw props (binding text), not slow-resolved literals, so fast-changing-runner can re-resolve fast props from the raw binding against the fast scope instead of re-normalizing a dead "".
Q5: Do we generate per-phase prop types in the builder (SlowProps/FastProps/InteractiveProps)?
A5: No. Checked against the code: the builder does not generate the props type from the contract — the component author declares it in-place in the .ts (markdown-live.ts: .withProps<MarkdownLiveProps>() with a locally-declared interface MarkdownLiveProps { markdown: string }; the interactive signature separately types markdown as () => string). Forcing authors to declare three interfaces instead of one would make the syntax markedly more complex for little gain.
Decision: keep the single, in-code PropsT unchanged (as today), and simply state the contract:
A prop marked
fastorfast+interactiveisundefined(or''for a string binding) during theslowphase.
The author already signals when they consume a prop by which render functions they implement, so the single type plus this documented rule is sufficient — a slow-only component never reads a fast prop; a fast/interactive component reads it only where it's defined. The existing interactive convention (props as signals, from client-only Jay — Background) is unchanged: at interactive, the author types props as () => T, exactly as markdown-live does today.
This removes the largest blast radius from the original draft: no changes to jay-stack-builder.ts / jay-stack-types.ts render signatures, no per-phase prop codegen in contract-compiler.ts. The whole solution reduces to runtime resolution timing (Q4) plus validation (Q6).
Q6: Does the compile-time validation (DL#152) still apply / change?
A6: The rule (source phase ≤ prop phase) is unchanged and stays. Two gaps to close: (a) DL#152's validator collapsed fast/fast+interactive — it should distinguish them so a fast (constant) prop bound to a fast+interactive (changing) source is flagged (a constant can't track a value that changes on the client). (b) The check lives only in jay-stack validate; the dev-server build should surface the same error so mismatches don't silently render "" in jay start (prevention-first).
Q7: What about withProps<PageProps>() on page components (not contract-driven)?
A7: Page props (language, url, route params, query, cookies) are request-time inputs — effectively fast, and always available at fast (route params always-available per DL#152 A6). Pages have no usage-site binding to phase-check; this log's per-phase split targets headless/Tier-2 instance props driven by contract phases. Page withProps keeps its current single-type shape. Open question OQ1 covers whether to unify later.
Q8: Backward compatibility?
A8: Not preserved (experimental framework, per CLAUDE.md). Props default to slow, so existing contracts are unaffected in behavior; existing single-PropsT .ts components keep compiling because SlowProps ⊆ FastProps ⊆ the old PropsT for all-slow contracts. Components that were silently receiving "" for a fast binding will now either resolve correctly (if declared fast/fast+interactive) or fail validation (if declared slow).
Design
Contract — unchanged
ContractProp.phase already exists (DL#152). No schema change. Tier 2 inherits phase from the matching tag (DL#187).
Runtime — per-phase resolution
Changes:
InstancePhaseData.discovered[]stores raw props (binding text) + the resolved slow subset — not slow-resolved literals for everything.slowRenderInstancesresolves onlyslow-phase props (scope = slow VS).renderFastChangingDatare-resolvesfast/fast+interactiveprops from raw bindings against{...slow, ...fast}(the merged scope it already builds atfast-changing-runner.ts:100).- Tier 2 passthrough (DL#187) is synthesized per phase (already the DL#187 plan) — it now echoes the phase-correct resolved props.
Builder — unchanged (single in-code prop type)
No change to jay-stack-builder.ts / jay-stack-types.ts render signatures and no per-phase prop codegen (Q5). The author keeps declaring one PropsT via .withProps<T>(). The documented contract is: fast / fast+interactive props are undefined/'' at the slow phase, and at interactive all props are signals (() => T), reactive only for fast+interactive. This matches client-only Jay's existing interactive-props-as-signals behavior (Background).
Validation — complete DL#152
Distinguish fast vs fast+interactive in the phase-order check, and run it in the dev-server build path, not only jay-stack validate.
Implementation Plan
Phase 1 — Runtime per-phase resolution (fixes SSR)
resolve-instance-props.ts,instance-slow-render.ts,fast-changing-runner.ts,dev-server.ts(and the production parts path): carry raw props; resolve slow-only at slow, fast/interactive at fast against the merged slow+fast scope. Verify the Tier 2 badge rendersbadge--warningat SSR for afast+interactivebinding.
Phase 2 — Validation completion
- Split
fast/fast+interactivein the DL#152 check (validate.ts); surface it in the dev-server build (not onlyjay-stack validate). Clear message on a constant (slow/fast) prop bound to afast+interactive(changing) source.
Phase 3 — Tests
- Compiler: server/element/hydrate fixtures for a
fast+interactiveinstance binding (extendpage-with-structural-badge). - Smoke: the dynamic badge asserts SSR
badge--warning+[!](the currently-failing assertions become correct once Phase 1 lands).
Phase 4 — Docs
- Plugin + designer agent-kit: the three-level prop table, the "declare the phase you consume the prop" guidance, and the explicit rule that
fast/fast+interactiveprops areundefinedat slow; note all props arrive as signals at interactive (reactive only forfast+interactive).
Examples
✅ fast+interactive prop → SSR + reactive client (target):
<jay:badge status="{currentStatus}" label="Dynamic" count="7" featured="false" />
currentStatus (fast+interactive) → resolved at fast for SSR (badge--warning), a signal at interactive (cycles on click).
✅ slow prop (unchanged):
<jay:markdown-content markdown="{article.body}" />
article.body slow → resolved at build, constant.
❌ constant prop bound to a changing source (validation error, Phase 3):
<!-- markdown-content consumes `markdown` at slow; cannot track an interactive source -->
<jay:markdown-content markdown="{liveDraft}" />
<!-- liveDraft: fast+interactive -->
Trade-offs
| Approach | Pro | Con |
|---|---|---|
| Per-phase resolution, single in-code prop type (chosen) | Fixes SSR/hydrate mismatch; completes DL#152; small blast radius (runtime + validation only); no builder/codegen changes; keeps the simple one-type authoring syntax | The single type can't statically stop an author reading a fast prop at slow — caught by convention + validation + docs, not the type checker |
Per-phase prop types (SlowProps/FastProps/InteractiveProps) |
Type checker prevents reading a fast prop at slow | Forces three interface declarations per component; large blast radius (builder, types, codegen); rejected as too complex for the gain (Q5) |
| Document-only: forbid fast/interactive instance bindings | Zero runtime change | Kills the product-card-in-grid case; DL#152 already allows these bindings |
Verification Criteria
- A
fast+interactiveinstance prop bound to afast+interactivepage tag renders the correct value at SSR (no""), matching what the client hydrates to (no mismatch). - A
fastprop bound to afasttag resolves at the fast phase (not""from slow). - A
slowprop still resolves at build; behavior unchanged for all-slow contracts. - The prop type stays a single in-code declaration — no
SlowProps/FastProps/InteractivePropsare generated; render signatures injay-stack-types.tsare unchanged. - A constant (
slow/fast) prop bound to afast+interactivesource fails validation with a clear message, surfaced in the dev build (not onlyjay-stack validate). - Tier 2 (DL#187) passthrough echoes phase-correct props at every phase, inheriting phase from the matching tag.
markdown-content(slow) andmarkdown-live(fast+interactive) compile and behave unchanged.
Open Questions (resolved)
- OQ1 — resolved: Do not change the prop type for components or pages. Both keep the single in-code prop type; page
withProps<PageProps>()is unaffected (request-time inputs, effectivelyfast, always available at fast). - OQ2 — resolved: No extra server→client contract needed. Client-side interactive resolution already works via the compiled prop getter, and that path is in fact safer — it keeps the client consistent and resilient. Fast-phase resolution (Phase 1) is sufficient to seed hydrate; "if it works, it works."
- OQ3 — resolved: Props are always signals at the interactive phase (reactive only for
fast+interactive; a constant-valued signal forslow/fast). Uniformity is simpler for the consuming side — no branching on whether a given prop is a value or a signal.
Implementation Results
All 7 verification criteria met. Phases 1–4 landed.
Phase 1 — Runtime per-phase resolution
- Added
normalizeInstancePropNames()tostack-server-runtime/lib/resolve-instance-props.ts— normalizes prop names against the contract (case-insensitive) but keeps binding values raw (unresolved). This is the key change:instancePhaseData.discovered[].propsnow stores raw bindings instead of slow-resolved literals. - Both slow-render paths were updated to store raw props via
normalizeInstancePropNames:instance-slow-render.ts(slowRenderInstances— pre-render / direct / production-build). The slow render call itself still receives resolved props (normalizeAndResolveInstanceProps); only thediscovered[]handoff carries raw bindings.slowly-changing-runner.ts(DevSlowlyChangingPhase.runSlowlyForPage— dev + production build).dev-server.tsfallback path buildsrawPropswithnormalizeInstancePropNames.
fast-changing-runner.tsneeded no change — it already re-resolvedinstance.propsagainst the merged{...mergedSlowViewState, ...fastViewState}scope; that call now works because the props reaching it are raw.- Deviation (found during impl): the SSR variant class was still missing after the fast re-resolution worked, because the compiler-jay-html enum coercion for reverse-mapped numeric enum values wasn't in
dist. Fixed by rebuilding the compiler chain (compiler-jay-html → compiler → vite-plugin → rollup-plugin → cli → stack-cli). This coercion (typeof (Status)[field] === 'number' ? (Status)[field] : Number(field)) is a prerequisite for criterion #1, not new code from this DL.
Phase 2 — Validation completion
- Deviation from plan: the
fastvsfast+interactivesplit in the phase-order check was already present invalidate.ts(PHASE_ORDER = { slow: 0, fast: 1, 'fast+interactive': 2 }, checksourceOrder > propOrder) — DL#152 had already shipped this half. No change needed there. - Real work was structural (Tier 2) prop-phase resolution: for Tier 2 structural imports (props ≡ tags, DL#187) the parser defaults all prop
phasetoslowwhile the tags carry the real phase.checkHeadlessInstancePropsnow reads the effective prop phase from the matching tag whenimp.structural:
Without this, every Tier 2 fast/interactive binding was a false-positive error.const propPhase = imp.structural ? (resolveContractTag(contract, contractProp.name)?.phase ?? contractProp.phase ?? 'slow') : (contractProp.phase ?? 'slow'); - Surfacing in dev/build (criterion #5): added
surfaceValidationIssues(projectPath, { verbose })torun-validate.ts, called fromrunBuild(exits onfalse) andrunDev(advisory, keeps serving).- Deviation / gotcha: the original design routed the exit through a
fatalboolean parameter (if (options.fatal) process.exit(1)inside the function). Rollup's constant-propagation over that parameter dead-code-eliminated theprocess.exit(1)branch in the vite lib build, silently disabling the build gate (bundle printed the error but exited 0). Fixed by havingsurfaceValidationIssuesreturn a boolean and letting each caller decide —runBuilddoesif (!valid) process.exit(1)at a real call site Rollup can't fold. Verified in the bundleddist/index.jsand end-to-end (build exits 1 on a deliberate mismatch, 0 on a clean project).
- Deviation / gotcha: the original design routed the exit through a
Phase 3 — Tests
stack-server-runtime/test/slow-render-instances-bindings.test.ts— updated to assertdiscovered[].propsholds raw bindings ({ productId: '{p._id}', ... }), the correct new behavior.stack-cli/test/validate.test.ts— addedstructural (Tier 2) prop phase from tag (DL#189)describe block (3 tests): no-warn for fast+interactive→fast+interactive, warns for fast+interactive→fast constant prop, keeps declared prop phase for non-structural. Full suite 71/71.- Smoke (
examples/jay-stack/smoke-test): the dynamic badge (status=fast+interactive) asserts SSRbadge--warning+[!]. 61/61 passing.
Phase 4 — Docs
agent-kit-template/plugin/contracts-guide.md— added the three-level prop-phase table under Props, the "declare the phase you consume the prop" rule, and the "undefined at slow / signals at interactive" rules.agent-kit-template/designer/contracts-and-plugins.md— added the "prop phase must cover the binding source" rules for jay-html bindings (which mismatches error and why).
Incidental fixes
packages/plugins/ui-kit/lib/clipboard-copy.jay-contract— declaredtextpropphase: fast+interactive(was a latent mismatch surfaced once the validation ran in the build path). All three jay-stack examples validate with 0 errors.
Verification against criteria
- ✅ SSR renders the
fast+interactivebadge value (badge--warning) matching hydrate — smoke 61/61. - ✅
fastprop resolves at fast (raw binding re-resolved against merged scope). - ✅
slowprops unchanged; all-slow contracts unaffected. - ✅ Single in-code
PropsT; no per-phase prop codegen;jay-stack-types.tsunchanged. - ✅ Constant prop bound to a changing source fails validation, surfaced in the build (exit 1) — verified with a deliberate mismatch.
- ✅ Tier 2 passthrough echoes phase-correct props (structural prop phase read from tag).
- ✅
markdown-content/markdown-livecompile and behave unchanged.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.