From 2551978fa40b68882daa349019f13f8f783571f5 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Wed, 19 Oct 2022 17:04:41 -0700 Subject: [PATCH 01/14] Increase timeout-minutes for dev jobs --- .github/workflows/build_test_deploy.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_test_deploy.yml b/.github/workflows/build_test_deploy.yml index 9a8cbba18e554..1d369657b7b77 100644 --- a/.github/workflows/build_test_deploy.yml +++ b/.github/workflows/build_test_deploy.yml @@ -243,7 +243,7 @@ jobs: name: Test Development runs-on: ubuntu-latest needs: [build, build-native-test] - timeout-minutes: 30 + timeout-minutes: 35 env: NEXT_TELEMETRY_DISABLED: 1 NEXT_TEST_JOB: 1 @@ -396,7 +396,7 @@ jobs: name: Test Development (E2E) runs-on: ubuntu-latest needs: [build, build-native-test] - timeout-minutes: 30 + timeout-minutes: 35 env: NEXT_TELEMETRY_DISABLED: 1 NEXT_TEST_JOB: 1 @@ -686,7 +686,7 @@ jobs: name: Test Production (E2E) runs-on: ubuntu-latest needs: [build, build-native-test] - timeout-minutes: 30 + timeout-minutes: 35 env: NEXT_TELEMETRY_DISABLED: 1 NEXT_TEST_JOB: 1 @@ -823,7 +823,7 @@ jobs: name: Test Integration runs-on: ubuntu-latest needs: [build, build-native-test] - timeout-minutes: 30 + timeout-minutes: 35 env: NEXT_TELEMETRY_DISABLED: 1 NEXT_TEST_JOB: 1 From d261e7cd253f57efdcc35bfc7b9fd1a27f20588f Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 20 Oct 2022 02:35:43 +0200 Subject: [PATCH 02/14] Add back() and forward() to new router (#41575) Adds `router.back()` and `router.forward()`. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/client/components/app-router.tsx | 2 ++ packages/next/shared/lib/app-router-context.ts | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/next/client/components/app-router.tsx b/packages/next/client/components/app-router.tsx index 89c694031af62..024dd1ab77b08 100644 --- a/packages/next/client/components/app-router.tsx +++ b/packages/next/client/components/app-router.tsx @@ -203,6 +203,8 @@ function Router({ } const routerInstance: AppRouterInstance = { + back: () => window.history.back(), + forward: () => window.history.forward(), // TODO-APP: implement prefetching of flight prefetch: async (href) => { // If prefetch has already been triggered, don't trigger it again. diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index 5bf9e55bfd038..d0a295d764faa 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -29,6 +29,14 @@ interface NavigateOptions { } export interface AppRouterInstance { + /** + * Navigate to the previous history entry. + */ + back(): void + /** + * Navigate to the next history entry. + */ + forward(): void /** * Refresh the current page. */ From bad909e4d16d7292878bb0209538adf6ddcb3711 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Thu, 20 Oct 2022 02:39:25 +0200 Subject: [PATCH 03/14] Update page config APIs (#41580) This PR updates app configurations from `export const config = { ... }` to be directly exported. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) Co-authored-by: JJ Kasper --- .../build/analysis/get-page-static-info.ts | 71 ++++++++++++------- packages/next/build/utils.ts | 33 ++++++++- .../webpack/plugins/flight-types-plugin.ts | 7 +- packages/next/server/app-render.tsx | 4 +- .../app-dir/app-edge/app/app-edge/page.tsx | 2 +- .../app-prefetch/app/dashboard/page.js | 4 +- .../app/isr-multiple/nested/page.js | 4 +- .../app-rendering/app/ssr-only/layout.js | 4 +- .../app/static-only/nested/page.js | 4 +- .../app/static-only/slow/page.js | 4 +- .../app-static/app/(new)/custom/page.js | 4 +- .../app/blog/[author]/[slug]/page.js | 4 +- .../app-static/app/blog/[author]/page.js | 4 +- .../dynamic-no-gen-params-ssr/[slug]/page.js | 4 +- .../ssr-auto/fetch-revalidate-zero/page.js | 4 +- .../app-dir/app-static/app/ssr-forced/page.js | 4 +- .../e2e/app-dir/app-typescript/app/layout.tsx | 4 -- .../app/(rootonly)/dashboard/hello/page.js | 4 +- test/e2e/app-dir/app/app/dashboard/page.js | 4 +- .../server-component/custom-digest/page.js | 4 +- .../app/app/error/server-component/page.js | 4 +- test/e2e/app-dir/app/app/layout.js | 4 +- .../app/app/slow-page-no-loading/page.js | 4 +- .../app/app/slow-page-with-loading/page.js | 4 +- test/e2e/app-dir/next-font/app/page.js | 2 +- .../(mpa-navigation)/(route-group)/layout.js | 4 +- .../(mpa-navigation)/basic-route/layout.js | 4 +- .../app/(mpa-navigation)/dynamic/layout.js | 4 +- .../(mpa-navigation)/to-pages-dir/layout.js | 4 +- .../with-parallel-routes/layout.js | 4 +- .../app/(required-tags)/has-tags/layout.js | 4 +- .../(required-tags)/missing-tags/layout.js | 4 +- .../static-missing-tags/[slug]/page.js | 4 +- .../rsc-basic/app/edge/dynamic/[id]/page.js | 4 +- .../rsc-basic/app/edge/dynamic/page.js | 4 +- test/e2e/app-dir/rsc-basic/app/layout.js | 4 +- .../rsc-basic/app/native-module/page.js | 4 +- 37 files changed, 112 insertions(+), 131 deletions(-) diff --git a/packages/next/build/analysis/get-page-static-info.ts b/packages/next/build/analysis/get-page-static-info.ts index 589eb59ffb270..da4fb7f45e64b 100644 --- a/packages/next/build/analysis/get-page-static-info.ts +++ b/packages/next/build/analysis/get-page-static-info.ts @@ -48,11 +48,30 @@ export function getRSCModuleType(source: string): RSCModuleType { * requires a runtime to be specified. Those are: * - Modules with `export function getStaticProps | getServerSideProps` * - Modules with `export { getStaticProps | getServerSideProps } ` + * - Modules with `export const runtime = ...` */ -export function checkExports(swcAST: any): { ssr: boolean; ssg: boolean } { +function checkExports(swcAST: any): { + ssr: boolean + ssg: boolean + runtime?: string +} { if (Array.isArray(swcAST?.body)) { try { + let runtime: string | undefined + let ssr: boolean = false + let ssg: boolean = false + for (const node of swcAST.body) { + if ( + node.type === 'ExportDeclaration' && + node.declaration?.type === 'VariableDeclaration' + ) { + const id = node.declaration?.declarations[0]?.id.value + if (id === 'runtime') { + runtime = node.declaration?.declarations[0]?.init.value + } + } + if ( node.type === 'ExportDeclaration' && node.declaration?.type === 'FunctionDeclaration' && @@ -60,10 +79,8 @@ export function checkExports(swcAST: any): { ssr: boolean; ssg: boolean } { node.declaration.identifier?.value ) ) { - return { - ssg: node.declaration.identifier.value === 'getStaticProps', - ssr: node.declaration.identifier.value === 'getServerSideProps', - } + ssg = node.declaration.identifier.value === 'getStaticProps' + ssr = node.declaration.identifier.value === 'getServerSideProps' } if ( @@ -72,10 +89,8 @@ export function checkExports(swcAST: any): { ssr: boolean; ssg: boolean } { ) { const id = node.declaration?.declarations[0]?.id.value if (['getStaticProps', 'getServerSideProps'].includes(id)) { - return { - ssg: id === 'getStaticProps', - ssr: id === 'getServerSideProps', - } + ssg = id === 'getStaticProps' + ssr = id === 'getServerSideProps' } } @@ -87,16 +102,14 @@ export function checkExports(swcAST: any): { ssr: boolean; ssg: boolean } { specifier.orig?.value ) - return { - ssg: values.some((value: any) => - ['getStaticProps'].includes(value) - ), - ssr: values.some((value: any) => - ['getServerSideProps'].includes(value) - ), - } + ssg = values.some((value: any) => ['getStaticProps'].includes(value)) + ssr = values.some((value: any) => + ['getServerSideProps'].includes(value) + ) } } + + return { ssr, ssg, runtime } } catch (err) {} } @@ -270,7 +283,7 @@ export async function getPageStaticInfo(params: { ) ) { const swcAST = await parseModule(pageFilePath, fileContent) - const { ssg, ssr } = checkExports(swcAST) + const { ssg, ssr, runtime } = checkExports(swcAST) const rsc = getRSCModuleType(fileContent) // default / failsafe value for config @@ -284,12 +297,18 @@ export async function getPageStaticInfo(params: { // `export config` doesn't exist, or other unknown error throw by swc, silence them } + // Currently, we use `export const config = { runtime: '...' }` to specify the page runtime. + // But in the new app directory, we prefer to use `export const runtime = '...'` + // and deprecate the old way. To prevent breaking changes for `pages`, we use the exported config + // as the fallback value. + let resolvedRuntime = runtime || config.runtime + if ( - typeof config.runtime !== 'undefined' && - !isServerRuntime(config.runtime) + typeof resolvedRuntime !== 'undefined' && + !isServerRuntime(resolvedRuntime) ) { const options = Object.values(SERVER_RUNTIME).join(', ') - if (typeof config.runtime !== 'string') { + if (typeof resolvedRuntime !== 'string') { Log.error( `The \`runtime\` config must be a string. Please leave it empty or choose one of: ${options}` ) @@ -303,14 +322,14 @@ export async function getPageStaticInfo(params: { } } - let runtime = - SERVER_RUNTIME.edge === config?.runtime + resolvedRuntime = + SERVER_RUNTIME.edge === resolvedRuntime ? SERVER_RUNTIME.edge : ssr || ssg - ? config?.runtime || nextConfig.experimental?.runtime + ? resolvedRuntime || nextConfig.experimental?.runtime : undefined - if (runtime === SERVER_RUNTIME.edge) { + if (resolvedRuntime === SERVER_RUNTIME.edge) { warnAboutExperimentalEdgeApiFunctions() } @@ -325,7 +344,7 @@ export async function getPageStaticInfo(params: { ssg, rsc, ...(middlewareConfig && { middleware: middlewareConfig }), - ...(runtime && { runtime }), + ...(resolvedRuntime && { runtime: resolvedRuntime }), } } diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 93c5e6592d193..8165f4a417f21 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -1044,13 +1044,41 @@ export type AppConfig = { preferredRegion?: string } type GenerateParams = Array<{ - config: AppConfig + config?: AppConfig segmentPath: string getStaticPaths?: GetStaticPaths generateStaticParams?: any isLayout?: boolean }> +export const collectAppConfig = (mod: any): AppConfig | undefined => { + let hasConfig = false + + const config: AppConfig = {} + if (typeof mod?.revalidate !== 'undefined') { + config.revalidate = mod.revalidate + hasConfig = true + } + if (typeof mod?.dynamicParams !== 'undefined') { + config.dynamicParams = mod.dynamicParams + hasConfig = true + } + if (typeof mod?.dynamic !== 'undefined') { + config.dynamic = mod.dynamic + hasConfig = true + } + if (typeof mod?.fetchCache !== 'undefined') { + config.fetchCache = mod.fetchCache + hasConfig = true + } + if (typeof mod?.preferredRegion !== 'undefined') { + config.preferredRegion = mod.preferredRegion + hasConfig = true + } + + return hasConfig ? config : undefined +} + export const collectGenerateParams = async ( segment: any, parentSegments: string[] = [], @@ -1059,13 +1087,14 @@ export const collectGenerateParams = async ( if (!Array.isArray(segment)) return generateParams const isLayout = !!segment[2]?.layout const mod = await (isLayout ? segment[2]?.layout?.() : segment[2]?.page?.()) + const config = collectAppConfig(mod) const result = { isLayout, segmentPath: `/${parentSegments.join('/')}${ segment[0] && parentSegments.length > 0 ? '/' : '' }${segment[0]}`, - config: mod?.config, + config, getStaticPaths: mod?.getStaticPaths, generateStaticParams: mod?.generateStaticParams, } diff --git a/packages/next/build/webpack/plugins/flight-types-plugin.ts b/packages/next/build/webpack/plugins/flight-types-plugin.ts index bf73137589ff3..fdc716c2452ec 100644 --- a/packages/next/build/webpack/plugins/flight-types-plugin.ts +++ b/packages/next/build/webpack/plugins/flight-types-plugin.ts @@ -31,17 +31,14 @@ interface IEntry { ? `default: (props: { children: React.ReactNode; params?: any }) => React.ReactNode | null` : `default: (props: { params?: any }) => React.ReactNode | null` } + config?: {} generateStaticParams?: (params?:any) => Promise - config?: { - // TODO: remove revalidate here - revalidate?: number | boolean - ${options.type === 'page' ? 'runtime?: string' : ''} - } revalidate?: RevalidateRange | false dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static' dynamicParams?: boolean fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache' preferredRegion?: 'auto' | 'home' | 'edge' + ${options.type === 'page' ? "runtime?: 'nodejs' | 'experimental-edge'" : ''} } // ============= diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 37afe1a50de9b..704fb57473415 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -947,8 +947,8 @@ export async function renderToHTMLOrFlight( ? await page() : undefined - if (layoutOrPageMod?.config) { - defaultRevalidate = layoutOrPageMod.config.revalidate + if (typeof layoutOrPageMod?.revalidate !== 'undefined') { + defaultRevalidate = layoutOrPageMod.revalidate if (isStaticGeneration && defaultRevalidate === 0) { const { DynamicServerError } = diff --git a/test/e2e/app-dir/app-edge/app/app-edge/page.tsx b/test/e2e/app-dir/app-edge/app/app-edge/page.tsx index 3aa83203566ab..ee909102491f2 100644 --- a/test/e2e/app-dir/app-edge/app/app-edge/page.tsx +++ b/test/e2e/app-dir/app-edge/app/app-edge/page.tsx @@ -1,4 +1,4 @@ export default function Page() { return

app-edge-ssr

} -export const config = { runtime: 'experimental-edge' } +export const runtime = 'experimental-edge' diff --git a/test/e2e/app-dir/app-prefetch/app/dashboard/page.js b/test/e2e/app-dir/app-prefetch/app/dashboard/page.js index 5f2d416fa6cfe..0649c4eb968f7 100644 --- a/test/e2e/app-dir/app-prefetch/app/dashboard/page.js +++ b/test/e2e/app-dir/app-prefetch/app/dashboard/page.js @@ -1,8 +1,6 @@ import { experimental_use as use } from 'react' -export const config = { - revalidate: 0, -} +export const revalidate = 0 async function getData() { await new Promise((resolve) => setTimeout(resolve, 3000)) diff --git a/test/e2e/app-dir/app-rendering/app/isr-multiple/nested/page.js b/test/e2e/app-dir/app-rendering/app/isr-multiple/nested/page.js index 4b39fb2fb7f52..9957606085263 100644 --- a/test/e2e/app-dir/app-rendering/app/isr-multiple/nested/page.js +++ b/test/e2e/app-dir/app-rendering/app/isr-multiple/nested/page.js @@ -1,8 +1,6 @@ import { experimental_use as use } from 'react' -export const config = { - revalidate: 1, -} +export const revalidate = 1 async function getData() { return { diff --git a/test/e2e/app-dir/app-rendering/app/ssr-only/layout.js b/test/e2e/app-dir/app-rendering/app/ssr-only/layout.js index 81fc9425465b8..8a85c4d14bb7e 100644 --- a/test/e2e/app-dir/app-rendering/app/ssr-only/layout.js +++ b/test/e2e/app-dir/app-rendering/app/ssr-only/layout.js @@ -1,8 +1,6 @@ import { experimental_use as use } from 'react' -export const config = { - revalidate: 0, -} +export const revalidate = 0 async function getData() { return { diff --git a/test/e2e/app-dir/app-rendering/app/static-only/nested/page.js b/test/e2e/app-dir/app-rendering/app/static-only/nested/page.js index c6206115e7f0a..df8cf59f917a3 100644 --- a/test/e2e/app-dir/app-rendering/app/static-only/nested/page.js +++ b/test/e2e/app-dir/app-rendering/app/static-only/nested/page.js @@ -1,8 +1,6 @@ import { experimental_use as use } from 'react' -export const config = { - revalidate: false, -} +export const revalidate = false async function getData() { return { diff --git a/test/e2e/app-dir/app-rendering/app/static-only/slow/page.js b/test/e2e/app-dir/app-rendering/app/static-only/slow/page.js index d16e406b0e453..df659a579fd49 100644 --- a/test/e2e/app-dir/app-rendering/app/static-only/slow/page.js +++ b/test/e2e/app-dir/app-rendering/app/static-only/slow/page.js @@ -1,8 +1,6 @@ import { experimental_use as use } from 'react' -export const config = { - revalidate: false, -} +export const revalidate = false async function getData() { await new Promise((resolve) => setTimeout(resolve, 5000)) diff --git a/test/e2e/app-dir/app-static/app/(new)/custom/page.js b/test/e2e/app-dir/app-static/app/(new)/custom/page.js index ddc2d8b0fd754..bb9db4f2e7325 100644 --- a/test/e2e/app-dir/app-static/app/(new)/custom/page.js +++ b/test/e2e/app-dir/app-static/app/(new)/custom/page.js @@ -1,6 +1,4 @@ -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Page() { return

new root ssr

diff --git a/test/e2e/app-dir/app-static/app/blog/[author]/[slug]/page.js b/test/e2e/app-dir/app-static/app/blog/[author]/[slug]/page.js index ee301c119b61b..f597e830916ff 100644 --- a/test/e2e/app-dir/app-static/app/blog/[author]/[slug]/page.js +++ b/test/e2e/app-dir/app-static/app/blog/[author]/[slug]/page.js @@ -1,6 +1,4 @@ -export const config = { - dynamicParams: true, -} +export const dynamicParams = true export default function Page({ params }) { return ( diff --git a/test/e2e/app-dir/app-static/app/blog/[author]/page.js b/test/e2e/app-dir/app-static/app/blog/[author]/page.js index cbdefb1dd8837..18ff1b30cde8d 100644 --- a/test/e2e/app-dir/app-static/app/blog/[author]/page.js +++ b/test/e2e/app-dir/app-static/app/blog/[author]/page.js @@ -1,8 +1,6 @@ import Link from 'next/link' -export const config = { - dynamicParams: false, -} +export const dynamicParams = false export default function Page({ params }) { return ( diff --git a/test/e2e/app-dir/app-static/app/dynamic-no-gen-params-ssr/[slug]/page.js b/test/e2e/app-dir/app-static/app/dynamic-no-gen-params-ssr/[slug]/page.js index 972c1ec17d586..834d9077d28d1 100644 --- a/test/e2e/app-dir/app-static/app/dynamic-no-gen-params-ssr/[slug]/page.js +++ b/test/e2e/app-dir/app-static/app/dynamic-no-gen-params-ssr/[slug]/page.js @@ -1,6 +1,4 @@ -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Page({ params }) { return ( diff --git a/test/e2e/app-dir/app-static/app/ssr-auto/fetch-revalidate-zero/page.js b/test/e2e/app-dir/app-static/app/ssr-auto/fetch-revalidate-zero/page.js index d8d2688196c37..c72ab71d05da3 100644 --- a/test/e2e/app-dir/app-static/app/ssr-auto/fetch-revalidate-zero/page.js +++ b/test/e2e/app-dir/app-static/app/ssr-auto/fetch-revalidate-zero/page.js @@ -20,6 +20,4 @@ export default function Page() { } // TODO-APP: remove revalidate config once next.revalidate is supported -export const config = { - revalidate: 0, -} +export const revalidate = 0 diff --git a/test/e2e/app-dir/app-static/app/ssr-forced/page.js b/test/e2e/app-dir/app-static/app/ssr-forced/page.js index 7c81b5c70ba10..e3ec469edbee1 100644 --- a/test/e2e/app-dir/app-static/app/ssr-forced/page.js +++ b/test/e2e/app-dir/app-static/app/ssr-forced/page.js @@ -1,6 +1,4 @@ -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Page() { return ( diff --git a/test/e2e/app-dir/app-typescript/app/layout.tsx b/test/e2e/app-dir/app-typescript/app/layout.tsx index e81e33f88cf6b..88d93c9a1395a 100644 --- a/test/e2e/app-dir/app-typescript/app/layout.tsx +++ b/test/e2e/app-dir/app-typescript/app/layout.tsx @@ -1,9 +1,5 @@ /* eslint-disable */ -export const config = { - revalidate: 0, -} - export const revalidate = -1 export default function Root({ children }) { diff --git a/test/e2e/app-dir/app/app/(rootonly)/dashboard/hello/page.js b/test/e2e/app-dir/app/app/(rootonly)/dashboard/hello/page.js index 20fef22a73245..0b83235a5f40c 100644 --- a/test/e2e/app-dir/app/app/(rootonly)/dashboard/hello/page.js +++ b/test/e2e/app-dir/app/app/(rootonly)/dashboard/hello/page.js @@ -6,6 +6,4 @@ export default function HelloPage(props) { ) } -export const config = { - runtime: 'experimental-edge', -} +export const runtime = 'experimental-edge' diff --git a/test/e2e/app-dir/app/app/dashboard/page.js b/test/e2e/app-dir/app/app/dashboard/page.js index 966a2a3e03b0b..5a3e15c25c935 100644 --- a/test/e2e/app-dir/app/app/dashboard/page.js +++ b/test/e2e/app-dir/app/app/dashboard/page.js @@ -13,6 +13,4 @@ export default function DashboardPage(props) { ) } -export const config = { - runtime: 'experimental-edge', -} +export const runtime = 'experimental-edge' diff --git a/test/e2e/app-dir/app/app/error/server-component/custom-digest/page.js b/test/e2e/app-dir/app/app/error/server-component/custom-digest/page.js index 4bb541ae218e6..3c1af09b5b8da 100644 --- a/test/e2e/app-dir/app/app/error/server-component/custom-digest/page.js +++ b/test/e2e/app-dir/app/app/error/server-component/custom-digest/page.js @@ -1,6 +1,4 @@ -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Page() { const err = new Error('this is a test') diff --git a/test/e2e/app-dir/app/app/error/server-component/page.js b/test/e2e/app-dir/app/app/error/server-component/page.js index f771b09bbbfd1..35108577dc03d 100644 --- a/test/e2e/app-dir/app/app/error/server-component/page.js +++ b/test/e2e/app-dir/app/app/error/server-component/page.js @@ -1,6 +1,4 @@ -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Page() { throw new Error('this is a test') diff --git a/test/e2e/app-dir/app/app/layout.js b/test/e2e/app-dir/app/app/layout.js index d632f8dab8d5f..f927b818ea39e 100644 --- a/test/e2e/app-dir/app/app/layout.js +++ b/test/e2e/app-dir/app/app/layout.js @@ -3,9 +3,7 @@ import { experimental_use as use } from 'react' import '../styles/global.css' import './style.css' -export const config = { - revalidate: 0, -} +export const revalidate = 0 async function getData() { return { diff --git a/test/e2e/app-dir/app/app/slow-page-no-loading/page.js b/test/e2e/app-dir/app/app/slow-page-no-loading/page.js index c92356c4175bd..07c80d148e092 100644 --- a/test/e2e/app-dir/app/app/slow-page-no-loading/page.js +++ b/test/e2e/app-dir/app/app/slow-page-no-loading/page.js @@ -13,6 +13,4 @@ export default function SlowPage(props) { return

{data.message}

} -export const config = { - runtime: 'experimental-edge', -} +export const runtime = 'experimental-edge' diff --git a/test/e2e/app-dir/app/app/slow-page-with-loading/page.js b/test/e2e/app-dir/app/app/slow-page-with-loading/page.js index c92356c4175bd..07c80d148e092 100644 --- a/test/e2e/app-dir/app/app/slow-page-with-loading/page.js +++ b/test/e2e/app-dir/app/app/slow-page-with-loading/page.js @@ -13,6 +13,4 @@ export default function SlowPage(props) { return

{data.message}

} -export const config = { - runtime: 'experimental-edge', -} +export const runtime = 'experimental-edge' diff --git a/test/e2e/app-dir/next-font/app/page.js b/test/e2e/app-dir/next-font/app/page.js index 4d2b19abd5385..32dcdf6c7997a 100644 --- a/test/e2e/app-dir/next-font/app/page.js +++ b/test/e2e/app-dir/next-font/app/page.js @@ -13,4 +13,4 @@ export default function HomePage() { ) } -export const config = { runtime: 'experimental-edge' } +export const runtime = 'experimental-edge' diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/(route-group)/layout.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/(route-group)/layout.js index cfbc1de9ce956..2efc75f50266f 100644 --- a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/(route-group)/layout.js +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/(route-group)/layout.js @@ -1,7 +1,5 @@ // TODO-APP: remove after fixing filtering static flight data -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Root({ children }) { return ( diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/basic-route/layout.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/basic-route/layout.js index cfbc1de9ce956..2efc75f50266f 100644 --- a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/basic-route/layout.js +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/basic-route/layout.js @@ -1,7 +1,5 @@ // TODO-APP: remove after fixing filtering static flight data -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Root({ children }) { return ( diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/layout.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/layout.js index cfbc1de9ce956..2efc75f50266f 100644 --- a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/layout.js +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/layout.js @@ -1,7 +1,5 @@ // TODO-APP: remove after fixing filtering static flight data -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Root({ children }) { return ( diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/to-pages-dir/layout.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/to-pages-dir/layout.js index cfbc1de9ce956..2efc75f50266f 100644 --- a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/to-pages-dir/layout.js +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/to-pages-dir/layout.js @@ -1,7 +1,5 @@ // TODO-APP: remove after fixing filtering static flight data -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Root({ children }) { return ( diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/layout.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/layout.js index bd7c2817d37a5..25648360d8d0a 100644 --- a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/layout.js +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/layout.js @@ -1,7 +1,5 @@ // TODO-APP: remove after fixing filtering static flight data -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Root({ one, two }) { return ( diff --git a/test/e2e/app-dir/root-layout/app/(required-tags)/has-tags/layout.js b/test/e2e/app-dir/root-layout/app/(required-tags)/has-tags/layout.js index 8d949068ce5b6..f8fd9c6d9b3ea 100644 --- a/test/e2e/app-dir/root-layout/app/(required-tags)/has-tags/layout.js +++ b/test/e2e/app-dir/root-layout/app/(required-tags)/has-tags/layout.js @@ -1,7 +1,5 @@ // TODO-APP: remove after fixing filtering static flight data -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Root({ children }) { return ( diff --git a/test/e2e/app-dir/root-layout/app/(required-tags)/missing-tags/layout.js b/test/e2e/app-dir/root-layout/app/(required-tags)/missing-tags/layout.js index 82917da557b6c..a77457c91e08d 100644 --- a/test/e2e/app-dir/root-layout/app/(required-tags)/missing-tags/layout.js +++ b/test/e2e/app-dir/root-layout/app/(required-tags)/missing-tags/layout.js @@ -1,7 +1,5 @@ // TODO-APP: remove after fixing filtering static flight data -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function Root({ children }) { return children diff --git a/test/e2e/app-dir/root-layout/app/(required-tags)/static-missing-tags/[slug]/page.js b/test/e2e/app-dir/root-layout/app/(required-tags)/static-missing-tags/[slug]/page.js index 19089f791f438..38449dcc2feaa 100644 --- a/test/e2e/app-dir/root-layout/app/(required-tags)/static-missing-tags/[slug]/page.js +++ b/test/e2e/app-dir/root-layout/app/(required-tags)/static-missing-tags/[slug]/page.js @@ -1,6 +1,4 @@ -export const config = { - dynamicParams: false, -} +export const dynamicParams = false export default function Page({ params }) { return

Static page

diff --git a/test/e2e/app-dir/rsc-basic/app/edge/dynamic/[id]/page.js b/test/e2e/app-dir/rsc-basic/app/edge/dynamic/[id]/page.js index 87bac2ff6be9e..6f2b162798eb9 100644 --- a/test/e2e/app-dir/rsc-basic/app/edge/dynamic/[id]/page.js +++ b/test/e2e/app-dir/rsc-basic/app/edge/dynamic/[id]/page.js @@ -2,6 +2,4 @@ export default function page() { return 'dynamic route [id] page' } -export const config = { - runtime: 'experimental-edge', -} +export const runtime = 'experimental-edge' diff --git a/test/e2e/app-dir/rsc-basic/app/edge/dynamic/page.js b/test/e2e/app-dir/rsc-basic/app/edge/dynamic/page.js index 1b83f0120a6ab..356b786c602f3 100644 --- a/test/e2e/app-dir/rsc-basic/app/edge/dynamic/page.js +++ b/test/e2e/app-dir/rsc-basic/app/edge/dynamic/page.js @@ -2,6 +2,4 @@ export default function page() { return 'dynamic route index page' } -export const config = { - runtime: 'experimental-edge', -} +export const runtime = 'experimental-edge' diff --git a/test/e2e/app-dir/rsc-basic/app/layout.js b/test/e2e/app-dir/rsc-basic/app/layout.js index 55cded827d1a8..e9a9ebba67353 100644 --- a/test/e2e/app-dir/rsc-basic/app/layout.js +++ b/test/e2e/app-dir/rsc-basic/app/layout.js @@ -1,9 +1,7 @@ import React from 'react' import RootStyleRegistry from './root-style-registry' -export const config = { - revalidate: 0, -} +export const revalidate = 0 export default function AppLayout({ children }) { return ( diff --git a/test/e2e/app-dir/rsc-basic/app/native-module/page.js b/test/e2e/app-dir/rsc-basic/app/native-module/page.js index 67dffb1a5259f..2afddefe3f60a 100644 --- a/test/e2e/app-dir/rsc-basic/app/native-module/page.js +++ b/test/e2e/app-dir/rsc-basic/app/native-module/page.js @@ -11,6 +11,4 @@ export default function Page() { ) } -export const config = { - runtime: 'nodejs', -} +export const runtime = 'nodejs' From 4adab6a61e2e624f26e6685fc67dc2511e97e135 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Thu, 20 Oct 2022 02:45:37 +0200 Subject: [PATCH 04/14] Improve server bundling strategy (#41584) This PR adds a list of popular packages that should always opt-out bundling in the server layer. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/build/webpack-config.ts | 22 +++++++------------ packages/next/lib/server-external-packages.ts | 20 +++++++++++++++++ 2 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 packages/next/lib/server-external-packages.ts diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 0a4c5300bba37..cc29ba8960e7d 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -13,6 +13,7 @@ import { WEBPACK_LAYERS, RSC_MOD_REF_PROXY_ALIAS, } from '../lib/constants' +import { EXTERNAL_PACKAGES } from '../lib/server-external-packages' import { fileExists } from '../lib/file-exists' import { CustomRoutes } from '../lib/load-custom-routes.js' import { @@ -1008,6 +1009,10 @@ export default async function getBaseWebpackConfig( const crossOrigin = config.crossOrigin const looseEsmExternals = config.experimental?.esmExternals === 'loose' + const optoutBundlingPackages = EXTERNAL_PACKAGES.concat( + ...(config.experimental.serverComponentsExternalPackages || []) + ) + async function handleExternals( context: string, request: string, @@ -1166,12 +1171,7 @@ export default async function getBaseWebpackConfig( if (/node_modules[/\\].*\.[mc]?js$/.test(res)) { if (layer === WEBPACK_LAYERS.server) { // All packages should be bundled for the server layer if they're not opted out. - if ( - isResourceInPackages( - res, - config.experimental.serverComponentsExternalPackages - ) - ) { + if (isResourceInPackages(res, optoutBundlingPackages)) { return `${externalType} ${request}` } @@ -1545,10 +1545,7 @@ export default async function getBaseWebpackConfig( // bundling, don't resolve it. if ( !codeCondition.test.test(req) || - isResourceInPackages( - req, - config.experimental.serverComponentsExternalPackages - ) + isResourceInPackages(req, optoutBundlingPackages) ) { return false } @@ -1612,10 +1609,7 @@ export default async function getBaseWebpackConfig( // bundling, don't resolve it. if ( !codeCondition.test.test(req) || - isResourceInPackages( - req, - config.experimental.serverComponentsExternalPackages - ) + isResourceInPackages(req, optoutBundlingPackages) ) { return false } diff --git a/packages/next/lib/server-external-packages.ts b/packages/next/lib/server-external-packages.ts new file mode 100644 index 0000000000000..5fc42d5ef7613 --- /dev/null +++ b/packages/next/lib/server-external-packages.ts @@ -0,0 +1,20 @@ +// A list of popular packages that cannot be bundled on the server. +export const EXTERNAL_PACKAGES = [ + 'eslint', + 'typescript', + 'prettier', + 'postcss', + 'jest', + 'autoprefixer', + 'tailwindcss', + 'sharp', + 'express', + 'ts-node', + 'webpack', + 'cypress', + '@sentry/nextjs', + '@sentry/node', + 'next-seo', + 'rimraf', + 'next-mdx-remote', +] From cec9d02dee2ab2c6d100ad3cbcafa4c8d6ab4805 Mon Sep 17 00:00:00 2001 From: Marcial Cambronero Date: Wed, 19 Oct 2022 18:51:51 -0600 Subject: [PATCH 05/14] Small change to note `` executes server methods (#41577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I stumbled upon a lack of clarity when using `` where I believed it didn't execute the server methods before navigation. This change makes it a bit more clear ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) Co-authored-by: BalΓ‘zs OrbΓ‘n <18369201+balazsorban44@users.noreply.github.com> Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com> --- docs/routing/introduction.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/routing/introduction.md b/docs/routing/introduction.md index 4fb506cbf7fa7..662c395937ff5 100644 --- a/docs/routing/introduction.md +++ b/docs/routing/introduction.md @@ -68,7 +68,7 @@ The example above uses multiple links. Each one maps a path (`href`) to a known - `/about` β†’ `pages/about.js` - `/blog/hello-world` β†’ `pages/blog/[slug].js` -Any `` in the viewport (initially or through scroll) will be prefetched by default (including the corresponding data) for pages using [Static Generation](/docs/basic-features/data-fetching/get-static-props.md). The corresponding data for [server-rendered](/docs/basic-features/data-fetching/get-server-side-props.md) routes is _not_ prefetched. +Any `` in the viewport (initially or through scroll) will be prefetched by default (including the corresponding data) for pages using [Static Generation](/docs/basic-features/data-fetching/get-static-props.md). The corresponding data for [server-rendered](/docs/basic-features/data-fetching/get-server-side-props.md) routes is fetched _only when_ the is clicked. ### Linking to dynamic paths From b0db95ffc612f991b19f9746adf6653785a33bec Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Thu, 20 Oct 2022 02:56:14 +0200 Subject: [PATCH 06/14] Update error message for invalid react-dom/server imports (#41582) Related discussion: https://vercel.slack.com/archives/C043ANYDB24/p1666208186363099?thread_ts=1664676119.265829&cid=C043ANYDB24. Tl;dr: RSC already renders and serializes things dynamically, there is no need to manually do that in RSC and embed them via dangerouslySetInnerHTML. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- .../plugins/wellknown-errors-plugin/parseRSC.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/next/build/webpack/plugins/wellknown-errors-plugin/parseRSC.ts b/packages/next/build/webpack/plugins/wellknown-errors-plugin/parseRSC.ts index 107d5971c7d09..7995032ac9726 100644 --- a/packages/next/build/webpack/plugins/wellknown-errors-plugin/parseRSC.ts +++ b/packages/next/build/webpack/plugins/wellknown-errors-plugin/parseRSC.ts @@ -24,10 +24,16 @@ export function formatRSCErrorMessage( formattedVerboseMessage = '\n\nMaybe one of these should be marked as a client entry with "use client":\n' } else if (NEXT_RSC_ERR_SERVER_IMPORT.test(message)) { - formattedMessage = message.replace( - NEXT_RSC_ERR_SERVER_IMPORT, - `\n\nYou're importing a component that imports $1. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.\n\n` - ) + const matches = message.match(NEXT_RSC_ERR_SERVER_IMPORT) + if (matches && matches[1] === 'react-dom/server') { + // If importing "react-dom/server", we should show a different error. + formattedMessage = `\n\nYou're importing a component that imports react-dom/server. To fix it, render or return the content directly as a Server Component instead for perf and security.` + } else { + formattedMessage = message.replace( + NEXT_RSC_ERR_SERVER_IMPORT, + `\n\nYou're importing a component that imports $1. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.\n\n` + ) + } formattedVerboseMessage = '\n\nMaybe one of these should be marked as a client entry "use client":\n' } else if (NEXT_RSC_ERR_CLIENT_IMPORT.test(message)) { From 765791a6d1fe56bf4247ea676f4ef8a0f803c034 Mon Sep 17 00:00:00 2001 From: Seiya Nuta Date: Thu, 20 Oct 2022 10:36:05 +0900 Subject: [PATCH 07/14] Support overriding request headers in middlewares (#41380) This PR adds a feature in middleware to add, modify, or delete request headers. This feature is quite useful to pass data from middleware to Serverless/Edge API routes. ### Questions for Reviewers - Should we deny modifying standard request headers like `Transfer-Encoding`? - Should we throw an error if the header is too large? Real-world HTTP servers will accept up to only 8KB - 32KB. ### New APIs Adds a new option `request.headers` to the `MiddlewareResponseInit` parameter in `NextResponse.next()` and `NextResponse.rewrite()`. It's a [`Header`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object holding *all* request headers. Specifically: ```ts interface MiddlewareResponseInit extends ResponseInit { request?: { headers?: Headers } } ``` ### Example ```ts // pages/api/hello.ts export default (req, res) => { const valueFromMiddleware = req.headers['x-hello-from-middleware'] return res.send(valueFromMiddleware) } // middleware.ts import { NextRequest, NextResponse } from 'next/server' export default function middleware(request: NextRequest) { // Clone request headers const headers = new Headers(request.headers); // Add a new request header headers.set('x-hello-from-middleware', 'foo'); // Delete a request header from the client headers.delete('x-from-client'); const resp = NextResponse.next({ // New option `request.headers` which accepts a Headers object // overrides request headers with the specified new ones. request: { headers } }); // You can still set *response* headers to the client, as before. resp.headers.set('x-hello-client', 'bar'); return resp; } ``` ### New middleware headers - `x-middleware-override-headers`: A comma separated list of *all* request header names. Headers not listed will be deleted. - `x-middleware-request-`: A new value for the header ``. ## Related Discussions - https://github.com/vercel/next.js/discussions/31188 - https://github.com/vercel/next.js/discussions/39300 ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [x] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [x] Integration tests added - [x] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/server/next-server.ts | 31 +++++ .../server/web/spec-extension/response.ts | 44 +++++- test/e2e/app-dir/app-middleware.test.ts | 129 ++++++++++++++++++ .../app-middleware/app/headers/page.js | 10 ++ test/e2e/app-dir/app-middleware/app/layout.js | 10 ++ test/e2e/app-dir/app-middleware/middleware.js | 30 ++++ .../e2e/app-dir/app-middleware/next.config.js | 7 + .../pages/api/dump-headers-edge.js | 11 ++ .../pages/api/dump-headers-serverless.js | 6 + .../app/.gitignore | 1 + .../app/middleware.js | 30 ++++ .../app/next.config.js | 1 + .../app/pages/api/dump-headers-edge.js | 11 ++ .../app/pages/api/dump-headers-serverless.js | 6 + .../app/pages/ssr-page.js | 15 ++ .../test/index.test.ts | 119 ++++++++++++++++ 16 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 test/e2e/app-dir/app-middleware.test.ts create mode 100644 test/e2e/app-dir/app-middleware/app/headers/page.js create mode 100644 test/e2e/app-dir/app-middleware/app/layout.js create mode 100644 test/e2e/app-dir/app-middleware/middleware.js create mode 100644 test/e2e/app-dir/app-middleware/next.config.js create mode 100644 test/e2e/app-dir/app-middleware/pages/api/dump-headers-edge.js create mode 100644 test/e2e/app-dir/app-middleware/pages/api/dump-headers-serverless.js create mode 100644 test/e2e/middleware-request-header-overrides/app/.gitignore create mode 100644 test/e2e/middleware-request-header-overrides/app/middleware.js create mode 100644 test/e2e/middleware-request-header-overrides/app/next.config.js create mode 100644 test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-edge.js create mode 100644 test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-serverless.js create mode 100644 test/e2e/middleware-request-header-overrides/app/pages/ssr-page.js create mode 100644 test/e2e/middleware-request-header-overrides/test/index.test.ts diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 34aca262a5955..fec28ec7d022b 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -1882,6 +1882,37 @@ export default class NextNodeServer extends BaseServer { result.response.headers.set('x-middleware-rewrite', rel) } + if (result.response.headers.has('x-middleware-override-headers')) { + const overriddenHeaders: Set = new Set() + for (const key of result.response.headers + .get('x-middleware-override-headers')! + .split(',')) { + overriddenHeaders.add(key.trim()) + } + + result.response.headers.delete('x-middleware-override-headers') + + // Delete headers. + for (const key of Object.keys(req.headers)) { + if (!overriddenHeaders.has(key)) { + delete req.headers[key] + } + } + + // Update or add headers. + for (const key of overriddenHeaders.keys()) { + const valueKey = 'x-middleware-request-' + key + const newValue = result.response.headers.get(valueKey) + const oldValue = req.headers[key] + + if (oldValue !== newValue) { + req.headers[key] = newValue === null ? undefined : newValue + } + + result.response.headers.delete(valueKey) + } + } + if (result.response.headers.has('Location')) { const value = result.response.headers.get('Location')! const rel = relativizeURL(value, initUrl) diff --git a/packages/next/server/web/spec-extension/response.ts b/packages/next/server/web/spec-extension/response.ts index 2e19ff0f60c1c..386353d897ba6 100644 --- a/packages/next/server/web/spec-extension/response.ts +++ b/packages/next/server/web/spec-extension/response.ts @@ -7,6 +7,25 @@ import { NextCookies } from './cookies' const INTERNALS = Symbol('internal response') const REDIRECTS = new Set([301, 302, 303, 307, 308]) +function handleMiddlewareField( + init: MiddlewareResponseInit | undefined, + headers: Headers +) { + if (init?.request?.headers) { + if (!(init.request.headers instanceof Headers)) { + throw new Error('request.headers must be an instance of Headers') + } + + const keys = [] + for (const [key, value] of init.request.headers) { + headers.set('x-middleware-request-' + key, value) + keys.push(key) + } + + headers.set('x-middleware-override-headers', keys.join(',')) + } +} + export class NextResponse extends Response { [INTERNALS]: { cookies: NextCookies @@ -71,15 +90,22 @@ export class NextResponse extends Response { }) } - static rewrite(destination: string | NextURL | URL, init?: ResponseInit) { + static rewrite( + destination: string | NextURL | URL, + init?: MiddlewareResponseInit + ) { const headers = new Headers(init?.headers) headers.set('x-middleware-rewrite', validateURL(destination)) + + handleMiddlewareField(init, headers) return new NextResponse(null, { ...init, headers }) } - static next(init?: ResponseInit) { + static next(init?: MiddlewareResponseInit) { const headers = new Headers(init?.headers) headers.set('x-middleware-next', '1') + + handleMiddlewareField(init, headers) return new NextResponse(null, { ...init, headers }) } } @@ -92,3 +118,17 @@ interface ResponseInit extends globalThis.ResponseInit { } url?: string } + +interface ModifiedRequest { + /** + * If this is set, the request headers will be overridden with this value. + */ + headers?: Headers +} + +interface MiddlewareResponseInit extends globalThis.ResponseInit { + /** + * These fields will override the request from clients. + */ + request?: ModifiedRequest +} diff --git a/test/e2e/app-dir/app-middleware.test.ts b/test/e2e/app-dir/app-middleware.test.ts new file mode 100644 index 0000000000000..b92ffa9352e9e --- /dev/null +++ b/test/e2e/app-dir/app-middleware.test.ts @@ -0,0 +1,129 @@ +/* eslint-env jest */ + +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import cheerio from 'cheerio' +import path from 'path' + +describe('app-dir with middleware', () => { + 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 + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: new FileRef(path.join(__dirname, 'app-middleware')), + dependencies: { + react: 'experimental', + 'react-dom': 'experimental', + }, + }) + }) + + describe.each([ + { + title: 'Serverless Functions', + path: '/api/dump-headers-serverless', + toJson: (res: Response) => res.json(), + }, + { + title: 'Edge Functions', + path: '/api/dump-headers-edge', + toJson: (res: Response) => res.json(), + }, + { + title: 'next/headers', + path: '/headers', + toJson: async (res: Response) => { + const $ = cheerio.load(await res.text()) + return JSON.parse($('#headers').text()) + }, + }, + ])('Mutate request headers for $title', ({ path, toJson }) => { + it(`Adds new headers`, async () => { + const res = await fetchViaHTTP(next.url, path, null, { + headers: { + 'x-from-client': 'hello-from-client', + }, + }) + expect(await toJson(res)).toMatchObject({ + 'x-from-client': 'hello-from-client', + 'x-from-middleware': 'hello-from-middleware', + }) + }) + + it(`Deletes headers`, async () => { + const res = await fetchViaHTTP( + next.url, + path, + { + 'remove-headers': 'x-from-client1,x-from-client2', + }, + { + headers: { + 'x-from-client1': 'hello-from-client', + 'X-From-Client2': 'hello-from-client', + }, + } + ) + + const json = await toJson(res) + expect(json).not.toHaveProperty('x-from-client1') + expect(json).not.toHaveProperty('X-From-Client2') + expect(json).toMatchObject({ + 'x-from-middleware': 'hello-from-middleware', + }) + + // Should not be included in response headers. + expect(res.headers.get('x-middleware-override-headers')).toBeNull() + expect( + res.headers.get('x-middleware-request-x-from-middleware') + ).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client1')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client2')).toBeNull() + }) + + it(`Updates headers`, async () => { + const res = await fetchViaHTTP( + next.url, + path, + { + 'update-headers': + 'x-from-client1=new-value1,x-from-client2=new-value2', + }, + { + headers: { + 'x-from-client1': 'old-value1', + 'X-From-Client2': 'old-value2', + 'x-from-client3': 'old-value3', + }, + } + ) + expect(await toJson(res)).toMatchObject({ + 'x-from-client1': 'new-value1', + 'x-from-client2': 'new-value2', + 'x-from-client3': 'old-value3', + 'x-from-middleware': 'hello-from-middleware', + }) + + // Should not be included in response headers. + expect(res.headers.get('x-middleware-override-headers')).toBeNull() + expect( + res.headers.get('x-middleware-request-x-from-middleware') + ).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client1')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client2')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client3')).toBeNull() + }) + }) +}) diff --git a/test/e2e/app-dir/app-middleware/app/headers/page.js b/test/e2e/app-dir/app-middleware/app/headers/page.js new file mode 100644 index 0000000000000..7e9456f738014 --- /dev/null +++ b/test/e2e/app-dir/app-middleware/app/headers/page.js @@ -0,0 +1,10 @@ +import { headers } from 'next/headers' + +export default function SSRPage() { + const headersObj = Object.fromEntries(headers()) + return ( + <> +

{JSON.stringify(headersObj)}

+ + ) +} diff --git a/test/e2e/app-dir/app-middleware/app/layout.js b/test/e2e/app-dir/app-middleware/app/layout.js new file mode 100644 index 0000000000000..3a1af60bc8b98 --- /dev/null +++ b/test/e2e/app-dir/app-middleware/app/layout.js @@ -0,0 +1,10 @@ +export default function Layout({ children }) { + return ( + + + app-middleware + + {children} + + ) +} diff --git a/test/e2e/app-dir/app-middleware/middleware.js b/test/e2e/app-dir/app-middleware/middleware.js new file mode 100644 index 0000000000000..4421a4f37f426 --- /dev/null +++ b/test/e2e/app-dir/app-middleware/middleware.js @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' + +/** + * @param {import('next/server').NextRequest} request + */ +export async function middleware(request) { + const headers = new Headers(request.headers) + headers.set('x-from-middleware', 'hello-from-middleware') + + const removeHeaders = request.nextUrl.searchParams.get('remove-headers') + if (removeHeaders) { + for (const key of removeHeaders.split(',')) { + headers.delete(key) + } + } + + const updateHeader = request.nextUrl.searchParams.get('update-headers') + if (updateHeader) { + for (const kv of updateHeader.split(',')) { + const [key, value] = kv.split('=') + headers.set(key, value) + } + } + + return NextResponse.next({ + request: { + headers, + }, + }) +} diff --git a/test/e2e/app-dir/app-middleware/next.config.js b/test/e2e/app-dir/app-middleware/next.config.js new file mode 100644 index 0000000000000..a928ea943ce24 --- /dev/null +++ b/test/e2e/app-dir/app-middleware/next.config.js @@ -0,0 +1,7 @@ +module.exports = { + experimental: { + appDir: true, + legacyBrowsers: false, + browsersListForSwc: true, + }, +} diff --git a/test/e2e/app-dir/app-middleware/pages/api/dump-headers-edge.js b/test/e2e/app-dir/app-middleware/pages/api/dump-headers-edge.js new file mode 100644 index 0000000000000..0ece8ea2c7518 --- /dev/null +++ b/test/e2e/app-dir/app-middleware/pages/api/dump-headers-edge.js @@ -0,0 +1,11 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default (req) => { + return Response.json(Object.fromEntries(req.headers.entries()), { + headers: { + 'headers-from-edge-function': '1', + }, + }) +} diff --git a/test/e2e/app-dir/app-middleware/pages/api/dump-headers-serverless.js b/test/e2e/app-dir/app-middleware/pages/api/dump-headers-serverless.js new file mode 100644 index 0000000000000..0f1a9262d9cd0 --- /dev/null +++ b/test/e2e/app-dir/app-middleware/pages/api/dump-headers-serverless.js @@ -0,0 +1,6 @@ +export default (req, res) => { + return res + .status(200) + .setHeader('headers-from-serverless', '1') + .json(req.headers) +} diff --git a/test/e2e/middleware-request-header-overrides/app/.gitignore b/test/e2e/middleware-request-header-overrides/app/.gitignore new file mode 100644 index 0000000000000..e985853ed84ac --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/test/e2e/middleware-request-header-overrides/app/middleware.js b/test/e2e/middleware-request-header-overrides/app/middleware.js new file mode 100644 index 0000000000000..4421a4f37f426 --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/middleware.js @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' + +/** + * @param {import('next/server').NextRequest} request + */ +export async function middleware(request) { + const headers = new Headers(request.headers) + headers.set('x-from-middleware', 'hello-from-middleware') + + const removeHeaders = request.nextUrl.searchParams.get('remove-headers') + if (removeHeaders) { + for (const key of removeHeaders.split(',')) { + headers.delete(key) + } + } + + const updateHeader = request.nextUrl.searchParams.get('update-headers') + if (updateHeader) { + for (const kv of updateHeader.split(',')) { + const [key, value] = kv.split('=') + headers.set(key, value) + } + } + + return NextResponse.next({ + request: { + headers, + }, + }) +} diff --git a/test/e2e/middleware-request-header-overrides/app/next.config.js b/test/e2e/middleware-request-header-overrides/app/next.config.js new file mode 100644 index 0000000000000..4ba52ba2c8df6 --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/next.config.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-edge.js b/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-edge.js new file mode 100644 index 0000000000000..0ece8ea2c7518 --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-edge.js @@ -0,0 +1,11 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default (req) => { + return Response.json(Object.fromEntries(req.headers.entries()), { + headers: { + 'headers-from-edge-function': '1', + }, + }) +} diff --git a/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-serverless.js b/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-serverless.js new file mode 100644 index 0000000000000..0f1a9262d9cd0 --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/pages/api/dump-headers-serverless.js @@ -0,0 +1,6 @@ +export default (req, res) => { + return res + .status(200) + .setHeader('headers-from-serverless', '1') + .json(req.headers) +} diff --git a/test/e2e/middleware-request-header-overrides/app/pages/ssr-page.js b/test/e2e/middleware-request-header-overrides/app/pages/ssr-page.js new file mode 100644 index 0000000000000..ed2e4a6fcce82 --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/app/pages/ssr-page.js @@ -0,0 +1,15 @@ +export default function SSRPage({ headers }) { + return ( + <> +

{JSON.stringify(headers)}

+ + ) +} + +export const getServerSideProps = (ctx) => { + return { + props: { + headers: ctx.req.headers, + }, + } +} diff --git a/test/e2e/middleware-request-header-overrides/test/index.test.ts b/test/e2e/middleware-request-header-overrides/test/index.test.ts new file mode 100644 index 0000000000000..03f7296b5b176 --- /dev/null +++ b/test/e2e/middleware-request-header-overrides/test/index.test.ts @@ -0,0 +1,119 @@ +/* eslint-env jest */ + +import { join } from 'path' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import cheerio from 'cheerio' + +describe('Middleware Request Headers Overrides', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + }, + }) + }) + + describe.each([ + { + title: 'Serverless Functions', + path: '/api/dump-headers-serverless', + toJson: (res: Response) => res.json(), + }, + { + title: 'Edge Functions', + path: '/api/dump-headers-edge', + toJson: (res: Response) => res.json(), + }, + { + title: 'getServerSideProps', + path: '/ssr-page', + toJson: async (res: Response) => { + const $ = cheerio.load(await res.text()) + return JSON.parse($('#headers').text()) + }, + }, + ])('$title Backend', ({ path, toJson }) => { + it(`Adds new headers`, async () => { + const res = await fetchViaHTTP(next.url, path, null, { + headers: { + 'x-from-client': 'hello-from-client', + }, + }) + expect(await toJson(res)).toMatchObject({ + 'x-from-client': 'hello-from-client', + 'x-from-middleware': 'hello-from-middleware', + }) + }) + + it(`Deletes headers`, async () => { + const res = await fetchViaHTTP( + next.url, + path, + { + 'remove-headers': 'x-from-client1,x-from-client2', + }, + { + headers: { + 'x-from-client1': 'hello-from-client', + 'X-From-Client2': 'hello-from-client', + }, + } + ) + + const json = await toJson(res) + expect(json).not.toHaveProperty('x-from-client1') + expect(json).not.toHaveProperty('X-From-Client2') + expect(json).toMatchObject({ + 'x-from-middleware': 'hello-from-middleware', + }) + + // Should not be included in response headers. + expect(res.headers.get('x-middleware-override-headers')).toBeNull() + expect( + res.headers.get('x-middleware-request-x-from-middleware') + ).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client1')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client2')).toBeNull() + }) + + it(`Updates headers`, async () => { + const res = await fetchViaHTTP( + next.url, + path, + { + 'update-headers': + 'x-from-client1=new-value1,x-from-client2=new-value2', + }, + { + headers: { + 'x-from-client1': 'old-value1', + 'X-From-Client2': 'old-value2', + 'x-from-client3': 'old-value3', + }, + } + ) + expect(await toJson(res)).toMatchObject({ + 'x-from-client1': 'new-value1', + 'x-from-client2': 'new-value2', + 'x-from-client3': 'old-value3', + 'x-from-middleware': 'hello-from-middleware', + }) + + // Should not be included in response headers. + expect(res.headers.get('x-middleware-override-headers')).toBeNull() + expect( + res.headers.get('x-middleware-request-x-from-middleware') + ).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client1')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client2')).toBeNull() + expect(res.headers.get('x-middleware-request-x-from-client3')).toBeNull() + }) + }) +}) From a5d53155cddb41fec1901955bb660fa6c1d4b4cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Born=C3=B6?= Date: Thu, 20 Oct 2022 03:38:00 +0200 Subject: [PATCH 08/14] Check root layout change on client (#41475) Moves the logic that checks if there's a new root layout to the client. Adds test for static and dynamic catchall. Related: #41457 ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) Co-authored-by: Tim Neutkens Co-authored-by: JJ Kasper --- packages/next/client/components/reducer.ts | 146 ++++++++++++++++-- packages/next/server/app-render.tsx | 35 +++-- test/e2e/app-dir/root-layout.test.ts | 58 ++++++- .../dynamic-catchall/[...slug]/layout.js | 10 ++ .../dynamic-catchall/[...slug]/page.js | 18 +++ .../[param] => [first]/[second]}/page.js | 4 +- .../(mpa-navigation)/dynamic/[first]/page.js | 10 ++ .../dynamic/first/[param]/page.js | 10 -- .../static-mpa-navigation/[slug]/layout.js | 14 ++ .../static-mpa-navigation/[slug]/page.js | 22 +++ .../with-parallel-routes/@one/inner/page.js | 2 +- 11 files changed, 286 insertions(+), 43 deletions(-) create mode 100644 test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/layout.js create mode 100644 test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/page.js rename test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/{second/[param] => [first]/[second]}/page.js (58%) create mode 100644 test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/[first]/page.js delete mode 100644 test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/first/[param]/page.js create mode 100644 test/e2e/app-dir/root-layout/app/(mpa-navigation)/static-mpa-navigation/[slug]/layout.js create mode 100644 test/e2e/app-dir/root-layout/app/(mpa-navigation)/static-mpa-navigation/[slug]/page.js diff --git a/packages/next/client/components/reducer.ts b/packages/next/client/components/reducer.ts index 7d9ff76dc8284..79cf60c681dc2 100644 --- a/packages/next/client/components/reducer.ts +++ b/packages/next/client/components/reducer.ts @@ -406,7 +406,7 @@ function applyRouterStatePatchToTree( flightRouterState: FlightRouterState, treePatch: FlightRouterState ): FlightRouterState | null { - const [segment, parallelRoutes /* , url */] = flightRouterState + const [segment, parallelRoutes, , , isRootLayout] = flightRouterState // Root refresh if (flightSegmentPath.length === 1) { @@ -451,6 +451,11 @@ function applyRouterStatePatchToTree( }, ] + // Current segment is the root layout + if (isRootLayout) { + tree[4] = true + } + // TODO-APP: Revisit // if (url) { // tree[2] = url @@ -494,6 +499,47 @@ function shouldHardNavigate( ) } +function isNavigatingToNewRootLayout( + currentTree: FlightRouterState, + nextTree: FlightRouterState +): boolean { + // Compare segments + const currentTreeSegment = currentTree[0] + const nextTreeSegment = nextTree[0] + // If any segment is different before we find the root layout, the root layout has changed. + // E.g. /same/(group1)/layout.js -> /same/(group2)/layout.js + // First segment is 'same' for both, keep looking. (group1) changed to (group2) before the root layout was found, it must have changed. + if (Array.isArray(currentTreeSegment) && Array.isArray(nextTreeSegment)) { + // Compare dynamic param name and type but ignore the value, different values would not affect the current root layout + // /[name] - /slug1 and /slug2, both values (slug1 & slug2) still has the same layout /[name]/layout.js + if ( + currentTreeSegment[0] !== nextTreeSegment[0] || + currentTreeSegment[2] !== nextTreeSegment[2] + ) { + return true + } + } else if (currentTreeSegment !== nextTreeSegment) { + return true + } + + // Current tree root layout found + if (currentTree[4]) { + // If the next tree doesn't have the root layout flag, it must have changed. + return !nextTree[4] + } + // Current tree didn't have its root layout here, must have changed. + if (nextTree[4]) { + return true + } + // We can't assume it's `parallelRoutes.children` here in case the root layout is `app/@something/layout.js` + // But it's not possible to be more than one parallelRoutes before the root layout is found + // TODO-APP: change to traverse all parallel routes + const currentTreeChild = Object.values(currentTree[1])[0] + const nextTreeChild = Object.values(nextTree[1])[0] + if (!currentTreeChild || !nextTreeChild) return true + return isNavigatingToNewRootLayout(currentTreeChild, nextTreeChild) +} + export type FocusAndScrollRef = { /** * If focus and scroll should be set in the layout-router's useEffect() @@ -518,6 +564,7 @@ interface RefreshAction { mutable: { previousTree?: FlightRouterState patchedTree?: FlightRouterState + mpaNavigation?: boolean canonicalUrlOverride?: string } } @@ -553,6 +600,7 @@ interface NavigateAction { forceOptimisticNavigation: boolean cache: CacheNode mutable: { + mpaNavigation?: boolean previousTree?: FlightRouterState patchedTree?: FlightRouterState canonicalUrlOverride?: string @@ -587,6 +635,7 @@ interface ServerPatchAction { cache: CacheNode mutable: { patchedTree?: FlightRouterState + mpaNavigation?: boolean canonicalUrlOverride?: string } } @@ -671,18 +720,42 @@ function clientReducer( const href = createHrefFromUrl(url) const pendingPush = navigateType === 'push' - // Handle concurrent rendering / strict mode case where the cache and tree were already populated. - if ( - mutable.patchedTree && + const isForCurrentTree = JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) - ) { + + if (mutable.mpaNavigation && isForCurrentTree) { + return { + // Set href. + canonicalUrl: mutable.canonicalUrlOverride + ? mutable.canonicalUrlOverride + : href, + // TODO-APP: verify mpaNavigation not being set is correct here. + pushRef: { + pendingPush, + mpaNavigation: mutable.mpaNavigation, + }, + // All navigation requires scroll and focus management to trigger. + focusAndScrollRef: { apply: false }, + // Apply cache. + cache: state.cache, + prefetchCache: state.prefetchCache, + // Apply patched router state. + tree: state.tree, + } + } + + // Handle concurrent rendering / strict mode case where the cache and tree were already populated. + if (mutable.patchedTree && isForCurrentTree) { return { // Set href. canonicalUrl: mutable.canonicalUrlOverride ? mutable.canonicalUrlOverride : href, // TODO-APP: verify mpaNavigation not being set is correct here. - pushRef: { pendingPush, mpaNavigation: false }, + pushRef: { + pendingPush, + mpaNavigation: false, + }, // All navigation requires scroll and focus management to trigger. focusAndScrollRef: { apply: true }, // Apply cache. @@ -705,6 +778,10 @@ function clientReducer( if (newTree !== null) { mutable.previousTree = state.tree mutable.patchedTree = newTree + mutable.mpaNavigation = isNavigatingToNewRootLayout( + state.tree, + newTree + ) const hardNavigate = // TODO-APP: Revisit if this is correct. @@ -793,6 +870,10 @@ function clientReducer( if (!res?.bailOptimistic) { mutable.previousTree = state.tree mutable.patchedTree = optimisticTree + mutable.mpaNavigation = isNavigatingToNewRootLayout( + state.tree, + optimisticTree + ) return { // Set href. canonicalUrl: href, @@ -867,6 +948,7 @@ function clientReducer( } mutable.previousTree = state.tree mutable.patchedTree = newTree + mutable.mpaNavigation = isNavigatingToNewRootLayout(state.tree, newTree) if (flightDataPath.length === 2) { cache.subTreeData = subTreeData @@ -906,6 +988,27 @@ function clientReducer( return state } + if (mutable.mpaNavigation) { + return { + // Set href. + canonicalUrl: mutable.canonicalUrlOverride + ? mutable.canonicalUrlOverride + : state.canonicalUrl, + // TODO-APP: verify mpaNavigation not being set is correct here. + pushRef: { + pendingPush: true, + mpaNavigation: mutable.mpaNavigation, + }, + // All navigation requires scroll and focus management to trigger. + focusAndScrollRef: { apply: false }, + // Apply cache. + cache: state.cache, + prefetchCache: state.prefetchCache, + // Apply patched router state. + tree: state.tree, + } + } + // Handle concurrent rendering / strict mode case where the cache and tree were already populated. if (mutable.patchedTree) { return { @@ -968,6 +1071,7 @@ function clientReducer( } mutable.patchedTree = newTree + mutable.mpaNavigation = isNavigatingToNewRootLayout(state.tree, newTree) // Root refresh if (flightDataPath.length === 2) { @@ -1013,11 +1117,32 @@ function clientReducer( const { cache, mutable } = action const href = state.canonicalUrl - // Handle concurrent rendering / strict mode case where the cache and tree were already populated. - if ( - mutable.patchedTree && + const isForCurrentTree = JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) - ) { + + if (mutable.mpaNavigation && isForCurrentTree) { + return { + // Set href. + canonicalUrl: mutable.canonicalUrlOverride + ? mutable.canonicalUrlOverride + : state.canonicalUrl, + // TODO-APP: verify mpaNavigation not being set is correct here. + pushRef: { + pendingPush: true, + mpaNavigation: mutable.mpaNavigation, + }, + // All navigation requires scroll and focus management to trigger. + focusAndScrollRef: { apply: false }, + // Apply cache. + cache: state.cache, + prefetchCache: state.prefetchCache, + // Apply patched router state. + tree: state.tree, + } + } + + // Handle concurrent rendering / strict mode case where the cache and tree were already populated. + if (mutable.patchedTree && isForCurrentTree) { return { // Set href. canonicalUrl: mutable.canonicalUrlOverride @@ -1095,6 +1220,7 @@ function clientReducer( mutable.previousTree = state.tree mutable.patchedTree = newTree + mutable.mpaNavigation = isNavigatingToNewRootLayout(state.tree, newTree) // Set subTreeData for the root node of the cache. cache.subTreeData = subTreeData diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 704fb57473415..e12dd045c71c0 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -443,7 +443,8 @@ export type FlightRouterState = [ segment: Segment, parallelRoutes: { [parallelRouterKey: string]: FlightRouterState }, url?: string, - refresh?: 'refetch' + refresh?: 'refetch', + isRootLayout?: boolean ] /** @@ -849,10 +850,10 @@ export async function renderToHTMLOrFlight( } } - const createFlightRouterStateFromLoaderTree = ([ - segment, - parallelRoutes, - ]: LoaderTree): FlightRouterState => { + const createFlightRouterStateFromLoaderTree = ( + [segment, parallelRoutes, { layout }]: LoaderTree, + rootLayoutIncluded = false + ): FlightRouterState => { const dynamicParam = getDynamicParamFromSegment(segment) const segmentTree: FlightRouterState = [ @@ -860,18 +861,22 @@ export async function renderToHTMLOrFlight( {}, ] - if (parallelRoutes) { - segmentTree[1] = Object.keys(parallelRoutes).reduce( - (existingValue, currentValue) => { - existingValue[currentValue] = createFlightRouterStateFromLoaderTree( - parallelRoutes[currentValue] - ) - return existingValue - }, - {} as FlightRouterState[1] - ) + if (!rootLayoutIncluded && typeof layout !== 'undefined') { + rootLayoutIncluded = true + segmentTree[4] = true } + segmentTree[1] = Object.keys(parallelRoutes).reduce( + (existingValue, currentValue) => { + existingValue[currentValue] = createFlightRouterStateFromLoaderTree( + parallelRoutes[currentValue], + rootLayoutIncluded + ) + return existingValue + }, + {} as FlightRouterState[1] + ) + return segmentTree } diff --git a/test/e2e/app-dir/root-layout.test.ts b/test/e2e/app-dir/root-layout.test.ts index 50d1fb7ab0e6b..5a344f51de9a0 100644 --- a/test/e2e/app-dir/root-layout.test.ts +++ b/test/e2e/app-dir/root-layout.test.ts @@ -4,7 +4,7 @@ import { NextInstance } from 'test/lib/next-modes/base' import webdriver from 'next-webdriver' import { getRedboxSource, hasRedbox } from 'next-test-utils' -describe.skip('app-dir root layout', () => { +describe('app-dir root layout', () => { const isDev = (global as any).isNextDev if ((global as any).isNextDeploy) { @@ -151,18 +151,18 @@ describe.skip('app-dir root layout', () => { }) it('should work with dynamic routes', async () => { - const browser = await webdriver(next.url, '/dynamic/first/route') + const browser = await webdriver(next.url, '/dynamic/first') - expect(await browser.elementById('dynamic-route').text()).toBe( - 'dynamic route' + expect(await browser.elementById('dynamic-first').text()).toBe( + 'dynamic first' ) await browser.eval('window.__TEST_NO_RELOAD = true') // Navigate to page with same root layout await browser.elementByCss('a').click() expect( - await browser.waitForElementByCss('#dynamic-second-hello').text() - ).toBe('dynamic hello') + await browser.waitForElementByCss('#dynamic-first-second').text() + ).toBe('dynamic first second') expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeTrue() // Navigate to page with different root layout @@ -172,5 +172,51 @@ describe.skip('app-dir root layout', () => { ).toBe('Inner basic route') expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeUndefined() }) + + it('should work with dynamic catchall routes', async () => { + const browser = await webdriver(next.url, '/dynamic-catchall/slug') + + expect(await browser.elementById('catchall-slug').text()).toBe( + 'catchall slug' + ) + await browser.eval('window.__TEST_NO_RELOAD = true') + + // Navigate to page with same root layout + await browser.elementById('to-next-url').click() + expect( + await browser.waitForElementByCss('#catchall-slug-slug').text() + ).toBe('catchall slug slug') + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeTrue() + + // Navigate to page with different root layout + await browser.elementById('to-dynamic-first').click() + expect(await browser.elementById('dynamic-first').text()).toBe( + 'dynamic first' + ) + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeUndefined() + }) + + it('should work with static routes', async () => { + const browser = await webdriver(next.url, '/static-mpa-navigation/slug1') + + expect(await browser.elementById('static-slug1').text()).toBe( + 'static slug1' + ) + await browser.eval('window.__TEST_NO_RELOAD = true') + + // Navigate to page with same root layout + await browser.elementByCss('a').click() + expect(await browser.waitForElementByCss('#static-slug2').text()).toBe( + 'static slug2' + ) + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeTrue() + + // Navigate to page with different root layout + await browser.elementByCss('a').click() + expect(await browser.elementById('basic-route').text()).toBe( + 'Basic route' + ) + expect(await browser.eval('window.__TEST_NO_RELOAD')).toBeUndefined() + }) }) }) diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/layout.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/layout.js new file mode 100644 index 0000000000000..05b841b280b3f --- /dev/null +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/layout.js @@ -0,0 +1,10 @@ +export default function Root({ children }) { + return ( + + + Hello + + {children} + + ) +} diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/page.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/page.js new file mode 100644 index 0000000000000..a4a02806a555e --- /dev/null +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic-catchall/[...slug]/page.js @@ -0,0 +1,18 @@ +import Link from 'next/link' + +export default function Page({ params }) { + const nextUrl = [...params.slug, 'slug'] + return ( + <> + + To next url + + + To next url + +

+ catchall {params.slug.join(' ')} +

+ + ) +} diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/second/[param]/page.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/[first]/[second]/page.js similarity index 58% rename from test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/second/[param]/page.js rename to test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/[first]/[second]/page.js index bfce324ce58ef..2d1c41c94d19d 100644 --- a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/second/[param]/page.js +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/[first]/[second]/page.js @@ -4,7 +4,9 @@ export default function Page({ params }) { return ( <> To basic inner -

dynamic {params.param}

+

+ dynamic {params.first} {params.second} +

) } diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/[first]/page.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/[first]/page.js new file mode 100644 index 0000000000000..e2fb10ecf4863 --- /dev/null +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/[first]/page.js @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function Page({ params }) { + return ( + <> + To inner dynamic +

dynamic {params.first}

+ + ) +} diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/first/[param]/page.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/first/[param]/page.js deleted file mode 100644 index 2f30013684886..0000000000000 --- a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/dynamic/first/[param]/page.js +++ /dev/null @@ -1,10 +0,0 @@ -import Link from 'next/link' - -export default function Page({ params }) { - return ( - <> - To second dynamic -

dynamic {params.param}

- - ) -} diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/static-mpa-navigation/[slug]/layout.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/static-mpa-navigation/[slug]/layout.js new file mode 100644 index 0000000000000..b1fea823e20ec --- /dev/null +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/static-mpa-navigation/[slug]/layout.js @@ -0,0 +1,14 @@ +export default function Layout({ children }) { + return ( + + + Hello + + {children} + + ) +} + +export function generateStaticParams() { + return [{ slug: 'slug1' }, { slug: 'slug2' }] +} diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/static-mpa-navigation/[slug]/page.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/static-mpa-navigation/[slug]/page.js new file mode 100644 index 0000000000000..5c1827b83546f --- /dev/null +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/static-mpa-navigation/[slug]/page.js @@ -0,0 +1,22 @@ +import Link from 'next/link' + +export const config = { + dynamicParams: false, +} + +export default function Page({ params }) { + return ( + <> + + To next + +

static {params.slug}

+ + ) +} diff --git a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@one/inner/page.js b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@one/inner/page.js index 849c7ab32a7fb..dc519a6f23b3f 100644 --- a/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@one/inner/page.js +++ b/test/e2e/app-dir/root-layout/app/(mpa-navigation)/with-parallel-routes/@one/inner/page.js @@ -3,7 +3,7 @@ import Link from 'next/link' export default function Page() { return ( <> - To dynamic route + To dynamic route

One inner

) From d7348c4ef42c649f08417d09234e689bd9f5fcff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Born=C3=B6?= Date: Thu, 20 Oct 2022 13:45:16 +0200 Subject: [PATCH 09/14] Full remaining path in selected layout segment (#41562) Make `useSelectedLayoutSegment` include the remaining segments from the current level to the leaf node. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/client/components/navigation.ts | 42 ++++++++++++++++--- .../(group)/second/[...catchall]/page.js | 11 +++++ .../first/[dynamic]/(group)/second/page.js | 3 ++ .../first/[dynamic]/page.js | 3 ++ .../first/layout.js | 14 +++++++ .../use-selected-layout-segment/first/page.js | 3 ++ .../use-selected-layout-segment/layout.js | 14 +++++++ .../server/page.js | 1 - test/e2e/app-dir/app/middleware.js | 12 ++++++ test/e2e/app-dir/app/next.config.js | 5 +++ test/e2e/app-dir/index.test.ts | 31 ++++++++++++++ 11 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/(group)/second/[...catchall]/page.js create mode 100644 test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/(group)/second/page.js create mode 100644 test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/page.js create mode 100644 test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/layout.js create mode 100644 test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/page.js create mode 100644 test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/layout.js diff --git a/packages/next/client/components/navigation.ts b/packages/next/client/components/navigation.ts index 7da7495b27da7..efca123183312 100644 --- a/packages/next/client/components/navigation.ts +++ b/packages/next/client/components/navigation.ts @@ -1,5 +1,6 @@ // useLayoutSegments() // Only the segments for the current place. ['children', 'dashboard', 'children', 'integrations'] -> /dashboard/integrations (/dashboard/layout.js would get ['children', 'dashboard', 'children', 'integrations']) +import type { FlightRouterState } from '../../server/app-render' import { useContext, useMemo } from 'react' import { SearchParamsContext, @@ -105,16 +106,45 @@ export function usePathname(): string { // return useContext(LayoutSegmentsContext) // } +// TODO-APP: handle parallel routes +function getSelectedLayoutSegmentPath( + tree: FlightRouterState, + parallelRouteKey: string, + first = true, + segmentPath: string[] = [] +): string[] { + let node: FlightRouterState + if (first) { + // Use the provided parallel route key on the first parallel route + node = tree[1][parallelRouteKey] + } else { + // After first parallel route prefer children, if there's no children pick the first parallel route. + const parallelRoutes = tree[1] + node = parallelRoutes.children ?? Object.values(parallelRoutes)[0] + } + + if (!node) return segmentPath + const segment = node[0] + const segmentValue = Array.isArray(segment) ? segment[1] : segment + if (!segmentValue) return segmentPath + + segmentPath.push(segmentValue) + + return getSelectedLayoutSegmentPath( + node, + parallelRouteKey, + false, + segmentPath + ) +} + // TODO-APP: Expand description when the docs are written for it. /** - * Get the current segment one level down from the layout. + * Get the canonical segment path from this level to the leaf node. */ export function useSelectedLayoutSegment( parallelRouteKey: string = 'children' -): string { +): string[] { const { tree } = useContext(LayoutRouterContext) - - const segment = tree[1][parallelRouteKey][0] - - return Array.isArray(segment) ? segment[1] : segment + return getSelectedLayoutSegmentPath(tree, parallelRouteKey) } diff --git a/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/(group)/second/[...catchall]/page.js b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/(group)/second/[...catchall]/page.js new file mode 100644 index 0000000000000..7e15d00e0a26a --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/(group)/second/[...catchall]/page.js @@ -0,0 +1,11 @@ +'use client' + +import { useSelectedLayoutSegment } from 'next/navigation' + +export default function Page() { + const selectedLayoutSegment = useSelectedLayoutSegment() + + return ( +

{JSON.stringify(selectedLayoutSegment)}

+ ) +} diff --git a/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/(group)/second/page.js b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/(group)/second/page.js new file mode 100644 index 0000000000000..c17431379f962 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/(group)/second/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return null +} diff --git a/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/page.js b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/page.js new file mode 100644 index 0000000000000..c17431379f962 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/[dynamic]/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return null +} diff --git a/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/layout.js b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/layout.js new file mode 100644 index 0000000000000..10342ef050bf8 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/layout.js @@ -0,0 +1,14 @@ +'use client' + +import { useSelectedLayoutSegment } from 'next/navigation' + +export default function Layout({ children }) { + const selectedLayoutSegment = useSelectedLayoutSegment() + + return ( + <> +

{JSON.stringify(selectedLayoutSegment)}

+ {children} + + ) +} diff --git a/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/page.js b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/page.js new file mode 100644 index 0000000000000..c17431379f962 --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/first/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return null +} diff --git a/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/layout.js b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/layout.js new file mode 100644 index 0000000000000..90311bb47861f --- /dev/null +++ b/test/e2e/app-dir/app/app/hooks/use-selected-layout-segment/layout.js @@ -0,0 +1,14 @@ +'use client' + +import { useSelectedLayoutSegment } from 'next/navigation' + +export default function Layout({ children }) { + const selectedLayoutSegment = useSelectedLayoutSegment() + + return ( + <> +

{JSON.stringify(selectedLayoutSegment)}

+ {children} + + ) +} 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 35c72bc285712..c38d4f5ab1023 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,4 +1,3 @@ -'use client' // TODO-APP: enable once test is not skipped. // import { useSelectedLayoutSegment } from 'next/navigation' diff --git a/test/e2e/app-dir/app/middleware.js b/test/e2e/app-dir/app/middleware.js index 1c0cb01aa16b3..b9163ca06adef 100644 --- a/test/e2e/app-dir/app/middleware.js +++ b/test/e2e/app-dir/app/middleware.js @@ -14,6 +14,18 @@ export function middleware(request) { return NextResponse.rewrite(new URL('/dashboard', request.url)) } + if ( + request.nextUrl.pathname === + '/hooks/use-selected-layout-segment/rewritten-middleware' + ) { + return NextResponse.rewrite( + new URL( + '/hooks/use-selected-layout-segment/first/slug3/second/catch/all', + request.url + ) + ) + } + if (request.nextUrl.pathname === '/redirect-middleware-to-dashboard') { return NextResponse.redirect(new URL('/dashboard', request.url)) } diff --git a/test/e2e/app-dir/app/next.config.js b/test/e2e/app-dir/app/next.config.js index c9f390fc829e4..0dcacc5e94534 100644 --- a/test/e2e/app-dir/app/next.config.js +++ b/test/e2e/app-dir/app/next.config.js @@ -12,6 +12,11 @@ module.exports = { source: '/rewritten-to-dashboard', destination: '/dashboard', }, + { + source: '/hooks/use-selected-layout-segment/rewritten', + destination: + '/hooks/use-selected-layout-segment/first/slug3/second/catch/all', + }, ], } }, diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index cc6b318efe65b..7fac781429347 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1194,6 +1194,37 @@ describe('app dir', () => { expect(el.attr('data-query')).toBe('query') }) }) + + describe('useSelectedLayoutSegment', () => { + it.each` + path | outerLayout | innerLayout + ${'/hooks/use-selected-layout-segment/first'} | ${['first']} | ${[]} + ${'/hooks/use-selected-layout-segment/first/slug1'} | ${['first', 'slug1']} | ${['slug1']} + ${'/hooks/use-selected-layout-segment/first/slug2/second'} | ${['first', 'slug2', '(group)', 'second']} | ${['slug2', '(group)', 'second']} + ${'/hooks/use-selected-layout-segment/first/slug2/second/a/b'} | ${['first', 'slug2', '(group)', 'second', 'a/b']} | ${['slug2', '(group)', 'second', 'a/b']} + ${'/hooks/use-selected-layout-segment/rewritten'} | ${['first', 'slug3', '(group)', 'second', 'catch/all']} | ${['slug3', '(group)', 'second', 'catch/all']} + ${'/hooks/use-selected-layout-segment/rewritten-middleware'} | ${['first', 'slug3', '(group)', 'second', 'catch/all']} | ${['slug3', '(group)', 'second', 'catch/all']} + `( + 'should have the correct layout segments at $path', + async ({ path, outerLayout, innerLayout }) => { + const html = await renderViaHTTP(next.url, path) + const $ = cheerio.load(html) + + expect(JSON.parse($('#outer-layout').text())).toEqual(outerLayout) + expect(JSON.parse($('#inner-layout').text())).toEqual(innerLayout) + } + ) + + it('should return an empty array in pages', async () => { + const html = await renderViaHTTP( + next.url, + '/hooks/use-selected-layout-segment/first/slug2/second/a/b' + ) + const $ = cheerio.load(html) + + expect(JSON.parse($('#page-layout-segments').text())).toEqual([]) + }) + }) }) if (isDev) { From f7fecf00cb40c2f784387ff8ccc5e213b8bdd9ca Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 20 Oct 2022 13:54:17 +0200 Subject: [PATCH 10/14] Add back/forward test for new router (#41590) --- .../app-dir/app/app/back-forward/[id]/page.js | 24 +++++++++++++ test/e2e/app-dir/index.test.ts | 36 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 test/e2e/app-dir/app/app/back-forward/[id]/page.js diff --git a/test/e2e/app-dir/app/app/back-forward/[id]/page.js b/test/e2e/app-dir/app/app/back-forward/[id]/page.js new file mode 100644 index 0000000000000..12da862d8a19e --- /dev/null +++ b/test/e2e/app-dir/app/app/back-forward/[id]/page.js @@ -0,0 +1,24 @@ +'use client' +import Link from 'next/link' +import { useRouter } from 'next/navigation' + +export default function Page({ params }) { + const router = useRouter() + return ( + <> +

Hello from {params.id}

+ + Go to {params.id === '1' ? '2' : '1'} + + + + + ) +} diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index 7fac781429347..f9de21f69d17b 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -913,6 +913,42 @@ describe('app dir', () => { await browser.close() } }) + + it('should support router.back and router.forward', async () => { + const browser = await webdriver(next.url, '/back-forward/1') + + const firstMessage = 'Hello from 1' + const secondMessage = 'Hello from 2' + + expect(await browser.elementByCss('#message-1').text()).toBe( + firstMessage + ) + + try { + const message2 = await browser + .waitForElementByCss('#to-other-page') + .click() + .waitForElementByCss('#message-2') + .text() + expect(message2).toBe(secondMessage) + + const message1 = await browser + .waitForElementByCss('#back-button') + .click() + .waitForElementByCss('#message-1') + .text() + expect(message1).toBe(firstMessage) + + const message2Again = await browser + .waitForElementByCss('#forward-button') + .click() + .waitForElementByCss('#message-2') + .text() + expect(message2Again).toBe(secondMessage) + } finally { + await browser.close() + } + }) }) describe('hooks', () => { From 2cafe367ac839cc09d1702cf2e31583da1f4df19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Thu, 20 Oct 2022 17:03:31 +0200 Subject: [PATCH 11/14] chore: clarify issue template for examples --- .github/ISSUE_TEMPLATE/1.bug_report.yml | 4 ++-- .github/ISSUE_TEMPLATE/2.example_bug_report.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml index e1ac19ead6b45..8f231984dfd17 100644 --- a/.github/ISSUE_TEMPLATE/1.bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -1,10 +1,10 @@ name: Bug Report description: Create a bug report for the Next.js core -labels: 'template: bug' +labels: ['template: bug'] body: - type: markdown attributes: - value: Thanks for taking the time to file a bug report! Please fill out this form as completely as possible. + value: 'NOTE: [examples](https://github.com/vercel/next.js/tree/canary/examples) related issue should be reported using [this](https://github.com/vercel/next.js/issues/new?assignees=&labels=type%3A+example%2Ctemplate%3A+bug&template=2.example_bug_report.yml) issue template instead.' - type: markdown attributes: value: If you leave out sections there is a high likelihood it will be moved to the GitHub Discussions ["Help" section](https://github.com/vercel/next.js/discussions/categories/help). diff --git a/.github/ISSUE_TEMPLATE/2.example_bug_report.yml b/.github/ISSUE_TEMPLATE/2.example_bug_report.yml index 88e5e74343a5f..8f5a8e5919a86 100644 --- a/.github/ISSUE_TEMPLATE/2.example_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/2.example_bug_report.yml @@ -1,6 +1,6 @@ -name: Example Bug Report +name: Bug Report for Examples description: Create a bug report for one of the Next.js examples -labels: 'type: example,template: bug' +labels: ['area: examples'] body: - type: markdown attributes: From 12408a3f2df217655f1ca1f5a63e437776208a47 Mon Sep 17 00:00:00 2001 From: Yohann MARTZOLFF <50139430+MarDi66@users.noreply.github.com> Date: Thu, 20 Oct 2022 17:05:54 +0200 Subject: [PATCH 12/14] chore(examples): update with-apivideo (#39727) Co-authored-by: Thibault Beyou <37510686+ThibaultBee@users.noreply.github.com> --- examples/with-apivideo-upload/README.md | 52 ------ .../components/Card/index.tsx | 61 ------- .../components/Loader/index.tsx | 30 ---- examples/with-apivideo-upload/style/common.ts | 124 -------------- examples/with-apivideo-upload/style/index.css | 130 --------------- .../.env.local.example | 0 .../.gitignore | 0 examples/with-apivideo/README.md | 53 ++++++ .../components/Card/Card.module.css | 48 ++++++ .../with-apivideo/components/Card/index.tsx | 24 +++ .../components/Loader/Loader.module.css | 17 ++ .../with-apivideo/components/Loader/index.tsx | 15 ++ .../components/Status/Status.module.css | 6 + .../components/Status/index.tsx | 13 +- .../next.config.js | 3 + .../package.json | 8 +- .../pages/_app.tsx | 1 + .../pages/api/[videoId].ts | 0 .../pages/api/uploadToken.ts | 0 examples/with-apivideo/pages/api/videos.ts | 14 ++ examples/with-apivideo/pages/index.tsx | 104 ++++++++++++ .../pages/uploader}/index.tsx | 131 +++++++-------- .../pages/videos}/[videoId].tsx | 118 ++++++------- examples/with-apivideo/pages/videos/index.tsx | 111 +++++++++++++ .../public/arrow.png | Bin .../public/check.png | Bin .../public/favicon.ico | Bin .../public/vercel.svg | 0 examples/with-apivideo/style/common.css | 157 ++++++++++++++++++ examples/with-apivideo/style/index.css | 20 +++ .../tsconfig.json | 0 31 files changed, 690 insertions(+), 550 deletions(-) delete mode 100644 examples/with-apivideo-upload/README.md delete mode 100644 examples/with-apivideo-upload/components/Card/index.tsx delete mode 100644 examples/with-apivideo-upload/components/Loader/index.tsx delete mode 100644 examples/with-apivideo-upload/style/common.ts delete mode 100644 examples/with-apivideo-upload/style/index.css rename examples/{with-apivideo-upload => with-apivideo}/.env.local.example (100%) rename examples/{with-apivideo-upload => with-apivideo}/.gitignore (100%) create mode 100644 examples/with-apivideo/README.md create mode 100644 examples/with-apivideo/components/Card/Card.module.css create mode 100644 examples/with-apivideo/components/Card/index.tsx create mode 100644 examples/with-apivideo/components/Loader/Loader.module.css create mode 100644 examples/with-apivideo/components/Loader/index.tsx create mode 100644 examples/with-apivideo/components/Status/Status.module.css rename examples/{with-apivideo-upload => with-apivideo}/components/Status/index.tsx (59%) rename examples/{with-apivideo-upload => with-apivideo}/next.config.js (77%) rename examples/{with-apivideo-upload => with-apivideo}/package.json (72%) rename examples/{with-apivideo-upload => with-apivideo}/pages/_app.tsx (82%) rename examples/{with-apivideo-upload => with-apivideo}/pages/api/[videoId].ts (100%) rename examples/{with-apivideo-upload => with-apivideo}/pages/api/uploadToken.ts (100%) create mode 100644 examples/with-apivideo/pages/api/videos.ts create mode 100644 examples/with-apivideo/pages/index.tsx rename examples/{with-apivideo-upload/pages => with-apivideo/pages/uploader}/index.tsx (72%) rename examples/{with-apivideo-upload/pages => with-apivideo/pages/videos}/[videoId].tsx (52%) create mode 100644 examples/with-apivideo/pages/videos/index.tsx rename examples/{with-apivideo-upload => with-apivideo}/public/arrow.png (100%) rename examples/{with-apivideo-upload => with-apivideo}/public/check.png (100%) rename examples/{with-apivideo-upload => with-apivideo}/public/favicon.ico (100%) rename examples/{with-apivideo-upload => with-apivideo}/public/vercel.svg (100%) create mode 100644 examples/with-apivideo/style/common.css create mode 100644 examples/with-apivideo/style/index.css rename examples/{with-apivideo-upload => with-apivideo}/tsconfig.json (100%) diff --git a/examples/with-apivideo-upload/README.md b/examples/with-apivideo-upload/README.md deleted file mode 100644 index a6ee13db21724..0000000000000 --- a/examples/with-apivideo-upload/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# api.video video uploader - -This video uploader and playback app is built with Next.js and api.video, the video first API. - -## Demo - -[https://apivideo-uploader.vercel.app/](https://apivideo-uploader.vercel.app/) - -## Deploy your own - -Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example): - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-apivideo-upload&project-name=with-apivideo-upload&repository-name=with-apivideo-upload) - -## How to use - -Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: - -```bash -npx create-next-app --example with-apivideo-upload with-apivideo-upload-app -``` - -```bash -yarn create next-app --example with-apivideo-upload with-apivideo-upload-app -``` - -```bash -pnpm create next-app --example with-apivideo-upload with-apivideo-upload-app -``` - -Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). - -## Getting started - -### 1. Create an api.video free account - -Go to [dashboard.api.video](https://dashboard.api.video/), log in or create a free account. -You can choose to stay in sandbox and have watermark over your videos, or enter in [production mode](https://api.video/pricing) and take advantage of all the features without limitations. - -### 2. Get you API key - -Once in the dashboard, find your API keys directly in the `/overview` or navigate to `/apikeys` with the "API Keys" button in the side navigation. -Copy your API key, and paste it in `.env.local.example` as value for `API_KEY`. -Rename `.env.local.example` to `.env.local`. -You can now try the application locally by running `npm run dev`, `yarn dev` or `pnpm dev` from the root directory. - -### 3. Deployment - -First, push your app to GitHub/GitLab or Bitbucket -The, go to [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) and import your new repository. -Add an environment variable with name `API_KEY` and your API key for value. -Click on deploy πŸŽ‰ diff --git a/examples/with-apivideo-upload/components/Card/index.tsx b/examples/with-apivideo-upload/components/Card/index.tsx deleted file mode 100644 index 6f9d3a61f9e02..0000000000000 --- a/examples/with-apivideo-upload/components/Card/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react' -import styled from 'styled-components' -import Image from 'next/image' - -interface ICardProps { - content: string - url: string - method: 'get' | 'post' -} - -const Container = styled.a` - border: 1px solid rgb(215, 219, 236); - border-radius: 0.25rem; - background-color: #ffffff; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 0.5rem; - font-size: 0.5rem; - position: relative; - box-shadow: rgb(0 0 0 / 5%) 0px 2px 4px; -` - -const Method = styled.div<{ $method: 'get' | 'post' }>` - color: #ffffff; - background-color: ${(p) => (p.$method === 'get' ? 'green' : 'blue')}; - padding: 0.3rem; - border-radius: 2px; - font-weight: 500; -` - -const Content = styled.p` - letter-spacing: 0.05rem; -` - -const ImageContainer = styled.div` - position: absolute; - bottom: -25px; - right: -105px; - display: flex; - gap: 5px; - align-items: flex-end; - p { - margin-bottom: -3px; - font-size: 0.5rem; - } -` - -const Card: React.FC = ({ content, url, method }): JSX.Element => ( - - {method.toUpperCase()} - {content} - - Sketch arrow -

Try it out with our API!

-
-
-) - -export default Card diff --git a/examples/with-apivideo-upload/components/Loader/index.tsx b/examples/with-apivideo-upload/components/Loader/index.tsx deleted file mode 100644 index 9abbffdb189aa..0000000000000 --- a/examples/with-apivideo-upload/components/Loader/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import Image from 'next/image' -import React from 'react' -import styled, { keyframes } from 'styled-components' - -interface ILoaderProps { - done: boolean -} - -const spin = keyframes` - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -` - -const Spinner = styled.div` - border: 3px solid #f3f3f3; - border-top: 3px solid rgb(235, 137, 82); - border-radius: 50%; - width: 25px; - height: 25px; - animation: ${spin} 1s linear infinite; -` - -const Loader: React.FC = ({ done }): JSX.Element => - done ? : - -export default Loader diff --git a/examples/with-apivideo-upload/style/common.ts b/examples/with-apivideo-upload/style/common.ts deleted file mode 100644 index 17b891ab8f746..0000000000000 --- a/examples/with-apivideo-upload/style/common.ts +++ /dev/null @@ -1,124 +0,0 @@ -import styled from 'styled-components' - -export const GlobalContainer = styled.div` - box-sizing: border-box; - height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - width: 100vw; - gap: 20px; - main { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - gap: 20px; - } -` - -export const Header = styled.header` - font-size: 2.5rem; - margin-top: 2rem; - span { - font-weight: 700; - background: -webkit-linear-gradient( - 45deg, - rgb(250, 91, 48) 0%, - rgb(128, 54, 255) 26.88%, - rgb(213, 63, 255) 50.44%, - rgb(235, 137, 82) 73.83%, - rgb(247, 181, 0) 100% - ); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - } -` - -export const TextsContainer = styled.div` - display: flex; - flex-direction: column; - gap: 20px; - padding: 3rem 5rem; - box-shadow: rgb(0 0 0 / 10%) 0px 2px 4px; - border-radius: 5px; -` - -export const Text = styled.p` - text-align: center; - font-size: 1.1rem; - letter-spacing: 0.03rem; - a { - font-weight: 700; - } -` - -export const Button = styled.button<{ $upload?: boolean }>` - background: ${(p) => - p.$upload - ? '-webkit-linear-gradient(45deg, rgb(250, 91, 48) 0%, rgb(235, 137, 82) 50%, rgb(247, 181, 0) 100%)' - : '-webkit-linear-gradient(45deg, rgb(247, 181, 0) 0%, rgb(235, 137, 82) 50%, rgb(250, 91, 48) 100%)'}; - border: none; - padding: 0.8rem 1.2rem; - border-radius: 5px; - color: #ffffff; - cursor: pointer; - font-size: 1.2rem; - font-weight: 500; -` - -export const StatusContainer = styled.div` - display: flex; - justify-content: center; - align-items: center; - gap: 2rem; - span { - width: 35px; - height: 5px; - border-radius: 5px; - background-color: rgb(235, 137, 82); - margin-top: 20px; - } -` - -export const Footer = styled.footer` - margin-top: auto; - margin-bottom: 1rem; - display: flex; - align-items: center; - gap: 5px; - a:nth-of-type(2) { - font-weight: 600; - font-size: 1.1rem; - } -` - -export const PlayerSdkContainer = styled.div<{ - $width: number - $height: number -}>` - width: ${(p) => (p.$width && p.$width <= 800 ? p.$width : '800')}px; - height: ${(p) => (p.$height && p.$height <= 250 ? p.$height : '250')}px; - iframe { - height: ${(p) => (p.$height <= 250 ? p.$height : '250')}px !important; - } -` - -export const InputsContainer = styled.div` - display: flex; - gap: 20px; - > div { - display: flex; - flex-direction: column; - gap: 5px; - label { - font-size: 0.6rem; - } - } - > div:last-child { - flex-direction: row; - align-items: center; - align-self: flex-end; - } -` diff --git a/examples/with-apivideo-upload/style/index.css b/examples/with-apivideo-upload/style/index.css deleted file mode 100644 index 56826a0da9643..0000000000000 --- a/examples/with-apivideo-upload/style/index.css +++ /dev/null @@ -1,130 +0,0 @@ -* { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, - Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; -} -html, -body, -div, -span, -applet, -object, -iframe, -h1, -h2, -h3, -h4, -h5, -h6, -p, -blockquote, -pre, -a, -abbr, -acronym, -address, -big, -cite, -code, -del, -dfn, -em, -img, -ins, -kbd, -q, -s, -samp, -small, -strike, -strong, -sub, -sup, -tt, -var, -b, -u, -i, -center, -dl, -dt, -dd, -ol, -ul, -li, -fieldset, -form, -label, -legend, -table, -caption, -tbody, -tfoot, -thead, -tr, -th, -td, -article, -aside, -canvas, -details, -embed, -figure, -figcaption, -footer, -header, -hgroup, -menu, -nav, -output, -ruby, -section, -summary, -time, -mark, -audio, -video { - margin: 0; - padding: 0; - border: 0; - font-size: 100%; - vertical-align: baseline; -} -article, -aside, -details, -figcaption, -figure, -footer, -header, -hgroup, -menu, -nav, -section { - display: block; -} -body { - line-height: 1; -} -ol, -ul { - list-style: none; -} -blockquote, -q { - quotes: none; -} -blockquote:before, -blockquote:after, -q:before, -q:after { - content: ''; - content: none; -} -table { - border-collapse: collapse; - border-spacing: 0; -} -a { - text-decoration: none; - color: unset; -} diff --git a/examples/with-apivideo-upload/.env.local.example b/examples/with-apivideo/.env.local.example similarity index 100% rename from examples/with-apivideo-upload/.env.local.example rename to examples/with-apivideo/.env.local.example diff --git a/examples/with-apivideo-upload/.gitignore b/examples/with-apivideo/.gitignore similarity index 100% rename from examples/with-apivideo-upload/.gitignore rename to examples/with-apivideo/.gitignore diff --git a/examples/with-apivideo/README.md b/examples/with-apivideo/README.md new file mode 100644 index 0000000000000..9c727d35881c7 --- /dev/null +++ b/examples/with-apivideo/README.md @@ -0,0 +1,53 @@ +# api.video video uploader + +This video uploader and playback app is built with Next.js and api.video, the video first API. + +## Demo + +[https://with-apivideo.vercel.app/videos](https://with-apivideo.vercel.app/videos) + +## Deploy your own + +Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-apivideo&project-name=with-apivideo&repository-name=with-apivideo) + +## How to use + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example: + +```bash +npx create-next-app --example with-apivideo-app +``` + +```bash +yarn create next-app --example with-apivideo-app +``` + +```bash +pnpm create next-app --example with-apivideo-app +``` + +Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). + +## Getting started + +### 1. Create an api.video free account + +1. Go to [dashboard.api.video](https://dashboard.api.video/), log in or create a free account. +2. You can choose to stay in sandbox and have watermark over your videos, or enter in [production mode](https://api.video/pricing) and take advantage of all the features without limitations. + +### 2. Get you API key + +1. Once in the dashboard, find your API keys directly in the `/overview` or navigate to `/apikeys` with the "API Keys" button in the side navigation. +2. Copy your API key, and paste it in `.env.local.example` as value for `API_KEY`. +3. Rename `.env.local.example` to `.env.local`. +4. Install the packages by running `npm install`, `yarn install` or `pnpm install`. +5. You can now try the application locally by running `npm run dev`, `yarn dev` or `pnpm dev` from the root directory. + +### 3. Deployment + +1. First, push your app to GitHub/GitLab or Bitbucket +2. Then, go to [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) and import your new repository. +3. Add an environment variable with name `API_KEY` and your API key for value. +4. Click on deploy πŸŽ‰ diff --git a/examples/with-apivideo/components/Card/Card.module.css b/examples/with-apivideo/components/Card/Card.module.css new file mode 100644 index 0000000000000..e3413393c3ba9 --- /dev/null +++ b/examples/with-apivideo/components/Card/Card.module.css @@ -0,0 +1,48 @@ +.container { + border: 1px solid rgb(215, 219, 236); + border-radius: 0.25rem; + background-color: #ffffff; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0.5rem; + font-size: 0.5rem; + position: relative; + box-shadow: rgb(0 0 0 / 5%) 0px 2px 4px; +} +.container > p { + letter-spacing: 0.05rem; +} + +.method { + color: #ffffff; + padding: 0.3rem; + border-radius: 2px; + font-weight: 500; +} +.get { + composes: method; + background-color: green; +} +.post { + composes: method; + background-color: blue; +} + +.image_container { + position: absolute; + bottom: -25px; + right: -105px; + display: flex; + gap: 5px; + align-items: flex-end; +} +.image_container > p { + margin-bottom: -3px; + font-size: 0.5rem; +} + +#foo { + font-size: large; +} diff --git a/examples/with-apivideo/components/Card/index.tsx b/examples/with-apivideo/components/Card/index.tsx new file mode 100644 index 0000000000000..d15d9ab88a9a7 --- /dev/null +++ b/examples/with-apivideo/components/Card/index.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import Image from 'next/image' +import styles from './Card.module.css' + +interface ICardProps { + content: string + url: string + method: 'get' | 'post' +} + +const Card: React.FC = ({ content, url, method }): JSX.Element => ( + +
+ {method.toUpperCase()} +
+

{content}

+
+ Sketch arrow +

Try it out with our API!

+
+
+) + +export default Card diff --git a/examples/with-apivideo/components/Loader/Loader.module.css b/examples/with-apivideo/components/Loader/Loader.module.css new file mode 100644 index 0000000000000..eeff8a8f5ffb0 --- /dev/null +++ b/examples/with-apivideo/components/Loader/Loader.module.css @@ -0,0 +1,17 @@ +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.spinner { + border: 3px solid #f3f3f3; + border-top: 3px solid rgb(235, 137, 82); + border-radius: 50%; + width: 25px; + height: 25px; + animation: spin 1s linear infinite; +} diff --git a/examples/with-apivideo/components/Loader/index.tsx b/examples/with-apivideo/components/Loader/index.tsx new file mode 100644 index 0000000000000..a9a254e34f961 --- /dev/null +++ b/examples/with-apivideo/components/Loader/index.tsx @@ -0,0 +1,15 @@ +import Image from 'next/image' +import React from 'react' +import styles from './Loader.module.css' + +interface ILoaderProps { + done: boolean +} +const Loader: React.FC = ({ done }): JSX.Element => + done ? ( + + ) : ( +
+ ) + +export default Loader diff --git a/examples/with-apivideo/components/Status/Status.module.css b/examples/with-apivideo/components/Status/Status.module.css new file mode 100644 index 0000000000000..98d690506cf6e --- /dev/null +++ b/examples/with-apivideo/components/Status/Status.module.css @@ -0,0 +1,6 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; +} diff --git a/examples/with-apivideo-upload/components/Status/index.tsx b/examples/with-apivideo/components/Status/index.tsx similarity index 59% rename from examples/with-apivideo-upload/components/Status/index.tsx rename to examples/with-apivideo/components/Status/index.tsx index d83f8fc123545..57d52260dcb4b 100644 --- a/examples/with-apivideo-upload/components/Status/index.tsx +++ b/examples/with-apivideo/components/Status/index.tsx @@ -1,23 +1,16 @@ import React from 'react' -import styled from 'styled-components' import Loader from '../Loader' +import styles from './Status.module.css' interface IStatusProps { done: boolean title: string } - -const Container = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 5px; -` const Status: React.FC = ({ done, title }): JSX.Element => ( - +

{title}

- +
) export default Status diff --git a/examples/with-apivideo-upload/next.config.js b/examples/with-apivideo/next.config.js similarity index 77% rename from examples/with-apivideo-upload/next.config.js rename to examples/with-apivideo/next.config.js index 0c0fded4f0325..886117d0e5e78 100644 --- a/examples/with-apivideo-upload/next.config.js +++ b/examples/with-apivideo/next.config.js @@ -4,6 +4,9 @@ const nextConfig = { compiler: { styledComponents: true, }, + images: { + domains: ['cdn.api.video'], + }, } module.exports = nextConfig diff --git a/examples/with-apivideo-upload/package.json b/examples/with-apivideo/package.json similarity index 72% rename from examples/with-apivideo-upload/package.json rename to examples/with-apivideo/package.json index 7c9be33c36fbc..96f9c6cd88686 100644 --- a/examples/with-apivideo-upload/package.json +++ b/examples/with-apivideo/package.json @@ -7,19 +7,17 @@ }, "dependencies": { "@api.video/nodejs-client": "2.2.5", - "@api.video/player-sdk": "1.2.13", + "@api.video/react-player": "^1.0.1", "@api.video/video-uploader": "1.0.4", - "@types/styled-components": "5.1.24", "next": "latest", "react": "18.1.0", - "react-dom": "18.1.0", - "styled-components": "5.3.5", - "swr": "1.2.2" + "react-dom": "18.1.0" }, "devDependencies": { "@types/node": "17.0.23", "@types/react": "17.0.43", "@types/react-dom": "17.0.14", + "eslint-config-next": "latest", "typescript": "4.6.3" } } diff --git a/examples/with-apivideo-upload/pages/_app.tsx b/examples/with-apivideo/pages/_app.tsx similarity index 82% rename from examples/with-apivideo-upload/pages/_app.tsx rename to examples/with-apivideo/pages/_app.tsx index fb4be69877960..3f67d34a14077 100644 --- a/examples/with-apivideo-upload/pages/_app.tsx +++ b/examples/with-apivideo/pages/_app.tsx @@ -1,4 +1,5 @@ import '../style/index.css' +import '../style/common.css' function MyApp({ Component, pageProps }: any) { return diff --git a/examples/with-apivideo-upload/pages/api/[videoId].ts b/examples/with-apivideo/pages/api/[videoId].ts similarity index 100% rename from examples/with-apivideo-upload/pages/api/[videoId].ts rename to examples/with-apivideo/pages/api/[videoId].ts diff --git a/examples/with-apivideo-upload/pages/api/uploadToken.ts b/examples/with-apivideo/pages/api/uploadToken.ts similarity index 100% rename from examples/with-apivideo-upload/pages/api/uploadToken.ts rename to examples/with-apivideo/pages/api/uploadToken.ts diff --git a/examples/with-apivideo/pages/api/videos.ts b/examples/with-apivideo/pages/api/videos.ts new file mode 100644 index 0000000000000..d12aa8631ea9d --- /dev/null +++ b/examples/with-apivideo/pages/api/videos.ts @@ -0,0 +1,14 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import ApiVideoClient from '@api.video/nodejs-client' + +const getVideoStatus = async (req: NextApiRequest, res: NextApiResponse) => { + try { + const client = new ApiVideoClient({ apiKey: process.env.API_KEY }) + const videos = await client.videos.list() + res.status(200).json({ videos }) + } catch (error) { + res.status(400).end() + } +} + +export default getVideoStatus diff --git a/examples/with-apivideo/pages/index.tsx b/examples/with-apivideo/pages/index.tsx new file mode 100644 index 0000000000000..8513f2ab38719 --- /dev/null +++ b/examples/with-apivideo/pages/index.tsx @@ -0,0 +1,104 @@ +import type { NextPage } from 'next' +import Head from 'next/head' +import Image from 'next/image' +import React from 'react' + +const Home: NextPage = () => ( +
+ + api.video sample app + + + + +
+ api.video sample app +
+ +
+
+

+ Hey fellow dev! πŸ‘‹
+ In this basic sample app, you will find{' '} + + api.video + {' '} + features examples such as video uploader,{' '} + videos list and player components. +

+

+ api.video provides APIs and clients to handle all your video needs. +
+ This app is built with the api.video{' '} + + Node.js client + + ,{' '} + + Typescript uploader + {' '} + and{' '} + + React player component + + . +

+

+ You can{' '} + + check the source code on GitHub + + . +

+

Please, choose a feature to explore.

+
+ + +
+ + +
+) + +export default Home diff --git a/examples/with-apivideo-upload/pages/index.tsx b/examples/with-apivideo/pages/uploader/index.tsx similarity index 72% rename from examples/with-apivideo-upload/pages/index.tsx rename to examples/with-apivideo/pages/uploader/index.tsx index 63bb34361dff1..fc78d70ce115d 100644 --- a/examples/with-apivideo-upload/pages/index.tsx +++ b/examples/with-apivideo/pages/uploader/index.tsx @@ -2,26 +2,15 @@ import type { NextPage } from 'next' import Head from 'next/head' import Image from 'next/image' import React, { ChangeEvent, useEffect, useRef, useState } from 'react' -import Card from '../components/Card' +import Card from '../../components/Card' import { VideoUploader, VideoUploadResponse } from '@api.video/video-uploader' -import Status from '../components/Status' +import Status from '../../components/Status' import { useRouter } from 'next/router' -import { - Button, - Footer, - GlobalContainer, - Header, - StatusContainer, - Text, - TextsContainer, -} from '../style/common' -import useSWR from 'swr' -const fetcher = async (url: string): Promise => { - return fetch(url).then((res) => res.json()) -} - -const Home: NextPage = () => { +const Uploader: NextPage = () => { + const [uploadToken, setUploadToken] = useState<{ token: string } | undefined>( + undefined + ) const [uploadProgress, setUploadProgress] = useState( undefined ) @@ -31,47 +20,33 @@ const Home: NextPage = () => { { ingested: false, encoded: false } ) const [interId, setInterId] = useState(undefined) - const [size, setSize] = useState< - { width: number; height: number } | undefined - >(undefined) const inputRef = useRef(null) const router = useRouter() - const { data: uploadToken } = useSWR<{ token: string }>( - '/api/uploadToken', - fetcher - ) - - const fetchVideoStatus = async (videoId: string): Promise => { - const { status } = await fetcher(`/api/${videoId}`) - const { encoding, ingest } = status - setStatus({ - ingested: ingest.status === 'uploaded', - encoded: encoding.playable, - }) - if (ingest.status === 'uploaded' && encoding.playable) { - setSize({ - width: encoding.metadata.width, - height: encoding.metadata.height, - }) - setReady(true) - } - } - - const clearState = (): void => { - setReady(false) - setStatus({ ingested: false, encoded: false }) - setVideo(undefined) - setUploadProgress(undefined) - setSize(undefined) - } - + useEffect(() => { + fetch('/api/uploadToken') + .then((res) => res.json()) + .then((res) => setUploadToken(res)) + }, []) useEffect(() => { if (video) { + const fetchVideoStatus = async (videoId: string): Promise => { + const { status } = await fetch(`/api/${videoId}`).then((res) => + res.json() + ) + const { encoding, ingest } = status + setStatus({ + ingested: ingest.status === 'uploaded', + encoded: encoding.playable, + }) + if (ingest.status === 'uploaded' && encoding.playable) setReady(true) + } const intervalId = window.setInterval(() => { fetchVideoStatus(video.videoId) }, 1000) setInterId(intervalId) + + return () => window.clearInterval(intervalId) } }, [video, ready]) useEffect(() => { @@ -82,6 +57,13 @@ const Home: NextPage = () => { e: ChangeEvent ): Promise => { e.preventDefault() + if (!uploadToken || !uploadToken.token) return + const clearState = (): void => { + setReady(false) + setStatus({ ingested: false, encoded: false }) + setVideo(undefined) + setUploadProgress(undefined) + } clearState() if (!e.target.files || !uploadToken) return const file = e.target.files[0] @@ -98,11 +80,11 @@ const Home: NextPage = () => { const handleNavigate = (): void => { if (!video) return - router.push(`/${video.videoId}?w=${size?.width}&h=${size?.height}`) + router.push(`/videos/${video.videoId}?uploaded=1`) } return ( - +
Video Uploader { -
+
api.video uploader πŸš€ -
+
- - +
+

Hey fellow dev! πŸ‘‹
Welcome to this basic example of video uploader provided by{' '} { Vercel & Next.js . - - +

+

api.video provides APIs and clients to handle all your video needs.
This app is built with the{' '} @@ -158,8 +140,8 @@ const Home: NextPage = () => { Typescript uploader . - - +

+

You can{' '} { check the source code on GitHub . - - +

+

Please add a video to upload and let the power of the API do the rest 🎩 - - +

+
{!uploadProgress ? ( <> - + { ) : ( <> - +
= 100} /> - +
{ )} {ready && video && ( - + )}
- +
) } -export default Home +export default Uploader diff --git a/examples/with-apivideo-upload/pages/[videoId].tsx b/examples/with-apivideo/pages/videos/[videoId].tsx similarity index 52% rename from examples/with-apivideo-upload/pages/[videoId].tsx rename to examples/with-apivideo/pages/videos/[videoId].tsx index 66607b0e3f46b..02fa01165bd33 100644 --- a/examples/with-apivideo-upload/pages/[videoId].tsx +++ b/examples/with-apivideo/pages/videos/[videoId].tsx @@ -1,64 +1,34 @@ -import { PlayerSdk, PlayerTheme } from '@api.video/player-sdk' import { GetServerSideProps, NextPage } from 'next' import Head from 'next/head' import Image from 'next/image' import { useRouter } from 'next/router' -import React, { ChangeEvent, useEffect, useState } from 'react' -import { - Button, - Footer, - GlobalContainer, - Header, - InputsContainer, - PlayerSdkContainer, - Text, - TextsContainer, -} from '../style/common' +import React, { ChangeEvent, useState } from 'react' +import ApiVideoPlayer, { PlayerTheme } from '@api.video/react-player' interface IVideoViewProps { children: React.ReactNode videoId: string - width: string - height: string + uploaded: string } const VideoView: NextPage = ({ videoId, - width, - height, + uploaded, }): JSX.Element => { - const [player, setPlayer] = useState(undefined) - const [playerSettings, setPlayerSettings] = useState({ + const [playerTheme, setPlayerTheme] = useState({ link: 'rgb(235, 137, 82)', linkHover: 'rgb(240, 95, 12)', }) const [hideControls, setHideControls] = useState(false) const router = useRouter() - useEffect(() => { - const player = new PlayerSdk('#player', { - id: videoId, + const handleChangeSetting = (e: ChangeEvent) => + setPlayerTheme({ + ...playerTheme, + [e.currentTarget.id]: e.currentTarget.value, }) - player.setTheme({ - link: 'rgb(235, 137, 82)', - linkHover: 'rgb(240, 95, 12)', - }) - setPlayer(player) - }, [videoId]) - useEffect(() => { - player && player?.loadConfig({ id: videoId, hideControls: hideControls }) - }, [hideControls, player, videoId]) - - const handleChangeSetting = ( - e: ChangeEvent, - prop: string - ) => { - const newSettings = { ...playerSettings, [prop]: e.currentTarget.value } - setPlayerSettings(newSettings) - player?.setTheme(newSettings) - } return ( - +
Video view = ({ /> -
- Already there πŸŽ‰ -
+
+ {uploaded ? ( + <> + Already there πŸŽ‰ + + ) : ( + <> + Here's your player πŸ‘€ + + )} +
- - +
+

This player is generated by the{' '} - api.video's Player SDK + api.video's React player component .
It provides multiple properties to customize your video player. - - Try 3 of them just bellow πŸ‘‡ - +

+

Try 3 of them just bellow πŸ‘‡

+
- +
handleChangeSetting(e, 'link')} + value={playerTheme.link} + onChange={handleChangeSetting} />
handleChangeSetting(e, 'linkHover')} + value={playerTheme.linkHover} + onChange={handleChangeSetting} />
@@ -117,17 +97,23 @@ const VideoView: NextPage = ({ />
- - + - +
- +
) } export default VideoView export const getServerSideProps: GetServerSideProps = async (context) => { - const { videoId, w, h } = context.query - return { props: { videoId, width: w ?? null, height: h ?? null } } + const { videoId, uploaded } = context.query + return { props: { videoId, uploaded: uploaded ?? null } } } diff --git a/examples/with-apivideo/pages/videos/index.tsx b/examples/with-apivideo/pages/videos/index.tsx new file mode 100644 index 0000000000000..013539159f228 --- /dev/null +++ b/examples/with-apivideo/pages/videos/index.tsx @@ -0,0 +1,111 @@ +import type { NextPage } from 'next' +import Head from 'next/head' +import Image from 'next/image' +import React, { useEffect, useState } from 'react' +import VideosListResponse from '@api.video/nodejs-client/lib/model/VideosListResponse' + +const Videos: NextPage = () => { + const [videosResponse, setVideosResponse] = useState< + VideosListResponse | undefined + >(undefined) + const [error, setError] = useState(false) + useEffect(() => { + fetch('/api/videos') + .then((res) => res.json()) + .then((res: { videos: VideosListResponse }) => + setVideosResponse(res.videos) + ) + .catch((_) => setError(true)) + }, []) + + return ( +
+ + Videos List + + + + +
+ api.video videos list πŸ“š +
+ +
+
+

+ Welcome to this basic example of videos list provided by{' '} + + api.video + + . +

+

+ Please, add your api.video API key in your .env file and let + the power of the API do the rest 🎩 +

+
+ + {!videosResponse && !error &&
Loading...
} + {error && ( +
+ An error occured trying to fetch your videos. Be sure to have you + API key set in your .env file this way: API_KEY=YOUR_API_KEY +
+ )} + {videosResponse && videosResponse.data?.length > 0 && ( +
+ {videosResponse.data.map((video) => ( + +

{video.title}

+ Video thumbmail +
+ ))} +
+ )} + {videosResponse && videosResponse.data?.length === 0 && ( + <> +

You don't have any video yet 🧐

+ + Upload my first video + + + )} +
+ + +
+ ) +} + +export default Videos diff --git a/examples/with-apivideo-upload/public/arrow.png b/examples/with-apivideo/public/arrow.png similarity index 100% rename from examples/with-apivideo-upload/public/arrow.png rename to examples/with-apivideo/public/arrow.png diff --git a/examples/with-apivideo-upload/public/check.png b/examples/with-apivideo/public/check.png similarity index 100% rename from examples/with-apivideo-upload/public/check.png rename to examples/with-apivideo/public/check.png diff --git a/examples/with-apivideo-upload/public/favicon.ico b/examples/with-apivideo/public/favicon.ico similarity index 100% rename from examples/with-apivideo-upload/public/favicon.ico rename to examples/with-apivideo/public/favicon.ico diff --git a/examples/with-apivideo-upload/public/vercel.svg b/examples/with-apivideo/public/vercel.svg similarity index 100% rename from examples/with-apivideo-upload/public/vercel.svg rename to examples/with-apivideo/public/vercel.svg diff --git a/examples/with-apivideo/style/common.css b/examples/with-apivideo/style/common.css new file mode 100644 index 0000000000000..bdb7c0800b597 --- /dev/null +++ b/examples/with-apivideo/style/common.css @@ -0,0 +1,157 @@ +header { + font-size: 2.5rem; + margin-top: 2rem; +} +header > span { + font-weight: 700; + background: -webkit-linear-gradient( + 45deg, + rgb(250, 91, 48) 0%, + rgb(128, 54, 255) 26.88%, + rgb(213, 63, 255) 50.44%, + rgb(235, 137, 82) 73.83%, + rgb(247, 181, 0) 100% + ); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +footer { + margin-top: auto; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 5px; +} +footer > a:nth-child(3) { + font-weight: 600; + font-size: 1.1rem; +} + +p { + text-align: center; + font-size: 1.1rem; + letter-spacing: 0.03rem; +} +p > a { + font-weight: 700; + text-decoration: underline; +} +p > a:hover { + color: rgb(2, 2, 102); +} + +.global-container { + box-sizing: border-box; + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + width: 100vw; + gap: 20px; +} +.global-container main { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +.texts-container { + display: flex; + flex-direction: column; + gap: 20px; + padding: 3rem 5rem; + box-shadow: rgb(0 0 0 / 10%) 0px 2px 4px; + border-radius: 5px; +} +.texts-container > p { + text-align: center; + font-size: 1.1rem; + letter-spacing: 0.03rem; +} +.texts-container > p a { + font-weight: 700; +} + +.videos-list { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: stretch; + gap: 1rem; +} +.videos-list .video-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 1rem 2rem; + box-shadow: rgb(0 0 0 / 10%) 0px 2px 4px; + border-radius: 5px; + max-width: 250px; + text-align: center; + transition: transform 200ms ease-in-out; +} +.videos-list .video-card:hover { + transform: scale(1.05); +} + +button.upload, +a.button { + background: -webkit-linear-gradient( + 45deg, + rgb(250, 91, 48) 0%, + rgb(235, 137, 82) 50%, + rgb(247, 181, 0) 100% + ); + border: none; + padding: 0.8rem 1.2rem; + border-radius: 5px; + color: #ffffff; + cursor: pointer; + font-size: 1.2rem; + font-weight: 500; +} + +.status-container { + display: flex; + justify-content: center; + align-items: center; + gap: 2rem; +} +.status-container > span { + width: 35px; + height: 5px; + border-radius: 5px; + background-color: rgb(235, 137, 82); + margin-top: 20px; +} + +.inputs-container { + display: flex; + gap: 20px; +} +.inputs-container > div { + display: flex; + flex-direction: column; + gap: 5px; +} +.inputs-container > div label { + font-size: 0.6rem; +} +.inputs-container > div:last-child { + flex-direction: row; + align-items: center; + align-self: flex-end; +} + +.error { + color: rgb(250, 91, 48); +} +.error i { + font-weight: 600; +} diff --git a/examples/with-apivideo/style/index.css b/examples/with-apivideo/style/index.css new file mode 100644 index 0000000000000..b98f152232405 --- /dev/null +++ b/examples/with-apivideo/style/index.css @@ -0,0 +1,20 @@ +html, +body { + padding: 0; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +p { + margin: 0 !important; +} + +* { + box-sizing: border-box; +} diff --git a/examples/with-apivideo-upload/tsconfig.json b/examples/with-apivideo/tsconfig.json similarity index 100% rename from examples/with-apivideo-upload/tsconfig.json rename to examples/with-apivideo/tsconfig.json From 6d29713023467f6aa9d0aa2e1d7a67fc4a26c4fc Mon Sep 17 00:00:00 2001 From: Jimmy Lai Date: Thu, 20 Oct 2022 17:42:50 +0200 Subject: [PATCH 13/14] perf: refactor path logic in router + add LRU cache (#41365) I'm investigating the runtime perf of the node server and this was one of the hot spot I stumbled upon. This diff: - refactors getPagePath to not throw when it doesn't find a path: this function is used to check if a path exists, we don't want that kind of overhead there - adds a LRU cache to cache the result of the evaluation Results: before: > 110k requests in 11.01s, 285 MB read after >135k requests in 11.01s, 348 MB read on an autocannon run ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/server/next-server.ts | 14 ++++----- packages/next/server/require.ts | 46 +++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index fec28ec7d022b..851f57ea8da6e 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -74,7 +74,7 @@ import BaseServer, { NoFallbackError, RequestContext, } from './base-server' -import { getPagePath, requireFontManifest } from './require' +import { getMaybePagePath, getPagePath, requireFontManifest } from './require' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' import { loadComponents } from './load-components' @@ -327,12 +327,12 @@ export default class NextNodeServer extends BaseServer { } protected async hasPage(pathname: string): Promise { - let found = false - try { - found = !!this.getPagePath(pathname, this.nextConfig.i18n?.locales) - } catch (_) {} - - return found + return !!getMaybePagePath( + pathname, + this.distDir, + this.nextConfig.i18n?.locales, + this.hasAppDir + ) } protected getBuildId(): string { diff --git a/packages/next/server/require.ts b/packages/next/server/require.ts index 2bbcdfe1dc7e3..2de811ff13fc4 100644 --- a/packages/next/server/require.ts +++ b/packages/next/server/require.ts @@ -11,13 +11,33 @@ import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin' import { PageNotFoundError, MissingStaticPage } from '../shared/lib/utils' +import LRUCache from 'next/dist/compiled/lru-cache' -export function getPagePath( +const pagePathCache = + process.env.NODE_ENV === 'development' + ? { + get: (_key: string) => { + return null + }, + set: () => {}, + has: () => false, + } + : new LRUCache({ + max: 1000, + }) + +export function getMaybePagePath( page: string, distDir: string, locales?: string[], appDirEnabled?: boolean -): string { +): string | null { + const cacheKey = `${page}:${locales}` + + if (pagePathCache.has(cacheKey)) { + return pagePathCache.get(cacheKey) as string | null + } + const serverBuildPath = join(distDir, SERVER_DIRECTORY) let appPathsManifest: undefined | PagesManifest @@ -60,10 +80,30 @@ export function getPagePath( pagePath = checkManifest(pagesManifest) } + if (!pagePath) { + pagePathCache.set(cacheKey, null) + return null + } + + const path = join(serverBuildPath, pagePath) + pagePathCache.set(cacheKey, path) + + return path +} + +export function getPagePath( + page: string, + distDir: string, + locales?: string[], + appDirEnabled?: boolean +): string { + const pagePath = getMaybePagePath(page, distDir, locales, appDirEnabled) + if (!pagePath) { throw new PageNotFoundError(page) } - return join(serverBuildPath, pagePath) + + return pagePath } export function requirePage( From 138a7bfda2934526a8556aa6313997cae72fb64a Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Thu, 20 Oct 2022 18:49:29 +0200 Subject: [PATCH 14/14] Add transpilePackages option (#41583) This is a new experimental feature to specify a list of packages (or subpaths in packages, like `pkg/src/index.ts`), to opt-in Next.js transpilation in the server build. cc @jescalan ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- packages/next/build/webpack-config.ts | 30 +++++++++++++++++-- packages/next/server/config-schema.ts | 6 ++++ packages/next/server/config-shared.ts | 3 ++ test/e2e/app-dir/rsc-external.test.ts | 5 ++++ .../app/external-imports/client/page.js | 3 ++ test/e2e/app-dir/rsc-external/next.config.js | 1 + .../untranspiled-module/index.ts | 3 ++ .../untranspiled-module/package.json | 4 +++ 8 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 test/e2e/app-dir/rsc-external/node_modules_bak/untranspiled-module/index.ts create mode 100644 test/e2e/app-dir/rsc-external/node_modules_bak/untranspiled-module/package.json diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index cc29ba8960e7d..d74fea3d98337 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -119,7 +119,9 @@ function errorIfEnvConflicted(config: NextConfigComplete, key: string) { function isResourceInPackages(resource: string, packageNames?: string[]) { return packageNames?.some((p: string) => - new RegExp('[/\\\\]node_modules[/\\\\]' + p + '[/\\\\]').test(resource) + resource.includes( + path.sep + pathJoin('node_modules', p.replace(/\//g, path.sep)) + path.sep + ) ) } @@ -1168,21 +1170,38 @@ export default async function getBaseWebpackConfig( return } + // If a package should be transpiled by Next.js, we skip making it external. + // It doesn't matter what the extension is, as we'll transpile it anyway. + const shouldBeBundled = isResourceInPackages( + res, + config.experimental.transpilePackages + ) + if (/node_modules[/\\].*\.[mc]?js$/.test(res)) { if (layer === WEBPACK_LAYERS.server) { // All packages should be bundled for the server layer if they're not opted out. - if (isResourceInPackages(res, optoutBundlingPackages)) { + // This option takes priority over the transpilePackages option. + if ( + isResourceInPackages( + res, + config.experimental.serverComponentsExternalPackages + ) + ) { return `${externalType} ${request}` } return } + if (shouldBeBundled) return + // Anything else that is standard JavaScript within `node_modules` // can be externalized. return `${externalType} ${request}` } + if (shouldBeBundled) return + // Default behavior: bundle the code! } @@ -1196,6 +1215,13 @@ export default async function getBaseWebpackConfig( if (babelIncludeRegexes.some((r) => r.test(excludePath))) { return false } + + const shouldBeBundled = isResourceInPackages( + excludePath, + config.experimental.transpilePackages + ) + if (shouldBeBundled) return false + return excludePath.includes('node_modules') }, } diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts index 0e5980a44baad..29d08ee22729b 100644 --- a/packages/next/server/config-schema.ts +++ b/packages/next/server/config-schema.ts @@ -348,6 +348,12 @@ const configSchema = { }, type: 'array', }, + transpilePackages: { + items: { + type: 'string', + }, + type: 'array', + }, scrollRestoration: { type: 'boolean', }, diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index fee7fe3560a30..942f5d41e514e 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -159,6 +159,9 @@ export interface ExperimentalConfig { // A list of packages that should be treated as external in the RSC server build serverComponentsExternalPackages?: string[] + // A list of packages that should always be transpiled and bundled in the server + transpilePackages?: string[] + fontLoaders?: [{ loader: string; options?: any }] webVitalsAttribution?: Array diff --git a/test/e2e/app-dir/rsc-external.test.ts b/test/e2e/app-dir/rsc-external.test.ts index 1924f0ffe0177..c7c030d6f37e0 100644 --- a/test/e2e/app-dir/rsc-external.test.ts +++ b/test/e2e/app-dir/rsc-external.test.ts @@ -84,6 +84,11 @@ describe('app dir - rsc external dependency', () => { ) }) + it('should transpile specific external packages with the `transpilePackages` option', async () => { + const clientHtml = await renderViaHTTP(next.url, '/external-imports/client') + expect(clientHtml).toContain('transpilePackages:5') + }) + it('should resolve the subset react in server components based on the react-server condition', async () => { await fetchViaHTTP(next.url, '/react-server').then(async (response) => { const result = await resolveStreamResponse(response) diff --git a/test/e2e/app-dir/rsc-external/app/external-imports/client/page.js b/test/e2e/app-dir/rsc-external/app/external-imports/client/page.js index d2be481e59eab..4c4c3499882ce 100644 --- a/test/e2e/app-dir/rsc-external/app/external-imports/client/page.js +++ b/test/e2e/app-dir/rsc-external/app/external-imports/client/page.js @@ -2,6 +2,8 @@ import getType, { named, value, array, obj } from 'non-isomorphic-text' +import add from 'untranspiled-module' + export default function Page() { return (
@@ -10,6 +12,7 @@ export default function Page() {
{`export value:${value}`}
{`export array:${array.join(',')}`}
{`export object:{x:${obj.x}}`}
+
{`transpilePackages:${add(2, 3)}`}
) } diff --git a/test/e2e/app-dir/rsc-external/next.config.js b/test/e2e/app-dir/rsc-external/next.config.js index fadd1f407c87e..34b0a1a36b0bd 100644 --- a/test/e2e/app-dir/rsc-external/next.config.js +++ b/test/e2e/app-dir/rsc-external/next.config.js @@ -3,5 +3,6 @@ module.exports = { experimental: { appDir: true, serverComponentsExternalPackages: ['conditional-exports-optout'], + transpilePackages: ['untranspiled-module'], }, } diff --git a/test/e2e/app-dir/rsc-external/node_modules_bak/untranspiled-module/index.ts b/test/e2e/app-dir/rsc-external/node_modules_bak/untranspiled-module/index.ts new file mode 100644 index 0000000000000..d964a1a631fbf --- /dev/null +++ b/test/e2e/app-dir/rsc-external/node_modules_bak/untranspiled-module/index.ts @@ -0,0 +1,3 @@ +export default function add(a: number, b: number) { + return a + b +} diff --git a/test/e2e/app-dir/rsc-external/node_modules_bak/untranspiled-module/package.json b/test/e2e/app-dir/rsc-external/node_modules_bak/untranspiled-module/package.json new file mode 100644 index 0000000000000..05558992ba58e --- /dev/null +++ b/test/e2e/app-dir/rsc-external/node_modules_bak/untranspiled-module/package.json @@ -0,0 +1,4 @@ +{ + "name": "untranspiled-module", + "main": "index.ts" +}