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

fix: improved hybrid hook support #41611

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/next/client/components/app-navigation.ts
@@ -0,0 +1,21 @@
import { useContext } from 'react'
import {
AppRouterContext,
AppRouterInstance,
} from '../../shared/lib/app-router-context'

/**
* useAppRouter will get the AppRouterInstance on the context if it's mounted.
* If it is not mounted, it will throw an error. This method should only be used
* when you expect only to have the app router mounted (not pages router).
*
* @returns the app router instance
*/
export function useAppRouter(): AppRouterInstance {
const router = useContext(AppRouterContext)
if (!router) {
throw new Error('invariant expected app router to be mounted')
}

return router
}
4 changes: 2 additions & 2 deletions packages/next/client/components/hooks-client-context.ts
@@ -1,7 +1,7 @@
import { createContext } from 'react'

export const SearchParamsContext = createContext<URLSearchParams>(null as any)
export const PathnameContext = createContext<string>(null as any)
export const SearchParamsContext = createContext<URLSearchParams | null>(null)
export const PathnameContext = createContext<string | null>(null)
export const ParamsContext = createContext(null as any)
export const LayoutSegmentsContext = createContext(null as any)

Expand Down
16 changes: 16 additions & 0 deletions packages/next/client/components/hybrid-router.ts
@@ -0,0 +1,16 @@
import { AppRouterInstance } from '../../shared/lib/app-router-context'
import { NextRouter } from '../router'

export const HYBRID_ROUTER_TYPE = Symbol('HYBRID_ROUTER_TYPE')

type MaskedHybridRouter<T extends string, Router, OtherRouter> = {
// Store the router type on the router via this private symbol.
[HYBRID_ROUTER_TYPE]: T
} & Router &
// Add partial fields of the other router so it's type-compatible with it, but
// it will show those extra fields as undefined.
Partial<Omit<OtherRouter, keyof Router>>

export type HybridRouter =
| MaskedHybridRouter<'app', AppRouterInstance, NextRouter>
| MaskedHybridRouter<'pages', NextRouter, AppRouterInstance>
6 changes: 3 additions & 3 deletions packages/next/client/components/layout-router.tsx
Expand Up @@ -25,12 +25,12 @@ import {
LayoutRouterContext,
GlobalLayoutRouterContext,
TemplateContext,
AppRouterContext,
} from '../../shared/lib/app-router-context'
import { fetchServerResponse } from './app-router'
import { createInfinitePromise } from './infinite-promise'
import { ErrorBoundary } from './error-boundary'
import { matchSegment } from './match-segments'
import { useAppRouter } from './app-navigation'

/**
* Add refetch marker to router state at the point of the current layout segment.
Expand Down Expand Up @@ -279,7 +279,7 @@ interface RedirectBoundaryProps {
}

function HandleRedirect({ redirect }: { redirect: string }) {
const router = useContext(AppRouterContext)
const router = useAppRouter()

useEffect(() => {
router.replace(redirect, {})
Expand Down Expand Up @@ -316,7 +316,7 @@ class RedirectErrorBoundary extends React.Component<
}

function RedirectBoundary({ children }: { children: React.ReactNode }) {
const router = useContext(AppRouterContext)
const router = useAppRouter()
return (
<RedirectErrorBoundary router={router}>{children}</RedirectErrorBoundary>
)
Expand Down
82 changes: 76 additions & 6 deletions packages/next/client/components/navigation.ts
Expand Up @@ -12,6 +12,9 @@ import {
AppRouterContext,
LayoutRouterContext,
} from '../../shared/lib/app-router-context'
import { RouterContext } from '../../shared/lib/router-context'
import type { ParsedUrlQuery } from 'querystring'
import { HybridRouter, HYBRID_ROUTER_TYPE } from './hybrid-router'

export {
ServerInsertedHTMLContext,
Expand Down Expand Up @@ -69,24 +72,80 @@ class ReadonlyURLSearchParams {
}
}

/**
* parsedURLQueryToURLSearchParams converts a parsed url query to a url search
* params object.
*
* @param query parsed url query
* @returns url search params object
*/
function parsedURLQueryToURLSearchParams(
query: ParsedUrlQuery
): URLSearchParams {
return new URLSearchParams(
Object.keys(query).reduce<[string, string][]>((acc, name) => {
const value = query[name]
if (Array.isArray(value)) {
acc.push(...value.map<[string, string]>((v) => [name, v]))
} else {
acc.push([name, value])
}

return acc
}, [])
)
}

/**
* Get a read-only URLSearchParams object. For example searchParams.get('foo') would return 'bar' when ?foo=bar
* Learn more about URLSearchParams here: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
*/
export function useSearchParams() {
const router = useContext(RouterContext)
const searchParams = useContext(SearchParamsContext)

const readonlySearchParams = useMemo(() => {
return new ReadonlyURLSearchParams(searchParams)
}, [searchParams])
// To support migration from pages to app, this adds a workaround that'll
// support the pages router here too.
if (router) {
if (!router.isReady) {
return new ReadonlyURLSearchParams(new URLSearchParams())
}

return new ReadonlyURLSearchParams(
parsedURLQueryToURLSearchParams(router.query)
)
}

if (searchParams) {
return new ReadonlyURLSearchParams(searchParams)
}

throw new Error('invariant at least one router was expected')
}, [router, searchParams])

return readonlySearchParams
}

// TODO-APP: Move the other router context over to this one
/**
* Get the router methods. For example router.push('/dashboard')
*/
export function useRouter(): import('../../shared/lib/app-router-context').AppRouterInstance {
return useContext(AppRouterContext)
export function useRouter(): HybridRouter {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way I was thinking about this is that this always returns a AppRouterInstance, we'd just set AppRouterContext for the pages router too with it's own AppRouterInstance object that under the hood calls the existing router. Does that make sense? It would keep the type the same regardless and makes migrating easier.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense! I can look into making these changes.

const router = useContext(RouterContext)
const appRouter = useContext(AppRouterContext)

return useMemo(() => {
if (router) {
return { [HYBRID_ROUTER_TYPE]: 'pages', ...router }
}

if (appRouter) {
return { [HYBRID_ROUTER_TYPE]: 'app', ...appRouter }
}

throw new Error('invariant at least one router was expected')
}, [router, appRouter])
}

// TODO-APP: getting all params when client-side navigating is non-trivial as it does not have route matchers so this might have to be a server context instead.
Expand All @@ -97,8 +156,19 @@ export function useRouter(): import('../../shared/lib/app-router-context').AppRo
/**
* Get the current pathname. For example usePathname() on /dashboard?foo=bar would return "/dashboard"
*/
export function usePathname(): string {
return useContext(PathnameContext)
export function usePathname(): string | null {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should throw in the null case. I'd prefer it to always be a string as that's how it's supposed to be in the new router.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we change it to throws then during migration, this would cause any component using it from pages to throw too. This sort of defeats the purpose for the hybrid approach.

const router = useContext(RouterContext)
const pathname = useContext(PathnameContext)

if (router) {
if (router.isReady) {
return router.asPath
}

return null
}

return pathname
}

// TODO-APP: define what should be provided through context.
Expand Down
Expand Up @@ -9,7 +9,6 @@ import React, {
} from 'react'
import stripAnsi from 'next/dist/compiled/strip-ansi'
import formatWebpackMessages from '../../dev/error-overlay/format-webpack-messages'
import { useRouter } from '../navigation'
import { errorOverlayReducer } from './internal/error-overlay-reducer'
import {
ACTION_BUILD_OK,
Expand All @@ -26,6 +25,7 @@ import {
useWebsocket,
useWebsocketPing,
} from './internal/helpers/use-websocket'
import { useAppRouter } from '../app-navigation'

interface Dispatcher {
onBuildOk(): void
Expand Down Expand Up @@ -174,7 +174,7 @@ function tryApplyUpdates(
function processMessage(
e: any,
sendMessage: any,
router: ReturnType<typeof useRouter>,
router: ReturnType<typeof useAppRouter>,
dispatcher: Dispatcher
) {
const obj = JSON.parse(e.data)
Expand Down Expand Up @@ -442,7 +442,7 @@ export default function HotReload({
useWebsocketPing(webSocketRef)
const sendMessage = useSendMessage(webSocketRef)

const router = useRouter()
const router = useAppRouter()
useEffect(() => {
const handler = (event: MessageEvent<PongEvent>) => {
if (
Expand Down
36 changes: 11 additions & 25 deletions packages/next/client/link.tsx
Expand Up @@ -4,19 +4,15 @@ import React from 'react'
import { UrlObject } from 'url'
import {
isLocalURL,
NextRouter,
PrefetchOptions,
resolveHref,
} from '../shared/lib/router/router'
import { addLocale } from './add-locale'
import { RouterContext } from '../shared/lib/router-context'
import {
AppRouterContext,
AppRouterInstance,
} from '../shared/lib/app-router-context'
import { useIntersection } from './use-intersection'
import { getDomainLocale } from './get-domain-locale'
import { addBasePath } from './add-base-path'
import { useRouter } from './components/navigation'
import { HybridRouter, HYBRID_ROUTER_TYPE } from './components/hybrid-router'

type Url = string | UrlObject
type RequiredKeys<T> = {
Expand Down Expand Up @@ -108,7 +104,7 @@ type LinkPropsOptional = OptionalKeys<InternalLinkProps>
const prefetched: { [cacheKey: string]: boolean } = {}

function prefetch(
router: NextRouter,
router: HybridRouter,
href: string,
as: string,
options?: PrefetchOptions
Expand Down Expand Up @@ -148,7 +144,7 @@ function isModifiedEvent(event: React.MouseEvent): boolean {

function linkClicked(
e: React.MouseEvent,
router: NextRouter | AppRouterInstance,
router: HybridRouter,
href: string,
as: string,
replace?: boolean,
Expand All @@ -171,19 +167,14 @@ function linkClicked(
e.preventDefault()

const navigate = () => {
// If the router is an NextRouter instance it will have `beforePopState`
if ('beforePopState' in router) {
if (router[HYBRID_ROUTER_TYPE] === 'pages') {
router[replace ? 'replace' : 'push'](href, as, {
shallow,
locale,
scroll,
})
} else {
// If `beforePopState` doesn't exist on the router it's the AppRouter.
const method: keyof AppRouterInstance = replace ? 'replace' : 'push'

// Apply `as` if it's provided.
router[method](as || href, {
router[replace ? 'replace' : 'push'](as || href, {
forceOptimisticNavigation: !prefetchEnabled,
})
}
Expand Down Expand Up @@ -357,13 +348,8 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
}

const p = prefetchProp !== false
let router = React.useContext(RouterContext)

// TODO-APP: type error. Remove `as any`
const appRouter = React.useContext(AppRouterContext) as any
if (appRouter) {
router = appRouter
}
const router = useRouter()
const isAppRouter = router[HYBRID_ROUTER_TYPE] === 'app'

const { href, as } = React.useMemo(() => {
const [resolvedHref, resolvedAs] = resolveHref(router, hrefProp, true)
Expand Down Expand Up @@ -487,7 +473,7 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
shallow,
scroll,
locale,
Boolean(appRouter),
isAppRouter,
p
)
}
Expand All @@ -505,7 +491,7 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
}

// Check for not prefetch disabled in page using appRouter
if (!(!p && appRouter)) {
if (!(!p && isAppRouter)) {
if (isLocalURL(href)) {
prefetch(router, href, as, { priority: true })
}
Expand All @@ -525,7 +511,7 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
}

// Check for not prefetch disabled in page using appRouter
if (!(!p && appRouter)) {
if (!(!p && isAppRouter)) {
if (isLocalURL(href)) {
prefetch(router, href, as, { priority: true })
}
Expand Down
7 changes: 6 additions & 1 deletion packages/next/client/route-announcer.tsx
Expand Up @@ -17,7 +17,12 @@ const nextjsRouteAnnouncerStyles: React.CSSProperties = {
}

export const RouteAnnouncer = () => {
const { asPath } = useRouter()
const router = useRouter()
if (!router) {
throw new Error('invariant expected pages router to be mounted')
}

const { asPath } = router
const [routeAnnouncement, setRouteAnnouncement] = React.useState('')

// Only announce the path change, but not for the first load because screen
Expand Down
2 changes: 1 addition & 1 deletion packages/next/client/router.ts
Expand Up @@ -129,7 +129,7 @@ export default singletonRouter as SingletonRouter
// Reexport the withRoute HOC
export { default as withRouter } from './with-router'

export function useRouter(): NextRouter {
export function useRouter(): NextRouter | null {
return React.useContext(RouterContext)
}

Expand Down
4 changes: 2 additions & 2 deletions packages/next/shared/lib/app-router-context.ts
Expand Up @@ -57,8 +57,8 @@ export interface AppRouterInstance {
prefetch(href: string): void
}

export const AppRouterContext = React.createContext<AppRouterInstance>(
null as any
export const AppRouterContext = React.createContext<AppRouterInstance | null>(
null
)
export const LayoutRouterContext = React.createContext<{
childNodes: CacheNode['parallelRoutes']
Expand Down
2 changes: 1 addition & 1 deletion packages/next/shared/lib/router-context.ts
@@ -1,7 +1,7 @@
import React from 'react'
import type { NextRouter } from './router/router'

export const RouterContext = React.createContext<NextRouter>(null as any)
export const RouterContext = React.createContext<NextRouter | null>(null)

if (process.env.NODE_ENV !== 'production') {
RouterContext.displayName = 'RouterContext'
Expand Down