Skip to content

Commit 85abc48

Browse files
ztannertimneutkens
andauthored
suspend in render, not in reducers (#56497)
This removes our current convention of throwing promises in reducers in favor of returning promises that can be consumed by `use` instead. This will help unblock some future improvements (batching, PPR) Reducers that would typically throw a promise now return their promise. This gets maintained by a mutable queue (initialized in hydrate) to ensure actions are processed in-order. The queue is also responsible for mutating state and passing it as an input to subsequent actions. This PR does not modify reducer behavior to keep changes minimal, but there's more cleanup that we can do in a follow-up PR to remove things that previously assumed reducers would be replayed. (I recommend reviewing with whitespace turned off) --------- Co-authored-by: Tim Neutkens <[email protected]>
1 parent be61804 commit 85abc48

16 files changed

+720
-566
lines changed

packages/next/src/client/app-index.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { GlobalLayoutRouterContext } from '../shared/lib/app-router-context.shar
1212
import onRecoverableError from './on-recoverable-error'
1313
import { callServer } from './app-call-server'
1414
import { isNextRouterError } from './components/is-next-router-error'
15+
import {
16+
ActionQueueContext,
17+
createMutableActionQueue,
18+
} from '../shared/lib/router/action-queue'
1519

1620
// Since React doesn't call onerror for errors caught in error boundaries.
1721
const origConsoleError = window.console.error
@@ -222,16 +226,20 @@ export function hydrate() {
222226
}
223227
}
224228

229+
const actionQueue = createMutableActionQueue()
230+
225231
const reactEl = (
226232
<StrictModeIfEnabled>
227233
<HeadManagerContext.Provider
228234
value={{
229235
appDir: true,
230236
}}
231237
>
232-
<Root>
233-
<RSCComponent />
234-
</Root>
238+
<ActionQueueContext.Provider value={actionQueue}>
239+
<Root>
240+
<RSCComponent />
241+
</Root>
242+
</ActionQueueContext.Provider>
235243
</HeadManagerContext.Provider>
236244
</StrictModeIfEnabled>
237245
)

packages/next/src/client/components/app-router.tsx

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import type {
2424
FlightData,
2525
} from '../../server/app-render/types'
2626
import type { ErrorComponent } from './error-boundary'
27-
import { reducer } from './router-reducer/router-reducer'
2827
import {
2928
ACTION_FAST_REFRESH,
3029
ACTION_NAVIGATE,
@@ -36,7 +35,6 @@ import {
3635
PrefetchKind,
3736
} from './router-reducer/router-reducer-types'
3837
import type {
39-
Mutable,
4038
ReducerActions,
4139
RouterChangeByServerResponse,
4240
RouterNavigate,
@@ -47,7 +45,10 @@ import {
4745
SearchParamsContext,
4846
PathnameContext,
4947
} from '../../shared/lib/hooks-client-context.shared-runtime'
50-
import { useReducerWithReduxDevtools } from './use-reducer-with-devtools'
48+
import {
49+
useReducerWithReduxDevtools,
50+
useUnwrapState,
51+
} from './use-reducer-with-devtools'
5152
import { ErrorBoundary } from './error-boundary'
5253
import { createInitialRouterState } from './router-reducer/create-initial-router-state'
5354
import type { InitialRouterStateParameters } from './router-reducer/create-initial-router-state'
@@ -73,9 +74,9 @@ export function getServerActionDispatcher() {
7374
return globalServerActionDispatcher
7475
}
7576

76-
let globalMutable: Mutable['globalMutable'] = {
77-
refresh: () => {}, // noop until the router is initialized
78-
}
77+
const globalMutable: {
78+
pendingMpaPath?: string
79+
} = {}
7980

8081
export function urlToUrlWithoutFlightMarker(url: string): URL {
8182
const urlWithoutFlightParameters = new URL(url, location.origin)
@@ -131,7 +132,7 @@ function HistoryUpdater({ tree, pushRef, canonicalUrl, sync }: any) {
131132
return null
132133
}
133134

134-
const createEmptyCacheNode = () => ({
135+
export const createEmptyCacheNode = () => ({
135136
status: CacheStates.LAZY_INITIALIZED,
136137
data: null,
137138
subTreeData: null,
@@ -145,7 +146,7 @@ function useServerActionDispatcher(dispatch: React.Dispatch<ReducerActions>) {
145146
dispatch({
146147
...actionPayload,
147148
type: ACTION_SERVER_ACTION,
148-
mutable: { globalMutable },
149+
mutable: {},
149150
cache: createEmptyCacheNode(),
150151
})
151152
})
@@ -174,7 +175,7 @@ function useChangeByServerResponse(
174175
previousTree,
175176
overrideCanonicalUrl,
176177
cache: createEmptyCacheNode(),
177-
mutable: { globalMutable },
178+
mutable: {},
178179
})
179180
})
180181
},
@@ -186,7 +187,6 @@ function useNavigate(dispatch: React.Dispatch<ReducerActions>): RouterNavigate {
186187
return useCallback(
187188
(href, navigateType, forceOptimisticNavigation, shouldScroll) => {
188189
const url = new URL(addBasePath(href), location.href)
189-
globalMutable.pendingNavigatePath = createHrefFromUrl(url)
190190

191191
return dispatch({
192192
type: ACTION_NAVIGATE,
@@ -197,7 +197,7 @@ function useNavigate(dispatch: React.Dispatch<ReducerActions>): RouterNavigate {
197197
shouldScroll: shouldScroll ?? true,
198198
navigateType,
199199
cache: createEmptyCacheNode(),
200-
mutable: { globalMutable },
200+
mutable: {},
201201
})
202202
},
203203
[dispatch]
@@ -229,25 +229,15 @@ function Router({
229229
}),
230230
[buildId, children, initialCanonicalUrl, initialTree, initialHead]
231231
)
232-
const [
233-
{
234-
tree,
235-
cache,
236-
prefetchCache,
237-
pushRef,
238-
focusAndScrollRef,
239-
canonicalUrl,
240-
nextUrl,
241-
},
242-
dispatch,
243-
sync,
244-
] = useReducerWithReduxDevtools(reducer, initialState)
232+
const [reducerState, dispatch, sync] =
233+
useReducerWithReduxDevtools(initialState)
245234

246235
useEffect(() => {
247236
// Ensure initialParallelRoutes is cleaned up from memory once it's used.
248237
initialParallelRoutes = null!
249238
}, [])
250239

240+
const { canonicalUrl } = useUnwrapState(reducerState)
251241
// Add memoized pathname/query for useSearchParams and usePathname.
252242
const { searchParams, pathname } = useMemo(() => {
253243
const url = new URL(
@@ -322,7 +312,7 @@ function Router({
322312
dispatch({
323313
type: ACTION_REFRESH,
324314
cache: createEmptyCacheNode(),
325-
mutable: { globalMutable },
315+
mutable: {},
326316
origin: window.location.origin,
327317
})
328318
})
@@ -338,7 +328,7 @@ function Router({
338328
dispatch({
339329
type: ACTION_FAST_REFRESH,
340330
cache: createEmptyCacheNode(),
341-
mutable: { globalMutable },
331+
mutable: {},
342332
origin: window.location.origin,
343333
})
344334
})
@@ -356,11 +346,10 @@ function Router({
356346
}
357347
}, [appRouter])
358348

359-
useEffect(() => {
360-
globalMutable.refresh = appRouter.refresh
361-
}, [appRouter.refresh])
362-
363349
if (process.env.NODE_ENV !== 'production') {
350+
// eslint-disable-next-line react-hooks/rules-of-hooks
351+
const { cache, prefetchCache, tree } = useUnwrapState(reducerState)
352+
364353
// This hook is in a conditional but that is ok because `process.env.NODE_ENV` never changes
365354
// eslint-disable-next-line react-hooks/rules-of-hooks
366355
useEffect(() => {
@@ -408,6 +397,7 @@ function Router({
408397
// probably safe because we know this is a singleton component and it's never
409398
// in <Offscreen>. At least I hope so. (It will run twice in dev strict mode,
410399
// but that's... fine?)
400+
const { pushRef } = useUnwrapState(reducerState)
411401
if (pushRef.mpaNavigation) {
412402
// if there's a re-render, we don't want to trigger another redirect if one is already in flight to the same URL
413403
if (globalMutable.pendingMpaPath !== canonicalUrl) {
@@ -466,6 +456,9 @@ function Router({
466456
}
467457
}, [onPopState])
468458

459+
const { cache, tree, nextUrl, focusAndScrollRef } =
460+
useUnwrapState(reducerState)
461+
469462
const head = useMemo(() => {
470463
return findHeadInCache(cache, tree[1])
471464
}, [cache, tree])
@@ -513,7 +506,7 @@ function Router({
513506
<LayoutRouterContext.Provider
514507
value={{
515508
childNodes: cache.parallelRoutes,
516-
tree: tree,
509+
tree,
517510
// Root node always has `url`
518511
// Provided in AppTreeContext to ensure it can be overwritten in layout-router
519512
url: canonicalUrl,

packages/next/src/client/components/router-reducer/read-record-value.test.ts

Lines changed: 0 additions & 28 deletions
This file was deleted.

packages/next/src/client/components/router-reducer/read-record-value.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.

0 commit comments

Comments
 (0)