Skip to content

Commit

Permalink
first pass at a queue
Browse files Browse the repository at this point in the history
  • Loading branch information
ztanner committed Oct 11, 2023
1 parent 3235239 commit 4eea5cf
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 85 deletions.
112 changes: 109 additions & 3 deletions packages/next/src/client/app-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,25 @@
import '../build/polyfills/polyfill-module'
// @ts-ignore react-dom/client exists when using React 18
import ReactDOMClient from 'react-dom/client'
import React, { use } from 'react'
import React, { use, startTransition } from 'react'
// @ts-ignore
// eslint-disable-next-line import/no-extraneous-dependencies
import { createFromReadableStream } from 'react-server-dom-webpack/client'

import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime'
import { GlobalLayoutRouterContext } from '../shared/lib/app-router-context.shared-runtime'
import {
ActionQueueContext,
GlobalLayoutRouterContext,
} from '../shared/lib/app-router-context.shared-runtime'
import onRecoverableError from './on-recoverable-error'
import { callServer } from './app-call-server'
import { isNextRouterError } from './components/is-next-router-error'
import type {
AppRouterState,
ReducerActions,
} from './components/router-reducer/router-reducer-types'
import { reducer } from './components/router-reducer/router-reducer'
import type { ReduxDevToolsInstance } from './components/use-flight-router-state'

// Since React doesn't call onerror for errors caught in error boundaries.
const origConsoleError = window.console.error
Expand All @@ -33,6 +42,101 @@ window.addEventListener('error', (ev: WindowEventMap['error']): void => {

const appElement: HTMLElement | Document | null = document

export type AppRouterActionQueue = {
state: AppRouterState | null
devToolsInstance?: ReduxDevToolsInstance
dispatch: any
action: (
state: AppRouterState | null,
action: ReducerActions
) => Promise<AppRouterState | null>
pending: ActionQueueNode | null
}

type ActionQueueNode = {
payload: any
next: ActionQueueNode | null
}

function finishRunningAction(
actionQueue: AppRouterActionQueue,
setState: React.Dispatch<Promise<AppRouterState | null>>
) {
if (actionQueue.pending !== null) {
actionQueue.pending = actionQueue.pending.next
if (actionQueue.pending !== null) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
runAction(actionQueue, actionQueue.pending.payload, setState)
}
}
}
async function runAction(
actionQueue: AppRouterActionQueue,
payload: ReducerActions,
setState: React.Dispatch<Promise<AppRouterState | null>>
) {
const prevState = actionQueue.state
const promise = actionQueue.action(prevState, payload)

promise.then(
(nextState: AppRouterState | null) => {
actionQueue.state = nextState

if (actionQueue.devToolsInstance) {
actionQueue.devToolsInstance.send(payload, nextState)
}

finishRunningAction(actionQueue, setState)
},
() => {
finishRunningAction(actionQueue, setState)
}
)

startTransition(() => {
setState(promise)
})
}

function dispatchAction(
actionQueue: AppRouterActionQueue,
payload: ReducerActions,
setState: React.Dispatch<Promise<AppRouterState | null>>
) {
const newAction: ActionQueueNode = {
payload,
next: null,
}
// Check if the queue is empty
if (actionQueue.pending === null || payload.type === 'navigate') {
// The queue is empty, so add the action and start it immediately
actionQueue.pending = newAction
runAction(actionQueue, newAction.payload, setState)
} else {
// The queue is not empty, so add the action to the end of the queue
// It will be started by finishActions after the previous action finishes
let last = actionQueue.pending
while (last.next !== null) {
last = last.next
}
last.next = newAction
}
}

let actionQueue: AppRouterActionQueue = {
state: null,
dispatch: (
payload: any,
setState: React.Dispatch<Promise<AppRouterState | null>>
) => dispatchAction(actionQueue, payload, setState),
action: async (state: AppRouterState | null, action: ReducerActions) => {
if (state === null) throw new Error('Missing state')
const result = reducer(state, action)
return result
},
pending: null,
}

const getCacheKey = () => {
const { pathname, search } = location
return pathname + search
Expand Down Expand Up @@ -230,7 +334,9 @@ export function hydrate() {
}}
>
<Root>
<RSCComponent />
<ActionQueueContext.Provider value={actionQueue}>
<RSCComponent />
</ActionQueueContext.Provider>
</Root>
</HeadManagerContext.Provider>
</StrictModeIfEnabled>
Expand Down
44 changes: 29 additions & 15 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import type { ReactNode } from 'react'
import type { ReactNode, Usable } from 'react'
import React, {
use,
useEffect,
Expand Down Expand Up @@ -35,6 +35,7 @@ import {
PrefetchKind,
} from './router-reducer/router-reducer-types'
import type {
AppRouterState,
ReducerActions,
RouterChangeByServerResponse,
RouterNavigate,
Expand Down Expand Up @@ -75,6 +76,23 @@ const globalMutable: {
pendingMpaPath?: string
} = {}

function isThenable(value: any): value is Promise<any> {
return (
value &&
(typeof value === 'object' || typeof value === 'function') &&
typeof value.then === 'function'
)
}

function useUnwrapState(state: AppRouterState | Usable<AppRouterState>) {
if (isThenable(state)) {
const result = use(state)
return result
}

return state
}

export function urlToUrlWithoutFlightMarker(url: string): URL {
const urlWithoutFlightParameters = new URL(url, location.origin)
urlWithoutFlightParameters.searchParams.delete(NEXT_RSC_UNION_QUERY)
Expand Down Expand Up @@ -226,25 +244,15 @@ function Router({
}),
[buildId, children, initialCanonicalUrl, initialTree, initialHead]
)
const [
{
tree,
cache,
prefetchCache,
pushRef,
focusAndScrollRef,
canonicalUrl,
nextUrl,
},
dispatch,
sync,
] = useReducerWithReduxDevtools(initialState)
const [reducerState, dispatch, sync] =
useReducerWithReduxDevtools(initialState)

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

const { canonicalUrl } = useUnwrapState(reducerState)
// Add memoized pathname/query for useSearchParams and usePathname.
const { searchParams, pathname } = useMemo(() => {
const url = new URL(
Expand Down Expand Up @@ -354,6 +362,9 @@ function Router({
}, [appRouter])

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { cache, prefetchCache, tree } = useUnwrapState(reducerState)

// This hook is in a conditional but that is ok because `process.env.NODE_ENV` never changes
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
Expand Down Expand Up @@ -401,10 +412,10 @@ function Router({
// probably safe because we know this is a singleton component and it's never
// in <Offscreen>. At least I hope so. (It will run twice in dev strict mode,
// but that's... fine?)
const { pushRef } = useUnwrapState(reducerState)
if (pushRef.mpaNavigation) {
// if there's a re-render, we don't want to trigger another redirect if one is already in flight to the same URL
if (globalMutable.pendingMpaPath !== canonicalUrl) {
console.log('push to', pushRef)
const location = window.location
if (pushRef.pendingPush) {
location.assign(canonicalUrl)
Expand Down Expand Up @@ -460,6 +471,9 @@ function Router({
}
}, [onPopState])

const { cache, tree, nextUrl, focusAndScrollRef } =
useUnwrapState(reducerState)

const head = useMemo(() => {
return findHeadInCache(cache, tree[1])
}, [cache, tree])
Expand Down
93 changes: 27 additions & 66 deletions packages/next/src/client/components/use-flight-router-state.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import { reducer } from './router-reducer/router-reducer'
import type { MutableRefObject, Dispatch } from 'react'
import React, { use } from 'react'
import type { Dispatch, Usable } from 'react'
import React, { useContext } from 'react'
import { useRef, useEffect, useCallback } from 'react'
import type {
AppRouterState,
ReducerActions,
ReducerState,
} from './router-reducer/router-reducer-types'
import {
flightRouterState,
updateFlightRouterState,
} from '../../shared/lib/app-router-context.shared-runtime'
import { ActionQueueContext } from '../../shared/lib/app-router-context.shared-runtime'

function normalizeRouterState(val: any): any {
if (val instanceof Map) {
Expand Down Expand Up @@ -88,57 +83,33 @@ declare global {
}
}

interface ReduxDevToolsInstance {
export interface ReduxDevToolsInstance {
send(action: any, state: any): void
init(initialState: any): void
}

function useReducerWithReduxDevtoolsNoop(
initialState: AppRouterState
): [AppRouterState, Dispatch<ReducerActions>, () => void] {
): [
Usable<AppRouterState> | AppRouterState,
Dispatch<ReducerActions>,
() => void
] {
return [initialState, () => {}, () => {}]
}

function dispatchWithDevtools(
action: ReducerActions,
ref: MutableRefObject<ReduxDevToolsInstance | undefined>
) {
if (!flightRouterState) throw new Error('Missing state')
const result = reducer(flightRouterState, action)

if (ref.current) {
ref.current.send(action, normalizeRouterState(result))
}

return result
}

function isThenable(value: any): value is Promise<any> {
return (
value &&
(typeof value === 'object' || typeof value === 'function') &&
typeof value.then === 'function'
)
}

function unwrapStateIfNeeded(state: AppRouterState | Promise<AppRouterState>) {
if (isThenable(state)) {
const result = use(state)
updateFlightRouterState(result)
return result
}

updateFlightRouterState(state)
return state
}

function useReducerWithReduxDevtoolsImpl(
initialState: AppRouterState
): [AppRouterState, Dispatch<ReducerActions>, () => void] {
): [
Usable<AppRouterState> | AppRouterState,
Dispatch<ReducerActions>,
() => void
] {
const [state, setState] = React.useState<
AppRouterState | Promise<AppRouterState>
Usable<AppRouterState> | AppRouterState
>(initialState)

const actionQueue = useContext(ActionQueueContext)
const devtoolsConnectionRef = useRef<ReduxDevToolsInstance>()
const enabledRef = useRef<boolean>()

Expand All @@ -163,34 +134,27 @@ function useReducerWithReduxDevtoolsImpl(
)
if (devtoolsConnectionRef.current) {
devtoolsConnectionRef.current.init(normalizeRouterState(initialState))

if (actionQueue) {
actionQueue.devToolsInstance = devtoolsConnectionRef.current
}
}

return () => {
devtoolsConnectionRef.current = undefined
}
}, [initialState])

const lastPromiseRef = useRef<ReducerState>()

const dispatchAction = useCallback((action: ReducerActions) => {
const result = dispatchWithDevtools(action, devtoolsConnectionRef)
setState(result)
return result
}, [])
}, [initialState, actionQueue])

const dispatch = useCallback(
(action: ReducerActions) => {
if (
isThenable(lastPromiseRef.current) &&
(action.type === 'refresh' || action.type === 'fast-refresh')
) {
// don't refresh if another action is in flight (is this a good idea?)
return
if (!actionQueue) return
if (!actionQueue.state) {
actionQueue.state = initialState
}

lastPromiseRef.current = dispatchAction(action)
actionQueue.dispatch(action, setState)
},
[dispatchAction]
[actionQueue, initialState]
)

const sync = useCallback(() => {
Expand All @@ -202,10 +166,7 @@ function useReducerWithReduxDevtoolsImpl(
}
}, [state])

const applicationState = unwrapStateIfNeeded(state)
lastPromiseRef.current = undefined

return [applicationState, dispatch, sync]
return [state, dispatch, sync]
}

export const useReducerWithReduxDevtools =
Expand Down
Loading

0 comments on commit 4eea5cf

Please sign in to comment.