Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
61 changes: 61 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,16 @@ async function flushScheduler(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}

/** Lets the scheduler's queued microtask flush run, without any timer. */
function flushMicrotasks(): Promise<void> {
return 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 +104,56 @@ 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.
it('keeps flushing after a callback throws and rethrows the error later', async () => {
const scheduler = createScheduler();
const error = new Error('callback failed');
const calls: string[] = [];
const deferred: Array<() => void> = [];
jest
.spyOn(globalThis, 'setTimeout')
.mockImplementation(((handler: () => void) => {
deferred.push(handler);
return 0;
}) as unknown as typeof setTimeout);

scheduler(() => {
throw error;
});
scheduler(() => calls.push('same-pass'));
await flushMicrotasks();
scheduler(() => calls.push('next-pass'));
await flushMicrotasks();

expect(calls).toEqual(['same-pass', 'next-pass']);
expect(deferred).toHaveLength(1);
expect(deferred[0]).toThrow(error);
});

it('runs callbacks queued by a failing pass on a later task, not in the same microtask', async () => {
const scheduler = createScheduler();
const deferred: Array<() => void> = [];
jest
.spyOn(globalThis, 'setTimeout')
.mockImplementation(((handler: () => void) => {
deferred.push(handler);
return 0;
}) as unknown as typeof setTimeout);
let runs = 0;
const failAndReschedule = () => {
runs += 1;
scheduler(failAndReschedule);
throw new Error('keeps failing');
};

scheduler(failAndReschedule);
await flushMicrotasks();
await flushMicrotasks();

// One run per task: the rethrow and the next flush are both on a timer.
expect(runs).toBe(1);
expect(deferred).toHaveLength(2);
});
});
22 changes: 19 additions & 3 deletions packages/joint-react/src/utils/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ 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 does not stop the flush: its error is rethrown on a later task
* (see {@link rethrowLater}), and the callbacks queued during that pass run
* on a later task too, so one that keeps re-scheduling itself and throwing
* cannot hold this microtask forever.
* @returns A function that queues callbacks for batched execution.
*/
export function createScheduler(): (callback: () => void) => void {
Expand All @@ -25,18 +29,30 @@ export function createScheduler(): (callback: () => void) => void {
const flush = (): void => {
scheduled = false;
flushing = true;
let hasFailed = false;

// Process cascading callbacks in the same flush so React sees
// all store changes as a single batched update.
while (callbacks.size > 0) {
while (callbacks.size > 0 && !hasFailed) {
const pending = callbacks;
callbacks = new Set();
for (const callback of pending) {
callback();
// One throwing callback must not leave the scheduler marked as
// flushing (nothing would ever flush again) or drop the rest of the
// pass. The error still surfaces, on a later task.
try {
callback();
} catch (error) {
hasFailed = true;
rethrowLater(error);
}
}
}

flushing = false;
if (callbacks.size === 0) return;
scheduled = true;
setTimeout(flush);
};

return (callback: () => void): void => {
Expand Down
Loading