Skip to content

Commit 3c1d897

Browse files
jhfclaude
andcommitted
fix(nav): Add timeout recovery to prevent navigation hang after login
When router.push() is called during login redirect, it can succeed at the JavaScript level while the underlying Next.js App Router fails to update its internal state. This causes usePathname() to remain stale, leaving the navigation machine stuck in a redirecting state with no escape path. Root cause analysis: - router.push('/') returns successfully (no error thrown) - RSC request completes with HTTP 200 - But usePathname() never updates from '/login' - The always guards can't fire because pathname hasn't changed - CLEAR_SIDE_EFFECT only cleared context but had no target transition This was observed specifically on cold sessions where: 1. Initial page load had expired tokens 2. Token refresh was attempted and failed (401) 3. User logged in successfully 4. Navigation to post-login destination hung The App Router's internal navigation state appears to get corrupted when there's been a failed auth cycle before the login, possibly due to: - Concurrent RSC requests from the failed refresh interfering - React Suspense boundary state not propagating correctly - Cookie timing issues between refresh failure and login success The fix adds a recovery transition: when CLEAR_SIDE_EFFECT fires with reason='timeout', the machine now transitions to 'evaluating' instead of staying stuck. This allows it to re-evaluate conditions and retry the navigation, or at minimum reach a stable state the user can interact with. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2dbe976 commit 3c1d897

File tree

1 file changed

+69
-27
lines changed

1 file changed

+69
-27
lines changed

app/src/atoms/navigation-machine.ts

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -262,15 +262,28 @@ export const navigationMachine = setup({
262262
handleSideEffectTimeout(context, event, 'redirectingToLogin')
263263
),
264264
},
265-
CLEAR_SIDE_EFFECT: {
266-
// Clear sideEffect (called by polling for TOO FAST/TOO SLOW detection)
267-
actions: assign(({ context }) => ({
268-
...context,
269-
sideEffect: undefined,
270-
sideEffectStartTime: undefined,
271-
sideEffectStartPathname: undefined,
272-
})),
273-
}
265+
CLEAR_SIDE_EFFECT: [
266+
{
267+
// TIMEOUT RECOVERY: Transition to evaluating to retry navigation.
268+
target: 'evaluating',
269+
guard: ({ event }) => event.reason === 'timeout',
270+
actions: assign(({ context }) => ({
271+
...context,
272+
sideEffect: undefined,
273+
sideEffectStartTime: undefined,
274+
sideEffectStartPathname: undefined,
275+
})),
276+
},
277+
{
278+
// FAST DETECTION: Just clear sideEffect, let always guards handle transition.
279+
actions: assign(({ context }) => ({
280+
...context,
281+
sideEffect: undefined,
282+
sideEffectStartTime: undefined,
283+
sideEffectStartPathname: undefined,
284+
})),
285+
},
286+
],
274287
},
275288
always: [
276289
{
@@ -309,15 +322,28 @@ export const navigationMachine = setup({
309322
handleSideEffectTimeout(context, event, 'redirectingToSetup')
310323
),
311324
},
312-
CLEAR_SIDE_EFFECT: {
313-
// Clear sideEffect (called by polling for TOO FAST/TOO SLOW detection)
314-
actions: assign(({ context }) => ({
315-
...context,
316-
sideEffect: undefined,
317-
sideEffectStartTime: undefined,
318-
sideEffectStartPathname: undefined,
319-
})),
320-
}
325+
CLEAR_SIDE_EFFECT: [
326+
{
327+
// TIMEOUT RECOVERY: Transition to evaluating to retry navigation.
328+
target: 'evaluating',
329+
guard: ({ event }) => event.reason === 'timeout',
330+
actions: assign(({ context }) => ({
331+
...context,
332+
sideEffect: undefined,
333+
sideEffectStartTime: undefined,
334+
sideEffectStartPathname: undefined,
335+
})),
336+
},
337+
{
338+
// FAST DETECTION: Just clear sideEffect, let always guards handle transition.
339+
actions: assign(({ context }) => ({
340+
...context,
341+
sideEffect: undefined,
342+
sideEffectStartTime: undefined,
343+
sideEffectStartPathname: undefined,
344+
})),
345+
},
346+
],
321347
},
322348
always: [
323349
{
@@ -475,15 +501,31 @@ export const navigationMachine = setup({
475501
handleSideEffectTimeout(context, event, 'redirectingFromLogin')
476502
),
477503
},
478-
CLEAR_SIDE_EFFECT: {
479-
// Clear sideEffect (called by polling for TOO FAST/TOO SLOW detection)
480-
actions: assign(({ context }) => ({
481-
...context,
482-
sideEffect: undefined,
483-
sideEffectStartTime: undefined,
484-
sideEffectStartPathname: undefined,
485-
})),
486-
},
504+
CLEAR_SIDE_EFFECT: [
505+
{
506+
// TIMEOUT RECOVERY: If navigation timed out, transition to evaluating to retry.
507+
// This prevents the machine from getting stuck when router.push() succeeds
508+
// but the pathname doesn't update (e.g., due to Next.js App Router issues).
509+
target: 'evaluating',
510+
guard: ({ event }) => event.reason === 'timeout',
511+
actions: assign(({ context }) => ({
512+
...context,
513+
sideEffect: undefined,
514+
sideEffectStartTime: undefined,
515+
sideEffectStartPathname: undefined,
516+
})),
517+
},
518+
{
519+
// FAST DETECTION: Navigation completed quickly, just clear sideEffect.
520+
// The always guards will handle the transition to idle.
521+
actions: assign(({ context }) => ({
522+
...context,
523+
sideEffect: undefined,
524+
sideEffectStartTime: undefined,
525+
sideEffectStartPathname: undefined,
526+
})),
527+
},
528+
],
487529
},
488530
always: [
489531
{

0 commit comments

Comments
 (0)