Skip to content

Commit e9842ff

Browse files
fix: address code review findings
- Move server action cache clearing before RSC deserialization so invalidation isn't skipped if the payload is malformed - Document isSameRoute staleness during rapid A->B->A navigations - Document why reading navigationSnapshotActiveCount during render is safe despite being a React anti-pattern - Fix restoreRscResponse comment: mutation is the risk, not just transfer/detach - Replace waitForTimeout with double-rAF waitForPaintFlush in E2E tests for deterministic CI behavior - Document the self-referencing promise identity pattern in prefetchRscResponse Co-authored-by: Divanshu Chauhan <23524935+Divkix@users.noreply.github.com>
1 parent e682454 commit e9842ff

3 files changed

Lines changed: 45 additions & 19 deletions

File tree

packages/vinext/src/server/app-browser-entry.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -459,17 +459,18 @@ function registerServerActionCallback(): void {
459459
return undefined;
460460
}
461461

462+
// Clear navigation caches after the fetch succeeds but before RSC
463+
// deserialization. Server actions may have mutated data, making cached
464+
// responses stale. We clear immediately after the fetch (not after
465+
// deserialization) so cache invalidation isn't skipped if the RSC
466+
// payload is malformed.
467+
clearClientNavigationCaches();
468+
462469
const result = await createFromFetch<ServerActionResult | ReactNode>(
463470
Promise.resolve(fetchResponse),
464471
{ temporaryReferences },
465472
);
466473

467-
// Clear navigation caches after the action succeeds — server actions
468-
// may have mutated data, making cached responses stale. We clear here
469-
// (not before the fetch) so failed actions don't unnecessarily
470-
// invalidate valid caches.
471-
clearClientNavigationCaches();
472-
473474
if (isServerActionResult(result)) {
474475
updateBrowserTree(
475476
result.root,
@@ -538,6 +539,12 @@ async function main(): Promise<void> {
538539
// so React keeps the old UI visible during the transition. For cross-route
539540
// navigations (different pathname), use synchronous updates — React's
540541
// startTransition hangs in Firefox when replacing the entire tree.
542+
//
543+
// Note: window.location.pathname reflects the *previous* page here because
544+
// the two-phase commit defers history updates to useLayoutEffect. During
545+
// rapid A->B->A, the second navigation may see isSameRoute=true while the
546+
// user perceives a cross-route nav. This is harmless — it only affects
547+
// whether startTransition is used (smoother animation, not correctness).
541548
const isSameRoute = url.pathname === window.location.pathname;
542549
const cachedRoute = getVisitedResponse(rscUrl, navigationKind);
543550
const navigationCommitEffect = createNavigationCommitEffect(href, historyUpdateMode);

packages/vinext/src/shims/navigation.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,10 @@ export async function snapshotRscResponse(response: Response): Promise<CachedRsc
245245

246246
export function restoreRscResponse(snapshot: CachedRscResponse): Response {
247247
// Uint8Array creates a view over the cached ArrayBuffer without copying.
248-
// ReadableStream consumption does not detach the underlying buffer, so
249-
// repeated restores from the same snapshot are safe. If a future consumer
250-
// ever transfers or detaches the buffer, this assumption breaks — add a
251-
// defensive .slice(0) in that case.
248+
// ReadableStream consumption does not mutate or detach the underlying
249+
// buffer, so repeated restores from the same snapshot are safe. If a
250+
// future consumer ever mutates, transfers, or detaches the buffer, this
251+
// assumption breaks — add a defensive .slice(0) in that case.
252252
const body = new ReadableStream<Uint8Array>({
253253
start(controller) {
254254
controller.enqueue(new Uint8Array(snapshot.body));
@@ -367,6 +367,11 @@ export function prefetchRscResponse(rscUrl: string, responsePromise: Promise<Res
367367
evictPrefetchCacheIfNeeded();
368368
}
369369

370+
// The `pendingResponse` variable is assigned and then referenced inside its
371+
// own .then()/.catch() chain. This is an intentional identity check pattern:
372+
// when the promise settles, `cache.get(rscUrl)?.pendingResponse === pendingResponse`
373+
// verifies this is still the *current* pending request for that URL (not a
374+
// newer one that superseded it).
370375
let pendingResponse: Promise<CachedRscResponse | null>;
371376
pendingResponse = responsePromise
372377
.then(async (response) => {
@@ -660,9 +665,16 @@ export function usePathname(): string {
660665
getPathnameSnapshot,
661666
() => _getServerContext()?.pathname ?? "/",
662667
);
663-
// Only use the render snapshot during an active navigation transition.
664-
// After commit, fall through to useSyncExternalStore so user
665-
// pushState/replaceState calls are immediately reflected.
668+
// Prefer the render snapshot during an active navigation transition so
669+
// hooks return the pending URL, not the stale committed one. After commit,
670+
// fall through to useSyncExternalStore so user pushState/replaceState
671+
// calls are immediately reflected.
672+
//
673+
// Reading navigationSnapshotActiveCount (mutable external state) during
674+
// render is technically a React anti-pattern, but safe here: the counter
675+
// only changes synchronously before startTransition (activate) and in
676+
// useLayoutEffect after commit (deactivate). React's concurrent renders
677+
// within a single transition see the same external state snapshot.
666678
if (renderSnapshot && (getClientNavigationState()?.navigationSnapshotActiveCount ?? 0) > 0) {
667679
return renderSnapshot.pathname;
668680
}

tests/e2e/app-router/navigation-regressions.spec.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ async function waitForHydration(page: import("@playwright/test").Page) {
99
}).toPass({ timeout: 10_000 });
1010
}
1111

12+
/** Wait for two animation frames to flush all pending paints and microtasks. */
13+
async function waitForPaintFlush(page: import("@playwright/test").Page) {
14+
await page.evaluate(
15+
() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))),
16+
);
17+
}
18+
1219
async function waitForProvidersList(page: import("@playwright/test").Page) {
1320
await expect(page.locator("#providers-title")).toHaveText("Providers");
1421
await expect(page.locator("#provider-list")).toBeVisible({ timeout: 10_000 });
@@ -154,7 +161,7 @@ test.describe("App Router navigation regressions", () => {
154161
await expect(page.locator("#client-param-label")).toHaveText("Client param: beta");
155162
await expect(page.locator("#param-filter-title")).toHaveText("Server param: beta");
156163
await expect(page.locator("#param-filter-first-row")).toHaveText("Beta 1");
157-
await page.waitForTimeout(100);
164+
await waitForPaintFlush(page);
158165

159166
const events = await page.evaluate(() => {
160167
(
@@ -264,7 +271,7 @@ test.describe("App Router navigation regressions", () => {
264271
await expect(page.locator("#client-filter-label")).toHaveText("Client filter: beta");
265272
await expect(page.locator("#query-filter-title")).toHaveText("Server filter: beta");
266273
await expect(page.locator("#query-filter-first-row")).toHaveText("Beta 1");
267-
await page.waitForTimeout(100);
274+
await waitForPaintFlush(page);
268275

269276
const events = await page.evaluate(() => {
270277
(
@@ -362,7 +369,7 @@ test.describe("App Router navigation regressions", () => {
362369
await page.locator("#query-filter-beta").click();
363370
await expect(page.locator("#query-filter-title")).toHaveText("Server filter: beta");
364371
await expect(page.locator("#query-filter-first-row")).toHaveText("Beta 1");
365-
await page.waitForTimeout(100);
372+
await waitForPaintFlush(page);
366373

367374
const events = await page.evaluate(() => {
368375
(
@@ -453,7 +460,7 @@ test.describe("App Router navigation regressions", () => {
453460
await page.locator("#query-filter-beta").click();
454461
await expect(page.locator("#query-filter-title")).toHaveText("Server filter: beta");
455462
await expect(page.locator("#query-filter-first-row")).toHaveText("Beta 1");
456-
await page.waitForTimeout(100);
463+
await waitForPaintFlush(page);
457464

458465
const events = await page.evaluate(() => {
459466
(
@@ -550,7 +557,7 @@ test.describe("App Router navigation regressions", () => {
550557
await expect(page.locator("#link-client-filter-label")).toHaveText("Client filter: beta");
551558
await expect(page.locator("#link-filter-title")).toHaveText("Server filter: beta");
552559
await expect(page.locator("#link-filter-first-row")).toHaveText("Beta 1");
553-
await page.waitForTimeout(100);
560+
await waitForPaintFlush(page);
554561

555562
const events = await page.evaluate(() => {
556563
(
@@ -609,7 +616,7 @@ test.describe("App Router navigation regressions", () => {
609616
await waitForHydration(page);
610617

611618
await page.locator("#link-filter-beta").hover();
612-
await page.waitForTimeout(50);
619+
await waitForPaintFlush(page);
613620

614621
await page.locator("#link-filter-beta").click();
615622
await expect(page.locator("#link-client-filter-label")).toHaveText("Client filter: beta");

0 commit comments

Comments
 (0)