querysub 0.438.0 → 0.439.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.438.0",
3
+ "version": "0.439.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -62,7 +62,7 @@
62
62
  "pako": "^2.1.0",
63
63
  "peggy": "^5.0.6",
64
64
  "querysub": "^0.357.0",
65
- "socket-function": "^1.1.20",
65
+ "socket-function": "^1.1.21",
66
66
  "terser": "^5.31.0",
67
67
  "typesafecss": "^0.28.0",
68
68
  "yaml": "^2.5.0",
@@ -1,12 +1,15 @@
1
1
  import { sha256 } from "js-sha256";
2
2
  import { getArchives } from "../../-a-archives/archives";
3
3
  import { getAllNodeIds, getOwnThreadId } from "../../-f-node-discovery/NodeDiscovery";
4
- import { pathValueArchives } from "../pathValueArchives";
4
+ import { archives, pathValueArchives } from "../pathValueArchives";
5
5
  import { ignoreErrors, logErrors, timeoutToUndefinedSilent } from "../../errors";
6
6
  import { green, magenta } from "socket-function/src/formatting/logColors";
7
7
  import { devDebugbreak, isPublic } from "../../config";
8
8
  import { sort } from "socket-function/src/misc";
9
9
  import { lazy } from "socket-function/src/caching";
10
+ import { decodeCborx, encodeCborx } from "../../misc/cloneHelpers";
11
+ import { runInParallel } from "socket-function/src/batching";
12
+ import { formatNumber } from "socket-function/src/formatting/format";
10
13
 
11
14
  const snapshots = lazy(() => getArchives("snapshots"));
12
15
 
@@ -140,6 +143,39 @@ async function getSnapshotBase(snapshotFile: string | "live") {
140
143
  };
141
144
  }
142
145
 
146
+ export interface ArchiveSnapshotPayloadFile {
147
+ path: string;
148
+ data: Buffer;
149
+ }
150
+
151
+ export async function downloadSnapshot(snapshotFile: string | "live"): Promise<Buffer> {
152
+ let { files } = await getSnapshotBase(snapshotFile);
153
+ let buffers = await pathValueArchives.getRawValueFiles(files, { includeRecycleBin: true });
154
+ let payload: ArchiveSnapshotPayloadFile[] = [];
155
+ for (let i = 0; i < files.length; i++) {
156
+ let data = buffers[i];
157
+ if (!data) continue;
158
+ payload.push({ path: files[i], data });
159
+ }
160
+ return encodeCborx(payload);
161
+ }
162
+
163
+ export async function uploadSnapshot(buffer: Buffer): Promise<ArchiveSnapshotOverview> {
164
+ let payload = decodeCborx<ArchiveSnapshotPayloadFile[]>(buffer);
165
+ let liveArchives = archives();
166
+ async function uploadFile(path: string, data: Buffer) {
167
+ let existing = await liveArchives.getInfo(path);
168
+ if (existing) return;
169
+ console.log(`Creating missing file (${formatNumber(data.length)}B): ${path}`);
170
+ await liveArchives.set(path, Buffer.from(data));
171
+ }
172
+ let parallel = runInParallel({
173
+ parallelCount: 32,
174
+ }, uploadFile);
175
+ await Promise.all(payload.map(file => parallel(file.path, file.data)));
176
+ return await saveSnapshot({ files: payload.map(x => x.path) });
177
+ }
178
+
143
179
  // EXTREME DANGEROUS! Should only be done when all servers are down (except one, which
144
180
  // is run on a developers machine). Worse case scenario, where everything is broken,
145
181
  // and you need to just go back to a previous state.
@@ -394,6 +394,18 @@ export function debugPathValue(pathValue: PathValue, write?: boolean): string {
394
394
  export function debugTime(time: Time): string {
395
395
  return `${time.time}[${time.version}]@${time.creatorId.toString().slice(4, 12)}`;
396
396
  }
397
+ export function parseDebugTime(debugTime: string): {
398
+ time: number;
399
+ version: number;
400
+ creatorIdPart: string;
401
+ } {
402
+ let parts = debugTime.split(/[\[\]\@]/g);
403
+ return {
404
+ time: +(parts[0]),
405
+ version: +(parts[1]),
406
+ creatorIdPart: parts[2],
407
+ };
408
+ }
397
409
 
398
410
 
399
411
  let getCompressNetworkBase = () => false;
@@ -9,7 +9,7 @@ import { getPathFromStr, getPathIndex } from "../path";
9
9
  import { rawSchema } from "../2-proxy/pathDatabaseProxyBase";
10
10
  import { getProxyPath } from "../2-proxy/pathValueProxy";
11
11
  import { atomicObjectRead, atomicObjectWrite, doProxyOptions, isProxyBlockedByOrder, isSynced, proxyWatcher } from "../2-proxy/PathValueProxyWatcher";
12
- import { authorityStorage, compareTime, debugTime, MAX_ACCEPTED_CHANGE_AGE, PathValue, Time } from "../0-path-value-core/pathValueCore";
12
+ import { authorityStorage, compareTime, debugTime, MAX_ACCEPTED_CHANGE_AGE, parseDebugTime, PathValue, Time } from "../0-path-value-core/pathValueCore";
13
13
  import { getModuleFromSpec } from "./pathFunctionLoader";
14
14
  import debugbreak from "debugbreak";
15
15
  import { parseArgs } from "./PathFunctionHelpers";
@@ -164,6 +164,15 @@ function getDebugName(call: CallSpec, functionSpec: FunctionSpec | undefined, co
164
164
  return `${mainPart}`;
165
165
  }
166
166
  }
167
+ export function parseDebugName(debugName: string) {
168
+ let parts = debugName.split("|");
169
+ if (parts.length !== 3) return undefined;
170
+ return {
171
+ file: parts[0],
172
+ functionId: parts[1],
173
+ runAtTime: parseDebugTime(parts[2]),
174
+ };
175
+ }
167
176
 
168
177
  type PermissionsCheckType = {
169
178
  new(callerMachineId: { callerMachineId: string; callerIP: string; }): {
@@ -531,7 +540,9 @@ export class PathFunctionRunner {
531
540
  return;
532
541
  }
533
542
 
534
- console.info(`New function call: ${getDebugName(callSpec, functionSpec, true)}`, {
543
+ let debugNameColored = getDebugName(callSpec, functionSpec, true);
544
+ let debugName = getDebugName(callSpec, functionSpec);
545
+ console.info(`New function call: ${debugNameColored}`, {
535
546
  callId: callSpec.CallId,
536
547
  timeId: callSpec.runAtTime.time,
537
548
  functionId: callSpec.FunctionId,
@@ -564,13 +575,13 @@ export class PathFunctionRunner {
564
575
  }
565
576
  if (PathFunctionRunner.DEBUG_CALLS) {
566
577
  let fraction = getRoutingOverridePart(callSpec.CallId)?.route;
567
- console.log(`${yellow("Function run fallback")} (primary servery failed to run function) after ${formatTime(secondaryDelay)}. Fraction ${fraction?.toFixed(3)}. Primary ${this.config.shardRange.startFraction.toFixed(3)} to ${this.config.shardRange.endFraction.toFixed(3)}. Secondary ${this.config.secondaryShardRange?.startFraction.toFixed(3)} to ${this.config.secondaryShardRange?.endFraction.toFixed(3)}. for ${getDebugName(callSpec, functionSpec, true)}`);
578
+ console.log(`${yellow("Function run fallback")} (primary servery failed to run function) after ${formatTime(secondaryDelay)}. Fraction ${fraction?.toFixed(3)}. Primary ${this.config.shardRange.startFraction.toFixed(3)} to ${this.config.shardRange.endFraction.toFixed(3)}. Secondary ${this.config.secondaryShardRange?.startFraction.toFixed(3)} to ${this.config.secondaryShardRange?.endFraction.toFixed(3)}. for ${debugNameColored}`);
568
579
  }
569
580
  }
570
581
 
571
582
  PathFunctionRunner.RUN_START_COUNT++;
572
583
  if (PathFunctionRunner.DEBUG_CALLS) {
573
- console.log(`STARTING ${getDebugName(callSpec, functionSpec, true)}`);
584
+ console.log(`STARTING ${debugNameColored}`);
574
585
  }
575
586
  let startTime = Date.now();
576
587
  let runCount = 0;
@@ -618,29 +629,29 @@ export class PathFunctionRunner {
618
629
  devFunctionMetadata = schemaObj?.functionMetadata?.[functionSpec.FunctionId];
619
630
  }
620
631
 
621
- console.info(`Running function: ${getDebugName(callSpec, functionSpec, true)}`, {
632
+ console.info(`Running function: ${debugNameColored}`, {
622
633
  callId: callSpec.CallId,
623
634
  outerLoop: retries,
624
635
  });
625
636
 
626
637
  await proxyWatcher.commitFunction({
627
638
  canWrite: true,
628
- debugName: getDebugName(callSpec, functionSpec),
639
+ debugName: debugName,
640
+ source: debugName,
629
641
  runAtTime: callSpec.runAtTime,
630
642
  getPermissionsCheck: PermissionsChecker && (() => new PermissionsChecker(callSpec)),
631
643
  nestedCalls: "inline",
632
644
  temporary: true,
633
645
  maxLocksOverride: devFunctionMetadata ? devFunctionMetadata.maxLocksOverride : functionSpec.maxLocksOverride,
634
- source: callSpec.CallId,
635
646
  watchFunction: function runCallWatcher() {
636
647
  runCount++;
637
648
  stats.lastInternalLoopCount = runCount;
638
649
  stats.totalInternalLoopCount++;
639
650
  if (PathFunctionRunner.DEBUG_CALLS) {
640
- console.log(`Evaluating (try count ${runCount}) ${getDebugName(callSpec, functionSpec, true)}`);
651
+ console.log(`Evaluating (try count ${runCount}) ${debugNameColored}`);
641
652
  }
642
653
  if (runCount > PathFunctionRunner.MAX_WATCH_LOOPS) {
643
- let errorMessage = `MAX_WATCH_LOOPS exceeded for ${getDebugName(callSpec, functionSpec, true)}. All accesses have to be consistent. So Querysub.time() instead of Date.now() and Querysub.nextId() instead of nextId() / Math.random(). If you need multiple random numbers, keep track of an index, and pass it to Querysub.nextId() for the nth random number.`;
654
+ let errorMessage = `MAX_WATCH_LOOPS exceeded for ${debugNameColored}. All accesses have to be consistent. So Querysub.time() instead of Date.now() and Querysub.nextId() instead of nextId() / Math.random(). If you need multiple random numbers, keep track of an index, and pass it to Querysub.nextId() for the nth random number.`;
644
655
  console.error(errorMessage, {
645
656
  callId: callSpec.CallId,
646
657
  runCount,
@@ -662,7 +673,7 @@ export class PathFunctionRunner {
662
673
  atomicObjectRead(syncedModule.Sources[callSpec.FunctionId])
663
674
  );
664
675
  if (!syncedSpec) {
665
- throw new Error(`Function spec not found for ${getDebugName(callSpec, functionSpec, true)}`);
676
+ throw new Error(`Function spec not found for ${debugNameColored}`);
666
677
  }
667
678
 
668
679
  // (We also need to depend on the RIGHT function spec).
@@ -775,7 +786,7 @@ export class PathFunctionRunner {
775
786
  // }
776
787
  //if (PathFunctionRunner.DEBUG_CALLS)
777
788
  {
778
- console.log(`Function run error ${getDebugName(callSpec, functionSpec, true)}`, {
789
+ console.log(`Function run error ${debugNameColored}`, {
779
790
  callId: callSpec.CallId,
780
791
  error: e.stack,
781
792
  });
@@ -799,7 +810,7 @@ export class PathFunctionRunner {
799
810
  }
800
811
 
801
812
  if (PathFunctionRunner.DEBUG_CALLS) {
802
- console.log(`FINISHED${nooped ? " (skipped)" : ""} ${getDebugName(callSpec, functionSpec, true)}, writes: ${finalWrites?.length}, took ${blue(formatTime(Date.now() - startTime))}`);
813
+ console.log(`FINISHED${nooped ? " (skipped)" : ""} ${debugNameColored}, writes: ${finalWrites?.length}, took ${blue(formatTime(Date.now() - startTime))}`);
803
814
  }
804
815
 
805
816
  let wallTime = Date.now() - startTime;
@@ -814,7 +825,7 @@ export class PathFunctionRunner {
814
825
  });
815
826
  }
816
827
 
817
- console.info(`Finished function evaluation: ${getDebugName(callSpec, functionSpec, true)}`, {
828
+ console.info(`Finished function evaluation: ${debugNameColored}`, {
818
829
  callId: callSpec.CallId,
819
830
  outerLoop: retries,
820
831
  finalWrites: finalWrites?.length,
@@ -17,6 +17,12 @@ import { InputLabel, InputLabelURL } from "../library-components/InputLabel";
17
17
  import { URLParam } from "../library-components/URLParam";
18
18
  import { hotReloadingGuard, isHotReloading, onHotReload } from "socket-function/hot/HotReloadController";
19
19
  import { isCallerDynamicModule, isDynamicModule } from "../3-path-functions/pathFunctionLoader";
20
+ import { ATag } from "../library-components/ATag";
21
+ import { managementPageURL, showingManagementURL } from "../diagnostics/managementPages";
22
+ import { endTimeParam, startTimeParam } from "../diagnostics/logs/TimeRangeSelector";
23
+ import { timeInHour } from "socket-function/src/misc";
24
+ import { additionalSearchURL, lifecycleIdURL } from "../diagnostics/logs/lifeCycleAnalysis/LifeCyclePage";
25
+ import { parseDebugName } from "../3-path-functions/PathFunctionRunner";
20
26
 
21
27
  // Map, so hot reloading doesn't break things
22
28
  let componentButtons = new Map<string, { title: string, callback: (component: ExternalRenderClass) => void }>();
@@ -290,9 +296,6 @@ class WatchModal extends qreact.Component<{
290
296
  return undefined;
291
297
  }
292
298
  let valueObj = authorityStorage.getValueAtTime(path);
293
- // if (!path.includes("LOCAL") && valueObj?.source) {
294
- // debugger;
295
- // }
296
299
  let value = pathValueSerializer.getPathValue(valueObj);
297
300
  let valueStr = String(value);
298
301
  try {
@@ -311,6 +314,9 @@ class WatchModal extends qreact.Component<{
311
314
  let logOnRead = PathValueProxyWatcher.LOG_WRITES_INCLUDES.has(path);
312
315
  let fncBreak = PathValueProxyWatcher.SET_FUNCTION_WATCH_ON_WRITES.has(path);
313
316
  let selectedButton = css.hsl(120, 75, 75);
317
+
318
+ let sourceObj = parseDebugName(valueObj?.source || "");
319
+
314
320
  return (
315
321
  <div class={css.hbox(10, 0).fillWidth.wrap}>
316
322
  <button class={breakOnWrites && selectedButton || ""} onClick={() => {
@@ -353,6 +359,23 @@ class WatchModal extends qreact.Component<{
353
359
  }}>
354
360
  Caller*
355
361
  </button>
362
+ {
363
+ valueObj && sourceObj && <ATag values={[
364
+ showingManagementURL.getOverride(true),
365
+ managementPageURL.getOverride("LifeCyclePage"),
366
+ startTimeParam.getOverride(valueObj.time.time - timeInHour),
367
+ endTimeParam.getOverride(valueObj.time.time + timeInHour),
368
+ lifecycleIdURL.getOverride("1772917451506.018_0.441679673600166"),
369
+ additionalSearchURL.getOverride(`${sourceObj.functionId} & ${sourceObj.runAtTime.time}`),
370
+ ]}
371
+ title={valueObj.source || ""}
372
+ >
373
+ View Call
374
+ </ATag>
375
+ || <div title={valueObj?.source || ""}>
376
+ Source
377
+ </div>
378
+ }
356
379
  <div class={css.hbox(4).wrap.button} onClick={() => {
357
380
  console.log(path);
358
381
  return navigator.clipboard.writeText(path);
@@ -47,7 +47,7 @@ function getNodeForPath(path: string): string | "missing" | undefined {
47
47
  }
48
48
 
49
49
  export class PathDistributionInfo extends qreact.Component {
50
- render() {
50
+ renderBase() {
51
51
  if (!isCurrentUserSuperUser()) return undefined;
52
52
 
53
53
  let nodeSpecs = querysubController(SocketFunction.browserNodeId()).debugGetNodeSpecs();
@@ -99,4 +99,12 @@ export class PathDistributionInfo extends qreact.Component {
99
99
  {parts.length > 0 && parts || "..."}
100
100
  </div>;
101
101
  }
102
+ render() {
103
+ try {
104
+ return this.renderBase();
105
+ } catch (error) {
106
+ console.error("Error in rendering PathDistributionInfo:", error);
107
+ return undefined;
108
+ }
109
+ }
102
110
  }
@@ -276,7 +276,7 @@ export class BufferUnitIndex {
276
276
  // Only inaccurate if unique units approaches or exceeds 65k
277
277
  return uniqueCount * 2;
278
278
  }
279
- }, `collectUniqueUnits`);
279
+ }, `estimatedUniqueUnits`);
280
280
 
281
281
  // Step 3: Calculate hash table size and mask
282
282
  const { hashTableCapacity, mask } = measureBlock(() => {
@@ -21,6 +21,11 @@ if (isNode()) {
21
21
  }));
22
22
  }
23
23
 
24
+ let loggingToDiskDisabled = false;
25
+ export function disableLoggingToDisk() {
26
+ loggingToDiskDisabled = true;
27
+ }
28
+
24
29
  // NOTE: When logging we spread objects. If we encounter strings, we set the field `param${index}`
25
30
  export type LogDatum = Record<string, unknown> & {
26
31
  time: number;
@@ -109,6 +114,7 @@ const logDiskDontShim = logDisk;
109
114
  /** @deprecated, Don't call this directly, call console info instead, which our shim will prevent from logging to the console, but it will still call logDisk. */
110
115
  export function logDisk(type: "log" | "warn" | "info" | "error", ...args: unknown[]) {
111
116
  if (!isNode()) return;
117
+ if (loggingToDiskDisabled) return;
112
118
  try {
113
119
  if (args.length === 0) return;
114
120
  let logType = args.find(x => typeof x === "string") as string | undefined;
@@ -1,6 +1,6 @@
1
1
  module.allowclient = true;
2
2
 
3
- import { ArchiveSnapshotOverview, ArchiveSnapshotRead, getSnapshot, getSnapshotList, loadSnapshot, saveSnapshot } from "../../0-path-value-core/archiveLocks/archiveSnapshots";
3
+ import { ArchiveSnapshotOverview, ArchiveSnapshotRead, downloadSnapshot, getSnapshot, getSnapshotList, loadSnapshot, saveSnapshot, uploadSnapshot } from "../../0-path-value-core/archiveLocks/archiveSnapshots";
4
4
  import { qreact } from "../../4-dom/qreact";
5
5
  import { SocketFunction } from "socket-function/SocketFunction";
6
6
  import { getBrowserUrlNode } from "../../-f-node-discovery/NodeDiscovery";
@@ -30,6 +30,8 @@ export class SnapshotViewer extends qreact.Component {
30
30
  // file => true
31
31
  expanded: t.lookup(t.boolean),
32
32
  saving: t.boolean(false),
33
+ uploading: t.boolean(false),
34
+ downloading: t.lookup(t.boolean),
33
35
  });
34
36
  render() {
35
37
  let controller = SnapshotViewerSynced(getBrowserUrlNode());
@@ -40,6 +42,11 @@ export class SnapshotViewer extends qreact.Component {
40
42
  return (
41
43
  <div class={css.pad2(10).vbox(10)}>
42
44
  <h1>Snapshots are taken before every change. "zombie" means the file exists, but it has no confirm (so it will be ignored).</h1>
45
+ <div class={css.hbox(10)}>
46
+ <Button onClick={() => triggerSnapshotUpload(this)}>
47
+ {this.state.uploading && "Uploading..." || "Upload Snapshot"}
48
+ </Button>
49
+ </div>
43
50
  <Table
44
51
  rows={snapshotList}
45
52
  columns={{
@@ -68,6 +75,9 @@ export class SnapshotViewer extends qreact.Component {
68
75
  {this.state.saving ? "Saving..." : "Save"}
69
76
  </Button>
70
77
  }
78
+ <Button onClick={() => downloadSnapshotFile(this, file)}>
79
+ {this.state.downloading[file] && "Downloading..." || "Download"}
80
+ </Button>
71
81
  </div>;
72
82
  }
73
83
  }
@@ -142,6 +152,61 @@ export class SnapshotViewer extends qreact.Component {
142
152
  }
143
153
  }
144
154
 
155
+ async function downloadSnapshotFile(component: SnapshotViewer, file: string) {
156
+ if (component.state.downloading[file]) return;
157
+ Querysub.localCommit(() => {
158
+ component.state.downloading[file] = true;
159
+ });
160
+ try {
161
+ let buffer = await SnapshotViewerController.nodes[getBrowserUrlNode()].downloadSnapshot(file);
162
+ let blob = new Blob([buffer]);
163
+ let url = URL.createObjectURL(blob);
164
+ let anchor = document.createElement("a");
165
+ anchor.href = url;
166
+ anchor.download = file + ".cborx";
167
+ if (file === "live") {
168
+ anchor.download = `${formatDateTime(Date.now())}.cborx`;
169
+ }
170
+ document.body.appendChild(anchor);
171
+ anchor.click();
172
+ document.body.removeChild(anchor);
173
+ URL.revokeObjectURL(url);
174
+ } catch (e: any) {
175
+ console.error(red(`Failed to download snapshot ${file}: ${e.stack}`));
176
+ } finally {
177
+ Querysub.localCommit(() => {
178
+ delete component.state.downloading[file];
179
+ });
180
+ }
181
+ }
182
+
183
+ async function triggerSnapshotUpload(component: SnapshotViewer) {
184
+ if (component.state.uploading) return;
185
+ let input = document.createElement("input");
186
+ input.type = "file";
187
+ input.accept = ".cborx";
188
+ input.onchange = async () => {
189
+ let selected = input.files?.[0];
190
+ if (!selected) return;
191
+ Querysub.localCommit(() => {
192
+ component.state.uploading = true;
193
+ });
194
+ try {
195
+ let arrayBuffer = await selected.arrayBuffer();
196
+ let buffer = Buffer.from(arrayBuffer);
197
+ await SnapshotViewerController.nodes[getBrowserUrlNode()].uploadSnapshot(buffer);
198
+ console.log(green("Uploaded snapshot"));
199
+ } catch (e: any) {
200
+ console.error(red(`Failed to upload snapshot: ${e.stack}`));
201
+ } finally {
202
+ Querysub.localCommit(() => {
203
+ component.state.uploading = false;
204
+ });
205
+ }
206
+ };
207
+ input.click();
208
+ }
209
+
145
210
  async function seriousConfirm(config: {
146
211
  message: string;
147
212
  }): Promise<boolean> {
@@ -192,6 +257,14 @@ class SnapshotViewerControllerBase {
192
257
  let files = await getSnapshot("live");
193
258
  await saveSnapshot({ files: files.files.map(x => x.file) });
194
259
  }
260
+
261
+ public async downloadSnapshot(snapshotFile: string | "live"): Promise<Buffer> {
262
+ return await downloadSnapshot(snapshotFile);
263
+ }
264
+
265
+ public async uploadSnapshot(buffer: Buffer): Promise<ArchiveSnapshotOverview> {
266
+ return await uploadSnapshot(buffer);
267
+ }
195
268
  }
196
269
 
197
270
  export const SnapshotViewerController = SocketFunction.register(
@@ -202,6 +275,9 @@ export const SnapshotViewerController = SocketFunction.register(
202
275
  loadSnapshot: {},
203
276
  getSnapshot: {},
204
277
  saveLiveSnapshot: {},
278
+ downloadSnapshot: {},
279
+ uploadSnapshot: {},
280
+ example: {}
205
281
  }),
206
282
  () => ({
207
283
  hooks: [assertIsManagementUser],
@@ -217,5 +293,6 @@ const SnapshotViewerSynced = getSyncedController(SnapshotViewerController, {
217
293
  },
218
294
  writes: {
219
295
  saveLiveSnapshot: ["snapshots"],
296
+ uploadSnapshot: ["snapshots"],
220
297
  }
221
298
  });
@@ -13,6 +13,8 @@ function onUncaught(...args: unknown[]) {
13
13
  // Ignore ResizeObserver errors, they are spurious
14
14
  // - https://github.com/vercel/next.js/discussions/51551
15
15
  if (error.message.startsWith("ResizeObserver loop")) return;
16
+ // We should really do this better. Basically, if we're disposing or canceling, it's not actually an error, just ignore it.
17
+ if (error.message.startsWith("Dispose")) return;
16
18
 
17
19
  onMessage({
18
20
  type: "error",