Skip to content

Commit

Permalink
feat: backwards compatible router-dep hook support
Browse files Browse the repository at this point in the history
  • Loading branch information
wyattjoh committed Oct 21, 2022
1 parent 499ab59 commit 6a091a5
Show file tree
Hide file tree
Showing 8 changed files with 142 additions and 47 deletions.
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
}
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
81 changes: 69 additions & 12 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,32 +72,76 @@ 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(() => {
if (!searchParams) {
// TODO-APP: consider throwing an error or adapting this to support pages router
return null
// To support migration from pages to app, this adds a workaround that'll
// support the pages router here too.
if (router) {
return new ReadonlyURLSearchParams(
parsedURLQueryToURLSearchParams(router.query)
)
}

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

return new ReadonlyURLSearchParams(searchParams)
}, [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
| null {
// TODO-APP: consider throwing an error or adapting this to support pages router
return useContext(AppRouterContext)
export function useRouter(): HybridRouter {
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 @@ -106,8 +153,18 @@ export function useRouter():
* Get the current pathname. For example usePathname() on /dashboard?foo=bar would return "/dashboard"
*/
export function usePathname(): string | null {
// TODO-APP: consider throwing an error or adapting this to support pages router
return useContext(PathnameContext)
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 @@ -8,7 +8,10 @@ import React, {
// @ts-expect-error TODO-APP: startTransition exists
startTransition,
} from 'react'
import { GlobalLayoutRouterContext } from '../../../shared/lib/app-router-context'
import {
AppRouterInstance,
GlobalLayoutRouterContext,
} from '../../../shared/lib/app-router-context'
import {
onBuildError,
onBuildOk,
Expand All @@ -20,11 +23,11 @@ import {
import type { DispatchFn } from './client'
import stripAnsi from 'next/dist/compiled/strip-ansi'
import formatWebpackMessages from '../../dev/error-overlay/format-webpack-messages'
import { useRouter } from '../navigation'
import {
errorOverlayReducer,
OverlayState,
} from './internal/error-overlay-reducer'
import { useAppRouter } from '../app-navigation'

function getSocketProtocol(assetPrefix: string): string {
let protocol = window.location.protocol
Expand Down Expand Up @@ -206,7 +209,7 @@ function tryApplyUpdates(
function processMessage(
e: any,
sendMessage: any,
router: ReturnType<typeof useRouter>,
router: AppRouterInstance,
dispatch: DispatchFn
) {
const obj = JSON.parse(e.data)
Expand Down Expand Up @@ -466,7 +469,7 @@ export default function HotReload({
const { tree } = useContext(GlobalLayoutRouterContext) ?? {
tree: initialTree,
}
const router = useRouter()
const router = useAppRouter()

const webSocketRef = useRef<WebSocket>()
const sendMessage = useCallback((data) => {
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
11 changes: 9 additions & 2 deletions packages/next/shared/lib/router/router.ts
Expand Up @@ -48,6 +48,7 @@ import { getNextPathnameInfo } from './utils/get-next-pathname-info'
import { formatNextPathnameInfo } from './utils/format-next-pathname-info'
import { compareRouterStates } from './utils/compare-states'
import { isBot } from './utils/is-bot'
import { AppRouterInstance } from '../app-router-context'

declare global {
interface Window {
Expand Down Expand Up @@ -220,7 +221,7 @@ export function interpolateAs(
* Preserves absolute urls.
*/
export function resolveHref(
router: NextRouter,
router: NextRouter | AppRouterInstance,
href: Url,
resolveAs?: boolean
): string {
Expand Down Expand Up @@ -252,7 +253,13 @@ export function resolveHref(

try {
base = new URL(
urlAsString.startsWith('#') ? router.asPath : router.pathname,
// TODO-APP: investigate if this is the intended beheviour
'asPath' in router
? urlAsString.startsWith('#')
? router.asPath
: router.pathname
: // Emulate the fallback in the catch below
'/',
'http://n'
)
} catch (_) {
Expand Down

0 comments on commit 6a091a5

Please sign in to comment.