@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 CHANGED
@@ -1,11 +1,10 @@
1
1
  # @akinon/next
2
2
 
3
- ## 1.126.13-v1-rc.9
3
+ ## 1.126.13
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - d480941: ZERO-4399: add card rewards to pz-masterpass-rest
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
- export const useSentryUncaughtErrors = (error: Error & { digest?: string }) => {
6
- useEffect(() => {
7
- Sentry.withScope(function (scope) {
8
- scope.setLevel('fatal');
9
- scope.setTags({
10
- APP_TYPE: 'ProjectZeroNext',
11
- TYPE: 'Client',
12
- LOG_TYPE: ClientLogType.UNCAUGHT_ERROR_PAGE
13
- });
14
- scope.setExtra('error', error);
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
- const error_ = new Error('FATAL: Uncaught client error');
17
- error_.name = 'UNCAUGHT_ERROR_PAGE';
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
- Sentry.captureException(error_, {
20
- fingerprint: ['UNCAUGHT_ERROR_PAGE', error.digest]
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-v1-rc.9",
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-v1-rc.9",
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
+ };
@@ -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