React Repeat Loops preview
Use one component description to render every stop in an itinerary. A repeat reads a collection from state and gives each row its item, while stable keys preserve row identity as the list changes.
The native React APIs on this page are experimental, private source-build packages. Run the repository example to use them; they are not an npm installation recipe.
Keep collection identity separate from order
The host owns the item array and changes it immutably when adding, removing, or reversing items. The specification names the collection path and key; RenderSpec handles repeated children. Reordering should not make a stop become a different item. The local playback example shows how those bindings remain meaningful while the specification itself arrives incrementally.
The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.
Change a keyed list
Choose Run, select Simple List and press Finish. Add Item creates a new identity, Remove deletes the chosen item, and Reverse items changes the order while the existing row elements keep their identities. The list holds at most 32 items. Reloading restores Alpha, Beta and Gamma.
Play streams JSON locally. Pause holds its position, Playback position seeks, Finish displays the full sample, and Reset clears playback. Your list edits survive these controls and switching to Task List or Sections. Those two samples display literal task layouts. No agent requests or saved browser state are used.
export const REPEAT_SAMPLES: readonly {
readonly label: string;
readonly json: string;
}[] = [
{
label: 'Simple List',
json: JSON.stringify(
{
root: 'root',
elements: {
root: {
type: 'Heading',
props: { content: 'Simple List' },
children: ['items'],
},
items: {
type: 'Card',
props: { title: 'Items' },
repeat: { statePath: '/items', key: 'id' },
children: ['row'],
},
row: {
type: 'Text',
props: {
content: { $item: 'label' },
itemId: { $item: 'id' },
index: { $index: true },
},
},
},
},
null,
2
),
},
{
label: 'Task List',
json: JSON.stringify(
{
root: 'root',
elements: {
root: {
type: 'Card',
props: { title: 'Task List' },
children: ['task1', 'task2', 'task3'],
},
task1: {
type: 'Text',
props: { content: 'Review pull request' },
},
task2: {
type: 'Text',
props: { content: 'Update documentation' },
},
task3: {
type: 'Text',
props: { content: 'Deploy to staging' },
},
},
},
null,
2
),
},
{
label: 'Sections',
json: JSON.stringify(
{
root: 'root',
elements: {
root: {
type: 'Heading',
props: { content: 'Multiple Sections' },
children: ['sectionA', 'sectionB'],
},
sectionA: {
type: 'Card',
props: { title: 'Frontend Tasks' },
children: ['feTask1', 'feTask2'],
},
feTask1: {
type: 'Text',
props: { content: 'Build component library' },
},
feTask2: {
type: 'Text',
props: { content: 'Add accessibility tests' },
},
sectionB: {
type: 'Card',
props: { title: 'Backend Tasks' },
children: ['beTask1', 'beTask2'],
},
beTask1: {
type: 'Text',
props: { content: 'Optimize database queries' },
},
beTask2: {
type: 'Text',
props: { content: 'Add rate limiting' },
},
},
},
null,
2
),
},
];Repeat children under one container
The React host passes immutable items through RenderSpec's state prop. In Simple List, the Card has repeat metadata with statePath /items and key id. The Card renders once; its Text child renders for each item. Child props use the authored item label, item id and repeat index. React keys each child group by the item's id.
This React container shape differs from Angular's repeating-element shape. Put the row beneath the repeated Card when using this preview. Heading, Text and Card are read-only registered views; Heading and Card display children supplied by the native renderer.
import type {
ReactRenderRegistry,
RenderViewProps,
} from '@threadplane/react/render';
function display(value: unknown): string | number {
return typeof value === 'string' ||
(typeof value === 'number' && Number.isFinite(value))
? value
: '';
}
function Heading({ props, children, loading }: RenderViewProps) {
return (
<div className="render-heading">
<h3 aria-busy={loading}>
{display(props.content) || (loading ? '…' : '')}
</h3>
{children}
</div>
);
}
function Text({ props, loading }: RenderViewProps) {
const value = display(props.content);
const id = typeof props.itemId === 'string' ? props.itemId : undefined;
const index =
typeof props.index === 'number' &&
Number.isSafeInteger(props.index) &&
props.index >= 0
? props.index
: undefined;
return (
<p aria-busy={loading} data-repeat-row={id}>
{index !== undefined ? `${index + 1}. ` : ''}
{value === '' && loading ? '…' : value}
</p>
);
}
function Card({ props, children, loading }: RenderViewProps) {
return (
<article className="render-card">
<h4 aria-busy={loading}>
{display(props.title) || (loading ? '…' : '')}
</h4>
{children}
</article>
);
}
export const localRegistry: ReactRenderRegistry = Object.freeze({
Heading,
Text,
Card,
});import type { RenderState } from '@threadplane/content/render-state';
export type ItemState = {
readonly items: readonly { readonly id: string; readonly label: string }[];
readonly nextId: number;
};
// Closed application shape, seeded by createItems and maintained by these
// commands or shape-preserving external writes; not arbitrary schema validation.
export type ItemsObserver = {
readonly getSnapshot: () => ItemState;
readonly subscribe: (notify: () => void) => () => void;
};
export type ItemCommands = {
readonly add: () => void;
readonly remove: (id: string) => void;
readonly reverse: () => void;
};
export function createItemCommands(
owner: Pick<RenderState, 'getSnapshot' | 'set' | 'update'>
): ItemCommands {
const read = owner.getSnapshot as ItemsObserver['getSnapshot'];
return {
add: () => {
const before = read();
const after = addItem(before);
if (after === before) return;
owner.update({ '/items': after.items, '/nextId': after.nextId });
},
remove: (id) => {
const before = read();
const after = removeItem(before, id);
if (after !== before) owner.set('/items', after.items);
},
reverse: () => {
const before = read();
const after = reverseItems(before);
if (after !== before) owner.set('/items', after.items);
},
};
}
export const MAX_ITEMS = 32;
function own(state: ItemState): ItemState {
return Object.freeze({
items: Object.freeze(state.items.map((item) => Object.freeze({ ...item }))),
nextId: state.nextId,
});
}
export function createItems(): ItemState {
return own({
items: [
{ id: 'alpha', label: 'Alpha' },
{ id: 'beta', label: 'Beta' },
{ id: 'gamma', label: 'Gamma' },
],
nextId: 1,
});
}
export function addItem(state: ItemState): ItemState {
if (
state.items.length >= MAX_ITEMS ||
!Number.isSafeInteger(state.nextId) ||
state.nextId < 1 ||
state.nextId >= Number.MAX_SAFE_INTEGER
)
return state;
const id = `item-${state.nextId}`;
if (state.items.some((item) => item.id === id)) return state;
return own({
items: [...state.items, { id, label: `Item ${state.nextId}` }],
nextId: state.nextId + 1,
});
}
export function removeItem(state: ItemState, id: string): ItemState {
if (!state.items.some((item) => item.id === id)) return state;
return own({ ...state, items: state.items.filter((item) => item.id !== id) });
}
export function reverseItems(state: ItemState): ItemState {
return state.items.length < 2
? state
: own({ ...state, items: [...state.items].reverse() });
}// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this internal example layout.
import { ExampleLayout } from '../../../../shared/react-example';
import { useEffect, useSyncExternalStore } from 'react';
import { useAgent } from '@threadplane/react';
import { RenderSpec } from '@threadplane/react/render';
import type { Playback } from './playback';
import { localRegistry } from './views';
import { MAX_ITEMS, type ItemsObserver, type ItemCommands } from './items';
export function RepeatDemo({
playback,
samples,
onReady,
renderState,
commands,
}: {
readonly playback: Playback;
readonly samples: readonly string[];
readonly onReady: () => void;
readonly renderState: ItemsObserver;
readonly commands: ItemCommands;
}) {
const host = useAgent(renderState);
const state = useSyncExternalStore(
playback.subscribe,
playback.getSnapshot,
playback.getSnapshot
);
useEffect(onReady, [onReady]);
const phase =
state.phase === 'error'
? 'Unavailable'
: state.phase[0].toUpperCase() + state.phase.slice(1);
return (
<ExampleLayout kind="render" className="spec-demo">
<header>
<h1>Repeat Loops</h1>
</header>
<p>
Add, remove and reorder local items. Each row keeps its identity when
the list changes. Playback controls keep your edits.
</p>
<section aria-label="Item controls" className="controls">
<div className="buttons">
<button
type="button"
disabled={host.items.length >= MAX_ITEMS}
onClick={commands.add}
>
Add Item
</button>
<button
type="button"
disabled={host.items.length < 2}
onClick={commands.reverse}
>
Reverse items
</button>
</div>
<p>
{host.items.length} of {MAX_ITEMS} items. Reloading restores Alpha,
Beta and Gamma.
</p>
<div className="buttons">
{host.items.map((item) => (
<button
type="button"
key={item.id}
onClick={() => commands.remove(item.id)}
>
Remove {item.label}
</button>
))}
</div>
</section>
<section aria-label="Playback controls" className="controls">
<label>
Sample
<select
value={state.selected}
onChange={(event) => playback.select(Number(event.target.value))}
>
{samples.map((label, index) => (
<option key={index} value={index}>
{label}
</option>
))}
</select>
</label>
<div className="buttons">
<button
type="button"
disabled={state.phase === 'error'}
onClick={state.playing ? playback.pause : playback.play}
>
{state.playing ? 'Pause' : 'Play'}
</button>
<button
type="button"
disabled={state.phase === 'error'}
onClick={playback.finish}
>
Finish
</button>
<button type="button" onClick={playback.reset}>
Reset
</button>
</div>
<label>
Playback position
<input
type="range"
min={0}
max={state.total}
step={1}
value={state.position}
disabled={state.phase === 'error'}
onChange={(event) => playback.seek(Number(event.target.value))}
/>
</label>
<p role="status">
{phase} · {state.position} characters
</p>
{state.phase === 'error' && (
<p role="alert">
This sample could not be displayed. Choose another sample to
continue.
</p>
)}
</section>
<div className="panels">
<section aria-label="Render output">
<h2>Render output</h2>
{state.spec ? (
<RenderSpec
spec={state.spec}
registry={localRegistry}
state={host}
loading={state.position < state.total}
/>
) : (
<p className="empty">Play a sample to see its view.</p>
)}
</section>
<section aria-label="Streaming JSON">
<h2>Streaming JSON</h2>
<pre>{state.rawJson}</pre>
</section>
</div>
</ExampleLayout>
);
}Keep streamed repeat metadata complete
The caller admits a complete authored sample before playback and copies a bounded display subset of partial data. A repeated Card waits until both its exact path and key are ready. Item expressions wait for their authored values. Unsupported paths, nested repeats and executable fields are rejected. Parser state stays private.
import type {
RenderElementData,
RenderSpecData,
} from '@threadplane/react/render';
const TYPES = ['Heading', 'Text', 'Card'];
const ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
const MAX_NODES = 32;
const MAX_TEXT = 16 * 1024;
type Binding = Readonly<{ $item: string }> | Readonly<{ $index: true }>;
function binding(
input: unknown,
complete: boolean,
prop: string
): Binding | undefined {
const expression = record(input);
if (prop === 'index') {
fields(expression, ['$index']);
if (
!complete &&
(expression.$index === undefined || expression.$index === false)
)
return undefined;
if (expression.$index !== true) invalid();
return Object.freeze({ $index: true });
}
fields(expression, ['$item']);
const expected = prop === 'itemId' ? 'id' : 'label';
const path = expression.$item;
if (
!complete &&
(path === undefined ||
(typeof path === 'string' &&
path !== expected &&
expected.startsWith(path)))
)
return undefined;
if (path !== expected) invalid();
return Object.freeze({ $item: expected });
}
function repeatMetadata(
input: unknown,
complete: boolean
): Readonly<{ statePath: string; key: string }> | undefined {
if (input === undefined && !complete) return undefined;
const metadata = record(input);
fields(metadata, ['statePath', 'key']);
for (const [field, expected] of [
['statePath', '/items'],
['key', 'id'],
]) {
const value = metadata[field];
if (
!complete &&
(value === undefined ||
(typeof value === 'string' &&
value !== expected &&
expected.startsWith(value)))
)
return undefined;
if (value !== expected) invalid();
}
return Object.freeze({ statePath: '/items', key: 'id' });
}
function invalid(): never {
throw new TypeError('Invalid local render data.');
}
function record(input: unknown): Record<string, unknown> {
if (
input === null ||
typeof input !== 'object' ||
Array.isArray(input) ||
![null, Object.prototype].includes(Object.getPrototypeOf(input)) ||
Object.getOwnPropertySymbols(input).length
)
invalid();
const entries = Object.entries(Object.getOwnPropertyDescriptors(input));
if (entries.length > MAX_NODES) invalid();
const result = Object.create(null) as Record<string, unknown>;
for (const [key, descriptor] of entries) {
if (!descriptor.enumerable || !('value' in descriptor)) invalid();
Object.defineProperty(result, key, {
value: descriptor.value,
enumerable: true,
});
}
return result;
}
function fields(
input: Record<string, unknown>,
allowed: readonly string[]
): void {
if (Object.keys(input).some((key) => !allowed.includes(key))) invalid();
}
function references(input: unknown, complete: boolean): readonly string[] {
if (input === undefined && !complete) return [];
if (
!Array.isArray(input) ||
Object.getPrototypeOf(input) !== Array.prototype ||
input.length > MAX_NODES ||
Object.getOwnPropertySymbols(input).length
)
invalid();
const descriptors = Object.getOwnPropertyDescriptors(input);
for (const [key, descriptor] of Object.entries(descriptors))
if (
!('value' in descriptor) ||
(key !== 'length' && !/^(0|[1-9][0-9]*)$/.test(key))
)
invalid();
const result: string[] = [];
for (let index = 0; index < input.length; index++) {
const value = descriptors[String(index)]?.value;
if ((value === undefined || value === '') && !complete) continue;
if (typeof value !== 'string' || !ID.test(value) || result.includes(value))
invalid();
result.push(value);
}
return result;
}
function project(input: unknown, complete: boolean): RenderSpecData | null {
if (input === null && !complete) return null;
const source = record(input);
fields(source, ['root', 'elements']);
if (
source.root === undefined ||
source.root === '' ||
source.elements === undefined
) {
if (complete) invalid();
return null;
}
if (typeof source.root !== 'string' || !ID.test(source.root)) invalid();
const sourceElements = record(source.elements);
const elements = Object.create(null) as Record<string, RenderElementData>;
const childLists = new Map<string, readonly string[]>();
let textSize = 0;
for (const [key, inputElement] of Object.entries(sourceElements)) {
if (!ID.test(key)) invalid();
if (inputElement === undefined && !complete) continue;
const element = record(inputElement);
fields(element, ['type', 'props', 'children', 'repeat']);
if (element.type === undefined && !complete) continue;
if (typeof element.type !== 'string') invalid();
if (!TYPES.includes(element.type)) {
if (complete) invalid();
continue;
}
let repeat: ReturnType<typeof repeatMetadata>;
if (Object.hasOwn(element, 'repeat')) {
if (element.type !== 'Card') invalid();
repeat = repeatMetadata(element.repeat, complete);
if (!repeat) continue;
}
const rawProps = element.props === undefined ? {} : record(element.props);
fields(
rawProps,
element.type === 'Card'
? ['title']
: element.type === 'Text'
? ['content', 'itemId', 'index']
: ['content']
);
const props = Object.create(null) as Record<string, string | Binding>;
for (const [prop, value] of Object.entries(rawProps)) {
if (value === undefined && !complete) continue;
const dynamic = element.type === 'Text';
let copied: string | Binding | undefined;
if (typeof value === 'string' && prop !== 'itemId' && prop !== 'index') {
textSize += value.length;
copied = value;
} else if (dynamic && value !== null && typeof value === 'object') {
copied = binding(value, complete, prop);
} else invalid();
if (textSize > MAX_TEXT) invalid();
if (copied !== undefined)
Object.defineProperty(props, prop, { value: copied, enumerable: true });
}
const children = Object.hasOwn(element, 'children')
? references(element.children, complete)
: undefined;
if (children) childLists.set(key, children);
Object.defineProperty(elements, key, {
value: {
type: element.type,
props: Object.freeze(props),
...(repeat ? { repeat } : {}),
},
enumerable: true,
});
}
for (const [key, children] of childLists) {
if (complete && children.some((child) => !Object.hasOwn(elements, child)))
invalid();
Object.defineProperty(elements[key], 'children', {
value: Object.freeze(
children.filter((child) => Object.hasOwn(elements, child))
),
enumerable: true,
});
}
const heights = new Map<string, number>();
const active = new Set<string>();
function height(key: string): number {
const known = heights.get(key);
if (known !== undefined) return known;
if (active.has(key)) invalid();
active.add(key);
let result = 1;
for (const child of elements[key].children ?? [])
result = Math.max(result, 1 + height(child));
active.delete(key);
if (result > 16) invalid();
heights.set(key, result);
return result;
}
for (const key of Object.keys(elements)) {
height(key);
Object.freeze(elements[key]);
}
if (!Object.hasOwn(elements, source.root)) {
if (complete) invalid();
return null;
}
if (complete) {
const visited = new Set<string>();
function scope(key: string, insideRepeat: boolean): void {
const token = `${key}:${insideRepeat}`;
if (visited.has(token)) return;
visited.add(token);
const element = elements[key];
if (insideRepeat && element.repeat) invalid();
if (
!insideRepeat &&
Object.values(element.props).some((value) => typeof value === 'object')
)
invalid();
for (const child of element.children ?? [])
scope(child, insideRepeat || !!element.repeat);
}
scope(source.root, false);
for (const key of Object.keys(elements))
if (!visited.has(`${key}:false`) && !visited.has(`${key}:true`))
scope(key, false);
}
return Object.freeze({
root: source.root,
elements: Object.freeze(elements),
});
}
/** Copy the known display subset; the incremental parser retains its own mutable tree. */
export function projectPartialSpec(input: unknown): RenderSpecData | null {
return project(input, false);
}
/** Admit a complete authored local sample before any playback begins. */
export function validateCompleteSpec(input: unknown): RenderSpecData {
return project(input, true) ?? invalid();
}The private playback owner rebuilds its parser on seek and revokes obsolete callbacks on Pause, Reset, selection changes and page exit. Complete requires the finished parser output to match the admitted source. Playback never changes the host's list.
import type { RenderSpecData } from '@threadplane/react/render';
import { createPartialJsonParser, materialize } from '@cacheplane/partial-json';
import { projectPartialSpec, validateCompleteSpec } from './projection';
export interface PlaybackSample {
readonly label: string;
readonly json: string;
}
export interface FrameScheduler {
request(callback: FrameRequestCallback): number;
cancel(id: number): void;
}
export interface PlaybackSnapshot {
readonly selected: number;
readonly label: string;
readonly position: number;
readonly total: number;
readonly phase: 'paused' | 'playing' | 'complete' | 'error';
readonly playing: boolean;
readonly rawJson: string;
readonly spec: RenderSpecData | null;
}
export interface Playback {
getSnapshot(): PlaybackSnapshot;
subscribe(listener: () => void): () => void;
play(): void;
pause(): void;
seek(position: number): void;
select(index: number): void;
finish(): void;
reset(): void;
dispose(): void;
}
export function createPlayback(
samples: readonly PlaybackSample[],
scheduler: FrameScheduler = {
request: (callback) => requestAnimationFrame(callback),
cancel: (id) => cancelAnimationFrame(id),
}
): Playback {
const owned = samples.map((sample) => ({
label: sample.label,
json: sample.json,
}));
const listeners = new Set<() => void>();
let live = true;
let generation = 0;
let frame: number | undefined;
let parser = createPartialJsonParser();
let admitted: RenderSpecData | null = null;
let snapshot: PlaybackSnapshot;
function revoke(): number {
generation++;
if (frame !== undefined) scheduler.cancel(frame);
frame = undefined;
return generation;
}
function publish(next: PlaybackSnapshot): void {
snapshot = Object.freeze(next);
const current = generation;
for (const listener of [...listeners]) {
if (!live || current !== generation) break;
if (!listeners.has(listener)) continue;
try {
listener();
} catch {
/* A subscriber cannot take ownership of playback. */
}
}
}
function initialize(selected: number, notify: boolean): void {
parser = createPartialJsonParser();
admitted = null;
const sample = owned[selected];
try {
if (!sample || sample.json.length > 16 * 1024)
throw Error('Invalid sample');
// Incremental parsers can retain malformed input as streaming forever.
admitted = validateCompleteSpec(JSON.parse(sample.json));
} catch {
/* Only authored, bounded complete JSON may be played. */
}
const next: PlaybackSnapshot = {
selected,
label: sample?.label ?? 'Unavailable sample',
position: 0,
total: sample?.json.length ?? 0,
phase: admitted ? 'paused' : 'error',
playing: false,
rawJson: '',
spec: null,
};
if (notify) publish(next);
else snapshot = Object.freeze(next);
}
function advance(position: number, playing: boolean): void {
const source = owned[snapshot.selected].json;
try {
parser.push(source.slice(snapshot.position, position));
const complete = position === source.length;
if (complete) parser.finish();
const value = parser.root ? materialize(parser.root) : null;
const spec = complete
? validateCompleteSpec(value)
: projectPartialSpec(value);
if (
complete &&
(parser.root?.status !== 'complete' ||
JSON.stringify(spec) !== JSON.stringify(admitted))
)
throw Error('Incomplete sample');
publish({
...snapshot,
position,
rawJson: source.slice(0, position),
spec,
playing: playing && !complete,
phase: complete ? 'complete' : playing ? 'playing' : 'paused',
});
} catch {
revoke();
publish({
...snapshot,
phase: 'error',
playing: false,
rawJson: '',
spec: null,
});
}
}
function schedule(current: number): void {
if (
!live ||
generation !== current ||
!snapshot.playing ||
frame !== undefined
)
return;
frame = scheduler.request(() => {
if (!live || generation !== current || !snapshot.playing) return;
frame = undefined;
advance(Math.min(snapshot.total, snapshot.position + 4), true);
schedule(current);
});
}
function seek(position: number): void {
if (!live || !Number.isFinite(position) || !admitted) return;
revoke();
parser = createPartialJsonParser();
const target = Math.max(0, Math.min(snapshot.total, Math.floor(position)));
// Rebuild privately, then publish once so a subscriber cannot interrupt a reset.
snapshot = Object.freeze({
...snapshot,
position: 0,
rawJson: '',
spec: null,
playing: false,
phase: 'paused',
});
advance(target, false);
}
initialize(0, false);
return {
getSnapshot: () => snapshot,
subscribe(listener) {
if (live) listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
play() {
if (!live || !admitted || snapshot.playing) return;
const current = revoke();
if (snapshot.phase === 'complete') {
parser = createPartialJsonParser();
snapshot = Object.freeze({
...snapshot,
position: 0,
rawJson: '',
spec: null,
});
}
publish({ ...snapshot, playing: true, phase: 'playing' });
schedule(current);
},
pause() {
if (!live || !snapshot.playing) return;
revoke();
publish({ ...snapshot, playing: false, phase: 'paused' });
},
seek,
select(index) {
if (
!live ||
!Number.isInteger(index) ||
index < 0 ||
index >= owned.length
)
return;
revoke();
initialize(index, true);
},
finish() {
seek(snapshot.total);
},
reset() {
seek(0);
},
dispose() {
if (!live) return;
live = false;
revoke();
listeners.clear();
snapshot = Object.freeze({
...snapshot,
playing: false,
phase: snapshot.phase === 'playing' ? 'paused' : snapshot.phase,
});
},
};
}Source build and verification
From a Threadplane checkout with dependencies installed:
npx nx build cockpit-render-repeat-loops-react
npx nx e2e cockpit-render-repeat-loops-reactThe isolated build installs local React, core and content artifacts with root-locked presentation dependencies. Browser coverage exercises actual row identity, capacity, empty recovery, playback, cleanup and narrow screens. Select Angular to read and run its existing repeat implementation.