Skip to content

PortalList

The portal-slot primitive in action. PortalList.rozie ships a tiny inline vanilla-JS "engine" (MiniListEngine) that owns per-row <li> containers but delegates per-row CONTENT rendering to a portal slot. PortalListDemo.rozie fills the #item slot with <template #item="{ item }">…</template> and the per-target compiler routes the consumer's fragment through each framework's standard imperative-render API (React createRoot, Vue render(h(...), container), Svelte mount(), Angular vcr.createEmbeddedView, Solid render, Lit render).

This is the cross-framework "foreign-engine cell rendering" pattern. Portal slots are what unlock wrappers like FullCalendar (eventContent), AG-Grid (cellRenderer), Swiper (slide content), and TipTap (custom node views) — every engine whose plugin contract is "give us a callback that returns DOM, we'll mount it where we want." Rozie's <slot name="X" portal /> + $portals.X(container, scope) => disposeFn shape is the cross-target equivalent of each official wrapper's per-framework portal mechanism (FullCalendar/React uses createPortal, FullCalendar/Vue uses <Teleport>, FullCalendar/Svelte uses mount, FullCalendar/Angular uses ViewContainerRef).

Live demo

The PortalList below is the actual examples/PortalList.rozie + examples/demos/PortalListDemo.rozie files from the monorepo, compiled via @rozie/unplugin/vite at build time. The colored swatches, monospaced ids, and bold labels all come from the consumer's <template #item> filler — but the surrounding <ul> and per-row <li> are owned by the wrapper's inline MiniListEngine. Open the dev tools and inspect the DOM: each row is a <li class="mini-list__row"> wrapping a <div class="mini-list__cell">, and the <div> was filled by mounting the consumer's framework-native fragment imperatively.

Why portal slots exist

Rozie's ordinary scoped slots compile to each target's native slot mechanism — Vue's <slot>, React's children prop, Svelte's {@render}, Angular's <ng-template>, etc. Native slots can only render INSIDE the framework's own template tree. They can NOT be invoked imperatively from inside a foreign engine's callback like cellRenderer(item) => DOM.

Portal slots solve the gap by exposing the same <slot name="X" /> authoring surface to the consumer but routing the producer-side invocation through each target's imperative render → returned dispose handle API. Authors write one wrapper; consumers fill it with <template #X> the same way they fill any other named slot.

V1 reactivity constraint

Portal slots are NOT reactive after mount in v1. They re-render only when the wrapper's script re-invokes them — which is how real engine callbacks behave anyway (FullCalendar re-calls eventContent when the event data changes; AG-Grid re-calls cellRenderer when the row updates). A reactive variant that subscribes to scope changes is a post-v1 evolution.

Styling engine-owned DOM

The MiniListEngine styles its row containers and <ul> via inline el.style.foo = … assignments rather than referencing classes from the wrapper's <style> block. The reason: every target scopes the wrapper's CSS via a [data-rozie-s-<hash>] attribute-selector rewrite bound to the declared template elements (React included — it emits a plain .css file scoped by that attribute, not CSS-Modules-hashed class names; Solid injects the same scoped CSS at runtime). Engine-created elements are NOT in the static template, so the scope attribute never reaches them. Inline styles bypass scoping uniformly across all 6 targets, which is the portable contract for engines that own their own DOM. The consumer's <template #item> content still flows through normal scoped CSS — that's the slot author's surface.

Authoring surface

The PortalList wrapper boils down to three pieces. The <template> block declares the portal slot:

rozie
<template>
<div class="rozie-portal-list">
  <slot name="item" portal :params="['item']" />
</div>
</template>

The <script> block invokes the slot from inside the engine's per-cell callback:

rozie
$onMount(() => {
  instance = new MiniListEngine($el, {
    items: $props.items,
    cellRenderer: (item) => {
      const node = document.createElement('div')
      const dispose = $portals.item(node, { item })
      return { node, dispose }
    },
  })
  return () => instance?.destroy()
})

And the consumer-side fill looks identical to any other scoped slot fill:

rozie
<PortalList :items="$data.items">
  <template #item="{ item }">
    <span :style="{ background: item.color }"></span>
    <code>#{{ item.id }}</code>
    <strong>{{ item.label }}</strong>
  </template>
</PortalList>

What the compiler does per target

Each target gets a portals closure synthesized inside the mount-phase lifecycle hook plus a dispose-tracking Set hoisted at component scope:

TargetMount APIClosure lives inBulk dispose runs in
ReactcreateRoot(c).render(slot(scope))useEffect bodyuseEffect cleanup
Vuerender(h('div', null, slot(scope)), c)<script setup> toponBeforeUnmount
Svelte 5mount(PortalHost, { target: c, props })<script lang="ts"> top$effect cleanup
Angularvcr.createEmbeddedView(tplRef, scope)ngAfterViewInit bodyDestroyRef.onDestroy
Solidrender(() => slot(scope), c)Component body toponCleanup
Litrender(slot(scope), c)firstUpdated bodydisconnectedCallback

All six targets dispose every active portal mount BEFORE destroying the engine in cleanup order — otherwise we'd be unmounting framework trees from already-detached containers. The Svelte 5 case ships a small PortalHost Snippet→Component shim from @rozie/runtime-svelte because Svelte 5's mount() requires a Component, not a Snippet.

Source — PortalList.rozie

rozie
<!--
  PortalList.rozie — dependency-free demonstration of the portal-slot
  primitive (Spike 003).

  This wrapper deliberately ships its OWN tiny vanilla-JS "engine" inline so
  the example has zero third-party dependencies. In a real wrapper this
  would be `import { Engine } from 'some-vanilla-lib'` (FullCalendar,
  AG-Grid, Swiper, etc.). The point of the example is the AUTHORING and
  CONSUMING shape, not the engine.

  The engine pattern PortalList exercises:
    - Engine owns a structural container (a `<ul>`) + per-row `<li>` elements
    - Engine calls a `cellRenderer(item)` callback per row
    - Callback returns `{ node, dispose }` — a DOM node the engine appends
      into the row, plus a dispose handle the engine invokes on row removal
    - Rozie's `$portals.item(node, scope)` does the per-target mount and
      returns the dispose function the contract requires

  Consumer surface (from PortalListDemo.rozie):

    <PortalList :items="$data.items">
      <template #item="{ item }">
        <span :style="'color: ' + item.color">
          <code>#{{ item.id }}</code> <strong>{{ item.label }}</strong>
        </span>
      </template>
    </PortalList>

  …which compiles to the per-target render-prop / scoped-slot / contentChild
  idiom (see docs/examples/portal-list.md "Per-target output" sections).
-->

<rozie name="PortalList" adopt-document-styles>

<props>
{
  items: { type: Array, default: () => [] }
}
</props>

<script>
// Tiny inline "engine" — kept in this file so the example stays
// dependency-free. The destruction order matters: dispose all cells BEFORE
// removing the structural container, otherwise we'd unmount framework trees
// from already-detached parents (the same constraint FullCalendar / AG-Grid
// impose in their real wrappers).
//
// Styles are applied INLINE via .style assignments rather than via classes
// referenced from the wrapper's <style> block. Reason: every target scopes
// the wrapper's CSS (React/Solid via CSS Modules hashed class names; Vue /
// Svelte / Angular via attribute selectors auto-rewritten onto declared
// template elements). Engine-created elements are not in the static template,
// so the scoped class names / attribute selectors never reach them. Inline
// styles bypass scoping uniformly across all 6 targets, which is the
// portable contract for engines that own their own DOM.
class MiniListEngine {
  constructor(rootEl, opts) {
    this.rootEl = rootEl
    this.items = opts.items
    this.cellRenderer = opts.cellRenderer
    this.disposers = []
    this._mount()
  }
  _mount() {
    const ul = document.createElement('ul')
    Object.assign(ul.style, {
      listStyle: 'none',
      margin: '0',
      padding: '0',
      border: '1px solid rgba(0, 0, 0, 0.12)',
      borderRadius: '6px',
      overflow: 'hidden',
    })
    const total = this.items.length
    for (let i = 0; i < total; i++) {
      const item = this.items[i]
      const li = document.createElement('li')
      Object.assign(li.style, {
        padding: '0.5rem 0.75rem',
        borderBottom: i === total - 1 ? 'none' : '1px solid rgba(0, 0, 0, 0.06)',
      })
      const cell = this.cellRenderer(item)
      Object.assign(cell.node.style, {
        display: 'flex',
        alignItems: 'center',
        gap: '0.5rem',
      })
      li.appendChild(cell.node)
      this.disposers.push(cell.dispose)
      ul.appendChild(li)
    }
    this.rootEl.appendChild(ul)
  }
  destroy() {
    for (const dispose of this.disposers) dispose()
    this.disposers = []
    while (this.rootEl.firstChild) this.rootEl.removeChild(this.rootEl.firstChild)
  }
}

let instance = null

$onMount(() => {
  instance = new MiniListEngine($el, {
    items: $props.items,
    cellRenderer: (item) => {
      const node = document.createElement('div')
      const dispose = $portals.item(node, { item })
      return { node, dispose }
    },
  })
  return () => instance?.destroy()
})
</script>

<template>
<div class="rozie-portal-list">
  <!--
    Portal slot — declared in template but never rendered. The wrapper's
    $onMount calls $portals.item(node, { item }) per row, which mounts the
    consumer's `<template #item="{ item }">` content into each engine-owned
    `<div>` (engine sets the per-cell flex styles inline).
  -->
  <slot name="item" portal :params="['item']" />
</div>
</template>

<style>
.rozie-portal-list {
  display: block;
  font-family: system-ui, -apple-system, sans-serif;
}
</style>

</rozie>

Source — PortalListDemo.rozie

rozie
<!--
  Visual-regression demo wrapper for PortalList — the portal-slot
  primitive (Spike 003) exhibit.

  PortalList itself bundles a tiny inline vanilla-JS "engine" that owns
  per-row `<li>` containers. This demo fills the portal `#item` slot with
  per-row markup (id + label), so the screenshot captures what consumers
  actually write — not the wrapper's bare engine output.

  Per-target lowering of the consumer surface:
    - React  → `<PortalList renderItem={({ item }) => <span>…</span>} />`
    - Vue    → `<PortalList><template #item="{ item }">…</template></PortalList>`
    - Svelte → `<PortalList>{#snippet item({ item })}…{/snippet}</PortalList>`
    - Angular → `<rozie-portal-list><ng-template #item let-item="item">…</ng-template></rozie-portal-list>`
    - Solid  → `<PortalList item={({ item }) => <span>…</span>} />`
    - Lit    → `<rozie-portal-list .item=${({ item }) => html\`…\`}>…</rozie-portal-list>`
-->
<rozie name="PortalListDemo">

<components>
{
  PortalList: '../PortalList.rozie',
}
</components>

<data>
{
  items: [
    { id: 1, label: 'Alpha', color: '#3b82f6' },
    { id: 2, label: 'Beta', color: '#10b981' },
    { id: 3, label: 'Gamma', color: '#f59e0b' },
    { id: 4, label: 'Delta', color: '#ef4444' }
  ],
}
</data>

<template>
<div class="portal-list-demo">
  <PortalList :items="$data.items">
    <template #item="{ item }">
      <!--
        data-* attributes (rather than class hooks) are deliberate — they
        survive React/Solid's CSS-Modules class hashing, Angular's
        view-encapsulation rewrites, and Lit's shadow-DOM boundary. The
        portal-list.spec.ts runtime smoke locates each row by these.

        Per-row differentiation is text-only — id + label — to keep the demo
        portable across all 6 targets without tripping on `:style` object-vs-
        string divergence (Vue/React/Solid accept an object form, Lit/Svelte
        want a string, Angular wants `[ngStyle]`). The point of the example
        is portal MOUNTING — that the wrapper's `$portals.item(...)` call
        successfully drops the consumer's fragment into each engine-owned
        cell — not the consumer's styling surface.
      -->
      <span class="portal-list-demo__swatch" data-portal-list-swatch :style="{background: item.color}"></span>
      <code class="portal-list-demo__id" data-portal-list-id>#{{ item.id }}</code>
      <strong class="portal-list-demo__label" data-portal-list-label>{{ item.label }}</strong>
    </template>
  </PortalList>
</div>
</template>

<style>
.portal-list-demo {
  font-family: system-ui, -apple-system, sans-serif;
  font-size: 0.875rem;
  color: #1a1a1a;
  padding: 1rem;
  max-width: 360px;
}

:root {
  /* Engine-DOM escape hatch — the `.portal-list-demo__*` chrome lives ONLY inside
     the `#item` portal fill, which teleports into <rozie-portal-list>'s shadow root
     ($portals.item per row), so on Lit a plain scoped rule cannot reach it (the
     consumer's scope attribute is absent in the shadow root). :root emits
     document-level CSS adopted into the shadow root via `adopt-document-styles`.
     NOT :global() (ROZ128). */
  .rozie-portal-list .portal-list-demo__swatch {
    display: inline-block;
    width: 12px;
    height: 12px;
    border-radius: 3px;
    flex-shrink: 0;
  }

  .rozie-portal-list .portal-list-demo__id {
    font-family: ui-monospace, monospace;
    color: rgba(0, 0, 0, 0.55);
    font-size: 0.8125rem;
  }
  .rozie-portal-list .portal-list-demo__label {
    font-weight: 600;
  }
}
</style>

</rozie>

Compiled output

vue
<template>

<div class="rozie-portal-list" ref="__rozieRootRef" v-bind="$attrs">
  
  
</div>

</template>

<script setup lang="ts">
import { Fragment, h, onBeforeUnmount, onMounted, ref, render, useSlots } from 'vue';

const props = withDefaults(
  defineProps<{ items?: any[] }>(),
  { items: () => [] }
);

defineSlots<{
  item(props: { item: any }): any;
}>();

const slots = useSlots();

const __rozieRootRef = ref<HTMLElement>();

// Tiny inline "engine" — kept in this file so the example stays
// dependency-free. The destruction order matters: dispose all cells BEFORE
// removing the structural container, otherwise we'd unmount framework trees
// from already-detached parents (the same constraint FullCalendar / AG-Grid
// impose in their real wrappers).
//
// Styles are applied INLINE via .style assignments rather than via classes
// referenced from the wrapper's <style> block. Reason: every target scopes
// the wrapper's CSS (React/Solid via CSS Modules hashed class names; Vue /
// Svelte / Angular via attribute selectors auto-rewritten onto declared
// template elements). Engine-created elements are not in the static template,
// so the scoped class names / attribute selectors never reach them. Inline
// styles bypass scoping uniformly across all 6 targets, which is the
// portable contract for engines that own their own DOM.
class MiniListEngine {
  constructor(rootEl: any, opts: any) {
    this.rootEl = rootEl;
    this.items = opts.items;
    this.cellRenderer = opts.cellRenderer;
    this.disposers = [];
    this._mount();
  }
  _mount() {
    const ul = document.createElement('ul');
    Object.assign(ul.style, {
      listStyle: 'none',
      margin: '0',
      padding: '0',
      border: '1px solid rgba(0, 0, 0, 0.12)',
      borderRadius: '6px',
      overflow: 'hidden'
    });
    const total = this.items.length;
    for (let i = 0; i < total; i++) {
      const item = this.items[i];
      const li = document.createElement('li');
      Object.assign(li.style, {
        padding: '0.5rem 0.75rem',
        borderBottom: i === total - 1 ? 'none' : '1px solid rgba(0, 0, 0, 0.06)'
      });
      const cell = this.cellRenderer(item);
      Object.assign(cell.node.style, {
        display: 'flex',
        alignItems: 'center',
        gap: '0.5rem'
      });
      li.appendChild(cell.node);
      this.disposers.push(cell.dispose);
      ul.appendChild(li);
    }
    this.rootEl.appendChild(ul);
  }
  destroy() {
    for (const dispose of this.disposers as any) dispose();
    this.disposers = [];
    while (this.rootEl.firstChild) this.rootEl.removeChild(this.rootEl.firstChild);
  }
}
let instance: any = null;

const portalContainers = new Set<HTMLElement>();
const portals = {
  item: (container: HTMLElement, scope: { item: unknown }): (() => void) => {
    const slotFn = slots.item;
    if (!slotFn) return () => {};
    // Spike 004: portal-scope attribute injection. Cascades the @portal
    // item { … } selectors from the unscoped <style> block below into
    // the engine-owned subtree.
    container.setAttribute('data-rozie-portal-item', 'bf13e2c6');
    const vnode = h(Fragment, null, slotFn(scope));
    render(vnode, container);
    portalContainers.add(container);
    return () => {
      render(null, container);
      portalContainers.delete(container);
    };
  },
};
onBeforeUnmount(() => {
  for (const container of portalContainers) render(null, container);
  portalContainers.clear();
});

let _cleanup_0: (() => void) | undefined;
onMounted(() => {
  instance = new MiniListEngine(__rozieRootRef.value!, {
    items: props.items,
    cellRenderer: (item: any) => {
      const node = document.createElement('div');
      const dispose = portals.item(node, {
        item
      });
      return {
        node,
        dispose
      };
    }
  });
  _cleanup_0 = () => instance?.destroy();
});
onBeforeUnmount(() => { _cleanup_0?.(); });
</script>

<style scoped>
.rozie-portal-list {
  display: block;
  font-family: system-ui, -apple-system, sans-serif;
}
</style>
tsx
import { useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { clsx } from '@rozie/runtime-react';
import './PortalList.css';

interface ItemCtx { item: any; }

interface PortalListProps {
  items?: any[];
  renderItem?: (ctx: ItemCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export default function PortalList(_props: PortalListProps): JSX.Element {
  const portalRoots = useRef<Set<Root>>(new Set());
  const __defaultItems = useState(() => (() => [])())[0];
  const props: Omit<PortalListProps, 'items'> & { items: any[] } = {
    ..._props,
    items: _props.items ?? __defaultItems,
  };
  const attrs: Record<string, unknown> = (() => {
    const { items, ...rest } = _props as PortalListProps & Record<string, unknown>;
    void items;
    return rest;
  })();
  const _renderItemRef = useRef(props.renderItem);
  _renderItemRef.current = props.renderItem;
  const instance = useRef<any>(null);
  const __rozieRoot = useRef<HTMLDivElement | null>(null);

  // Tiny inline "engine" — kept in this file so the example stays
  // dependency-free. The destruction order matters: dispose all cells BEFORE
  // removing the structural container, otherwise we'd unmount framework trees
  // from already-detached parents (the same constraint FullCalendar / AG-Grid
  // impose in their real wrappers).
  //
  // Styles are applied INLINE via .style assignments rather than via classes
  // referenced from the wrapper's <style> block. Reason: every target scopes
  // the wrapper's CSS (React/Solid via CSS Modules hashed class names; Vue /
  // Svelte / Angular via attribute selectors auto-rewritten onto declared
  // template elements). Engine-created elements are not in the static template,
  // so the scoped class names / attribute selectors never reach them. Inline
  // styles bypass scoping uniformly across all 6 targets, which is the
  // portable contract for engines that own their own DOM.
  class MiniListEngine {
    constructor(rootEl: any, opts: any) {
      this.rootEl = rootEl;
      this.items = opts.items;
      this.cellRenderer = opts.cellRenderer;
      this.disposers = [];
      this._mount();
    }
    _mount() {
      const ul = document.createElement('ul');
      Object.assign(ul.style, {
        listStyle: 'none',
        margin: '0',
        padding: '0',
        border: '1px solid rgba(0, 0, 0, 0.12)',
        borderRadius: '6px',
        overflow: 'hidden'
      });
      const total = this.items.length;
      for (let i = 0; i < total; i++) {
        const item = this.items[i];
        const li = document.createElement('li');
        Object.assign(li.style, {
          padding: '0.5rem 0.75rem',
          borderBottom: i === total - 1 ? 'none' : '1px solid rgba(0, 0, 0, 0.06)'
        });
        const cell = this.cellRenderer(item);
        Object.assign(cell.node.style, {
          display: 'flex',
          alignItems: 'center',
          gap: '0.5rem'
        });
        li.appendChild(cell.node);
        this.disposers.push(cell.dispose);
        ul.appendChild(li);
      }
      this.rootEl.appendChild(ul);
    }
    destroy() {
      for (const dispose of this.disposers as any) dispose();
      this.disposers = [];
      while (this.rootEl.firstChild) this.rootEl.removeChild(this.rootEl.firstChild);
    }
  }

  useEffect(() => {
    const portals = {
    item: (container: HTMLElement, scope: { item: unknown }): (() => void) => {
      const slot = _renderItemRef.current ?? props.slots?.['item'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      // Cascades the @portal item { … } selectors from the
      // component's .module.css into the engine-owned subtree.
      container.setAttribute('data-rozie-portal-item', 'bf13e2c6');
      const root = createRoot(container);
      flushSync(() => root.render(slot(scope)));
      portalRoots.current.add(root);
      return () => {
        root.unmount();
        portalRoots.current.delete(root);
      };
    },
  };
    instance.current = new MiniListEngine(__rozieRoot.current!, {
      items: props.items,
      cellRenderer: (item: any) => {
        const node = document.createElement('div');
        const dispose = portals.item(node, {
          item
        });
        return {
          node,
          dispose
        };
      }
    });
    return () => {
      for (const root of portalRoots.current) root.unmount();
  portalRoots.current.clear();
      instance.current?.destroy();
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  return (
    <>
    <div ref={__rozieRoot} {...attrs} className={clsx("rozie-portal-list", (attrs.className as string | undefined))} data-rozie-s-bf13e2c6="">
      
      
    </div>
    </>
  );
}
svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';

import type { Snippet } from 'svelte';
import { mount, unmount } from 'svelte';
import PortalHost from '@rozie/runtime-svelte/PortalHost.svelte';
import { onMount } from 'svelte';

interface Props {
  items?: any[];
  item?: Snippet<[{ item: any }]>;
  snippets?: Record<string, any>;
  [key: string]: unknown;
}

let __defaultItems = (() => [])();

let {
  items = __defaultItems,
  item: __itemProp,
  snippets,
  ...__rozieAttrs
}: Props = $props();

const item = $derived(__itemProp ?? snippets?.item);

let __rozieRoot = $state<HTMLElement | undefined>(undefined);

// Tiny inline "engine" — kept in this file so the example stays
// dependency-free. The destruction order matters: dispose all cells BEFORE
// removing the structural container, otherwise we'd unmount framework trees
// from already-detached parents (the same constraint FullCalendar / AG-Grid
// impose in their real wrappers).
//
// Styles are applied INLINE via .style assignments rather than via classes
// referenced from the wrapper's <style> block. Reason: every target scopes
// the wrapper's CSS (React/Solid via CSS Modules hashed class names; Vue /
// Svelte / Angular via attribute selectors auto-rewritten onto declared
// template elements). Engine-created elements are not in the static template,
// so the scoped class names / attribute selectors never reach them. Inline
// styles bypass scoping uniformly across all 6 targets, which is the
// portable contract for engines that own their own DOM.
class MiniListEngine {
  constructor(rootEl: any, opts: any) {
    this.rootEl = rootEl;
    this.items = opts.items;
    this.cellRenderer = opts.cellRenderer;
    this.disposers = [];
    this._mount();
  }
  _mount() {
    const ul = document.createElement('ul');
    Object.assign(ul.style, {
      listStyle: 'none',
      margin: '0',
      padding: '0',
      border: '1px solid rgba(0, 0, 0, 0.12)',
      borderRadius: '6px',
      overflow: 'hidden'
    });
    const total = this.items.length;
    for (let i = 0; i < total; i++) {
      const item = this.items[i];
      const li = document.createElement('li');
      Object.assign(li.style, {
        padding: '0.5rem 0.75rem',
        borderBottom: i === total - 1 ? 'none' : '1px solid rgba(0, 0, 0, 0.06)'
      });
      const cell = this.cellRenderer(item);
      Object.assign(cell.node.style, {
        display: 'flex',
        alignItems: 'center',
        gap: '0.5rem'
      });
      li.appendChild(cell.node);
      this.disposers.push(cell.dispose);
      ul.appendChild(li);
    }
    this.rootEl.appendChild(ul);
  }
  destroy() {
    for (const dispose of this.disposers as any) dispose();
    this.disposers = [];
    while (this.rootEl.firstChild) this.rootEl.removeChild(this.rootEl.firstChild);
  }
}
let instance: any = null;

const portalInstances = new Set<Record<string, unknown>>();
const portals = {
  item: (container: HTMLElement, scope: { item: unknown }): (() => void) => {
    if (!item) return () => {};
    // Spike 004: portal-scope attribute injection.
    container.setAttribute('data-rozie-portal-item', 'bf13e2c6');
    const inst = mount(PortalHost, {
      target: container,
      props: { snippet: item, scope },
    });
    portalInstances.add(inst as Record<string, unknown>);
    return () => {
      unmount(inst);
      portalInstances.delete(inst as Record<string, unknown>);
    };
  },
};
$effect(() => () => {
  for (const inst of portalInstances) unmount(inst as Parameters<typeof unmount>[0]);
  portalInstances.clear();
});

onMount(() => {
  instance = new MiniListEngine(__rozieRoot!, {
    items: items,
    cellRenderer: (item: any) => {
      const node = document.createElement('div');
      const dispose = portals.item(node, {
        item
      });
      return {
        node,
        dispose
      };
    }
  });
  return () => instance?.destroy();
});
</script>

<div bind:this={__rozieRoot} {...__rozieAttrs} class={["rozie-portal-list", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-bf13e2c6></div>

<style>
:global {
  .rozie-portal-list[data-rozie-s-bf13e2c6] {
    display: block;
    font-family: system-ui, -apple-system, sans-serif;
  }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, EmbeddedViewRef, Renderer2, TemplateRef, ViewContainerRef, ViewEncapsulation, afterRenderEffect, contentChild, effect, inject, input, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';

interface ItemCtx {
  $implicit: { item: any };
  item: any;
}

// Tiny inline "engine" — kept in this file so the example stays
// dependency-free. The destruction order matters: dispose all cells BEFORE
// removing the structural container, otherwise we'd unmount framework trees
// from already-detached parents (the same constraint FullCalendar / AG-Grid
// impose in their real wrappers).
//
// Styles are applied INLINE via .style assignments rather than via classes
// referenced from the wrapper's <style> block. Reason: every target scopes
// the wrapper's CSS (React/Solid via CSS Modules hashed class names; Vue /
// Svelte / Angular via attribute selectors auto-rewritten onto declared
// template elements). Engine-created elements are not in the static template,
// so the scoped class names / attribute selectors never reach them. Inline
// styles bypass scoping uniformly across all 6 targets, which is the
// portable contract for engines that own their own DOM.
class MiniListEngine {
  constructor(rootEl: any, opts: any) {
    this.rootEl = rootEl;
    this.items = opts.items;
    this.cellRenderer = opts.cellRenderer;
    this.disposers = [];
    this._mount();
  }
  _mount() {
    const ul = document.createElement('ul');
    Object.assign(ul.style, {
      listStyle: 'none',
      margin: '0',
      padding: '0',
      border: '1px solid rgba(0, 0, 0, 0.12)',
      borderRadius: '6px',
      overflow: 'hidden'
    });
    const total = this.items.length;
    for (let i = 0; i < total; i++) {
      const item = this.items[i];
      const li = document.createElement('li');
      Object.assign(li.style, {
        padding: '0.5rem 0.75rem',
        borderBottom: i === total - 1 ? 'none' : '1px solid rgba(0, 0, 0, 0.06)'
      });
      const cell = this.cellRenderer(item);
      Object.assign(cell.node.style, {
        display: 'flex',
        alignItems: 'center',
        gap: '0.5rem'
      });
      li.appendChild(cell.node);
      this.disposers.push(cell.dispose);
      ul.appendChild(li);
    }
    this.rootEl.appendChild(ul);
  }
  destroy() {
    for (const dispose of this.disposers as any) dispose();
    this.disposers = [];
    while (this.rootEl.firstChild) this.rootEl.removeChild(this.rootEl.firstChild);
  }
}

@Component({
  selector: 'rozie-portal-list',
  standalone: true,
  imports: [NgTemplateOutlet],
  template: `

    <div class="rozie-portal-list" #__rozieRoot #rozieSpread_0 #rozieListenersTarget_1>
      
      
    </div>
    <ng-container #rozie_portalAnchor></ng-container>
  `,
  styles: [`
    .rozie-portal-list {
      display: block;
      font-family: system-ui, -apple-system, sans-serif;
    }
  `],
})
export class PortalList {
  items = input<any[]>((() => [])());
  __rozieRoot = viewChild<ElementRef<HTMLDivElement>>('__rozieRoot');
  @ContentChild('item', { read: TemplateRef }) itemTpl?: TemplateRef<ItemCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  private _portalViews = new Set<EmbeddedViewRef<unknown>>();
  private _portalAnchor = viewChild('rozie_portalAnchor', { read: ViewContainerRef });
  private _itemTpl = contentChild('item', { read: TemplateRef });
  private __rozieDestroyRef = inject(DestroyRef);

  ngAfterViewInit() {
    const portals = {
      item: (container: HTMLElement, scope: { item: unknown }): (() => void) => {
        const tpl = this._itemTpl();
        const vcr = this._portalAnchor();
        if (!tpl || !vcr) return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-item', 'bf13e2c6');
        const view = vcr.createEmbeddedView(tpl, scope as unknown as Record<string, unknown>);
        view.detectChanges();
        for (const node of view.rootNodes as globalThis.Node[]) container.appendChild(node);
        this._portalViews.add(view as EmbeddedViewRef<unknown>);
        return () => {
          view.destroy();
          this._portalViews.delete(view as EmbeddedViewRef<unknown>);
        };
      },
    };
    this.instance = new MiniListEngine(this.__rozieRoot()!.nativeElement, {
      items: this.items(),
      cellRenderer: (item: any) => {
        const node = document.createElement('div');
        const dispose = portals.item(node, {
          item
        });
        return {
          node,
          dispose
        };
      }
    });
    this.__rozieDestroyRef.onDestroy(() => this.instance?.destroy());
    this.__rozieDestroyRef.onDestroy(() => {
      for (const view of this._portalViews) view.destroy();
      this._portalViews.clear();
    });
  }

  instance: any = null;

  static ngTemplateContextGuard(
    _dir: PortalList,
    _ctx: unknown,
  ): _ctx is ItemCtx {
    return true;
  }

  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 = [];
      });
    }
  });
}

export default PortalList;
tsx
import type { JSX } from 'solid-js';
import { mergeProps, onCleanup, onMount, splitProps } from 'solid-js';
import { render } from 'solid-js/web';
import { __rozieInjectStyle } from '@rozie/runtime-solid';

__rozieInjectStyle('PortalList-bf13e2c6', `.rozie-portal-list[data-rozie-s-bf13e2c6] {
  display: block;
  font-family: system-ui, -apple-system, sans-serif;
}`);

interface ItemSlotCtx { item: any; }

interface PortalListProps {
  items?: any[];
  itemSlot?: (ctx: ItemSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
}

export default function PortalList(_props: PortalListProps): JSX.Element {
  const _merged = mergeProps({ items: (() => [])() }, _props);
  const [local, attrs] = splitProps(_merged, ['items']);

  const portalDisposers = new Set<() => void>();
  const portals = {
    item: (container: HTMLElement, scope: { item: unknown }): (() => void) => {
      const slot = _props.itemSlot ?? _props.slots?.['item'];
      if (typeof slot !== 'function') return () => {};
      // Spike 004: portal-scope attribute injection.
      container.setAttribute('data-rozie-portal-item', 'bf13e2c6');
      const dispose = render(() => slot(scope), container);
      portalDisposers.add(dispose);
      return () => {
        dispose();
        portalDisposers.delete(dispose);
      };
    },
  };
  onCleanup(() => {
    for (const dispose of portalDisposers) dispose();
    portalDisposers.clear();
  });
  onMount(() => {
    const _cleanup = (() => {
    instance = new MiniListEngine(__rozieRootRef!, {
      items: local.items,
      cellRenderer: (item: any) => {
        const node = document.createElement('div');
        const dispose = portals.item(node, {
          item
        });
        return {
          node,
          dispose
        };
      }
    });
  })() as unknown;
    if (_cleanup) onCleanup(_cleanup as () => void);
    onCleanup(() => instance?.destroy());
  });
  let __rozieRootRef: HTMLElement | null = null;

  // Tiny inline "engine" — kept in this file so the example stays
  // dependency-free. The destruction order matters: dispose all cells BEFORE
  // removing the structural container, otherwise we'd unmount framework trees
  // from already-detached parents (the same constraint FullCalendar / AG-Grid
  // impose in their real wrappers).
  //
  // Styles are applied INLINE via .style assignments rather than via classes
  // referenced from the wrapper's <style> block. Reason: every target scopes
  // the wrapper's CSS (React/Solid via CSS Modules hashed class names; Vue /
  // Svelte / Angular via attribute selectors auto-rewritten onto declared
  // template elements). Engine-created elements are not in the static template,
  // so the scoped class names / attribute selectors never reach them. Inline
  // styles bypass scoping uniformly across all 6 targets, which is the
  // portable contract for engines that own their own DOM.
  class MiniListEngine {
    constructor(rootEl: any, opts: any) {
      this.rootEl = rootEl;
      this.items = opts.items;
      this.cellRenderer = opts.cellRenderer;
      this.disposers = [];
      this._mount();
    }
    _mount() {
      const ul = document.createElement('ul');
      Object.assign(ul.style, {
        listStyle: 'none',
        margin: '0',
        padding: '0',
        border: '1px solid rgba(0, 0, 0, 0.12)',
        borderRadius: '6px',
        overflow: 'hidden'
      });
      const total = this.items.length;
      for (let i = 0; i < total; i++) {
        const item = this.items[i];
        const li = document.createElement('li');
        Object.assign(li.style, {
          padding: '0.5rem 0.75rem',
          borderBottom: i === total - 1 ? 'none' : '1px solid rgba(0, 0, 0, 0.06)'
        });
        const cell = this.cellRenderer(item);
        Object.assign(cell.node.style, {
          display: 'flex',
          alignItems: 'center',
          gap: '0.5rem'
        });
        li.appendChild(cell.node);
        this.disposers.push(cell.dispose);
        ul.appendChild(li);
      }
      this.rootEl.appendChild(ul);
    }
    destroy() {
      for (const dispose of this.disposers as any) dispose();
      this.disposers = [];
      while (this.rootEl.firstChild) this.rootEl.removeChild(this.rootEl.firstChild);
    }
  }
  let instance: any = null;

  return (
    <>
    <div ref={(el) => { __rozieRootRef = el as HTMLElement; }} {...attrs} class={"rozie-portal-list" + (((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-bf13e2c6="">
      
      
    </div>
    </>
  );
}
ts
import { LitElement, css, html, nothing, render } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { adoptDocumentStyles, rozieListeners, rozieSpread } from '@rozie/runtime-lit';

interface RozieItemSlotCtx {
  item: unknown;
}

@customElement('rozie-portal-list')
export default class PortalList extends SignalWatcher(LitElement) {
  static styles = css`
.rozie-portal-list[data-rozie-s-bf13e2c6] {
  display: block;
  font-family: system-ui, -apple-system, sans-serif;
}
`;

  @property({ type: Array }) items: any[] = [];
  @query('[data-rozie-ref="__rozieRoot"]') private _ref__rozieRoot!: HTMLElement;
private _portalContainers = new Set<HTMLElement>();

  @state() private _hasSlotItem = false;
  @queryAssignedElements({ slot: 'item', flatten: true }) private _slotItemElements!: Element[];
  @property({ attribute: false }) item?: (scope: { item: unknown }) => unknown;

  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;

  private _armListeners(): void {
    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="item"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotItem = this._slotItemElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }
  }

  connectedCallback(): void {
    // Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
    this._hasSlotItem = Array.from(this.children).some((el) => el.getAttribute('slot') === 'item');
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    adoptDocumentStyles(this);

    this._armListeners();

    const portals = {
      item: (container: HTMLElement, scope: { item: unknown }): (() => void) => {
        const tpl = this.item;
        if (typeof tpl !== 'function') return () => {};
        // Spike 004: portal-scope attribute injection.
        container.setAttribute('data-rozie-portal-item', 'bf13e2c6');
        render(tpl(scope), container);
        this._portalContainers.add(container);
        return () => {
          render(nothing, container);
          this._portalContainers.delete(container);
        };
      },
    };

    // Tiny inline "engine" — kept in this file so the example stays
    // dependency-free. The destruction order matters: dispose all cells BEFORE
    // removing the structural container, otherwise we'd unmount framework trees
    // from already-detached parents (the same constraint FullCalendar / AG-Grid
    // impose in their real wrappers).
    //
    // Styles are applied INLINE via .style assignments rather than via classes
    // referenced from the wrapper's <style> block. Reason: every target scopes
    // the wrapper's CSS (React/Solid via CSS Modules hashed class names; Vue /
    // Svelte / Angular via attribute selectors auto-rewritten onto declared
    // template elements). Engine-created elements are not in the static template,
    // so the scoped class names / attribute selectors never reach them. Inline
    // styles bypass scoping uniformly across all 6 targets, which is the
    // portable contract for engines that own their own DOM.
    class MiniListEngine {
      constructor(rootEl: any, opts: any) {
        this.rootEl = rootEl;
        this.items = opts.items;
        this.cellRenderer = opts.cellRenderer;
        this.disposers = [];
        this._mount();
      }
      _mount() {
        const ul = document.createElement('ul');
        Object.assign(ul.style, {
          listStyle: 'none',
          margin: '0',
          padding: '0',
          border: '1px solid rgba(0, 0, 0, 0.12)',
          borderRadius: '6px',
          overflow: 'hidden'
        });
        const total = this.items.length;
        for (let i = 0; i < total; i++) {
          const item = this.items[i];
          const li = document.createElement('li');
          Object.assign(li.style, {
            padding: '0.5rem 0.75rem',
            borderBottom: i === total - 1 ? 'none' : '1px solid rgba(0, 0, 0, 0.06)'
          });
          const cell = this.cellRenderer(item);
          Object.assign(cell.node.style, {
            display: 'flex',
            alignItems: 'center',
            gap: '0.5rem'
          });
          li.appendChild(cell.node);
          this.disposers.push(cell.dispose);
          ul.appendChild(li);
        }
        this.rootEl.appendChild(ul);
      }
      destroy() {
        for (const dispose of this.disposers as any) dispose();
        this.disposers = [];
        while (this.rootEl.firstChild) this.rootEl.removeChild(this.rootEl.firstChild);
      }
    }

    this._disconnectCleanups.push((() => this.instance?.destroy()));

    this.instance = new MiniListEngine(this._ref__rozieRoot, {
      items: this.items,
      cellRenderer: (item: any) => {
        const node = document.createElement('div');
        const dispose = portals.item(node, {
          item
        });
        return {
          node,
          dispose
        };
      }
    });
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const container of this._portalContainers) render(nothing, container);
      this._portalContainers.clear();
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  render() {
    return html`
<div class="rozie-portal-list" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="__rozieRoot" data-rozie-s-bf13e2c6>
  
  <slot name="item"></slot>
</div>
`;
  }

  instance: any = null;

  /**
   * 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>(['items']);
    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.