From 175e081e862b503d2daf4c2d07d589f86d037f06 Mon Sep 17 00:00:00 2001 From: Lee Robinson Date: Mon, 28 Feb 2022 02:32:27 -0600 Subject: [PATCH 01/13] Improve TypeScript documentation. (#34690) - Moves example out of hidden dropdown at the top and features more prominently - Adds a clone and deploy link for those looking to get started immediately - Cross-posts ignoring TS errors docs over, because I've personally assumed it should be on this page and then ctrl+f found nothing - Added version history to the top, based on contents of this file - Remove custom app caveats from code snippet to improve copy-paste ability --- .../ignoring-typescript-errors.md | 2 +- docs/basic-features/typescript.md | 58 +++++++++++-------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/docs/api-reference/next.config.js/ignoring-typescript-errors.md b/docs/api-reference/next.config.js/ignoring-typescript-errors.md index 57335b30cf8c..a26cbc26cbc8 100644 --- a/docs/api-reference/next.config.js/ignoring-typescript-errors.md +++ b/docs/api-reference/next.config.js/ignoring-typescript-errors.md @@ -8,7 +8,7 @@ Next.js fails your **production build** (`next build`) when TypeScript errors ar If you'd like Next.js to dangerously produce production code even when your application has errors, you can disable the built-in type checking step. -> Be sure you are running type checks as part of your build or deploy process, otherwise this can be very dangerous. +If disabled, be sure you are running type checks as part of your build or deploy process, otherwise this can be very dangerous. Open `next.config.js` and enable the `ignoreBuildErrors` option in the `typescript` config: diff --git a/docs/basic-features/typescript.md b/docs/basic-features/typescript.md index db23d29dad07..9869dc850e43 100644 --- a/docs/basic-features/typescript.md +++ b/docs/basic-features/typescript.md @@ -5,14 +5,19 @@ description: Next.js supports TypeScript by default and has built-in types for p # TypeScript
- Examples - + Version History + +| Version | Changes | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `v12.0.0` | [SWC](https://nextjs.org/docs/advanced-features/compiler) is now used by default to compile TypeScript and TSX for faster builds. | +| `v10.2.1` | [Incremental type checking](https://www.typescriptlang.org/tsconfig#incremental) support added when enabled in your `tsconfig.json`. | +
-Next.js provides an integrated [TypeScript](https://www.typescriptlang.org/) -experience out of the box, similar to an IDE. +Next.js provides an integrated [TypeScript](https://www.typescriptlang.org/) experience, including zero-configuration set up and built-in types for Pages, APIs, and more. + +- [Clone and deploy the TypeScript starter](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-typescript&project-name=with-typescript&repository-name=with-typescript) +- [View an example application](https://github.com/vercel/next.js/tree/canary/examples/with-typescript) ## `create-next-app` support @@ -120,26 +125,11 @@ export default (req: NextApiRequest, res: NextApiResponse) => { If you have a [custom `App`](/docs/advanced-features/custom-app.md), you can use the built-in type `AppProps` and change file name to `./pages/_app.tsx` like so: ```ts -// import App from "next/app"; -import type { AppProps /*, AppContext */ } from 'next/app' +import type { AppProps } from 'next/app' -function MyApp({ Component, pageProps }: AppProps) { +export default function MyApp({ Component, pageProps }: AppProps) { return } - -// Only uncomment this method if you have blocking data requirements for -// every single page in your application. This disables the ability to -// perform automatic static optimization, causing every page in your app to -// be server-side rendered. -// -// MyApp.getInitialProps = async (appContext: AppContext) => { -// // calls page's `getInitialProps` and fills `appProps.pageProps` -// const appProps = await App.getInitialProps(appContext); - -// return { ...appProps } -// } - -export default MyApp ``` ## Path aliases and baseUrl @@ -170,3 +160,25 @@ module.exports = nextConfig Since `v10.2.1` Next.js supports [incremental type checking](https://www.typescriptlang.org/tsconfig#incremental) when enabled in your `tsconfig.json`, this can help speed up type checking in larger applications. It is highly recommended to be on at least `v4.3.2` of TypeScript to experience the [best performance](https://devblogs.microsoft.com/typescript/announcing-typescript-4-3/#lazier-incremental) when leveraging this feature. + +## Ignoring TypeScript Errors + +Next.js fails your **production build** (`next build`) when TypeScript errors are present in your project. + +If you'd like Next.js to dangerously produce production code even when your application has errors, you can disable the built-in type checking step. + +If disabled, be sure you are running type checks as part of your build or deploy process, otherwise this can be very dangerous. + +Open `next.config.js` and enable the `ignoreBuildErrors` option in the `typescript` config: + +```js +module.exports = { + typescript: { + // !! WARN !! + // Dangerously allow production builds to successfully complete even if + // your project has type errors. + // !! WARN !! + ignoreBuildErrors: true, + }, +} +``` From 8ba2aec022f88b8d773013c8188fe284779bba8c Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 28 Feb 2022 16:00:28 +0100 Subject: [PATCH 02/13] Fix flight loader for shared components (#34692) * fix flight loader for shared components * add tests * fix condition --- packages/next/build/webpack-config.ts | 26 ++-- .../loaders/next-flight-client-loader.ts | 9 +- .../loaders/next-flight-server-loader.ts | 121 ++++++++++++------ .../webpack/plugins/flight-manifest-plugin.ts | 2 +- .../app/components/client.client.js | 7 + .../app/components/shared.client.js | 3 + .../app/components/shared.js | 21 +++ .../app/pages/shared.server.js | 22 ++++ .../test/rsc.js | 20 +++ yarn.lock | 7 +- 10 files changed, 176 insertions(+), 62 deletions(-) create mode 100644 test/integration/react-streaming-and-server-components/app/components/client.client.js create mode 100644 test/integration/react-streaming-and-server-components/app/components/shared.client.js create mode 100644 test/integration/react-streaming-and-server-components/app/components/shared.js create mode 100644 test/integration/react-streaming-and-server-components/app/pages/shared.server.js diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 4dc5b5b3e7a8..3b280aba9737 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1,7 +1,6 @@ import ReactRefreshWebpackPlugin from 'next/dist/compiled/@next/react-refresh-utils/ReactRefreshWebpackPlugin' import chalk from 'next/dist/compiled/chalk' import crypto from 'crypto' -import { stringify } from 'querystring' import { webpack } from 'next/dist/compiled/webpack/webpack' import type { webpack5 } from 'next/dist/compiled/webpack/webpack' import path, { join as pathJoin, relative as relativePath } from 'path' @@ -1180,10 +1179,11 @@ export default async function getBaseWebpackConfig( ...codeCondition, test: serverComponentsRegex, use: { - loader: `next-flight-server-loader?${stringify({ + loader: 'next-flight-server-loader', + options: { client: 1, - pageExtensions: JSON.stringify(rawPageExtensions), - })}`, + pageExtensions: rawPageExtensions, + }, }, }, ] @@ -1195,22 +1195,16 @@ export default async function getBaseWebpackConfig( ? [ { ...codeCondition, - test: serverComponentsRegex, use: { - loader: `next-flight-server-loader?${stringify({ - pageExtensions: JSON.stringify(rawPageExtensions), - })}`, - }, - }, - { - ...codeCondition, - test: clientComponentsRegex, - use: { - loader: 'next-flight-client-loader', + loader: 'next-flight-server-loader', + options: { + pageExtensions: rawPageExtensions, + }, }, }, { - test: /next[\\/](dist[\\/]client[\\/])?(link|image)/, + test: codeCondition.test, + resourceQuery: /__sc_client__/, use: { loader: 'next-flight-client-loader', }, diff --git a/packages/next/build/webpack/loaders/next-flight-client-loader.ts b/packages/next/build/webpack/loaders/next-flight-client-loader.ts index 65af111e6dca..c7cc6c4badea 100644 --- a/packages/next/build/webpack/loaders/next-flight-client-loader.ts +++ b/packages/next/build/webpack/loaders/next-flight-client-loader.ts @@ -94,11 +94,8 @@ export default async function transformSource( this: any, source: string ): Promise { - const { resourcePath, resourceQuery } = this + const { resourcePath } = this - if (resourceQuery !== '?flight') return source - - let url = resourcePath const transformedSource = source if (typeof transformedSource !== 'string') { throw new Error('Expected source to have been transformed to a string.') @@ -108,7 +105,7 @@ export default async function transformSource( await parseExportNamesInto(resourcePath, transformedSource, names) // next.js/packages/next/.js - if (/[\\/]next[\\/](link|image)\.js$/.test(url)) { + if (/[\\/]next[\\/](link|image)\.js$/.test(resourcePath)) { names.push('default') } @@ -122,7 +119,7 @@ export default async function transformSource( newSrc += 'export const ' + name + ' = ' } newSrc += '{ $$typeof: MODULE_REFERENCE, filepath: ' - newSrc += JSON.stringify(url) + newSrc += JSON.stringify(resourcePath) newSrc += ', name: ' newSrc += JSON.stringify(name) newSrc += '};\n' diff --git a/packages/next/build/webpack/loaders/next-flight-server-loader.ts b/packages/next/build/webpack/loaders/next-flight-server-loader.ts index f125d562da50..2555a72795bc 100644 --- a/packages/next/build/webpack/loaders/next-flight-server-loader.ts +++ b/packages/next/build/webpack/loaders/next-flight-server-loader.ts @@ -5,17 +5,19 @@ import { parse } from '../../swc' import { getBaseSWCOptions } from '../../swc/options' import { getRawPageExtensions } from '../../utils' -function isClientComponent(importSource: string, pageExtensions: string[]) { - return new RegExp(`\\.client(\\.(${pageExtensions.join('|')}))?`).test( - importSource - ) -} +const getIsClientComponent = + (pageExtensions: string[]) => (importSource: string) => { + return new RegExp(`\\.client(\\.(${pageExtensions.join('|')}))?`).test( + importSource + ) + } -function isServerComponent(importSource: string, pageExtensions: string[]) { - return new RegExp(`\\.server(\\.(${pageExtensions.join('|')}))?`).test( - importSource - ) -} +const getIsServerComponent = + (pageExtensions: string[]) => (importSource: string) => { + return new RegExp(`\\.server(\\.(${pageExtensions.join('|')}))?`).test( + importSource + ) + } function isNextComponent(importSource: string) { return ( @@ -31,13 +33,21 @@ export function isImageImport(importSource: string) { ) } -async function parseImportsInfo( - resourcePath: string, - source: string, - imports: Array, - isClientCompilation: boolean, - pageExtensions: string[] -): Promise<{ +async function parseImportsInfo({ + resourcePath, + source, + imports, + isClientCompilation, + isServerComponent, + isClientComponent, +}: { + resourcePath: string + source: string + imports: Array + isClientCompilation: boolean + isServerComponent: (name: string) => boolean + isClientComponent: (name: string) => boolean +}): Promise<{ source: string defaultExportName: string }> { @@ -45,7 +55,6 @@ async function parseImportsInfo( filename: resourcePath, globalWindow: isClientCompilation, }) - const ast = await parse(source, { ...opts.jsc.parser, isModule: true }) const { body } = ast const beginPos = ast.span.start @@ -58,29 +67,49 @@ async function parseImportsInfo( case 'ImportDeclaration': { const importSource = node.source.value if (!isClientCompilation) { + // Server compilation for .server.js. + if (isServerComponent(importSource)) { + continue + } + + const importDeclarations = source.substring( + lastIndex, + node.source.span.start - beginPos + ) + if ( !( - isClientComponent(importSource, pageExtensions) || + isClientComponent(importSource) || isNextComponent(importSource) || isImageImport(importSource) ) ) { - continue + if ( + ['react/jsx-runtime', 'react/jsx-dev-runtime'].includes( + importSource + ) + ) { + continue + } + + // A shared component. It should be handled as a server + // component. + transformedSource += importDeclarations + transformedSource += JSON.stringify(`${importSource}?__sc_server__`) + } else { + // A client component. It should be loaded as module reference. + transformedSource += importDeclarations + transformedSource += JSON.stringify(`${importSource}?__sc_client__`) + imports.push(`require(${JSON.stringify(importSource)})`) } - const importDeclarations = source.substring( - lastIndex, - node.source.span.start - beginPos - ) - transformedSource += importDeclarations - transformedSource += JSON.stringify(`${node.source.value}?flight`) } else { // For the client compilation, we skip all modules imports but // always keep client components in the bundle. All client components // have to be imported from either server or client components. if ( !( - isClientComponent(importSource, pageExtensions) || - isServerComponent(importSource, pageExtensions) || + isClientComponent(importSource) || + isServerComponent(importSource) || // Special cases for Next.js APIs that are considered as client // components: isNextComponent(importSource) || @@ -89,11 +118,12 @@ async function parseImportsInfo( ) { continue } + + imports.push(`require(${JSON.stringify(importSource)})`) } lastIndex = node.source.span.end - beginPos - imports.push(`require(${JSON.stringify(importSource)})`) - continue + break } case 'ExportDefaultDeclaration': { const def = node.decl @@ -126,28 +156,44 @@ export default async function transformSource( this: any, source: string ): Promise { - const { client: isClientCompilation, pageExtensions: pageExtensionsJson } = - this.getOptions() - const { resourcePath } = this - const pageExtensions = JSON.parse(pageExtensionsJson) + const { client: isClientCompilation, pageExtensions } = this.getOptions() + const { resourcePath, resourceQuery } = this if (typeof source !== 'string') { throw new Error('Expected source to have been transformed to a string.') } + // We currently assume that all components are shared components (unsuffixed) + // from node_modules. if (resourcePath.includes('/node_modules/')) { return source } + const rawRawPageExtensions = getRawPageExtensions(pageExtensions) + const isServerComponent = getIsServerComponent(rawRawPageExtensions) + const isClientComponent = getIsClientComponent(rawRawPageExtensions) + + if (!isClientCompilation) { + // We only apply the loader to server components, or shared components that + // are imported by a server component. + if ( + !isServerComponent(resourcePath) && + resourceQuery !== '?__sc_server__' + ) { + return source + } + } + const imports: string[] = [] const { source: transformedSource, defaultExportName } = - await parseImportsInfo( + await parseImportsInfo({ resourcePath, source, imports, isClientCompilation, - getRawPageExtensions(pageExtensions) - ) + isServerComponent, + isClientComponent, + }) /** * For .server.js files, we handle this loader differently. @@ -177,6 +223,5 @@ export default async function transformSource( } const transformed = transformedSource + '\n' + noop + '\n' + defaultExportNoop - return transformed } diff --git a/packages/next/build/webpack/plugins/flight-manifest-plugin.ts b/packages/next/build/webpack/plugins/flight-manifest-plugin.ts index ef01915b4b35..29cbd09aac86 100644 --- a/packages/next/build/webpack/plugins/flight-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/flight-manifest-plugin.ts @@ -69,7 +69,7 @@ export class FlightManifestPlugin { const { clientComponentsRegex } = this compilation.chunkGroups.forEach((chunkGroup: any) => { function recordModule(id: string, _chunk: any, mod: any) { - const resource = mod.resource?.replace(/\?flight$/, '') + const resource = mod.resource?.replace(/\?__sc_client__$/, '') // TODO: Hook into deps instead of the target module. // That way we know by the type of dep whether to include. diff --git a/test/integration/react-streaming-and-server-components/app/components/client.client.js b/test/integration/react-streaming-and-server-components/app/components/client.client.js new file mode 100644 index 000000000000..7b20e208abd7 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/components/client.client.js @@ -0,0 +1,7 @@ +import { useState } from 'react' + +export default function Client() { + // To ensure that this component is rendered as a client component, we use a + // state here. + return useState('client_component')[0] +} diff --git a/test/integration/react-streaming-and-server-components/app/components/shared.client.js b/test/integration/react-streaming-and-server-components/app/components/shared.client.js new file mode 100644 index 000000000000..201654274422 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/components/shared.client.js @@ -0,0 +1,3 @@ +import Shared from './shared' + +export default Shared diff --git a/test/integration/react-streaming-and-server-components/app/components/shared.js b/test/integration/react-streaming-and-server-components/app/components/shared.js new file mode 100644 index 000000000000..f66f284b9c8a --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/components/shared.js @@ -0,0 +1,21 @@ +import { useState } from 'react' +import Client from './client.client' + +const random = ~~(Math.random() * 10000) + +export default function Shared() { + let isServerComponent + try { + useState() + isServerComponent = false + } catch (e) { + isServerComponent = true + } + + return ( + <> + ,{' '} + {(isServerComponent ? 'shared:server' : 'shared:client') + ':' + random} + + ) +} diff --git a/test/integration/react-streaming-and-server-components/app/pages/shared.server.js b/test/integration/react-streaming-and-server-components/app/pages/shared.server.js new file mode 100644 index 000000000000..4bb1e2c534ff --- /dev/null +++ b/test/integration/react-streaming-and-server-components/app/pages/shared.server.js @@ -0,0 +1,22 @@ +import ClientFromDirect from '../components/client.client' +import ClientFromShared from '../components/shared' +import SharedFromClient from '../components/shared.client' + +export default function Page() { + // All three client components should be rendered correctly, but only + // shared component is a server component, and another is a client component. + // These two shared components should be created as two module instances. + return ( +
+ +
+ +
+ +
+ +
+ +
+ ) +} diff --git a/test/integration/react-streaming-and-server-components/test/rsc.js b/test/integration/react-streaming-and-server-components/test/rsc.js index a01fefe5eb1d..6e8b76d8c2a4 100644 --- a/test/integration/react-streaming-and-server-components/test/rsc.js +++ b/test/integration/react-streaming-and-server-components/test/rsc.js @@ -55,6 +55,26 @@ export default function (context, { runtime, env }) { expect(html).toContain('foo.client') }) + it('should resolve different kinds of components correctly', async () => { + const html = await renderViaHTTP(context.appPort, '/shared') + const main = getNodeBySelector(html, '#main').html() + + // Should have 5 occurrences of "client_component". + expect([...main.matchAll(/client_component/g)].length).toBe(5) + + // Should have 2 occurrences of "shared:server", and 2 occurrences of + // "shared:client". + const sharedServerModule = [...main.matchAll(/shared:server:(\d+)/g)] + const sharedClientModule = [...main.matchAll(/shared:client:(\d+)/g)] + expect(sharedServerModule.length).toBe(2) + expect(sharedClientModule.length).toBe(2) + + // Should have 2 modules created for the shared component. + expect(sharedServerModule[0][1]).toBe(sharedServerModule[1][1]) + expect(sharedClientModule[0][1]).toBe(sharedClientModule[1][1]) + expect(sharedServerModule[0][1]).not.toBe(sharedClientModule[0][1]) + }) + it('should support next/link in server components', async () => { const linkHTML = await renderViaHTTP(context.appPort, '/next-api/link') const linkText = getNodeBySelector( diff --git a/yarn.lock b/yarn.lock index 809d3a4e7732..dc7a69096c03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4855,7 +4855,7 @@ version "4.1.5" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" -"@types/eslint-scope@^3.7.3": +"@types/eslint-scope@^3.7.0", "@types/eslint-scope@^3.7.3": version "3.7.3" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224" integrity sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g== @@ -4880,6 +4880,11 @@ version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" +"@types/estree@^0.0.50": + version "0.0.50" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83" + integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw== + "@types/estree@^0.0.51": version "0.0.51" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" From 281ef22ebabeb0a77155e68269926fe15207d27a Mon Sep 17 00:00:00 2001 From: Alessandro Date: Mon, 28 Feb 2022 16:09:09 +0100 Subject: [PATCH 03/13] Fix broken link in react-18 streaming docs. (#34884) A link leads to a 404 page due to a missing `/ ` --- docs/advanced-features/react-18/streaming.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced-features/react-18/streaming.md b/docs/advanced-features/react-18/streaming.md index 544759b3daa6..984264190dc5 100644 --- a/docs/advanced-features/react-18/streaming.md +++ b/docs/advanced-features/react-18/streaming.md @@ -1,7 +1,7 @@ # Streaming SSR (Alpha) React 18 will include architectural improvements to React server-side rendering (SSR) performance. This means you can use `Suspense` in your React components in streaming SSR mode and React will render them on the server and send them through HTTP streams. -It's worth noting that another experimental feature, React Server Components, is based on streaming. You can read more about server components related streaming APIs in [`next/streaming`](docs/api-reference/next/streaming.md). However, this guide focuses on basic React 18 streaming. +It's worth noting that another experimental feature, React Server Components, is based on streaming. You can read more about server components related streaming APIs in [`next/streaming`](/docs/api-reference/next/streaming.md). However, this guide focuses on basic React 18 streaming. ## Enable Streaming SSR From 7f9b476341624a1813cea128cafb422f5d65ac97 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 28 Feb 2022 11:24:42 -0800 Subject: [PATCH 04/13] Update to use whatwg validator for test (#34891) The w3c validator seems to be down and this shouldn't block our tests so this uses the whatwg validator instead x-ref: https://github.com/vercel/next.js/runs/5361585193?check_suite_focus=true x-ref: https://github.com/vercel/next.js/runs/5356851471?check_suite_focus=true x-ref: https://github.com/vercel/next.js/runs/5361463120?check_suite_focus=true --- test/integration/image-component/default/test/index.test.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/integration/image-component/default/test/index.test.js b/test/integration/image-component/default/test/index.test.js index 96d1041b6b59..9bc8665170f2 100644 --- a/test/integration/image-component/default/test/index.test.js +++ b/test/integration/image-component/default/test/index.test.js @@ -1150,7 +1150,7 @@ function runTests(mode) { } }) - it('should be valid W3C HTML', async () => { + it('should be valid HTML', async () => { let browser try { browser = await webdriver(appPort, '/valid-html-w3c') @@ -1161,8 +1161,10 @@ function runTests(mode) { url, format: 'json', isLocal: true, + validator: 'whatwg', }) - expect(result.messages).toEqual([]) + expect(result.isValid).toBe(true) + expect(result.errors).toEqual([]) } finally { if (browser) { await browser.close() From 7ca78dd036cd3cfe04b9cf29e669d93126be1c9c Mon Sep 17 00:00:00 2001 From: Sukka Date: Tue, 1 Mar 2022 03:50:57 +0800 Subject: [PATCH 05/13] refactor: re-use existed escapeRegex (#34470) ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have 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 helpful link attached, see `contributing.md` ## Documentation / Examples - [x] Make sure the linting passes by running `yarn lint` `escape-regex.ts` will always be included in the bundle, so not re-using it actually makes the size larger. --- packages/next/shared/lib/router/utils/route-regex.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/next/shared/lib/router/utils/route-regex.ts b/packages/next/shared/lib/router/utils/route-regex.ts index a3a24dc755b0..ca26d12a1e1f 100644 --- a/packages/next/shared/lib/router/utils/route-regex.ts +++ b/packages/next/shared/lib/router/utils/route-regex.ts @@ -1,15 +1,11 @@ +import { escapeStringRegexp } from '../../escape-regexp' + interface Group { pos: number repeat: boolean optional: boolean } -// this isn't importing the escape-string-regex module -// to reduce bytes -function escapeRegex(str: string) { - return str.replace(/[|\\{}()[\]^$+*?.-]/g, '\\$&') -} - function parseParameter(param: string) { const optional = param.startsWith('[') && param.endsWith(']') if (optional) { @@ -34,7 +30,7 @@ export function getParametrizedRoute(route: string) { groups[key] = { pos: groupIndex++, repeat, optional } return repeat ? (optional ? '(?:/(.+?))?' : '/(.+?)') : '/([^/]+?)' } else { - return `/${escapeRegex(segment)}` + return `/${escapeStringRegexp(segment)}` } }) .join('') @@ -92,7 +88,7 @@ export function getParametrizedRoute(route: string) { : `/(?<${cleanedKey}>.+?)` : `/(?<${cleanedKey}>[^/]+?)` } else { - return `/${escapeRegex(segment)}` + return `/${escapeStringRegexp(segment)}` } }) .join('') From 8e3b6fc4b94dbaff6112046c23a73672411d6658 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Mon, 28 Feb 2022 22:57:08 +0100 Subject: [PATCH 06/13] Fix only byte stream writing is allowed in CF workers (#34893) CF worker doesn't allow to use `writer.write(string)` but only byte stream, we have to transform the Uint8Array stream ![image](https://user-images.githubusercontent.com/4800338/156043536-25fcdb15-3f69-427e-9e31-97169609eb7a.png) --- packages/next/server/render.tsx | 181 +++++++++++++++----------------- 1 file changed, 84 insertions(+), 97 deletions(-) diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 014b6c104a12..2dd64eb1b402 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -304,17 +304,15 @@ const rscCache = new Map() function createRSCHook() { return ( - writable: WritableStream, + writable: WritableStream, id: string, - req: ReadableStream, + req: ReadableStream, bootstrap: boolean ) => { let entry = rscCache.get(id) if (!entry) { const [renderStream, forwardStream] = readableStreamTee(req) - entry = createFromReadableStream( - pipeThrough(renderStream, createTextEncoderStream()) - ) + entry = createFromReadableStream(renderStream) rscCache.set(id, entry) let bootstrapped = false @@ -325,10 +323,11 @@ function createRSCHook() { if (bootstrap && !bootstrapped) { bootstrapped = true writer.write( - `` + encodeText( + `` + ) ) } if (done) { @@ -336,11 +335,11 @@ function createRSCHook() { writer.close() } else { writer.write( - `` + encodeText( + `` + ) ) process() } @@ -365,7 +364,7 @@ function createServerComponentRenderer( runtime, }: { cachePrefix: string - transformStream: TransformStream + transformStream: TransformStream serverComponentManifest: NonNullable runtime: 'nodejs' | 'edge' } @@ -381,12 +380,9 @@ function createServerComponentRenderer( const writable = transformStream.writable const ServerComponentWrapper = (props: any) => { const id = (React as any).useId() - const reqStream: ReadableStream = pipeThrough( - renderToReadableStream( - renderFlight(App, OriginalComponent, props), - serverComponentManifest - ), - createTextDecoderStream() + const reqStream: ReadableStream = renderToReadableStream( + renderFlight(App, OriginalComponent, props), + serverComponentManifest ) const response = useRSCResponse( @@ -482,8 +478,8 @@ export async function renderToHTML( let Component: React.ComponentType<{}> | ((props: any) => JSX.Element) = renderOpts.Component let serverComponentsInlinedTransformStream: TransformStream< - string, - string + Uint8Array, + Uint8Array > | null = null if (isServerComponent) { @@ -1181,21 +1177,16 @@ export async function renderToHTML( if (isResSent(res) && !isSSG) return null if (renderServerComponentData) { - const stream: ReadableStream = pipeThrough( - renderToReadableStream( - renderFlight(App, OriginalComponent, { - ...props.pageProps, - ...serverComponentProps, - }), - serverComponentManifest - ), - createTextDecoderStream() + const stream: ReadableStream = renderToReadableStream( + renderFlight(App, OriginalComponent, { + ...props.pageProps, + ...serverComponentProps, + }), + serverComponentManifest ) + return new RenderResult( - pipeThrough( - pipeThrough(stream, createBufferedTransformStream()), - createTextEncoderStream() - ) + pipeThrough(stream, createBufferedTransformStream()) ) } @@ -1360,7 +1351,8 @@ export async function renderToHTML( generateStaticHTML: true, }) - return await streamToString(flushEffectStream) + const flushed = await streamToString(flushEffectStream) + return flushed } return await renderToStream({ @@ -1607,9 +1599,7 @@ export async function renderToHTML( return new RenderResult(html) } - return new RenderResult( - pipeThrough(chainStreams(streams), createTextEncoderStream()) - ) + return new RenderResult(chainStreams(streams)) } function errorToJSON(err: Error) { @@ -1707,27 +1697,10 @@ function createTransformStream({ } } -function createTextDecoderStream(): TransformStream { - const decoder = new TextDecoder() - return createTransformStream({ - transform(chunk, controller) { - controller.enqueue( - typeof chunk === 'string' ? chunk : decoder.decode(chunk) - ) - }, - }) -} - -function createTextEncoderStream(): TransformStream { - const encoder = new TextEncoder() - return createTransformStream({ - transform(chunk, controller) { - controller.enqueue(encoder.encode(chunk)) - }, - }) -} - -function createBufferedTransformStream(): TransformStream { +function createBufferedTransformStream(): TransformStream< + Uint8Array, + Uint8Array +> { let bufferedString = '' let pendingFlush: Promise | null = null @@ -1735,7 +1708,7 @@ function createBufferedTransformStream(): TransformStream { if (!pendingFlush) { pendingFlush = new Promise((resolve) => { setTimeout(() => { - controller.enqueue(bufferedString) + controller.enqueue(encodeText(bufferedString)) bufferedString = '' pendingFlush = null resolve() @@ -1747,7 +1720,7 @@ function createBufferedTransformStream(): TransformStream { return createTransformStream({ transform(chunk, controller) { - bufferedString += chunk + bufferedString += decodeText(chunk) flushBuffer(controller) }, @@ -1761,11 +1734,11 @@ function createBufferedTransformStream(): TransformStream { function createFlushEffectStream( handleFlushEffect: () => Promise -): TransformStream { +): TransformStream { return createTransformStream({ async transform(chunk, controller) { const extraChunk = await handleFlushEffect() - controller.enqueue(extraChunk + chunk) + controller.enqueue(encodeText(extraChunk + decodeText(chunk))) }, }) } @@ -1781,10 +1754,10 @@ function renderToStream({ ReactDOMServer: typeof import('react-dom/server') element: React.ReactElement suffix?: string - dataStream?: ReadableStream + dataStream?: ReadableStream generateStaticHTML: boolean flushEffectHandler?: () => Promise -}): Promise> { +}): Promise> { return new Promise((resolve, reject) => { let resolved = false @@ -1799,7 +1772,7 @@ function renderToStream({ // defer to a microtask to ensure `stream` is set. resolve( Promise.resolve().then(() => { - const transforms: Array> = [ + const transforms: Array> = [ createBufferedTransformStream(), flushEffectHandler ? createFlushEffectStream(flushEffectHandler) @@ -1820,45 +1793,57 @@ function renderToStream({ } } - const renderStream = pipeThrough( - (ReactDOMServer as any).renderToReadableStream(element, { - onError(err: Error) { - if (!resolved) { - resolved = true - reject(err) - } - }, - onCompleteShell() { - if (!generateStaticHTML) { - doResolve() - } - }, - onCompleteAll() { + const renderStream: ReadableStream = ( + ReactDOMServer as any + ).renderToReadableStream(element, { + onError(err: Error) { + if (!resolved) { + resolved = true + reject(err) + } + }, + onCompleteShell() { + if (!generateStaticHTML) { doResolve() - }, - }), - createTextDecoderStream() - ) + } + }, + onCompleteAll() { + doResolve() + }, + }) }) } -function createSuffixStream(suffix: string): TransformStream { +function encodeText(input: string) { + return new TextEncoder().encode(input) +} + +function decodeText(input?: Uint8Array) { + return new TextDecoder().decode(input) +} + +function createSuffixStream( + suffix: string +): TransformStream { return createTransformStream({ flush(controller) { if (suffix) { - controller.enqueue(suffix) + controller.enqueue(encodeText(suffix)) } }, }) } -function createPrefixStream(prefix: string): TransformStream { +function createPrefixStream( + prefix: string +): TransformStream { let prefixFlushed = false return createTransformStream({ transform(chunk, controller) { if (!prefixFlushed && prefix) { prefixFlushed = true - controller.enqueue(chunk + prefix) + controller.enqueue(chunk) + controller.enqueue(encodeText(prefix)) } else { controller.enqueue(chunk) } @@ -1866,15 +1851,15 @@ function createPrefixStream(prefix: string): TransformStream { flush(controller) { if (!prefixFlushed && prefix) { prefixFlushed = true - controller.enqueue(prefix) + controller.enqueue(encodeText(prefix)) } }, }) } function createInlineDataStream( - dataStream: ReadableStream -): TransformStream { + dataStream: ReadableStream +): TransformStream { let dataStreamFinished: Promise | null = null return createTransformStream({ transform(chunk, controller) { @@ -1966,19 +1951,21 @@ function chainStreams(streams: ReadableStream[]): ReadableStream { return readable } -function streamFromArray(strings: string[]): ReadableStream { +function streamFromArray(strings: string[]): ReadableStream { // Note: we use a TransformStream here instead of instantiating a ReadableStream // because the built-in ReadableStream polyfill runs strings through TextEncoder. const { readable, writable } = new TransformStream() const writer = writable.getWriter() - strings.forEach((str) => writer.write(str)) + strings.forEach((str) => writer.write(encodeText(str))) writer.close() return readable } -async function streamToString(stream: ReadableStream): Promise { +async function streamToString( + stream: ReadableStream +): Promise { const reader = stream.getReader() let bufferedString = '' @@ -1989,6 +1976,6 @@ async function streamToString(stream: ReadableStream): Promise { return bufferedString } - bufferedString += value + bufferedString += decodeText(value) } } From 57702cb2a9a9dba4b552e0007c16449cf36cfb44 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Mon, 28 Feb 2022 23:39:51 +0100 Subject: [PATCH 07/13] Only warn styles and scripts under next head in concurrent mode (#34897) x-ref: #34021 , #34004 Only log each warning once and only trigger in concurrent mode ## Bug - [x] Related issues linked using `fixes #number` - [x] Integration tests added - [x] Errors have helpful link attached, see `contributing.md` --- packages/next/client/image.tsx | 12 +-- packages/next/shared/lib/head.tsx | 10 ++- packages/next/shared/lib/utils.ts | 13 +++ .../client-navigation/test/index.test.js | 69 ---------------- .../app}/pages/head-with-json-ld-snippet.js | 0 .../app/pages/head.js | 15 ++++ .../test/index.test.js | 6 +- .../test/streaming.js | 82 ++++++++++++++++++- 8 files changed, 119 insertions(+), 88 deletions(-) rename test/integration/{client-navigation => react-streaming-and-server-components/app}/pages/head-with-json-ld-snippet.js (100%) create mode 100644 test/integration/react-streaming-and-server-components/app/pages/head.js diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 03f5438f9361..6fad0b58d355 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -8,6 +8,7 @@ import { } from '../shared/lib/image-config' import { useIntersection } from './use-intersection' import { ImageConfigContext } from '../shared/lib/image-config-context' +import { warnOnce } from '../shared/lib/utils' const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete const loadedImageURLs = new Set() @@ -23,17 +24,6 @@ if (typeof window === 'undefined') { ;(global as any).__NEXT_IMAGE_IMPORTED = true } -let warnOnce = (_: string) => {} -if (process.env.NODE_ENV !== 'production') { - const warnings = new Set() - warnOnce = (msg: string) => { - if (!warnings.has(msg)) { - console.warn(msg) - } - warnings.add(msg) - } -} - const VALID_LOADING_VALUES = ['lazy', 'eager', undefined] as const type LoadingValue = typeof VALID_LOADING_VALUES[number] type ImageConfig = ImageConfigComplete & { allSizes: number[] } diff --git a/packages/next/shared/lib/head.tsx b/packages/next/shared/lib/head.tsx index 81d339e21a9f..d4bbcc64bed2 100644 --- a/packages/next/shared/lib/head.tsx +++ b/packages/next/shared/lib/head.tsx @@ -3,6 +3,7 @@ import Effect from './side-effect' import { AmpStateContext } from './amp-context' import { HeadManagerContext } from './head-manager-context' import { isInAmpMode } from './amp' +import { warnOnce } from './utils' type WithInAmpMode = { inAmpMode?: boolean @@ -161,17 +162,20 @@ function reduceComponents( return React.cloneElement(c, newProps) } } - if (process.env.NODE_ENV === 'development') { + if ( + process.env.NODE_ENV === 'development' && + process.env.__NEXT_CONCURRENT_FEATURES + ) { // omit JSON-LD structured data snippets from the warning if (c.type === 'script' && c.props['type'] !== 'application/ld+json') { const srcMessage = c.props['src'] ? ` + + +

Streaming Head

+ +) diff --git a/test/integration/react-streaming-and-server-components/test/index.test.js b/test/integration/react-streaming-and-server-components/test/index.test.js index d93f233a1aab..527a17c87517 100644 --- a/test/integration/react-streaming-and-server-components/test/index.test.js +++ b/test/integration/react-streaming-and-server-components/test/index.test.js @@ -151,7 +151,7 @@ describe('Edge runtime - prod', () => { }) basic(context, { env: 'prod' }) - streaming(context) + streaming(context, { env: 'prod' }) rsc(context, { runtime: 'edge', env: 'prod' }) }) @@ -184,14 +184,14 @@ describe('Edge runtime - dev', () => { }) basic(context, { env: 'dev' }) - streaming(context) + streaming(context, { env: 'dev' }) rsc(context, { runtime: 'edge', env: 'dev' }) }) const nodejsRuntimeBasicSuite = { runTests: (context, env) => { basic(context, { env }) - streaming(context) + streaming(context, { env }) rsc(context, { runtime: 'nodejs' }) if (env === 'prod') { diff --git a/test/integration/react-streaming-and-server-components/test/streaming.js b/test/integration/react-streaming-and-server-components/test/streaming.js index 31b6e6259149..edfeccebdcd8 100644 --- a/test/integration/react-streaming-and-server-components/test/streaming.js +++ b/test/integration/react-streaming-and-server-components/test/streaming.js @@ -1,6 +1,6 @@ /* eslint-env jest */ import webdriver from 'next-webdriver' -import { fetchViaHTTP } from 'next-test-utils' +import { fetchViaHTTP, waitFor } from 'next-test-utils' async function resolveStreamResponse(response, onData) { let result = '' @@ -16,7 +16,7 @@ async function resolveStreamResponse(response, onData) { return result } -export default function (context) { +export default function (context, { env }) { it('should support streaming for fizz response', async () => { await fetchViaHTTP(context.appPort, '/streaming', null, {}).then( async (response) => { @@ -99,4 +99,82 @@ export default function (context) { expect(result).toMatch(/<\/body><\/html>/) }) }) + + if (env === 'dev') { + it('should warn when stylesheets or scripts are in head', async () => { + let browser + try { + browser = await webdriver(context.appPort, '/head') + + await browser.waitForElementByCss('h1') + await waitFor(1000) + const browserLogs = await browser.log('browser') + let foundStyles = false + let foundScripts = false + const logs = [] + browserLogs.forEach(({ message }) => { + if (message.includes('Do not add stylesheets using next/head')) { + foundStyles = true + logs.push(message) + } + if (message.includes('Do not add