Appearance
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:
| Target | Mount API | Closure lives in | Bulk dispose runs in |
|---|---|---|---|
| React | createRoot(c).render(slot(scope)) | useEffect body | useEffect cleanup |
| Vue | render(h('div', null, slot(scope)), c) | <script setup> top | onBeforeUnmount |
| Svelte 5 | mount(PortalHost, { target: c, props }) | <script lang="ts"> top | $effect cleanup |
| Angular | vcr.createEmbeddedView(tplRef, scope) | ngAfterViewInit body | DestroyRef.onDestroy |
| Solid | render(() => slot(scope), c) | Component body top | onCleanup |
| Lit | render(slot(scope), c) | firstUpdated body | disconnectedCallback |
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;
}
}