Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ jobs:
secrets: inherit
with:
publish: './packages/*'
template: './examples/*'
147 changes: 147 additions & 0 deletions packages/ui/src/host-render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { type Node } from './core.ts';
import { describe, expect, test } from 'vitest';
import { useHostRenderer } from './host-render.ts';
import { createHost, type Host } from './host.ts';
import { RenderApi } from './render.ts';

describe('host renderer', () => {
test('does not request a render while building a detached subtree', () => {
const { host, requests } = setup();
const box = host.createElement('box');
const text = host.createElement('text');
const literal = host.createLiteral('before');

host.insertBefore(text, literal);
host.insertBefore(box, text);
host.setProperty(text, 'color', 1);
host.setText(literal, 'after');
host.removeChild(box, text);

expectRequests(requests);
});

test('requests a render when a subtree is attached', () => {
const { host, requests } = setup();
const parent = host.createElement('box');
const child = host.createElement('text');

host.insertBefore(parent, child);
host.insertBefore(host.element, parent);

expectRequests(requests, host.root);
});

test('requests a render when an attached property is set or removed', () => {
const { host, requests } = setup();
const element = host.createElement('box');
host.insertBefore(host.element, element);
requests.length = 0;
const node = element.node!;

host.setProperty(element, 'color', 1);
host.setProperty(element, 'color', undefined);

expectRequests(requests, node, node);
});

test('requests a render when attached text changes', () => {
const { host, requests } = setup();
const text = host.createElement('text');
const literal = host.createLiteral('before');
host.insertBefore(text, literal);
host.insertBefore(host.element, text);
requests.length = 0;
const node = text.node!;

host.setText(literal, 'after');

expectRequests(requests, node);
});

test('requests a render when a child is removed from an attached parent', () => {
const { host, requests } = setup();
const parent = host.createElement('box');
const child = host.createElement('text');
host.insertBefore(parent, child);
host.insertBefore(host.element, parent);
requests.length = 0;
const node = parent.node!;

host.removeChild(parent, child);

expectRequests(requests, node);
});

test('requests a render when a child moves within an attached parent', () => {
const { host, requests } = setup();
const parent = host.createElement('box');
const first = host.createElement('text');
const second = host.createElement('text');
host.insertBefore(parent, first);
host.insertBefore(parent, second);
host.insertBefore(host.element, parent);
requests.length = 0;
const node = parent.node!;

host.insertBefore(parent, second, first);

expectRequests(requests, node);
});

test('requests a render from the old parent when moving into a detached parent', () => {
const { host, requests } = setup();
const attached = host.createElement('box');
const detached = host.createElement('box');
const child = host.createElement('text');
host.insertBefore(attached, child);
host.insertBefore(host.element, attached);
requests.length = 0;
const node = attached.node!;

host.insertBefore(detached, child);

expectRequests(requests, node);
});

test('requests a render from both parents when moving between attached parents', () => {
const { host, requests } = setup();
const first = host.createElement('box');
const second = host.createElement('box');
const child = host.createElement('text');
host.insertBefore(first, child);
host.insertBefore(host.element, first);
host.insertBefore(host.element, second);
requests.length = 0;
const firstNode = first.node!;
const secondNode = second.node!;

host.insertBefore(second, child);

expect(requests).toHaveLength(2);
expect(requests).toContain(firstNode);
expect(requests).toContain(secondNode);
});
});

function setup(): { host: Host; requests: Node[] } {
const host = createHost();
const requests: Node[] = [];

const controller = new AbortController();
RenderApi.init(host.root, controller.signal);
useHostRenderer(host);
RenderApi.decorate(host.root, () => ({
request() {
requests.push(this.node);
},
}));

return { host, requests };
}

function expectRequests(actual: Node[], ...expected: Node[]): void {
expect(actual).toHaveLength(expected.length);
for (const [index, node] of expected.entries()) {
expect(actual[index]).toBe(node);
}
}
42 changes: 42 additions & 0 deletions packages/ui/src/host-render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// oxlint-disable max-params
import { RenderApi } from './render.ts';
import { type Host, HostApi } from './host.ts';

export function useHostRenderer(host: Host): void {
HostApi.decorate(host.root, () => ({
insertBefore(parent, child, anchor) {
const originalParentNode = child.parent?.node ?? null;

super.insertBefore(parent, child, anchor);

if (originalParentNode) {
RenderApi.at(originalParentNode).request();
}

if (parent.node && parent.node !== originalParentNode) {
RenderApi.at(parent.node).request();
}
},
removeChild(parent, child) {
super.removeChild(parent, child);

if (parent.node) {
RenderApi.at(parent.node).request();
}
},
setProperty(element, name, value) {
super.setProperty(element, name, value);

if (element.node) {
RenderApi.at(element.node).request();
}
},
setText(text, content) {
super.setText(text, content);

if (text.parent && text.parent.node) {
RenderApi.at(text.parent.node).request();
}
},
}));
}
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { box, type BoxProps } from './components/box.ts';
export { text, type TextProps } from './components/text.ts';
export { LayoutApi } from './layout.ts';
export { createRoot, type RenderRoot, type RenderRootOptions } from './render/createRoot.ts';
export { RenderApi } from './render.ts';
109 changes: 109 additions & 0 deletions packages/ui/src/render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, test } from 'vitest';
import { Node } from './core.ts';
import { RenderApi } from './render.ts';

describe('RenderApi', () => {
test('renders a requested frame and returns its terminal operations', async () => {
const { controller, renderer, iterator } = setup();
const pending = iterator.next();
const value = [];

await expectPending(pending);
renderer.request();

const frame = await receive(pending);
expect(renderer.render(frame, () => value)).toBe(value);
controller.abort();
});

test('absorbs requests made inside the render callback', async () => {
const { controller, renderer, iterator } = setup();
renderer.request();
const frame = await nextFrame(iterator);

renderer.render(frame, () => {
renderer.request();
renderer.request();
renderer.request();
return [];
});

const pending = iterator.next();
await expectPending(pending);

renderer.request();
const next = await receive(pending);
renderer.render(next, () => []);
controller.abort();
});

test('coalesces requests made after rendering into one follow-up frame', async () => {
const { controller, renderer, iterator } = setup();
renderer.request();
const frame = await nextFrame(iterator);
renderer.render(frame, () => []);

renderer.request();
renderer.request();
renderer.request();

const next = await nextFrame(iterator);
renderer.render(next, () => []);
const pending = iterator.next();
await expectPending(pending);
controller.abort();
await expect(pending).resolves.toEqual({ done: true, value: undefined });
});

test('stops waiting for frames when aborted', async () => {
const { controller, iterator } = setup();
const pending = iterator.next();

controller.abort();

await expect(pending).resolves.toEqual({ done: true, value: undefined });
});
});

function setup() {
const controller = new AbortController();
const renderer = RenderApi.init(new Node(), controller.signal);
const iterator = renderer[Symbol.asyncIterator]();
return { controller, renderer, iterator };
}

async function nextFrame<T>(iterator: AsyncIterator<T>): Promise<T> {
return receive(iterator.next());
}

async function receive<T>(result: PromiseLike<IteratorResult<T>>): Promise<T> {
const item = await result;
if (item.done) {
throw new RenderFrameIteratorStoppedError();
}
return item.value;
}

class RenderFrameIteratorStoppedError extends Error {
readonly code = 'CLACK_UI_TEST_RENDER_FRAME_ITERATOR_STOPPED';

constructor() {
super('render frame iterator stopped');
this.name = 'RenderFrameIteratorStoppedError';
}
}

async function expectPending<T>(result: PromiseLike<T>): Promise<void> {
let settled = false;
void result.then(
() => {
settled = true;
},
() => {
settled = true;
},
);

await new Promise<void>((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
}
74 changes: 74 additions & 0 deletions packages/ui/src/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Op } from '@bomb.sh/tty';
import { Api } from './core/api.ts';
import type { Node } from './core/node.ts';
import { withResolvers, type WithResolvers } from './with-resolvers.ts';

/**
* A coalescing, back-pressured stream of render frames.
*
* `request()` schedules a frame unless one is already pending. Requests made
* while that frame is rendering are considered served by it. The frame becomes
* complete when its render callback returns, before painting begins; requests
* made during paint therefore coalesce into one subsequent frame.
*
* Consumers iterate over the API and await each paint before asking for the
* next frame. This prevents rendering from outrunning terminal output while
* still retaining changes requested during a slow paint. `render()` and
* `paint()` are separate methods so decorators can observe or replace either
* phase independently. Iteration ends when the constructor's signal aborts.
*/
export class RenderApi extends Api {
aborted = withResolvers<IteratorReturnResult<void>>();
current: Frame | undefined = undefined;
resolvers: WithResolvers<IteratorYieldResult<Frame>> = withResolvers();

constructor(node: Node, signal: AbortSignal) {
super(node);
signal.addEventListener('abort', () => this.aborted.resolve({ done: true, value: undefined }));
}

/** Requests a frame, coalescing with the current pending or rendering frame. */
request(): void {
if (!this.current) {
this.current = {
render: (callback) => {
try {
return callback();
} finally {
this.current = undefined;
}
},
};
this.resolvers.resolve({ done: false, value: this.current });
this.resolvers = withResolvers();
}
}

/** Runs layout within a frame and marks that frame served when layout finishes. */
render(frame: Frame, callback: () => Op[]): Op[] {
return frame.render(callback);
}

/** Runs the output phase; requests during it schedule the next frame. */
paint(callback: () => Promise<void>): Promise<void> {
return callback();
}

[Symbol.asyncIterator](): AsyncIterator<Frame, void, void> {
return {
next: () => {
const frame = this.current
? Promise.resolve({
done: false as const,
value: this.current,
})
: this.resolvers.promise;
return Promise.race([frame, this.aborted.promise]);
},
};
}
}

export interface Frame {
render<T>(callback: () => T): T;
}
Loading
Loading