@vercel/next-browser 0.1.7 → 0.2.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/dist/suspense.js CHANGED
@@ -7,46 +7,24 @@ export async function countBoundaries(page) {
7
7
  const nonRoot = boundaries.filter((b) => b.parentID !== 0);
8
8
  return { total: nonRoot.length, suspended: nonRoot.filter((b) => b.isSuspended).length };
9
9
  }
10
- export async function snapshotFromDom(page) {
11
- const count = await page.evaluate(() => document.querySelectorAll('template[id^="B:"]').length).catch(() => 0);
12
- const boundaries = [];
13
- for (let i = 0; i < count; i++) {
14
- boundaries.push({
15
- id: -(i + 1),
16
- parentID: 1,
17
- name: `shell-hole-${i}`,
18
- isSuspended: true,
19
- environments: [],
20
- suspendedBy: [],
21
- unknownSuspenders: null,
22
- owners: [],
23
- jsxSource: null,
24
- });
25
- }
26
- return boundaries;
27
- }
28
10
  export async function formatAnalysis(unlocked, locked, origin) {
11
+ const report = await analyzeBoundaries(unlocked, locked, origin);
12
+ return formatReport(report);
13
+ }
14
+ export async function analyzeBoundaries(unlocked, locked, origin) {
29
15
  await resolveSources(unlocked, origin);
30
16
  await resolveSources(locked, origin);
31
- const fromDom = locked.length > 0 && locked[0].id < 0;
32
17
  const holes = [];
33
18
  const statics = [];
34
- if (fromDom) {
35
- const dynamicUnlocked = unlocked.filter((b) => b.parentID !== 0 && b.suspendedBy.length > 0);
36
- for (const lb of locked) {
37
- const match = dynamicUnlocked.shift();
38
- holes.push({ shell: lb, full: match });
39
- }
40
- for (const ub of unlocked) {
41
- if (ub.parentID !== 0 && ub.suspendedBy.length === 0)
42
- statics.push(ub);
43
- }
44
- }
45
- else {
19
+ const hasLockedData = locked.some((b) => b.parentID !== 0);
20
+ if (hasLockedData) {
21
+ // DevTools was connected during lock — match locked vs unlocked by key
46
22
  const unlockedByKey = new Map();
47
23
  for (const b of unlocked)
48
24
  unlockedByKey.set(boundaryKey(b), b);
49
25
  for (const lb of locked) {
26
+ if (lb.parentID === 0)
27
+ continue;
50
28
  if (lb.isSuspended) {
51
29
  holes.push({ shell: lb, full: unlockedByKey.get(boundaryKey(lb)) });
52
30
  }
@@ -55,49 +33,455 @@ export async function formatAnalysis(unlocked, locked, origin) {
55
33
  }
56
34
  }
57
35
  }
58
- const totalBoundaries = fromDom ? holes.length + statics.length : locked.length;
36
+ else {
37
+ // DevTools wasn't connected during lock (goto case) — derive from unlocked.
38
+ // Boundaries with suspendedBy data were dynamic holes in the shell.
39
+ for (const b of unlocked) {
40
+ if (b.parentID === 0)
41
+ continue;
42
+ if (b.suspendedBy.length > 0 || b.unknownSuspenders) {
43
+ holes.push({ shell: b, full: b });
44
+ }
45
+ else {
46
+ statics.push(b);
47
+ }
48
+ }
49
+ }
50
+ const holeInsights = holes
51
+ .map(({ shell, full }) => buildBoundaryInsight(shell, full ?? shell))
52
+ .sort(compareBoundaryInsights);
53
+ const staticSummaries = statics.map((b) => ({
54
+ id: b.id,
55
+ name: b.name,
56
+ source: b.jsxSource,
57
+ renderedBy: b.owners,
58
+ }));
59
+ const rootCauses = buildRootCauseGroups(holeInsights);
60
+ const filesToRead = collectFilesToRead(holeInsights, rootCauses);
61
+ return {
62
+ totalBoundaries: holeInsights.length + staticSummaries.length,
63
+ dynamicHoleCount: holeInsights.length,
64
+ staticCount: staticSummaries.length,
65
+ holes: holeInsights,
66
+ statics: staticSummaries,
67
+ rootCauses,
68
+ filesToRead,
69
+ };
70
+ }
71
+ export function formatReport(report) {
59
72
  const lines = [
60
73
  "# PPR Shell Analysis",
61
- `# ${totalBoundaries} boundaries: ${holes.length} dynamic holes, ${statics.length} static`,
74
+ `# ${report.totalBoundaries} boundaries: ${report.dynamicHoleCount} dynamic holes, ${report.staticCount} static`,
62
75
  "",
63
76
  ];
64
- if (holes.length > 0) {
65
- lines.push("## Dynamic holes (suspended in shell)");
66
- for (const { shell, full } of holes) {
67
- const b = full ?? shell;
68
- const name = b.name?.startsWith("shell-hole") ? "(hole)" : (b.name ?? "(unnamed)");
69
- const src = b.jsxSource ? `${b.jsxSource[0]}:${b.jsxSource[1]}:${b.jsxSource[2]}` : null;
70
- lines.push(` ${name}${src ? ` at ${src}` : ""}`);
71
- if (b.owners.length > 0)
72
- lines.push(` rendered by: ${b.owners.join(" > ")}`);
73
- if (shell.environments.length > 0)
74
- lines.push(` environments: ${shell.environments.join(", ")}`);
75
- if (full && full.suspendedBy.length > 0) {
76
- lines.push(" blocked by:");
77
- for (const s of full.suspendedBy) {
78
- const dur = s.duration > 0 ? ` (${s.duration}ms)` : "";
79
- const env = s.env ? ` [${s.env}]` : "";
80
- const owner = s.ownerName ? ` initiated by <${s.ownerName}>` : "";
81
- const awaiter = s.awaiterName ? ` awaited in <${s.awaiterName}>` : "";
82
- lines.push(` - ${s.name}: ${s.description || "(no description)"}${dur}${env}${owner}${awaiter}`);
83
- }
77
+ if (report.holes.length > 0) {
78
+ lines.push("## Summary");
79
+ const topBoundary = report.holes[0];
80
+ if (topBoundary?.primaryBlocker) {
81
+ lines.push(`- Top actionable hole: ${topBoundary.name ?? "(unnamed)"} ${topBoundary.primaryBlocker.name} ` +
82
+ `(${topBoundary.primaryBlocker.kind})`);
83
+ lines.push(`- Suggested next step: ${topBoundary.recommendation}`);
84
+ }
85
+ const topRootCause = report.rootCauses[0];
86
+ if (topRootCause) {
87
+ lines.push(`- Most common root cause: ${topRootCause.name} (${topRootCause.kind}) ` +
88
+ `affecting ${topRootCause.count} boundar${topRootCause.count === 1 ? "y" : "ies"}`);
89
+ }
90
+ lines.push("");
91
+ lines.push("## Quick Reference");
92
+ lines.push("| Boundary | Type | Fallback source | Primary blocker | Source | Suggested next step |");
93
+ lines.push("| --- | --- | --- | --- | --- | --- |");
94
+ for (const hole of report.holes) {
95
+ const blocker = hole.primaryBlocker;
96
+ const source = blocker?.sourceFrame
97
+ ? `${blocker.sourceFrame[1]}:${blocker.sourceFrame[2]}`
98
+ : hole.source
99
+ ? `${hole.source[0]}:${hole.source[1]}`
100
+ : "unknown";
101
+ const fallback = hole.fallbackSource.path ?? hole.fallbackSource.kind;
102
+ lines.push(`| ${escapeCell(hole.name ?? "(unnamed)")} | ${hole.boundaryKind} | ${escapeCell(fallback)} | ` +
103
+ `${escapeCell(blocker ? `${blocker.name} (${blocker.kind})` : "unknown")} | ` +
104
+ `${escapeCell(source)} | ${escapeCell(hole.recommendation)} |`);
105
+ }
106
+ lines.push("");
107
+ if (report.filesToRead.length > 0) {
108
+ lines.push("## Files to Read");
109
+ for (const file of report.filesToRead) {
110
+ lines.push(`- ${file}`);
84
111
  }
85
- else if (full?.unknownSuspenders) {
86
- lines.push(` suspenders unknown: ${full.unknownSuspenders}`);
112
+ lines.push("");
113
+ }
114
+ if (report.rootCauses.length > 0) {
115
+ lines.push("## Root Causes");
116
+ for (const cause of report.rootCauses) {
117
+ const source = cause.sourceFrame
118
+ ? `${cause.sourceFrame[1]}:${cause.sourceFrame[2]}`
119
+ : "unknown";
120
+ lines.push(`- ${cause.name} (${cause.kind}) at ${source} — affects ${cause.count} ` +
121
+ `boundar${cause.count === 1 ? "y" : "ies"}`);
122
+ lines.push(` next step: ${cause.suggestion}`);
123
+ lines.push(` boundaries: ${cause.boundaryNames.join(", ")}`);
87
124
  }
125
+ lines.push("");
126
+ }
127
+ // Detail section — only shows info NOT already in the Quick Reference table:
128
+ // owner chains, environment tags, secondary blockers, and stack frames.
129
+ const holesWithDetail = report.holes.filter((h) => h.renderedBy.length > 0 || h.environments.length > 0 || h.blockers.length > 1 || h.unknownSuspenders);
130
+ if (holesWithDetail.length > 0) {
131
+ lines.push("## Detail");
132
+ for (const hole of holesWithDetail) {
133
+ const name = hole.name ?? "(unnamed)";
134
+ lines.push(` ${name}`);
135
+ if (hole.renderedBy.length > 0) {
136
+ lines.push(` rendered by: ${hole.renderedBy.map((o) => {
137
+ const env = o.env ? ` [${o.env}]` : "";
138
+ const src = o.source ? ` at ${o.source[0]}:${o.source[1]}` : "";
139
+ return `${o.name}${env}${src}`;
140
+ }).join(" > ")}`);
141
+ }
142
+ if (hole.environments.length > 0)
143
+ lines.push(` environments: ${hole.environments.join(", ")}`);
144
+ if (hole.blockers.length > 1) {
145
+ lines.push(" secondary blockers:");
146
+ for (const blocker of hole.blockers.slice(1)) {
147
+ const env = blocker.env ? ` [${blocker.env}]` : "";
148
+ const owner = blocker.ownerName ? ` initiated by <${blocker.ownerName}>` : "";
149
+ const awaiter = blocker.awaiterName ? ` awaited in <${blocker.awaiterName}>` : "";
150
+ lines.push(` - ${blocker.name}: ${blocker.description || "(no description)"}${env}${owner}${awaiter}`);
151
+ if (blocker.ownerFrame) {
152
+ const [fn, file, line] = blocker.ownerFrame;
153
+ lines.push(` owner: ${fn || "(anonymous)"} ${file}:${line}`);
154
+ }
155
+ else if (blocker.awaiterFrame) {
156
+ const [fn, file, line] = blocker.awaiterFrame;
157
+ lines.push(` awaiter: ${fn || "(anonymous)"} ${file}:${line}`);
158
+ }
159
+ }
160
+ }
161
+ if (hole.unknownSuspenders) {
162
+ lines.push(` suspenders unknown: ${hole.unknownSuspenders}`);
163
+ }
164
+ }
165
+ lines.push("");
88
166
  }
89
- lines.push("");
90
167
  }
91
- if (statics.length > 0) {
168
+ if (report.statics.length > 0) {
92
169
  lines.push("## Static (pre-rendered in shell)");
93
- for (const b of statics) {
170
+ for (const b of report.statics) {
94
171
  const name = b.name ?? "(unnamed)";
95
- const src = b.jsxSource ? ` at ${b.jsxSource[0]}:${b.jsxSource[1]}:${b.jsxSource[2]}` : "";
172
+ const src = b.source ? ` at ${b.source[0]}:${b.source[1]}:${b.source[2]}` : "";
96
173
  lines.push(` ${name}${src}`);
97
174
  }
98
175
  }
99
176
  return lines.join("\n");
100
177
  }
178
+ function buildBoundaryInsight(shell, resolved) {
179
+ const boundaryKind = inferBoundaryKind(resolved);
180
+ const blockers = resolved.suspendedBy
181
+ .map((blocker) => buildActionableBlocker(blocker))
182
+ .sort(compareActionableBlockers);
183
+ const primaryBlocker = blockers[0] ?? null;
184
+ const recommendation = recommendBoundaryFix(boundaryKind, primaryBlocker, resolved.unknownSuspenders);
185
+ return {
186
+ id: resolved.id,
187
+ name: resolved.name,
188
+ boundaryKind,
189
+ environments: shell.environments,
190
+ source: resolved.jsxSource,
191
+ renderedBy: resolved.owners,
192
+ fallbackSource: {
193
+ kind: "unknown",
194
+ path: null,
195
+ confidence: boundaryKind === "route-segment" ? "medium" : "low",
196
+ },
197
+ primaryBlocker,
198
+ blockers,
199
+ unknownSuspenders: resolved.unknownSuspenders,
200
+ actionability: Math.max(primaryBlocker?.actionability ?? 0, boundaryKind === "route-segment" ? 55 : 0),
201
+ recommendation: recommendation.text,
202
+ recommendationKind: recommendation.kind,
203
+ };
204
+ }
205
+ function buildActionableBlocker(suspender) {
206
+ const ownerFrame = pickPreferredFrame(suspender.ownerStack);
207
+ const awaiterFrame = pickPreferredFrame(suspender.awaiterStack);
208
+ const sourceFrame = ownerFrame ?? awaiterFrame;
209
+ const kind = classifyBlocker(suspender, sourceFrame);
210
+ const suggestion = suggestBlockerFix(kind);
211
+ let actionability = blockerActionability(kind);
212
+ if (sourceFrame && !isFrameworkishPath(sourceFrame[1]))
213
+ actionability += 8;
214
+ if (suspender.ownerName || suspender.awaiterName)
215
+ actionability += 4;
216
+ actionability = Math.min(actionability, 100);
217
+ return {
218
+ key: buildBlockerKey(suspender.name, kind, sourceFrame),
219
+ name: suspender.name,
220
+ kind,
221
+ env: suspender.env,
222
+ description: suspender.description,
223
+ ownerName: suspender.ownerName,
224
+ awaiterName: suspender.awaiterName,
225
+ sourceFrame,
226
+ ownerFrame,
227
+ awaiterFrame,
228
+ actionability,
229
+ suggestion,
230
+ };
231
+ }
232
+ function buildRootCauseGroups(holes) {
233
+ const groups = new Map();
234
+ for (const hole of holes) {
235
+ const blocker = hole.primaryBlocker;
236
+ if (!blocker)
237
+ continue;
238
+ const existing = groups.get(blocker.key);
239
+ if (existing) {
240
+ existing.boundaryIds.push(hole.id);
241
+ existing.boundaryNames.push(hole.name ?? `boundary-${hole.id}`);
242
+ existing.count++;
243
+ existing.actionability = Math.max(existing.actionability, blocker.actionability);
244
+ continue;
245
+ }
246
+ groups.set(blocker.key, {
247
+ key: blocker.key,
248
+ kind: blocker.kind,
249
+ name: blocker.name,
250
+ sourceFrame: blocker.sourceFrame,
251
+ boundaryIds: [hole.id],
252
+ boundaryNames: [hole.name ?? `boundary-${hole.id}`],
253
+ count: 1,
254
+ actionability: blocker.actionability,
255
+ suggestion: blocker.suggestion,
256
+ });
257
+ }
258
+ return [...groups.values()].sort((a, b) => {
259
+ const scoreA = a.count * a.actionability;
260
+ const scoreB = b.count * b.actionability;
261
+ return scoreB - scoreA || a.name.localeCompare(b.name);
262
+ });
263
+ }
264
+ function collectFilesToRead(holes, rootCauses) {
265
+ const counts = new Map();
266
+ const add = (file) => {
267
+ if (!file)
268
+ return;
269
+ counts.set(file, (counts.get(file) ?? 0) + 1);
270
+ };
271
+ for (const hole of holes) {
272
+ add(hole.source?.[0]);
273
+ add(hole.primaryBlocker?.sourceFrame?.[1]);
274
+ for (const owner of hole.renderedBy)
275
+ add(owner.source?.[0]);
276
+ }
277
+ for (const cause of rootCauses)
278
+ add(cause.sourceFrame?.[1]);
279
+ return [...counts.entries()]
280
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
281
+ .map(([file]) => file)
282
+ .slice(0, 12);
283
+ }
284
+ function compareBoundaryInsights(a, b) {
285
+ return (b.actionability - a.actionability ||
286
+ boundaryKindWeight(b.boundaryKind) - boundaryKindWeight(a.boundaryKind) ||
287
+ (b.blockers.length - a.blockers.length) ||
288
+ (a.name ?? "").localeCompare(b.name ?? ""));
289
+ }
290
+ function compareActionableBlockers(a, b) {
291
+ return (b.actionability - a.actionability ||
292
+ blockerKindWeight(b.kind) - blockerKindWeight(a.kind) ||
293
+ a.name.localeCompare(b.name));
294
+ }
295
+ function boundaryKindWeight(kind) {
296
+ if (kind === "route-segment")
297
+ return 3;
298
+ if (kind === "explicit-suspense")
299
+ return 2;
300
+ return 1;
301
+ }
302
+ function blockerKindWeight(kind) {
303
+ switch (kind) {
304
+ case "client-hook": return 7;
305
+ case "request-api": return 6;
306
+ case "server-fetch": return 5;
307
+ case "cache": return 4;
308
+ case "stream": return 3;
309
+ case "unknown": return 2;
310
+ case "framework": return 1;
311
+ }
312
+ }
313
+ function blockerActionability(kind) {
314
+ switch (kind) {
315
+ case "client-hook": return 90;
316
+ case "request-api": return 88;
317
+ case "server-fetch": return 82;
318
+ case "cache": return 74;
319
+ case "stream": return 60;
320
+ case "unknown": return 35;
321
+ case "framework": return 18;
322
+ }
323
+ }
324
+ function inferBoundaryKind(boundary) {
325
+ const ownerNames = boundary.owners.map((owner) => owner.name);
326
+ if ((boundary.name && boundary.name.endsWith("/")) ||
327
+ ownerNames.includes("LoadingBoundary") ||
328
+ ownerNames.includes("OuterLayoutRouter")) {
329
+ return "route-segment";
330
+ }
331
+ if (boundary.name?.includes("Suspense") ||
332
+ ownerNames.some((name) => name.includes("Suspense"))) {
333
+ return "explicit-suspense";
334
+ }
335
+ return "component";
336
+ }
337
+ function classifyBlocker(suspender, sourceFrame) {
338
+ const name = suspender.name.toLowerCase();
339
+ if (name === "usepathname" ||
340
+ name === "useparams" ||
341
+ name === "usesearchparams" ||
342
+ name === "useselectedlayoutsegments" ||
343
+ name === "useselectedlayoutsegment" ||
344
+ name === "userouter") {
345
+ return "client-hook";
346
+ }
347
+ if (name === "cookies" ||
348
+ name === "headers" ||
349
+ name === "connection" ||
350
+ name === "params" ||
351
+ name === "searchparams" ||
352
+ name === "draftmode") {
353
+ return "request-api";
354
+ }
355
+ if (name === "rsc stream")
356
+ return "stream";
357
+ if (name.includes("fetch"))
358
+ return "server-fetch";
359
+ if (name.includes("cache") || suspender.description.toLowerCase().includes("cache")) {
360
+ return "cache";
361
+ }
362
+ if (name.startsWith("use"))
363
+ return "client-hook";
364
+ if (sourceFrame && isFrameworkishPath(sourceFrame[1]))
365
+ return "framework";
366
+ return "unknown";
367
+ }
368
+ function suggestBlockerFix(kind) {
369
+ switch (kind) {
370
+ case "client-hook":
371
+ return "Move route hooks behind a smaller client Suspense or provide a real non-null loading fallback for this segment.";
372
+ case "request-api":
373
+ return "Push request-bound reads to a smaller server leaf, or cache around them so the parent shell can stay static.";
374
+ case "server-fetch":
375
+ return "Split static shell content from data widgets, then push the fetch into smaller Suspense leaves or cache it.";
376
+ case "cache":
377
+ return "This looks cache-related; check whether \"use cache\" or runtime prefetch can eliminate the suspension.";
378
+ case "stream":
379
+ return "A stream is still pending here; extract static siblings outside the boundary and push the stream consumer deeper.";
380
+ case "framework":
381
+ return "This currently looks framework-driven; find the nearest user-owned caller above it before changing code.";
382
+ case "unknown":
383
+ return "Inspect the nearest user-owned owner/awaiter frame and verify whether this suspender really belongs at this boundary.";
384
+ }
385
+ }
386
+ function recommendBoundaryFix(boundaryKind, primaryBlocker, unknownSuspenders) {
387
+ if (boundaryKind === "route-segment" && primaryBlocker?.kind === "client-hook") {
388
+ return {
389
+ kind: "check-loading-fallback",
390
+ text: "This route segment is suspending on client hooks. Check loading.tsx first; if it is null or visually empty, fix the fallback before chasing deeper push-down work.",
391
+ };
392
+ }
393
+ if (primaryBlocker?.kind === "client-hook") {
394
+ return {
395
+ kind: "push-client-hooks-down",
396
+ text: "Push the hook-using client UI behind a smaller local Suspense boundary so the parent shell can prerender.",
397
+ };
398
+ }
399
+ if (primaryBlocker?.kind === "request-api" || primaryBlocker?.kind === "server-fetch") {
400
+ return {
401
+ kind: "push-request-io-down",
402
+ text: "Push the request-bound async work into a smaller leaf or split static siblings out of this boundary.",
403
+ };
404
+ }
405
+ if (primaryBlocker?.kind === "cache") {
406
+ return {
407
+ kind: "cache-or-runtime-prefetch",
408
+ text: "Check whether caching or runtime prefetch can move this personalized content into the shell.",
409
+ };
410
+ }
411
+ if (primaryBlocker?.kind === "stream") {
412
+ return {
413
+ kind: "extract-static-shell",
414
+ text: "Keep the stream behind Suspense, but extract any static shell content outside the boundary.",
415
+ };
416
+ }
417
+ if (primaryBlocker?.kind === "framework") {
418
+ return {
419
+ kind: "investigate-framework",
420
+ text: "The top blocker still looks framework-heavy. Find the nearest user-owned caller before changing boundary placement.",
421
+ };
422
+ }
423
+ if (unknownSuspenders) {
424
+ return {
425
+ kind: "investigate-unknown",
426
+ text: `React could not identify the suspender (${unknownSuspenders}). Investigate the nearest user-owned owner or awaiter frame.`,
427
+ };
428
+ }
429
+ return {
430
+ kind: "investigate-unknown",
431
+ text: "No primary blocker was identified. Inspect the boundary source and owner chain directly.",
432
+ };
433
+ }
434
+ function pickPreferredFrame(stack) {
435
+ if (!stack || stack.length === 0)
436
+ return null;
437
+ return stack.find((frame) => !isFrameworkishPath(frame[1])) ?? stack[0];
438
+ }
439
+ function isFrameworkishPath(file) {
440
+ return file.includes("/node_modules/");
441
+ }
442
+ function buildBlockerKey(name, kind, sourceFrame) {
443
+ if (!sourceFrame)
444
+ return `${kind}:${name}:unknown`;
445
+ return `${kind}:${name}:${sourceFrame[1]}:${sourceFrame[2]}`;
446
+ }
447
+ function labelActionability(value) {
448
+ if (value >= 80)
449
+ return "high";
450
+ if (value >= 50)
451
+ return "medium";
452
+ return "low";
453
+ }
454
+ function escapeCell(value) {
455
+ return value.replaceAll("|", "\\|");
456
+ }
457
+ export function annotateReportWithPageMetadata(report, pageMetadata) {
458
+ const sessions = Array.isArray(pageMetadata?.sessions)
459
+ ? (pageMetadata.sessions)
460
+ : [];
461
+ const loadingPaths = sessions.flatMap((session) => (session.segments ?? [])
462
+ .filter((segment) => segment.type === "boundary:loading" && typeof segment.path === "string")
463
+ .map((segment) => segment.path));
464
+ for (const hole of report.holes) {
465
+ if (hole.boundaryKind !== "route-segment")
466
+ continue;
467
+ const segmentName = normalizeBoundarySegmentName(hole.name);
468
+ if (!segmentName)
469
+ continue;
470
+ const exact = loadingPaths.find((path) => path.split("/").at(-2) === segmentName);
471
+ if (exact) {
472
+ hole.fallbackSource = {
473
+ kind: "loading-tsx",
474
+ path: exact,
475
+ confidence: "high",
476
+ };
477
+ }
478
+ }
479
+ }
480
+ function normalizeBoundarySegmentName(name) {
481
+ if (!name)
482
+ return null;
483
+ return name.endsWith("/") ? name.slice(0, -1) : name;
484
+ }
101
485
  function boundaryKey(b) {
102
486
  if (b.jsxSource)
103
487
  return `${b.jsxSource[0]}:${b.jsxSource[1]}:${b.jsxSource[2]}`;
@@ -106,13 +490,40 @@ function boundaryKey(b) {
106
490
  async function resolveSources(boundaries, origin) {
107
491
  for (const b of boundaries) {
108
492
  if (b.jsxSource) {
109
- const [file, line, col] = b.jsxSource;
110
- const resolved = (await sourcemap.resolve(origin, file, line, col)) ??
111
- (await sourcemap.resolveViaMap(origin, file, line, col));
112
- if (resolved)
113
- b.jsxSource = [resolved.file, resolved.line, resolved.column];
493
+ b.jsxSource = await resolveOne(b.jsxSource, origin);
494
+ }
495
+ for (const o of b.owners) {
496
+ if (o.source) {
497
+ o.source = await resolveOne(o.source, origin);
498
+ }
499
+ }
500
+ for (const s of b.suspendedBy) {
501
+ if (s.ownerStack)
502
+ s.ownerStack = await resolveStack(s.ownerStack, origin);
503
+ if (s.awaiterStack)
504
+ s.awaiterStack = await resolveStack(s.awaiterStack, origin);
505
+ }
506
+ }
507
+ }
508
+ async function resolveOne(src, origin) {
509
+ const [file, line, col] = src;
510
+ const resolved = (await sourcemap.resolve(origin, file, line, col)) ??
511
+ (await sourcemap.resolveViaMap(origin, file, line, col));
512
+ return resolved ? [resolved.file, resolved.line, resolved.column] : src;
513
+ }
514
+ async function resolveStack(stack, origin) {
515
+ const out = [];
516
+ for (const [name, file, line, col] of stack) {
517
+ const resolved = (await sourcemap.resolve(origin, file, line, col)) ??
518
+ (await sourcemap.resolveViaMap(origin, file, line, col));
519
+ if (resolved) {
520
+ out.push([name, resolved.file, resolved.line, resolved.column]);
521
+ }
522
+ else {
523
+ out.push([name, file, line, col]);
114
524
  }
115
525
  }
526
+ return out;
116
527
  }
117
528
  async function inPageSuspense(inspect) {
118
529
  const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
@@ -155,16 +566,17 @@ async function inPageSuspense(inspect) {
155
566
  function collect(renderer) {
156
567
  return new Promise((resolve) => {
157
568
  const out = [];
158
- const listener = (e) => {
159
- const p = e.data?.payload;
160
- if (e.data?.source === "react-devtools-bridge" && p?.event === "operations") {
161
- out.push(p.payload);
162
- }
569
+ // Operations are emitted via hook.emit("operations", payload),
570
+ // NOT via window.postMessage.
571
+ const origEmit = hook.emit;
572
+ hook.emit = function (event, payload) {
573
+ if (event === "operations")
574
+ out.push(payload);
575
+ return origEmit.apply(this, arguments);
163
576
  };
164
- window.addEventListener("message", listener);
165
577
  renderer.flushInitialOperations();
166
578
  setTimeout(() => {
167
- window.removeEventListener("message", listener);
579
+ hook.emit = origEmit;
168
580
  resolve(out);
169
581
  }, 50);
170
582
  });
@@ -283,7 +695,9 @@ async function inPageSuspense(inspect) {
283
695
  duration: awaited.end && awaited.start ? Math.round(awaited.end - awaited.start) : 0,
284
696
  env: awaited.env ?? entry?.env ?? null,
285
697
  ownerName: awaited.owner?.displayName ?? null,
698
+ ownerStack: parseStack(awaited.owner?.stack ?? awaited.stack),
286
699
  awaiterName: entry?.owner?.displayName ?? null,
700
+ awaiterStack: parseStack(entry?.owner?.stack ?? entry?.stack),
287
701
  });
288
702
  }
289
703
  }
@@ -297,8 +711,16 @@ async function inPageSuspense(inspect) {
297
711
  }
298
712
  if (Array.isArray(data.owners)) {
299
713
  for (const o of data.owners) {
300
- if (o?.displayName)
301
- boundary.owners.push(o.displayName);
714
+ if (o?.displayName) {
715
+ const src = Array.isArray(o.stack) && o.stack.length > 0 && Array.isArray(o.stack[0])
716
+ ? [o.stack[0][1] || "(unknown)", o.stack[0][2], o.stack[0][3]]
717
+ : null;
718
+ boundary.owners.push({
719
+ name: o.displayName,
720
+ env: o.env ?? null,
721
+ source: src,
722
+ });
723
+ }
302
724
  }
303
725
  }
304
726
  if (Array.isArray(data.stack) && data.stack.length > 0) {
@@ -308,6 +730,13 @@ async function inPageSuspense(inspect) {
308
730
  }
309
731
  }
310
732
  }
733
+ function parseStack(raw) {
734
+ if (!Array.isArray(raw) || raw.length === 0)
735
+ return null;
736
+ return raw
737
+ .filter((f) => Array.isArray(f) && f.length >= 4)
738
+ .map((f) => [f[0] ?? "", f[1] ?? "", f[2] ?? 0, f[3] ?? 0]);
739
+ }
311
740
  function preview(v) {
312
741
  if (v == null)
313
742
  return "";