diff --git a/packages/dev/src/components/Welcome.server.jsx b/packages/dev/src/components/Welcome.server.jsx index 92d96a2948..6a85d06b75 100644 --- a/packages/dev/src/components/Welcome.server.jsx +++ b/packages/dev/src/components/Welcome.server.jsx @@ -1,5 +1,6 @@ import {useShopQuery, flattenConnection, Link} from '@shopify/hydrogen'; import gql from 'graphql-tag'; +import {Suspense} from 'react'; function ExternalIcon() { return ( @@ -31,7 +32,20 @@ function DocsButton({url, label}) { ); } -function StorefrontInfo({shopName, totalProducts, totalCollections}) { +function BoxFallback() { + return ( +
+ ); +} + +function StorefrontInfo() { + const {data} = useShopQuery({query: QUERY}); + const shopName = data ? data.shop.name : ''; + const products = data && flattenConnection(data.products); + const collections = data && flattenConnection(data.collections); + const totalProducts = products && products.length; + const totalCollections = collections && collections.length; + const pluralize = (count, noun, suffix = 's') => `${count} ${noun}${count === 1 ? '' : suffix}`; return ( @@ -71,7 +85,14 @@ function StorefrontInfo({shopName, totalProducts, totalCollections}) { ); } -function TemplateLinks({firstProductPath, firstCollectionPath}) { +function TemplateLinks() { + const {data} = useShopQuery({query: QUERY}); + const products = data && flattenConnection(data.products); + const collections = data && flattenConnection(data.collections); + + const firstProduct = products && products.length ? products[0].handle : ''; + const firstCollection = collections[0] ? collections[0].handle : ''; + return (

@@ -80,7 +101,7 @@ function TemplateLinks({firstProductPath, firstCollectionPath}) {

); diff --git a/packages/dev/src/pages/index.server.jsx b/packages/dev/src/pages/index.server.jsx index e77abb4cd2..1c5945a1ff 100644 --- a/packages/dev/src/pages/index.server.jsx +++ b/packages/dev/src/pages/index.server.jsx @@ -11,6 +11,94 @@ import Layout from '../components/Layout.server'; import FeaturedCollection from '../components/FeaturedCollection'; import ProductCard from '../components/ProductCard'; import Welcome from '../components/Welcome.server'; +import {Suspense} from 'react'; + +export default function Index({country = {isoCode: 'US'}}) { + return ( + }> +
+ + }> + + + }> + + +
+
+ ); +} + +function BoxFallback() { + return
; +} + +function FeaturedProductsBox({country}) { + const {data} = useShopQuery({ + query: QUERY, + variables: { + country: country.isoCode, + }, + }); + + const collections = data ? flattenConnection(data.collections) : []; + const featuredProductsCollection = collections[0]; + const featuredProducts = featuredProductsCollection + ? flattenConnection(featuredProductsCollection.products) + : null; + + return ( +
+ {featuredProductsCollection ? ( + <> +
+ + {featuredProductsCollection.title} + + + + Shop all + + +
+
+ {featuredProducts.map((product) => ( +
+ +
+ ))} +
+
+ + Shop all + +
+ + ) : null} +
+ ); +} + +function FeaturedCollectionBox({country}) { + const {data} = useShopQuery({ + query: QUERY, + variables: { + country: country.isoCode, + }, + }); + + const collections = data ? flattenConnection(data.collections) : []; + const featuredCollection = + collections && collections.length > 1 ? collections[1] : collections[0]; + + return ; +} function GradientBackground() { return ( @@ -67,66 +155,6 @@ function GradientBackground() { ); } -export default function Index({country = {isoCode: 'US'}}) { - const {data} = useShopQuery({ - query: QUERY, - variables: { - country: country.isoCode, - }, - }); - - const collections = data ? flattenConnection(data.collections) : []; - const featuredProductsCollection = collections[0]; - const featuredProducts = featuredProductsCollection - ? flattenConnection(featuredProductsCollection.products) - : null; - const featuredCollection = - collections && collections.length > 1 ? collections[1] : collections[0]; - - return ( - }> -
- -
- {featuredProductsCollection ? ( - <> -
- - {featuredProductsCollection.title} - - - - Shop all - - -
-
- {featuredProducts.map((product) => ( -
- -
- ))} -
-
- - Shop all - -
- - ) : null} -
- -
-
- ); -} - const QUERY = gql` query indexContent( $country: CountryCode diff --git a/packages/hydrogen/package.json b/packages/hydrogen/package.json index 73d1c4b1c0..5e4094b365 100644 --- a/packages/hydrogen/package.json +++ b/packages/hydrogen/package.json @@ -63,7 +63,7 @@ "@rollup/plugin-graphql": "^1.0.0", "@types/connect": "^3.4.34", "@types/graphql": "^14.5.0", - "@types/node": "^15.12.4", + "@types/node": "^16.11.7", "@types/node-fetch": "^2.5.9", "@types/react": "^17.0.3", "@types/react-dom": "^17.0.3", diff --git a/packages/hydrogen/src/entry-client.tsx b/packages/hydrogen/src/entry-client.tsx index ca8226191e..bcfc513db8 100644 --- a/packages/hydrogen/src/entry-client.tsx +++ b/packages/hydrogen/src/entry-client.tsx @@ -4,7 +4,7 @@ import {createRoot} from 'react-dom'; import {BrowserRouter} from 'react-router-dom'; import type {ClientHandler, ShopifyConfig} from './types'; import {ErrorBoundary} from 'react-error-boundary'; -import {useServerResponse} from './framework/Hydration/Cache.client'; +import {useServerResponse} from './framework/Hydration/rsc'; import { ServerStateProvider, ServerStateRouter, diff --git a/packages/hydrogen/src/entry-server.tsx b/packages/hydrogen/src/entry-server.tsx index 5b876b98c2..d9e1f22add 100644 --- a/packages/hydrogen/src/entry-server.tsx +++ b/packages/hydrogen/src/entry-server.tsx @@ -13,24 +13,21 @@ import { import {getErrorMarkup} from './utilities/error'; import {defer} from './utilities/defer'; import type {ServerHandler} from './types'; -import {FilledContext} from 'react-helmet-async'; +import type {FilledContext} from 'react-helmet-async'; import {Html} from './framework/Hydration/Html'; import {Renderer, Hydrator, Streamer} from './types'; import {ServerComponentResponse} from './framework/Hydration/ServerComponentResponse.server'; import {ServerComponentRequest} from './framework/Hydration/ServerComponentRequest.server'; import {getCacheControlHeader} from './framework/cache'; import {ServerRequestProvider} from './foundation/ServerRequestProvider'; +import {setShopifyConfig} from './foundation/useShop'; import type {ServerResponse} from 'http'; -import type {PassThrough as PassThroughType} from 'stream'; +import type {PassThrough as PassThroughType, Writable} from 'stream'; // @ts-ignore -import * as rscRenderer from '@shopify/hydrogen/vendor/react-server-dom-vite/writer'; -import {setShopifyConfig} from './foundation/useShop'; - -const { - renderToPipeableStream: rscRenderToPipeableStream, - renderToReadableStream: rscRenderToReadableStream, -} = rscRenderer; +import {renderToReadableStream as rscRenderToReadableStream} from '@shopify/hydrogen/vendor/react-server-dom-vite/writer.browser.server'; +// @ts-ignore +import {createFromReadableStream} from '@shopify/hydrogen/vendor/react-server-dom-vite'; declare global { // This is provided by a Vite plugin @@ -39,22 +36,6 @@ declare global { var __WORKER__: boolean; } -function flightContainer({ - init, - chunk, - nonce, -}: { - init?: boolean; - chunk?: string; - nonce?: string; -}) { - const normalizedChunk = chunk?.replace(/\\/g, String.raw`\\`); - - return `window.__flight${ - init ? '=[]' : `.push(\`${normalizedChunk}\`)` - }`; -} - /** * If a query is taking too long, or something else went wrong, * send back a response containing the Suspense fallback and rely @@ -70,7 +51,10 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { * and returning any initial state that needs to be hydrated into the client version of the app. * NOTE: This is currently only used for SEO bots or Worker runtime (where Stream is not yet supported). */ - const render: Renderer = async function (url, {request, template, dev}) { + const render: Renderer = async function ( + url, + {request, template, nonce, dev} + ) { const log = getLoggerFromContext(request); const state = {pathname: url.pathname, search: url.search}; @@ -85,7 +69,7 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { , - {log} + {log, nonce} ); const {headers, status, statusText} = getResponseOptions(componentResponse); @@ -113,6 +97,7 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { const params = {url, ...extractHeadElements(helmetContext)}; const {bodyAttributes, htmlAttributes, ...head} = params; + head.script = (head.script || '') + flightContainer({init: true, nonce}); html = html .replace( @@ -137,13 +122,14 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { */ const stream: Streamer = async function ( url: URL, - {request, response, template, dev} + {request, response, template, nonce, dev} ) { const log = getLoggerFromContext(request); const state = {pathname: url.pathname, search: url.search}; + let didError: Error | undefined; // App for RSC rendering - const {ReactApp: ReactAppRSC} = buildReactApp({ + const {ReactApp: ReactAppRSC, componentResponse} = buildReactApp({ App, state, request, @@ -151,78 +137,46 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { isRSC: true, }); - let isContainerInitialized = false; - let flightResponseBuffer = ''; - const flushRSC = ( - writable: {write: (chunk: string) => void}, - chunk = '' - ) => { - if (!isContainerInitialized) { - isContainerInitialized = true; - writable.write(flightContainer({init: true})); - } - - if (flightResponseBuffer) { - chunk = flightResponseBuffer + chunk; - flightResponseBuffer = ''; - } - - if (chunk) { - writable.write(flightContainer({chunk})); - } - }; - - if (__WORKER__) { - // Worker branch - // TODO implement RSC with TransformStream? - } else { - // Node.js branch - - const {pipe} = rscRenderToPipeableStream(); - - const writer = await createNodeWriter(); - writer.setEncoding('utf-8'); - writer.on('data', (chunk: string) => { - if (response.headersSent) { - flushRSC(response, chunk); - } else { - flightResponseBuffer += chunk; - } - }); - - pipe(writer); + const [rscReadableForFizz, rscReadableForFlight] = ( + rscRenderToReadableStream() as ReadableStream + ).tee(); + + const rscResponse = createFromReadableStream(rscReadableForFizz); + function RscConsumer() { + return ( + + {rscResponse.readRoot()} + + ); } - // App for SSR rendering - const {ReactApp, componentResponse} = buildReactApp({ - App, - state, - request, - log, - }); - - if (!__WORKER__ && response) { - response.socket!.on('error', (error: any) => { - log.fatal(error); - }); - } - - let didError: Error | undefined; - const ReactAppSSR = ( - + ); + const rscToScriptTagReadable = new ReadableStream({ + start(controller) { + let init = true; + const encoder = new TextEncoder(); + bufferReadableStream(rscReadableForFlight.getReader(), (chunk) => { + const scriptTag = flightContainer({init, chunk, nonce}); + controller.enqueue(encoder.encode(scriptTag)); + init = false; + }).then(() => controller.close()); + }, + }); + if (__WORKER__) { - const deferred = defer(); + const deferredShouldReturnApp = defer(); const encoder = new TextEncoder(); const transform = new TransformStream(); const writable = transform.writable.getWriter(); const responseOptions = {} as ResponseOptions; - const readable: ReadableStream = renderToReadableStream(ReactAppSSR, { + const ssrReadable: ReadableStream = renderToReadableStream(ReactAppSSR, { + nonce, onCompleteShell() { Object.assign( responseOptions, @@ -239,7 +193,7 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { if (isRedirect(responseOptions)) { // Return redirects early without further rendering/streaming - return deferred.resolve(false); + return deferredShouldReturnApp.resolve(false); } if (!componentResponse.canStream()) return; @@ -251,7 +205,7 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { dev ? didError : undefined ); - deferred.resolve(true); + deferredShouldReturnApp.resolve(true); }, async onCompleteAll() { if (componentResponse.canStream()) return; @@ -263,12 +217,12 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { if (isRedirect(responseOptions)) { // Redirects found after any async code - return deferred.resolve(false); + return deferredShouldReturnApp.resolve(false); } if (componentResponse.customBody) { writable.write(encoder.encode(await componentResponse.customBody)); - return deferred.resolve(false); + return deferredShouldReturnApp.resolve(false); } startWritingHtmlToStream( @@ -278,12 +232,12 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { dev ? didError : undefined ); - deferred.resolve(true); + deferredShouldReturnApp.resolve(true); }, onError(error: any) { didError = error; - if (dev && deferred.status === 'pending') { + if (dev && deferredShouldReturnApp.status === 'pending') { writable.write(getErrorMarkup(error)); } @@ -291,18 +245,59 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { }, }); - const shouldUseStream = await deferred.promise; + if (await deferredShouldReturnApp.promise) { + let bufferedSsr = ''; + let isPendingSsrWrite = false; + const writingSSR = bufferReadableStream( + ssrReadable.getReader(), + (chunk) => { + bufferedSsr += chunk; + + if (!isPendingSsrWrite) { + isPendingSsrWrite = true; + setTimeout(() => { + isPendingSsrWrite = false; + // React can write fractional chunks synchronously. + // This timeout ensures we only write full HTML tags + // in order to allow RSC writing concurrently. + if (bufferedSsr) { + writable.write(encoder.encode(bufferedSsr)); + bufferedSsr = ''; + } + }, 0); + } + } + ); - if (shouldUseStream) { - writable.releaseLock(); - readable.pipeTo(transform.writable); + const writingRSC = bufferReadableStream( + rscToScriptTagReadable.getReader(), + (scriptTag) => writable.write(encoder.encode(scriptTag)) + ); + + Promise.all([writingSSR, writingRSC]).then(() => { + // Last SSR write might be pending, delay closing the writable one tick + setTimeout(() => writable.close(), 0); + logServerResponse('str', log, request, responseOptions.status); + }); + } else { + writable.close(); + logServerResponse('str', log, request, responseOptions.status); } - logServerResponse('str', log, request, responseOptions.status); + if (await isStreamingSupported()) { + return new Response(transform.readable, responseOptions); + } + + const bufferedBody = await bufferReadableStream( + transform.readable.getReader() + ); + + return new Response(bufferedBody, responseOptions); + } else if (response) { + response.socket!.on('error', log.fatal); - return new Response(transform.readable, responseOptions); - } else { const {pipe} = renderToPipeableStream(ReactAppSSR, { + nonce, onCompleteShell() { /** * TODO: This assumes `response.cache()` has been called _before_ any @@ -327,10 +322,18 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { startWritingHtmlToServerResponse( response, - pipe, - flushRSC, dev ? didError : undefined ); + + // Piping ends the response so let RSC go first. + // Generally, RSC reader should finish before SSR because + // the latter is also reading RSC until it finishes. + // However, this might not be the case in small apps that + // are written in SSR at once. + setTimeout(() => pipe(response), 0); + bufferReadableStream(rscToScriptTagReadable.getReader(), (chunk) => + response.write(chunk) + ); }, async onCompleteAll() { clearTimeout(streamTimeout); @@ -352,10 +355,17 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { startWritingHtmlToServerResponse( response, - pipe, - flushRSC, dev ? didError : undefined ); + + bufferReadableStream(rscToScriptTagReadable.getReader()).then( + (scriptTags) => { + // Piping ends the response so script tags + // must be written before that. + response.write(scriptTags); + pipe(response); + } + ); }, onError(error: any) { didError = error; @@ -396,18 +406,12 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { isRSC: true, }); - if (!__WORKER__ && response) { - response.socket!.on('error', (error: any) => { - log.fatal(error); - }); - } - if (__WORKER__) { const readable = rscRenderToReadableStream( ) as ReadableStream; - if (isStreamable) { + if (isStreamable && (await isStreamingSupported())) { logServerResponse('rsc', log, request, 200); return new Response(readable); } @@ -418,8 +422,17 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { logServerResponse('rsc', log, request, 200); return new Response(bufferedBody); - } else { - const stream = rscRenderToPipeableStream().pipe(response); + } else if (response) { + response.socket!.on('error', log.fatal); + + const rscWriter = await import( + // @ts-ignore + '@shopify/hydrogen/vendor/react-server-dom-vite/writer.node.server' + ); + + const stream = rscWriter + .renderToPipeableStream() + .pipe(response) as Writable; stream.on('finish', function () { logServerResponse('rsc', log, request, response!.statusCode); @@ -434,7 +447,10 @@ const renderHydrogen: ServerHandler = (App, {shopifyConfig}) => { }; }; -async function bufferReadableStream(reader: ReadableStreamDefaultReader) { +async function bufferReadableStream( + reader: ReadableStreamDefaultReader, + cb?: (chunk: string) => void +) { const decoder = new TextDecoder(); let result = ''; @@ -442,7 +458,14 @@ async function bufferReadableStream(reader: ReadableStreamDefaultReader) { const {done, value} = await reader.read(); if (done) break; - result += typeof value === 'string' ? value : decoder.decode(value); + const stringValue = + typeof value === 'string' ? value : decoder.decode(value); + + result += stringValue; + + if (cb) { + cb(stringValue); + } } return result; @@ -466,7 +489,7 @@ function buildReactApp({ const hydrogenServerProps = { request, response: componentResponse, - helmetContext: helmetContext, + helmetContext, log, }; @@ -507,7 +530,7 @@ function extractHeadElements(helmetContext: FilledContext) { async function renderToBufferedString( ReactApp: JSX.Element, - {log}: {log: Logger} + {log, nonce}: {log: Logger; nonce?: string} ): Promise { return new Promise(async (resolve, reject) => { const errorTimeout = setTimeout(() => { @@ -517,6 +540,7 @@ async function renderToBufferedString( if (__WORKER__) { const deferred = defer(); const readable = renderToReadableStream(ReactApp, { + nonce, onCompleteAll() { clearTimeout(errorTimeout); /** @@ -540,6 +564,7 @@ async function renderToBufferedString( const writer = await createNodeWriter(); const {pipe} = renderToPipeableStream(ReactApp, { + nonce, /** * When hydrating, we have to wait until `onCompleteAll` to avoid having * `template` and `script` tags inserted and rendered as part of the hydration response. @@ -567,8 +592,6 @@ export default renderHydrogen; function startWritingHtmlToServerResponse( response: ServerResponse, - pipe: (r: ServerResponse) => void, - flushRSC: (w: ServerResponse) => void, error?: Error ) { if (!response.headersSent) { @@ -576,9 +599,6 @@ function startWritingHtmlToServerResponse( response.write(''); } - pipe(response); - flushRSC(response); - if (error) { // This error was delayed until the headers were properly sent. response.write(getErrorMarkup(error)); @@ -690,3 +710,48 @@ async function createNodeWriter() { const {PassThrough} = await import(streamImport); return new PassThrough() as InstanceType; } + +function flightContainer({ + init, + chunk, + nonce, +}: { + chunk?: string; + init?: boolean; + nonce?: string; +}) { + let script = ``; + if (init) { + script += 'var __flight=[];'; + } + + if (chunk) { + const normalizedChunk = chunk?.replace(/\\/g, String.raw`\\`); + script += `__flight.push(\`${normalizedChunk}\`)`; + } + + return script + ''; +} + +let cachedStreamingSupport: boolean; +async function isStreamingSupported() { + if (cachedStreamingSupport === undefined) { + try { + const rs = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + + // This will throw in CFW until streaming + // is supported. It works in Miniflare. + await new Response(rs).text(); + + cachedStreamingSupport = true; + } catch (_) { + cachedStreamingSupport = false; + } + } + + return cachedStreamingSupport; +} diff --git a/packages/hydrogen/src/framework/Hydration/Cache.client.ts b/packages/hydrogen/src/framework/Hydration/rsc.ts similarity index 91% rename from packages/hydrogen/src/framework/Hydration/Cache.client.ts rename to packages/hydrogen/src/framework/Hydration/rsc.ts index dd093e5db3..3385b197fa 100644 --- a/packages/hydrogen/src/framework/Hydration/Cache.client.ts +++ b/packages/hydrogen/src/framework/Hydration/rsc.ts @@ -1,3 +1,6 @@ +// TODO should we move this file to src/foundation +// so it is considered ESM instead of CJS? + // @ts-ignore import {unstable_getCacheForType, unstable_useCacheRefresh} from 'react'; import { @@ -13,7 +16,7 @@ declare global { let rscReader: ReadableStream | null; -if (window.__flight && window.__flight.length > 0) { +if (__flight && __flight.length > 0) { const contentLoaded = new Promise((resolve) => document.addEventListener('DOMContentLoaded', resolve) ); @@ -27,8 +30,8 @@ if (window.__flight && window.__flight.length > 0) { return 0; }; - window.__flight.forEach(write); - window.__flight.push = write; + __flight.forEach(write); + __flight.push = write; contentLoaded.then(() => controller.close()); }, diff --git a/packages/hydrogen/src/framework/middleware.ts b/packages/hydrogen/src/framework/middleware.ts index 9189f50a2f..470ef15ab3 100644 --- a/packages/hydrogen/src/framework/middleware.ts +++ b/packages/hydrogen/src/framework/middleware.ts @@ -84,6 +84,18 @@ export function hydrogenMiddleware({ globalThis.Headers = fetch.Headers; } + if (!globalThis.ReadableStream) { + const {ReadableStream, WritableStream, TransformStream} = await import( + 'stream/web' + ); + + Object.assign(globalThis, { + ReadableStream, + WritableStream, + TransformStream, + }); + } + /** * Dynamically import ServerComponentResponse after the `fetch` * polyfill has loaded above. diff --git a/packages/hydrogen/src/framework/plugins/vite-plugin-hydrogen-config.ts b/packages/hydrogen/src/framework/plugins/vite-plugin-hydrogen-config.ts index 383a65f3ad..4a36286bce 100644 --- a/packages/hydrogen/src/framework/plugins/vite-plugin-hydrogen-config.ts +++ b/packages/hydrogen/src/framework/plugins/vite-plugin-hydrogen-config.ts @@ -73,5 +73,36 @@ export default () => { envPrefix: ['VITE_', 'PUBLIC_'], }), + + // TODO: Remove when react-dom/fizz is fixed + generateBundle: process.env.WORKER + ? (options, bundle) => { + // There's only one key in bundle, normally `worker.js` + const [bundleKey] = Object.keys(bundle); + const workerBundle = bundle[bundleKey]; + // It's always a chunk, this is just for TypeScript + if (workerBundle.type === 'chunk') { + // React fizz and flight try to access an undefined value. + // This puts a guard before accessing it. + workerBundle.code = workerBundle.code.replace( + /\((\w+)\.locked\)/gm, + '($1 && $1.locked)' + ); + + // `renderToReadableStream` is bugged in React. + // This adds a workaround until these issues are fixed: + // https://github.com/facebook/react/issues/22772 + // https://github.com/facebook/react/issues/23113 + workerBundle.code = workerBundle.code.replace( + /var \w+\s*=\s*(\w+)\.completedRootSegment;/g, + 'if($1.status===5)return\n$1.status=5;\n$&' + ); + workerBundle.code = workerBundle.code.replace( + /(\w+)\.allPendingTasks\s*={2,3}\s*0\s*\&\&\s*\w+\.pingedTasks\.length/g, + '$1.status=0;\n$&' + ); + } + } + : undefined, } as Plugin; }; diff --git a/packages/hydrogen/src/handle-event.ts b/packages/hydrogen/src/handle-event.ts index 4641890691..2c39ae9989 100644 --- a/packages/hydrogen/src/handle-event.ts +++ b/packages/hydrogen/src/handle-event.ts @@ -17,9 +17,10 @@ export interface HandleEventOptions { indexTemplate: string | ((url: string) => Promise); assetHandler?: (event: HydrogenFetchEvent, url: URL) => Promise; cache?: Cache; - streamableResponse: ServerResponse; + streamableResponse?: ServerResponse; dev?: boolean; context?: RuntimeContext; + nonce?: string; } export default async function handleEvent( @@ -33,6 +34,7 @@ export default async function handleEvent( dev, cache, context, + nonce, }: HandleEventOptions ) { const url = new URL(request.url); @@ -71,9 +73,9 @@ export default async function handleEvent( ); } - // TODO: use __WORKER__ boolean to enable streaming once CFW supports `new Response(stream)` const isStreamable = - !!streamableResponse && !isBotUA(url, request.headers.get('user-agent')); + !isBotUA(url, request.headers.get('user-agent')) && + (!!streamableResponse || supportsReadableStream()); if (isReactHydrationRequest) { return hydrate(url, { @@ -94,6 +96,7 @@ export default async function handleEvent( request, response: streamableResponse, template, + nonce, dev, }); } @@ -101,6 +104,7 @@ export default async function handleEvent( return render(url, { request, template, + nonce, dev, }); } @@ -163,3 +167,12 @@ const botUserAgents = [ * Creates a regex based on the botUserAgents array */ const botUARegex = new RegExp(botUserAgents.join('|'), 'i'); + +function supportsReadableStream() { + try { + new ReadableStream(); + return true; + } catch (_) { + return false; + } +} diff --git a/packages/hydrogen/src/types.ts b/packages/hydrogen/src/types.ts index a360cae650..402b4f6085 100644 --- a/packages/hydrogen/src/types.ts +++ b/packages/hydrogen/src/types.ts @@ -7,6 +7,7 @@ export type Renderer = ( options: { request: ServerComponentRequest; template: string; + nonce?: string; dev?: boolean; } ) => Promise; @@ -15,8 +16,9 @@ export type Streamer = ( url: URL, options: { request: ServerComponentRequest; - response: ServerResponse; + response?: ServerResponse; template: string; + nonce?: string; dev?: boolean; } ) => void; diff --git a/packages/hydrogen/vendor/react-server-dom-vite/esm/react-server-dom-vite-writer.browser.server.js b/packages/hydrogen/vendor/react-server-dom-vite/esm/react-server-dom-vite-writer.browser.server.js index c6ad3b655e..8ec64afa69 100644 --- a/packages/hydrogen/vendor/react-server-dom-vite/esm/react-server-dom-vite-writer.browser.server.js +++ b/packages/hydrogen/vendor/react-server-dom-vite/esm/react-server-dom-vite-writer.browser.server.js @@ -839,7 +839,10 @@ function performWork(request) { } } +let reentrant = false; function flushCompletedChunks(request, destination) { + if (reentrant) return; + reentrant = true; try { // We emit module chunks first in the stream so that // they can be preloaded as early as possible. @@ -893,6 +896,7 @@ function flushCompletedChunks(request, destination) { errorChunks.splice(0, i); } finally { + reentrant = false; } if (request.pendingChunks === 0) { diff --git a/yarn.lock b/yarn.lock index da70679eb3..76e91d78ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2880,11 +2880,16 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.12.tgz#ac7fb693ac587ee182c3780c26eb65546a1a3c10" integrity sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw== -"@types/node@^15.12.4", "@types/node@^15.6.1": +"@types/node@^15.6.1": version "15.14.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-15.14.9.tgz#bc43c990c3c9be7281868bbc7b8fdd6e2b57adfa" integrity sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A== +"@types/node@^16.11.7": + version "16.11.19" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.19.tgz#1afa165146997b8286b6eabcb1c2d50729055169" + integrity sha512-BPAcfDPoHlRQNKktbsbnpACGdypPFBuX4xQlsWDE7B8XXcfII+SpOLay3/qZmCLb39kV5S1RTYwXdkx2lwLYng== + "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"