Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle link with back/forward navigation #37332

Merged
merged 13 commits into from
Jun 1, 2022
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))
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
},
[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(() => {
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
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