Appearance
TreeNode
A minimal recursive component. Demonstrates <components> block self-import and the inline <TreeNode :node="child" /> recursion inside its own <template>. Each target gets the idiomatic self-reference form: Vue's defineOptions({ name }) + setup-scope import, React's hoisted named function declaration, Svelte's import TreeNode from './TreeNode.svelte' (with extension), Angular's forwardRef(() => TreeNode), Solid's named function declaration, and Lit's self-referencing custom-element tag.
Live demo
Three levels deep — Vue's defineOptions({ name: 'TreeNode' }) lets the component reference itself by name inside its own <template>.
Source — TreeNode.rozie
rozie
<!--
TreeNode.rozie
Demonstrates Phase 06.2 component composition + recursion (D-119):
- <components> block declaring a SELF-import (TreeNode → './TreeNode.rozie').
The explicit self-entry is redundant per CONTEXT.md <specifics>: outer-name
self-ref path covers it (D-114). Kept here on purpose to exercise both the
explicit-self-entry skip logic (React/Angular) AND the outer-name precedence.
- Recursive template: <TreeNode :node="child" /> inside the component itself.
tagKind: 'self' on the inner tag (P1 lowering); P2 emit dispatches per target:
Vue → defineOptions({ name: 'TreeNode' }) + setup-scope import
React → named function declaration (hoisted self-ref)
Svelte → import TreeNode from './TreeNode.svelte' (D-117 self-import idiom)
Angular → forwardRef(() => TreeNode) in @Component({ imports: [...] })
- Test data shape: { id, label, children: TreeNode[] }; the browser-mount
integration test (Phase 06.2 P3 Task 2) feeds a 3-level fixture and
asserts all 3 labels render in DOM.
- r-for bare-comma form `r-for="child, childIndex in ..."` exercising both
the item alias and the index alias — complements Table.rozie's flat-list
coverage with the recursive-tree case. The `:data-index="childIndex"`
attribute exposes the index in the DOM so visual regression / browser
mount tests can verify per-target index binding (defense in depth for
the extractRForAliases bare-comma form support added 2026-05-17).
-->
<rozie name="TreeNode">
<components>
{
TreeNode: './TreeNode.rozie',
}
</components>
<props>
{
node: { type: Object, default: () => ({ id: '', label: '', children: [] }) },
}
</props>
<template>
<div class="tree-node">
<span class="tree-node__label">{{ $props.node.label }}</span>
<ul r-if="$props.node.children && $props.node.children.length > 0" class="tree-node__children">
<li r-for="child, childIndex in $props.node.children" :key="child.id" :data-index="childIndex">
<TreeNode :node="child" />
</li>
</ul>
</div>
</template>
<style>
.tree-node { font-family: system-ui; padding-left: 0.5rem; }
.tree-node__label { display: inline-block; }
.tree-node__children { list-style: none; margin: 0.25rem 0 0 0; padding-left: 1rem; border-left: 1px dashed currentColor; }
</style>
</rozie>Compiled output
vue
<template>
<div class="tree-node" v-bind="$attrs">
<span class="tree-node__label">{{ props.node.label }}</span>
<ul v-if="props.node.children && props.node.children.length > 0" class="tree-node__children">
<li v-for="(child, childIndex) in props.node.children" :key="child.id" :data-index="childIndex">
<TreeNode :node="child"></TreeNode>
</li>
</ul></div>
</template>
<script setup lang="ts">
import TreeNode from './TreeNode.vue';
defineOptions({ name: 'TreeNode' });
const props = withDefaults(
defineProps<{ node?: Record<string, any> }>(),
{ node: () => ({
id: '',
label: '',
children: []
}) }
);
</script>
<style scoped>
.tree-node { font-family: system-ui; padding-left: 0.5rem; }
.tree-node__label { display: inline-block; }
.tree-node__children { list-style: none; margin: 0.25rem 0 0 0; padding-left: 1rem; border-left: 1px dashed currentColor; }
</style>tsx
import { useState } from 'react';
import { clsx, rozieAttr, rozieDisplay } from '@rozie/runtime-react';
import './TreeNode.css';
interface TreeNodeProps {
node?: Record<string, any>;
}
export default function TreeNode(_props: TreeNodeProps): JSX.Element {
const __defaultNode = useState(() => (() => ({
id: '',
label: '',
children: []
}))())[0];
const props: Omit<TreeNodeProps, 'node'> & { node: Record<string, any> } = {
..._props,
node: _props.node ?? __defaultNode,
};
const attrs: Record<string, unknown> = (() => {
const { node, ...rest } = _props as TreeNodeProps & Record<string, unknown>;
void node;
return rest;
})();
return (
<>
<div {...attrs} className={clsx("tree-node", (attrs.className as string | undefined))} data-rozie-s-a7176a6e="">
<span className={"tree-node__label"} data-rozie-s-a7176a6e="">{rozieDisplay(props.node.label)}</span>
{(props.node.children && props.node.children.length > 0) && <ul className={"tree-node__children"} data-rozie-s-a7176a6e="">
{props.node.children.map((child, childIndex) => <li key={child.id} data-index={rozieAttr(childIndex)} data-rozie-s-a7176a6e="">
<TreeNode node={child} data-rozie-s-a7176a6e="" />
</li>)}
</ul>}</div>
</>
);
}svelte
<script lang="ts">
import TreeNode from './TreeNode.svelte';
import { applyListeners, rozieAttr, rozieDisplay } from '@rozie/runtime-svelte';
interface Props {
node?: any;
[key: string]: unknown;
}
let __defaultNode = (() => ({
id: '',
label: '',
children: []
}))();
let { node = __defaultNode, ...__rozieAttrs }: Props = $props();
</script>
<div {...__rozieAttrs} class={["tree-node", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-a7176a6e><span class="tree-node__label" data-rozie-s-a7176a6e>{rozieDisplay(node.label)}</span>{#if node.children && node.children.length > 0}<ul class="tree-node__children" data-rozie-s-a7176a6e>{#each node.children as child, childIndex (child.id)}<li data-index={rozieAttr(childIndex)} data-rozie-s-a7176a6e><TreeNode node={child} data-rozie-s-a7176a6e></TreeNode></li>{/each}</ul>{/if}</div>
<style>
:global {
.tree-node[data-rozie-s-a7176a6e] { font-family: system-ui; padding-left: 0.5rem; }
.tree-node__label[data-rozie-s-a7176a6e] { display: inline-block; }
.tree-node__children[data-rozie-s-a7176a6e] { list-style: none; margin: 0.25rem 0 0 0; padding-left: 1rem; border-left: 1px dashed currentColor; }
}
</style>ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, viewChild } from '@angular/core';
function __rozieDisplay(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'object') {
try {
return JSON.stringify(v, null, 2);
} catch {
// Circular structure or a non-serialisable value (BigInt nested in an
// object). Degrade to a non-throwing form so the wrap never crashes the
// render — that is the entire point of "safe" interpolation (SPEC-1).
return String(v);
}
}
return String(v);
}
function __rozieAttr(v: unknown): string | null {
return v == null ? null : __rozieDisplay(v);
}
@Component({
selector: 'rozie-tree-node',
standalone: true,
imports: [forwardRef(() => TreeNode)],
template: `
<div class="tree-node" #rozieSpread_0 #rozieListenersTarget_1>
<span class="tree-node__label">{{ rozieDisplay(node().label) }}</span>
@if (node().children && node().children.length > 0) {
<ul class="tree-node__children">
@for (child of node().children; track child.id; let childIndex = $index) {
<li [attr.data-index]="rozieAttr(childIndex)">
<rozie-tree-node [node]="child"></rozie-tree-node>
</li>
}
</ul>
}</div>
`,
styles: [`
.tree-node { font-family: system-ui; padding-left: 0.5rem; }
.tree-node__label { display: inline-block; }
.tree-node__children { list-style: none; margin: 0.25rem 0 0 0; padding-left: 1rem; border-left: 1px dashed currentColor; }
`],
})
export class TreeNode {
node = input<Record<string, any>>((() => ({
id: '',
label: '',
children: []
}))());
private __rozieDestroyRef = inject(DestroyRef);
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default TreeNode;tsx
import type { JSX } from 'solid-js';
import { For, Show, mergeProps, splitProps } from 'solid-js';
import { __rozieInjectStyle, rozieAttr, rozieDisplay } from '@rozie/runtime-solid';
__rozieInjectStyle('TreeNode-a7176a6e', `.tree-node[data-rozie-s-a7176a6e] { font-family: system-ui; padding-left: 0.5rem; }
.tree-node__label[data-rozie-s-a7176a6e] { display: inline-block; }
.tree-node__children[data-rozie-s-a7176a6e] { list-style: none; margin: 0.25rem 0 0 0; padding-left: 1rem; border-left: 1px dashed currentColor; }`);
interface TreeNodeProps {
node?: Record<string, any>;
}
export default function TreeNode(_props: TreeNodeProps): JSX.Element {
const _merged = mergeProps({ node: (() => ({
id: '',
label: '',
children: []
}))() }, _props);
const [local, attrs] = splitProps(_merged, ['node']);
return (
<>
<div {...attrs} class={"tree-node" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-a7176a6e="">
<span class={"tree-node__label"} data-rozie-s-a7176a6e="">{rozieDisplay(local.node.label)}</span>
{<Show when={local.node.children && local.node.children.length > 0}><ul class={"tree-node__children"} data-rozie-s-a7176a6e="">
<For each={local.node.children}>{(child, childIndex) => <li data-index={rozieAttr(childIndex())} data-rozie-s-a7176a6e="">
<TreeNode node={child} data-rozie-s-a7176a6e="" />
</li>}</For>
</ul></Show>}</div>
</>
);
}ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { rozieAttr, rozieDisplay, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';
@customElement('rozie-tree-node')
export default class TreeNode extends SignalWatcher(LitElement) {
static styles = css`
.tree-node[data-rozie-s-a7176a6e] { font-family: system-ui; padding-left: 0.5rem; }
.tree-node__label[data-rozie-s-a7176a6e] { display: inline-block; }
.tree-node__children[data-rozie-s-a7176a6e] { list-style: none; margin: 0.25rem 0 0 0; padding-left: 1rem; border-left: 1px dashed currentColor; }
`;
@property({ type: Object }) node: any = {
id: '',
label: '',
children: []
};
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
render() {
return html`
<div class="tree-node" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-a7176a6e>
<span class="tree-node__label" data-rozie-s-a7176a6e>${rozieDisplay(this.node.label)}</span>
${this.node.children && this.node.children.length > 0 ? html`<ul class="tree-node__children" data-rozie-s-a7176a6e>
${repeat<any>(this.node.children, (child, childIndex) => child.id, (child, childIndex) => html`<li key=${rozieAttr(child.id)} data-index=${rozieAttr(childIndex)} data-rozie-s-a7176a6e>
<rozie-tree-node .node=${child}></rozie-tree-node>
</li>`)}
</ul>` : nothing}</div>
`;
}
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['node']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}