From 7f9b476341624a1813cea128cafb422f5d65ac97 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 28 Feb 2022 11:24:42 -0800 Subject: [PATCH 01/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 02/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 03/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 04/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