Skip to content

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;
  }
}

Pre-v1.0 — internal monorepo.