Skip to content

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:

TargetConsumer-side dispatch the compiler emitsNative to the framework?
Vue<template #[<expr>]>body</template> — Vue 3.4+ native scoped-slot bracketed formYes — native
Lit<div slot="${<expr>}">body</div> — shadow-DOM native projection routes on the runtime slot= valueYes — native
React<Producer slots={{ [<expr>]: () => <>body</> }} /> — additive slots prop with object dispatchNo — but object dispatch is the idiomatic React form
Solid<Producer slots={{ [<expr>()]: () => <>body</> }} /> — signal-auto-called keyNo — 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 getterNo — 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:

  1. The rozieSlots record property — a direct object of callbacks. No JSON serialization, no attribute observer. This is the primary path.
  2. A named function prop — this.headerCell?.(ctx), the render-prop analog.
  3. Native <slot> projection carrying data-rozie-params — a JSON-serialized context object on the projected element, readable with the small observeRozieSlotCtx helper.

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:

Targetr-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 via v-model/bind: macros)
  • $props.X — only when the consumer's own <props> declares X with model: 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 ​

CodeTriggerNotes
ROZ949r-model:propName= on a component whose producer prop lacks model: trueDual-frame diagnostic — consumer site + producer decl site, so authors see exactly which prop on which producer needs the model: true toggle
ROZ950r-model: with empty arg (e.g. r-model:="..."), OR r-model:propName= applied to a non-component HTML tagSingle combined code — both cases share "the directive cannot be applied here" semantics
ROZ951RHS is not a writable lvalue per the rules aboveHint 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:

  1. Record lookup — this.rozieSlots?.[key]?.(scope), tried first.
  2. Named function property — the pre-existing per-slot @property receiver, for an ordinary statically-named scoped slot that never needed the record.
  3. 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 toRoot r-if lowers to
ReactuseEffect(…, [])open && <div> inside the same component function
VueonMounted(…)v-if in the template
SvelteonMount(…) at instance scope{#if} in the markup
AngularngAfterViewInit()@if in the template
SolidonMount(…) at component-body scope<Show> inside the returned JSX
LitfirstUpdated()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.

Pre-1.0 — APIs may change between minor versions.