cb15c5e0d8
Release / Check for new version (push) Has been cancelled
Release / Build macOS ARM64 (push) Has been cancelled
Release / Build macOS x64 (push) Has been cancelled
Release / Build Linux ARM64 (push) Has been cancelled
Release / Build Linux musl ARM64 (push) Has been cancelled
Release / Build Linux musl x64 (push) Has been cancelled
Release / Build Linux x64 (push) Has been cancelled
Release / Build Windows x64 (push) Has been cancelled
Release / Publish to npm (push) Has been cancelled
Release / Publish sandbox package to npm (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
CI / Rust (windows-latest - x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Native E2E Tests (push) Has been cancelled
CI / Windows Integration Test (push) Has been cancelled
CI / Global Install (macos-latest) (push) Has been cancelled
CI / Global Install (ubuntu-latest) (push) Has been cancelled
CI / Version Sync Check (push) Has been cancelled
CI / Rust (push) Has been cancelled
CI / Dashboard (push) Has been cancelled
CI / Sandbox Package (push) Has been cancelled
CI / Rust (macos-latest - aarch64-apple-darwin) (push) Has been cancelled
CI / Rust (macos-latest - x86_64-apple-darwin) (push) Has been cancelled
CI / Global Install (windows-latest) (push) Has been cancelled
843 lines
29 KiB
Rust
843 lines
29 KiB
Rust
//! Browser-side evaluation scripts for React/web introspection.
|
|
//!
|
|
//! These are JavaScript strings evaluated in the page context via
|
|
//! `Runtime.evaluate`. They assume the React DevTools hook is already
|
|
//! installed (via `--enable react-devtools`) except for `VITALS_INIT` and
|
|
//! `PUSHSTATE`, which only use standard Web APIs.
|
|
//!
|
|
//! Kept as raw strings rather than TS/JS files because the daemon is a single
|
|
//! Rust binary with no filesystem vendor step at runtime.
|
|
|
|
/// JS helper injected into every renderer-reading script via the `{{PICK_RI}}`
|
|
/// placeholder. Defines `__abPickReactRendererId(hook)`, which returns the id of
|
|
/// the renderer interface that actually holds the app's DOM tree.
|
|
///
|
|
/// We used to hardcode `rendererInterfaces.get(1)`, assuming the first renderer
|
|
/// to call `hook.inject()` is react-dom. In Turbopack RSC apps (e.g. Next.js
|
|
/// 16.3+) the `react-server-dom-*` Flight client registers first as id 1 with
|
|
/// zero fiber roots, so `get(1)` read an empty tree and every `react` command
|
|
/// silently reported nothing. Instead, pick the first non-Flight renderer that
|
|
/// has mounted fiber roots, falling back to any renderer with roots, then any
|
|
/// renderer at all.
|
|
pub const PICK_REACT_RENDERER: &str = r#"
|
|
function __abPickReactRendererId(hook) {
|
|
const ris = hook && hook.rendererInterfaces;
|
|
if (!ris || !ris.get || !ris.keys) return null;
|
|
const rootsOf = (id) => {
|
|
try { return hook.getFiberRoots ? hook.getFiberRoots(id).size : 0; } catch (e) { return 0; }
|
|
};
|
|
const isFlight = (id) => {
|
|
const r = hook.renderers && hook.renderers.get && hook.renderers.get(id);
|
|
return /react-server-dom/.test((r && r.rendererPackageName) || "");
|
|
};
|
|
let firstWithRoots = null, firstAny = null;
|
|
for (const id of ris.keys()) {
|
|
if (!ris.get(id)) continue;
|
|
if (firstAny === null) firstAny = id;
|
|
if (rootsOf(id) > 0) {
|
|
if (firstWithRoots === null) firstWithRoots = id;
|
|
if (!isFlight(id)) return id;
|
|
}
|
|
}
|
|
return firstWithRoots !== null ? firstWithRoots : firstAny;
|
|
}
|
|
"#;
|
|
|
|
/// Build a no-argument async IIFE page-eval that returns the component tree as
|
|
/// JSON.
|
|
pub const TREE_SNAPSHOT: &str = r#"
|
|
(async () => {
|
|
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
if (!hook) throw new Error("React DevTools hook not installed - relaunch with --enable react-devtools");
|
|
{{PICK_RI}}
|
|
const __abRiId = __abPickReactRendererId(hook);
|
|
const ri = (__abRiId != null && hook.rendererInterfaces && hook.rendererInterfaces.get) ? hook.rendererInterfaces.get(__abRiId) : null;
|
|
if (!ri) throw new Error("No React renderer attached - the page has not booted React yet");
|
|
|
|
const batches = await new Promise((resolve) => {
|
|
const out = [];
|
|
const origEmit = hook.emit;
|
|
hook.emit = function (event, payload) {
|
|
if (event === "operations") out.push(Array.from(payload));
|
|
return origEmit.apply(hook, arguments);
|
|
};
|
|
ri.flushInitialOperations();
|
|
setTimeout(() => {
|
|
hook.emit = origEmit;
|
|
resolve(out);
|
|
}, 50);
|
|
});
|
|
|
|
const nodes = batches.flatMap((ops) => {
|
|
let i = 2;
|
|
const strings = [null];
|
|
const tableEnd = ++i + ops[i - 1];
|
|
while (i < tableEnd) {
|
|
const len = ops[i++];
|
|
strings.push(String.fromCodePoint(...ops.slice(i, i + len)));
|
|
i += len;
|
|
}
|
|
const out = [];
|
|
while (i < ops.length) {
|
|
const op = ops[i];
|
|
if (op === 1) {
|
|
const id = ops[i + 1];
|
|
const type = ops[i + 2];
|
|
i += 3;
|
|
if (type === 11) {
|
|
out.push({ id, type, name: null, key: null, parent: 0 });
|
|
i += 4;
|
|
} else {
|
|
out.push({
|
|
id,
|
|
type,
|
|
name: strings[ops[i + 2]] || null,
|
|
key: strings[ops[i + 3]] || null,
|
|
parent: ops[i],
|
|
});
|
|
i += 5;
|
|
}
|
|
} else {
|
|
i += skip(op, ops, i);
|
|
}
|
|
}
|
|
return out;
|
|
|
|
function skip(op, ops, i) {
|
|
if (op === 2) return 2 + ops[i + 1];
|
|
if (op === 3) return 3 + ops[i + 2];
|
|
if (op === 4) return 3;
|
|
if (op === 5) return 4;
|
|
if (op === 6) return 1;
|
|
if (op === 7) return 3;
|
|
if (op === 8) return 6 + rects(ops[i + 5]);
|
|
if (op === 9) return 2 + ops[i + 1];
|
|
if (op === 10) return 3 + ops[i + 2];
|
|
if (op === 11) return 3 + rects(ops[i + 2]);
|
|
if (op === 12) return suspenders(ops, i);
|
|
if (op === 13) return 2;
|
|
return 1;
|
|
}
|
|
function rects(n) {
|
|
return n === -1 ? 0 : n * 4;
|
|
}
|
|
function suspenders(ops, i) {
|
|
let j = i + 2;
|
|
for (let c = 0; c < ops[i + 1]; c++) j += 5 + ops[j + 4];
|
|
return j - i;
|
|
}
|
|
});
|
|
|
|
return JSON.stringify(nodes);
|
|
})()
|
|
"#;
|
|
|
|
/// Template for `inspect` — replace {{ID}} with the numeric fiber id.
|
|
pub const TREE_INSPECT: &str = r#"
|
|
(() => {
|
|
const id = {{ID}};
|
|
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
{{PICK_RI}}
|
|
const __abRiId = __abPickReactRendererId(hook);
|
|
const ri = (__abRiId != null && hook.rendererInterfaces && hook.rendererInterfaces.get) ? hook.rendererInterfaces.get(__abRiId) : null;
|
|
if (!ri) throw new Error("No React renderer attached");
|
|
if (!ri.hasElementWithId(id)) throw new Error("element " + id + " not found (page reloaded?)");
|
|
const result = ri.inspectElement(__abRiId, id, null, true);
|
|
if (!result || result.type !== "full-data") {
|
|
throw new Error("inspect failed: " + (result && result.type));
|
|
}
|
|
const v = result.value;
|
|
const name = ri.getDisplayNameForElementID(id);
|
|
const lines = [name + " #" + id];
|
|
if (v.key != null) lines.push("key: " + JSON.stringify(v.key));
|
|
section("props", v.props);
|
|
section("hooks", v.hooks);
|
|
section("state", v.state);
|
|
section("context", v.context);
|
|
if (v.owners && v.owners.length) {
|
|
lines.push("rendered by: " + v.owners.map((o) => o.displayName).join(" > "));
|
|
}
|
|
const source = Array.isArray(v.source)
|
|
? [v.source[1], v.source[2], v.source[3]]
|
|
: null;
|
|
return JSON.stringify({ text: lines.join("\n"), source });
|
|
|
|
function section(label, payload) {
|
|
const data = (payload && payload.data) || payload;
|
|
if (data == null) return;
|
|
if (Array.isArray(data)) {
|
|
if (data.length === 0) return;
|
|
lines.push(label + ":");
|
|
for (const h of data) lines.push(" " + hookLine(h));
|
|
} else if (typeof data === "object") {
|
|
const entries = Object.entries(data);
|
|
if (entries.length === 0) return;
|
|
lines.push(label + ":");
|
|
for (const [k, val] of entries) lines.push(" " + k + ": " + preview(val));
|
|
}
|
|
}
|
|
function hookLine(h) {
|
|
const idx = h.id != null ? "[" + h.id + "] " : "";
|
|
const sub = h.subHooks && h.subHooks.length ? " (" + h.subHooks.length + " sub)" : "";
|
|
return idx + h.name + ": " + preview(h.value) + sub;
|
|
}
|
|
function preview(v) {
|
|
if (v == null) return String(v);
|
|
if (typeof v !== "object") return JSON.stringify(v);
|
|
if (v.type === "undefined") return "undefined";
|
|
if (v.preview_long) return v.preview_long;
|
|
if (v.preview_short) return v.preview_short;
|
|
if (Array.isArray(v)) return "[" + v.map(preview).join(", ") + "]";
|
|
const entries = Object.entries(v).map((e) => e[0] + ": " + preview(e[1]));
|
|
return "{" + entries.join(", ") + "}";
|
|
}
|
|
})()
|
|
"#;
|
|
|
|
/// Fiber profiler init script. Registered via `addScriptToEvaluateOnNewDocument`
|
|
/// so it survives navigations; also evaluated immediately on the current page
|
|
/// by `react renders start`.
|
|
pub const RENDERS_INIT: &str = r#"
|
|
(() => {
|
|
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
if (!hook || window.__AB_RENDERS_ACTIVE__) return;
|
|
|
|
const MAX_COMPONENTS = 200;
|
|
const data = {};
|
|
const fps = { frames: [], last: 0, rafId: 0 };
|
|
|
|
window.__AB_RENDERS__ = data;
|
|
window.__AB_RENDERS_FPS__ = fps;
|
|
window.__AB_RENDERS_START__ = performance.now();
|
|
window.__AB_RENDERS_ACTIVE__ = true;
|
|
|
|
function fpsLoop(now) {
|
|
if (fps.last > 0) fps.frames.push(now - fps.last);
|
|
fps.last = now;
|
|
fps.rafId = requestAnimationFrame(fpsLoop);
|
|
}
|
|
fps.rafId = requestAnimationFrame(fpsLoop);
|
|
|
|
const origOnCommit = hook.onCommitFiberRoot;
|
|
window.__AB_RENDERS_ORIG_COMMIT__ = origOnCommit;
|
|
|
|
hook.onCommitFiberRoot = function (rendererID, root) {
|
|
try { walkFiber(root.current); } catch {}
|
|
if (typeof origOnCommit === "function") {
|
|
return origOnCommit.apply(hook, arguments);
|
|
}
|
|
};
|
|
|
|
function getName(fiber) {
|
|
if (!fiber.type || typeof fiber.type === "string") return null;
|
|
return fiber.type.displayName || fiber.type.name || null;
|
|
}
|
|
|
|
function brief(val) {
|
|
if (val === undefined) return "undefined";
|
|
if (val === null) return "null";
|
|
if (typeof val === "function") return "fn()";
|
|
if (typeof val === "string") return val.length > 60 ? '"' + val.slice(0, 57) + '..."' : '"' + val + '"';
|
|
if (typeof val === "number" || typeof val === "boolean") return String(val);
|
|
if (Array.isArray(val)) return "Array(" + val.length + ")";
|
|
if (typeof val === "object") {
|
|
try {
|
|
const keys = Object.keys(val);
|
|
return keys.length <= 3 ? "{" + keys.join(", ") + "}" : "{" + keys.slice(0, 3).join(", ") + ", ...}";
|
|
} catch { return "{...}"; }
|
|
}
|
|
return String(val).slice(0, 40);
|
|
}
|
|
|
|
function getChanges(fiber) {
|
|
const changes = [];
|
|
const alt = fiber.alternate;
|
|
if (!alt) { changes.push({ type: "mount" }); return changes; }
|
|
if (fiber.memoizedProps !== alt.memoizedProps) {
|
|
const curr = fiber.memoizedProps || {};
|
|
const prev = alt.memoizedProps || {};
|
|
const allKeys = new Set([...Object.keys(curr), ...Object.keys(prev)]);
|
|
for (const k of allKeys) {
|
|
if (k !== "children" && curr[k] !== prev[k]) {
|
|
changes.push({ type: "props", name: k, prev: brief(prev[k]), next: brief(curr[k]) });
|
|
}
|
|
}
|
|
}
|
|
if (fiber.memoizedState !== alt.memoizedState) {
|
|
let curr = fiber.memoizedState;
|
|
let prev = alt.memoizedState;
|
|
let hookIdx = 0;
|
|
while (curr || prev) {
|
|
if ((curr && curr.memoizedState) !== (prev && prev.memoizedState)) {
|
|
changes.push({
|
|
type: "state",
|
|
name: "hook #" + hookIdx,
|
|
prev: brief(prev && prev.memoizedState),
|
|
next: brief(curr && curr.memoizedState),
|
|
});
|
|
}
|
|
curr = curr && curr.next;
|
|
prev = prev && prev.next;
|
|
hookIdx++;
|
|
}
|
|
}
|
|
if (fiber.dependencies && fiber.dependencies.firstContext) {
|
|
let ctx = fiber.dependencies.firstContext;
|
|
let altCtx = alt.dependencies && alt.dependencies.firstContext;
|
|
while (ctx) {
|
|
if (!altCtx || ctx.memoizedValue !== (altCtx && altCtx.memoizedValue)) {
|
|
const ctxName =
|
|
(ctx.context && ctx.context.displayName) ||
|
|
(ctx.context && ctx.context.Provider && ctx.context.Provider.displayName) ||
|
|
"unknown";
|
|
changes.push({
|
|
type: "context",
|
|
name: ctxName,
|
|
prev: brief(altCtx && altCtx.memoizedValue),
|
|
next: brief(ctx.memoizedValue),
|
|
});
|
|
}
|
|
ctx = ctx.next;
|
|
altCtx = altCtx && altCtx.next;
|
|
}
|
|
}
|
|
if (changes.length === 0) {
|
|
let parent = fiber.return;
|
|
while (parent) {
|
|
const pName = getName(parent);
|
|
if (pName) {
|
|
const suffix = !parent.alternate ? " (mount)" : "";
|
|
changes.push({ type: "parent", name: pName + suffix });
|
|
break;
|
|
}
|
|
parent = parent.return;
|
|
}
|
|
if (changes.length === 0) changes.push({ type: "parent", name: "unknown" });
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
function childrenTime(fiber) {
|
|
let t = 0;
|
|
let child = fiber.child;
|
|
while (child) {
|
|
if (typeof child.actualDuration === "number") t += child.actualDuration;
|
|
child = child.sibling;
|
|
}
|
|
return t;
|
|
}
|
|
|
|
function hasDomMutation(fiber) {
|
|
if (!fiber.alternate) return true;
|
|
let child = fiber.child;
|
|
while (child) {
|
|
if (typeof child.type === "string" && (child.flags & 6) > 0) return true;
|
|
child = child.sibling;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function walkFiber(fiber) {
|
|
if (!fiber) return;
|
|
const tag = fiber.tag;
|
|
if (tag === 0 || tag === 1 || tag === 2 || tag === 11 || tag === 15) {
|
|
const didRender =
|
|
fiber.alternate === null ||
|
|
fiber.flags > 0 ||
|
|
fiber.memoizedProps !== (fiber.alternate && fiber.alternate.memoizedProps) ||
|
|
fiber.memoizedState !== (fiber.alternate && fiber.alternate.memoizedState);
|
|
if (didRender) {
|
|
const name = getName(fiber);
|
|
if (name) {
|
|
if (!(name in data) && Object.keys(data).length >= MAX_COMPONENTS) {
|
|
// at cap - skip
|
|
} else {
|
|
if (!data[name]) {
|
|
data[name] = {
|
|
count: 0, mounts: 0, totalTime: 0, selfTime: 0,
|
|
domMutations: 0, changes: [], _instances: new Set(),
|
|
};
|
|
}
|
|
data[name].count++;
|
|
if (!fiber.alternate) data[name].mounts++;
|
|
if (!data[name]._instances.has(fiber)) {
|
|
data[name]._instances.add(fiber);
|
|
if (fiber.alternate) data[name]._instances.add(fiber.alternate);
|
|
}
|
|
if (typeof fiber.actualDuration === "number") {
|
|
data[name].totalTime += fiber.actualDuration;
|
|
data[name].selfTime += Math.max(0, fiber.actualDuration - childrenTime(fiber));
|
|
}
|
|
if (hasDomMutation(fiber)) data[name].domMutations++;
|
|
const ch = getChanges(fiber);
|
|
for (const c of ch) {
|
|
if (data[name].changes.length < 50) data[name].changes.push(c);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
walkFiber(fiber.child);
|
|
walkFiber(fiber.sibling);
|
|
}
|
|
})()
|
|
"#;
|
|
|
|
/// Stop script for fiber profiler. Returns the collected profile as JSON.
|
|
pub const RENDERS_STOP: &str = r#"
|
|
(() => {
|
|
const active = window.__AB_RENDERS_ACTIVE__;
|
|
if (!active) throw new Error("renders recording not active - run `react renders start` first");
|
|
|
|
const data = window.__AB_RENDERS__;
|
|
const startTime = window.__AB_RENDERS_START__;
|
|
const elapsed = performance.now() - startTime;
|
|
|
|
const fpsData = window.__AB_RENDERS_FPS__;
|
|
let fpsStats = { avg: 0, min: 0, max: 0, drops: 0 };
|
|
if (fpsData) {
|
|
cancelAnimationFrame(fpsData.rafId);
|
|
if (fpsData.frames.length > 0) {
|
|
const fpsSamples = fpsData.frames.map((dt) => (dt > 0 ? 1000 / dt : 0));
|
|
const sum = fpsSamples.reduce((a, b) => a + b, 0);
|
|
fpsStats = {
|
|
avg: Math.round(sum / fpsSamples.length),
|
|
min: Math.round(Math.min(...fpsSamples)),
|
|
max: Math.round(Math.max(...fpsSamples)),
|
|
drops: fpsSamples.filter((f) => f < 30).length,
|
|
};
|
|
}
|
|
}
|
|
|
|
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
const orig = window.__AB_RENDERS_ORIG_COMMIT__;
|
|
if (hook) hook.onCommitFiberRoot = orig || undefined;
|
|
|
|
delete window.__AB_RENDERS__;
|
|
delete window.__AB_RENDERS_START__;
|
|
delete window.__AB_RENDERS_ACTIVE__;
|
|
delete window.__AB_RENDERS_ORIG_COMMIT__;
|
|
delete window.__AB_RENDERS_FPS__;
|
|
|
|
if (!data) {
|
|
return JSON.stringify({
|
|
elapsed: 0, fps: fpsStats, totalRenders: 0, totalMounts: 0,
|
|
totalReRenders: 0, totalComponents: 0, components: [],
|
|
});
|
|
}
|
|
|
|
const round = (n) => Math.round(n * 100) / 100;
|
|
const components = Object.entries(data)
|
|
.map(([name, entry]) => {
|
|
const summary = {};
|
|
for (const c of entry.changes) {
|
|
const key = c.type === "props" ? "props." + c.name
|
|
: c.type === "state" ? "state (" + c.name + ")"
|
|
: c.type === "context" ? "context (" + c.name + ")"
|
|
: c.type === "parent" ? "parent (" + c.name + ")"
|
|
: c.type;
|
|
summary[key] = (summary[key] || 0) + 1;
|
|
}
|
|
return {
|
|
name,
|
|
count: entry.count,
|
|
mounts: entry.mounts,
|
|
reRenders: entry.count - entry.mounts,
|
|
instanceCount: entry._instances.size,
|
|
totalTime: round(entry.totalTime),
|
|
selfTime: round(entry.selfTime),
|
|
domMutations: entry.domMutations,
|
|
changes: entry.changes,
|
|
changeSummary: summary,
|
|
};
|
|
})
|
|
.sort((a, b) => b.totalTime - a.totalTime || b.count - a.count);
|
|
|
|
return JSON.stringify({
|
|
elapsed: round(elapsed / 1000),
|
|
fps: fpsStats,
|
|
totalRenders: components.reduce((s, c) => s + c.count, 0),
|
|
totalMounts: components.reduce((s, c) => s + c.mounts, 0),
|
|
totalReRenders: components.reduce((s, c) => s + c.reRenders, 0),
|
|
totalComponents: components.length,
|
|
components,
|
|
});
|
|
})()
|
|
"#;
|
|
|
|
/// Suspense boundary walker. Returns boundaries with suspendedBy metadata as JSON.
|
|
pub const SUSPENSE_WALK: &str = r#"
|
|
(async () => {
|
|
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
if (!hook) throw new Error("React DevTools hook not installed - relaunch with --enable react-devtools");
|
|
{{PICK_RI}}
|
|
const __abRiId = __abPickReactRendererId(hook);
|
|
const ri = (__abRiId != null && hook.rendererInterfaces && hook.rendererInterfaces.get) ? hook.rendererInterfaces.get(__abRiId) : null;
|
|
if (!ri) throw new Error("No React renderer attached");
|
|
|
|
const batches = await new Promise((resolve) => {
|
|
const out = [];
|
|
const origEmit = hook.emit;
|
|
hook.emit = function (event, payload) {
|
|
if (event === "operations") out.push(payload);
|
|
return origEmit.apply(this, arguments);
|
|
};
|
|
ri.flushInitialOperations();
|
|
setTimeout(() => {
|
|
hook.emit = origEmit;
|
|
resolve(out);
|
|
}, 50);
|
|
});
|
|
|
|
const boundaryMap = new Map();
|
|
for (const ops of batches) decodeSuspenseOps(ops, boundaryMap);
|
|
|
|
const results = [];
|
|
for (const b of boundaryMap.values()) {
|
|
if (b.parentID === 0) continue;
|
|
const boundary = {
|
|
id: b.id,
|
|
parentID: b.parentID,
|
|
name: b.name,
|
|
isSuspended: b.isSuspended,
|
|
environments: b.environments,
|
|
suspendedBy: [],
|
|
unknownSuspenders: null,
|
|
owners: [],
|
|
jsxSource: null,
|
|
};
|
|
if (ri.hasElementWithId(b.id)) {
|
|
const displayName = ri.getDisplayNameForElementID(b.id);
|
|
if (displayName) boundary.name = displayName;
|
|
const result = ri.inspectElement(__abRiId, b.id, null, true);
|
|
if (result && result.type === "full-data") {
|
|
parseInspection(boundary, result.value);
|
|
}
|
|
}
|
|
results.push(boundary);
|
|
}
|
|
return JSON.stringify(results);
|
|
|
|
function decodeSuspenseOps(ops, map) {
|
|
let i = 2;
|
|
const strings = [null];
|
|
const tableEnd = ++i + ops[i - 1];
|
|
while (i < tableEnd) {
|
|
const len = ops[i++];
|
|
strings.push(String.fromCodePoint(...ops.slice(i, i + len)));
|
|
i += len;
|
|
}
|
|
while (i < ops.length) {
|
|
const op = ops[i];
|
|
if (op === 1) {
|
|
const type = ops[i + 2];
|
|
i += 3 + (type === 11 ? 4 : 5);
|
|
} else if (op === 2) {
|
|
i += 2 + ops[i + 1];
|
|
} else if (op === 3) {
|
|
i += 3 + ops[i + 2];
|
|
} else if (op === 4) {
|
|
i += 3;
|
|
} else if (op === 5) {
|
|
i += 4;
|
|
} else if (op === 6) {
|
|
i++;
|
|
} else if (op === 7) {
|
|
i += 3;
|
|
} else if (op === 8) {
|
|
const id = ops[i + 1];
|
|
const parentID = ops[i + 2];
|
|
const nameStrID = ops[i + 3];
|
|
const isSuspended = ops[i + 4] === 1;
|
|
const numRects = ops[i + 5];
|
|
i += 6;
|
|
if (numRects !== -1) i += numRects * 4;
|
|
map.set(id, { id, parentID, name: strings[nameStrID] || null, isSuspended, environments: [] });
|
|
} else if (op === 9) {
|
|
i += 2 + ops[i + 1];
|
|
} else if (op === 10) {
|
|
i += 3 + ops[i + 2];
|
|
} else if (op === 11) {
|
|
const numRects = ops[i + 2];
|
|
i += 3;
|
|
if (numRects !== -1) i += numRects * 4;
|
|
} else if (op === 12) {
|
|
i++;
|
|
const changeLen = ops[i++];
|
|
for (let c = 0; c < changeLen; c++) {
|
|
const id = ops[i++];
|
|
i++;
|
|
i++;
|
|
const isSuspended = ops[i++] === 1;
|
|
const envLen = ops[i++];
|
|
const envs = [];
|
|
for (let e = 0; e < envLen; e++) {
|
|
const n = strings[ops[i++]];
|
|
if (n != null) envs.push(n);
|
|
}
|
|
const node = map.get(id);
|
|
if (node) {
|
|
node.isSuspended = isSuspended;
|
|
for (const env of envs) {
|
|
if (!node.environments.includes(env)) node.environments.push(env);
|
|
}
|
|
}
|
|
}
|
|
} else if (op === 13) {
|
|
i += 2;
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseInspection(boundary, data) {
|
|
const rawSuspendedBy = data.suspendedBy;
|
|
const rawSuspenders = Array.isArray(rawSuspendedBy)
|
|
? rawSuspendedBy
|
|
: rawSuspendedBy && Array.isArray(rawSuspendedBy.data) ? rawSuspendedBy.data : null;
|
|
if (rawSuspenders) {
|
|
for (const entry of rawSuspenders) {
|
|
const awaited = entry && entry.awaited;
|
|
if (!awaited) continue;
|
|
const desc = preview(awaited.description) || preview(awaited.value);
|
|
boundary.suspendedBy.push({
|
|
name: awaited.name || "unknown",
|
|
description: desc,
|
|
duration: awaited.end && awaited.start ? Math.round(awaited.end - awaited.start) : 0,
|
|
env: awaited.env || (entry && entry.env) || null,
|
|
ownerName: (awaited.owner && awaited.owner.displayName) || null,
|
|
ownerStack: parseStack((awaited.owner && awaited.owner.stack) || awaited.stack),
|
|
awaiterName: (entry && entry.owner && entry.owner.displayName) || null,
|
|
awaiterStack: parseStack((entry && entry.owner && entry.owner.stack) || (entry && entry.stack)),
|
|
});
|
|
}
|
|
}
|
|
if (data.unknownSuspenders && data.unknownSuspenders !== 0) {
|
|
const reasons = {
|
|
1: "production build (no debug info)",
|
|
2: "old React version (missing tracking)",
|
|
3: "thrown Promise (library using throw instead of use())",
|
|
};
|
|
boundary.unknownSuspenders = reasons[data.unknownSuspenders] || "unknown reason";
|
|
}
|
|
if (Array.isArray(data.owners)) {
|
|
for (const o of data.owners) {
|
|
if (o && o.displayName) {
|
|
const src = Array.isArray(o.stack) && o.stack.length > 0 && Array.isArray(o.stack[0])
|
|
? [o.stack[0][1] || "(unknown)", o.stack[0][2], o.stack[0][3]]
|
|
: null;
|
|
boundary.owners.push({ name: o.displayName, env: o.env || null, source: src });
|
|
}
|
|
}
|
|
}
|
|
if (Array.isArray(data.stack) && data.stack.length > 0) {
|
|
const frame = data.stack[0];
|
|
if (Array.isArray(frame) && frame.length >= 4) {
|
|
boundary.jsxSource = [frame[1] || "(unknown)", frame[2], frame[3]];
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseStack(raw) {
|
|
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
return raw
|
|
.filter((f) => Array.isArray(f) && f.length >= 4)
|
|
.map((f) => [f[0] || "", f[1] || "", f[2] || 0, f[3] || 0]);
|
|
}
|
|
|
|
function preview(v) {
|
|
if (v == null) return "";
|
|
if (typeof v === "string") return v;
|
|
if (typeof v !== "object") return String(v);
|
|
if (typeof v.preview_long === "string") return v.preview_long;
|
|
if (typeof v.preview_short === "string") return v.preview_short;
|
|
if (typeof v.value === "string") return v.value;
|
|
try {
|
|
const s = JSON.stringify(v);
|
|
return s.length > 80 ? s.slice(0, 77) + "..." : s;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
})()
|
|
"#;
|
|
|
|
/// Init script for Core Web Vitals + React hydration timing capture. Installs
|
|
/// PerformanceObservers for LCP/CLS and intercepts `console.timeStamp` to
|
|
/// capture React's profiling reconciler timings. Idempotent.
|
|
pub const VITALS_INIT: &str = r#"
|
|
(() => {
|
|
if (window.__AB_VITALS_INSTALLED__) return;
|
|
window.__AB_VITALS_INSTALLED__ = true;
|
|
|
|
const cwv = { lcp: null, cls: 0, clsEntries: [], fcp: null, inp: null };
|
|
window.__AB_VITALS__ = cwv;
|
|
|
|
try {
|
|
new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
if (entries.length > 0) {
|
|
const last = entries[entries.length - 1];
|
|
cwv.lcp = {
|
|
startTime: Math.round(last.startTime * 100) / 100,
|
|
size: last.size,
|
|
element: last.element && last.element.tagName ? last.element.tagName.toLowerCase() : null,
|
|
url: last.url || null,
|
|
};
|
|
}
|
|
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
} catch {}
|
|
|
|
try {
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
if (!entry.hadRecentInput) {
|
|
cwv.cls += entry.value;
|
|
cwv.clsEntries.push({
|
|
value: Math.round(entry.value * 10000) / 10000,
|
|
startTime: Math.round(entry.startTime * 100) / 100,
|
|
});
|
|
}
|
|
}
|
|
}).observe({ type: "layout-shift", buffered: true });
|
|
} catch {}
|
|
|
|
try {
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
if (entry.name === "first-contentful-paint") {
|
|
cwv.fcp = Math.round(entry.startTime * 100) / 100;
|
|
}
|
|
}
|
|
}).observe({ type: "paint", buffered: true });
|
|
} catch {}
|
|
|
|
try {
|
|
new PerformanceObserver((list) => {
|
|
let worst = cwv.inp || 0;
|
|
for (const entry of list.getEntries()) {
|
|
if (entry.duration > worst) worst = entry.duration;
|
|
}
|
|
if (worst > 0) cwv.inp = Math.round(worst * 100) / 100;
|
|
}).observe({ type: "event", buffered: true, durationThreshold: 40 });
|
|
} catch {}
|
|
|
|
// React profiling build emits console.timeStamp(label, start, end, track, trackGroup, color)
|
|
// for reconciler phases and per-component hydration timing. Intercept and collect.
|
|
const timing = [];
|
|
window.__AB_REACT_TIMING__ = timing;
|
|
const orig = console.timeStamp;
|
|
console.timeStamp = function (label) {
|
|
const args = arguments;
|
|
if (typeof label === "string" && args.length >= 3 && typeof args[1] === "number") {
|
|
timing.push({
|
|
label,
|
|
startTime: args[1],
|
|
endTime: args[2],
|
|
track: args[3] || "",
|
|
trackGroup: args[4] || "",
|
|
color: args[5] || "",
|
|
});
|
|
}
|
|
return orig.apply(console, args);
|
|
};
|
|
})()
|
|
"#;
|
|
|
|
/// Read script for vitals — collects observed metrics plus Navigation Timing
|
|
/// TTFB and any React hydration phases. Returns JSON.
|
|
pub const VITALS_READ: &str = r#"
|
|
(() => {
|
|
const cwv = window.__AB_VITALS__ || {};
|
|
const timing = window.__AB_REACT_TIMING__ || [];
|
|
const nav = performance.getEntriesByType("navigation")[0];
|
|
const ttfb = nav
|
|
? Math.round((nav.responseStart - nav.requestStart) * 100) / 100
|
|
: null;
|
|
return JSON.stringify({ cwv, timing, ttfb });
|
|
})()
|
|
"#;
|
|
|
|
/// SPA client-side navigation. Tries the framework router first so Next.js
|
|
/// app/pages router triggers an RSC fetch (pure `history.pushState` would
|
|
/// be shallow routing and bypass data loading). Falls back to
|
|
/// `history.pushState` + popstate/navigate events for vanilla pages and
|
|
/// routers that listen to history events (React Router, TanStack Router,
|
|
/// Solid Router, Vue Router).
|
|
pub const PUSHSTATE: &str = r#"
|
|
((url) => {
|
|
const before = location.href;
|
|
const absolute = new URL(url, before).href;
|
|
if (absolute === before) return before;
|
|
|
|
// Next.js pages + app router expose window.next.router with a `push`
|
|
// method that triggers the RSC fetch and re-render pipeline.
|
|
const r = typeof window.next === "object" && window.next && window.next.router;
|
|
if (r && typeof r.push === "function") {
|
|
try { r.push(url); return location.href; } catch {}
|
|
}
|
|
|
|
history.pushState(null, "", absolute);
|
|
try { dispatchEvent(new PopStateEvent("popstate", { state: null })); } catch {}
|
|
try { dispatchEvent(new Event("navigate")); } catch {}
|
|
return location.href;
|
|
})({{URL}})
|
|
"#;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Every renderer-reading script must select its renderer via the injected
|
|
/// `__abPickReactRendererId` helper rather than the old hardcoded
|
|
/// `rendererInterfaces.get(1)`. On Turbopack RSC apps (Next.js 16.3+) the
|
|
/// Flight renderer registers first as id 1 with no fiber roots, so the
|
|
/// hardcoded id made `react tree`/`inspect`/`suspense` read an empty tree.
|
|
#[test]
|
|
fn renderer_scripts_use_picker_not_hardcoded_id_1() {
|
|
for (name, script) in [
|
|
("TREE_SNAPSHOT", TREE_SNAPSHOT),
|
|
("TREE_INSPECT", TREE_INSPECT),
|
|
("SUSPENSE_WALK", SUSPENSE_WALK),
|
|
] {
|
|
assert!(
|
|
script.contains("{{PICK_RI}}"),
|
|
"{name} must inject the renderer picker via the {{{{PICK_RI}}}} placeholder"
|
|
);
|
|
|
|
let full = script.replace("{{PICK_RI}}", PICK_REACT_RENDERER);
|
|
assert!(
|
|
!full.contains("{{PICK_RI}}"),
|
|
"{name} still has an unresolved {{{{PICK_RI}}}} placeholder after injection"
|
|
);
|
|
assert!(
|
|
full.contains("function __abPickReactRendererId"),
|
|
"{name} is missing the picker definition after injection"
|
|
);
|
|
assert!(
|
|
full.contains("__abPickReactRendererId(hook)"),
|
|
"{name} does not call the picker to choose a renderer"
|
|
);
|
|
assert!(
|
|
!full.contains(".get(1)"),
|
|
"{name} still hardcodes rendererInterfaces.get(1)"
|
|
);
|
|
assert!(
|
|
!full.contains("inspectElement(1,"),
|
|
"{name} still hardcodes inspectElement with renderer id 1"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The picker prefers a non-Flight renderer with mounted fiber roots and
|
|
/// falls back gracefully, never assuming a fixed renderer id.
|
|
#[test]
|
|
fn picker_skips_flight_and_falls_back() {
|
|
assert!(PICK_REACT_RENDERER.contains("react-server-dom"));
|
|
assert!(PICK_REACT_RENDERER.contains("getFiberRoots"));
|
|
assert!(PICK_REACT_RENDERER.contains("firstWithRoots"));
|
|
assert!(PICK_REACT_RENDERER.contains("firstAny"));
|
|
}
|
|
}
|