diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 9bd71e4376d6d..d788d719dda63 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -29,6 +29,14 @@ import { } from './hooks-client-context' import { useReducerWithReduxDevtools } from './use-reducer-with-devtools' +function urlToUrlWithoutFlightParameters(url: string): URL { + const urlWithoutFlightParameters = new URL(url, location.origin) + urlWithoutFlightParameters.searchParams.delete('__flight__') + urlWithoutFlightParameters.searchParams.delete('__flight_router_state_tree__') + urlWithoutFlightParameters.searchParams.delete('__flight_prefetch__') + return urlWithoutFlightParameters +} + /** * Fetch the flight data for the provided url. Takes in the current router state to decide what to render server-side. */ @@ -36,7 +44,7 @@ export async function fetchServerResponse( url: URL, flightRouterState: FlightRouterState, prefetch?: true -): Promise<[FlightData: FlightData]> { +): Promise<[FlightData: FlightData, canonicalUrlOverride: URL | undefined]> { const flightUrl = new URL(url) const searchParams = flightUrl.searchParams // Enable flight response @@ -51,9 +59,13 @@ export async function fetchServerResponse( } const res = await fetch(flightUrl.toString()) + const canonicalUrl = res.redirected + ? urlToUrlWithoutFlightParameters(res.url) + : undefined + // Handle the `fetch` readable stream that can be unwrapped by `React.use`. const flightData: FlightData = await createFromFetch(Promise.resolve(res)) - return [flightData] + return [flightData, canonicalUrl] } /** @@ -140,11 +152,16 @@ export default function AppRouter({ * Server response that only patches the cache and tree. */ const changeByServerResponse = useCallback( - (previousTree: FlightRouterState, flightData: FlightData) => { + ( + previousTree: FlightRouterState, + flightData: FlightData, + overrideCanonicalUrl: URL | undefined + ) => { dispatch({ type: ACTION_SERVER_PATCH, flightData, previousTree, + overrideCanonicalUrl, cache: { data: null, subTreeData: null, @@ -192,19 +209,18 @@ export default function AppRouter({ try { // TODO-APP: handle case where history.state is not the new router history entry - const r = fetchServerResponse( + const serverResponse = await fetchServerResponse( url, // initialTree is used when history.state.tree is missing because the history state is set in `useEffect` below, it being missing means this is the hydration case. window.history.state?.tree || initialTree, true ) - const [flightData] = await r // @ts-ignore startTransition exists React.startTransition(() => { dispatch({ type: ACTION_PREFETCH, url, - flightData, + serverResponse, }) }) } catch (err) { diff --git a/packages/next/client/components/infinite-promise.ts b/packages/next/client/components/infinite-promise.ts new file mode 100644 index 0000000000000..b1ff7582e68c6 --- /dev/null +++ b/packages/next/client/components/infinite-promise.ts @@ -0,0 +1,22 @@ +/** + * Used to cache in createInfinitePromise + */ +let infinitePromise: Promise + +/** + * Create a Promise that does not resolve. This is used to suspend when data is not available yet. + */ +export function createInfinitePromise() { + if (!infinitePromise) { + // Only create the Promise once + infinitePromise = new Promise((/* resolve */) => { + // This is used to debug when the rendering is never updated. + // setTimeout(() => { + // infinitePromise = new Error('Infinite promise') + // resolve() + // }, 5000) + }) + } + + return infinitePromise +} diff --git a/packages/next/client/components/layout-router.client.tsx b/packages/next/client/components/layout-router.client.tsx index 4e85432cf2ca8..cf8f25bbb7df8 100644 --- a/packages/next/client/components/layout-router.client.tsx +++ b/packages/next/client/components/layout-router.client.tsx @@ -23,6 +23,8 @@ import { TemplateContext, } from '../../shared/lib/app-router-context' import { fetchServerResponse } from './app-router.client' +import { createInfinitePromise } from './infinite-promise' + // import { matchSegment } from './match-segments' /** @@ -95,29 +97,6 @@ function walkAddRefetch( return treeToRecreate } -/** - * Used to cache in createInfinitePromise - */ -let infinitePromise: Promise | Error - -/** - * Create a Promise that does not resolve. This is used to suspend when data is not available yet. - */ -function createInfinitePromise() { - if (!infinitePromise) { - // Only create the Promise once - infinitePromise = new Promise((/* resolve */) => { - // This is used to debug when the rendering is never updated. - // setTimeout(() => { - // infinitePromise = new Error('Infinite promise') - // resolve() - // }, 5000) - }) - } - - return infinitePromise -} - /** * Check if the top of the HTMLElement is in the viewport. */ @@ -237,7 +216,7 @@ export function InnerLayoutRouter({ * Flight response data */ // When the data has not resolved yet `use` will suspend here. - const [flightData] = use(childNode.data) + const [flightData, overrideCanonicalUrl] = use(childNode.data) // Handle case when navigating to page in `pages` from `app` if (typeof flightData === 'string') { @@ -245,52 +224,25 @@ export function InnerLayoutRouter({ return null } - /** - * If the fast path was triggered. - * The fast path is when the returned Flight data path matches the layout segment path, then we can write the data to the cache in render instead of dispatching an action. - */ - let fastPath: boolean = false - - // If there are multiple patches returned in the Flight data we need to dispatch to ensure a single render. - // if (flightData.length === 1) { - // const flightDataPath = flightData[0] - - // if (segmentPathMatches(flightDataPath, segmentPath)) { - // // Ensure data is set to null as subTreeData will be set in the cache now. - // childNode.data = null - // // Last item is the subtreeData - // // TODO-APP: routerTreePatch needs to be applied to the tree, handle it in render? - // const [, /* routerTreePatch */ subTreeData] = flightDataPath.slice(-2) - // // Add subTreeData into the cache - // childNode.subTreeData = subTreeData - // // This field is required for new items - // childNode.parallelRoutes = new Map() - // fastPath = true - // } - // } - - // When the fast path is not used a new action is dispatched to update the tree and cache. - if (!fastPath) { - // segmentPath from the server does not match the layout's segmentPath - childNode.data = null - - // setTimeout is used to start a new transition during render, this is an intentional hack around React. - setTimeout(() => { - // @ts-ignore startTransition exists - React.startTransition(() => { - // TODO-APP: handle redirect - changeByServerResponse(fullTree, flightData) - }) + // segmentPath from the server does not match the layout's segmentPath + childNode.data = null + + // setTimeout is used to start a new transition during render, this is an intentional hack around React. + setTimeout(() => { + // @ts-ignore startTransition exists + React.startTransition(() => { + // TODO-APP: handle redirect + changeByServerResponse(fullTree, flightData, overrideCanonicalUrl) }) - // Suspend infinitely as `changeByServerResponse` will cause a different part of the tree to be rendered. - throw createInfinitePromise() - } + }) + // Suspend infinitely as `changeByServerResponse` will cause a different part of the tree to be rendered. + use(createInfinitePromise()) } // If cache node has no subTreeData and no data request we have to infinitely suspend as the data will likely flow in from another place. // TODO-APP: double check users can't return null in a component that will kick in here. if (!childNode.subTreeData) { - throw createInfinitePromise() + use(createInfinitePromise()) } const subtree = ( diff --git a/packages/next/client/components/redirect-client.ts b/packages/next/client/components/redirect-client.ts new file mode 100644 index 0000000000000..d47af16deff12 --- /dev/null +++ b/packages/next/client/components/redirect-client.ts @@ -0,0 +1,15 @@ +import React, { experimental_use as use } from 'react' +import { AppRouterContext } from '../../shared/lib/app-router-context' +import { createInfinitePromise } from './infinite-promise' + +export function redirect(url: string) { + const router = use(AppRouterContext) + setTimeout(() => { + // @ts-ignore startTransition exists + React.startTransition(() => { + router.replace(url, {}) + }) + }) + // setTimeout is used to start a new transition during render, this is an intentional hack around React. + use(createInfinitePromise()) +} diff --git a/packages/next/client/components/redirect.ts b/packages/next/client/components/redirect.ts new file mode 100644 index 0000000000000..af126a333a93f --- /dev/null +++ b/packages/next/client/components/redirect.ts @@ -0,0 +1,9 @@ +export const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT' + +export function redirect(url: string) { + // eslint-disable-next-line no-throw-literal + throw { + url, + code: REDIRECT_ERROR_CODE, + } +} diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 60c33a9916938..6052c849e038e 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -11,6 +11,10 @@ import { experimental_use as use } from 'react' import { matchSegment } from './match-segments' import { fetchServerResponse } from './app-router.client' +function createHrefFromUrl(url: URL): string { + return url.pathname + url.search + url.hash +} + /** * Invalidate cache one level down from the router state. */ @@ -477,6 +481,7 @@ interface ReloadAction { mutable: { previousTree?: FlightRouterState patchedTree?: FlightRouterState + canonicalUrlOverride?: string } } @@ -513,6 +518,7 @@ interface NavigateAction { mutable: { previousTree?: FlightRouterState patchedTree?: FlightRouterState + canonicalUrlOverride?: string useExistingCache?: true } } @@ -540,16 +546,18 @@ interface ServerPatchAction { type: typeof ACTION_SERVER_PATCH flightData: FlightData previousTree: FlightRouterState + overrideCanonicalUrl: URL | undefined cache: CacheNode mutable: { patchedTree?: FlightRouterState + canonicalUrlOverride?: string } } interface PrefetchAction { type: typeof ACTION_PREFETCH url: URL - flightData: FlightData + serverResponse: Awaited> } interface PushRef { @@ -587,6 +595,7 @@ type AppRouterState = { { flightSegmentPath: FlightSegmentPath treePatch: FlightRouterState + canonicalUrlOverride: URL | undefined } > /** @@ -620,8 +629,8 @@ export function reducer( case ACTION_NAVIGATE: { const { url, navigateType, cache, mutable, forceOptimisticNavigation } = action - const { pathname, search, hash } = url - const href = pathname + search + hash + const { pathname, search } = url + const href = createHrefFromUrl(url) const pendingPush = navigateType === 'push' // Handle concurrent rendering / strict mode case where the cache and tree were already populated. @@ -631,7 +640,9 @@ export function reducer( ) { return { // Set href. - canonicalUrl: href, + canonicalUrl: mutable.canonicalUrlOverride + ? mutable.canonicalUrlOverride + : href, // TODO-APP: verify mpaNavigation not being set is correct here. pushRef: { pendingPush, mpaNavigation: false }, // All navigation requires scroll and focus management to trigger. @@ -647,7 +658,8 @@ export function reducer( const prefetchValues = state.prefetchCache.get(href) if (prefetchValues) { // The one before last item is the router state tree patch - const { flightSegmentPath, treePatch } = prefetchValues + const { flightSegmentPath, treePatch, canonicalUrlOverride } = + prefetchValues // Create new tree based on the flightSegmentPath and router state patch const newTree = applyRouterStatePatchToTree( @@ -685,9 +697,19 @@ export function reducer( mutable.useExistingCache = true } + const canonicalUrlOverrideHref = canonicalUrlOverride + ? createHrefFromUrl(canonicalUrlOverride) + : undefined + + if (canonicalUrlOverrideHref) { + mutable.canonicalUrlOverride = canonicalUrlOverrideHref + } + return { // Set href. - canonicalUrl: href, + canonicalUrl: canonicalUrlOverrideHref + ? canonicalUrlOverrideHref + : href, // Set pendingPush. pushRef: { pendingPush, mpaNavigation: false }, // All navigation requires scroll and focus management to trigger. @@ -763,7 +785,7 @@ export function reducer( } // Unwrap cache data with `use` to suspend here (in the reducer) until the fetch resolves. - const [flightData] = use(cache.data) + const [flightData, canonicalUrlOverride] = use(cache.data) // Handle case when navigating to page in `pages` from `app` if (typeof flightData === 'string') { @@ -803,6 +825,12 @@ export function reducer( throw new Error('SEGMENT MISMATCH') } + const canonicalUrlOverrideHref = canonicalUrlOverride + ? createHrefFromUrl(canonicalUrlOverride) + : undefined + if (canonicalUrlOverrideHref) { + mutable.canonicalUrlOverride = canonicalUrlOverrideHref + } mutable.previousTree = state.tree mutable.patchedTree = newTree @@ -813,7 +841,9 @@ export function reducer( return { // Set href. - canonicalUrl: href, + canonicalUrl: canonicalUrlOverrideHref + ? canonicalUrlOverrideHref + : href, // Set pendingPush. pushRef: { pendingPush, mpaNavigation: false }, // All navigation requires scroll and focus management to trigger. @@ -826,7 +856,9 @@ export function reducer( } } case ACTION_SERVER_PATCH: { - const { flightData, previousTree, cache, mutable } = action + const { flightData, previousTree, overrideCanonicalUrl, cache, mutable } = + action + // When a fetch is slow to resolve it could be that you navigated away while the request was happening or before the reducer runs. // In that case opt-out of applying the patch given that the data could be stale. if (JSON.stringify(previousTree) !== JSON.stringify(state.tree)) { @@ -840,7 +872,9 @@ export function reducer( if (mutable.patchedTree) { return { // Keep href as it was set during navigate / restore - canonicalUrl: state.canonicalUrl, + canonicalUrl: mutable.canonicalUrlOverride + ? mutable.canonicalUrlOverride + : state.canonicalUrl, // Keep pushRef as server-patch only causes cache/tree update. pushRef: state.pushRef, // Keep focusAndScrollRef as server-patch only causes cache/tree update. @@ -887,6 +921,14 @@ export function reducer( throw new Error('SEGMENT MISMATCH') } + const canonicalUrlOverrideHref = overrideCanonicalUrl + ? createHrefFromUrl(overrideCanonicalUrl) + : undefined + + if (canonicalUrlOverrideHref) { + mutable.canonicalUrlOverride = canonicalUrlOverrideHref + } + mutable.patchedTree = newTree // Copy subTreeData for the root node of the cache. @@ -895,7 +937,9 @@ export function reducer( return { // Keep href as it was set during navigate / restore - canonicalUrl: state.canonicalUrl, + canonicalUrl: canonicalUrlOverrideHref + ? canonicalUrlOverrideHref + : state.canonicalUrl, // Keep pushRef as server-patch only causes cache/tree update. pushRef: state.pushRef, // Keep focusAndScrollRef as server-patch only causes cache/tree update. @@ -909,7 +953,7 @@ export function reducer( } case ACTION_RESTORE: { const { url, tree } = action - const href = url.pathname + url.search + url.hash + const href = createHrefFromUrl(url) return { // Set canonical url @@ -933,7 +977,9 @@ export function reducer( ) { return { // Set href. - canonicalUrl: href, + canonicalUrl: mutable.canonicalUrlOverride + ? mutable.canonicalUrlOverride + : href, // set pendingPush (always false in this case). pushRef: state.pushRef, // Apply focus and scroll. @@ -954,7 +1000,7 @@ export function reducer( 'refetch', ]) } - const [flightData] = use(cache.data) + const [flightData, canonicalUrlOverride] = use(cache.data) // Handle case when navigating to page in `pages` from `app` if (typeof flightData === 'string') { @@ -994,6 +1040,14 @@ export function reducer( throw new Error('SEGMENT MISMATCH') } + const canonicalUrlOverrideHref = canonicalUrlOverride + ? createHrefFromUrl(canonicalUrlOverride) + : undefined + + if (canonicalUrlOverride) { + mutable.canonicalUrlOverride = canonicalUrlOverrideHref + } + mutable.previousTree = state.tree mutable.patchedTree = newTree @@ -1002,7 +1056,9 @@ export function reducer( return { // Set href, this doesn't reuse the state.canonicalUrl as because of concurrent rendering the href might change between dispatching and applying. - canonicalUrl: href, + canonicalUrl: canonicalUrlOverrideHref + ? canonicalUrlOverrideHref + : href, // set pendingPush (always false in this case). pushRef: state.pushRef, // TODO-APP: might need to disable this for Fast Refresh. @@ -1015,15 +1071,15 @@ export function reducer( } } case ACTION_PREFETCH: { - const { url, flightData } = action + const { url, serverResponse } = action + const [flightData, canonicalUrlOverride] = serverResponse // TODO-APP: Implement prefetch for hard navigation if (typeof flightData === 'string') { return state } - const { pathname, search, hash } = url - const href = pathname + search + hash + const href = createHrefFromUrl(url) // TODO-APP: Currently the Flight data can only have one item but in the future it can have multiple paths. const flightDataPath = flightData[0] @@ -1042,6 +1098,7 @@ export function reducer( // Path without the last segment, router state, and the subTreeData flightSegmentPath: flightDataPath.slice(0, -2), treePatch, + canonicalUrlOverride, }) return state diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 4e4112a428ab7..e16ef64d024ec 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -27,6 +27,7 @@ import { import { FlushEffectsContext } from '../shared/lib/flush-effects' import { stripInternalQueries } from './internal-utils' import type { ComponentsType } from '../build/webpack/loaders/next-app-loader' +import { REDIRECT_ERROR_CODE } from '../client/components/redirect' // this needs to be required lazily so that `next-server` can set // the env before we require @@ -48,7 +49,7 @@ export type RenderOptsPartial = { export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial /** - * Flight Response is always set to application/octet-stream to ensure it does not + * Flight Response is always set to application/octet-stream to ensure it does not get interpreted as HTML. */ class FlightRenderResult extends RenderResult { constructor(response: string | ReadableStream) { @@ -65,15 +66,38 @@ function interopDefault(mod: any) { // tolerate dynamic server errors during prerendering so console // isn't spammed with unactionable errors -function onError(err: any) { - const { DynamicServerError } = - require('../client/components/hooks-server-context') as typeof import('../client/components/hooks-server-context') +/** + * Create error handler for renderers. + */ +function createErrorHandler( + /** + * Used for debugging + */ + _source: string +) { + return (err: any) => { + if ( + // Use error message instead of type because HTML renderer uses Flight data which is serialized so it's not the same object instance. + err.message && + !err.message.includes('Dynamic server usage') && + // TODO-APP: Handle redirect throw + err.code !== REDIRECT_ERROR_CODE + ) { + // Used for debugging error source + // console.error(_source, err) + console.error(err) + } - if (!(err instanceof DynamicServerError)) { - console.error(err) + return null } } +const serverComponentsErrorHandler = createErrorHandler( + 'serverComponentsRenderer' +) +const flightDataRendererErrorHandler = createErrorHandler('flightDataRenderer') +const htmlRendererErrorHandler = createErrorHandler('htmlRenderer') + let isFetchPatched = false // we patch fetch to collect cache information used for @@ -241,7 +265,7 @@ function createServerComponentRenderer( serverComponentManifest, { context: serverContexts, - onError, + onError: serverComponentsErrorHandler, } ) } @@ -515,7 +539,7 @@ export async function renderToHTMLOrFlight( const flightData: FlightData = pathname + (search ? `?${search}` : '') return new FlightRenderResult( renderToReadableStream(flightData, serverComponentManifest, { - onError, + onError: flightDataRendererErrorHandler, }).pipeThrough(createBufferedTransformStream()) ) } @@ -1008,7 +1032,7 @@ export async function renderToHTMLOrFlight( serverComponentManifest, { context: serverContexts, - onError, + onError: flightDataRendererErrorHandler, } ).pipeThrough(createBufferedTransformStream()) @@ -1121,6 +1145,7 @@ export async function renderToHTMLOrFlight( ReactDOMServer, element: content, streamOptions: { + onError: htmlRendererErrorHandler, nonce, // Include hydration scripts in the HTML bootstrapScripts: subresourceIntegrityManifest @@ -1140,7 +1165,11 @@ export async function renderToHTMLOrFlight( flushEffectHandler, flushEffectsToHead: true, }) - } catch (err) { + } catch (err: any) { + if (err.code === REDIRECT_ERROR_CODE) { + throw err + } + // TODO-APP: show error overlay in development. `element` should probably be wrapped in AppRouter for this case. const renderStream = await renderToInitialStream({ ReactDOMServer, @@ -1173,9 +1202,8 @@ export async function renderToHTMLOrFlight( } } - const readable = await bodyResult() - if (generateStaticHTML) { + const readable = await bodyResult() let staticHtml = Buffer.from( (await readable.getReader().read()).value || '' ).toString() @@ -1190,5 +1218,20 @@ export async function renderToHTMLOrFlight( return new RenderResult(staticHtml) } - return new RenderResult(readable) + + try { + return new RenderResult(await bodyResult()) + } catch (err: any) { + if (err.code === REDIRECT_ERROR_CODE) { + ;(renderOpts as any).pageData = { + pageProps: { + __N_REDIRECT: err.url, + __N_REDIRECT_STATUS: 307, + }, + } + ;(renderOpts as any).isRedirect = true + return RenderResult.fromStatic('') + } + throw err + } } diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index f9a36be37dc3f..bceec77fc5bb2 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -61,7 +61,8 @@ export const GlobalLayoutRouterContext = React.createContext<{ tree: FlightRouterState changeByServerResponse: ( previousTree: FlightRouterState, - flightData: FlightData + flightData: FlightData, + overrideCanonicalUrl: URL | undefined ) => void focusAndScrollRef: FocusAndScrollRef }>(null as any) diff --git a/packages/next/types/index.d.ts b/packages/next/types/index.d.ts index 28866d91b134c..09647baa800df 100644 --- a/packages/next/types/index.d.ts +++ b/packages/next/types/index.d.ts @@ -41,7 +41,7 @@ declare module 'react' { } // TODO-APP: check if this is the right type. - function experimental_use(promise: Promise): Awaited + function experimental_use(promise: Promise | React.Context): T } export type Redirect = diff --git a/test/e2e/app-dir/app/app/hooks/use-layout-segments/server/page.js b/test/e2e/app-dir/app/app/hooks/use-layout-segments/server/page.js index 24902634d214e..8ffb8c9e59cd4 100644 --- a/test/e2e/app-dir/app/app/hooks/use-layout-segments/server/page.js +++ b/test/e2e/app-dir/app/app/hooks/use-layout-segments/server/page.js @@ -1,8 +1,10 @@ -import { useLayoutSegments } from 'next/dist/client/components/hooks-client' +'client' +// TODO-APP: enable once test is not skipped. +// import { useLayoutSegments } from 'next/dist/client/components/hooks-client' export default function Page() { // This should throw an error. - useLayoutSegments() + // useLayoutSegments() return null } diff --git a/test/e2e/app-dir/app/app/hooks/use-params/server/page.js b/test/e2e/app-dir/app/app/hooks/use-params/server/page.js index c3f5252df45f5..0190f96a517b2 100644 --- a/test/e2e/app-dir/app/app/hooks/use-params/server/page.js +++ b/test/e2e/app-dir/app/app/hooks/use-params/server/page.js @@ -1,8 +1,9 @@ -import { useParams } from 'next/dist/client/components/hooks-client' +// TODO-APP: enable when implemented. +// import { useParams } from 'next/dist/client/components/hooks-client' export default function Page() { // This should throw an error. - useParams() + // useParams() return null } diff --git a/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/server/page.js b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/server/page.js index 4f9a6f42af71b..a5d718cbeaca6 100644 --- a/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/server/page.js +++ b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/server/page.js @@ -1,8 +1,10 @@ -import { useSelectedLayoutSegment } from 'next/dist/client/components/hooks-client' +'client' +// TODO-APP: enable once test is not skipped. +// import { useSelectedLayoutSegment } from 'next/dist/client/components/hooks-client' export default function Page() { // This should throw an error. - useSelectedLayoutSegment() + // useSelectedLayoutSegment() return null } diff --git a/test/e2e/app-dir/app/app/redirect/client-side/page.js b/test/e2e/app-dir/app/app/redirect/client-side/page.js new file mode 100644 index 0000000000000..bfcc674c991e1 --- /dev/null +++ b/test/e2e/app-dir/app/app/redirect/client-side/page.js @@ -0,0 +1,19 @@ +'client' + +import { redirect } from 'next/dist/client/components/redirect-client' +import React from 'react' + +export default function Page() { + const [shouldRedirect, setShouldRedirect] = React.useState(false) + + if (shouldRedirect) { + redirect('/redirect/result') + } + return ( + + ) +} diff --git a/test/e2e/app-dir/app/app/redirect/clientcomponent/client-component.js b/test/e2e/app-dir/app/app/redirect/clientcomponent/client-component.js new file mode 100644 index 0000000000000..5e7cb994fdc0f --- /dev/null +++ b/test/e2e/app-dir/app/app/redirect/clientcomponent/client-component.js @@ -0,0 +1,7 @@ +'client' +import { redirect } from 'next/dist/client/components/redirect' + +export default function ClientComp() { + redirect('/redirect/result') + return <> +} diff --git a/test/e2e/app-dir/app/app/redirect/clientcomponent/page.js b/test/e2e/app-dir/app/app/redirect/clientcomponent/page.js new file mode 100644 index 0000000000000..39d5495fd3fc6 --- /dev/null +++ b/test/e2e/app-dir/app/app/redirect/clientcomponent/page.js @@ -0,0 +1,8 @@ +import ClientComp from './client-component' +import { useHeaders } from 'next/dist/client/components/hooks-server' + +export default function Page() { + // Opt-in to SSR. + useHeaders() + return +} diff --git a/test/e2e/app-dir/app/app/redirect/next-config-redirect/page.js b/test/e2e/app-dir/app/app/redirect/next-config-redirect/page.js new file mode 100644 index 0000000000000..95038b8326dcd --- /dev/null +++ b/test/e2e/app-dir/app/app/redirect/next-config-redirect/page.js @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> + + To Dashboard through /redirect/a + + + ) +} diff --git a/test/e2e/app-dir/app/app/redirect/next-middleware-redirect/page.js b/test/e2e/app-dir/app/app/redirect/next-middleware-redirect/page.js new file mode 100644 index 0000000000000..49a721599e253 --- /dev/null +++ b/test/e2e/app-dir/app/app/redirect/next-middleware-redirect/page.js @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> + + To Dashboard through /redirect/a + + + ) +} diff --git a/test/e2e/app-dir/app/app/redirect/result/page.js b/test/e2e/app-dir/app/app/redirect/result/page.js new file mode 100644 index 0000000000000..6e4c13806737c --- /dev/null +++ b/test/e2e/app-dir/app/app/redirect/result/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

Result Page

+} diff --git a/test/e2e/app-dir/app/app/redirect/servercomponent/page.js b/test/e2e/app-dir/app/app/redirect/servercomponent/page.js new file mode 100644 index 0000000000000..a3b187fcb82be --- /dev/null +++ b/test/e2e/app-dir/app/app/redirect/servercomponent/page.js @@ -0,0 +1,6 @@ +import { redirect } from 'next/dist/client/components/redirect' + +export default function Page() { + redirect('/redirect/result') + return <> +} diff --git a/test/e2e/app-dir/app/middleware.js b/test/e2e/app-dir/app/middleware.js index 84563968b03f9..4dfacac7fc684 100644 --- a/test/e2e/app-dir/app/middleware.js +++ b/test/e2e/app-dir/app/middleware.js @@ -10,6 +10,10 @@ export function middleware(request) { return NextResponse.rewrite(new URL('/dashboard', request.url)) } + if (request.nextUrl.pathname === '/redirect-middleware-to-dashboard') { + return NextResponse.redirect(new URL('/dashboard', request.url)) + } + if (request.nextUrl.pathname.startsWith('/internal/test')) { const method = request.nextUrl.pathname.endsWith('rewrite') ? 'rewrite' diff --git a/test/e2e/app-dir/app/next.config.js b/test/e2e/app-dir/app/next.config.js index f42e8d268566e..ab7d00811f2d4 100644 --- a/test/e2e/app-dir/app/next.config.js +++ b/test/e2e/app-dir/app/next.config.js @@ -18,4 +18,13 @@ module.exports = { ], } }, + redirects: () => { + return [ + { + source: '/redirect/a', + destination: '/dashboard', + permanent: false, + }, + ] + }, } diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index dae1dd844ba58..9dc1ef82d10b5 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -29,9 +29,6 @@ describe('app dir', () => { 'react-dom': 'experimental', }, skipStart: true, - env: { - VERCEL_ANALYTICS_ID: 'fake-analytics-id', - }, }) await next.start() @@ -623,7 +620,7 @@ describe('app dir', () => { }) // TODO-APP: investigate hydration not kicking in on some runs - it.skip('should serve client-side', async () => { + it('should serve client-side', async () => { const browser = await webdriver(next.url, '/client-component-route') // After hydration count should be 1 @@ -643,8 +640,7 @@ describe('app dir', () => { expect($('p').text()).toBe('hello from app/client-nested') }) - // TODO-APP: investigate hydration not kicking in on some runs - it.skip('should include it client-side', async () => { + it('should include it client-side', async () => { const browser = await webdriver(next.url, '/client-nested') // After hydration count should be 1 @@ -1448,40 +1444,6 @@ describe('app dir', () => { }) }) - // Analytics events are only sent in production - ;(isDev ? describe.skip : describe)('Vercel analytics', () => { - it('should send web vitals to Vercel analytics', async () => { - let eventsCount = 0 - let countEvents = false - const browser = await webdriver(next.url, '/client-nested', { - beforePageLoad(page) { - page.route( - 'https://vitals.vercel-insights.com/v1/vitals', - (route) => { - if (countEvents) { - eventsCount += 1 - } - - route.fulfill() - } - ) - }, - }) - - // Start counting analytics events - countEvents = true - - // Refresh will trigger CLS and LCP. When page loads FCP and TTFB will trigger: - await browser.refresh() - - // After interaction LCP and FID will trigger - await browser.elementByCss('button').click() - - // Make sure all registered events in performance-relayer has fired - await check(() => eventsCount, /6/) - }) - }) - describe('known bugs', () => { it('should not share flight data between requests', async () => { const fetches = await Promise.all( @@ -1496,6 +1458,82 @@ describe('app dir', () => { } }) }) + + describe('redirect', () => { + describe('components', () => { + it.skip('should redirect in a server component', async () => { + const browser = await webdriver(next.url, '/redirect/servercomponent') + await browser.waitForElementByCss('#result-page') + expect(await browser.elementByCss('#result-page').text()).toBe( + 'Result Page' + ) + }) + + it('should redirect in a client component', async () => { + const browser = await webdriver(next.url, '/redirect/clientcomponent') + await browser.waitForElementByCss('#result-page') + expect(await browser.elementByCss('#result-page').text()).toBe( + 'Result Page' + ) + }) + + it('should redirect client-side', async () => { + const browser = await webdriver(next.url, '/redirect/client-side') + await browser + .elementByCss('button') + .click() + .waitForElementByCss('#result-page') + expect(await browser.elementByCss('#result-page').text()).toBe( + 'Result Page' + ) + }) + }) + + describe('next.config.js redirects', () => { + it('should redirect from next.config.js', async () => { + const browser = await webdriver(next.url, '/redirect/a') + expect(await browser.elementByCss('h1').text()).toBe('Dashboard') + expect(await browser.url()).toBe(next.url + '/dashboard') + }) + + it('should redirect from next.config.js with link navigation', async () => { + const browser = await webdriver( + next.url, + '/redirect/next-config-redirect' + ) + await browser + .elementByCss('#redirect-a') + .click() + .waitForElementByCss('h1') + expect(await browser.elementByCss('h1').text()).toBe('Dashboard') + expect(await browser.url()).toBe(next.url + '/dashboard') + }) + }) + + describe('middleware redirects', () => { + it('should redirect from middleware', async () => { + const browser = await webdriver( + next.url, + '/redirect-middleware-to-dashboard' + ) + expect(await browser.elementByCss('h1').text()).toBe('Dashboard') + expect(await browser.url()).toBe(next.url + '/dashboard') + }) + + it('should redirect from middleware with link navigation', async () => { + const browser = await webdriver( + next.url, + '/redirect/next-middleware-redirect' + ) + await browser + .elementByCss('#redirect-middleware') + .click() + .waitForElementByCss('h1') + expect(await browser.elementByCss('h1').text()).toBe('Dashboard') + expect(await browser.url()).toBe(next.url + '/dashboard') + }) + }) + }) } runTests() diff --git a/test/e2e/app-dir/vercel-analytics.test.ts b/test/e2e/app-dir/vercel-analytics.test.ts new file mode 100644 index 0000000000000..0e6e71d99b963 --- /dev/null +++ b/test/e2e/app-dir/vercel-analytics.test.ts @@ -0,0 +1,90 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check } from 'next-test-utils' +import path from 'path' +import webdriver from 'next-webdriver' + +describe('vercel analytics', () => { + const isDev = (global as any).isNextDev + + if ((global as any).isNextDeploy) { + it('should skip next deploy for now', () => {}) + return + } + + if (process.env.NEXT_TEST_REACT_VERSION === '^17') { + it('should skip for react v17', () => {}) + return + } + let next: NextInstance + + function runTests({ assetPrefix }: { assetPrefix?: boolean }) { + beforeAll(async () => { + next = await createNext({ + files: new FileRef(path.join(__dirname, 'app')), + dependencies: { + react: 'experimental', + 'react-dom': 'experimental', + }, + skipStart: true, + env: { + VERCEL_ANALYTICS_ID: 'fake-analytics-id', + }, + }) + + if (assetPrefix) { + const content = await next.readFile('next.config.js') + await next.patchFile( + 'next.config.js', + content + .replace('// assetPrefix', 'assetPrefix') + .replace('// beforeFiles', 'beforeFiles') + ) + } + await next.start() + }) + afterAll(() => next.destroy()) + + // Analytics events are only sent in production + ;(isDev ? describe.skip : describe)('Vercel analytics', () => { + it('should send web vitals to Vercel analytics', async () => { + let eventsCount = 0 + let countEvents = false + const browser = await webdriver(next.url, '/client-nested', { + beforePageLoad(page) { + page.route( + 'https://vitals.vercel-insights.com/v1/vitals', + (route) => { + if (countEvents) { + eventsCount += 1 + } + + route.fulfill() + } + ) + }, + }) + + // Start counting analytics events + countEvents = true + + // Refresh will trigger CLS and LCP. When page loads FCP and TTFB will trigger: + await browser.refresh() + + // After interaction LCP and FID will trigger + await browser.elementByCss('button').click() + + // Make sure all registered events in performance-relayer has fired + await check(() => eventsCount, /6/) + }) + }) + } + + describe('without assetPrefix', () => { + runTests({}) + }) + + describe('with assetPrefix', () => { + runTests({ assetPrefix: true }) + }) +})