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 b14e95bfdd429f9..d8afb228b26025d 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -623,6 +623,14 @@ { "title": "opening-an-issue", "path": "/errors/opening-an-issue.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/client/streaming/index.ts b/packages/next/client/streaming/index.ts index f6fc5aaf42e4b69..d072e85795ae46b 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 { useFlushEffects as unstable_useFlushEffects } from '../../shared/lib/flush-effects' 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 573fdf5d268f3ea..d18e1f336b006ee 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -65,6 +65,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 { FlushEffectsContext } from '../shared/lib/flush-effects' let optimizeAmp: typeof import('./optimize-amp').default let getFontDefinitionFromManifest: typeof import('./font-utils').getFontDefinitionFromManifest @@ -301,19 +302,18 @@ function checkRedirectValues( 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) if (!entry) { const [renderStream, forwardStream] = readableStreamTee(req) - entry = createFromReadableStream(renderStream) + entry = createFromReadableStream( + pipeThrough(renderStream, createTextEncoderStream()) + ) rscCache.set(id, entry) let bootstrapped = false @@ -324,11 +324,10 @@ function createRSCHook() { if (bootstrap && !bootstrapped) { bootstrapped = true writer.write( - encoder.encode( - `` - ) + `` ) } if (done) { @@ -336,11 +335,11 @@ function createRSCHook() { writer.close() } else { writer.write( - encoder.encode( - `` - ) + `` ) process() } @@ -365,7 +364,7 @@ function createServerComponentRenderer( runtime, }: { cachePrefix: string - transformStream: TransformStream + transformStream: TransformStream serverComponentManifest: NonNullable runtime: 'nodejs' | 'edge' } @@ -381,9 +380,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: ReadableStream = pipeThrough( + renderToReadableStream( + renderFlight(App, OriginalComponent, props), + serverComponentManifest + ), + createTextDecoderStream() ) const response = useRSCResponse( @@ -478,8 +480,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() @@ -734,33 +738,68 @@ export async function renderToHTML( const nextExport = !isSSG && (renderOpts.nextExport || (dev && (isAutoExport || isFallback))) + const styledJsxFlushEffect = () => { + const styles = jsxStyleRegistry.styles() + jsxStyleRegistry.flush() + return <>{styles} + } + + 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. + flushEffects = null + + const flushEffectsImpl = React.useCallback( + (callbacks: Array<() => React.ReactNode>) => { + if (flushEffects) { + throw new Error( + 'The `useFlushEffects` hook cannot be used more than once.' + + '\nRead more: https://nextjs.org/docs/messages/multiple-flush-effects' + ) + } + flushEffects = callbacks + }, + [] + ) + + 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. @@ -1141,15 +1180,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: ReadableStream = pipeThrough( + renderToReadableStream( + renderFlight(App, OriginalComponent, { + ...props.pageProps, + ...serverComponentProps, + }), + serverComponentManifest + ), + createTextDecoderStream() ) return new RenderResult( - pipeThrough(stream, createBufferedTransformStream()) + pipeThrough( + pipeThrough(stream, createBufferedTransformStream()), + createTextEncoderStream() + ) ) } @@ -1290,14 +1335,34 @@ export async function renderToHTML( // up to date when getWrappedApp is called const content = renderContent() - return await renderToStream( + const flushEffectHandler = async () => { + const allFlushEffects = [ + styledJsxFlushEffect, + ...(flushEffects || []), + ] + const flushEffectStream = await renderToStream({ + ReactDOMServer, + element: ( + <> + {allFlushEffects.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, + flushEffectHandler, + }) } } else { const content = renderContent() @@ -1308,12 +1373,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, } } } @@ -1423,13 +1491,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) @@ -1471,7 +1537,7 @@ export async function renderToHTML( prefix.push('') } - let streams: Array = [ + let streams = [ streamFromArray(prefix), await documentResult.bodyResult(renderTargetSuffix), ] @@ -1534,7 +1600,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) { @@ -1561,23 +1629,25 @@ function serializeError( } } -function createTransformStream({ +function createTransformStream({ flush, transform, }: { - flush?: (controller: TransformStreamDefaultController) => Promise | void + flush?: ( + controller: TransformStreamDefaultController + ) => Promise | void transform?: ( - chunk: Uint8Array, - controller: TransformStreamDefaultController - ) => void -}): TransformStream { + chunk: Input, + controller: TransformStreamDefaultController + ) => Promise | void +}): 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) }, @@ -1611,7 +1681,10 @@ function createTransformStream({ } if (transform) { - transform(value, controller) + const maybePromise = transform(value, controller) + if (maybePromise) { + await maybePromise + } } else { controller.enqueue(value) } @@ -1627,10 +1700,27 @@ function createTransformStream({ } } -function createBufferedTransformStream(): TransformStream { +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 { let bufferedString = '' let pendingFlush: Promise | null = null @@ -1638,7 +1728,7 @@ function createBufferedTransformStream(): TransformStream { if (!pendingFlush) { pendingFlush = new Promise((resolve) => { setTimeout(() => { - controller.enqueue(encoder.encode(bufferedString)) + controller.enqueue(bufferedString) bufferedString = '' pendingFlush = null resolve() @@ -1650,7 +1740,7 @@ function createBufferedTransformStream(): TransformStream { return createTransformStream({ transform(chunk, controller) { - bufferedString += decoder.decode(chunk) + bufferedString += chunk flushBuffer(controller) }, @@ -1662,24 +1752,37 @@ function createBufferedTransformStream(): TransformStream { }) } -function renderToStream( - ReactDOMServer: typeof import('react-dom/server'), - element: React.ReactElement, - suffix: string | null, - dataStream: ReadableStream, +function createFlushEffectStream( + handleFlushEffect: () => Promise +): TransformStream { + return createTransformStream({ + async transform(chunk, controller) { + const extraChunk = await handleFlushEffect() + controller.enqueue(extraChunk + chunk) + }, + }) +} + +function renderToStream({ + ReactDOMServer, + element, + suffix, + dataStream, + generateStaticHTML, + flushEffectHandler, +}: { + ReactDOMServer: typeof import('react-dom/server') + element: React.ReactElement + suffix?: string + dataStream?: ReadableStream generateStaticHTML: boolean -): Promise { + flushEffectHandler?: () => 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) { @@ -1687,47 +1790,52 @@ 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) + resolve( + Promise.resolve().then(() => { + const transforms: Array> = [ + createBufferedTransformStream(), + flushEffectHandler + ? createFlushEffectStream(flushEffectHandler) + : null, + suffixUnclosed != null + ? createPrefixStream(suffixUnclosed) + : null, + dataStream ? createInlineDataStream(dataStream) : null, + suffixUnclosed != null ? createSuffixStream(closeTag) : null, + ].filter(Boolean) as any + + return transforms.reduce( + (readable, transform) => pipeThrough(readable, transform), + renderStream ) - ) + }) ) } } - const stream: 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() + ) }) } -function createSuffixStream(suffix: Uint8Array | null) { +function createSuffixStream(suffix: string): TransformStream { return createTransformStream({ flush(controller) { if (suffix) { @@ -1737,15 +1845,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) { @@ -1757,14 +1866,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() // We are buffering here for the inlined data stream because the @@ -1798,9 +1907,9 @@ function createInlineDataStream( }) } -function pipeTo( - readable: ReadableStream, - writable: WritableStream, +function pipeTo( + readable: ReadableStream, + writable: WritableStream, options?: { preventClose: boolean } ) { let resolver: () => void @@ -1827,15 +1936,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() @@ -1850,22 +1959,20 @@ 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 { + // 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() - return new ReadableStream({ - start(controller) { - chunks.forEach((chunk) => controller.enqueue(chunk)) - controller.close() - }, - }) + const writer = writable.getWriter() + strings.forEach((str) => writer.write(str)) + writer.close() + + return readable } -async function streamToString(stream: ReadableStream): Promise { +async function streamToString(stream: ReadableStream): Promise { const reader = stream.getReader() - const decoder = new TextDecoder() - let bufferedString = '' while (true) { @@ -1875,7 +1982,7 @@ async function streamToString(stream: ReadableStream): Promise { return bufferedString } - bufferedString += decoder.decode(value) + bufferedString += value } } diff --git a/packages/next/shared/lib/flush-effects.ts b/packages/next/shared/lib/flush-effects.ts new file mode 100644 index 000000000000000..341c126ed9e63d9 --- /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/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/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/client.js b/test/integration/react-18/app/pages/use-flush-effect/client.js new file mode 100644 index 000000000000000..884d7f9266ecdda --- /dev/null +++ b/test/integration/react-18/app/pages/use-flush-effect/client.js @@ -0,0 +1,36 @@ +import { unstable_useFlushEffects } from 'next/streaming' +import React from 'react' + +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() { + unstable_useFlushEffects([]) + return null +} + +export default function Client() { + return ( + + + + ) +} + +export async function getServerSideProps() { + // disable exporting this page + return { props: {} } +} 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..f8d09da3462d3c1 --- /dev/null +++ b/test/integration/react-18/app/pages/use-flush-effect/custom.js @@ -0,0 +1,12 @@ +import { unstable_useFlushEffects } from 'next/streaming' + +export default function Custom() { + if (typeof window === 'undefined') { + // eslint-disable-next-line react-hooks/rules-of-hooks + unstable_useFlushEffects([ + () => foo, + () => bar, + ]) + } + return null +} 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..5417eac3457aca8 --- /dev/null +++ b/test/integration/react-18/app/pages/use-flush-effect/multiple-calls.js @@ -0,0 +1,23 @@ +import { unstable_useFlushEffects } from 'next/streaming' + +function Component() { + if (typeof window === 'undefined') { + // eslint-disable-next-line react-hooks/rules-of-hooks + unstable_useFlushEffects([]) + } + return null +} + +export default function MultipleCalls() { + return ( + <> + + + + ) +} + +export async function getServerSideProps() { + // disable exporting this page + return { props: {} } +} diff --git a/test/integration/react-18/app/pages/use-flush-effect/styled-jsx.js b/test/integration/react-18/app/pages/use-flush-effect/styled-jsx.js new file mode 100644 index 000000000000000..27105d3f9c3a804 --- /dev/null +++ b/test/integration/react-18/app/pages/use-flush-effect/styled-jsx.js @@ -0,0 +1,30 @@ +import React from 'react' +import dynamic from 'next/dynamic' + +const Red = dynamic(() => import('../../components/red'), { + suspense: true, +}) + +function Blue() { + return ( +
+

This is Blue.

+ +
+ ) +} + +export default function StyledJsx() { + return ( + <> + + + + + + ) +} diff --git a/test/integration/react-18/test/concurrent.js b/test/integration/react-18/test/concurrent.js index 2f11225ba782196..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) { @@ -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 f0e634e92477844..8d68baaef1912b7 100644 --- a/test/integration/react-18/test/index.test.js +++ b/test/integration/react-18/test/index.test.js @@ -135,9 +135,9 @@ describe('Blocking mode', () => { dynamicHello.restore() }) - runTests('`runtime` is disabled', (context) => + runTests('`runtime` is disabled', (context) => { blocking(context, (p, q) => renderViaHTTP(context.appPort, p, q)) - ) + }) }) function runTestsAgainstRuntime(runtime) {