@akinon/next 1.126.13-v1-rc.9 → 1.126.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -3
- package/__tests__/uncaught-error.test.ts +489 -0
- package/components/client-root.tsx +3 -1
- package/hooks/index.ts +2 -0
- package/hooks/use-chunk-load-recovery.ts +38 -0
- package/hooks/use-sentry-uncaught-errors.ts +45 -15
- package/hooks/use-uncaught-error-recovery.ts +28 -0
- package/package.json +2 -2
- package/sentry/index.ts +5 -0
- package/utils/uncaught-error.ts +228 -0
- package/views/error-page.tsx +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# @akinon/next
|
|
2
2
|
|
|
3
|
-
## 1.126.13
|
|
3
|
+
## 1.126.13
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- 182a639b: ZERO-4483: Verify v1-rc maintenance pipeline (no-op patch to confirm v1-rc dist-tag flow)
|
|
7
|
+
- 5b17eef: ZERO-5129, ZERO-5130: Report the error the [pz] error boundary caught instead of a synthetic fatal one, so Sentry groups by what broke and drops stale chunks; reload once, instead of showing the error page, after a ChunkLoadError (also one no boundary catches) or a removeChild/insertBefore DOM mutation race
|
|
9
8
|
|
|
10
9
|
## 1.126.12
|
|
11
10
|
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO-5129 / ZERO-5130: what the storefront does with an error nothing else
|
|
3
|
+
* caught — which kind it is, whether it recovers on its own, and what reaches
|
|
4
|
+
* Sentry.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
buildUncaughtErrorReport,
|
|
8
|
+
canAttemptRecovery,
|
|
9
|
+
claimRecoveryAttempt,
|
|
10
|
+
classifyUncaughtError,
|
|
11
|
+
copyError,
|
|
12
|
+
decideUncaughtError,
|
|
13
|
+
RECOVERY_WINDOW_MS,
|
|
14
|
+
runUncaughtErrorRecovery
|
|
15
|
+
} from '../utils/uncaught-error';
|
|
16
|
+
|
|
17
|
+
const sentryInit = jest.fn();
|
|
18
|
+
const scopes: Array<Record<string, jest.Mock>> = [];
|
|
19
|
+
const captureException = jest.fn();
|
|
20
|
+
|
|
21
|
+
jest.mock('@sentry/nextjs', () => ({
|
|
22
|
+
init: (options: unknown) => sentryInit(options),
|
|
23
|
+
isInitialized: () => false,
|
|
24
|
+
withScope: (callback: (scope: unknown) => void) => {
|
|
25
|
+
const scope = {
|
|
26
|
+
setLevel: jest.fn(),
|
|
27
|
+
setTags: jest.fn(),
|
|
28
|
+
setExtra: jest.fn()
|
|
29
|
+
};
|
|
30
|
+
scopes.push(scope);
|
|
31
|
+
callback(scope);
|
|
32
|
+
},
|
|
33
|
+
captureException: (...args: unknown[]) => captureException(...args)
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// Effects and memos run as soon as the hook is called; no renderer is needed to
|
|
37
|
+
// see what they do.
|
|
38
|
+
jest.mock('react', () => ({
|
|
39
|
+
...jest.requireActual('react'),
|
|
40
|
+
useEffect: (effect: () => void) => effect(),
|
|
41
|
+
useMemo: (compute: () => unknown) => compute()
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const named = (name: string, message: string, stack?: string) => {
|
|
45
|
+
const error = new Error(message);
|
|
46
|
+
error.name = name;
|
|
47
|
+
if (stack !== undefined) error.stack = stack;
|
|
48
|
+
return error;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const memoryStorage = () => {
|
|
52
|
+
const values = new Map<string, string>();
|
|
53
|
+
return {
|
|
54
|
+
getItem: (key: string) => values.get(key) ?? null,
|
|
55
|
+
setItem: (key: string, value: string) => void values.set(key, value)
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe('classifying what the error boundary caught', () => {
|
|
60
|
+
it.each([
|
|
61
|
+
[
|
|
62
|
+
'webpack ChunkLoadError',
|
|
63
|
+
named(
|
|
64
|
+
'ChunkLoadError',
|
|
65
|
+
'Loading chunk 2390 failed.\n(error: https://www.lacoste.com.tr/_next/static/chunks/app/category/page-94a025687cec4f55.js)'
|
|
66
|
+
)
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
'a CSS chunk',
|
|
70
|
+
named(
|
|
71
|
+
'Error',
|
|
72
|
+
'Loading CSS chunk 8104 failed.\n(/_next/static/css/a.css)'
|
|
73
|
+
)
|
|
74
|
+
],
|
|
75
|
+
[
|
|
76
|
+
"Turbopack's wording",
|
|
77
|
+
named(
|
|
78
|
+
'Error',
|
|
79
|
+
'Failed to load chunk /_next/static/chunks/0f2b.js from module 4411'
|
|
80
|
+
)
|
|
81
|
+
]
|
|
82
|
+
])('%s is a stale chunk', (_label, error) => {
|
|
83
|
+
expect(classifyUncaughtError(error)).toBe('chunk-load');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it.each([
|
|
87
|
+
[
|
|
88
|
+
'Chrome removeChild',
|
|
89
|
+
named(
|
|
90
|
+
'NotFoundError',
|
|
91
|
+
"Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node."
|
|
92
|
+
)
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
'Chrome insertBefore',
|
|
96
|
+
named(
|
|
97
|
+
'NotFoundError',
|
|
98
|
+
"Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node."
|
|
99
|
+
)
|
|
100
|
+
],
|
|
101
|
+
[
|
|
102
|
+
'Firefox removeChild',
|
|
103
|
+
named(
|
|
104
|
+
'NotFoundError',
|
|
105
|
+
'Node.removeChild: The node to be removed is not a child of this node'
|
|
106
|
+
)
|
|
107
|
+
],
|
|
108
|
+
[
|
|
109
|
+
'Safari, which names the method only in the stack',
|
|
110
|
+
named(
|
|
111
|
+
'NotFoundError',
|
|
112
|
+
'The object can not be found here.',
|
|
113
|
+
'insertBefore@[native code]\naZ@https://tr.uspoloassn.com/_next/static/chunks/fd9d1056.js:1:76229'
|
|
114
|
+
)
|
|
115
|
+
]
|
|
116
|
+
])('%s is a DOM mutation race', (_label, error) => {
|
|
117
|
+
expect(classifyUncaughtError(error)).toBe('dom-mutation');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it.each([
|
|
121
|
+
[
|
|
122
|
+
'a TypeError',
|
|
123
|
+
new TypeError("Cannot read properties of undefined (reading 'id')")
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
'a NotFoundError from another API',
|
|
127
|
+
named('NotFoundError', 'Requested device not found')
|
|
128
|
+
],
|
|
129
|
+
[
|
|
130
|
+
"Safari's wording without a DOM method in the stack",
|
|
131
|
+
named(
|
|
132
|
+
'NotFoundError',
|
|
133
|
+
'The object can not be found here.',
|
|
134
|
+
'transaction@[native code]'
|
|
135
|
+
)
|
|
136
|
+
],
|
|
137
|
+
['a thrown string', 'boom'],
|
|
138
|
+
['nothing at all', undefined]
|
|
139
|
+
])('%s is an uncaught error', (_label, error) => {
|
|
140
|
+
expect(classifyUncaughtError(error)).toBe('uncaught');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('the one recovery attempt per window', () => {
|
|
145
|
+
const now = 1_000_000;
|
|
146
|
+
|
|
147
|
+
it('is granted once, then again after the window', () => {
|
|
148
|
+
const storage = memoryStorage();
|
|
149
|
+
|
|
150
|
+
expect(claimRecoveryAttempt('chunk-load', storage, now)).toBe(true);
|
|
151
|
+
expect(claimRecoveryAttempt('chunk-load', storage, now + 1000)).toBe(false);
|
|
152
|
+
expect(
|
|
153
|
+
claimRecoveryAttempt('chunk-load', storage, now + RECOVERY_WINDOW_MS)
|
|
154
|
+
).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('is counted per kind', () => {
|
|
158
|
+
const storage = memoryStorage();
|
|
159
|
+
|
|
160
|
+
expect(claimRecoveryAttempt('chunk-load', storage, now)).toBe(true);
|
|
161
|
+
expect(claimRecoveryAttempt('dom-mutation', storage, now)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('is never granted to an uncaught error, or without storage', () => {
|
|
165
|
+
expect(claimRecoveryAttempt('uncaught', memoryStorage(), now)).toBe(false);
|
|
166
|
+
expect(claimRecoveryAttempt('chunk-load', null, now)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('is not granted when storage refuses access, rather than looping', () => {
|
|
170
|
+
const refusing = {
|
|
171
|
+
getItem: () => {
|
|
172
|
+
throw new Error('SecurityError');
|
|
173
|
+
},
|
|
174
|
+
setItem: () => undefined
|
|
175
|
+
};
|
|
176
|
+
const readOnly = {
|
|
177
|
+
getItem: () => null,
|
|
178
|
+
setItem: () => {
|
|
179
|
+
throw new Error('QuotaExceededError');
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
expect(claimRecoveryAttempt('chunk-load', refusing, now)).toBe(false);
|
|
184
|
+
expect(claimRecoveryAttempt('chunk-load', readOnly, now)).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('can be looked at without being taken', () => {
|
|
188
|
+
const storage = memoryStorage();
|
|
189
|
+
|
|
190
|
+
expect(canAttemptRecovery('dom-mutation', storage, now)).toBe(true);
|
|
191
|
+
expect(canAttemptRecovery('dom-mutation', storage, now)).toBe(true);
|
|
192
|
+
expect(claimRecoveryAttempt('dom-mutation', storage, now)).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('the decision about one error', () => {
|
|
197
|
+
it('is taken once, and every caller hears the same answer', () => {
|
|
198
|
+
const storage = memoryStorage();
|
|
199
|
+
const error = named('ChunkLoadError', 'Loading chunk 1 failed.');
|
|
200
|
+
|
|
201
|
+
const first = decideUncaughtError(error, storage, 1000);
|
|
202
|
+
const second = decideUncaughtError(error, storage, 1000);
|
|
203
|
+
|
|
204
|
+
expect(first).toEqual({ kind: 'chunk-load', recovering: true });
|
|
205
|
+
expect(second).toBe(first);
|
|
206
|
+
// A second failure inside the window does not get another attempt.
|
|
207
|
+
expect(
|
|
208
|
+
decideUncaughtError(
|
|
209
|
+
named('ChunkLoadError', 'Loading chunk 1 failed.'),
|
|
210
|
+
storage,
|
|
211
|
+
2000
|
|
212
|
+
)
|
|
213
|
+
).toEqual({ kind: 'chunk-load', recovering: false });
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('carrying out a recovery', () => {
|
|
218
|
+
it('reloads the page once, however many callers ask', () => {
|
|
219
|
+
const reload = jest.fn();
|
|
220
|
+
|
|
221
|
+
for (const [error, kind] of [
|
|
222
|
+
[named('ChunkLoadError', 'Loading chunk 7 failed.'), 'chunk-load'],
|
|
223
|
+
[named('NotFoundError', 'x'), 'dom-mutation']
|
|
224
|
+
] as const) {
|
|
225
|
+
const decision = { kind, recovering: true };
|
|
226
|
+
runUncaughtErrorRecovery(error, decision, reload);
|
|
227
|
+
runUncaughtErrorRecovery(error, decision, reload);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
expect(reload).toHaveBeenCalledTimes(2);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('does nothing when the attempt was not granted', () => {
|
|
234
|
+
const reload = jest.fn();
|
|
235
|
+
|
|
236
|
+
runUncaughtErrorRecovery(
|
|
237
|
+
named('ChunkLoadError', 'x'),
|
|
238
|
+
{ kind: 'chunk-load', recovering: false },
|
|
239
|
+
reload
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(reload).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('what the report says', () => {
|
|
247
|
+
it('reports a crash as fatal, grouped by the error itself', () => {
|
|
248
|
+
const error = new TypeError('x is undefined');
|
|
249
|
+
const report = buildUncaughtErrorReport(error, {
|
|
250
|
+
kind: 'uncaught',
|
|
251
|
+
recovering: false
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(report.level).toBe('fatal');
|
|
255
|
+
expect(report.fingerprint).toEqual([
|
|
256
|
+
'UNCAUGHT_ERROR_PAGE',
|
|
257
|
+
'{{ default }}'
|
|
258
|
+
]);
|
|
259
|
+
expect(report.tags).toEqual({
|
|
260
|
+
UNCAUGHT_ERROR_KIND: 'uncaught',
|
|
261
|
+
RECOVERY: 'none'
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('keeps grouping a server error by its digest', () => {
|
|
266
|
+
const error = Object.assign(
|
|
267
|
+
new Error('An error occurred in the Server Components render.'),
|
|
268
|
+
{
|
|
269
|
+
digest: '3141592653'
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(
|
|
274
|
+
buildUncaughtErrorReport(error, { kind: 'uncaught', recovering: false })
|
|
275
|
+
.fingerprint
|
|
276
|
+
).toEqual(['UNCAUGHT_ERROR_PAGE', '3141592653']);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('reports a DOM mutation race as a warning, under one issue', () => {
|
|
280
|
+
const report = buildUncaughtErrorReport(named('NotFoundError', 'x'), {
|
|
281
|
+
kind: 'dom-mutation',
|
|
282
|
+
recovering: true
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(report.level).toBe('warning');
|
|
286
|
+
expect(report.fingerprint).toEqual(['UNCAUGHT_ERROR_PAGE', 'dom-mutation']);
|
|
287
|
+
expect(report.tags.RECOVERY).toBe('attempted');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('sends a copy of the error, name, message and stack intact', () => {
|
|
291
|
+
const error = named(
|
|
292
|
+
'NotFoundError',
|
|
293
|
+
'gone',
|
|
294
|
+
'NotFoundError: gone\n at a2 (x.js:1:1)'
|
|
295
|
+
);
|
|
296
|
+
const copy = copyError(error);
|
|
297
|
+
|
|
298
|
+
expect(copy).not.toBe(error);
|
|
299
|
+
expect([copy.name, copy.message, copy.stack]).toEqual([
|
|
300
|
+
error.name,
|
|
301
|
+
error.message,
|
|
302
|
+
error.stack
|
|
303
|
+
]);
|
|
304
|
+
expect(copyError('boom').message).toBe('boom');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('the client filter, with the real error in hand', () => {
|
|
309
|
+
const beforeSend = () => {
|
|
310
|
+
sentryInit.mockClear();
|
|
311
|
+
const { initSentry } = require('../sentry');
|
|
312
|
+
initSentry('Client', { dsn: 'https://key@sentry.invalid/1' });
|
|
313
|
+
return sentryInit.mock.calls[0][0].beforeSend as (
|
|
314
|
+
event: unknown,
|
|
315
|
+
hint: unknown
|
|
316
|
+
) => unknown;
|
|
317
|
+
};
|
|
318
|
+
const errorPageEvent = { tags: { LOG_TYPE: 'UNCAUGHT_ERROR_PAGE' } };
|
|
319
|
+
|
|
320
|
+
it('drops a stale chunk the error page reports', () => {
|
|
321
|
+
const send = beforeSend();
|
|
322
|
+
|
|
323
|
+
for (const message of [
|
|
324
|
+
'Loading chunk 2390 failed.',
|
|
325
|
+
'Loading CSS chunk 12 failed.',
|
|
326
|
+
'Failed to load chunk /_next/static/chunks/a.js'
|
|
327
|
+
]) {
|
|
328
|
+
expect(
|
|
329
|
+
send(errorPageEvent, {
|
|
330
|
+
originalException: copyError(named('ChunkLoadError', message))
|
|
331
|
+
})
|
|
332
|
+
).toBeNull();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('lets a DOM mutation race and a crash through', () => {
|
|
337
|
+
const send = beforeSend();
|
|
338
|
+
|
|
339
|
+
expect(
|
|
340
|
+
send(errorPageEvent, {
|
|
341
|
+
originalException: copyError(
|
|
342
|
+
named(
|
|
343
|
+
'NotFoundError',
|
|
344
|
+
"Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node."
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
})
|
|
348
|
+
).toBe(errorPageEvent);
|
|
349
|
+
expect(
|
|
350
|
+
send(errorPageEvent, {
|
|
351
|
+
originalException: new TypeError('x is undefined')
|
|
352
|
+
})
|
|
353
|
+
).toBe(errorPageEvent);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('still drops what the error page did not report', () => {
|
|
357
|
+
expect(
|
|
358
|
+
beforeSend()({ tags: {} }, { originalException: new TypeError('x') })
|
|
359
|
+
).toBeNull();
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('the error boundary hook', () => {
|
|
364
|
+
const load = () => {
|
|
365
|
+
return require('../hooks/use-sentry-uncaught-errors')
|
|
366
|
+
.useSentryUncaughtErrors as (error: Error) => void;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
beforeEach(() => {
|
|
370
|
+
captureException.mockClear();
|
|
371
|
+
scopes.length = 0;
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('reports the error itself, not a stand-in, once', () => {
|
|
375
|
+
const error = named(
|
|
376
|
+
'NotFoundError',
|
|
377
|
+
"Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node."
|
|
378
|
+
);
|
|
379
|
+
const useSentryUncaughtErrors = load();
|
|
380
|
+
|
|
381
|
+
useSentryUncaughtErrors(error);
|
|
382
|
+
useSentryUncaughtErrors(error);
|
|
383
|
+
|
|
384
|
+
expect(captureException).toHaveBeenCalledTimes(1);
|
|
385
|
+
const [sent, hint] = captureException.mock.calls[0];
|
|
386
|
+
expect([sent.name, sent.message]).toEqual([error.name, error.message]);
|
|
387
|
+
expect(hint).toEqual({
|
|
388
|
+
fingerprint: ['UNCAUGHT_ERROR_PAGE', 'dom-mutation']
|
|
389
|
+
});
|
|
390
|
+
expect(scopes[0].setLevel).toHaveBeenCalledWith('warning');
|
|
391
|
+
expect(scopes[0].setTags).toHaveBeenCalledWith(
|
|
392
|
+
expect.objectContaining({
|
|
393
|
+
LOG_TYPE: 'UNCAUGHT_ERROR_PAGE',
|
|
394
|
+
UNCAUGHT_ERROR_KIND: 'dom-mutation'
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('keeps a crash fatal', () => {
|
|
400
|
+
const useSentryUncaughtErrors = load();
|
|
401
|
+
|
|
402
|
+
useSentryUncaughtErrors(
|
|
403
|
+
new TypeError("Cannot read properties of null (reading 'map')")
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
expect(scopes[0].setLevel).toHaveBeenCalledWith('fatal');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('reloads a tab that asked for a chunk of the previous build, once per window', () => {
|
|
410
|
+
const reload = jest.fn();
|
|
411
|
+
const storage = memoryStorage();
|
|
412
|
+
(globalThis as unknown as { window: unknown }).window = {
|
|
413
|
+
sessionStorage: storage,
|
|
414
|
+
location: { reload }
|
|
415
|
+
};
|
|
416
|
+
const useSentryUncaughtErrors = load();
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
useSentryUncaughtErrors(
|
|
420
|
+
named('ChunkLoadError', 'Loading chunk 9393 failed.')
|
|
421
|
+
);
|
|
422
|
+
useSentryUncaughtErrors(
|
|
423
|
+
named('ChunkLoadError', 'Loading chunk 9393 failed.')
|
|
424
|
+
);
|
|
425
|
+
} finally {
|
|
426
|
+
delete (globalThis as unknown as { window?: unknown }).window;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe('the error page hook', () => {
|
|
434
|
+
const load = () =>
|
|
435
|
+
require('../hooks/use-uncaught-error-recovery')
|
|
436
|
+
.useUncaughtErrorRecovery as (error: Error) => boolean;
|
|
437
|
+
|
|
438
|
+
const withWindow = (run: (reload: jest.Mock) => void) => {
|
|
439
|
+
const reload = jest.fn();
|
|
440
|
+
(globalThis as unknown as { window: unknown }).window = {
|
|
441
|
+
sessionStorage: memoryStorage(),
|
|
442
|
+
location: { reload }
|
|
443
|
+
};
|
|
444
|
+
try {
|
|
445
|
+
run(reload);
|
|
446
|
+
} finally {
|
|
447
|
+
delete (globalThis as unknown as { window?: unknown }).window;
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const domMutation = () =>
|
|
452
|
+
named(
|
|
453
|
+
'NotFoundError',
|
|
454
|
+
"Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node."
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
it('draws nothing while a DOM mutation race reloads the page, once', () => {
|
|
458
|
+
withWindow((reload) => {
|
|
459
|
+
const useUncaughtErrorRecovery = load();
|
|
460
|
+
|
|
461
|
+
expect(useUncaughtErrorRecovery(domMutation())).toBe(true);
|
|
462
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
463
|
+
|
|
464
|
+
// The same race again inside the window shows the error page.
|
|
465
|
+
expect(useUncaughtErrorRecovery(domMutation())).toBe(false);
|
|
466
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('draws nothing while a stale chunk reloads the page', () => {
|
|
471
|
+
withWindow((reload) => {
|
|
472
|
+
expect(load()(named('ChunkLoadError', 'Loading chunk 4 failed.'))).toBe(
|
|
473
|
+
true
|
|
474
|
+
);
|
|
475
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('shows the error page for a crash', () => {
|
|
480
|
+
withWindow((reload) => {
|
|
481
|
+
expect(load()(new TypeError('x is undefined'))).toBe(false);
|
|
482
|
+
expect(reload).not.toHaveBeenCalled();
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('shows the error page on the server, where no attempt can be recorded', () => {
|
|
487
|
+
expect(load()(domMutation())).toBe(false);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
|
-
import { useMobileIframeHandler } from '../hooks';
|
|
4
|
+
import { useChunkLoadRecovery, useMobileIframeHandler } from '../hooks';
|
|
5
5
|
import { LoggerPopup } from './logger-popup';
|
|
6
6
|
import { LoggerProvider } from '../hooks/use-logger-context';
|
|
7
7
|
import * as Sentry from '@sentry/nextjs';
|
|
@@ -19,6 +19,8 @@ export default function ClientRoot({
|
|
|
19
19
|
sessionId: sessionId || ''
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
useChunkLoadRecovery();
|
|
23
|
+
|
|
22
24
|
const initializeSentry = async () => {
|
|
23
25
|
const response = await fetch('/api/sentry', { next: { revalidate: 0 } });
|
|
24
26
|
const data = await response.json();
|
package/hooks/index.ts
CHANGED
|
@@ -13,4 +13,6 @@ export * from './use-message-listener';
|
|
|
13
13
|
export * from './use-logger';
|
|
14
14
|
export * from './use-logger-context';
|
|
15
15
|
export * from './use-sentry-uncaught-errors';
|
|
16
|
+
export * from './use-uncaught-error-recovery';
|
|
17
|
+
export * from './use-chunk-load-recovery';
|
|
16
18
|
export * from './use-pz-params';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
classifyUncaughtError,
|
|
4
|
+
decideUncaughtError,
|
|
5
|
+
runUncaughtErrorRecovery
|
|
6
|
+
} from '../utils/uncaught-error';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reloads once when a chunk of the previous build fails to load and no error
|
|
10
|
+
* boundary catches it.
|
|
11
|
+
*
|
|
12
|
+
* A client-side navigation that needs a route chunk after a deploy can throw
|
|
13
|
+
* its ChunkLoadError outside every boundary — Next.js then shows its own
|
|
14
|
+
* "This page couldn't load" screen, and the `[pz]` error page never learns of
|
|
15
|
+
* it. The same once-per-window attempt the error page uses applies here, so
|
|
16
|
+
* the two never reload twice for one failure, and a build that really lost
|
|
17
|
+
* the chunk is not reloaded forever.
|
|
18
|
+
*/
|
|
19
|
+
export const useChunkLoadRecovery = () => {
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const recover = (error: unknown) => {
|
|
22
|
+
if (classifyUncaughtError(error) !== 'chunk-load') return;
|
|
23
|
+
|
|
24
|
+
runUncaughtErrorRecovery(error, decideUncaughtError(error));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const onError = (event: ErrorEvent) => recover(event.error);
|
|
28
|
+
const onRejection = (event: PromiseRejectionEvent) => recover(event.reason);
|
|
29
|
+
|
|
30
|
+
window.addEventListener('error', onError);
|
|
31
|
+
window.addEventListener('unhandledrejection', onRejection);
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
window.removeEventListener('error', onError);
|
|
35
|
+
window.removeEventListener('unhandledrejection', onRejection);
|
|
36
|
+
};
|
|
37
|
+
}, []);
|
|
38
|
+
};
|
|
@@ -1,24 +1,54 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
import * as Sentry from '@sentry/nextjs';
|
|
3
3
|
import { ClientLogType } from '@akinon/next/sentry';
|
|
4
|
+
import {
|
|
5
|
+
buildUncaughtErrorReport,
|
|
6
|
+
decideUncaughtError,
|
|
7
|
+
runUncaughtErrorRecovery,
|
|
8
|
+
UncaughtErrorDecision
|
|
9
|
+
} from '../utils/uncaught-error';
|
|
4
10
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const reported = new WeakSet<object>();
|
|
12
|
+
|
|
13
|
+
const reportUncaughtError = (
|
|
14
|
+
error: Error & { digest?: string },
|
|
15
|
+
decision: UncaughtErrorDecision
|
|
16
|
+
) => {
|
|
17
|
+
// Once per error object: a re-render or a strict-mode effect re-run is not a
|
|
18
|
+
// second occurrence.
|
|
19
|
+
if (reported.has(error)) return;
|
|
20
|
+
reported.add(error);
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
const report = buildUncaughtErrorReport(error, decision);
|
|
23
|
+
|
|
24
|
+
Sentry.withScope(function (scope) {
|
|
25
|
+
scope.setLevel(report.level);
|
|
26
|
+
scope.setTags({
|
|
27
|
+
APP_TYPE: 'ProjectZeroNext',
|
|
28
|
+
TYPE: 'Client',
|
|
29
|
+
LOG_TYPE: ClientLogType.UNCAUGHT_ERROR_PAGE,
|
|
30
|
+
...report.tags
|
|
31
|
+
});
|
|
32
|
+
scope.setExtra('error', error);
|
|
18
33
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
// The error itself, not a stand-in: Sentry groups by what actually broke,
|
|
35
|
+
// and the client filter (`isNetworkError`) can recognise a stale chunk.
|
|
36
|
+
Sentry.captureException(report.error, {
|
|
37
|
+
fingerprint: report.fingerprint
|
|
22
38
|
});
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reports an error the `[pz]` error boundary caught, and reloads the page for
|
|
44
|
+
* the kinds that recover on their own — a stale chunk, a DOM mutation race
|
|
45
|
+
* (see `utils/uncaught-error`).
|
|
46
|
+
*/
|
|
47
|
+
export const useSentryUncaughtErrors = (error: Error & { digest?: string }) => {
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const decision = decideUncaughtError(error);
|
|
50
|
+
|
|
51
|
+
reportUncaughtError(error, decision);
|
|
52
|
+
runUncaughtErrorRecovery(error, decision);
|
|
23
53
|
}, [error]);
|
|
24
54
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
decideUncaughtError,
|
|
4
|
+
runUncaughtErrorRecovery
|
|
5
|
+
} from '../utils/uncaught-error';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether the page is reloading to recover from `error` — a stale chunk or a
|
|
9
|
+
* DOM mutation race (see `utils/uncaught-error`). While it does, the error page
|
|
10
|
+
* should draw nothing: the 500 page would only flash before the reload. When
|
|
11
|
+
* the recovery attempt is spent, this is false and the page shows.
|
|
12
|
+
*
|
|
13
|
+
* The decision is taken while rendering, so the first render already knows.
|
|
14
|
+
* Taking it twice is safe: it is made once per error object and every later
|
|
15
|
+
* call returns the same answer. On the server there is no storage to record
|
|
16
|
+
* an attempt in, so it is always false there.
|
|
17
|
+
*/
|
|
18
|
+
export const useUncaughtErrorRecovery = (
|
|
19
|
+
error: Error & { digest?: string }
|
|
20
|
+
): boolean => {
|
|
21
|
+
const decision = useMemo(() => decideUncaughtError(error), [error]);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
runUncaughtErrorRecovery(error, decision);
|
|
25
|
+
}, [error, decision]);
|
|
26
|
+
|
|
27
|
+
return decision.recovering;
|
|
28
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/next",
|
|
3
3
|
"description": "Core package for Project Zero Next",
|
|
4
|
-
"version": "1.126.13
|
|
4
|
+
"version": "1.126.13",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"set-cookie-parser": "2.6.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@akinon/eslint-plugin-projectzero": "1.126.13
|
|
38
|
+
"@akinon/eslint-plugin-projectzero": "1.126.13",
|
|
39
39
|
"@babel/core": "7.26.10",
|
|
40
40
|
"@babel/preset-env": "7.26.9",
|
|
41
41
|
"@babel/preset-typescript": "7.27.0",
|
package/sentry/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as Sentry from '@sentry/nextjs';
|
|
2
|
+
import { classifyUncaughtError } from '../utils/uncaught-error';
|
|
2
3
|
|
|
3
4
|
const SENTRY_DSN: string | undefined =
|
|
4
5
|
process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
|
@@ -27,6 +28,10 @@ const isNetworkError = (exception: unknown): boolean => {
|
|
|
27
28
|
|
|
28
29
|
if (exception.name === 'NetworkError') return true;
|
|
29
30
|
|
|
31
|
+
// A stale build's chunk — including a CSS chunk, and Turbopack's wording —
|
|
32
|
+
// the same way the error boundary recognises it.
|
|
33
|
+
if (classifyUncaughtError(exception) === 'chunk-load') return true;
|
|
34
|
+
|
|
30
35
|
if (exception.name === 'TypeError') {
|
|
31
36
|
return networkErrorPatterns.some((pattern) =>
|
|
32
37
|
exception.message.toLowerCase().includes(pattern)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the storefront does with an error nothing else caught.
|
|
3
|
+
*
|
|
4
|
+
* The `[pz]` error boundary used to treat every error the same way: report a
|
|
5
|
+
* synthetic `FATAL: Uncaught client error` to Sentry and show the 500 page. Two
|
|
6
|
+
* kinds of error are neither fatal nor the page's fault, and both recover on
|
|
7
|
+
* their own:
|
|
8
|
+
*
|
|
9
|
+
* - `chunk-load`: a tab still running the previous build asked for one of its
|
|
10
|
+
* chunks after a deploy removed it. Nothing is broken; a reload fetches the
|
|
11
|
+
* new build. It is not reported: the Sentry filter drops it by design.
|
|
12
|
+
* - `dom-mutation`: React found a node it manages moved or removed behind its
|
|
13
|
+
* back — Chrome's page translation, an extension, a third-party script — and
|
|
14
|
+
* `removeChild`/`insertBefore` threw while it committed. A fresh document
|
|
15
|
+
* gets past it. Reported as a warning, under one issue.
|
|
16
|
+
*
|
|
17
|
+
* Both recover by reloading the page. For a DOM mutation, re-rendering just
|
|
18
|
+
* the segment (the boundary's `reset`) would be lighter, but it does not hold:
|
|
19
|
+
* a commit that failed half way leaves the App Router's own state behind it
|
|
20
|
+
* inconsistent, and on a Next.js 16 production build its next render throws
|
|
21
|
+
* React #310 ("Rendered more hooks than during the previous render") at the
|
|
22
|
+
* root — Next.js's "This page couldn't load" instead of the page. The URL
|
|
23
|
+
* already points where the user was going, so the reload lands there.
|
|
24
|
+
*
|
|
25
|
+
* Everything else is `uncaught`: a real crash, reported as fatal and grouped by
|
|
26
|
+
* the error itself.
|
|
27
|
+
*
|
|
28
|
+
* Recovery is tried once per kind per window, so an error that survives it —
|
|
29
|
+
* a deploy that really lost its chunks, a page that keeps being rewritten —
|
|
30
|
+
* shows the error page instead of reloading in a loop.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export type UncaughtErrorKind = 'chunk-load' | 'dom-mutation' | 'uncaught';
|
|
34
|
+
|
|
35
|
+
export interface UncaughtErrorDecision {
|
|
36
|
+
kind: UncaughtErrorKind;
|
|
37
|
+
/** Whether this error gets its one recovery attempt. */
|
|
38
|
+
recovering: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type RecoveryStorage = Pick<Storage, 'getItem' | 'setItem'>;
|
|
42
|
+
|
|
43
|
+
export const RECOVERY_WINDOW_MS = 30_000;
|
|
44
|
+
|
|
45
|
+
const RECOVERY_KEY_PREFIX = 'pz-uncaught-error-recovery:';
|
|
46
|
+
|
|
47
|
+
// webpack: "Loading chunk 2390 failed." / "Loading CSS chunk 12 failed.";
|
|
48
|
+
// Turbopack: "Failed to load chunk static/chunks/…".
|
|
49
|
+
const CHUNK_LOAD_PATTERNS = [
|
|
50
|
+
/loading (css )?chunk \S+ failed/i,
|
|
51
|
+
/failed to load chunk/i
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const DOM_MUTATION_METHODS = /\b(removeChild|insertBefore)\b/;
|
|
55
|
+
// Chrome and Firefox name the method in the message; Safari only says "The
|
|
56
|
+
// object can not be found here." and names it in the stack.
|
|
57
|
+
const DOM_MUTATION_MESSAGES = [
|
|
58
|
+
/not a child of this node/i,
|
|
59
|
+
/the object can not be found here/i
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const readText = (value: unknown): string =>
|
|
63
|
+
typeof value === 'string' ? value : '';
|
|
64
|
+
|
|
65
|
+
export const classifyUncaughtError = (error: unknown): UncaughtErrorKind => {
|
|
66
|
+
if (!error || typeof error !== 'object') return 'uncaught';
|
|
67
|
+
|
|
68
|
+
const { name, message, stack } = error as Record<string, unknown>;
|
|
69
|
+
const errorName = readText(name);
|
|
70
|
+
const errorMessage = readText(message);
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
errorName === 'ChunkLoadError' ||
|
|
74
|
+
CHUNK_LOAD_PATTERNS.some((pattern) => pattern.test(errorMessage))
|
|
75
|
+
) {
|
|
76
|
+
return 'chunk-load';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
errorName === 'NotFoundError' &&
|
|
81
|
+
(DOM_MUTATION_METHODS.test(errorMessage) ||
|
|
82
|
+
DOM_MUTATION_METHODS.test(readText(stack))) &&
|
|
83
|
+
DOM_MUTATION_MESSAGES.some((pattern) => pattern.test(errorMessage))
|
|
84
|
+
) {
|
|
85
|
+
return 'dom-mutation';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return 'uncaught';
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** sessionStorage, or null where it is missing or refuses access. */
|
|
92
|
+
export const readSessionStorage = (): RecoveryStorage | null => {
|
|
93
|
+
try {
|
|
94
|
+
return typeof window !== 'undefined' && window.sessionStorage
|
|
95
|
+
? window.sessionStorage
|
|
96
|
+
: null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Whether a recovery of this kind would be allowed now. Reads only. */
|
|
103
|
+
export const canAttemptRecovery = (
|
|
104
|
+
kind: UncaughtErrorKind,
|
|
105
|
+
storage: RecoveryStorage | null,
|
|
106
|
+
now: number = Date.now()
|
|
107
|
+
): boolean => {
|
|
108
|
+
if (kind === 'uncaught' || !storage) return false;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const last = storage.getItem(`${RECOVERY_KEY_PREFIX}${kind}`);
|
|
112
|
+
return !last || now - Number(last) >= RECOVERY_WINDOW_MS;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Takes this kind's recovery attempt for the window, if it is free. Without
|
|
120
|
+
* storage there is no way to know a reload already happened, so nothing is
|
|
121
|
+
* attempted rather than risking a loop.
|
|
122
|
+
*/
|
|
123
|
+
export const claimRecoveryAttempt = (
|
|
124
|
+
kind: UncaughtErrorKind,
|
|
125
|
+
storage: RecoveryStorage | null,
|
|
126
|
+
now: number = Date.now()
|
|
127
|
+
): boolean => {
|
|
128
|
+
if (!canAttemptRecovery(kind, storage, now)) return false;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
(storage as RecoveryStorage).setItem(
|
|
132
|
+
`${RECOVERY_KEY_PREFIX}${kind}`,
|
|
133
|
+
String(now)
|
|
134
|
+
);
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// One decision per error object. The boundary's hook and the error page both
|
|
142
|
+
// ask about the same error; the first to ask takes the attempt, the other
|
|
143
|
+
// hears the same answer.
|
|
144
|
+
const decisions = new WeakMap<object, UncaughtErrorDecision>();
|
|
145
|
+
|
|
146
|
+
export const decideUncaughtError = (
|
|
147
|
+
error: unknown,
|
|
148
|
+
storage: RecoveryStorage | null = readSessionStorage(),
|
|
149
|
+
now: number = Date.now()
|
|
150
|
+
): UncaughtErrorDecision => {
|
|
151
|
+
const cacheable = !!error && typeof error === 'object';
|
|
152
|
+
const cached = cacheable ? decisions.get(error as object) : undefined;
|
|
153
|
+
if (cached) return cached;
|
|
154
|
+
|
|
155
|
+
const kind = classifyUncaughtError(error);
|
|
156
|
+
const decision = {
|
|
157
|
+
kind,
|
|
158
|
+
recovering: claimRecoveryAttempt(kind, storage, now)
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (cacheable) decisions.set(error as object, decision);
|
|
162
|
+
return decision;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const recovered = new WeakSet<object>();
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Carries out a recovery the decision granted: reloads the page, once per
|
|
169
|
+
* error however many callers ask.
|
|
170
|
+
*/
|
|
171
|
+
export const runUncaughtErrorRecovery = (
|
|
172
|
+
error: unknown,
|
|
173
|
+
decision: UncaughtErrorDecision,
|
|
174
|
+
reload: () => void = () => window.location.reload()
|
|
175
|
+
): void => {
|
|
176
|
+
if (!decision.recovering || !error || typeof error !== 'object') return;
|
|
177
|
+
if (recovered.has(error as object)) return;
|
|
178
|
+
|
|
179
|
+
recovered.add(error as object);
|
|
180
|
+
reload();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export interface UncaughtErrorReport {
|
|
184
|
+
/** A copy of the error, so Sentry sees it as new; see `copyError`. */
|
|
185
|
+
error: Error;
|
|
186
|
+
level: 'fatal' | 'warning';
|
|
187
|
+
fingerprint: string[];
|
|
188
|
+
tags: Record<string, string>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A fresh error with the same name, message and stack. Sentry skips an error
|
|
193
|
+
* object it has captured before, and the global handlers may have captured
|
|
194
|
+
* this one on its way to the boundary — only for `beforeSend` to drop it,
|
|
195
|
+
* since it lets through the error page's reports alone.
|
|
196
|
+
*/
|
|
197
|
+
export const copyError = (error: unknown): Error => {
|
|
198
|
+
if (!(error instanceof Error)) return new Error(String(error));
|
|
199
|
+
|
|
200
|
+
const copy = new Error(error.message);
|
|
201
|
+
copy.name = error.name;
|
|
202
|
+
copy.stack = error.stack;
|
|
203
|
+
return copy;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export const buildUncaughtErrorReport = (
|
|
207
|
+
error: Error & { digest?: string },
|
|
208
|
+
decision: UncaughtErrorDecision
|
|
209
|
+
): UncaughtErrorReport => {
|
|
210
|
+
const fingerprint =
|
|
211
|
+
decision.kind !== 'uncaught'
|
|
212
|
+
? ['UNCAUGHT_ERROR_PAGE', decision.kind]
|
|
213
|
+
: error.digest
|
|
214
|
+
? // A server error reaches the client with a generic message; its
|
|
215
|
+
// digest is what tells one apart from another.
|
|
216
|
+
['UNCAUGHT_ERROR_PAGE', error.digest]
|
|
217
|
+
: ['UNCAUGHT_ERROR_PAGE', '{{ default }}'];
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
error: copyError(error),
|
|
221
|
+
level: decision.kind === 'uncaught' ? 'fatal' : 'warning',
|
|
222
|
+
fingerprint,
|
|
223
|
+
tags: {
|
|
224
|
+
UNCAUGHT_ERROR_KIND: decision.kind,
|
|
225
|
+
RECOVERY: decision.recovering ? 'attempted' : 'none'
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
};
|
package/views/error-page.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
-
import { useLocalization } from '../hooks';
|
|
2
|
+
import { useLocalization, useUncaughtErrorRecovery } from '../hooks';
|
|
3
3
|
import { Button, Link } from '../components';
|
|
4
4
|
import { ROUTES } from 'routes';
|
|
5
5
|
|
|
@@ -36,6 +36,11 @@ function ClientErrorUI({
|
|
|
36
36
|
reset: () => void;
|
|
37
37
|
}) {
|
|
38
38
|
const { t } = useLocalization();
|
|
39
|
+
// A stale chunk or a DOM mutation race reloads the page; the 500 page would
|
|
40
|
+
// only flash before it.
|
|
41
|
+
const recovering = useUncaughtErrorRecovery(error);
|
|
42
|
+
|
|
43
|
+
if (recovering) return null;
|
|
39
44
|
|
|
40
45
|
const errorMessage = error?.message || 'Unknown error';
|
|
41
46
|
|