From 3d06cb8f4f541e58badce0cb472b3eda00061cee Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 3 Aug 2026 09:42:28 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20render:=20request=20frames=20after?= =?UTF-8?q?=20host=20mutations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/preview.yml | 1 + packages/ui/src/host-render.test.ts | 147 ++++++++++++++++++++++++++++ packages/ui/src/host-render.ts | 42 ++++++++ packages/ui/src/index.ts | 1 + packages/ui/src/render.test.ts | 109 +++++++++++++++++++++ packages/ui/src/render.ts | 74 ++++++++++++++ packages/ui/src/with-resolvers.ts | 21 ++++ 7 files changed, 395 insertions(+) create mode 100644 packages/ui/src/host-render.test.ts create mode 100644 packages/ui/src/host-render.ts create mode 100644 packages/ui/src/render.test.ts create mode 100644 packages/ui/src/render.ts create mode 100644 packages/ui/src/with-resolvers.ts diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 4330045..c7e833d 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -11,3 +11,4 @@ jobs: secrets: inherit with: publish: './packages/*' + template: './examples/*' diff --git a/packages/ui/src/host-render.test.ts b/packages/ui/src/host-render.test.ts new file mode 100644 index 0000000..72af747 --- /dev/null +++ b/packages/ui/src/host-render.test.ts @@ -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); + } +} diff --git a/packages/ui/src/host-render.ts b/packages/ui/src/host-render.ts new file mode 100644 index 0000000..8cd2e8e --- /dev/null +++ b/packages/ui/src/host-render.ts @@ -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(); + } + }, + })); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4b7d950..9c0703a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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'; diff --git a/packages/ui/src/render.test.ts b/packages/ui/src/render.test.ts new file mode 100644 index 0000000..7c7ca21 --- /dev/null +++ b/packages/ui/src/render.test.ts @@ -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(iterator: AsyncIterator): Promise { + return receive(iterator.next()); +} + +async function receive(result: PromiseLike>): Promise { + 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(result: PromiseLike): Promise { + let settled = false; + void result.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); +} diff --git a/packages/ui/src/render.ts b/packages/ui/src/render.ts new file mode 100644 index 0000000..1665616 --- /dev/null +++ b/packages/ui/src/render.ts @@ -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>(); + current: Frame | undefined = undefined; + resolvers: WithResolvers> = 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): Promise { + return callback(); + } + + [Symbol.asyncIterator](): AsyncIterator { + 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(callback: () => T): T; +} diff --git a/packages/ui/src/with-resolvers.ts b/packages/ui/src/with-resolvers.ts new file mode 100644 index 0000000..dac0a92 --- /dev/null +++ b/packages/ui/src/with-resolvers.ts @@ -0,0 +1,21 @@ +/** + * Just a shim for Promise.withResolvers() because I don't know + * if we can rely on it. + */ +export interface WithResolvers { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + +export function withResolvers(): WithResolvers { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +}