diff --git a/.eslintrc.json b/.eslintrc.json index 8eec065efcf790b..21089d04c71aa8f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -238,7 +238,6 @@ "no-obj-calls": "warn", "no-octal": "warn", "no-octal-escape": "warn", - "no-redeclare": ["warn", { "builtinGlobals": false }], "no-regex-spaces": "warn", "no-restricted-syntax": [ "warn", @@ -330,6 +329,10 @@ "react/style-prop-object": "warn", "react-hooks/rules-of-hooks": "error", // "@typescript-eslint/non-nullable-type-assertion-style": "warn", - "@typescript-eslint/prefer-as-const": "warn" + "@typescript-eslint/prefer-as-const": "warn", + "@typescript-eslint/no-redeclare": [ + "warn", + { "builtinGlobals": false, "ignoreDeclarationMerge": true } + ] } } diff --git a/packages/next/client/components/app-router.tsx b/packages/next/client/components/app-router.tsx index e0b2c964911f083..a1ed740d1094f62 100644 --- a/packages/next/client/components/app-router.tsx +++ b/packages/next/client/components/app-router.tsx @@ -26,7 +26,7 @@ import { // ParamsContext, PathnameContext, // LayoutSegmentsContext, -} from './hooks-client-context' +} from '../../shared/lib/hooks-client-context' import { useReducerWithReduxDevtools } from './use-reducer-with-devtools' import { ErrorBoundary, GlobalErrorComponent } from './error-boundary' diff --git a/packages/next/client/components/layout-router.tsx b/packages/next/client/components/layout-router.tsx index 5a4e27caa5bc061..dc8cb0c5139a0cf 100644 --- a/packages/next/client/components/layout-router.tsx +++ b/packages/next/client/components/layout-router.tsx @@ -19,12 +19,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 { useRouter } from './navigation' /** * Add refetch marker to router state at the point of the current layout segment. @@ -109,11 +109,13 @@ export function InnerLayoutRouter({ path: string rootLayoutIncluded: boolean }) { - const { - changeByServerResponse, - tree: fullTree, - focusAndScrollRef, - } = useContext(GlobalLayoutRouterContext) + const context = useContext(GlobalLayoutRouterContext) + if (!context) { + throw new Error('invariant global layout router not mounted') + } + + const { changeByServerResponse, tree: fullTree, focusAndScrollRef } = context + const focusAndScrollElementRef = useRef(null) useEffect(() => { @@ -275,7 +277,7 @@ interface RedirectBoundaryProps { } function HandleRedirect({ redirect }: { redirect: string }) { - const router = useContext(AppRouterContext) + const router = useRouter() useEffect(() => { router.replace(redirect, {}) @@ -312,7 +314,7 @@ class RedirectErrorBoundary extends React.Component< } function RedirectBoundary({ children }: { children: React.ReactNode }) { - const router = useContext(AppRouterContext) + const router = useRouter() return ( {children} ) @@ -389,7 +391,12 @@ export default function OuterLayoutRouter({ notFound: React.ReactNode | undefined rootLayoutIncluded: boolean }) { - const { childNodes, tree, url } = useContext(LayoutRouterContext) + const context = useContext(LayoutRouterContext) + if (!context) { + throw new Error('invariant expected layout router to be mounted') + } + + const { childNodes, tree, url } = context // Get the current parallelRouter cache node let childNodesForParallelRouter = childNodes.get(parallelRouterKey) diff --git a/packages/next/client/components/navigation.ts b/packages/next/client/components/navigation.ts index 755922ac789aa56..39b3d7092521fb7 100644 --- a/packages/next/client/components/navigation.ts +++ b/packages/next/client/components/navigation.ts @@ -11,7 +11,7 @@ import { // ParamsContext, PathnameContext, // LayoutSegmentsContext, -} from './hooks-client-context' +} from '../../shared/lib/hooks-client-context' import { staticGenerationBailout } from './static-generation-bailout' const INTERNAL_URLSEARCHPARAMS_INSTANCE = Symbol( @@ -72,9 +72,15 @@ class ReadonlyURLSearchParams { export function useSearchParams() { staticGenerationBailout('useSearchParams') const searchParams = useContext(SearchParamsContext) + if (!searchParams) { + throw new Error('invariant expected search params to be mounted') + } + + // eslint-disable-next-line react-hooks/rules-of-hooks const readonlySearchParams = useMemo(() => { return new ReadonlyURLSearchParams(searchParams) }, [searchParams]) + return readonlySearchParams } @@ -83,7 +89,12 @@ export function useSearchParams() { */ export function usePathname(): string { staticGenerationBailout('usePathname') - return useContext(PathnameContext) + const pathname = useContext(PathnameContext) + if (pathname === null) { + throw new Error('invariant expected pathname to be mounted') + } + + return pathname } // 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. @@ -106,7 +117,12 @@ export { * Get the router methods. For example router.push('/dashboard') */ export function useRouter(): import('../../shared/lib/app-router-context').AppRouterInstance { - return useContext(AppRouterContext) + const router = useContext(AppRouterContext) + if (router === null) { + throw new Error('invariant expected app router to be mounted') + } + + return router } // TODO-APP: handle parallel routes diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index ed9fc3cce074fd6..a63a5ca9b05c911 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -36,6 +36,16 @@ import { ImageConfigContext } from '../shared/lib/image-config-context' import { ImageConfigComplete } from '../shared/lib/image-config' import { removeBasePath } from './remove-base-path' import { hasBasePath } from './has-base-path' +import { AppRouterContext } from '../shared/lib/app-router-context' +import { + adaptForAppRouterInstance, + adaptForPathname, + adaptForSearchParams, +} from '../shared/lib/router/adapters' +import { + PathnameContext, + SearchParamsContext, +} from '../shared/lib/hooks-client-context' /// @@ -304,15 +314,23 @@ function AppContainer({ ) } > - - - - {children} - - - + + + + + + + {children} + + + + + + ) } diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 8dec76ea0150a7a..22fe09d5a0d6690 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -5,9 +5,10 @@ import { UrlObject } from 'url' import { isLocalURL, NextRouter, - PrefetchOptions, + PrefetchOptions as RouterPrefetchOptions, resolveHref, } from '../shared/lib/router/router' +import { formatUrl } from '../shared/lib/router/utils/format-url' import { addLocale } from './add-locale' import { RouterContext } from '../shared/lib/router-context' import { @@ -77,7 +78,7 @@ type InternalLinkProps = { */ locale?: string | false /** - * Enable legacy link behaviour. + * Enable legacy link behavior. * @defaultValue `false` * @see https://github.com/vercel/next.js/commit/489e65ed98544e69b0afd7e0cfc3f9f6c2b803b7 */ @@ -105,16 +106,53 @@ export type LinkProps = InternalLinkProps type LinkPropsRequired = RequiredKeys type LinkPropsOptional = OptionalKeys -const prefetched: { [cacheKey: string]: boolean } = {} +const prefetched = new Set() + +type PrefetchOptions = RouterPrefetchOptions & { + /** + * bypassPrefetchedCheck will bypass the check to see if the `href` has + * already been fetched. + */ + bypassPrefetchedCheck?: boolean +} function prefetch( - router: NextRouter, + router: NextRouter | AppRouterInstance, href: string, as: string, - options?: PrefetchOptions + options: PrefetchOptions ): void { - if (typeof window === 'undefined' || !router) return - if (!isLocalURL(href)) return + if (typeof window === 'undefined') { + return + } + + if (!isLocalURL(href)) { + return + } + + // We should only dedupe requests when experimental.optimisticClientCache is + // disabled. + if (!options.bypassPrefetchedCheck) { + const locale = + // Let the link's locale prop override the default router locale. + typeof options.locale !== 'undefined' + ? options.locale + : // Otherwise fallback to the router's locale. + 'locale' in router + ? router.locale + : undefined + + const prefetchedKey = href + '%' + as + '%' + locale + + // If we've already fetched the key, then don't prefetch it again! + if (prefetched.has(prefetchedKey)) { + return + } + + // Mark this URL as prefetched. + prefetched.add(prefetchedKey) + } + // Prefetch the JSON page if asked (only in the client) // We need to handle a prefetch error here since we may be // loading with priority which can reject but we don't @@ -125,13 +163,6 @@ function prefetch( throw err } }) - const curLocale = - options && typeof options.locale !== 'undefined' - ? options.locale - : router && router.locale - - // Join on an invalid URI character - prefetched[href + '%' + as + (curLocale ? '%' + curLocale : '')] = true } function isModifiedEvent(event: React.MouseEvent): boolean { @@ -179,11 +210,7 @@ function linkClicked( 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, }) } @@ -202,6 +229,14 @@ type LinkPropsReal = React.PropsWithChildren< LinkProps > +function formatStringOrUrl(urlObjOrString: UrlObject | string): string { + if (typeof urlObjOrString === 'string') { + return urlObjOrString + } + + return formatUrl(urlObjOrString) +} + /** * React Component that enables client-side transitions between routes. */ @@ -341,8 +376,8 @@ const Link = React.forwardRef( scroll, locale, onClick, - onMouseEnter, - onTouchStart, + onMouseEnter: onMouseEnterProp, + onTouchStart: onTouchStartProp, legacyBehavior = Boolean(process.env.__NEXT_NEW_LINK_BEHAVIOR) !== true, ...restProps } = props @@ -356,22 +391,37 @@ const Link = React.forwardRef( children = {children} } - const p = prefetchProp !== false - let router = React.useContext(RouterContext) + const prefetchEnabled = prefetchProp !== false - // TODO-APP: type error. Remove `as any` - const appRouter = React.useContext(AppRouterContext) as any - if (appRouter) { - router = appRouter - } + const pagesRouter = React.useContext(RouterContext) + const appRouter = React.useContext(AppRouterContext) + const router = pagesRouter ?? appRouter + + // We're in the app directory if there is no pages router. + const isAppRouter = !pagesRouter const { href, as } = React.useMemo(() => { - const [resolvedHref, resolvedAs] = resolveHref(router, hrefProp, true) + if (!pagesRouter) { + const resolvedHref = formatStringOrUrl(hrefProp) + return { + href: resolvedHref, + as: asProp ? formatStringOrUrl(asProp) : resolvedHref, + } + } + + const [resolvedHref, resolvedAs] = resolveHref( + pagesRouter, + hrefProp, + true + ) + return { href: resolvedHref, - as: asProp ? resolveHref(router, asProp) : resolvedAs || resolvedHref, + as: asProp + ? resolveHref(pagesRouter, asProp) + : resolvedAs || resolvedHref, } - }, [router, hrefProp, asProp]) + }, [pagesRouter, hrefProp, asProp]) const previousHref = React.useRef(href) const previousAs = React.useRef(as) @@ -385,7 +435,7 @@ const Link = React.forwardRef( `"onClick" was passed to with \`href\` of \`${hrefProp}\` but "legacyBehavior" was set. The legacy behavior requires onClick be set on the child of next/link` ) } - if (onMouseEnter) { + if (onMouseEnterProp) { console.warn( `"onMouseEnter" was passed to with \`href\` of \`${hrefProp}\` but "legacyBehavior" was set. The legacy behavior requires onMouseEnter be set on the child of next/link` ) @@ -445,18 +495,29 @@ const Link = React.forwardRef( }, [as, childRef, href, resetVisible, setIntersectionRef] ) + + // Prefetch the URL if we haven't already and it's visible. React.useEffect(() => { - const shouldPrefetch = isVisible && p && isLocalURL(href) - const curLocale = - typeof locale !== 'undefined' ? locale : router && router.locale - const isPrefetched = - prefetched[href + '%' + as + (curLocale ? '%' + curLocale : '')] - if (shouldPrefetch && !isPrefetched) { - prefetch(router, href, as, { - locale: curLocale, - }) + if (!router) { + return } - }, [as, href, isVisible, locale, p, router]) + + // If we don't need to prefetch the URL, don't do prefetch. + if (!isVisible || !prefetchEnabled) { + return + } + + // Prefetch the URL. + prefetch(router, href, as, { locale }) + }, [ + as, + href, + isVisible, + locale, + prefetchEnabled, + pagesRouter?.locale, + router, + ]) const childProps: { onTouchStart: React.TouchEventHandler @@ -478,6 +539,7 @@ const Link = React.forwardRef( if (!legacyBehavior && typeof onClick === 'function') { onClick(e) } + if ( legacyBehavior && child.props && @@ -485,25 +547,33 @@ const Link = React.forwardRef( ) { child.props.onClick(e) } - if (!e.defaultPrevented) { - linkClicked( - e, - router, - href, - as, - replace, - shallow, - scroll, - locale, - Boolean(appRouter), - p - ) + + if (!router) { + return } + + if (e.defaultPrevented) { + return + } + + linkClicked( + e, + router, + href, + as, + replace, + shallow, + scroll, + locale, + isAppRouter, + prefetchEnabled + ) }, onMouseEnter: (e: React.MouseEvent) => { - if (!legacyBehavior && typeof onMouseEnter === 'function') { - onMouseEnter(e) + if (!legacyBehavior && typeof onMouseEnterProp === 'function') { + onMouseEnterProp(e) } + if ( legacyBehavior && child.props && @@ -512,16 +582,24 @@ const Link = React.forwardRef( child.props.onMouseEnter(e) } - // Check for not prefetch disabled in page using appRouter - if (!(!p && appRouter)) { - if (isLocalURL(href)) { - prefetch(router, href, as, { priority: true }) - } + if (!router) { + return + } + + if (!prefetchEnabled && isAppRouter) { + return } + + prefetch(router, href, as, { + locale, + priority: true, + // @see {https://github.com/vercel/next.js/discussions/40268?sort=top#discussioncomment-3572642} + bypassPrefetchedCheck: true, + }) }, onTouchStart: (e: React.TouchEvent) => { - if (!legacyBehavior && typeof onTouchStart === 'function') { - onTouchStart(e) + if (!legacyBehavior && typeof onTouchStartProp === 'function') { + onTouchStartProp(e) } if ( @@ -532,12 +610,20 @@ const Link = React.forwardRef( child.props.onTouchStart(e) } - // Check for not prefetch disabled in page using appRouter - if (!(!p && appRouter)) { - if (isLocalURL(href)) { - prefetch(router, href, as, { priority: true }) - } + if (!router) { + return + } + + if (!prefetchEnabled && isAppRouter) { + return } + + prefetch(router, href, as, { + locale, + priority: true, + // @see {https://github.com/vercel/next.js/discussions/40268?sort=top#discussioncomment-3572642} + bypassPrefetchedCheck: true, + }) }, } @@ -549,18 +635,22 @@ const Link = React.forwardRef( (child.type === 'a' && !('href' in child.props)) ) { const curLocale = - typeof locale !== 'undefined' ? locale : router && router.locale + typeof locale !== 'undefined' ? locale : pagesRouter?.locale // we only render domain locales if we are currently on a domain locale // so that locale links are still visitable in development/preview envs const localeDomain = - router && - router.isLocaleDomain && - getDomainLocale(as, curLocale, router.locales, router.domainLocales) + pagesRouter?.isLocaleDomain && + getDomainLocale( + as, + curLocale, + pagesRouter?.locales, + pagesRouter?.domainLocales + ) childProps.href = localeDomain || - addBasePath(addLocale(as, curLocale, router && router.defaultLocale)) + addBasePath(addLocale(as, curLocale, pagesRouter?.defaultLocale)) } return legacyBehavior ? ( diff --git a/packages/next/client/route-announcer.tsx b/packages/next/client/route-announcer.tsx index 3b59fd84d96bcbd..cbd26f09ea7bda4 100644 --- a/packages/next/client/route-announcer.tsx +++ b/packages/next/client/route-announcer.tsx @@ -17,7 +17,7 @@ const nextjsRouteAnnouncerStyles: React.CSSProperties = { } export const RouteAnnouncer = () => { - const { asPath } = useRouter() + const { asPath } = useRouter(true) const [routeAnnouncement, setRouteAnnouncement] = React.useState('') // Only announce the path change, but not for the first load because screen diff --git a/packages/next/client/router.ts b/packages/next/client/router.ts index f739b291209b612..14664a831f37b3d 100644 --- a/packages/next/client/router.ts +++ b/packages/next/client/router.ts @@ -129,8 +129,15 @@ export default singletonRouter as SingletonRouter // Reexport the withRoute HOC export { default as withRouter } from './with-router' -export function useRouter(): NextRouter { - return React.useContext(RouterContext) +export function useRouter(throwOnMissing: true): NextRouter +export function useRouter(): NextRouter | null +export function useRouter(throwOnMissing?: boolean) { + const router = React.useContext(RouterContext) + if (!router && throwOnMissing) { + throw new Error('invariant expected pages router to be mounted') + } + + return router } // INTERNAL APIS diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index aee88e14ee0bc2f..b379c3d9a8a70fa 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -82,6 +82,16 @@ import { import { ImageConfigContext } from '../shared/lib/image-config-context' import stripAnsi from 'next/dist/compiled/strip-ansi' import { stripInternalQueries } from './internal-utils' +import { + adaptForAppRouterInstance, + adaptForPathname, + adaptForSearchParams, +} from '../shared/lib/router/adapters' +import { AppRouterContext } from '../shared/lib/app-router-context' +import { + PathnameContext, + SearchParamsContext, +} from '../shared/lib/hooks-client-context' let tryGetPreviewData: typeof import('./api-utils/node').tryGetPreviewData let warn: typeof import('../build/output/log').warn @@ -168,6 +178,9 @@ class ServerRouter implements NextRouter { back() { noRouter() } + forward(): void { + noRouter() + } prefetch(): any { noRouter() } @@ -582,6 +595,8 @@ export async function renderToHTML( getRequestMeta(req, '__nextIsLocaleDomain') ) + const appRouter = adaptForAppRouterInstance(router) + let scriptLoader: any = {} const jsxStyleRegistry = createStyleRegistry() const ampState = { @@ -604,32 +619,38 @@ export async function renderToHTML( } const AppContainer = ({ children }: { children: JSX.Element }) => ( - - - { - head = state - }, - updateScripts: (scripts) => { - scriptLoader = scripts - }, - scripts: initialScripts, - mountedInstances: new Set(), - }} - > - reactLoadableModules.push(moduleName)} - > - - - {children} - - - - - - + + + + + + { + head = state + }, + updateScripts: (scripts) => { + scriptLoader = scripts + }, + scripts: initialScripts, + mountedInstances: new Set(), + }} + > + reactLoadableModules.push(moduleName)} + > + + + {children} + + + + + + + + + ) // The `useId` API uses the path indexes to generate an ID for each node. diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 42eb5c0931612bb..16702c0dd0a8c9c 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -59,8 +59,8 @@ export interface AppRouterInstance { prefetch(href: string): void } -export const AppRouterContext = React.createContext( - null as any +export const AppRouterContext = React.createContext( + null ) export const LayoutRouterContext = React.createContext<{ childNodes: CacheNode['parallelRoutes'] diff --git a/packages/next/client/components/hooks-client-context.ts b/packages/next/shared/lib/hooks-client-context.ts similarity index 86% rename from packages/next/client/components/hooks-client-context.ts rename to packages/next/shared/lib/hooks-client-context.ts index b34157d876df44a..83d14c1451a0c6d 100644 --- a/packages/next/client/components/hooks-client-context.ts +++ b/packages/next/shared/lib/hooks-client-context.ts @@ -2,8 +2,8 @@ import { createContext } from 'react' -export const SearchParamsContext = createContext(null as any) -export const PathnameContext = createContext(null as any) +export const SearchParamsContext = createContext(null) +export const PathnameContext = createContext(null) export const ParamsContext = createContext(null as any) export const LayoutSegmentsContext = createContext(null as any) diff --git a/packages/next/shared/lib/loadable.d.ts b/packages/next/shared/lib/loadable.d.ts index 505fa09be9c3c3f..8ac6e8043379751 100644 --- a/packages/next/shared/lib/loadable.d.ts +++ b/packages/next/shared/lib/loadable.d.ts @@ -9,7 +9,7 @@ declare namespace LoadableExport { } } -// eslint-disable-next-line no-redeclare +// eslint-disable-next-line @typescript-eslint/no-redeclare declare const LoadableExport: LoadableExport.ILoadable export = LoadableExport diff --git a/packages/next/shared/lib/router-context.ts b/packages/next/shared/lib/router-context.ts index d00df65cfeb5089..4a9d9a575b89827 100644 --- a/packages/next/shared/lib/router-context.ts +++ b/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(null as any) +export const RouterContext = React.createContext(null) if (process.env.NODE_ENV !== 'production') { RouterContext.displayName = 'RouterContext' diff --git a/packages/next/shared/lib/router/adapters.ts b/packages/next/shared/lib/router/adapters.ts new file mode 100644 index 000000000000000..facd64c2a7effed --- /dev/null +++ b/packages/next/shared/lib/router/adapters.ts @@ -0,0 +1,81 @@ +import type { ParsedUrlQuery } from 'node:querystring' +import { AppRouterInstance } from '../app-router-context' +import { NextRouter } from './router' + +/** + * adaptForAppRouterInstance implements the AppRouterInstance with a NextRouter. + * + * @param router the NextRouter to adapt + * @returns an AppRouterInstance + */ +export function adaptForAppRouterInstance( + router: NextRouter +): AppRouterInstance { + return { + back(): void { + router.back() + }, + forward(): void { + router.forward() + }, + refresh(): void { + router.reload() + }, + push(href: string): void { + void router.push(href) + }, + replace(href: string): void { + void router.replace(href) + }, + prefetch(href: string): void { + void router.prefetch(href) + }, + } +} + +/** + * transforms the ParsedUrlQuery into a URLSearchParams. + * + * @param query the query to transform + * @returns URLSearchParams + */ +function transformQuery(query: ParsedUrlQuery): URLSearchParams { + const params = new URLSearchParams() + + for (const [name, value] of Object.entries(query)) { + if (Array.isArray(value)) { + for (const val of value) { + params.append(name, val) + } + } else if (typeof value !== 'undefined') { + params.append(name, value) + } + } + + return params +} + +/** + * adaptForSearchParams transforms the ParsedURLQuery into URLSearchParams. + * + * @param router the router that contains the query. + * @returns the search params in the URLSearchParams format + */ +export function adaptForSearchParams(router: NextRouter): URLSearchParams { + if (!router.isReady || !router.query) { + return new URLSearchParams() + } + + return transformQuery(router.query) +} + +/** + * adaptForPathname adapts the `asPath` parameter from the router to a pathname. + * + * @param asPath the asPath parameter to transform that comes from the router + * @returns pathname part of `asPath` + */ +export function adaptForPathname(asPath: string): string { + const url = new URL(asPath, 'http://f') + return url.pathname +} diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts index ec609730f24c7ba..e7d9610913e7d8b 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -122,11 +122,11 @@ function stripOrigin(url: string) { return url.startsWith(origin) ? url.substring(origin.length) : url } -function omit( +function omit( object: T, keys: K[] ): Omit { - const omitted: { [key: string]: any } = {} + const omitted: { [key: string]: unknown } = {} Object.keys(object).forEach((key) => { if (!keys.includes(key as K)) { omitted[key] = object[key] @@ -259,6 +259,7 @@ export function resolveHref( // fallback to / for invalid asPath values e.g. // base = new URL('/', 'http://n') } + try { const finalUrl = new URL(urlAsString, base) finalUrl.pathname = normalizePathTrailingSlash(finalUrl.pathname) @@ -542,6 +543,7 @@ export type NextRouter = BaseRouter & | 'replace' | 'reload' | 'back' + | 'forward' | 'prefetch' | 'beforePopState' | 'events' @@ -1137,6 +1139,13 @@ export default class Router implements BaseRouter { window.history.back() } + /** + * Go forward in history + */ + forward() { + window.history.forward() + } + /** * Performs a `pushState` with arguments * @param url of the route diff --git a/test/e2e/app-dir/app/components/router-hooks-fixtures.js b/test/e2e/app-dir/app/components/router-hooks-fixtures.js new file mode 100644 index 000000000000000..95f62d6f6fd7f35 --- /dev/null +++ b/test/e2e/app-dir/app/components/router-hooks-fixtures.js @@ -0,0 +1,37 @@ +import { useRouter as usePagesRouter } from 'next/router' +import { + usePathname, + useRouter as useAppRouter, + useSearchParams, +} from 'next/navigation' +import { useState, useEffect } from 'react' + +export const RouterHooksFixtures = () => { + const pagesRouter = usePagesRouter() + const appRouter = useAppRouter() + const searchParams = useSearchParams() + const pathname = usePathname() + + const [value, setValue] = useState(null) + useEffect(() => { + if (!pagesRouter.isReady) { + return + } + + setValue(searchParams.get('key')) + }, [pagesRouter.isReady, searchParams]) + + const onClick = () => { + appRouter.push('/adapter-hooks/pushed') + } + + return ( +
+
{value}
+
{pathname}
+ +
+ ) +} diff --git a/test/e2e/app-dir/app/pages/adapter-hooks/[id].js b/test/e2e/app-dir/app/pages/adapter-hooks/[id].js new file mode 100644 index 000000000000000..6395b062b3f84a5 --- /dev/null +++ b/test/e2e/app-dir/app/pages/adapter-hooks/[id].js @@ -0,0 +1,5 @@ +import { RouterHooksFixtures } from '../../components/router-hooks-fixtures' + +export default function Page() { + return +} diff --git a/test/e2e/app-dir/app/pages/adapter-hooks/[id]/account.js b/test/e2e/app-dir/app/pages/adapter-hooks/[id]/account.js new file mode 100644 index 000000000000000..2c917626f2e3c25 --- /dev/null +++ b/test/e2e/app-dir/app/pages/adapter-hooks/[id]/account.js @@ -0,0 +1,13 @@ +import { RouterHooksFixtures } from '../../../components/router-hooks-fixtures' + +export default function Page() { + return +} + +export const getStaticProps = () => { + return { props: {} } +} + +export const getStaticPaths = () => { + return { fallback: 'blocking', paths: [{ params: { id: '1' } }] } +} diff --git a/test/e2e/app-dir/app/pages/adapter-hooks/pushed.js b/test/e2e/app-dir/app/pages/adapter-hooks/pushed.js new file mode 100644 index 000000000000000..95d5882bfc305a3 --- /dev/null +++ b/test/e2e/app-dir/app/pages/adapter-hooks/pushed.js @@ -0,0 +1,3 @@ +export default function Page() { + return
+} diff --git a/test/e2e/app-dir/app/pages/adapter-hooks/static.js b/test/e2e/app-dir/app/pages/adapter-hooks/static.js new file mode 100644 index 000000000000000..4bac4a3f900e7de --- /dev/null +++ b/test/e2e/app-dir/app/pages/adapter-hooks/static.js @@ -0,0 +1,9 @@ +import { RouterHooksFixtures } from '../../components/router-hooks-fixtures' + +export default function Page() { + return +} + +export const getStaticProps = () => { + return { props: {} } +} diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index 7f8c5e2b2e473c2..338dd701459237f 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1148,6 +1148,42 @@ describe('app dir', () => { describe('client components', () => { describe('hooks', () => { + describe('from pages', () => { + it.each([ + { pathname: '/adapter-hooks/static' }, + { pathname: '/adapter-hooks/1' }, + { pathname: '/adapter-hooks/2' }, + { pathname: '/adapter-hooks/1/account' }, + { pathname: '/adapter-hooks/static', keyValue: 'value' }, + { pathname: '/adapter-hooks/1', keyValue: 'value' }, + { pathname: '/adapter-hooks/2', keyValue: 'value' }, + { pathname: '/adapter-hooks/1/account', keyValue: 'value' }, + ])( + 'should have the correct hooks', + async ({ pathname, keyValue = '' }) => { + const browser = await webdriver( + next.url, + pathname + (keyValue ? `?key=${keyValue}` : '') + ) + + try { + await browser.waitForElementByCss('#router-ready') + expect(await browser.elementById('key-value').text()).toBe( + keyValue + ) + expect(await browser.elementById('pathname').text()).toBe( + pathname + ) + + await browser.elementByCss('button').click() + await browser.waitForElementByCss('#pushed') + } finally { + await browser.close() + } + } + ) + }) + describe('usePathname', () => { it('should have the correct pathname', async () => { const html = await renderViaHTTP(next.url, '/hooks/use-pathname') diff --git a/test/integration/typescript/pages/hello.tsx b/test/integration/typescript/pages/hello.tsx index acfd061ee410906..1e5c82d05ec5cf9 100644 --- a/test/integration/typescript/pages/hello.tsx +++ b/test/integration/typescript/pages/hello.tsx @@ -31,7 +31,7 @@ class Test2 extends Test { new Test2().show() export default function HelloPage(): JSX.Element { - const router = useRouter() + const router = useRouter(true) console.log(process.browser) console.log(router.pathname) console.log(router.isReady) diff --git a/test/integration/typescript/test/index.test.js b/test/integration/typescript/test/index.test.js index c9ba200286d81d2..3268404f6c7d578 100644 --- a/test/integration/typescript/test/index.test.js +++ b/test/integration/typescript/test/index.test.js @@ -142,6 +142,7 @@ export default function EvilPage(): JSX.Element { try { errorPage.replace('static ', 'static async ') const output = await nextBuild(appDir, [], { stdout: true }) + expect(output.stdout).toMatch(/Compiled successfully/) } finally { errorPage.restore() @@ -153,6 +154,7 @@ export default function EvilPage(): JSX.Element { try { page.replace(/async \(/g, '(') const output = await nextBuild(appDir, [], { stdout: true }) + expect(output.stdout).toMatch(/Compiled successfully/) } finally { page.restore()