Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/react-scheduler-throw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": patch
---

<GraphProvider /> - fix every diagram on the page freezing after a change callback such as `onCellsChange` throws
78 changes: 78 additions & 0 deletions packages/joint-react/src/utils/__tests__/scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ async function flushScheduler(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}

/** Lets the scheduler's queued microtasks run, without any timer. */
async function flushMicrotasks(): Promise<void> {
for (let tick = 0; tick < 5; tick++) {
await Promise.resolve();
}
}

describe('createScheduler', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('batches multiple schedule calls into a single flush', async () => {
const scheduler = createScheduler();
const calls: string[] = [];
Expand Down Expand Up @@ -95,4 +106,71 @@ describe('createScheduler', () => {

expect(calls).toEqual(['batch-1', 'batch-2']);
});

// Regression: a throwing callback left the scheduler marked as flushing, so
// every later callback was queued forever — for every store on the page.
describe('when a callback throws', () => {
const errors: unknown[] = [];
const captureError = (event: ErrorEvent) => {
errors.push(event.error);
event.preventDefault();
};

beforeEach(() => {
errors.length = 0;
globalThis.addEventListener('error', captureError);
});

afterEach(() => {
globalThis.removeEventListener('error', captureError);
});

it('reports the error and keeps flushing, without a timer', async () => {
const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout');
const scheduler = createScheduler();
const error = new Error('callback failed');
const calls: string[] = [];

scheduler(() => {
throw error;
});
scheduler(() => calls.push('same-pass'));
await flushMicrotasks();
expect(setTimeoutSpy).not.toHaveBeenCalled();
setTimeoutSpy.mockRestore();
await flushScheduler();
scheduler(() => calls.push('next-pass'));
await flushScheduler();

expect(calls).toEqual(['same-pass', 'next-pass']);
expect(errors).toEqual([error]);
});

it('runs a callback queued by the failing callback', async () => {
const scheduler = createScheduler();
const calls: string[] = [];

scheduler(() => {
scheduler(() => calls.push('cascaded'));
throw new Error('callback failed');
});
await flushScheduler();

expect(calls).toEqual(['cascaded']);
expect(errors).toHaveLength(1);
});
});

it('runs cascading callbacks before any microtask queued after the flush starts', async () => {
const scheduler = createScheduler();
const calls: string[] = [];

scheduler(() => {
queueMicrotask(() => calls.push('microtask'));
scheduler(() => calls.push('cascaded'));
});
await flushScheduler();

expect(calls).toEqual(['cascaded', 'microtask']);
});
});
41 changes: 22 additions & 19 deletions packages/joint-react/src/utils/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,37 +14,40 @@ export function rethrowLater(error: unknown): void {
*
* Cascading callbacks (added during flush) are processed in the SAME
* microtask so that all listener notifications land in a single React
* batch update instead of triggering separate re-render cycles.
* batch update instead of triggering separate re-render cycles. A callback
* that throws ends that microtask, and the error is reported like any other
* uncaught error; the callbacks still queued run in the next microtask.
* @returns A function that queues callbacks for batched execution.
*/
export function createScheduler(): (callback: () => void) => void {
let scheduled = false;
let flushing = false;
let callbacks = new Set<() => void>();
const callbacks = new Set<() => void>();

const flush = (): void => {
scheduled = false;
flushing = true;

// Process cascading callbacks in the same flush so React sees
// all store changes as a single batched update.
while (callbacks.size > 0) {
const pending = callbacks;
callbacks = new Set();
for (const callback of pending) {
callback();
}
const finishFlush = (): void => {
if (callbacks.size > 0) {
queueMicrotask(flush);
return;
}
scheduled = false;
};

flushing = false;
const flush = (): void => {
// Queued before any callback runs, so it still runs if one of them throws.
queueMicrotask(finishFlush);

// A Set iteration also visits entries added while it runs, which is what
// keeps cascading callbacks in this microtask.
for (const callback of callbacks) {
callbacks.delete(callback);
callback();
}
};

return (callback: () => void): void => {
callbacks.add(callback);

// If we're already inside a flush, the while-loop will pick up
// the new callback — no need to schedule another microtask.
if (scheduled || flushing) {
// Already scheduled or inside a flush: the pending flush picks it up.
if (scheduled) {
return;
}

Expand Down
Loading