Skip to content

Commit 7221dc4

Browse files
manovotnyclaude
andcommitted
test(nextjs): cover the deferred refresh pending-transition gate
A gateless implementation that dispatched router.refresh() synchronously passed the previous suite. The new test holds a transition open by suspending inside it, asserts the refresh stays parked, then settles the transition and asserts a single dispatch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 33d1ff5 commit 7221dc4

1 file changed

Lines changed: 59 additions & 2 deletions

File tree

‎packages/nextjs/src/app-router/client/__tests__/useDeferredRefresh.test.tsx‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { act, cleanup, render, waitFor } from '@testing-library/react';
2-
import React from 'react';
2+
import React, { useTransition } from 'react';
33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
44

55
import { useDeferredRefresh } from '../useDeferredRefresh';
@@ -11,10 +11,38 @@ vi.mock('next/navigation', () => ({
1111
}));
1212

1313
let currentRefresh: (() => void) | undefined;
14+
let startHeldTransition: (() => void) | undefined;
15+
let finishHeldTransition: (() => void) | undefined;
16+
17+
// Suspends inside a transition until the gate promise resolves, keeping
18+
// React's transition lanes pending (the state the deferral gate protects)
19+
const gate: { promise: Promise<void> | null; done: boolean } = { promise: null, done: false };
20+
21+
const Suspender = () => {
22+
if (!gate.done) {
23+
// eslint-disable-next-line @typescript-eslint/only-throw-error
24+
throw gate.promise;
25+
}
26+
return null;
27+
};
1428

1529
const Harness = () => {
1630
currentRefresh = useDeferredRefresh();
17-
return null;
31+
const [suspended, setSuspended] = React.useState(false);
32+
const [, startTransition] = useTransition();
33+
startHeldTransition = () => {
34+
gate.done = false;
35+
let resolveGate!: () => void;
36+
gate.promise = new Promise<void>(res => {
37+
resolveGate = res;
38+
});
39+
finishHeldTransition = () => {
40+
gate.done = true;
41+
resolveGate();
42+
};
43+
startTransition(() => setSuspended(true));
44+
};
45+
return <React.Suspense fallback={null}>{suspended ? <Suspender /> : null}</React.Suspense>;
1846
};
1947

2048
const refresh = () => {
@@ -47,6 +75,35 @@ describe('useDeferredRefresh', () => {
4775
});
4876
});
4977

78+
it('does not dispatch router.refresh while another transition is pending', async () => {
79+
render(<Harness />);
80+
81+
act(() => {
82+
startHeldTransition!();
83+
});
84+
85+
act(() => {
86+
refresh();
87+
});
88+
89+
// Let effects and microtasks run; the refresh must stay parked while the
90+
// held transition keeps React's transition lanes pending
91+
await act(async () => {
92+
await new Promise(res => setTimeout(res, 20));
93+
});
94+
expect(mockRefresh).not.toHaveBeenCalled();
95+
expect(window.__clerk_internal_refresh?.pending).toBe(true);
96+
97+
act(() => {
98+
finishHeldTransition!();
99+
});
100+
101+
await waitFor(() => {
102+
expect(mockRefresh).toHaveBeenCalledTimes(1);
103+
});
104+
expect(window.__clerk_internal_refresh?.pending).toBe(false);
105+
});
106+
50107
it('coalesces concurrent requests into a single router.refresh', async () => {
51108
render(<Harness />);
52109

0 commit comments

Comments
 (0)