Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix abort behavior for renderToReadableStream #10047

Merged
merged 1 commit into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/short-maps-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@remix-run/dev": patch
---

Stop passing `request.signal` as the `renderToReadableStream` `signal` to abort server rendering for cloudflare/deno runtimes because by the time that `request` is aborted, aborting the rendering is useless because there's no way for React to flush down the unresolved boundaries

- This has been incorrect for some time, but only recently exposed due to a bug in how we were aborting requests when running via `remix vite:dev` because we were incorrectly aborting requests after successful renders - which was causing us to abort a completed React render, and try to close an already closed `ReadableStream`.
- This has likely not shown up in any production scenarios because cloudflare/deno production runtimes are (correctly) not aborting the `request.signal` on successful renders
- The built-in `entry.server` files no longer pass a `signal` to `renderToReadableStream` because adding a timeout-based abort signal to the default behavior would constitute a breaking change
- Users can configure this abort behavior via their own `entry.server` via `remix reveal entry.server`, and the template entry.server files have been updated with an example approach for newly created Remix apps
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export default async function handleRequest(
const body = await renderToReadableStream(
<RemixServer context={remixContext} url={request.url} />,
{
signal: request.signal,
// If you wish to abort the rendering process, you can pass a signal here.
// Please refer to the templates for example son how to configure this.
// signal: controller.signal,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stop passing the request.signal to fix the issue - but do not add any default timeout behavior to avoid a breaking change

onError(error: unknown) {
// Log streaming rendering errors from inside the shell
console.error(error);
Expand Down
4 changes: 3 additions & 1 deletion packages/remix-dev/config/defaults/entry.server.deno.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export default async function handleRequest(
const body = await renderToReadableStream(
<RemixServer context={remixContext} url={request.url} />,
{
signal: request.signal,
// If you wish to abort the rendering process, you can pass a signal here.
// Please refer to the templates for example son how to configure this.
// signal: controller.signal,
onError(error: unknown) {
// Log streaming rendering errors from inside the shell
console.error(error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";

const ABORT_DELAY = 5000;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Templates can have the timeout logic included so that net new apps have it going forward


export default async function handleRequest(
request: Request,
responseStatusCode: number,
Expand All @@ -19,18 +21,29 @@ export default async function handleRequest(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ABORT_DELAY);

const body = await renderToReadableStream(
<RemixServer context={remixContext} url={request.url} />,
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
signal: request.signal,
signal: controller.signal,
onError(error: unknown) {
// Log streaming rendering errors from inside the shell
console.error(error);
if (!controller.signal.aborted) {
// Log streaming rendering errors from inside the shell
console.error(error);
}
responseStatusCode = 500;
},
}
);

body.allReady.then(() => clearTimeout(timeoutId));

if (isbot(request.headers.get("user-agent") || "")) {
await body.allReady;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";

const ABORT_DELAY = 5000;

export default async function handleRequest(
request: Request,
responseStatusCode: number,
Expand All @@ -19,18 +21,29 @@ export default async function handleRequest(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ABORT_DELAY);

const body = await renderToReadableStream(
<RemixServer context={remixContext} url={request.url} />,
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
signal: request.signal,
signal: controller.signal,
onError(error: unknown) {
// Log streaming rendering errors from inside the shell
console.error(error);
if (!controller.signal.aborted) {
// Log streaming rendering errors from inside the shell
console.error(error);
}
responseStatusCode = 500;
},
}
);

body.allReady.then(() => clearTimeout(timeoutId));

if (isbot(request.headers.get("user-agent") || "")) {
await body.allReady;
}
Expand Down
22 changes: 17 additions & 5 deletions templates/classic-remix-compiler/deno/app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,40 @@ import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";

const ABORT_DELAY = 5000;

export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// 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!

loadContext: AppLoadContext
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ABORT_DELAY);

const body = await renderToReadableStream(
<RemixServer context={remixContext} url={request.url} />,
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
signal: request.signal,
signal: controller.signal,
onError(error: unknown) {
// Log streaming rendering errors from inside the shell
console.error(error);
if (!controller.signal.aborted) {
// Log streaming rendering errors from inside the shell
console.error(error);
}
responseStatusCode = 500;
},
}
);

body.allReady.then(() => clearTimeout(timeoutId));

if (isbot(request.headers.get("user-agent") || "")) {
await body.allReady;
}
Expand Down
21 changes: 17 additions & 4 deletions templates/cloudflare-workers/app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";

const ABORT_DELAY = 5000;

export default async function handleRequest(
request: Request,
responseStatusCode: number,
Expand All @@ -19,18 +21,29 @@ export default async function handleRequest(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ABORT_DELAY);

const body = await renderToReadableStream(
<RemixServer context={remixContext} url={request.url} />,
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
signal: request.signal,
signal: controller.signal,
onError(error: unknown) {
// Log streaming rendering errors from inside the shell
console.error(error);
if (!controller.signal.aborted) {
// Log streaming rendering errors from inside the shell
console.error(error);
}
responseStatusCode = 500;
},
}
);

body.allReady.then(() => clearTimeout(timeoutId));

if (isbot(request.headers.get("user-agent") || "")) {
await body.allReady;
}
Expand Down
21 changes: 17 additions & 4 deletions templates/cloudflare/app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";

const ABORT_DELAY = 5000;

export default async function handleRequest(
request: Request,
responseStatusCode: number,
Expand All @@ -19,18 +21,29 @@ export default async function handleRequest(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ABORT_DELAY);

const body = await renderToReadableStream(
<RemixServer context={remixContext} url={request.url} />,
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
signal: request.signal,
signal: controller.signal,
onError(error: unknown) {
// Log streaming rendering errors from inside the shell
console.error(error);
if (!controller.signal.aborted) {
// Log streaming rendering errors from inside the shell
console.error(error);
}
responseStatusCode = 500;
},
}
);

body.allReady.then(() => clearTimeout(timeoutId));

if (isbot(request.headers.get("user-agent") || "")) {
await body.allReady;
}
Expand Down