Appearance
Cross-Framework Parity & Known Limitations
Rozie's goal is high-percentage cross-framework parity — one .rozie definition that compiles to idiomatic React, Vue, Svelte, Angular, Solid, and Lit. "High-percentage", not 100%: each target framework has its own capabilities and constraints, and a small set of documented edge cases is the deliberate trade-off for a single author-side API.
Every component behavior that matters — reactive state, two-way binding, events, lifecycle, slots, listeners — behaves identically across all six targets. The limitations below are about how a consumer authors against a slot, or about a target framework's own runtime semantics — never about a component rendering wrong state or firing a broken event.
Text interpolation of non-primitives — unified
Interpolating a non-primitive value (, ) is a place the six targets historically diverged hard: Vue pretty-printed JSON, Svelte/Angular comma-joined [object Object], Solid/Lit space-joined it, and React threw Objects are not valid as a React child and crashed. Rozie unifies this — a non-provably-primitive interpolation is wrapped in an internal rozieDisplay helper (Vue toDisplayString semantics, crash-safe on circular/BigInt structures) so the same portable JSON renders everywhere and React no longer crashes. Provably-primitive interpolations (typed String/Number/Boolean props, .length, comparisons, concatenations, boolean HTML attributes, …) stay raw and byte-identical to per-target hand-written output. Vue is untouched (its native behavior already matches); Angular inlines the helper as a component method.
This is on by default and reversible: safeInterpolation: false (compiler/plugin option), --no-safe-interpolation (CLI), or <rozie safe-interpolation="false"> (per-component envelope attribute, precedence: envelope › global › default-on) restores the old raw per-target emit. Separately, a bare whole-object sigil ({} rather than ) has no portable v1 representation and is a uniform compile error (ROZ978), independent of safeInterpolation. See Safe non-primitive interpolation for the full mechanics.
Relatedly, in attribute position the targets used to disagree on nullish values: a whole-value binding like :data-locked="$data.locked ? 'true' : null" (or a plain :title="$data.note" that is null) dropped the attribute on Vue but rendered attr="" on the other five (they routed through rozieDisplay, and rozieDisplay(null) is ''). Rozie unifies this too — a nullish bound attribute value now drops the attribute on all six targets, matching Vue's native :attr binding and the web platform (so [data-locked] presence selectors and hasAttribute(...) agree everywhere). The drop predicate is value == null only — false still stringifies, so aria-expanded="false" / data-x="false" are preserved. Text/interpolation position is unchanged (null → '', the table above). See Attribute position — a nullish bound value drops the attribute.
Slot consumer ergonomics
React — scoped slots are render-prop function props
React has no native template-slot mechanism. A Rozie scoped slot (<slot name="item" :value="x" />) compiles, for the React target, to a render-prop function prop:
tsx
// Rozie scoped slot → React consumer
<List renderItem={(ctx) => <Row label={ctx.value} />} />Every other target gets a native template/snippet/slot binding; React consumers use the render-prop form. The slot still receives the exact same params — only the consumer-side authoring shape differs.
Consumer-side slot fill — third-party React consumers of compiled Rozie components
When one .rozie file consumes another (<Modal><template #header="{ close }">…</template></Modal>), Rozie's compiler threads the producer's SlotDecl.paramTypes onto the consumer's SlotFillerDecl.paramTypes, then emits the per-target dispatch shape with full type narrowing. For Rozie-to-Rozie composition this is transparent: a Rozie consumer authors <template #header="{ close }"> and the compiler produces a correctly-typed render-prop, snippet block, template, or native slot per target.
For third-party React consumers that import a compiled Rozie component directly (without going through the Rozie compiler), the React render-prop divergence applies asymmetrically. The consumer must use the render-prop form:
tsx
// External React consumer (NOT a .rozie file) — note the function-prop shape
import Modal from '@my-design-system/modal';
<Modal renderHeader={({ close }) => <button onClick={close}>×</button>} />The renderHeader prop signature is exported via the .d.ts sidecar, so the close param is fully typed — the only ergonomic friction is the function-prop authoring shape (vs Vue's <template #header="{ close }">). This is the documented v1 acceptable edge case per Rozie's "high-percentage parity, not 100%" stance.
The canonical example is examples/ModalConsumer.rozie and its compiled output at tests/dist-parity/fixtures/ModalConsumer.*.
Dynamic slot names (R5) — per-target consumer-side divergences
A Rozie consumer using <template #[expr]> (dynamic slot name, where expr evaluates at runtime to the slot name) compiles to a different dispatch shape on each target.
Between two .rozie files this is transparent. The compiler synthesizes both halves of the hand-off, so the author writes the bracketed template and nothing else:
- The producer automatically gains the matching
slots?/snippets?/templates?input, fully typed — including a template-literal index signature for each dynamic-name family. It is never declared in<props>. - The consumer automatically emits the keyed record, with any scoped params threaded onto the callback signature.
The per-target shapes the compiler emits:
| Target | Consumer-side dispatch the compiler emits | Native to the framework? |
|---|---|---|
| Vue | <template #[<expr>]>body</template> — Vue 3.4+ native scoped-slot bracketed form | Yes — native |
| Lit | <div slot="${<expr>}">body</div> — shadow-DOM native projection routes on the runtime slot= value | Yes — native |
| React | <Producer slots={{ [<expr>]: () => <>body</> }} /> — additive slots prop with object dispatch | No — but object dispatch is the idiomatic React form |
| Solid | <Producer slots={{ [<expr>()]: () => <>body</> }} /> — signal-auto-called key | No — same shape; the key is a signal read |
| Svelte | <Producer snippets={{ [<expr>]: __rozieDynSlot_N }}>{#snippet __rozieDynSlot_N()}body{/snippet}</Producer> | No — snippets + {#snippet} is the idiomatic Svelte 5 form |
| Angular | <Producer><ng-template #__dynSlot_N>body</ng-template><ng-container *ngTemplateOutlet="templates[<expr>]" /></Producer> + class-body @ViewChild + templates getter | No — and the most ceremony of the six |
Where the divergence is actually felt is a hand-written, non-Rozie consumer importing a compiled Rozie producer — the same asymmetry described for third-party React consumers above. A Vue or Lit consumer writes the framework-native form and it works. A React, Solid, Svelte, or Angular consumer builds the record by hand:
tsx
// External React consumer (NOT a .rozie file)
import DynamicSlots from '@my-design-system/dynamic-slots';
<DynamicSlots slots={{ [columnKey]: ({ row, value }) => <Cell row={row} value={value} /> }} />That is the idiomatic shape in those frameworks rather than a Rozie-specific concession — none of them has a template-slot syntax for Vue's bracketed form to be more native than. The producer's emitted .d.ts types the record for the consumer with a family index signature, but the key itself is not validated: the emitted type is a template-literal index signature (`cell-${string}`) plus a trailing catch-all index signature ([key: string]), so a near-miss key (cell-pric) and an arbitrary key (cel-price) both typecheck —
ts
slots?: { 'cell-total'?: ((params: { value: any }) => import('react').ReactNode) | undefined;
[key: `cell-${string}`]: ((params: { row: any; value: any }) => import('react').ReactNode) | undefined;
[key: string]: ((...args: any[]) => import('react').ReactNode) | undefined; };(tests/dist-parity/fixtures/DynamicSlots.tsx:13). What the family typing genuinely provides is scoped-parameter typing — a typed { row, value } context on a matching fill instead of an untyped variadic (...args: any[]) signature — a real and worthwhile gain, but not key validation. The runtime dispatch order is slots?.[name]?.(ctx) ?? renderNamed?.(ctx) ?? defaultContent.
Angular — the lone consumer-typing divergence
Angular's producer-side intake for a dynamic-name slot family is templates?: Record<string, TemplateRef<unknown>> — type-erased per key. The per-family context interfaces the compiler emits exist only to satisfy the static ngTemplateContextGuard, not to type a keyed record the way the other five targets' slots? / snippets? / rozieSlots? inputs do. This is documented, not closed — every other target (React, Vue, Svelte, Solid, Lit) gets a keyed, scoped-parameter-typed record for a dynamic-name family; Angular's consumer intake stays a plain Record<string, TemplateRef<unknown>> until a future phase takes it up.
Lit — scoped slot params arrive via a data attribute
Web Components have no native scoped-slot mechanism, so the Lit target dispatches a scoped fill through three tiers, in this order:
- The
rozieSlotsrecord property — a direct object of callbacks. No JSON serialization, no attribute observer. This is the primary path. - A named function prop —
this.headerCell?.(ctx), the render-prop analog. - Native
<slot>projection carryingdata-rozie-params— a JSON-serialized context object on the projected element, readable with the smallobserveRozieSlotCtxhelper.
Tier 3 is the fallback, and it is what an ordinary statically-named scoped slot resolves to when neither of the first two is supplied. Default and named slots without params use native <slot> projection unchanged.
A first-paint smoke check (tests/visual-regression/specs/lit-scoped-fill-firstpaint.spec.ts) verifies the observed ctx is wired correctly on the first paint — no flicker, no undefined reference in the body's this._headerCtx?.close access.
A scoped fill that is also dynamically named, or matched against a producer's dynamic-name family, or targeting a non-identifier static name, always takes tier 1 — the attribute path cannot express those. See Lit — rozieSlots record dispatch for the dispatch order and the third-party direct-binding form.
Consumer-side two-way binding
A producer prop declared model: true emits the per-target two-way machinery (defineModel, $bindable, useControllableState, model<T>(), createControllableSignal, Lit custom-event pair) on the producer side. The consumer side opts in to the matching two-way wiring via the r-model:propName="<writable-lvalue>" directive — the Vue 3 v-model:argName= analog, parallel to the existing form-input r-model="$data.draft" sugar.
rozie
<!-- consumer.rozie — engaging the producer's model: true machinery -->
<Modal r-model:open="$data.dialogOpen">
<template #footer="{ close }">
<button @click="close">×</button>
</template>
</Modal>Per-target emit
The directive lowers to each target's two-way binding shape:
| Target | r-model:open="$data.open" emit |
|---|---|
| Vue | <Modal v-model:open="open"> |
| Svelte | <Modal bind:open={open}> |
| React | <Modal open={open} onOpenChange={setOpen}> |
| Solid | <Modal open={open()} onOpenChange={setOpen}> |
| Angular | <rozie-modal [open]="open()" (openChange)="open.set($event)"> (long-form [(open)] banana-in-a-box) |
| Lit | <rozie-modal .open=${this._open.value} @open-change=${(e: CustomEvent) => { this._open.value = e.detail; }}> |
The byte-locked dist-parity fixtures live at tests/dist-parity/fixtures/ModalConsumer.{vue,svelte,tsx,solid.tsx,lit.ts,angular.ts} and the matching forwarding-pattern fixtures live at tests/dist-parity/fixtures/WrapperModal.*. All 6 × 4 entrypoints (compile / cli / babel-plugin / unplugin) emit byte-identical output.
LHS rules
The right-hand-side expression must be a writable lvalue:
$data.X— top-level reactive state member (most common case)$data.X.Y.Z— deep member chain rooted in$data(validator accepts; Lit/React/Solid emit inline reassignment arrow as setter; Vue/Svelte handle natively viav-model/bind:macros)$props.X— only when the consumer's own<props>declaresXwithmodel: true(the forwarding pattern; see WrapperModal demonstration below)
Literals, ternaries, function calls, $computed refs, $refs.X, and $props.X without model: true are rejected at IR-validation time with ROZ951 (LHS not writable).
Diagnostic codes
| Code | Trigger | Notes |
|---|---|---|
| ROZ949 | r-model:propName= on a component whose producer prop lacks model: true | Dual-frame diagnostic — consumer site + producer decl site, so authors see exactly which prop on which producer needs the model: true toggle |
| ROZ950 | r-model: with empty arg (e.g. r-model:="..."), OR r-model:propName= applied to a non-component HTML tag | Single combined code — both cases share "the directive cannot be applied here" semantics |
| ROZ951 | RHS is not a writable lvalue per the rules above | Hint suggests bind to $data.X or, in a wrapper component, $props.X declared with model: true |
WrapperModal forwarding pattern
A consumer component can ITSELF declare a model: true prop and forward it into a producer's r-model:propName= directive. The wrapper's prop becomes two-way (its consumers can r-model:open="$data.x" on the wrapper) and internally propagates through the inner Modal's controllable-state machinery:
rozie
<rozie name="WrapperModal">
<components>{ Modal: './Modal.rozie' }</components>
<props>
{
open: { type: Boolean, default: false, model: true }
}
</props>
<template>
<Modal r-model:open="$props.open" /> <!-- forwards wrapper's own model:true prop -->
</template>
</rozie>The byte-locked emit lives at tests/dist-parity/fixtures/WrapperModal.* for each target. The wrapper's useControllableState (React) / createControllableSignal (Solid) / defineModel (Vue) / $bindable (Svelte) / model<T>() (Angular) / createLitControllableProperty (Lit) instance becomes the bridge between the parent's two-way bind and the inner Modal's matching machinery.
Lit — rozieSlots record dispatch for scoped, dynamic-name, and family-matched fills
Lit's static-name scoped-fill mechanism (a per-slot _<name>Ctx class field, described above) needs a stable name known at compile time. That broke down for a dynamic name (only known at runtime) — there was no stable field name to synthesise it from, so mixing a scoped fill with a dynamic name (e.g. <template #[someName]="{ ctx }">…</template>) did not work on Lit — the only one of the six targets where this pairing fell through.
Lit now gains the same rozieSlots record property the other five record-capable targets (slots?: / snippets?: / templates?: — React / Solid / Svelte / Angular) already had:
ts
@property({ attribute: false })
rozieSlots?: Record<string, (scope: any) => unknown>;Every qualifying slot fill — scoped, dynamically named, or matched against a producer's dynamic-name family (SlotDecl.dynamicNameExpr / namePrefix, see Producer-side dynamic slot names) — now contributes one entry to a single .rozieSlots=${{ ... }} object literal on the producer's tag, collected in source order. The producer's own dispatch tries, in order:
- Record lookup —
this.rozieSlots?.[key]?.(scope), tried first. - Named function property — the pre-existing per-slot
@propertyreceiver, for an ordinary statically-named scoped slot that never needed the record. - Native
<slot>fallback — for a fill with no scope and no dynamic name, or when a consumer left a family member unfilled.
The legacy data-rozie-params + observeRozieSlotCtx light-DOM path is retained unchanged — a paramless dynamic fill (a runtime name with no scoped context to carry) keeps its original attribute-projection wrapper rather than being forced through the record. Nothing that worked before this feature landed changed shape.
Backed by packages/targets/lit/src/emit/__tests__/rozieSlots.test.ts (19 cases: record-path routing, dispatch ordering, and a regression guard proving the legacy paramless-dynamic-fill path is untouched) and packages/core/tests/79-09-r12-six-target-compile.test.ts.
Third-party (non-Rozie) Lit consumers — direct record binding
A plain Lit (or any custom-elements) consumer that imports a compiled Rozie component directly, without going through the Rozie compiler, can now set the record property directly instead of round-tripping scope data through a JSON-serialized data-rozie-params attribute and an observeRozieSlotCtx observer:
ts
// External Lit consumer (NOT a .rozie file) — direct record binding
import { html, render } from 'lit';
import '@my-design-system/table';
const el = document.querySelector('my-table')!;
(el as any).rozieSlots = {
'cell-status': (scope: { row: Row; value: unknown }) =>
html`<span class="badge">${scope.value}</span>`,
'cell-score': (scope: { row: Row; value: unknown }) =>
html`<strong>${scope.value}</strong>`,
};Each key is a scope-taking function — no serialization, no attribute observer, no JSON round-trip. This is the direct form of the slots/snippets/templates props that third-party React/Solid/Svelte consumers already had; Lit was the one target still missing a settable record property, and this closes that gap.
Target-framework lifecycle semantics
Lit / Solid — lifecycle hooks colocated with an always-rendered component
$onMount is connect-once on every target. It fires when the component instance mounts — not when a root-level r-if flips. That is true of all six emitters, and it is deliberate:
| Target | $onMount lowers to | Root r-if lowers to |
|---|---|---|
| React | useEffect(…, []) | open && <div> inside the same component function |
| Vue | onMounted(…) | v-if in the template |
| Svelte | onMount(…) at instance scope | {#if} in the markup |
| Angular | ngAfterViewInit() | @if in the template |
| Solid | onMount(…) at component-body scope | <Show> inside the returned JSX |
| Lit | firstUpdated() | a nothing ternary inside render() |
In every case the hook sits outside the conditional. Hold the component mounted and toggle the condition, and React's useEffect(…, []) re-fires no more than Lit's firstUpdated().
Where you may still observe a difference is the consumer side, and it is a property of the host application's template rather than of the emitted component. A React, Vue, Svelte, or Angular consumer typically writes the condition around the component — open && <Modal />, <Modal v-if="open" /> — which destroys and recreates the instance, so the hook runs again. A Lit consumer drives a connected custom element by property (<rz-modal .open=…>); there is no idiomatic way to "unmount" it, so the element stays connected and the hook does not re-run. Solid behaves the same way whenever the consumer keeps the component mounted (note that Solid's open() && <Modal /> does not unmount — <Show> is required). Rozie compiles the component, not the page that hosts it, so it cannot influence this.
The cleanup itself is always symmetric (no leaks, no double-fire).
For prop-coupled effects, use $watch — not $onMount. $watch is supported on all six targets, fires post-flush everywhere (so its callback may safely read $refs), and accepts { immediate: true } when the effect must also run on the initial value:
js
$watch(() => $props.open, (isOpen) => {
if (isOpen) lockScroll()
else unlockScroll()
})The two primitives are complementary by design: $onMount anchors connect-time setup and its symmetric teardown, $watch tracks prop transitions. The reference Modal.rozie pairs them exactly this way for its lockBodyScroll prop.
Do not rely on $onMount re-firing
Because $onMount and $watch are frequently paired, an effect invoked from both must be idempotent. Modal.rozie's lockScroll saves the previous document.body.style.overflow before overwriting it — running it twice for one open would save the already-locked value and leave the page permanently unscrollable. Gate such effects so they run once per transition.
These are the complete set of documented limitations as of v1. Everything else — props, producer-side model: two-way machinery, consumer-side two-way binding (r-model:propName=), <data> reactive state, $computed, <listeners> (<listener> elements with modifiers + r-if conditional attach), r-for / r-if / form-input r-model, default + named slots, $emit, refs — behaves identically.