Skip to content

Commit 5917bf9

Browse files
committed
Switch to isRouteErrorResponse on Remix v2.
1 parent 88a7f23 commit 5917bf9

File tree

5 files changed

+108
-32
lines changed

5 files changed

+108
-32
lines changed

packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77
import { PassThrough } from 'node:stream';
88

99
import type { AppLoadContext, EntryContext, DataFunctionArgs } from '@remix-run/node';
10-
import { json } from '@remix-run/node';
10+
import { createReadableStreamFromReadable } from '@remix-run/node';
1111
import { RemixServer } from '@remix-run/react';
1212
import { renderToPipeableStream } from 'react-dom/server';
1313
import * as Sentry from '@sentry/remix';
14+
import { installGlobals } from '@remix-run/node';
15+
import isbot from 'isbot';
16+
17+
installGlobals();
1418

1519
const ABORT_DELAY = 5_000;
1620

@@ -21,11 +25,7 @@ Sentry.init({
2125
});
2226

2327
export function handleError(error: unknown, { request }: DataFunctionArgs): void {
24-
if (error instanceof Error) {
25-
Sentry.captureRemixServerException(error, 'remix.server', request);
26-
} else {
27-
Sentry.captureException(error);
28-
}
28+
Sentry.captureRemixServerException(error, 'remix.server', request);
2929
}
3030

3131
export default function handleRequest(
@@ -35,7 +35,55 @@ export default function handleRequest(
3535
remixContext: EntryContext,
3636
loadContext: AppLoadContext,
3737
) {
38-
handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
38+
return isbot(request.headers.get('user-agent'))
39+
? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
40+
: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
41+
}
42+
43+
function handleBotRequest(
44+
request: Request,
45+
responseStatusCode: number,
46+
responseHeaders: Headers,
47+
remixContext: EntryContext,
48+
) {
49+
return new Promise((resolve, reject) => {
50+
let shellRendered = false;
51+
const { pipe, abort } = renderToPipeableStream(
52+
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
53+
{
54+
onAllReady() {
55+
shellRendered = true;
56+
const body = new PassThrough();
57+
const stream = createReadableStreamFromReadable(body);
58+
59+
responseHeaders.set('Content-Type', 'text/html');
60+
61+
resolve(
62+
new Response(stream, {
63+
headers: responseHeaders,
64+
status: responseStatusCode,
65+
}),
66+
);
67+
68+
pipe(body);
69+
},
70+
onShellError(error: unknown) {
71+
reject(error);
72+
},
73+
onError(error: unknown) {
74+
responseStatusCode = 500;
75+
// Log streaming rendering errors from inside the shell. Don't log
76+
// errors encountered during initial shell rendering since they'll
77+
// reject and get logged in handleDocumentRequest.
78+
if (shellRendered) {
79+
console.error(error);
80+
}
81+
},
82+
},
83+
);
84+
85+
setTimeout(abort, ABORT_DELAY);
86+
});
3987
}
4088

4189
function handleBrowserRequest(
@@ -45,16 +93,19 @@ function handleBrowserRequest(
4593
remixContext: EntryContext,
4694
) {
4795
return new Promise((resolve, reject) => {
96+
let shellRendered = false;
4897
const { pipe, abort } = renderToPipeableStream(
4998
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
5099
{
51100
onShellReady() {
101+
shellRendered = true;
52102
const body = new PassThrough();
103+
const stream = createReadableStreamFromReadable(body);
53104

54105
responseHeaders.set('Content-Type', 'text/html');
55106

56107
resolve(
57-
json(body, {
108+
new Response(stream, {
58109
headers: responseHeaders,
59110
status: responseStatusCode,
60111
}),
@@ -66,8 +117,13 @@ function handleBrowserRequest(
66117
reject(error);
67118
},
68119
onError(error: unknown) {
69-
console.error(error);
70120
responseStatusCode = 500;
121+
// Log streaming rendering errors from inside the shell. Don't log
122+
// errors encountered during initial shell rendering since they'll
123+
// reject and get logged in handleDocumentRequest.
124+
if (shellRendered) {
125+
console.error(error);
126+
}
71127
},
72128
},
73129
);

packages/remix/src/client/errors.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,7 @@
11
import { captureException, withScope } from '@sentry/core';
22
import { addExceptionMechanism, isNodeEnv, isString } from '@sentry/utils';
33

4-
import type { ErrorResponse } from '../utils/vendor/types';
5-
6-
/**
7-
* Checks whether the given error is an ErrorResponse.
8-
* ErrorResponse is when users throw a response from their loader or action functions.
9-
* This is in fact a server-side error that we capture on the client.
10-
*
11-
* @param error The error to check.
12-
* @returns boolean
13-
*/
14-
function isErrorResponse(error: unknown): error is ErrorResponse {
15-
return typeof error === 'object' && error !== null && 'status' in error && 'statusText' in error;
16-
}
4+
import { isRouteErrorResponse } from '../utils/vendor/response';
175

186
/**
197
* Captures an error that is thrown inside a Remix ErrorBoundary.
@@ -24,7 +12,7 @@ function isErrorResponse(error: unknown): error is ErrorResponse {
2412
export function captureRemixErrorBoundaryError(error: unknown): string | undefined {
2513
let eventId: string | undefined;
2614
const isClientSideRuntimeError = !isNodeEnv() && error instanceof Error;
27-
const isRemixErrorResponse = isErrorResponse(error);
15+
const isRemixErrorResponse = isRouteErrorResponse(error);
2816
// Server-side errors apart from `ErrorResponse`s also appear here without their stacktraces.
2917
// So, we only capture:
3018
// 1. `ErrorResponse`s

packages/remix/src/utils/instrumentServer.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ import {
1414
} from '@sentry/utils';
1515

1616
import { getFutureFlagsServer, getRemixVersionFromBuild } from './futureFlags';
17-
import { extractData, getRequestMatch, isDeferredData, isResponse, json, matchServerRoutes } from './vendor/response';
17+
import {
18+
extractData,
19+
getRequestMatch,
20+
isDeferredData,
21+
isResponse,
22+
isRouteErrorResponse,
23+
json,
24+
matchServerRoutes,
25+
} from './vendor/response';
1826
import type {
1927
AppData,
2028
CreateRequestHandlerFunction,
@@ -33,6 +41,7 @@ import type {
3341
import { normalizeRemixRequest } from './web-fetch';
3442

3543
let FUTURE_FLAGS: FutureConfig | undefined;
44+
let IS_REMIX_V2: boolean | undefined;
3645

3746
// Flag to track if the core request handler is instrumented.
3847
export let isRequestHandlerWrapped = false;
@@ -72,7 +81,11 @@ async function extractResponseError(response: Response): Promise<unknown> {
7281
export async function captureRemixServerException(err: unknown, name: string, request: Request): Promise<void> {
7382
// Skip capturing if the thrown error is not a 5xx response
7483
// https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders
75-
if (isResponse(err) && err.status < 500) {
84+
if (IS_REMIX_V2) {
85+
if (isRouteErrorResponse(err) && err.status < 500) {
86+
return;
87+
}
88+
} else if (isResponse(err) && err.status < 500) {
7689
return;
7790
}
7891

@@ -397,7 +410,10 @@ function wrapRequestHandler(origRequestHandler: RequestHandler, build: ServerBui
397410

398411
const res = (await origRequestHandler.call(this, request, loadContext)) as Response;
399412

400-
transaction.setHttpStatus(res.status);
413+
if (isResponse(res)) {
414+
transaction.setHttpStatus(res.status);
415+
}
416+
401417
transaction.finish();
402418

403419
return res;
@@ -412,6 +428,7 @@ export function instrumentBuild(build: ServerBuild): ServerBuild {
412428
const routes: ServerRouteManifest = {};
413429

414430
const remixVersion = getRemixVersionFromBuild(build);
431+
IS_REMIX_V2 = remixVersion === 2;
415432

416433
const wrappedEntry = { ...build.entry, module: { ...build.entry.module } };
417434

packages/remix/src/utils/vendor/response.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//
77
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
88

9-
import type { DeferredData, ReactRouterDomPkg, RouteMatch, ServerRoute } from './types';
9+
import type { DeferredData, ErrorResponse, ReactRouterDomPkg, RouteMatch, ServerRoute } from './types';
1010

1111
/**
1212
* Based on Remix Implementation
@@ -140,3 +140,22 @@ export function isDeferredData(value: any): value is DeferredData {
140140
typeof deferred.resolveData === 'function'
141141
);
142142
}
143+
144+
/**
145+
* https://github.com/remix-run/react-router/blob/f9b3dbd9cbf513366c456b33d95227f42f36da63/packages/router/utils.ts#L1574
146+
*
147+
* Check if the given error is an ErrorResponse generated from a 4xx/5xx
148+
* Response thrown from an action/loader
149+
*/
150+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
151+
export function isRouteErrorResponse(value: any): value is ErrorResponse {
152+
const error: ErrorResponse = value;
153+
154+
return (
155+
error != null &&
156+
typeof error.status === 'number' &&
157+
typeof error.statusText === 'string' &&
158+
typeof error.internal === 'boolean' &&
159+
'data' in error
160+
);
161+
}

packages/remix/test/integration/app_v2/entry.server.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,7 @@ Sentry.init({
1212
});
1313

1414
export function handleError(error: unknown, { request }: DataFunctionArgs): void {
15-
if (error instanceof Error) {
16-
Sentry.captureRemixServerException(error, 'remix.server', request);
17-
} else {
18-
Sentry.captureException(error);
19-
}
15+
Sentry.captureRemixServerException(error, 'remix.server', request);
2016
}
2117

2218
export default function handleRequest(

0 commit comments

Comments
 (0)