feat(presentation): add cancellable frame renderer
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
import type { FrameScheduler } from './ui-contracts.ts';
|
||||||
|
|
||||||
|
class FrameRendererFault extends Error {
|
||||||
|
readonly errors: readonly unknown[];
|
||||||
|
|
||||||
|
constructor(errors: readonly unknown[]) {
|
||||||
|
super('FrameRenderer render and disposal failed');
|
||||||
|
this.name = 'FrameRendererFault';
|
||||||
|
this.errors = Object.freeze([...errors]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FrameRenderer {
|
||||||
|
private pending: (() => void) | null = null;
|
||||||
|
private generation = 0;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly scheduler: FrameScheduler,
|
||||||
|
private readonly render: () => void,
|
||||||
|
private readonly onFault: (error: unknown) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public request(): void {
|
||||||
|
if (this.disposed || this.pending !== null) return;
|
||||||
|
|
||||||
|
const generation = this.generation;
|
||||||
|
this.pending = this.scheduler.request(() => {
|
||||||
|
if (this.disposed || generation !== this.generation) return;
|
||||||
|
this.pending = null;
|
||||||
|
this.generation += 1;
|
||||||
|
try {
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
let fault: unknown = error;
|
||||||
|
try {
|
||||||
|
this.dispose();
|
||||||
|
} catch (cancelError) {
|
||||||
|
fault = new FrameRendererFault([error, cancelError]);
|
||||||
|
}
|
||||||
|
this.onFault(fault);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public flush(): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
|
||||||
|
const cancel = this.pending;
|
||||||
|
this.pending = null;
|
||||||
|
this.generation += 1;
|
||||||
|
const generation = this.generation;
|
||||||
|
if (cancel !== null) cancel();
|
||||||
|
if (this.disposed || generation !== this.generation) return;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
|
||||||
|
const cancel = this.pending;
|
||||||
|
this.pending = null;
|
||||||
|
this.generation += 1;
|
||||||
|
if (cancel !== null) cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { FrameRenderer } from '../../YouleNexus/assets/framework/presentation/frame-renderer.ts';
|
||||||
|
|
||||||
|
test('coalesces frames and invalidates callbacks even if scheduler delivers late', () => {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
let cancelled = 0;
|
||||||
|
let rendered = 0;
|
||||||
|
const renderer = new FrameRenderer({
|
||||||
|
request(cb) { callbacks.push(cb); return () => { cancelled++; }; },
|
||||||
|
}, () => { rendered++; }, e => { throw e; });
|
||||||
|
renderer.request(); renderer.request();
|
||||||
|
assert.equal(callbacks.length, 1);
|
||||||
|
renderer.flush();
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
callbacks[0]!();
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
renderer.request();
|
||||||
|
renderer.dispose(); renderer.dispose();
|
||||||
|
callbacks[1]!();
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
assert.equal(cancelled, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('releases the current frame before render can request the next frame', () => {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
let rendered = 0;
|
||||||
|
let renderer!: FrameRenderer;
|
||||||
|
renderer = new FrameRenderer({
|
||||||
|
request(callback) { callbacks.push(callback); return () => {}; },
|
||||||
|
}, () => {
|
||||||
|
rendered += 1;
|
||||||
|
renderer.request();
|
||||||
|
}, error => { throw error; });
|
||||||
|
|
||||||
|
renderer.request();
|
||||||
|
callbacks[0]!();
|
||||||
|
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
assert.equal(callbacks.length, 2);
|
||||||
|
|
||||||
|
callbacks[1]!();
|
||||||
|
assert.equal(rendered, 2);
|
||||||
|
assert.equal(callbacks.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose during render prevents a reentrant frame request', () => {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
let rendered = 0;
|
||||||
|
let renderer!: FrameRenderer;
|
||||||
|
renderer = new FrameRenderer({
|
||||||
|
request(callback) { callbacks.push(callback); return () => {}; },
|
||||||
|
}, () => {
|
||||||
|
rendered += 1;
|
||||||
|
renderer.dispose();
|
||||||
|
renderer.request();
|
||||||
|
}, error => { throw error; });
|
||||||
|
|
||||||
|
renderer.request();
|
||||||
|
callbacks[0]!();
|
||||||
|
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
assert.equal(callbacks.length, 1);
|
||||||
|
renderer.flush();
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a throwing dispose cancellation still makes every callback stale', () => {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
const cancelError = new Error('cancel failed');
|
||||||
|
let rendered = 0;
|
||||||
|
const renderer = new FrameRenderer({
|
||||||
|
request(callback) {
|
||||||
|
callbacks.push(callback);
|
||||||
|
return () => { throw cancelError; };
|
||||||
|
},
|
||||||
|
}, () => { rendered += 1; }, error => { throw error; });
|
||||||
|
|
||||||
|
renderer.request();
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
renderer.dispose();
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(thrown, cancelError);
|
||||||
|
callbacks[0]!();
|
||||||
|
assert.equal(rendered, 0);
|
||||||
|
renderer.request();
|
||||||
|
assert.equal(callbacks.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a throwing flush cancellation invalidates the old frame before rethrowing', () => {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
const cancelError = new Error('cancel failed');
|
||||||
|
let rendered = 0;
|
||||||
|
const renderer = new FrameRenderer({
|
||||||
|
request(callback) {
|
||||||
|
callbacks.push(callback);
|
||||||
|
return () => { throw cancelError; };
|
||||||
|
},
|
||||||
|
}, () => { rendered += 1; }, error => { throw error; });
|
||||||
|
|
||||||
|
renderer.request();
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
renderer.flush();
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(thrown, cancelError);
|
||||||
|
callbacks[0]!();
|
||||||
|
assert.equal(rendered, 0);
|
||||||
|
renderer.request();
|
||||||
|
assert.equal(callbacks.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flush does not render when cancellation disposes the renderer', () => {
|
||||||
|
let rendered = 0;
|
||||||
|
let renderer!: FrameRenderer;
|
||||||
|
renderer = new FrameRenderer({
|
||||||
|
request() { return () => { renderer.dispose(); }; },
|
||||||
|
}, () => { rendered += 1; }, error => { throw error; });
|
||||||
|
|
||||||
|
renderer.request();
|
||||||
|
renderer.flush();
|
||||||
|
|
||||||
|
assert.equal(rendered, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cancellation that reenters flush supersedes the outer render', () => {
|
||||||
|
let rendered = 0;
|
||||||
|
let renderer!: FrameRenderer;
|
||||||
|
renderer = new FrameRenderer({
|
||||||
|
request() { return () => { renderer.flush(); }; },
|
||||||
|
}, () => { rendered += 1; }, error => { throw error; });
|
||||||
|
|
||||||
|
renderer.request();
|
||||||
|
renderer.flush();
|
||||||
|
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports an asynchronous render error once and terminates future frames', () => {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
const renderError = new Error('render failed');
|
||||||
|
const faults: unknown[] = [];
|
||||||
|
let rendered = 0;
|
||||||
|
const renderer = new FrameRenderer({
|
||||||
|
request(callback) { callbacks.push(callback); return () => {}; },
|
||||||
|
}, () => {
|
||||||
|
rendered += 1;
|
||||||
|
throw renderError;
|
||||||
|
}, error => { faults.push(error); });
|
||||||
|
|
||||||
|
renderer.request();
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
callbacks[0]!();
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(thrown, undefined);
|
||||||
|
assert.deepEqual(faults, [renderError]);
|
||||||
|
callbacks[0]!();
|
||||||
|
renderer.request();
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
assert.deepEqual(faults, [renderError]);
|
||||||
|
assert.equal(callbacks.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregates an asynchronous render error with reentrant cancellation failure', () => {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
const renderError = new Error('render failed');
|
||||||
|
const cancelError = new Error('cancel failed');
|
||||||
|
const faults: unknown[] = [];
|
||||||
|
let rendered = 0;
|
||||||
|
let renderer!: FrameRenderer;
|
||||||
|
renderer = new FrameRenderer({
|
||||||
|
request(callback) {
|
||||||
|
callbacks.push(callback);
|
||||||
|
return () => { throw cancelError; };
|
||||||
|
},
|
||||||
|
}, () => {
|
||||||
|
rendered += 1;
|
||||||
|
renderer.request();
|
||||||
|
throw renderError;
|
||||||
|
}, error => { faults.push(error); });
|
||||||
|
|
||||||
|
renderer.request();
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
callbacks[0]!();
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(thrown, undefined);
|
||||||
|
assert.equal(faults.length, 1);
|
||||||
|
const fault = faults[0];
|
||||||
|
assert.ok(fault instanceof Error);
|
||||||
|
assert.ok('errors' in fault);
|
||||||
|
assert.deepEqual(fault.errors, [renderError, cancelError]);
|
||||||
|
callbacks[1]!();
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
assert.equal(faults.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flush rethrows a synchronous render error without reporting an async fault', () => {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
const renderError = new Error('render failed');
|
||||||
|
const faults: unknown[] = [];
|
||||||
|
let rendered = 0;
|
||||||
|
const renderer = new FrameRenderer({
|
||||||
|
request(callback) { callbacks.push(callback); return () => {}; },
|
||||||
|
}, () => {
|
||||||
|
rendered += 1;
|
||||||
|
throw renderError;
|
||||||
|
}, error => { faults.push(error); });
|
||||||
|
|
||||||
|
renderer.request();
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
renderer.flush();
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(thrown, renderError);
|
||||||
|
assert.deepEqual(faults, []);
|
||||||
|
callbacks[0]!();
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not swallow an onFault callback error', () => {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
const renderError = new Error('render failed');
|
||||||
|
const callbackError = new Error('fault callback failed');
|
||||||
|
const reported: unknown[] = [];
|
||||||
|
const renderer = new FrameRenderer({
|
||||||
|
request(callback) { callbacks.push(callback); return () => {}; },
|
||||||
|
}, () => { throw renderError; }, error => {
|
||||||
|
reported.push(error);
|
||||||
|
throw callbackError;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderer.request();
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
callbacks[0]!();
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(thrown, callbackError);
|
||||||
|
assert.deepEqual(reported, [renderError]);
|
||||||
|
renderer.request();
|
||||||
|
assert.equal(callbacks.length, 1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user