diff --git a/package.json b/package.json index 1ee3b16..8cdb79a 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,8 @@ "engines": { "node": ">= 18.20" }, - "packageManager": "yarn@4.5.0" + "packageManager": "yarn@4.5.0", + "resolutions": { + "@emotion/utils": "1.4.0" + } } diff --git a/packages/excavator-web/app/entry.client.tsx b/packages/excavator-web/app/entry.client.tsx index b435445..bb3edb2 100644 --- a/packages/excavator-web/app/entry.client.tsx +++ b/packages/excavator-web/app/entry.client.tsx @@ -1,32 +1,28 @@ +import createEmotionCache from "@emotion/cache"; import { CacheProvider } from "@emotion/react"; import { RemixBrowser } from "@remix-run/react"; -import React, { useState } from "react"; +import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; -import { ClientStyleContext } from "./context.js"; -import createEmotionCache, { defaultCache } from "./createEmotionCache.js"; +const hydrate = () => { + const emotionCache = createEmotionCache({ key: "css" }); -interface ClientCacheProviderProps { - children: React.ReactNode; -} - -function ClientCacheProvider({ children }: ClientCacheProviderProps) { - const [cache, setCache] = useState(defaultCache); + startTransition(() => { + hydrateRoot( + document, + + + + + , + ); + }); +}; - function reset() { - setCache(createEmotionCache()); - } - - return ( - - {children} - - ); +if (typeof requestIdleCallback === "function") { + requestIdleCallback(hydrate); +} else { + // Safari doesn't support requestIdleCallback + // https://caniuse.com/requestidlecallback + setTimeout(hydrate, 1); } - -hydrateRoot( - document, - - - , -); diff --git a/packages/excavator-web/app/entry.server.tsx b/packages/excavator-web/app/entry.server.tsx index c02ba6c..e6bdc87 100644 --- a/packages/excavator-web/app/entry.server.tsx +++ b/packages/excavator-web/app/entry.server.tsx @@ -1,45 +1,142 @@ -import { CacheProvider } from "@emotion/react"; +import createEmotionCache from "@emotion/cache"; +import { CacheProvider as EmotionCacheProvider } from "@emotion/react"; import createEmotionServer from "@emotion/server/create-instance"; -import type { EntryContext } from "@remix-run/node"; +import type { AppLoadContext, EntryContext } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; -import React from "react"; -import { renderToString } from "react-dom/server"; +import { Response } from "@remix-run/web-fetch"; +import { isbot } from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; +import { PassThrough } from "stream"; -// Depends on the runtime you choose -import { ServerStyleContext } from "./context.js"; -import createEmotionCache from "./createEmotionCache.js"; +const ABORT_DELAY = 5000; -export default function handleRequest( +const handleRequest = ( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext, -) { - const cache = createEmotionCache(); - const { extractCriticalToChunks } = createEmotionServer(cache); + // This is ignored so we can keep it in the template for visibility. Feel + // free to delete this parameter in your app if you're not using it! + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loadContext: AppLoadContext, +) => + isbot(request.headers.get("user-agent")) + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext, + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext, + ); +export default handleRequest; - const html = renderToString( - - +const handleBotRequest = ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) => + new Promise((resolve, reject) => { + let shellRendered = false; + const emotionCache = createEmotionCache({ key: "css" }); + + const { pipe, abort } = renderToPipeableStream( + - - , - ); + , + { + onAllReady: () => { + shellRendered = true; + const reactBody = new PassThrough(); + const emotionServer = createEmotionServer(emotionCache); + + const bodyWithStyles = emotionServer.renderStylesToNodeStream(); + reactBody.pipe(bodyWithStyles); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + // @ts-expect-error: Stream is not compatible with ReadWriteStream + new Response(bodyWithStyles, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(reactBody); + }, + onShellError: (error: unknown) => { + reject(error); + }, + onError: (error: unknown) => { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); - const chunks = extractCriticalToChunks(html); + setTimeout(abort, ABORT_DELAY); + }); - const markup = renderToString( - - +const handleBrowserRequest = ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) => + new Promise((resolve, reject) => { + const emotionCache = createEmotionCache({ key: "css" }); + + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + - - , - ); + , + { + onShellReady: () => { + shellRendered = true; + const reactBody = new PassThrough(); + const emotionServer = createEmotionServer(emotionCache); + + const bodyWithStyles = emotionServer.renderStylesToNodeStream(); + reactBody.pipe(bodyWithStyles); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + // @ts-expect-error: Stream is not compatible with ReadWriteStream + new Response(bodyWithStyles, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); - responseHeaders.set("Content-Type", "text/html"); + pipe(reactBody); + }, + onShellError: (error: unknown) => { + reject(error); + }, + onError: (error: unknown) => { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); - return new Response(`${markup}`, { - status: responseStatusCode, - headers: responseHeaders, + setTimeout(abort, ABORT_DELAY); }); -} diff --git a/yarn.lock b/yarn.lock index 0d28866..46ee261 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3555,27 +3555,13 @@ __metadata: languageName: node linkType: hard -"@emotion/utils@npm:^1.2.1": - version: 1.2.1 - resolution: "@emotion/utils@npm:1.2.1" - checksum: 10c0/db43ca803361740c14dfb1cca1464d10d27f4c8b40d3e8864e6932ccf375d1450778ff4e4eadee03fb97f2aeb18de9fae98294905596a12ff7d4cd1910414d8d - languageName: node - linkType: hard - -"@emotion/utils@npm:^1.4.0": +"@emotion/utils@npm:1.4.0": version: 1.4.0 resolution: "@emotion/utils@npm:1.4.0" checksum: 10c0/b2ae698d6e935f4961a8349286b5b0a6117a16e179459cbf9c8d97d5daa7d96c99876b950f09b1a793d6b295713b2c8f89544bd8c3f26b8e4db60a218a0d4c42 languageName: node linkType: hard -"@emotion/utils@npm:^1.4.1": - version: 1.4.1 - resolution: "@emotion/utils@npm:1.4.1" - checksum: 10c0/f4704e0bdf48062fd6eb9c64771c88f521aab1e108a48cb23d65b6438597c63a6945301cef4c43611e79e0e76a304ec5481c31025ea8f573d7ad5423d747602c - languageName: node - linkType: hard - "@emotion/weak-memoize@npm:^0.4.0": version: 0.4.0 resolution: "@emotion/weak-memoize@npm:0.4.0"