Headless Component Default Fast View State
Design Log #105 — Headless Component Client Defaults
Written for AI agents. See Log Methodology Note below for details.
Background
Headless component instances inside forEach can be added dynamically on the client (e.g., "Add Item" button appends to the array). When a new item is created client-side, makeHeadlessInstanceComponent's wrappedConstructor looks up the instance's fast ViewState from __headlessInstances[key] — but no server data exists for the new item. The lookup returns undefined, makeSignals({}) creates an empty object, and the interactive constructor crashes when destructuring signals (e.g., const [value, setValue] = fastViewState.value → TypeError).
Related
- DL#102 Issue 7 — headless instance ref onclick (fixed: coordinate on root element)
- DL#104 — hydration test plan (6c forEach interactivity test exposes this)
Problem
When a headless component instance is created client-side (not from SSR):
- No
__headlessInstancesentry exists for the new instance's coordinate key fastVSisundefined→makeSignals({})→ empty signals- Interactive constructor receives empty
Signals<FastVS>→ crash on destructure
This affects:
- forEach "add item" — new items have no server data
- Conditional toggle — showing a previously-hidden headless instance that wasn't SSR'd (condition was false at SSR time)
- Any dynamic headless instance creation after initial page load
Does NOT affect:
- Conditional true→false→true toggle — the DOM instance and component are preserved, no new creation needed
Design
New lifecycle: withClientDefaults
Add an optional lifecycle function to the full-stack component builder that provides default ViewState and carryForward values from props. Called on the client only when server data is missing.
export const widget = makeJayStackComponent<WidgetContract>()
.withProps<WidgetProps>()
.withFastRender(async (props) => {
// Server-side: may use database, services, etc.
const product = await db.getProduct(props.productId);
return Pipeline.ok({}).toPhaseOutput(() => ({
viewState: { name: product.name, price: product.price, inStock: product.inStock },
carryForward: { productId: product.id },
}));
})
.withClientDefaults((props) => ({
// Client-side fallback: pure computation from props, no server APIs
viewState: { name: props.name, price: props.price, inStock: true },
carryForward: { productId: props.productId },
}))
.withInteractive((props, refs, fastViewState, carryForward) => {
// fastViewState is ALWAYS Signals<FastVS> — never undefined
const [inStock] = fastViewState.inStock;
// ...
});
Note: The props are the same on server and client. On the server, the page's fast render does the query and passes product data as props to each widget. On the client, the page passes the same props when adding new items. clientDefaults uses these props to compute initial values.
Synchronous only
clientDefaults is synchronous — (props: Props) => { viewState, carryForward }. No Promise support.
Individual ViewState members can be async (Jay supports async rendering via when-loading/when-resolved). If the component needs to fetch data for a new item, it should use an async ViewState member, not an async clientDefaults.
Runtime behavior
In makeHeadlessInstanceComponent's wrappedConstructor:
const fastVS = instanceData?.viewStates?.[resolvedKey];
const cf = instanceData?.carryForwards?.[resolvedKey];
let resolvedFastVS: object;
let resolvedCf: object;
if (fastVS) {
// Server data available (existing SSR items)
resolvedFastVS = fastVS;
resolvedCf = cf || {};
} else if (clientDefaults) {
// No server data, use client-side defaults
const defaults = clientDefaults(props);
resolvedFastVS = defaults.viewState;
resolvedCf = defaults.carryForward ?? {};
} else {
// No server data, no defaults — warn and use empty
console.warn(`Headless component instance has no server data and no clientDefaults`);
resolvedFastVS = {};
resolvedCf = {};
}
const signalVS = makeSignals(resolvedFastVS);
return interactiveConstructor(props, refs, signalVS, resolvedCf, ...pluginResolvedContexts);
Builder chain position
makeJayStackComponent()
.withProps<P>()
.withSlowlyRender(...) // optional
.withFastRender(...) // optional
.withClientDefaults(...) // NEW, optional, client-only
.withInteractive(...) // optional
Type signature
withClientDefaults(
fn: (props: Props) => { viewState: FastViewState; carryForward?: CarryForward }
): Builder<...>
Return type must match the shape of withFastRender's output — viewState has the same type as FastViewState, carryForward has the same type as the carry-forward from the fast phase.
Compiler interaction
makeHeadlessInstanceComponent receives the whole component definition object instead of individual properties:
// Current (4 separate params):
makeHeadlessInstanceComponent(preRender, widget.comp, key, widget.contexts);
// Proposed (component object):
makeHeadlessInstanceComponent(preRender, widget, key);
makeHeadlessInstanceComponent reads from widget:
widget.comp— interactive constructorwidget.contexts— context markerswidget.clientDefaults— default factory (may be undefined)
This simplifies the compiler output and makes adding future properties non-breaking.
Client-only code
clientDefaults is client-only — it must NOT be included in server bundles. The compiler's existing code-splitting for makeJayStackComponent erases client-only functions from server builds. clientDefaults follows the same pattern: it's stored on the component definition object (alongside comp) which is already client-only.
Conditional headless instances
- SSR condition = false → client toggles to true: The create path runs. No server data exists →
clientDefaultsis called. - SSR condition = true → client toggles false → true: The
hydrateConditionalpreserve-on-toggle behavior keeps the DOM instance alive. No new creation.clientDefaultsis NOT called.
Questions and Answers
Q1: Should clientDefaults also provide default carryForward?
A: Yes. The function returns { viewState, carryForward }. CarryForward is optional and defaults to {}.
Q2: What happens for conditional headless instances?
A: SSR false → toggle true: clientDefaults called. Toggle true→false→true: DOM preserved, no call needed.
Q3: How does this interact with the compiler?
A: Pass the whole widget object to makeHeadlessInstanceComponent instead of widget.comp + widget.contexts separately. Simplifies compiler output and is future-proof.
Q4: Should missing clientDefaults be an error or a warning?
A: Warning for now. Log a console warning, pass {}. The interactive constructor may crash with a clearer error (destructuring undefined).
Q5: For the product search example, where does the product data come from?
A: Props are the same on server and client. The page's fast render does the query, passes product data as props. On the client, the page passes the same props when adding new items. clientDefaults uses these props — no duplication.
Implementation Plan
Phase 1: Runtime support
- Add
clientDefaults?: (props: Props) => { viewState: FastVS; carryForward?: CF }toJayStackComponentDefinition - Add
withClientDefaults(fn)tojay-stack-builder.ts - Update
makeHeadlessInstanceComponentsignature: receive component object instead ofcomp+contexts - In
wrappedConstructor: whenfastVSis undefined, callclientDefaults(props)if available, else warn and use{}
Phase 2: Compiler support
- Update all
makeHeadlessInstanceComponentcall sites to passwidget(whole object) instead ofwidget.comp, key, widget.contexts - Element target, hydrate target, server-element target
- Verify
clientDefaultsis client-only (not in server bundle)
Phase 3: Tests
Runtime tests (packages/jay-stack/stack-client-runtime or packages/runtime/runtime)
makeHeadlessInstanceComponentwithclientDefaults:- Server data available → uses server data,
clientDefaultsNOT called - Server data missing,
clientDefaultsdefined → callsclientDefaults(props), creates correct signals - Server data missing,
clientDefaultsundefined → warns, passes{}(graceful degradation) clientDefaultsreceives correct props- CarryForward from
clientDefaultsis passed to interactive constructor
- Server data available → uses server data,
makeHeadlessInstanceComponentwith whole component object:- Reads
widget.comp,widget.contexts,widget.clientDefaultscorrectly - Works when
clientDefaultsis undefined (backward compatible)
- Reads
Compiler tests (packages/compiler/compiler-jay-html)
- Element target:
makeHeadlessInstanceComponentcall emitswidget(whole object) instead ofwidget.comp, key, widget.contexts - Hydrate target: same —
makeHeadlessInstanceComponentcall passes whole object - Update all headless instance fixtures (element + hydrate) to match new call signature
- Verify
clientDefaultsproperty is NOT referenced in server-element target output
Builder tests (packages/jay-stack/full-stack-component)
withClientDefaultsin builder chain: setsclientDefaultson component definitionwithClientDefaultsis optional: omitting it →clientDefaultsis undefined- Chain order:
withFastRender→withClientDefaults→withInteractiveworks - Chain without
withFastRender:withClientDefaults→withInteractiveworks
Integration tests (packages/jay-stack/dev-server/test/hydration.test.ts)
- 6c forEach: "Add Item" creates widget with default values from
clientDefaults(no crash) - 6c forEach: existing SSR items still use server data (not defaults)
- 6c forEach: increment on new item works (signals from defaults are reactive)
- 6c forEach: remove item works (no orphan state)
- 6b conditional: false→true toggle uses
clientDefaultswhen SSR condition was false - Label text preserved after button click (slow data not overwritten by interactive update)
Verification Criteria
- New forEach items render with default values (no crash)
- Existing SSR items still use server fast ViewState (not defaults)
- Interactive constructor always receives valid
Signals<FastVS>(never undefined) withClientDefaultsis optional — components without it behave as before (warning logged)clientDefaultsis not included in server bundles- 6c forEach interactivity test passes (add item, increment, remove)
- All existing compiler fixture tests pass (updated for new call signature)
- All existing hydration tests pass (no regressions)
Addendum: withClientDefaults lifecycle clarification
Investigation
Explored whether withClientDefaults should be removed from the framework. Analysis found:
- Every usage in test fixtures was an exact copy of
withFastRenderoutput — appeared redundant - The builder only allows
withClientDefaultsafterwithFastRender, making it unavailable for components without server phases
Conclusion: keep withClientDefaults
withClientDefaults serves a legitimate purpose that withFastRender cannot: providing initial ViewState for forEach items created on the client. When a user adds a new item to a forEach array, makeHeadlessInstanceComponent creates a new instance with no server data. clientDefaults provides the initial ViewState for these dynamically-created items.
This is NOT a safety net for framework bugs — it's a required feature for dynamic client-side item creation.
What withClientDefaults is NOT for
- Fallback when server data delivery fails (that's a framework bug to fix)
- Default ViewState for components without
withFastRender(usewithFastRenderinstead) - Components outside forEach that have static props (server always provides data)
UI Kit components
The ui-kit components (scroll-carousel, clipboard-copy) use withFastRender for SSR initial state and do NOT need withClientDefaults — they're not used inside forEach.
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.