diff --git a/docs/advanced-features/react-18/streaming.md b/docs/advanced-features/react-18/streaming.md index 544759b3daa66..984264190dc56 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 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 57335b30cf8ce..a26cbc26cbc8f 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 db23d29dad073..9869dc850e431 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, + }, +} +``` diff --git a/packages/next/bin/next.ts b/packages/next/bin/next.ts index bba1a9c9a0099..dead0052077ff 100755 --- a/packages/next/bin/next.ts +++ b/packages/next/bin/next.ts @@ -15,7 +15,7 @@ import { NON_STANDARD_NODE_ENV } from '../lib/constants' const defaultCommand = 'dev' export type cliCommand = (argv?: string[]) => void -const commands: { [command: string]: () => Promise } = { +export const commands: { [command: string]: () => Promise } = { build: () => Promise.resolve(require('../cli/next-build').nextBuild), start: () => Promise.resolve(require('../cli/next-start').nextStart), export: () => Promise.resolve(require('../cli/next-export').nextExport), diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 4dc5b5b3e7a8a..3b280aba97378 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 65af111e6dcac..c7cc6c4badeac 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 f125d562da505..2555a72795bcf 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 ef01915b4b350..29cbd09aac86b 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/packages/next/client/image.tsx b/packages/next/client/image.tsx index 03f5438f93612..6fad0b58d355c 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/lib/detect-typo.ts b/packages/next/lib/detect-typo.ts new file mode 100644 index 0000000000000..b94200482dbc9 --- /dev/null +++ b/packages/next/lib/detect-typo.ts @@ -0,0 +1,44 @@ +// the minimum number of operations required to convert string a to string b. +function minDistance(a: string, b: string, threshold: number): number { + const m = a.length + const n = b.length + + if (m < n) { + return minDistance(b, a, threshold) + } + + if (n === 0) { + return m + } + + let previousRow = Array.from({ length: n + 1 }, (_, i) => i) + + for (let i = 0; i < m; i++) { + const s1 = a[i] + let currentRow = [i + 1] + for (let j = 0; j < n; j++) { + const s2 = b[j] + const insertions = previousRow[j + 1] + 1 + const deletions = currentRow[j] + 1 + const substitutions = previousRow[j] + Number(s1 !== s2) + currentRow.push(Math.min(insertions, deletions, substitutions)) + } + previousRow = currentRow + } + return previousRow[previousRow.length - 1] +} + +export function detectTypo(input: string, options: string[], threshold = 2) { + const potentialTypos = options + .map((o) => ({ + option: o, + distance: minDistance(o, input, threshold), + })) + .filter(({ distance }) => distance <= threshold && distance > 0) + .sort((a, b) => a.distance - b.distance) + + if (potentialTypos.length) { + return potentialTypos[0].option + } + return null +} diff --git a/packages/next/lib/get-project-dir.ts b/packages/next/lib/get-project-dir.ts index 24b4c32070ab3..f132c846d0ce5 100644 --- a/packages/next/lib/get-project-dir.ts +++ b/packages/next/lib/get-project-dir.ts @@ -1,6 +1,8 @@ import fs from 'fs' import path from 'path' +import { commands } from '../bin/next' import * as Log from '../build/output/log' +import { detectTypo } from './detect-typo' export function getProjectDir(dir?: string) { try { @@ -19,6 +21,17 @@ export function getProjectDir(dir?: string) { return realDir } catch (err: any) { if (err.code === 'ENOENT') { + if (typeof dir === 'string') { + const detectedTypo = detectTypo(dir, Object.keys(commands)) + + if (detectedTypo) { + Log.error( + `"next ${dir}" does not exist. Did you mean "next ${detectedTypo}"?` + ) + process.exit(1) + } + } + Log.error( `Invalid project directory provided, no such directory: ${path.resolve( dir || '.' diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 014b6c104a12c..2dd64eb1b402e 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) } } diff --git a/packages/next/shared/lib/head.tsx b/packages/next/shared/lib/head.tsx index 81d339e21a9f3..d4bbcc64bed22 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/app/pages/shared.server.js b/test/integration/react-streaming-and-server-components/app/pages/shared.server.js new file mode 100644 index 0000000000000..4bb1e2c534ffa --- /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/index.test.js b/test/integration/react-streaming-and-server-components/test/index.test.js index d93f233a1aab0..527a17c875179 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/rsc.js b/test/integration/react-streaming-and-server-components/test/rsc.js index a01fefe5eb1d6..6e8b76d8c2a40 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/test/integration/react-streaming-and-server-components/test/streaming.js b/test/integration/react-streaming-and-server-components/test/streaming.js index 31b6e62591492..edfeccebdcd87 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