Skip to content

Commit

Permalink
Handle link with back/forward navigation (#37332)
Browse files Browse the repository at this point in the history
  • Loading branch information
timneutkens authored Jun 1, 2022
1 parent 5384171 commit 647c93e
Show file tree
Hide file tree
Showing 10 changed files with 183 additions and 69 deletions.
12 changes: 11 additions & 1 deletion packages/next/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,11 +432,13 @@ export function finalizeEntrypoint({
compilerType,
value,
isServerComponent,
appDir,
}: {
compilerType?: 'client' | 'server' | 'edge-server'
name: string
value: ObjectValue<webpack5.EntryObject>
isServerComponent?: boolean
appDir?: boolean
}): ObjectValue<webpack5.EntryObject> {
const entry =
typeof value !== 'object' || Array.isArray(value)
Expand Down Expand Up @@ -471,11 +473,19 @@ export function finalizeEntrypoint({
name !== CLIENT_STATIC_FILES_RUNTIME_AMP &&
name !== CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH
) {
// TODO: this is a temporary fix. @shuding is going to change the handling of server components
if (appDir && entry.import.includes('flight')) {
return {
dependOn: CLIENT_STATIC_FILES_RUNTIME_MAIN_ROOT,
...entry,
}
}

return {
dependOn:
name.startsWith('pages/') && name !== 'pages/_app'
? 'pages/_app'
: 'main',
: CLIENT_STATIC_FILES_RUNTIME_MAIN,
...entry,
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/next/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2289,6 +2289,7 @@ export default async function getBaseWebpackConfig(
value: entry[name],
compilerType,
name,
appDir: config.experimental.appDir,
})
}

Expand Down
1 change: 1 addition & 0 deletions packages/next/build/webpack/loaders/next-app-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{
};
export const AppRouter = require('next/dist/client/components/app-router.client.js').default
export const LayoutRouter = require('next/dist/client/components/layout-router.client.js').default
export const __next_app_webpack_require__ = __webpack_require__
`
Expand Down
7 changes: 7 additions & 0 deletions packages/next/client/app-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-we

export const version = process.env.__NEXT_VERSION

// History replace has to happen on bootup to ensure `state` is always populated in popstate event
window.history.replaceState(
{ url: window.location.toString() },
'',
window.location.toString()
)

const appElement: HTMLElement | Document | null = document

let reactRoot: any = null
Expand Down
77 changes: 32 additions & 45 deletions packages/next/client/components/app-router.client.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,61 @@
import React from 'react'
import { createFromFetch } from 'next/dist/compiled/react-server-dom-webpack'
import { AppRouterContext } from '../../shared/lib/app-router-context'
import useRouter from './userouter.js'

function createResponseCache() {
return new Map<string, any>()
}
const rscCache = createResponseCache()

const getCacheKey = () => {
const { pathname, search } = location
return pathname + search
}

function fetchFlight(href: string) {
const url = new URL(href, location.origin)
const searchParams = url.searchParams
function fetchFlight(href: string, layoutPath?: string) {
const flightUrl = new URL(href, location.origin.toString())
const searchParams = flightUrl.searchParams
searchParams.append('__flight__', '1')
if (layoutPath) {
searchParams.append('__flight_router_path__', layoutPath)
}

return fetch(url.toString())
return fetch(flightUrl.toString())
}

function fetchServerResponse(cacheKey: string) {
export function fetchServerResponse(href: string, layoutPath?: string) {
const cacheKey = href + layoutPath
let response = rscCache.get(cacheKey)
if (response) return response

response = createFromFetch(fetchFlight(getCacheKey()))
response = createFromFetch(fetchFlight(href, layoutPath))

rscCache.set(cacheKey, response)
return response
}

// TODO: move to client component when handling is implemented
export default function AppRouter({ initialUrl, children }: any) {
const initialState = {
url: initialUrl,
}
const previousUrlRef = React.useRef(initialState)
const [current, setCurrent] = React.useState(initialState)
const appRouter = React.useMemo(() => {
return {
prefetch: () => {},
replace: (url: string) => {
previousUrlRef.current = current
setCurrent({ ...current, url })
// TODO: update url eagerly or not?
window.history.replaceState(current, '', url)
},
push: (url: string) => {
previousUrlRef.current = current
setCurrent({ ...current, url })
// TODO: update url eagerly or not?
window.history.pushState(current, '', url)
},
url: current.url,
export default function AppRouter({ initialUrl, layoutPath, children }: any) {
const [appRouter, previousUrlRef, current] = useRouter(initialUrl)

const onPopState = React.useCallback(
({ state }: PopStateEvent) => {
if (!state) {
return
}
// @ts-ignore useTransition exists
// TODO: Ideally the back button should not use startTransition as it should apply the updates synchronously
React.startTransition(() => appRouter.replace(state.url))
},
[appRouter]
)
React.useEffect(() => {
window.addEventListener('popstate', onPopState)
return () => {
window.removeEventListener('popstate', onPopState)
}
}, [current])
if (typeof window !== 'undefined') {
// @ts-ignore TODO: for testing
window.appRouter = appRouter
console.log({
appRouter,
previous: previousUrlRef.current,
current,
})
}
})

let root
// TODO: Check the RSC cache first for the page you want to navigate to
if (current.url !== previousUrlRef.current?.url) {
// eslint-disable-next-line
const data = fetchServerResponse(current.url)
const data = fetchServerResponse(current.url, layoutPath)
root = data.readRoot()
}
return (
Expand Down
30 changes: 30 additions & 0 deletions packages/next/client/components/layout-router.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react'
import { AppRouterContext } from '../../shared/lib/app-router-context'
import { fetchServerResponse } from './app-router.client.js'
import useRouter from './userouter.js'

// TODO:
// What is the next segment for this router?
// What is the parallel router (for lookup in history state)?
// If you have children or another prop name for parallel router does not exist because it has not seen before
// Use the props as the initial value to fill in the history state, this would cause a replaceState to update the tree
// Should probably be batched (wrapper around history)
export default function LayoutRouter({
initialUrl,
layoutPath,
children,
}: any) {
const [appRouter, previousUrlRef, current] = useRouter(initialUrl)
let root
if (current.url !== previousUrlRef.current?.url) {
// eslint-disable-next-line
const data = fetchServerResponse(current.url, layoutPath)
root = data.readRoot()
// TODO: handle case where middleware rewrites to another page
}
return (
<AppRouterContext.Provider value={appRouter}>
{root ? root : children}
</AppRouterContext.Provider>
)
}
38 changes: 38 additions & 0 deletions packages/next/client/components/userouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react'

// TODO: Handle case where current layout path does not handle navigation
export default function useRouter(initialUrl: string): any {
const initialState = {
url: initialUrl,
}
const previousUrlRef = React.useRef(initialState)
const [current, setCurrent] = React.useState(initialState)
const change = React.useCallback(
(method: 'replaceState' | 'pushState', url: string) => {
// @ts-ignore startTransition exists
React.startTransition(() => {
previousUrlRef.current = current
const state = { ...current, url }
setCurrent(state)
// TODO: update url eagerly or not?
window.history[method](state, '', url)
})
},
[current]
)
const appRouter = React.useMemo(() => {
return {
// TODO: implement prefetching of loading / flight
prefetch: () => Promise.resolve({}),
replace: (url: string) => {
return change('replaceState', url)
},
push: (url: string) => {
return change('pushState', url)
},
url: current.url,
}
}, [current, change])

return [appRouter, previousUrlRef, current, change]
}
45 changes: 37 additions & 8 deletions packages/next/client/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { useIntersection } from './use-intersection'
import { getDomainLocale } from './get-domain-locale'
import { addBasePath } from './add-base-path'

// @ts-ignore useTransition exist
const hasUseTransition = typeof React.useTransition !== 'undefined'

type Url = string | UrlObject
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K
Expand Down Expand Up @@ -98,7 +101,8 @@ function linkClicked(
replace?: boolean,
shallow?: boolean,
scroll?: boolean,
locale?: string | false
locale?: string | false,
startTransition?: (cb: any) => void
): void {
const { nodeName } = e.currentTarget

Expand All @@ -112,12 +116,20 @@ function linkClicked(

e.preventDefault()

// replace state instead of push if prop is present
router[replace ? 'replace' : 'push'](href, as, {
shallow,
locale,
scroll,
})
const navigate = () => {
// replace state instead of push if prop is present
router[replace ? 'replace' : 'push'](href, as, {
shallow,
locale,
scroll,
})
}

if (startTransition) {
startTransition(navigate)
} else {
navigate()
}
}

type LinkPropsReal = React.PropsWithChildren<
Expand Down Expand Up @@ -268,6 +280,13 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
}

const p = prefetchProp !== false
const [, /* isPending */ startTransition] = hasUseTransition
? // Rules of hooks is disabled here because the useTransition will always exist with React 18.
// There is no difference between renders in this case, only between using React 18 vs 17.
// @ts-ignore useTransition exists
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useTransition()
: []
let router = React.useContext(RouterContext)

const appRouter = React.useContext(AppRouterContext)
Expand Down Expand Up @@ -387,7 +406,17 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
child.props.onClick(e)
}
if (!e.defaultPrevented) {
linkClicked(e, router, href, as, replace, shallow, scroll, locale)
linkClicked(
e,
router,
href,
as,
replace,
shallow,
scroll,
locale,
appRouter ? startTransition : undefined
)
}
},
onMouseEnter: (e: React.MouseEvent) => {
Expand Down
37 changes: 22 additions & 15 deletions packages/next/server/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,14 +229,15 @@ export async function renderToHTML(
if (flightRouterPath) {
// TODO: check the actual path
const pathLength = path.length
return pathLength >= flightRouterPath.length
return pathLength > flightRouterPath.length
}
return true
})
.sort()
.map((path) => {
const mod = ComponentMod.components[path]()
mod.Component = mod.default || mod
mod.path = path
return mod
})

Expand Down Expand Up @@ -305,6 +306,7 @@ export async function renderToHTML(
preloadDataFetchingRecord(dataCache, dataCacheKey, fetcher)
}

const LayoutRouter = ComponentMod.LayoutRouter
// eslint-disable-next-line no-loop-func
const lastComponent = WrappedComponent
WrappedComponent = (props: any) => {
Expand All @@ -326,22 +328,23 @@ export async function renderToHTML(
}
}

// if this is the root layout pass children as children prop
if (!isSubtreeRender && i === 0) {
return React.createElement(layout.Component, {
...props,
children: React.createElement(
lastComponent || React.Fragment,
{},
null
),
})
}

const children = React.createElement(
lastComponent || React.Fragment,
{},
null
)
// Pages don't need to be wrapped in a router
return React.createElement(
layout.Component,
props,
React.createElement(lastComponent || React.Fragment, {}, null)
layout.path.endsWith('/page') ? (
children
) : (
// TODO: only provide the part of the url that is relevant to the layout (see layout-router.client.tsx)
<LayoutRouter initialUrl={pathname} layoutPath={layout.path}>
{children}
</LayoutRouter>
)
)
}
// TODO: loading state
Expand All @@ -357,8 +360,12 @@ export async function renderToHTML(

const AppRouter = ComponentMod.AppRouter
const WrappedComponentWithRouter = () => {
if (flightRouterPath) {
return <WrappedComponent />
}
return (
<AppRouter initialUrl={req.url}>
// TODO: verify pathname passed is correct
<AppRouter initialUrl={pathname}>
<WrappedComponent />
</AppRouter>
)
Expand Down
Loading

0 comments on commit 647c93e

Please sign in to comment.