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"