From b47023ce0409c56305578f180437eed7d5ad8c85 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Wed, 9 Feb 2022 03:16:34 +0000 Subject: [PATCH 01/23] Add useFlushEffect --- packages/next/client/streaming/index.ts | 1 + packages/next/server/render.tsx | 200 ++++++++++++++++------- packages/next/shared/lib/flush-effect.ts | 15 ++ 3 files changed, 156 insertions(+), 60 deletions(-) create mode 100644 packages/next/shared/lib/flush-effect.ts diff --git a/packages/next/client/streaming/index.ts b/packages/next/client/streaming/index.ts index f6fc5aaf42e4b69..f826ea9c31f5f00 100644 --- a/packages/next/client/streaming/index.ts +++ b/packages/next/client/streaming/index.ts @@ -1,2 +1,3 @@ export { useRefreshRoot as unstable_useRefreshRoot } from './refresh' export { useWebVitalsReport as unstable_useWebVitalsReport } from './vitals' +export { useFlushEffect } from '../../shared/lib/flush-effect' diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index cf73919b4df6769..a61eb3ecd789803 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -63,6 +63,7 @@ import isError from '../lib/is-error' import { readableStreamTee } from './web/utils' import { ImageConfigContext } from '../shared/lib/image-config-context' import { ImageConfigComplete } from './image-config' +import { FlushEffectContext } from '../shared/lib/flush-effect' let optimizeAmp: typeof import('./optimize-amp').default let getFontDefinitionFromManifest: typeof import('./font-utils').getFontDefinitionFromManifest @@ -725,33 +726,67 @@ export async function renderToHTML( const nextExport = !isSSG && (renderOpts.nextExport || (dev && (isAutoExport || isFallback))) + const flushEffectState: { + effects: Array<() => React.ReactNode> + sealed: boolean + } = { effects: [], sealed: false } + + function FlushEffectContainer({ children }: { children: JSX.Element }) { + // If the client tree suspends, this component will be rendered multiple + // times before we flush. To ensure we don't call old callbacks corresponding + // to a previous render, we clear any registered callbacks whenever we render. + flushEffectState.effects.length = 0 + + const flushEffectImpl = React.useCallback( + (callback: () => React.ReactNode) => { + if (!flushEffectState.sealed) { + flushEffectState.effects.push(callback) + } else { + throw new Error( + '`useFlushEffect` was called after flushing started. Did you use it inside a boundary?' + + '\nRead more: https://nextjs.org/docs/messages/flush-after-start' + ) + } + }, + [] + ) + + return ( + + {children} + + ) + } + const AppContainer = ({ children }: { children: JSX.Element }) => ( - - - { - head = state - }, - updateScripts: (scripts) => { - scriptLoader = scripts - }, - scripts: {}, - mountedInstances: new Set(), - }} - > - reactLoadableModules.push(moduleName)} + + + + { + head = state + }, + updateScripts: (scripts) => { + scriptLoader = scripts + }, + scripts: {}, + mountedInstances: new Set(), + }} > - - - {children} - - - - - - + reactLoadableModules.push(moduleName)} + > + + + {children} + + + + + + + ) // The `useId` API uses the path indexes to generate an ID for each node. @@ -1281,14 +1316,33 @@ export async function renderToHTML( // up to date when getWrappedApp is called const content = renderContent() - return await renderToStream( + const handleFlushEffect = async () => { + // Prevent additional flush effects from being registered + flushEffectState.sealed = true + + const flushEffectStream = await renderToStream({ + ReactDOMServer, + element: ( + <> + {flushEffectState.effects.map((flushEffect, i) => ( + {flushEffect()} + ))} + + ), + generateStaticHTML: true, + }) + + return await streamToString(flushEffectStream) + } + + return await renderToStream({ ReactDOMServer, - content, + element: content, suffix, - serverComponentsInlinedTransformStream?.readable ?? - streamFromArray([]), - generateStaticHTML || !hasConcurrentFeatures - ) + dataStream: serverComponentsInlinedTransformStream?.readable, + generateStaticHTML: generateStaticHTML || !hasConcurrentFeatures, + handleFlushEffect, + }) } } else { const content = renderContent() @@ -1414,13 +1468,11 @@ export async function renderToHTML( let documentHTML: string if (hasConcurrentFeatures) { - const documentStream = await renderToStream( + const documentStream = await renderToStream({ ReactDOMServer, - document, - null, - streamFromArray([]), - true - ) + element: document, + generateStaticHTML: true, + }) documentHTML = await streamToString(documentStream) } else { documentHTML = ReactDOMServer.renderToStaticMarkup(document) @@ -1563,7 +1615,7 @@ function createTransformStream({ transform?: ( chunk: Uint8Array, controller: TransformStreamDefaultController - ) => void + ) => Promise | void }): TransformStream { const source = new TransformStream() const sink = new TransformStream() @@ -1605,7 +1657,10 @@ function createTransformStream({ } if (transform) { - transform(value, controller) + const maybePromise = transform(value, controller) + if (maybePromise) { + await maybePromise + } } else { controller.enqueue(value) } @@ -1656,13 +1711,39 @@ function createBufferedTransformStream(): TransformStream { }) } -function renderToStream( - ReactDOMServer: typeof import('react-dom/server'), - element: React.ReactElement, - suffix: string | null, - dataStream: ReadableStream, +function createFlushEffectStream( + handleFlushEffect: () => Promise +): TransformStream { + const decoder = new TextDecoder() + const encoder = new TextEncoder() + + return createTransformStream({ + async transform(chunk, controller) { + const extraChunk = await handleFlushEffect() + // HACK: Re-encode the original chunk to ensure that the two chunks + // are compressed and sent together to try to reduce overhead. + controller.enqueue( + encoder.encode(`${extraChunk}${decoder.decode(chunk)}`) + ) + }, + }) +} + +function renderToStream({ + ReactDOMServer, + element, + suffix, + dataStream, + generateStaticHTML, + handleFlushEffect, +}: { + ReactDOMServer: typeof import('react-dom/server') + element: React.ReactElement + suffix?: string + dataStream?: ReadableStream generateStaticHTML: boolean -): Promise { + handleFlushEffect?: () => Promise +}): Promise { return new Promise((resolve, reject) => { let resolved = false @@ -1681,22 +1762,21 @@ function renderToStream( // React will call our callbacks synchronously, so we need to // defer to a microtask to ensure `stream` is set. - Promise.resolve().then(() => - resolve( - pipeThrough( - pipeThrough( - pipeThrough(stream, createBufferedTransformStream()), - createInlineDataStream( - pipeThrough( - dataStream, - createPrefixStream(suffixState?.suffixUnclosed ?? null) - ) - ) - ), - createSuffixStream(suffixState?.closeTag ?? null) - ) + Promise.resolve().then(() => { + const transforms: Array = [ + createBufferedTransformStream(), + handleFlushEffect + ? createFlushEffectStream(handleFlushEffect) + : null, + dataStream ? createInlineDataStream(dataStream) : null, + suffixState ? createPrefixStream(suffixState.suffixUnclosed) : null, + ].filter(Boolean) as any + + return transforms.reduce( + (readable, transform) => pipeThrough(readable, transform), + stream ) - ) + }) } } diff --git a/packages/next/shared/lib/flush-effect.ts b/packages/next/shared/lib/flush-effect.ts new file mode 100644 index 000000000000000..dc030408d6b7d13 --- /dev/null +++ b/packages/next/shared/lib/flush-effect.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react' + +export type FlushEffectHook = (callback: () => React.ReactNode) => void + +export const FlushEffectContext: React.Context = + createContext(null as any) + +export function useFlushEffect(callback: () => React.ReactNode): void { + const flushEffectImpl = useContext(FlushEffectContext) + return flushEffectImpl!(callback) +} + +if (process.env.NODE_ENV !== 'production') { + FlushEffectContext.displayName = 'FlushEffectContext' +} From 4cd556089577e5cc0666fab136522ebc23b3d5d6 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Wed, 9 Feb 2022 04:40:48 +0000 Subject: [PATCH 02/23] Flush styled-jsx using a flush effect --- packages/next/server/render.tsx | 68 ++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index a61eb3ecd789803..64cae9652cc2921 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -63,7 +63,7 @@ import isError from '../lib/is-error' import { readableStreamTee } from './web/utils' import { ImageConfigContext } from '../shared/lib/image-config-context' import { ImageConfigComplete } from './image-config' -import { FlushEffectContext } from '../shared/lib/flush-effect' +import { FlushEffectContext, useFlushEffect } from '../shared/lib/flush-effect' let optimizeAmp: typeof import('./optimize-amp').default let getFontDefinitionFromManifest: typeof import('./font-utils').getFontDefinitionFromManifest @@ -739,14 +739,13 @@ export async function renderToHTML( const flushEffectImpl = React.useCallback( (callback: () => React.ReactNode) => { - if (!flushEffectState.sealed) { - flushEffectState.effects.push(callback) - } else { + if (flushEffectState.sealed) { throw new Error( '`useFlushEffect` was called after flushing started. Did you use it inside a boundary?' + '\nRead more: https://nextjs.org/docs/messages/flush-after-start' ) } + flushEffectState.effects.push(callback) }, [] ) @@ -758,6 +757,24 @@ export async function renderToHTML( ) } + function StyleRegistryFlusher({ children }: { children: JSX.Element }) { + useFlushEffect(() => { + const styles = jsxStyleRegistry.styles() + jsxStyleRegistry.flush() + return ( + <> + {React.Children.map(styles, (element, i) => + React.cloneElement(element, { + key: i, + }) + )} + + ) + }) + + return {children} + } + const AppContainer = ({ children }: { children: JSX.Element }) => ( @@ -777,11 +794,11 @@ export async function renderToHTML( reactLoadableModules.push(moduleName)} > - + {children} - + @@ -1353,12 +1370,15 @@ export async function renderToHTML( bodyResult = (suffix: string) => streamFromArray([result, suffix]) } + const styles = jsxStyleRegistry.styles() + jsxStyleRegistry.flush() + return { bodyResult, documentElement: () => (Document as any)(), head, headTags: [], - styles: jsxStyleRegistry.styles(), + styles, } } } @@ -1762,21 +1782,25 @@ function renderToStream({ // React will call our callbacks synchronously, so we need to // defer to a microtask to ensure `stream` is set. - Promise.resolve().then(() => { - const transforms: Array = [ - createBufferedTransformStream(), - handleFlushEffect - ? createFlushEffectStream(handleFlushEffect) - : null, - dataStream ? createInlineDataStream(dataStream) : null, - suffixState ? createPrefixStream(suffixState.suffixUnclosed) : null, - ].filter(Boolean) as any - - return transforms.reduce( - (readable, transform) => pipeThrough(readable, transform), - stream - ) - }) + resolve( + Promise.resolve().then(() => { + const transforms: Array = [ + createBufferedTransformStream(), + handleFlushEffect + ? createFlushEffectStream(handleFlushEffect) + : null, + dataStream ? createInlineDataStream(dataStream) : null, + suffixState + ? createPrefixStream(suffixState.suffixUnclosed) + : null, + ].filter(Boolean) as any + + return transforms.reduce( + (readable, transform) => pipeThrough(readable, transform), + stream + ) + }) + ) } } From 2890aeb5c7845d3c06f224cd19e5668c0d9eea92 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Thu, 10 Feb 2022 17:01:09 +0000 Subject: [PATCH 03/23] Better typing and tweaks --- packages/next/server/render-result.ts | 4 +- packages/next/server/render.tsx | 162 ++++++++++++++------------ 2 files changed, 87 insertions(+), 79 deletions(-) diff --git a/packages/next/server/render-result.ts b/packages/next/server/render-result.ts index 052fbc85fa0dd64..e7c2cd51b4b634f 100644 --- a/packages/next/server/render-result.ts +++ b/packages/next/server/render-result.ts @@ -1,9 +1,9 @@ import type { ServerResponse } from 'http' export default class RenderResult { - _result: string | ReadableStream + _result: string | ReadableStream - constructor(response: string | ReadableStream) { + constructor(response: string | ReadableStream) { this._result = response } diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 64cae9652cc2921..27298a3a10b2f10 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -302,12 +302,11 @@ const rscCache = new Map() function createRSCHook() { const decoder = new TextDecoder() - const encoder = new TextEncoder() return ( - writable: WritableStream, + writable: WritableStream, id: string, - req: ReadableStream, + req: ReadableStream, bootstrap: boolean ) => { let entry = rscCache.get(id) @@ -324,11 +323,10 @@ function createRSCHook() { if (bootstrap && !bootstrapped) { bootstrapped = true writer.write( - encoder.encode( - `` - ) + `` ) } if (done) { @@ -336,11 +334,11 @@ function createRSCHook() { writer.close() } else { writer.write( - encoder.encode( - `` - ) + `` ) process() } @@ -381,9 +379,12 @@ function createServerComponentRenderer( const writable = transformStream.writable const ServerComponentWrapper = (props: any) => { const id = (React as any).useId() - const reqStream = renderToReadableStream( - renderFlight(App, OriginalComponent, props), - serverComponentManifest + const reqStream = pipeThrough( + renderToReadableStream( + renderFlight(App, OriginalComponent, props), + serverComponentManifest + ), + createTextDecoderStream() ) const response = useRSCResponse( @@ -1184,15 +1185,21 @@ export async function renderToHTML( if (isResSent(res) && !isSSG) return null if (renderServerComponentData) { - const stream: ReadableStream = renderToReadableStream( - renderFlight(App, OriginalComponent, { - ...props.pageProps, - ...serverComponentProps, - }), - serverComponentManifest + const stream = pipeThrough( + renderToReadableStream( + renderFlight(App, OriginalComponent, { + ...props.pageProps, + ...serverComponentProps, + }), + serverComponentManifest + ), + createTextDecoderStream() ) return new RenderResult( - pipeThrough(stream, createBufferedTransformStream()) + pipeThrough( + pipeThrough(stream, createBufferedTransformStream()), + createTextEncoderStream() + ) ) } @@ -1534,7 +1541,7 @@ export async function renderToHTML( prefix.push('') } - let streams: Array = [ + let streams = [ streamFromArray(prefix), await documentResult.bodyResult(renderTargetSuffix), ] @@ -1600,7 +1607,9 @@ export async function renderToHTML( return new RenderResult(html) } - return new RenderResult(chainStreams(streams)) + return new RenderResult( + pipeThrough(chainStreams(streams), createTextEncoderStream()) + ) } function errorToJSON(err: Error) { @@ -1627,23 +1636,25 @@ function serializeError( } } -function createTransformStream({ +function createTransformStream({ flush, transform, }: { - flush?: (controller: TransformStreamDefaultController) => Promise | void + flush?: ( + controller: TransformStreamDefaultController + ) => Promise | void transform?: ( - chunk: Uint8Array, - controller: TransformStreamDefaultController + chunk: Input, + controller: TransformStreamDefaultController ) => Promise | void -}): TransformStream { +}): TransformStream { const source = new TransformStream() const sink = new TransformStream() const reader = source.readable.getReader() const writer = sink.writable.getWriter() const controller = { - enqueue(chunk: Uint8Array) { + enqueue(chunk: Output) { writer.write(chunk) }, @@ -1696,10 +1707,25 @@ function createTransformStream({ } } -function createBufferedTransformStream(): TransformStream { +function createTextDecoderStream(): TransformStream { const decoder = new TextDecoder() + return createTransformStream({ + transform(chunk, controller) { + controller.enqueue(decoder.decode(chunk)) + }, + }) +} + +function createTextEncoderStream(): TransformStream { const encoder = new TextEncoder() + return createTransformStream({ + transform(chunk, controller) { + controller.enqueue(encoder.encode(chunk)) + }, + }) +} +function createBufferedTransformStream(): TransformStream { let bufferedString = '' let pendingFlush: Promise | null = null @@ -1707,7 +1733,7 @@ function createBufferedTransformStream(): TransformStream { if (!pendingFlush) { pendingFlush = new Promise((resolve) => { setTimeout(() => { - controller.enqueue(encoder.encode(bufferedString)) + controller.enqueue(bufferedString) bufferedString = '' pendingFlush = null resolve() @@ -1719,7 +1745,7 @@ function createBufferedTransformStream(): TransformStream { return createTransformStream({ transform(chunk, controller) { - bufferedString += decoder.decode(chunk) + bufferedString += chunk flushBuffer(controller) }, @@ -1734,17 +1760,10 @@ function createBufferedTransformStream(): TransformStream { function createFlushEffectStream( handleFlushEffect: () => Promise ): TransformStream { - const decoder = new TextDecoder() - const encoder = new TextEncoder() - return createTransformStream({ async transform(chunk, controller) { const extraChunk = await handleFlushEffect() - // HACK: Re-encode the original chunk to ensure that the two chunks - // are compressed and sent together to try to reduce overhead. - controller.enqueue( - encoder.encode(`${extraChunk}${decoder.decode(chunk)}`) - ) + controller.enqueue(extraChunk + chunk) }, }) } @@ -1760,21 +1779,15 @@ function renderToStream({ ReactDOMServer: typeof import('react-dom/server') element: React.ReactElement suffix?: string - dataStream?: ReadableStream + dataStream?: ReadableStream generateStaticHTML: boolean handleFlushEffect?: () => Promise -}): Promise { +}): Promise> { return new Promise((resolve, reject) => { let resolved = false - const closeTagString = '' - const encoder = new TextEncoder() - const suffixState = suffix - ? { - closeTag: encoder.encode(closeTagString), - suffixUnclosed: encoder.encode(suffix.split(closeTagString)[0]), - } - : null + const closeTag = '' + const suffixUnclosed = suffix ? suffix.split(closeTag)[0] : null const doResolve = () => { if (!resolved) { @@ -1789,22 +1802,21 @@ function renderToStream({ handleFlushEffect ? createFlushEffectStream(handleFlushEffect) : null, + suffixUnclosed ? createPrefixStream(suffixUnclosed) : null, dataStream ? createInlineDataStream(dataStream) : null, - suffixState - ? createPrefixStream(suffixState.suffixUnclosed) - : null, + suffixUnclosed ? createSuffixStream(closeTag) : null, ].filter(Boolean) as any return transforms.reduce( (readable, transform) => pipeThrough(readable, transform), - stream + pipeThrough(renderStream, createTextDecoderStream()) ) }) ) } } - const stream: ReadableStream = ( + const renderStream: ReadableStream = ( ReactDOMServer as any ).renderToReadableStream(element, { onError(err: Error) { @@ -1825,7 +1837,7 @@ function renderToStream({ }) } -function createSuffixStream(suffix: Uint8Array | null) { +function createSuffixStream(suffix: string): TransformStream { return createTransformStream({ flush(controller) { if (suffix) { @@ -1835,15 +1847,16 @@ function createSuffixStream(suffix: Uint8Array | null) { }) } -function createPrefixStream(prefix: Uint8Array | null) { +function createPrefixStream(prefix: string): TransformStream { let prefixFlushed = false return createTransformStream({ transform(chunk, controller) { if (!prefixFlushed && prefix) { prefixFlushed = true - controller.enqueue(prefix) + controller.enqueue(chunk + prefix) + } else { + controller.enqueue(chunk) } - controller.enqueue(chunk) }, flush(controller) { if (!prefixFlushed && prefix) { @@ -1855,14 +1868,14 @@ function createPrefixStream(prefix: Uint8Array | null) { } function createInlineDataStream( - dataStream: ReadableStream | null -): TransformStream { + dataStream: ReadableStream +): TransformStream { let dataStreamFinished: Promise | null = null return createTransformStream({ transform(chunk, controller) { controller.enqueue(chunk) - if (!dataStreamFinished && dataStream) { + if (!dataStreamFinished) { const dataStreamReader = dataStream.getReader() dataStreamFinished = (async () => { try { @@ -1916,15 +1929,15 @@ function pipeTo( return promise } -function pipeThrough( - readable: ReadableStream, - transformStream: TransformStream +function pipeThrough( + readable: ReadableStream, + transformStream: TransformStream ) { pipeTo(readable, transformStream.writable) return transformStream.readable } -function chainStreams(streams: ReadableStream[]): ReadableStream { +function chainStreams(streams: ReadableStream[]): ReadableStream { const { readable, writable } = new TransformStream() let promise = Promise.resolve() @@ -1939,22 +1952,17 @@ function chainStreams(streams: ReadableStream[]): ReadableStream { return readable } -function streamFromArray(strings: string[]): ReadableStream { - const encoder = new TextEncoder() - const chunks = Array.from(strings.map((str) => encoder.encode(str))) - +function streamFromArray(strings: string[]): ReadableStream { return new ReadableStream({ start(controller) { - chunks.forEach((chunk) => controller.enqueue(chunk)) + strings.forEach((str) => controller.enqueue(str)) controller.close() }, }) } -async function streamToString(stream: ReadableStream): Promise { +async function streamToString(stream: ReadableStream): Promise { const reader = stream.getReader() - const decoder = new TextDecoder() - let bufferedString = '' while (true) { @@ -1964,7 +1972,7 @@ async function streamToString(stream: ReadableStream): Promise { return bufferedString } - bufferedString += decoder.decode(value) + bufferedString += value } } From c18f87d95904511e47dcf7db3203c6a9930b67cf Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Thu, 10 Feb 2022 17:05:49 +0000 Subject: [PATCH 04/23] More types --- packages/next/server/render.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index d107ef801cc0eb0..b54fb77272c5b53 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -363,7 +363,7 @@ function createServerComponentRenderer( runtime, }: { cachePrefix: string - transformStream: TransformStream + transformStream: TransformStream serverComponentManifest: NonNullable runtime: 'nodejs' | 'edge' } @@ -474,8 +474,10 @@ export async function renderToHTML( let Component: React.ComponentType<{}> | ((props: any) => JSX.Element) = renderOpts.Component - let serverComponentsInlinedTransformStream: TransformStream | null = - null + let serverComponentsInlinedTransformStream: TransformStream< + string, + string + > | null = null if (isServerComponent) { serverComponentsInlinedTransformStream = new TransformStream() @@ -1759,7 +1761,7 @@ function createBufferedTransformStream(): TransformStream { function createFlushEffectStream( handleFlushEffect: () => Promise -): TransformStream { +): TransformStream { return createTransformStream({ async transform(chunk, controller) { const extraChunk = await handleFlushEffect() @@ -1797,7 +1799,7 @@ function renderToStream({ // defer to a microtask to ensure `stream` is set. resolve( Promise.resolve().then(() => { - const transforms: Array = [ + const transforms: Array> = [ createBufferedTransformStream(), handleFlushEffect ? createFlushEffectStream(handleFlushEffect) From ba6a9f75985aeb1f512c09e65980266d008f190a Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Thu, 10 Feb 2022 17:45:20 +0000 Subject: [PATCH 05/23] Fix stream outputing uint8array --- packages/next/server/render.tsx | 60 +++++++++++++++++---------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index b54fb77272c5b53..a9407ea17682414 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -301,8 +301,6 @@ function checkRedirectValues( const rscCache = new Map() function createRSCHook() { - const decoder = new TextDecoder() - return ( writable: WritableStream, id: string, @@ -1811,31 +1809,32 @@ function renderToStream({ return transforms.reduce( (readable, transform) => pipeThrough(readable, transform), - pipeThrough(renderStream, createTextDecoderStream()) + renderStream ) }) ) } } - const renderStream: ReadableStream = ( - ReactDOMServer as any - ).renderToReadableStream(element, { - onError(err: Error) { - if (!resolved) { - resolved = true - reject(err) - } - }, - onCompleteShell() { - if (!generateStaticHTML) { + const renderStream = pipeThrough( + (ReactDOMServer as any).renderToReadableStream(element, { + onError(err: Error) { + if (!resolved) { + resolved = true + reject(err) + } + }, + onCompleteShell() { + if (!generateStaticHTML) { + doResolve() + } + }, + onCompleteAll() { doResolve() - } - }, - onCompleteAll() { - doResolve() - }, - }) + }, + }), + createTextDecoderStream() + ) }) } @@ -1902,9 +1901,9 @@ function createInlineDataStream( }) } -function pipeTo( - readable: ReadableStream, - writable: WritableStream, +function pipeTo( + readable: ReadableStream, + writable: WritableStream, options?: { preventClose: boolean } ) { let resolver: () => void @@ -1955,12 +1954,15 @@ function chainStreams(streams: ReadableStream[]): ReadableStream { } function streamFromArray(strings: string[]): ReadableStream { - return new ReadableStream({ - start(controller) { - strings.forEach((str) => controller.enqueue(str)) - controller.close() - }, - }) + // 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)) + writer.close() + + return readable } async function streamToString(stream: ReadableStream): Promise { From 0c0416d7de3ac8ad0a4f214996d7c05f55f18ea6 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Fri, 11 Feb 2022 20:38:31 +0000 Subject: [PATCH 06/23] Change to useFlushEffects --- packages/next/client/streaming/index.ts | 2 +- packages/next/server/render.tsx | 81 +++++++++---------- packages/next/shared/lib/flush-effect.ts | 15 ---- packages/next/shared/lib/flush-effects.ts | 21 +++++ test/integration/react-18/app/next.config.js | 2 +- test/integration/react-18/app/package.json | 1 + .../pages/use-flush-effect/multiple-calls.js | 17 ++++ 7 files changed, 78 insertions(+), 61 deletions(-) delete mode 100644 packages/next/shared/lib/flush-effect.ts create mode 100644 packages/next/shared/lib/flush-effects.ts create mode 100644 test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js diff --git a/packages/next/client/streaming/index.ts b/packages/next/client/streaming/index.ts index f826ea9c31f5f00..593ede04a0299d1 100644 --- a/packages/next/client/streaming/index.ts +++ b/packages/next/client/streaming/index.ts @@ -1,3 +1,3 @@ export { useRefreshRoot as unstable_useRefreshRoot } from './refresh' export { useWebVitalsReport as unstable_useWebVitalsReport } from './vitals' -export { useFlushEffect } from '../../shared/lib/flush-effect' +export { useFlushEffects } from '../../shared/lib/flush-effects' diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index a9407ea17682414..76177a6db0cce7b 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -63,7 +63,7 @@ import isError from '../lib/is-error' import { readableStreamTee } from './web/utils' import { ImageConfigContext } from '../shared/lib/image-config-context' import { ImageConfigComplete } from './image-config' -import { FlushEffectContext, useFlushEffect } from '../shared/lib/flush-effect' +import { FlushEffectsContext } from '../shared/lib/flush-effects' let optimizeAmp: typeof import('./optimize-amp').default let getFontDefinitionFromManifest: typeof import('./font-utils').getFontDefinitionFromManifest @@ -727,55 +727,47 @@ export async function renderToHTML( const nextExport = !isSSG && (renderOpts.nextExport || (dev && (isAutoExport || isFallback))) - const flushEffectState: { - effects: Array<() => React.ReactNode> - sealed: boolean - } = { effects: [], sealed: false } + const styledJsxFlushEffect = () => { + const styles = jsxStyleRegistry.styles() + jsxStyleRegistry.flush() + return ( + <> + {React.Children.map(styles, (element, i) => + React.cloneElement(element, { + key: i, + }) + )} + + ) + } + let flushEffects: Array<() => React.ReactNode> | null = null function FlushEffectContainer({ children }: { children: JSX.Element }) { // If the client tree suspends, this component will be rendered multiple // times before we flush. To ensure we don't call old callbacks corresponding // to a previous render, we clear any registered callbacks whenever we render. - flushEffectState.effects.length = 0 + flushEffects = null - const flushEffectImpl = React.useCallback( - (callback: () => React.ReactNode) => { - if (flushEffectState.sealed) { + const flushEffectsImpl = React.useCallback( + (callbacks: Array<() => React.ReactNode>) => { + if (flushEffects) { throw new Error( - '`useFlushEffect` was called after flushing started. Did you use it inside a boundary?' + - '\nRead more: https://nextjs.org/docs/messages/flush-after-start' + 'The `useFlushEffects` hook cannot be called more than once.' + + '\nRead more: https://nextjs.org/docs/messages/multiple-flush-effects' ) } - flushEffectState.effects.push(callback) + flushEffects = callbacks }, [] ) return ( - + {children} - + ) } - function StyleRegistryFlusher({ children }: { children: JSX.Element }) { - useFlushEffect(() => { - const styles = jsxStyleRegistry.styles() - jsxStyleRegistry.flush() - return ( - <> - {React.Children.map(styles, (element, i) => - React.cloneElement(element, { - key: i, - }) - )} - - ) - }) - - return {children} - } - const AppContainer = ({ children }: { children: JSX.Element }) => ( @@ -795,11 +787,11 @@ export async function renderToHTML( reactLoadableModules.push(moduleName)} > - + {children} - + @@ -1340,15 +1332,16 @@ export async function renderToHTML( // up to date when getWrappedApp is called const content = renderContent() - const handleFlushEffect = async () => { - // Prevent additional flush effects from being registered - flushEffectState.sealed = true - + const flushEffectHandler = async () => { + const allFlushEffects = [ + styledJsxFlushEffect, + ...(flushEffects || []), + ] const flushEffectStream = await renderToStream({ ReactDOMServer, element: ( <> - {flushEffectState.effects.map((flushEffect, i) => ( + {allFlushEffects.map((flushEffect, i) => ( {flushEffect()} ))} @@ -1365,7 +1358,7 @@ export async function renderToHTML( suffix, dataStream: serverComponentsInlinedTransformStream?.readable, generateStaticHTML: generateStaticHTML || !hasConcurrentFeatures, - handleFlushEffect, + flushEffectHandler, }) } } else { @@ -1774,14 +1767,14 @@ function renderToStream({ suffix, dataStream, generateStaticHTML, - handleFlushEffect, + flushEffectHandler, }: { ReactDOMServer: typeof import('react-dom/server') element: React.ReactElement suffix?: string dataStream?: ReadableStream generateStaticHTML: boolean - handleFlushEffect?: () => Promise + flushEffectHandler?: () => Promise }): Promise> { return new Promise((resolve, reject) => { let resolved = false @@ -1799,8 +1792,8 @@ function renderToStream({ Promise.resolve().then(() => { const transforms: Array> = [ createBufferedTransformStream(), - handleFlushEffect - ? createFlushEffectStream(handleFlushEffect) + flushEffectHandler + ? createFlushEffectStream(flushEffectHandler) : null, suffixUnclosed ? createPrefixStream(suffixUnclosed) : null, dataStream ? createInlineDataStream(dataStream) : null, diff --git a/packages/next/shared/lib/flush-effect.ts b/packages/next/shared/lib/flush-effect.ts deleted file mode 100644 index dc030408d6b7d13..000000000000000 --- a/packages/next/shared/lib/flush-effect.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext, useContext } from 'react' - -export type FlushEffectHook = (callback: () => React.ReactNode) => void - -export const FlushEffectContext: React.Context = - createContext(null as any) - -export function useFlushEffect(callback: () => React.ReactNode): void { - const flushEffectImpl = useContext(FlushEffectContext) - return flushEffectImpl!(callback) -} - -if (process.env.NODE_ENV !== 'production') { - FlushEffectContext.displayName = 'FlushEffectContext' -} diff --git a/packages/next/shared/lib/flush-effects.ts b/packages/next/shared/lib/flush-effects.ts new file mode 100644 index 000000000000000..d1286db2648c3e6 --- /dev/null +++ b/packages/next/shared/lib/flush-effects.ts @@ -0,0 +1,21 @@ +import { createContext, useContext } from 'react' + +export type FlushEffectsHook = (callbacks: Array<() => React.ReactNode>) => void + +export const FlushEffectsContext: React.Context = + createContext(null as any) + +export function useFlushEffects(callbacks: Array<() => React.ReactNode>): void { + const flushEffectsImpl = useContext(FlushEffectsContext) + if (!flushEffectsImpl) { + throw new Error( + `useFlushEffects can not be called on the client.` + + `\nRead more: https://nextjs.org/docs/messages/client-flush-effects` + ) + } + return flushEffectsImpl!(callbacks) +} + +if (process.env.NODE_ENV !== 'production') { + FlushEffectsContext.displayName = 'FlushEffectsContext' +} diff --git a/test/integration/react-18/app/next.config.js b/test/integration/react-18/app/next.config.js index 54f5fa1a497951d..7ed95474e2af098 100644 --- a/test/integration/react-18/app/next.config.js +++ b/test/integration/react-18/app/next.config.js @@ -3,7 +3,7 @@ const withReact18 = require('../test/with-react-18') module.exports = withReact18({ experimental: { reactRoot: true, - // runtime: 'edge', + runtime: 'edge', }, images: { deviceSizes: [480, 1024, 1600, 2000], diff --git a/test/integration/react-18/app/package.json b/test/integration/react-18/app/package.json index f9dafc993a79cae..478f826a5969982 100644 --- a/test/integration/react-18/app/package.json +++ b/test/integration/react-18/app/package.json @@ -1,6 +1,7 @@ { "scripts": { "next": "node -r ../test/require-hook.js ../../../../packages/next/dist/bin/next", + "debug": "NODE_OPTIONS='--inspect' node -r ../../react-18/test/require-hook.js ../../../../packages/next/dist/bin/next", "dev": "yarn next dev", "build": "yarn next build", "start": "yarn next start" diff --git a/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js b/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js new file mode 100644 index 000000000000000..9bdfef997f0cab1 --- /dev/null +++ b/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js @@ -0,0 +1,17 @@ +import { useFlushEffects } from 'next/streaming' + +function Component() { + if (typeof window === 'undefined') { + useFlushEffects([]) + } + return null +} + +export default function MultipleCalls() { + return ( + <> + + + + ) +} From e7c256d15d93ad97bb2325a1058b01d907ff883e Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Mon, 14 Feb 2022 19:27:29 +0000 Subject: [PATCH 07/23] Initial tests --- .../react-18/app/components/red.js | 21 +++++++++++++ .../app/pages/use-flush-effect/client.js | 6 ++++ .../app/pages/use-flush-effect/custom.js | 11 +++++++ .../app/pages/use-flush-effect/styled-jsx.js | 30 +++++++++++++++++++ test/integration/react-18/test/common.js | 27 +++++++++++++++++ test/integration/react-18/test/index.test.js | 7 +++-- 6 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 test/integration/react-18/app/components/red.js create mode 100644 test/integration/react-18/app/pages/use-flush-effect/client.js create mode 100644 test/integration/react-18/app/pages/use-flush-effect/custom.js create mode 100644 test/integration/react-18/app/pages/use-flush-effect/styled-jsx.js create mode 100644 test/integration/react-18/test/common.js diff --git a/test/integration/react-18/app/components/red.js b/test/integration/react-18/app/components/red.js new file mode 100644 index 000000000000000..8f92c5ccc52d5c8 --- /dev/null +++ b/test/integration/react-18/app/components/red.js @@ -0,0 +1,21 @@ +import React from 'react' +import { useCachedPromise } from './promise-cache' + +export default function Styled({ name }) { + useCachedPromise( + name, + () => new Promise((resolve) => setTimeout(resolve, 1000)), + true + ) + + return ( +
+

This is Red.

+ +
+ ) +} diff --git a/test/integration/react-18/app/pages/use-flush-effect/client.js b/test/integration/react-18/app/pages/use-flush-effect/client.js new file mode 100644 index 000000000000000..0fc92531611dcca --- /dev/null +++ b/test/integration/react-18/app/pages/use-flush-effect/client.js @@ -0,0 +1,6 @@ +import { useFlushEffects } from 'next/streaming' + +export default function Client() { + useFlushEffects([]) + return null +} diff --git a/test/integration/react-18/app/pages/use-flush-effect/custom.js b/test/integration/react-18/app/pages/use-flush-effect/custom.js new file mode 100644 index 000000000000000..5a476ba46c99ff2 --- /dev/null +++ b/test/integration/react-18/app/pages/use-flush-effect/custom.js @@ -0,0 +1,11 @@ +import { useFlushEffects } from 'next/streaming' + +export default function Custom() { + if (typeof window === 'undefined') { + useFlushEffects([ + () => , + () => , ]) } return null diff --git a/test/integration/react-18/test/common.js b/test/integration/react-18/test/common.js index cf3575bd80664f6..39e634317a1da74 100644 --- a/test/integration/react-18/test/common.js +++ b/test/integration/react-18/test/common.js @@ -1,15 +1,25 @@ /* eslint-env jest */ import webdriver from 'next-webdriver' -import cheerio from 'cheerio' import { - fetchViaHTTP, renderViaHTTP, hasRedbox, getRedboxSource, } from 'next-test-utils' export default (context) => { + async function withBrowser(path, cb) { + let browser + try { + browser = await webdriver(context.appPort, path, false) + await cb(browser) + } finally { + if (browser) { + await browser.close() + } + } + } + it('throws if useFlushEffects is called more than once', async () => { await renderViaHTTP(context.appPort, '/use-flush-effect/multiple-calls') expect(context.stderr).toContain( @@ -18,10 +28,25 @@ export default (context) => { }) it('throws if useFlushEffects is called on the client', async () => { - const browser = await webdriver(context.appPort, '/use-flush-effect/client') - expect(await hasRedbox(browser)).toBe(true) - expect(await getRedboxSource(browser)).toMatch( - /Error: useFlushEffects can not be called on the client/ - ) + await withBrowser('/use-flush-effect/client', async browser => { + expect(await hasRedbox(browser)).toBe(true) + expect(await getRedboxSource(browser)).toMatch( + /Error: useFlushEffects can not be called on the client/ + ) + }) + }) + + it('flushes styles as the page renders', async () => { + await withBrowser('/use-flush-effect/styled-jsx', async browser => { + await check(() => browser.waitForElementByCss('#__jsx-900f996af369fc74').text(), /blue/) + await check(() => browser.waitForElementByCss('#__jsx-c74678abd3b78a').text(), /red/) + }) + }) + + it('flushes custom effects', async () => { + await withBrowser('/use-flush-effect/custom', async browser => { + await check(() => browser.waitForElementByCss('#custom-flush-effect-1').text(), /foo/) + await check(() => browser.waitForElementByCss('#custom-flush-effect-2').text(), /bar/) + }) }) } From 9bd184baee0fe47f8519690da85c56a768368e52 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Mon, 14 Feb 2022 20:21:45 +0000 Subject: [PATCH 10/23] Fix lint --- .../app/pages/use-flush-effect/custom.js | 1 + .../pages/use-flush-effect/multiple-calls.js | 1 + test/integration/react-18/test/common.js | 55 ++++++++++++------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/test/integration/react-18/app/pages/use-flush-effect/custom.js b/test/integration/react-18/app/pages/use-flush-effect/custom.js index 61d3f3194715c84..b8e6bb63c06ee9f 100644 --- a/test/integration/react-18/app/pages/use-flush-effect/custom.js +++ b/test/integration/react-18/app/pages/use-flush-effect/custom.js @@ -2,6 +2,7 @@ import { useFlushEffects } from 'next/streaming' export default function Custom() { if (typeof window === 'undefined') { + // eslint-disable-next-line react-hooks/rules-of-hooks useFlushEffects([ () => , () => , diff --git a/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js b/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js index 9bdfef997f0cab1..d599fa89f2d9cd1 100644 --- a/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js +++ b/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js @@ -2,6 +2,7 @@ import { useFlushEffects } from 'next/streaming' function Component() { if (typeof window === 'undefined') { + // eslint-disable-next-line react-hooks/rules-of-hooks useFlushEffects([]) } return null diff --git a/test/integration/react-18/test/common.js b/test/integration/react-18/test/common.js index 39e634317a1da74..9e42e667af0d233 100644 --- a/test/integration/react-18/test/common.js +++ b/test/integration/react-18/test/common.js @@ -2,23 +2,24 @@ import webdriver from 'next-webdriver' import { + check, renderViaHTTP, hasRedbox, getRedboxSource, } from 'next-test-utils' export default (context) => { - async function withBrowser(path, cb) { - let browser - try { - browser = await webdriver(context.appPort, path, false) - await cb(browser) - } finally { - if (browser) { - await browser.close() - } - } + async function withBrowser(path, cb) { + let browser + try { + browser = await webdriver(context.appPort, path, false) + await cb(browser) + } finally { + if (browser) { + await browser.close() } + } + } it('throws if useFlushEffects is called more than once', async () => { await renderViaHTTP(context.appPort, '/use-flush-effect/multiple-calls') @@ -28,25 +29,37 @@ export default (context) => { }) it('throws if useFlushEffects is called on the client', async () => { - await withBrowser('/use-flush-effect/client', async browser => { - expect(await hasRedbox(browser)).toBe(true) - expect(await getRedboxSource(browser)).toMatch( - /Error: useFlushEffects can not be called on the client/ - ) + await withBrowser('/use-flush-effect/client', async (browser) => { + expect(await hasRedbox(browser)).toBe(true) + expect(await getRedboxSource(browser)).toMatch( + /Error: useFlushEffects can not be called on the client/ + ) }) }) it('flushes styles as the page renders', async () => { - await withBrowser('/use-flush-effect/styled-jsx', async browser => { - await check(() => browser.waitForElementByCss('#__jsx-900f996af369fc74').text(), /blue/) - await check(() => browser.waitForElementByCss('#__jsx-c74678abd3b78a').text(), /red/) + await withBrowser('/use-flush-effect/styled-jsx', async (browser) => { + await check( + () => browser.waitForElementByCss('#__jsx-900f996af369fc74').text(), + /blue/ + ) + await check( + () => browser.waitForElementByCss('#__jsx-c74678abd3b78a').text(), + /red/ + ) }) }) it('flushes custom effects', async () => { - await withBrowser('/use-flush-effect/custom', async browser => { - await check(() => browser.waitForElementByCss('#custom-flush-effect-1').text(), /foo/) - await check(() => browser.waitForElementByCss('#custom-flush-effect-2').text(), /bar/) + await withBrowser('/use-flush-effect/custom', async (browser) => { + await check( + () => browser.waitForElementByCss('#custom-flush-effect-1').text(), + /foo/ + ) + await check( + () => browser.waitForElementByCss('#custom-flush-effect-2').text(), + /bar/ + ) }) }) } From 5a6364d14c2f32db384454f0ae1b3b534eab8a51 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Mon, 14 Feb 2022 21:45:29 +0000 Subject: [PATCH 11/23] Fix tests --- .../app/pages/use-flush-effect/client.js | 27 ++++++++++++++++++- test/integration/react-18/test/common.js | 6 ++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/test/integration/react-18/app/pages/use-flush-effect/client.js b/test/integration/react-18/app/pages/use-flush-effect/client.js index 0fc92531611dcca..d35b19dca767cc9 100644 --- a/test/integration/react-18/app/pages/use-flush-effect/client.js +++ b/test/integration/react-18/app/pages/use-flush-effect/client.js @@ -1,6 +1,31 @@ import { useFlushEffects } from 'next/streaming' +import React from 'react' -export default function Client() { +class ErrorBoundary extends React.Component { + state = {} + + static getDerivedStateFromError(error) { + return { error } + } + + render() { + return this.state.error ? ( + {this.state.error.message} + ) : ( + this.props.children + ) + } +} + +function Component() { useFlushEffects([]) return null } + +export default function Client() { + return ( + + + + ) +} diff --git a/test/integration/react-18/test/common.js b/test/integration/react-18/test/common.js index 9e42e667af0d233..06f0f3ae9436187 100644 --- a/test/integration/react-18/test/common.js +++ b/test/integration/react-18/test/common.js @@ -30,9 +30,9 @@ export default (context) => { it('throws if useFlushEffects is called on the client', async () => { await withBrowser('/use-flush-effect/client', async (browser) => { - expect(await hasRedbox(browser)).toBe(true) - expect(await getRedboxSource(browser)).toMatch( - /Error: useFlushEffects can not be called on the client/ + await check( + () => browser.waitForElementByCss('#error').text(), + /useFlushEffects can not be called on the client/ ) }) }) From 744d64d6b08141b965baa14fb27ce12c667217e2 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Mon, 14 Feb 2022 21:53:45 +0000 Subject: [PATCH 12/23] Fix lint --- test/integration/react-18/test/common.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/integration/react-18/test/common.js b/test/integration/react-18/test/common.js index 06f0f3ae9436187..5faf81016520a77 100644 --- a/test/integration/react-18/test/common.js +++ b/test/integration/react-18/test/common.js @@ -4,8 +4,6 @@ import webdriver from 'next-webdriver' import { check, renderViaHTTP, - hasRedbox, - getRedboxSource, } from 'next-test-utils' export default (context) => { From ca7071279b354ab30d68dc6cea3978b3927ebcb0 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Mon, 14 Feb 2022 22:17:02 +0000 Subject: [PATCH 13/23] Fix lint --- test/integration/react-18/test/common.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/integration/react-18/test/common.js b/test/integration/react-18/test/common.js index 5faf81016520a77..bdbf0aa6270f387 100644 --- a/test/integration/react-18/test/common.js +++ b/test/integration/react-18/test/common.js @@ -1,10 +1,7 @@ /* eslint-env jest */ import webdriver from 'next-webdriver' -import { - check, - renderViaHTTP, -} from 'next-test-utils' +import { check, renderViaHTTP } from 'next-test-utils' export default (context) => { async function withBrowser(path, cb) { From d77a1f82e649f8f6089b5da55ede9ee1b678c14d Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Mon, 14 Feb 2022 22:39:19 +0000 Subject: [PATCH 14/23] Fix tests --- packages/next/server/render.tsx | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 3d3ee2d7d03e2c9..e6eb00a9ccc27b3 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -377,12 +377,9 @@ function createServerComponentRenderer( const writable = transformStream.writable const ServerComponentWrapper = (props: any) => { const id = (React as any).useId() - const reqStream = pipeThrough( - renderToReadableStream( - renderFlight(App, OriginalComponent, props), - serverComponentManifest - ), - createTextDecoderStream() + const reqStream: ReadableStream = renderToReadableStream( + renderFlight(App, OriginalComponent, props), + serverComponentManifest ) const response = useRSCResponse( @@ -1183,15 +1180,12 @@ export async function renderToHTML( if (isResSent(res) && !isSSG) return null if (renderServerComponentData) { - const stream = 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( From 866c27ed1dc942d405f571ce0309a09dd6acf75a Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Tue, 15 Feb 2022 17:13:14 +0000 Subject: [PATCH 15/23] Fix tests --- packages/next/server/render.tsx | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index c59afcb417fbbcd..39454d2e52968c7 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -376,9 +376,12 @@ function createServerComponentRenderer( const writable = transformStream.writable const ServerComponentWrapper = (props: any) => { const id = (React as any).useId() - const reqStream: ReadableStream = renderToReadableStream( - renderFlight(App, OriginalComponent, props), - serverComponentManifest + const reqStream: ReadableStream = pipeThrough( + renderToReadableStream( + renderFlight(App, OriginalComponent, props), + serverComponentManifest + ), + createTextDecoderStream() ) const response = useRSCResponse( @@ -1179,12 +1182,15 @@ export async function renderToHTML( if (isResSent(res) && !isSSG) return null if (renderServerComponentData) { - const stream: ReadableStream = renderToReadableStream( - renderFlight(App, OriginalComponent, { - ...props.pageProps, - ...serverComponentProps, - }), - serverComponentManifest + const stream: ReadableStream = pipeThrough( + renderToReadableStream( + renderFlight(App, OriginalComponent, { + ...props.pageProps, + ...serverComponentProps, + }), + serverComponentManifest + ), + createTextDecoderStream() ) return new RenderResult( pipeThrough( @@ -1699,7 +1705,9 @@ function createTextDecoderStream(): TransformStream { const decoder = new TextDecoder() return createTransformStream({ transform(chunk, controller) { - controller.enqueue(decoder.decode(chunk)) + controller.enqueue( + typeof chunk === 'string' ? chunk : decoder.decode(chunk) + ) }, }) } From 2623d54ef5a28948e8bed64a88acd0aa2c2ff202 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Tue, 15 Feb 2022 18:07:40 +0000 Subject: [PATCH 16/23] Add docs and fix tests --- errors/client-flush-effects.md | 24 ++++++++++++++++++++++++ errors/manifest.json | 8 ++++++++ errors/multiple-flush-effects.md | 9 +++++++++ packages/next/server/render.tsx | 6 ++++-- test/integration/react-18/test/common.js | 4 ++-- 5 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 errors/client-flush-effects.md create mode 100644 errors/multiple-flush-effects.md diff --git a/errors/client-flush-effects.md b/errors/client-flush-effects.md new file mode 100644 index 000000000000000..64f107b499903cc --- /dev/null +++ b/errors/client-flush-effects.md @@ -0,0 +1,24 @@ +# `useFlushEffects` can not be called on the client + +#### Why This Error Occurred + +The `useFlushEffects` hook was executed while rendering a component on the client, or in another unsupported environment. + +#### Possible Ways to Fix It + +The `useFlushEffects` hook can only be called while _server rendering a client component_. As a best practice, we recommend creating a wrapper hook: + +```jsx +// lib/use-style-libraries.js +import { useFlushEffects } from 'next/streaming' + +export default function useStyleLibraries() { + if (typeof window === 'undefined') { + // eslint-disable-next-line react-hooks/rules-of-hooks + useFlushEffects([ + /* ... */ + ]) + } + /* ... */ +} +``` diff --git a/errors/manifest.json b/errors/manifest.json index a7e0bc694424af6..bbe43951f5d5f71 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -619,6 +619,14 @@ { "title": "ignored-compiler-options", "path": "/errors/ignored-compiler-options.md" + }, + { + "title": "multiple-flush-effects", + "path": "/errors/multiple-flush-effects.md" + }, + { + "title": "client-flush-effects", + "path": "/errors/client-flush-effects.md" } ] } diff --git a/errors/multiple-flush-effects.md b/errors/multiple-flush-effects.md new file mode 100644 index 000000000000000..51a9d7a58a0db7f --- /dev/null +++ b/errors/multiple-flush-effects.md @@ -0,0 +1,9 @@ +# The `useFlushEffects` hook cannot be used more than once. + +#### Why This Error Occurred + +The `useFlushEffects` hook is being used more than once while a page is being rendered. + +#### Possible Ways to Fix It + +The `useFlushEffects` hook should only be called inside the body of the `pages/_app` component, before returning any `` boundaries. Restructure your application to prevent extraneous calls. diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 39454d2e52968c7..5483cfe2608a5dd 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -309,7 +309,9 @@ function createRSCHook() { let entry = rscCache.get(id) if (!entry) { const [renderStream, forwardStream] = readableStreamTee(req) - entry = createFromReadableStream(renderStream) + entry = createFromReadableStream( + pipeThrough(renderStream, createTextEncoderStream()) + ) rscCache.set(id, entry) let bootstrapped = false @@ -757,7 +759,7 @@ export async function renderToHTML( (callbacks: Array<() => React.ReactNode>) => { if (flushEffects) { throw new Error( - 'The `useFlushEffects` hook cannot be called more than once.' + + 'The `useFlushEffects` hook cannot be used more than once.' + '\nRead more: https://nextjs.org/docs/messages/multiple-flush-effects' ) } diff --git a/test/integration/react-18/test/common.js b/test/integration/react-18/test/common.js index bdbf0aa6270f387..df8e0ccb64ed1a8 100644 --- a/test/integration/react-18/test/common.js +++ b/test/integration/react-18/test/common.js @@ -16,10 +16,10 @@ export default (context) => { } } - it('throws if useFlushEffects is called more than once', async () => { + it('throws if useFlushEffects is used more than once', async () => { await renderViaHTTP(context.appPort, '/use-flush-effect/multiple-calls') expect(context.stderr).toContain( - 'Error: The `useFlushEffects` hook cannot be called more than once.' + 'Error: The `useFlushEffects` hook cannot be used more than once.' ) }) From 0c992a556553e45faed2a3ce201c5730c91b184b Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Tue, 15 Feb 2022 18:58:56 +0000 Subject: [PATCH 17/23] Fix tests --- .../react-18/app/pages/use-flush-effect/client.js | 5 +++++ .../react-18/app/pages/use-flush-effect/multiple-calls.js | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/test/integration/react-18/app/pages/use-flush-effect/client.js b/test/integration/react-18/app/pages/use-flush-effect/client.js index d35b19dca767cc9..271e6dd43f821e6 100644 --- a/test/integration/react-18/app/pages/use-flush-effect/client.js +++ b/test/integration/react-18/app/pages/use-flush-effect/client.js @@ -29,3 +29,8 @@ export default function Client() { ) } + +export async function getServerSideProps() { + // disable exporting this page + return { props: {} } +} diff --git a/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js b/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js index d599fa89f2d9cd1..ce26f3b22296edb 100644 --- a/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js +++ b/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js @@ -16,3 +16,8 @@ export default function MultipleCalls() { ) } + +export async function getServerSideProps() { + // disable exporting this page + return { props: {} } +} From c51b271ebbd4f3ec5f6532c4036ac82e6a5c37b3 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Tue, 15 Feb 2022 20:18:29 +0000 Subject: [PATCH 18/23] Fix test --- .../integration/react-18/app/pages/use-flush-effect/custom.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/react-18/app/pages/use-flush-effect/custom.js b/test/integration/react-18/app/pages/use-flush-effect/custom.js index b8e6bb63c06ee9f..61d639c4df0c8fc 100644 --- a/test/integration/react-18/app/pages/use-flush-effect/custom.js +++ b/test/integration/react-18/app/pages/use-flush-effect/custom.js @@ -4,8 +4,8 @@ export default function Custom() { if (typeof window === 'undefined') { // eslint-disable-next-line react-hooks/rules-of-hooks useFlushEffects([ - () => , - () => , + () => foo, + () => bar, ]) } return null From 2683de3921b6ba516da1347d900f6df7ecadab85 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Wed, 16 Feb 2022 00:40:07 +0000 Subject: [PATCH 19/23] Fix tests --- test/integration/react-18/test/common.js | 60 -------------------- test/integration/react-18/test/concurrent.js | 42 ++++++++++++++ test/integration/react-18/test/index.test.js | 3 - 3 files changed, 42 insertions(+), 63 deletions(-) delete mode 100644 test/integration/react-18/test/common.js diff --git a/test/integration/react-18/test/common.js b/test/integration/react-18/test/common.js deleted file mode 100644 index df8e0ccb64ed1a8..000000000000000 --- a/test/integration/react-18/test/common.js +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-env jest */ - -import webdriver from 'next-webdriver' -import { check, renderViaHTTP } from 'next-test-utils' - -export default (context) => { - async function withBrowser(path, cb) { - let browser - try { - browser = await webdriver(context.appPort, path, false) - await cb(browser) - } finally { - if (browser) { - await browser.close() - } - } - } - - it('throws if useFlushEffects is used more than once', async () => { - await renderViaHTTP(context.appPort, '/use-flush-effect/multiple-calls') - expect(context.stderr).toContain( - 'Error: The `useFlushEffects` hook cannot be used more than once.' - ) - }) - - it('throws if useFlushEffects is called on the client', async () => { - await withBrowser('/use-flush-effect/client', async (browser) => { - await check( - () => browser.waitForElementByCss('#error').text(), - /useFlushEffects can not be called on the client/ - ) - }) - }) - - it('flushes styles as the page renders', async () => { - await withBrowser('/use-flush-effect/styled-jsx', async (browser) => { - await check( - () => browser.waitForElementByCss('#__jsx-900f996af369fc74').text(), - /blue/ - ) - await check( - () => browser.waitForElementByCss('#__jsx-c74678abd3b78a').text(), - /red/ - ) - }) - }) - - it('flushes custom effects', async () => { - await withBrowser('/use-flush-effect/custom', async (browser) => { - await check( - () => browser.waitForElementByCss('#custom-flush-effect-1').text(), - /foo/ - ) - await check( - () => browser.waitForElementByCss('#custom-flush-effect-2').text(), - /bar/ - ) - }) - }) -} diff --git a/test/integration/react-18/test/concurrent.js b/test/integration/react-18/test/concurrent.js index 2f11225ba782196..92ba82e6b032055 100644 --- a/test/integration/react-18/test/concurrent.js +++ b/test/integration/react-18/test/concurrent.js @@ -70,4 +70,46 @@ export default (context, _render) => { ) }) }) + + it('throws if useFlushEffects is used more than once', async () => { + await renderViaHTTP(context.appPort, '/use-flush-effect/multiple-calls') + expect(context.stderr).toContain( + 'Error: The `useFlushEffects` hook cannot be used more than once.' + ) + }) + + it('throws if useFlushEffects is called on the client', async () => { + await withBrowser('/use-flush-effect/client', async (browser) => { + await check( + () => browser.waitForElementByCss('#error').text(), + /useFlushEffects can not be called on the client/ + ) + }) + }) + + it('flushes styles as the page renders', async () => { + await withBrowser('/use-flush-effect/styled-jsx', async (browser) => { + await check( + () => browser.waitForElementByCss('#__jsx-900f996af369fc74').text(), + /blue/ + ) + await check( + () => browser.waitForElementByCss('#__jsx-c74678abd3b78a').text(), + /red/ + ) + }) + }) + + it('flushes custom effects', async () => { + await withBrowser('/use-flush-effect/custom', async (browser) => { + await check( + () => browser.waitForElementByCss('#custom-flush-effect-1').text(), + /foo/ + ) + await check( + () => browser.waitForElementByCss('#custom-flush-effect-2').text(), + /bar/ + ) + }) + }) } diff --git a/test/integration/react-18/test/index.test.js b/test/integration/react-18/test/index.test.js index 87675c0167c8556..8d68baaef1912b7 100644 --- a/test/integration/react-18/test/index.test.js +++ b/test/integration/react-18/test/index.test.js @@ -16,7 +16,6 @@ import blocking from './blocking' import concurrent from './concurrent' import basics from './basics' import strictMode from './strict-mode' -import common from './common' // overrides react and react-dom to v18 const nodeArgs = ['-r', join(__dirname, 'require-hook.js')] @@ -138,7 +137,6 @@ describe('Blocking mode', () => { runTests('`runtime` is disabled', (context) => { blocking(context, (p, q) => renderViaHTTP(context.appPort, p, q)) - common(context, (p, q) => renderViaHTTP(context.appPort, p, q)) }) }) @@ -157,7 +155,6 @@ function runTestsAgainstRuntime(runtime) { runTests(`runtime is set to '${runtime}'`, (context) => { concurrent(context, (p, q) => renderViaHTTP(context.appPort, p, q)) - common(context, (p, q) => renderViaHTTP(context.appPort, p, q)) it('should stream to users', async () => { const res = await fetchViaHTTP(context.appPort, '/ssr') From a90bffa7a1625cc81b9d1113bd078b767cd43c0a Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Wed, 16 Feb 2022 00:49:31 +0000 Subject: [PATCH 20/23] Fix tests --- test/integration/react-18/test/concurrent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/react-18/test/concurrent.js b/test/integration/react-18/test/concurrent.js index 92ba82e6b032055..15eb424b16bea24 100644 --- a/test/integration/react-18/test/concurrent.js +++ b/test/integration/react-18/test/concurrent.js @@ -1,7 +1,7 @@ /* eslint-env jest */ import webdriver from 'next-webdriver' -import { check } from 'next-test-utils' +import { check, renderViaHTTP } from 'next-test-utils' export default (context, _render) => { async function withBrowser(path, cb) { From 39438953a983b3862e4543fefd5420e47efbbcc1 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Wed, 16 Feb 2022 02:27:13 +0000 Subject: [PATCH 21/23] Fix tests --- packages/next/server/render.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 5483cfe2608a5dd..780041e806cc404 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -1800,9 +1800,11 @@ function renderToStream({ flushEffectHandler ? createFlushEffectStream(flushEffectHandler) : null, - suffixUnclosed ? createPrefixStream(suffixUnclosed) : null, + suffixUnclosed != null + ? createPrefixStream(suffixUnclosed) + : null, dataStream ? createInlineDataStream(dataStream) : null, - suffixUnclosed ? createSuffixStream(closeTag) : null, + suffixUnclosed != null ? createSuffixStream(closeTag) : null, ].filter(Boolean) as any return transforms.reduce( From edc116fb026f45d3d962fa8655d48ccbe135c047 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Thu, 17 Feb 2022 18:27:46 +0000 Subject: [PATCH 22/23] Prefix w/ unstable and fix unnecessary assertion --- packages/next/client/streaming/index.ts | 2 +- packages/next/shared/lib/flush-effects.ts | 2 +- .../integration/react-18/app/pages/use-flush-effect/client.js | 4 ++-- .../integration/react-18/app/pages/use-flush-effect/custom.js | 4 ++-- .../react-18/app/pages/use-flush-effect/multiple-calls.js | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/next/client/streaming/index.ts b/packages/next/client/streaming/index.ts index 593ede04a0299d1..d072e85795ae46b 100644 --- a/packages/next/client/streaming/index.ts +++ b/packages/next/client/streaming/index.ts @@ -1,3 +1,3 @@ export { useRefreshRoot as unstable_useRefreshRoot } from './refresh' export { useWebVitalsReport as unstable_useWebVitalsReport } from './vitals' -export { useFlushEffects } from '../../shared/lib/flush-effects' +export { useFlushEffects as unstable_useFlushEffects } from '../../shared/lib/flush-effects' diff --git a/packages/next/shared/lib/flush-effects.ts b/packages/next/shared/lib/flush-effects.ts index d1286db2648c3e6..341c126ed9e63d9 100644 --- a/packages/next/shared/lib/flush-effects.ts +++ b/packages/next/shared/lib/flush-effects.ts @@ -13,7 +13,7 @@ export function useFlushEffects(callbacks: Array<() => React.ReactNode>): void { `\nRead more: https://nextjs.org/docs/messages/client-flush-effects` ) } - return flushEffectsImpl!(callbacks) + return flushEffectsImpl(callbacks) } if (process.env.NODE_ENV !== 'production') { diff --git a/test/integration/react-18/app/pages/use-flush-effect/client.js b/test/integration/react-18/app/pages/use-flush-effect/client.js index 271e6dd43f821e6..884d7f9266ecdda 100644 --- a/test/integration/react-18/app/pages/use-flush-effect/client.js +++ b/test/integration/react-18/app/pages/use-flush-effect/client.js @@ -1,4 +1,4 @@ -import { useFlushEffects } from 'next/streaming' +import { unstable_useFlushEffects } from 'next/streaming' import React from 'react' class ErrorBoundary extends React.Component { @@ -18,7 +18,7 @@ class ErrorBoundary extends React.Component { } function Component() { - useFlushEffects([]) + unstable_useFlushEffects([]) return null } diff --git a/test/integration/react-18/app/pages/use-flush-effect/custom.js b/test/integration/react-18/app/pages/use-flush-effect/custom.js index 61d639c4df0c8fc..f8d09da3462d3c1 100644 --- a/test/integration/react-18/app/pages/use-flush-effect/custom.js +++ b/test/integration/react-18/app/pages/use-flush-effect/custom.js @@ -1,9 +1,9 @@ -import { useFlushEffects } from 'next/streaming' +import { unstable_useFlushEffects } from 'next/streaming' export default function Custom() { if (typeof window === 'undefined') { // eslint-disable-next-line react-hooks/rules-of-hooks - useFlushEffects([ + unstable_useFlushEffects([ () => foo, () => bar, ]) diff --git a/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js b/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js index ce26f3b22296edb..5417eac3457aca8 100644 --- a/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js +++ b/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js @@ -1,9 +1,9 @@ -import { useFlushEffects } from 'next/streaming' +import { unstable_useFlushEffects } from 'next/streaming' function Component() { if (typeof window === 'undefined') { // eslint-disable-next-line react-hooks/rules-of-hooks - useFlushEffects([]) + unstable_useFlushEffects([]) } return null } From fec9cb668a6e47cdf3072711c9d9db89f7239941 Mon Sep 17 00:00:00 2001 From: Gerald Monaco Date: Thu, 17 Feb 2022 18:56:01 +0000 Subject: [PATCH 23/23] Remove key that is already being set by styled-jsx --- packages/next/server/render.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 965f9b60b6ddb9f..d18e1f336b006ee 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -741,15 +741,7 @@ export async function renderToHTML( const styledJsxFlushEffect = () => { const styles = jsxStyleRegistry.styles() jsxStyleRegistry.flush() - return ( - <> - {React.Children.map(styles, (element, i) => - React.cloneElement(element, { - key: i, - }) - )} - - ) + return <>{styles} } let flushEffects: Array<() => React.ReactNode> | null = null