@atlassian/chippy 0.0.1-beta.1 → 0.0.1-beta.2
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/README.md +142 -54
- package/bin/chippy.mjs +53 -95
- package/bin/pack.mjs +20 -0
- package/bin/skill-dir.mjs +15 -0
- package/lib/avalanche.js +54 -23
- package/lib/avalanche.js.map +2 -2
- package/lib/bootstrap.js +20 -1
- package/lib/bootstrap.js.map +3 -3
- package/lib/extension/avalanche.es.js +173 -34
- package/lib/extension/avalanche.es.js.map +1 -1
- package/lib/extension/chippy-page-bridge.js +164 -44
- package/lib/mcp-skill/README.md +2 -4
- package/lib/mcp-skill/SKILL.md +21 -16
- package/lib/mcp-skill/extension/armed-reload-startup.js +83 -0
- package/lib/mcp-skill/extension/avalanche.es.js +173 -34
- package/lib/mcp-skill/extension/avalanche.es.js.map +1 -1
- package/lib/mcp-skill/extension/avalanche.iife.js +173 -34
- package/lib/mcp-skill/extension/avalanche.iife.js.map +1 -1
- package/lib/mcp-skill/extension/background.js +2 -0
- package/lib/mcp-skill/extension/chippy-page-bridge.js +164 -44
- package/lib/mcp-skill/extension/content-script.js +23 -0
- package/lib/mcp-skill/extension/devtools-hook-shim.js +18 -1
- package/lib/mcp-skill/extension/early-bootstrap.js +3 -1
- package/lib/mcp-skill/extension/manifest.json +1 -1
- package/lib/mcp-skill/extension/panel-settings.js +2 -20
- package/lib/mcp-skill/extension/startup-handoff.js +30 -0
- package/lib/mcp-skill/references/README.md +2 -2
- package/lib/mcp-skill/references/avalanche-detector.md +3 -3
- package/lib/mcp-skill/references/browser-runtime-api.md +4 -4
- package/lib/mcp-skill/references/command-output-formats.md +12 -2
- package/lib/mcp-skill/references/command-surface.md +6 -6
- package/lib/mcp-skill/references/mode-a-injection.md +43 -9
- package/lib/mcp-skill/references/playwright-cli-setup.md +5 -5
- package/lib/mcp-skill/references/playwright-probes.md +1 -1
- package/lib/mcp-skill/references/recipes.txt +1 -1
- package/lib/mcp-skill/references/stability-render.md +4 -4
- package/lib/mcp-skill/references/test-mode-playbook.md +2 -1
- package/lib/mcp-skill/references/troubleshooting.md +6 -1
- package/lib/mcp-skill/scripts/chippy-helper-server.js +116 -58
- package/lib/mcp-skill/scripts/chippy.sh +41 -7
- package/lib/mcp-skill/scripts/helper-access.js +20 -6
- package/lib/mcp-skill/scripts/helper-proxy.js +87 -0
- package/lib/mcp-skill/scripts/page-socket-trust.js +81 -0
- package/lib/plugin.js.map +2 -2
- package/lib/storybook/connection-fallback.jsx +1 -1
- package/lib/storybook/manager-owner.js +42 -0
- package/lib/storybook/manager.jsx +22 -0
- package/lib/storybook/preview.js +53 -18
- package/lib/storybook/setup.js +15 -4
- package/lib/types/chippy-bridge/fiber-source.d.ts +14 -3
- package/lib/types/chippy-bridge/react-renderer.d.ts +30 -5
- package/lib/types/chippy-bridge/recorded-source.d.ts +44 -0
- package/lib/types/debug-render/core.d.ts +6 -0
- package/package.json +3 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// Page WebSocket identity checks for the Chippy helper.
|
|
3
|
+
//
|
|
4
|
+
// Any page in the browser can open the page WebSocket, because the pages
|
|
5
|
+
// Chippy inspects run on arbitrary origins. What a page cannot forge is the
|
|
6
|
+
// Origin header its browser sends with the WebSocket handshake. These checks
|
|
7
|
+
// bind a socket's claims to that origin:
|
|
8
|
+
// 1. The page URL a socket reports must belong to its handshake origin, so a
|
|
9
|
+
// page cannot appear to the agent as another site.
|
|
10
|
+
// 2. A client id belongs to the origin that first claimed it. A reload of the
|
|
11
|
+
// same page may reuse its id; a page on another origin may not take it
|
|
12
|
+
// over, even after the original socket closed.
|
|
13
|
+
// A socket without an Origin header is a non-browser client on this machine;
|
|
14
|
+
// such a client can already read the helper token, so its origin is `null`
|
|
15
|
+
// and it is held to the same id binding as any other origin.
|
|
16
|
+
|
|
17
|
+
const MAX_BOUND_CLIENT_IDS = 2000;
|
|
18
|
+
|
|
19
|
+
function handshakeOrigin(headers) {
|
|
20
|
+
const origin = headers && headers.origin;
|
|
21
|
+
return typeof origin === 'string' && origin ? origin : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function urlOrigin(value) {
|
|
25
|
+
try { return new URL(value).origin; } catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Returns null when the page URL is consistent with the socket, otherwise an error.
|
|
29
|
+
function checkPageUrl(socketOrigin, pageUrl) {
|
|
30
|
+
if (socketOrigin === null || pageUrl == null || pageUrl === '') return null;
|
|
31
|
+
const claimed = urlOrigin(String(pageUrl));
|
|
32
|
+
if (claimed === socketOrigin) return null;
|
|
33
|
+
return `page URL ${JSON.stringify(String(pageUrl))} does not belong to the WebSocket origin ${socketOrigin}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pageUrlsOf(msg) {
|
|
37
|
+
const urls = [];
|
|
38
|
+
if (msg && msg.page && msg.page.url != null) urls.push(msg.page.url);
|
|
39
|
+
if (msg && msg.snapshot && msg.snapshot.page && msg.snapshot.page.url != null) urls.push(msg.snapshot.page.url);
|
|
40
|
+
return urls;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createClientIdBindings({ max = MAX_BOUND_CLIENT_IDS } = {}) {
|
|
44
|
+
const owners = new Map();
|
|
45
|
+
|
|
46
|
+
function bind(clientId, origin) {
|
|
47
|
+
owners.delete(clientId);
|
|
48
|
+
owners.set(clientId, origin);
|
|
49
|
+
while (owners.size > max) owners.delete(owners.keys().next().value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Returns null and records the binding when `origin` may use `clientId`.
|
|
53
|
+
function claim(clientId, origin) {
|
|
54
|
+
if (owners.has(clientId) && owners.get(clientId) !== origin) {
|
|
55
|
+
return `client id ${JSON.stringify(clientId)} belongs to another origin`;
|
|
56
|
+
}
|
|
57
|
+
bind(clientId, origin);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { claim, ownerOf: (clientId) => (owners.has(clientId) ? owners.get(clientId) : undefined) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Returns null when `msg` may be applied to a socket from `socketOrigin`
|
|
65
|
+
// currently registered as `currentClientId`, otherwise an error string.
|
|
66
|
+
function checkPageMessage({ bindings, socketOrigin, currentClientId, msg }) {
|
|
67
|
+
for (const url of pageUrlsOf(msg)) {
|
|
68
|
+
const error = checkPageUrl(socketOrigin, url);
|
|
69
|
+
if (error) return error;
|
|
70
|
+
}
|
|
71
|
+
const requested = msg && msg.clientId ? String(msg.clientId) : null;
|
|
72
|
+
if (requested && requested !== currentClientId) return bindings.claim(requested, socketOrigin);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
checkPageMessage,
|
|
78
|
+
checkPageUrl,
|
|
79
|
+
createClientIdBindings,
|
|
80
|
+
handshakeOrigin,
|
|
81
|
+
};
|
package/lib/plugin.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/chippy-bridge/source-map.ts", "../src/chippy-bridge/fiber-source.ts", "../src/layout-lab/fiber-walk.ts", "../src/avalanche/internals.tsx", "../src/package/plugin.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Small, dependency-free Source Map v3 reader.\n *\n * Chippy only needs the generated-position lookup used by React 19's\n * `_debugStack` source path, so it deliberately omits names and source contents.\n */\n\nconst DIGITS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';\nconst VALUE_OF = new Int8Array(128).fill(-1);\nfor (let index = 0; index < DIGITS.length; index += 1) {\n VALUE_OF[DIGITS.charCodeAt(index)] = index;\n}\n\ntype Segment = {\n generatedColumn: number;\n sourceIndex: number | null;\n originalLine: number | null;\n originalColumn: number | null;\n};\n\nexport type SourceMap = {\n sources: readonly string[];\n lines: readonly (readonly Segment[])[];\n offset: { line: number; column: number };\n};\n\nexport type OriginalPosition = {\n source: string;\n line: number;\n column: number;\n};\n\ntype RawMap = {\n version?: unknown;\n sources?: unknown;\n sourceRoot?: unknown;\n mappings?: unknown;\n sections?: unknown;\n};\n\nfunction decodeVlq(field: string): number[] {\n const values: number[] = [];\n let value = 0;\n let shift = 0;\n\n for (let index = 0; index < field.length; index += 1) {\n const code = field.charCodeAt(index);\n const digit = code < 128 ? VALUE_OF[code]! : -1;\n if (digit < 0) return values;\n\n const more = (digit & 32) !== 0;\n value += (digit & 31) << shift;\n if (more) {\n shift += 5;\n continue;\n }\n\n const negative = (value & 1) === 1;\n const magnitude = value >>> 1;\n values.push(negative ? (magnitude === 0 ? -0x80000000 : -magnitude) : magnitude);\n value = 0;\n shift = 0;\n }\n\n return values;\n}\n\nfunction decodeMappings(mappings: string, sourceCount: number): Segment[][] {\n const lines: Segment[][] = [];\n let sourceIndex = 0;\n let originalLine = 0;\n let originalColumn = 0;\n\n for (const encodedLine of mappings.split(';')) {\n const segments: Segment[] = [];\n let generatedColumn = 0;\n\n for (const field of encodedLine === '' ? [] : encodedLine.split(',')) {\n const values = decodeVlq(field);\n if (values.length < 4) {\n if (values.length > 0) {\n generatedColumn += values[0]!;\n segments.push({ generatedColumn, sourceIndex: null, originalLine: null, originalColumn: null });\n }\n continue;\n }\n\n generatedColumn += values[0]!;\n sourceIndex += values[1]!;\n originalLine += values[2]!;\n originalColumn += values[3]!;\n if (sourceIndex >= 0 && sourceIndex < sourceCount) {\n segments.push({ generatedColumn, sourceIndex, originalLine, originalColumn });\n }\n }\n\n segments.sort((left, right) => left.generatedColumn - right.generatedColumn);\n lines.push(segments);\n }\n\n return lines;\n}\n\nfunction resolveSources(raw: RawMap): string[] | null {\n if (!Array.isArray(raw.sources)) return null;\n const root = typeof raw.sourceRoot === 'string' ? raw.sourceRoot : '';\n\n return raw.sources.map((source) => {\n if (typeof source !== 'string') return '';\n if (root === '' || source === '' || /^[a-z][a-z0-9+.-]*:/i.test(source)) return source;\n if (root.endsWith('/') && source.startsWith('/')) return `${root}${source.slice(1)}`;\n if (root.endsWith('/') || source.startsWith('/')) return `${root}${source}`;\n return `${root}/${source}`;\n });\n}\n\nexport function parseSourceMap(text: string): SourceMap[] {\n let raw: RawMap;\n try {\n raw = JSON.parse(text) as RawMap;\n } catch {\n return [];\n }\n\n if (raw === null || typeof raw !== 'object') return [];\n if (raw.version !== 3) return [];\n\n if (Array.isArray(raw.sections)) {\n return raw.sections.flatMap((section) => {\n if (section === null || typeof section !== 'object') return [];\n const { offset, map } = section as { offset?: unknown; map?: unknown };\n if (offset === null || typeof offset !== 'object' || map === null || typeof map !== 'object') return [];\n const { line, column } = offset as { line?: unknown; column?: unknown };\n if (typeof line !== 'number' || typeof column !== 'number') return [];\n return parseSourceMap(JSON.stringify(map)).map((parsed) => ({ ...parsed, offset: { line, column } }));\n });\n }\n\n const sources = resolveSources(raw);\n if (sources === null || typeof raw.mappings !== 'string') return [];\n return [{ sources, lines: decodeMappings(raw.mappings, sources.length), offset: { line: 0, column: 0 } }];\n}\n\nfunction sectionsFor(maps: readonly SourceMap[], line: number, column: number): readonly SourceMap[] {\n if (\n maps.length === 0 ||\n (maps.length === 1 && maps[0]?.offset.line === 0 && maps[0]?.offset.column === 0)\n ) return maps;\n const zeroBasedLine = line - 1;\n const candidates = maps.filter(\n (map) =>\n map.offset.line < zeroBasedLine ||\n (map.offset.line === zeroBasedLine && map.offset.column < column),\n );\n return candidates.length === 0 ? [] : [candidates[candidates.length - 1]!];\n}\n\nfunction segmentAt(segments: readonly Segment[], column: number): Segment | undefined {\n let low = 0;\n let high = segments.length - 1;\n let found: Segment | undefined;\n\n while (low <= high) {\n const middle = (low + high) >> 1;\n const segment = segments[middle]!;\n if (segment.generatedColumn <= column) {\n found = segment;\n low = middle + 1;\n } else {\n high = middle - 1;\n }\n }\n\n return found;\n}\n\nexport function originalPositionFor(\n maps: readonly SourceMap[],\n line: number,\n column: number,\n): OriginalPosition | null {\n for (const map of sectionsFor(maps, line, column)) {\n const localLine = line - map.offset.line;\n const localColumn = localLine === 1 ? column - map.offset.column : column;\n const segments = map.lines[localLine - 1];\n if (segments === undefined || segments.length === 0) continue;\n\n const found = segmentAt(segments, localColumn - 1);\n if (found === undefined) continue;\n if (\n found.sourceIndex === null ||\n found.originalLine === null ||\n found.originalColumn === null\n ) return null;\n const source = map.sources[found.sourceIndex];\n if (source === undefined || source === '') continue;\n return { source, line: found.originalLine + 1, column: found.originalColumn + 1 };\n }\n\n return null;\n}\n\nexport function sourceMappingUrlOf(code: string): string | null {\n const found = [...code.matchAll(/^[/*]{2,}[#@]\\s*sourceMappingURL=(\\S+)\\s*(?:\\*\\/)?$/gm)];\n return found.at(-1)?.[1] ?? null;\n}\n\nexport function inlineSourceMapOf(url: string): string | null {\n const match = /^data:application\\/json[^,]*,(.*)$/s.exec(url);\n if (match === null) return null;\n const payload = match[1]!;\n\n if (!/;base64/i.test(url)) {\n try {\n return decodeURIComponent(payload);\n } catch {\n return null;\n }\n }\n\n try {\n const decode = (globalThis as { atob?: (input: string) => string }).atob;\n if (decode) {\n const binary = decode(payload);\n const Decoder = (globalThis as {\n TextDecoder?: new () => { decode(bytes: Uint8Array): string };\n }).TextDecoder;\n return Decoder\n ? new Decoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)))\n : binary;\n }\n const buffer = (globalThis as {\n Buffer?: { from(value: string, encoding: string): { toString(encoding: string): string } };\n }).Buffer;\n return buffer?.from(payload, 'base64').toString('utf8') ?? null;\n } catch {\n return null;\n }\n}\n", "import {\n inlineSourceMapOf,\n originalPositionFor,\n parseSourceMap,\n sourceMappingUrlOf,\n type OriginalPosition,\n type SourceMap,\n} from './source-map';\n\nexport type FiberSourceLocation = {\n fileName: string;\n lineNumber: number;\n columnNumber: number | null;\n};\n\nexport type StackFrame = {\n url: string;\n line: number;\n column: number;\n function?: string;\n};\n\nexport type SourceFiber = {\n type?: unknown;\n _debugSource?: unknown;\n _debugStack?: unknown;\n};\n\nexport type FetchModule = (url: string) => Promise<string | null>;\n\nexport type CallSiteResolver = {\n locate(frames: readonly StackFrame[]): Promise<FiberSourceLocation | null>;\n readonly stats: { modules: number; sites: number; located: number };\n};\n\ntype ResolvedMaps = {\n maps: readonly SourceMap[];\n sourceBaseUrl: string;\n};\n\nconst V8_FRAME = /^\\s*at\\s+(?:(.+?)\\s+\\()?(.+?):(\\d+):(\\d+)\\)?$/;\nconst AT_FRAME = /^\\s*(?:(.*?)@)(.+?):(\\d+):(\\d+)$/;\nconst MAX_CALL_SITE_FRAMES = 4;\n\nconst resolvedFiberSources = new WeakMap<object, FiberSourceLocation | null>();\nconst pendingFiberSources = new WeakMap<object, Promise<FiberSourceLocation | null>>();\nlet defaultResolver: CallSiteResolver | null = null;\n\nexport function parseStackFrames(stack: string): StackFrame[] {\n const frames: StackFrame[] = [];\n\n for (const raw of stack.split('\\n')) {\n const line = raw.trimEnd();\n if (line === '' || line.includes('(eval at ')) continue;\n const match = V8_FRAME.exec(line) ?? AT_FRAME.exec(line);\n if (!match) continue;\n\n const [, name, url = '', lineDigits = '', columnDigits = ''] = match;\n if (url === '' || url.startsWith('data:')) continue;\n const at = Number(lineDigits);\n const column = Number(columnDigits);\n if (!Number.isFinite(at) || !Number.isFinite(column) || at < 1) continue;\n frames.push({\n url,\n line: at,\n column,\n ...(name !== undefined && name !== '' ? { function: name } : {}),\n });\n }\n\n return frames;\n}\n\nexport function isVendorPath(path: string): boolean {\n return (\n path.includes('node_modules') ||\n path.includes('/.vite/deps/') ||\n path.includes('/.yarn/')\n );\n}\n\nfunction rawLocation(frame: StackFrame): FiberSourceLocation {\n return { fileName: frame.url, lineNumber: frame.line, columnNumber: frame.column };\n}\n\nfunction locationFromOriginal(original: OriginalPosition): FiberSourceLocation {\n return { fileName: original.source, lineNumber: original.line, columnNumber: original.column };\n}\n\nexport function writerLocationOf(\n frames: readonly StackFrame[],\n originalFor: (frame: StackFrame) => OriginalPosition | null,\n): FiberSourceLocation | null {\n for (const frame of frames) {\n if (isVendorPath(frame.url)) continue;\n const original = originalFor(frame);\n if (original === null) return rawLocation(frame);\n if (isVendorPath(original.source)) continue;\n return locationFromOriginal(original);\n }\n return null;\n}\n\nfunction parseUrl(url: string, base?: string): URL | null {\n try {\n return new URL(url, base);\n } catch {\n return null;\n }\n}\n\nfunction sourcePath(source: string, moduleUrl: string): string {\n if (/^\\/(?:Users|home|private|Volumes)\\//.test(source)) return source;\n const direct = parseUrl(source);\n if (direct?.protocol === 'file:') return decodeURIComponent(direct.pathname);\n const resolved = parseUrl(source, moduleUrl)?.href ?? source;\n const parsed = parseUrl(resolved);\n return parsed ? parsed.pathname.replace(/^\\/+/, '') : resolved;\n}\n\nexport function createCallSiteResolver(fetchModule: FetchModule): CallSiteResolver {\n const maps = new Map<string, Promise<ResolvedMaps | null>>();\n const sites = new Map<string, Promise<FiberSourceLocation | null>>();\n const stats = { modules: 0, sites: 0, located: 0 };\n\n async function mapsFor(url: string): Promise<ResolvedMaps | null> {\n const cached = maps.get(url);\n if (cached) return cached;\n\n const pending = (async () => {\n stats.modules += 1;\n const code = await fetchModule(url);\n if (code === null) return null;\n const annotation = sourceMappingUrlOf(code);\n if (annotation === null) return null;\n const inline = inlineSourceMapOf(annotation);\n if (inline !== null) return { maps: parseSourceMap(inline), sourceBaseUrl: url };\n const mapUrl = parseUrl(annotation, url)?.href ?? null;\n const text = mapUrl === null ? null : await fetchModule(mapUrl);\n return text === null || mapUrl === null\n ? null\n : { maps: parseSourceMap(text), sourceBaseUrl: mapUrl };\n })();\n\n maps.set(url, pending);\n return pending;\n }\n\n async function locateFrame(frame: StackFrame): Promise<FiberSourceLocation | null> {\n const parsed = await mapsFor(frame.url);\n if (!parsed || parsed.maps.length === 0) return null;\n const original = originalPositionFor(parsed.maps, frame.line, frame.column);\n if (!original) return null;\n return {\n fileName: sourcePath(original.source, parsed.sourceBaseUrl),\n lineNumber: original.line,\n columnNumber: original.column,\n };\n }\n\n async function locate(frames: readonly StackFrame[]): Promise<FiberSourceLocation | null> {\n for (const frame of frames) {\n if (isVendorPath(frame.url)) continue;\n const key = `${frame.url}:${frame.line}:${frame.column}`;\n let answer = sites.get(key);\n if (!answer) {\n stats.sites += 1;\n answer = locateFrame(frame);\n sites.set(key, answer);\n }\n\n const located = await answer;\n const chosen = located === null\n ? writerLocationOf([frame], () => null)\n : isVendorPath(located.fileName) ? null : located;\n if (chosen) {\n stats.located += 1;\n return chosen;\n }\n }\n return null;\n }\n\n return { locate, stats };\n}\n\nfunction debugSourceOf(value: unknown): FiberSourceLocation | null {\n if (!value || typeof value !== 'object') return null;\n const source = value as { fileName?: unknown; lineNumber?: unknown; columnNumber?: unknown };\n if (typeof source.fileName !== 'string' || typeof source.lineNumber !== 'number') return null;\n return {\n fileName: source.fileName,\n lineNumber: source.lineNumber,\n columnNumber: typeof source.columnNumber === 'number' ? source.columnNumber : null,\n };\n}\n\nfunction recordedSource(fiber: SourceFiber): FiberSourceLocation | null {\n const direct = debugSourceOf(fiber._debugSource);\n if (direct) return direct;\n const type = fiber.type;\n return type && (typeof type === 'object' || typeof type === 'function')\n ? debugSourceOf((type as { _debugSource?: unknown })._debugSource)\n : null;\n}\n\nexport function getFiberStackFrames(fiber: SourceFiber | null | undefined): StackFrame[] {\n if (!fiber || !fiber._debugStack || typeof fiber._debugStack !== 'object') return [];\n const stack = (fiber._debugStack as { stack?: unknown }).stack;\n if (typeof stack !== 'string' || stack === '') return [];\n const candidates: StackFrame[] = [];\n for (const frame of parseStackFrames(stack)) {\n if (isVendorPath(frame.url)) continue;\n candidates.push(frame);\n if (candidates.length >= MAX_CALL_SITE_FRAMES) break;\n }\n return candidates;\n}\n\n/** Stable synchronous source, before any async source-map upgrade. */\nexport function getFiberSourceCandidate(fiber: SourceFiber | null | undefined): FiberSourceLocation | null {\n if (!fiber) return null;\n const recorded = recordedSource(fiber);\n if (recorded) return recorded;\n return writerLocationOf(getFiberStackFrames(fiber), () => null);\n}\n\n/** Synchronous source read used by fiber walks and snapshots. */\nexport function getFiberSource(fiber: SourceFiber | null | undefined): FiberSourceLocation | null {\n if (!fiber) return null;\n const recorded = recordedSource(fiber);\n if (recorded) return recorded;\n const cached = resolvedFiberSources.get(fiber as object);\n if (cached !== undefined) return cached;\n return getFiberSourceCandidate(fiber);\n}\n\nasync function fetchModuleFromPage(url: string): Promise<string | null> {\n const fetchFn = (globalThis as { fetch?: typeof fetch }).fetch;\n if (!fetchFn) return null;\n const controller = typeof AbortController === 'function' ? new AbortController() : null;\n const timeout = controller ? setTimeout(() => controller.abort(), 2000) : null;\n try {\n const response = await fetchFn(url, {\n credentials: 'same-origin',\n signal: controller?.signal,\n });\n return response.ok ? await response.text() : null;\n } catch {\n return null;\n } finally {\n if (timeout !== null) clearTimeout(timeout);\n }\n}\n\nfunction pageResolver(): CallSiteResolver {\n if (!defaultResolver) defaultResolver = createCallSiteResolver(fetchModuleFromPage);\n return defaultResolver;\n}\n\n/** Resolve a React 19 stack frame through the module's inline or external map. */\nexport function resolveFiberSource(\n fiber: SourceFiber | null | undefined,\n resolver: CallSiteResolver = pageResolver(),\n): Promise<FiberSourceLocation | null> {\n if (!fiber) return Promise.resolve(null);\n const recorded = recordedSource(fiber);\n if (recorded) return Promise.resolve(recorded);\n\n const cached = pendingFiberSources.get(fiber as object);\n if (cached) return cached;\n const frames = getFiberStackFrames(fiber);\n const fallback = writerLocationOf(frames, () => null);\n const pending = resolver.locate(frames)\n .catch(() => null)\n .then((source) => {\n const located = source ?? fallback;\n resolvedFiberSources.set(fiber as object, located);\n return located;\n });\n pendingFiberSources.set(fiber as object, pending);\n return pending;\n}\n", "/**\n * Pure fiber-walk helpers extracted from layout-lab.js.\n * No DOM writes, no side effects \u2014 read-only introspection of React fiber trees.\n */\n\nimport {\n getFiberSource as readFiberSource,\n resolveFiberSource as resolveSourceLocation,\n} from '../chippy-bridge/fiber-source';\n\n/**\n * Minimal duck-typed Fiber shape.\n * Only fields used by these helpers are typed; rest may exist but are not read.\n */\nexport type Fiber = {\n type?: unknown;\n elementType?: unknown;\n key?: string | null;\n memoizedState?: unknown;\n memoizedProps?: Record<string, unknown> | null;\n stateNode?: Element | null;\n return?: Fiber | null;\n child?: Fiber | null;\n sibling?: Fiber | null;\n _debugOwner?: Fiber | null;\n _debugSource?: SourceLocation | null;\n _debugStack?: unknown;\n};\n\nexport type SourceLocation = {\n fileName?: string | null;\n lineNumber?: number | null;\n columnNumber?: number | null;\n};\n\nexport type DescribedSource = {\n fileName: string | null;\n lineNumber: number | null;\n columnNumber: number | null;\n};\n\nexport type HookSummary = {\n hookCount: number;\n hookCountTruncated: boolean;\n hookCountMax: number;\n};\n\nexport type OwnerEntry = {\n name: string | null;\n key: string | null;\n props: string[];\n} & HookSummary & {\n source: DescribedSource | null;\n};\n\nexport type FiberTarget = {\n domTag: string | null;\n domText: string;\n fiberType: string | null;\n source: DescribedSource | null;\n};\n\nexport type ComponentDescription = {\n name: string | null;\n key: string | null;\n props: Record<string, unknown>;\n source: DescribedSource | null;\n stateNode: string | null;\n owners: OwnerEntry[];\n parents: OwnerEntry[];\n} & HookSummary;\n\n/**\n * Find the React fiber key in a DOM node's own properties.\n * Returns the property name (e.g. '__reactFiber$abc123') or null.\n */\nexport function getFiberKey(node: Node | Element | null | undefined): string | null {\n if (!node) return null;\n const keys = Object.getOwnPropertyNames(node as object);\n const key = keys.find((k) => k.startsWith('__reactFiber$'));\n return key || null;\n}\n\n/**\n * Get the React fiber attached to a DOM node, or null if not a React host node.\n */\nexport function getFiber(node: Node | Element | null | undefined): Fiber | null {\n if (!node) return null;\n const key = getFiberKey(node);\n // React stores the fiber as an expando property on the host node.\n return key ? ((node as unknown as Record<string, unknown>)[key] as Fiber | null) : null;\n}\n\n/**\n * Extract display name from a React type (class, function, or string).\n * Returns displayName > name > null for functions; 'string' for primitives.\n */\nexport function getDisplayName(type: unknown): string | null {\n if (!type) return null;\n if (typeof type === 'string') return type;\n const obj = type as { displayName?: unknown; name?: unknown };\n return (obj.displayName as string) || (obj.name as string) || null;\n}\n\n/**\n * True if fiber is a user-defined component (not a DOM string, not a hook-internal wrapper).\n */\nexport function isUserComponent(fiber: Fiber | null | undefined): boolean {\n if (!fiber) return false;\n const name = getDisplayName(fiber.type);\n if (!name || typeof fiber.type === 'string') return false;\n if (['hookified', 'unboundStoryFn', 'Anonymous'].includes(name)) return false;\n return true;\n}\n\n/**\n * Walk DOM parents to find the nearest fiber-attached element.\n */\nexport function getNearestFiber(domNode: Node | Element | null | undefined): Fiber | null {\n let current = domNode as Element | null;\n while (current) {\n const fiber = getFiber(current);\n if (fiber) return fiber;\n current = (current as Element).parentElement;\n }\n return null;\n}\n\n/**\n * Walk DOM parents, then walk fiber return chain, to find the nearest user component.\n */\nexport function getNearestComponent(domNode: Node | Element | null | undefined): Fiber | null {\n let fiber = getNearestFiber(domNode);\n while (fiber) {\n if (isUserComponent(fiber)) return fiber;\n fiber = fiber.return || null;\n }\n return null;\n}\n\n/**\n * Count hooks in fiber.memoizedState linked list; detect cycle.\n */\nexport function getHookSummary(fiber: Fiber | null | undefined): HookSummary {\n const MAX_HOOKS = 500;\n const seen = new Set<unknown>();\n let hookNode = fiber?.memoizedState || null;\n let hookCount = 0;\n let hookCountTruncated = false;\n\n while (hookNode) {\n if (seen.has(hookNode)) break;\n if (hookCount >= MAX_HOOKS) {\n hookCountTruncated = true;\n break;\n }\n seen.add(hookNode);\n hookCount += 1;\n hookNode = (hookNode as { next?: unknown }).next || null;\n }\n\n return { hookCount, hookCountTruncated, hookCountMax: MAX_HOOKS };\n}\n\n/**\n * Walk fiber._debugOwner chain; collect all user components with metadata.\n * Returns empty array if fiber or owner chain is null.\n */\nexport function getOwnerChain(fiber: Fiber | null | undefined): OwnerEntry[] {\n const chain: OwnerEntry[] = [];\n let current = fiber?._debugOwner || null;\n while (current) {\n if (isUserComponent(current)) {\n chain.push({\n name: getDisplayName(current.type),\n key: current.key || null,\n props: current.memoizedProps ? Object.keys(current.memoizedProps) : [],\n ...getHookSummary(current),\n source: describeFiberSource(current),\n });\n }\n current = current._debugOwner || null;\n }\n return chain;\n}\n\n/**\n * Walk fiber._debugOwner chain; return raw fiber nodes (for navigation).\n */\nexport function getOwnerFibers(fiber: Fiber | null | undefined): Fiber[] {\n const fibers: Fiber[] = [];\n let current = fiber?._debugOwner || null;\n while (current) {\n if (isUserComponent(current)) fibers.push(current);\n current = current._debugOwner || null;\n }\n return fibers;\n}\n\n/**\n * Recursively walk fiber.child/sibling to find the first host DOM element rendered by this subtree.\n */\nexport function getFiberDomNode(fiber: Fiber | null | undefined): Element | null {\n if (!fiber) return null;\n // Host fiber \u2014 stateNode is a DOM element\n if (fiber.stateNode instanceof Element) return fiber.stateNode;\n // Walk children\n let child = fiber.child || null;\n while (child) {\n const found = getFiberDomNode(child);\n if (found) return found;\n child = child.sibling || null;\n }\n return null;\n}\n\n/**\n * Walk fiber.return chain; collect all user component parents with metadata.\n */\nexport function getReturnComponentChain(fiber: Fiber | null | undefined): OwnerEntry[] {\n const chain: OwnerEntry[] = [];\n let current = fiber?.return || null;\n while (current) {\n if (isUserComponent(current)) {\n chain.push({\n name: getDisplayName(current.type),\n key: current.key || null,\n props: current.memoizedProps ? Object.keys(current.memoizedProps) : [],\n ...getHookSummary(current),\n source: describeFiberSource(current),\n });\n }\n current = current.return || null;\n }\n return chain;\n}\n\n/**\n * Walk fiber.return chain; return raw fiber nodes (for navigation).\n */\nexport function getReturnComponentFibers(fiber: Fiber | null | undefined): Fiber[] {\n const fibers: Fiber[] = [];\n let current = fiber?.return || null;\n while (current) {\n if (isUserComponent(current)) fibers.push(current);\n current = current.return || null;\n }\n return fibers;\n}\n\n/**\n * Normalize source location object to canonical shape.\n */\nexport function describeSource(source: SourceLocation | null | undefined): DescribedSource | null {\n if (!source) return null;\n return {\n fileName: source.fileName || null,\n lineNumber: source.lineNumber || null,\n columnNumber: source.columnNumber || null,\n };\n}\n\n/**\n * Read a fiber source across React versions.\n *\n * React <=18 exposes `_debugSource`. React 19 exposes `_debugStack`; its first\n * non-vendor frame is returned synchronously and may later be upgraded from the\n * bundle position to the original source position by `resolveFiberSource`.\n */\nexport function describeFiberSource(fiber: Fiber | null | undefined): DescribedSource | null {\n return describeSource(readFiberSource(fiber));\n}\n\nexport async function resolveFiberSource(fiber: Fiber | null | undefined): Promise<DescribedSource | null> {\n return describeSource(await resolveSourceLocation(fiber));\n}\n\n/**\n * Describe a fiber + DOM node pair; used for inspection UI.\n */\nexport function describeFiberTarget(fiber: Fiber | null | undefined, domNode: Element | null | undefined): FiberTarget | null {\n if (!fiber && !domNode) return null;\n return {\n domTag: domNode?.tagName?.toLowerCase() || null,\n domText: domNode?.textContent?.trim().slice(0, 60) || '',\n fiberType: getDisplayName(fiber?.type) || getDisplayName(fiber?.elementType) || null,\n source: describeFiberSource(fiber),\n };\n}\n\n/**\n * Full component description: name, props, hook summary, source, owners, parents.\n */\nexport function describeComponent(fiber: Fiber | null | undefined): ComponentDescription | null {\n if (!fiber) return null;\n return {\n name: getDisplayName(fiber.type),\n key: fiber.key || null,\n props: fiber.memoizedProps || {},\n ...getHookSummary(fiber),\n source: describeFiberSource(fiber),\n stateNode: fiber.stateNode instanceof Element ? fiber.stateNode.tagName.toLowerCase() : null,\n owners: getOwnerChain(fiber),\n parents: getReturnComponentChain(fiber),\n };\n}\n", "// React fiber constants and structural comparisons used by the commit detector.\n\nimport { getFiberSourceCandidate } from '../chippy-bridge/fiber-source';\n\nexport type ComponentType =\n | Function\n | symbol\n | {\n // ForwardRef/memo/etc may be objects.\n $$typeof?: symbol;\n render?: Function;\n displayName?: string;\n name?: string;\n };\n\ninterface StoreCheck {\n getSnapshot: () => unknown;\n value: unknown;\n}\n\nexport interface Fiber {\n tag: number;\n type: ComponentType;\n alternate: Fiber | null;\n return: Fiber | null;\n child: Fiber | null;\n sibling: Fiber | null;\n flags: number;\n subtreeFlags: number;\n lanes: number;\n memoizedState: Hook | null;\n memoizedProps?: unknown;\n pendingProps?: unknown;\n updateQueue: { stores: StoreCheck[] | null } | null;\n /**\n * React populates this array during the render phase with fibers that will be\n * deleted in the commit phase. Present when subtreeFlags & ChildDeletion !== 0.\n * Only available in React 18+ (replaces the older effectList pattern).\n */\n deletions?: Fiber[] | null;\n\n // Dev-only / optional debug fields (not always present).\n elementType?: unknown;\n _debugSource?: { fileName: string; lineNumber: number };\n _debugStack?: unknown;\n _debugHookTypes?: string[];\n}\n\nexport interface Hook {\n memoizedState: unknown;\n baseState: unknown;\n baseQueue: unknown;\n queue: HookQueue | null;\n next: Hook | null;\n}\n\ninterface HookQueue {\n dispatch?: Function;\n value?: unknown;\n getSnapshot?: Function;\n lastRenderedState?: unknown;\n}\n\nexport interface DepOrigin {\n type: ComponentType;\n hookIndex: number;\n depth: number;\n hookType: 'useCallback' | 'useMemo';\n}\n\nexport interface ThrashInfo {\n hookIndex: number;\n hookType: 'effect' | 'callback' | 'memo';\n deps: unknown[];\n changedDepIndices: number[];\n changedDepOrigins: (DepOrigin | null)[];\n effectTag?: number;\n memoValue?: unknown;\n /** The hook body function (for useCallback) or memoized value (for useMemo) */\n hookBody?: Function | unknown;\n}\n\nexport type ChangedHook =\n | {\n prevValue: unknown;\n currentValue: unknown;\n info?: string; // For structural changes\n }\n | 'repeat'; // Entire object replaced with string if identical to previous commit\n\nexport interface StateChangeResult {\n hasChange: boolean;\n actionable: boolean;\n changedIndices: number[];\n changedHooks: ChangedHook[];\n /** True if any of the changes came from useSyncExternalStore */\n hasExternalStoreChange: boolean;\n}\n\nexport interface ThrashingResult {\n effects: ThrashInfo[];\n callbacks: ThrashInfo[];\n memos: ThrashInfo[];\n}\n\n// React fiber flags (React 18.3.1)\nexport const PerformedWork = 1;\n\n// Flags relevant to filtering out non-preventable work from collateral counts.\n// - Placement: mount/insert work (initial render)\n// - Hydrating: work done while hydrating server-rendered markup\n// These are stable for React 18.x.\nexport const Placement = 2;\nexport const Hydrating = 4096;\n\n// Special sentinel values for hook change detection\n\n// Component fiber tags for React 18.3.1 (stable within 18.x)\n// 0: FunctionComponent, 1: ClassComponent, 2: IndeterminateComponent\n// 11: MemoComponent, 14: SimpleMemoComponent, 15: ForwardRef\nconst COMPONENT_TAGS = new Set([0, 1, 2, 11, 14, 15]);\n\n// Context Provider tag\nconst CONTEXT_PROVIDER_TAG = 10;\n\n// Suspense fiber tag (React 18.3.1 \u2014 stable within 18.x)\nexport const SuspenseTag = 13;\n\n// ChildDeletion \u2014 set on fiber.flags (NOT subtreeFlags) when the fiber has deletions[].\n// Value 16 is stable for React 18.3.1.\nexport const ChildDeletion = 16;\n\n// Update \u2014 set on fiber.flags when the fiber received an update this commit.\n// This is the flag React puts on Suspense fibers when they transition state.\n// Value 4 is stable for React 18.3.1.\nexport const Update = 4;\n\n// DidCapture \u2014 render-phase flag, cleared before commit. Do NOT check at commit time.\n// Kept for documentation. Value 128 in React 18.3.1.\nexport const DidCapture = 128;\n\nexport function isComponentFiber(tag: number): boolean {\n return COMPONENT_TAGS.has(tag);\n}\n\nexport function isContextProviderFiber(tag: number): boolean {\n return tag === CONTEXT_PROVIDER_TAG;\n}\n\nexport function isSuspenseFiber(tag: number): boolean {\n return tag === SuspenseTag;\n}\n\n/**\n * Detect whether a Suspense boundary transitioned between suspended/unsuspended states.\n *\n * Suspense memoizedState is:\n * null \u2192 primary content is showing (not suspended)\n * non-null (object) \u2192 boundary is showing the fallback (suspended/dehydrated)\n *\n * Returns:\n * { entered: true } \u2014 boundary just entered fallback\n * - null \u2192 non-null (update path)\n * - no alternate + memoizedState non-null (first render directly to fallback)\n * { left: true } \u2014 boundary just left fallback / revealed primary (non-null \u2192 null)\n * null \u2014 no state change (both null, both non-null, or first render not suspended)\n *\n * A Suspense that renders without delay never enters fallback, so memoizedState stays\n * null \u2192 returns null \u2192 correctly ignored by the detector.\n */\nexport function didSuspenseStateChange(\n fiber: Fiber\n): { entered: boolean; left: boolean } | null {\n const currSuspended = fiber.memoizedState !== null;\n\n if (!fiber.alternate) {\n // First render of this Suspense boundary.\n // If it went directly to fallback (memoizedState non-null), that IS an enter.\n // If it rendered primary content (memoizedState null), no transition.\n if (currSuspended) {\n return { entered: true, left: false };\n }\n return null;\n }\n\n const prevSuspended = fiber.alternate.memoizedState !== null;\n if (prevSuspended === currSuspended) return null;\n return { entered: currSuspended, left: prevSuspended };\n}\n\n/**\n * Check if a Context Provider's value changed.\n */\nexport function didContextValueChange(fiber: Fiber): boolean {\n if (fiber.tag !== CONTEXT_PROVIDER_TAG || !fiber.alternate) return false;\n\n type FiberWithProps = Fiber & { memoizedProps?: { value?: unknown } };\n const alternateFiber: FiberWithProps = fiber.alternate;\n const currentFiber: FiberWithProps = fiber;\n\n const prevProps = alternateFiber.memoizedProps;\n const currentProps = currentFiber.memoizedProps;\n\n return prevProps && currentProps && !Object.is(prevProps.value, currentProps.value);\n}\n\n// Detect React built-in fiber types (Suspense/Offscreen/etc).\n// IMPORTANT: Do NOT use component names. User components can be named anything.\n// In React 18, built-ins like Suspense/Offscreen are represented as symbols.\nexport function isReactInternalComponentType(type: ComponentType): boolean {\n return typeof type === 'symbol';\n}\n\nexport function getComponentName(fiber: Fiber): string {\n type TypeWithName = {\n displayName?: string;\n name?: string;\n };\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n const type = fiber.type as TypeWithName;\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n const elementType = fiber.elementType as TypeWithName;\n const named = (\n type?.displayName ||\n type?.name ||\n elementType?.displayName ||\n elementType?.name\n );\n if (named) return named;\n const source = getFiberSourceCandidate(fiber);\n return (source && `<${source.fileName}:${source.lineNumber}>`) || 'Anonymous';\n}\n\n/**\n * Check if hook is a state hook (useState/useReducer/useSyncExternalStore).\n *\n * All hooks share a base shape. The reliable differentiators:\n * - useState/useReducer: queue.dispatch is a function\n * - useSyncExternalStore: queue has 'value' and 'getSnapshot' properties\n */\nexport function isStateHook(hook: Hook): boolean {\n const queue = hook.queue;\n if (!queue) return false;\n\n return typeof queue.dispatch === 'function' || ('value' in queue && 'getSnapshot' in queue);\n}\n\n/** True if the hook is a useSyncExternalStore-like hook. */\nexport function isExternalStoreHook(hook: Hook): boolean {\n const queue = hook.queue;\n return (\n queue && typeof queue.dispatch !== 'function' && 'value' in queue && 'getSnapshot' in queue\n );\n}\n\n/**\n * Compare hook chains to detect state changes.\n *\n * IMPORTANT: Hook indices are React internal memoizedState indices, NOT code order.\n * `useContext` hooks are not in memoizedState, causing index misalignment.\n */\nexport function detectHookStateChanges(\n prevHookChain: Hook | null,\n currentHookChain: Hook | null\n): StateChangeResult {\n if (prevHookChain === currentHookChain) {\n return {\n hasChange: false,\n actionable: false,\n changedIndices: [],\n changedHooks: [],\n hasExternalStoreChange: false,\n };\n }\n\n let prevHook = prevHookChain;\n let currentHook = currentHookChain;\n let memoizedStateIndex = 0;\n const changedIndices: number[] = [];\n const changedHooks: ChangedHook[] = [];\n let hasExternalStoreChange = false;\n\n while (prevHook && currentHook) {\n if (isStateHook(currentHook)) {\n const prevValueRaw =\n prevHook.memoizedState === undefined\n ? prevHook.queue?.lastRenderedState\n : prevHook.memoizedState;\n const curValueRaw =\n currentHook.memoizedState === undefined\n ? currentHook.queue?.lastRenderedState\n : currentHook.memoizedState;\n\n if (!Object.is(prevValueRaw, curValueRaw)) {\n const isExternal = isExternalStoreHook(currentHook);\n if (isExternal) hasExternalStoreChange = true;\n\n changedIndices.push(memoizedStateIndex);\n changedHooks.push({\n prevValue: prevValueRaw,\n currentValue: curValueRaw,\n ...(isExternal && { isExternalStore: true }),\n });\n }\n }\n\n prevHook = prevHook.next;\n currentHook = currentHook.next;\n memoizedStateIndex++;\n }\n\n return {\n hasChange: changedIndices.length > 0,\n actionable: changedIndices.length > 0,\n changedIndices,\n changedHooks,\n hasExternalStoreChange,\n };\n}\n\n/**\n * Detect thrashing for hooks with deps: useEffect/useLayoutEffect/useInsertionEffect,\n * useCallback and useMemo.\n */\nexport function detectHookThrashing(\n prevHookChain: Hook | null,\n currentHookChain: Hook | null,\n mode: 'non-primitive' | 'all' = 'non-primitive',\n valueProvenance: WeakMap<object | Function, DepOrigin> | null = null\n): ThrashingResult {\n if (prevHookChain === currentHookChain) {\n return { effects: [], callbacks: [], memos: [] };\n }\n\n const thrashedEffects: ThrashInfo[] = [];\n const thrashedCallbacks: ThrashInfo[] = [];\n const thrashedMemos: ThrashInfo[] = [];\n\n let prevHook = prevHookChain;\n let currentHook = currentHookChain;\n let hookIndex = 0;\n\n while (prevHook && currentHook) {\n if (!isStateHook(currentHook)) {\n const prevValue = prevHook.memoizedState;\n const currentValue = currentHook.memoizedState;\n\n let prevDeps: unknown[] | null = null;\n let currentDeps: unknown[] | null = null;\n let hookType: ThrashInfo['hookType'] | null = null;\n\n // Effect hooks store an object with `deps`.\n if (\n prevValue &&\n currentValue &&\n typeof prevValue === 'object' &&\n typeof currentValue === 'object' &&\n 'deps' in prevValue &&\n 'deps' in currentValue &&\n 'tag' in currentValue\n ) {\n type EffectHook = { deps: unknown[] };\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n prevDeps = (prevValue as EffectHook).deps;\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n currentDeps = (currentValue as EffectHook).deps;\n hookType = 'effect';\n }\n // useMemo/useCallback store [result, deps].\n else if (\n Array.isArray(prevValue) &&\n Array.isArray(currentValue) &&\n prevValue.length === 2 &&\n currentValue.length === 2 &&\n Array.isArray(prevValue[1]) &&\n Array.isArray(currentValue[1])\n ) {\n prevDeps = prevValue[1];\n currentDeps = currentValue[1];\n hookType = typeof currentValue[0] === 'function' ? 'callback' : 'memo';\n }\n\n if (hookType && Array.isArray(prevDeps) && Array.isArray(currentDeps)) {\n const changedDepIndices: number[] = [];\n const changedDepOrigins: (DepOrigin | null)[] = [];\n\n // eslint-disable-next-line @atlassian/perf-linting/no-quadratic-complexity\n for (let j = 0; j < currentDeps.length; j++) {\n if (!Object.is(prevDeps[j], currentDeps[j])) {\n changedDepIndices.push(j);\n\n let origin: DepOrigin | null = null;\n const dep = currentDeps[j];\n if (\n valueProvenance &&\n dep != null &&\n (typeof dep === 'object' || typeof dep === 'function')\n ) {\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n origin = valueProvenance.get(dep as object | ((...args: never[]) => unknown)) || null;\n }\n changedDepOrigins.push(origin);\n }\n }\n\n let shouldReport = changedDepIndices.length > 0;\n if (shouldReport && mode === 'non-primitive') {\n // Only report if at least one non-primitive dep changed.\n // eslint-disable-next-line @atlassian/perf-linting/no-quadratic-complexity\n shouldReport = changedDepIndices.some((idx) => {\n const dep = currentDeps[idx];\n const t = typeof dep;\n return !(\n dep == null ||\n t === 'string' ||\n t === 'number' ||\n t === 'boolean' ||\n t === 'symbol' ||\n t === 'bigint'\n );\n });\n }\n\n if (shouldReport) {\n const thrashInfo: ThrashInfo = {\n hookIndex,\n hookType,\n deps: currentDeps,\n changedDepIndices,\n changedDepOrigins,\n };\n\n if (hookType === 'effect') {\n type EffectValue = { tag?: number; create?: Function };\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n const effectValue = currentValue as EffectValue;\n thrashInfo.effectTag = effectValue.tag;\n // Capture the effect function itself (the create function)\n if (effectValue.create) {\n thrashInfo.hookBody = effectValue.create;\n }\n thrashedEffects.push(thrashInfo);\n } else if (hookType === 'callback') {\n type CallbackValue = [Function, unknown[]];\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n const callbackFn = (currentValue as CallbackValue)[0];\n // Capture the callback function itself\n thrashInfo.hookBody = callbackFn;\n thrashedCallbacks.push(thrashInfo);\n } else {\n type MemoValue = [unknown, unknown[]];\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n const memoValue = (currentValue as MemoValue)[0];\n thrashInfo.memoValue = memoValue;\n // For useMemo, also expose the memoized value as hookBody\n thrashInfo.hookBody = memoValue;\n thrashedMemos.push(thrashInfo);\n }\n }\n }\n }\n\n prevHook = prevHook.next;\n currentHook = currentHook.next;\n hookIndex++;\n }\n\n return {\n effects: thrashedEffects,\n callbacks: thrashedCallbacks,\n memos: thrashedMemos,\n };\n}\n\nexport function didFiberUpdate(fiber: Fiber): boolean {\n // eslint-disable-next-line no-bitwise\n return (fiber.flags & PerformedWork) !== 0;\n}\n\n/**\n * Detect if fiber has actionable hook state changes by comparing with fiber.alternate.\n *\n * \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n * \u2502 \u26A0\uFE0F WARNING: fiber.alternate COMPARISON IS UNRELIABLE \u2502\n * \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n *\n * This function compares fiber.memoizedState vs fiber.alternate.memoizedState.\n *\n * THE PROBLEM:\n * fiber.alternate is NOT always \"state from the previous commit\".\n * It's \"state from last time this fiber was used as work-in-progress\".\n *\n * If a component re-renders due to parent/context changes (not self-update),\n * its alternate may be stale - from 1 commit ago, or 100 commits ago!\n *\n * This causes FALSE POSITIVES: we detect \"state changed\" when it didn't\n * change in THIS commit - it changed in some PAST commit.\n *\n * WHEN THIS FUNCTION IS RELIABLE:\n * \u2713 Component called setState (it IS the initiator)\n * \u2713 Component has useSyncExternalStore that just triggered\n * \u2713 Used alongside memoizedUpdaters validation\n *\n * WHEN THIS FUNCTION IS UNRELIABLE:\n * \u2717 Component re-rendered due to parent update\n * \u2717 Component re-rendered due to context change\n * \u2717 Component was memoized and skipped N commits\n * \u2717 Any scenario where alternate wasn't recently updated\n *\n * RECOMMENDED USAGE:\n * - In development: Use memoizedUpdaters to verify component is an initiator,\n * THEN use this function to get hook change details.\n * - In production: Use lastSeenHookState tracking (see detector.tsx)\n * instead of relying on fiber.alternate.\n *\n * See detector.tsx for the full explanation of the \"swap\" problem and solution.\n */\nexport function didFiberSelfUpdate(fiber: Fiber): StateChangeResult | null {\n // Early exit: only check fibers that actually rendered (have PerformedWork flag)\n if (!didFiberUpdate(fiber)) return null;\n\n const alt = fiber.alternate;\n if (!alt) return null;\n\n const prevState = alt.memoizedState;\n const currentState = fiber.memoizedState;\n\n const result = detectHookStateChanges(prevState, currentState);\n return result.hasChange ? result : null;\n}\n\nexport function isDevelopment(): boolean {\n return process.env.NODE_ENV !== 'production';\n}\n", "/**\n * Types and helpers for writing a Chippy plugin. `chippy pack` bundles a\n * plugin module's default export into a custom skill and extension build.\n */\nimport type { ChippyPlugin } from '../plugins/index';\n\nexport type { ChippyPlugin, PluginStatus } from '../plugins/index';\nexport type {\n ContextBoundaryKind,\n ContextContribution,\n ContextField,\n ContextProvider,\n} from '../layout-lab/marker-context';\nexport type { Fiber } from '../layout-lab/fiber-walk';\nexport type {\n CommitMetricsAdapter,\n CommitMetricsDelta,\n CommitMetricsMeasurement,\n CommitMetricsRoot,\n CommitMetricsSnapshot,\n} from '../avalanche/commit-metrics-adapter';\n/** Display name of a fiber's `type`: function, class, memo, or forwardRef. */\nexport { getDisplayName } from '../layout-lab/fiber-walk';\n/** Fiber name helpers that commit metrics adapters use for hook ancestry. */\nexport { getComponentName, isComponentFiber } from '../avalanche/internals';\n\n/** Identity helper that type-checks a plugin definition. */\nexport function definePlugin(plugin: ChippyPlugin): ChippyPlugin {\n return plugin;\n}\n"],
|
|
5
|
-
"mappings": ";AAOA,IAAM,SAAS;AACf,IAAM,WAAW,IAAI,UAAU,GAAG,EAAE,KAAK,EAAE;AAC3C,SAAS,QAAQ,GAAG,QAAQ,OAAO,QAAQ,SAAS,GAAG;AACrD,WAAS,OAAO,WAAW,KAAK,CAAC,IAAI;AACvC;;;
|
|
4
|
+
"sourcesContent": ["/**\n * Small, dependency-free Source Map v3 reader.\n *\n * Chippy only needs the generated-position lookup used by React 19's\n * `_debugStack` source path, so it deliberately omits names and source contents.\n */\n\nconst DIGITS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';\nconst VALUE_OF = new Int8Array(128).fill(-1);\nfor (let index = 0; index < DIGITS.length; index += 1) {\n VALUE_OF[DIGITS.charCodeAt(index)] = index;\n}\n\ntype Segment = {\n generatedColumn: number;\n sourceIndex: number | null;\n originalLine: number | null;\n originalColumn: number | null;\n};\n\nexport type SourceMap = {\n sources: readonly string[];\n lines: readonly (readonly Segment[])[];\n offset: { line: number; column: number };\n};\n\nexport type OriginalPosition = {\n source: string;\n line: number;\n column: number;\n};\n\ntype RawMap = {\n version?: unknown;\n sources?: unknown;\n sourceRoot?: unknown;\n mappings?: unknown;\n sections?: unknown;\n};\n\nfunction decodeVlq(field: string): number[] {\n const values: number[] = [];\n let value = 0;\n let shift = 0;\n\n for (let index = 0; index < field.length; index += 1) {\n const code = field.charCodeAt(index);\n const digit = code < 128 ? VALUE_OF[code]! : -1;\n if (digit < 0) return values;\n\n const more = (digit & 32) !== 0;\n value += (digit & 31) << shift;\n if (more) {\n shift += 5;\n continue;\n }\n\n const negative = (value & 1) === 1;\n const magnitude = value >>> 1;\n values.push(negative ? (magnitude === 0 ? -0x80000000 : -magnitude) : magnitude);\n value = 0;\n shift = 0;\n }\n\n return values;\n}\n\nfunction decodeMappings(mappings: string, sourceCount: number): Segment[][] {\n const lines: Segment[][] = [];\n let sourceIndex = 0;\n let originalLine = 0;\n let originalColumn = 0;\n\n for (const encodedLine of mappings.split(';')) {\n const segments: Segment[] = [];\n let generatedColumn = 0;\n\n for (const field of encodedLine === '' ? [] : encodedLine.split(',')) {\n const values = decodeVlq(field);\n if (values.length < 4) {\n if (values.length > 0) {\n generatedColumn += values[0]!;\n segments.push({ generatedColumn, sourceIndex: null, originalLine: null, originalColumn: null });\n }\n continue;\n }\n\n generatedColumn += values[0]!;\n sourceIndex += values[1]!;\n originalLine += values[2]!;\n originalColumn += values[3]!;\n if (sourceIndex >= 0 && sourceIndex < sourceCount) {\n segments.push({ generatedColumn, sourceIndex, originalLine, originalColumn });\n }\n }\n\n segments.sort((left, right) => left.generatedColumn - right.generatedColumn);\n lines.push(segments);\n }\n\n return lines;\n}\n\nfunction resolveSources(raw: RawMap): string[] | null {\n if (!Array.isArray(raw.sources)) return null;\n const root = typeof raw.sourceRoot === 'string' ? raw.sourceRoot : '';\n\n return raw.sources.map((source) => {\n if (typeof source !== 'string') return '';\n if (root === '' || source === '' || /^[a-z][a-z0-9+.-]*:/i.test(source)) return source;\n if (root.endsWith('/') && source.startsWith('/')) return `${root}${source.slice(1)}`;\n if (root.endsWith('/') || source.startsWith('/')) return `${root}${source}`;\n return `${root}/${source}`;\n });\n}\n\nexport function parseSourceMap(text: string): SourceMap[] {\n let raw: RawMap;\n try {\n raw = JSON.parse(text) as RawMap;\n } catch {\n return [];\n }\n\n if (raw === null || typeof raw !== 'object') return [];\n if (raw.version !== 3) return [];\n\n if (Array.isArray(raw.sections)) {\n return raw.sections.flatMap((section) => {\n if (section === null || typeof section !== 'object') return [];\n const { offset, map } = section as { offset?: unknown; map?: unknown };\n if (offset === null || typeof offset !== 'object' || map === null || typeof map !== 'object') return [];\n const { line, column } = offset as { line?: unknown; column?: unknown };\n if (typeof line !== 'number' || typeof column !== 'number') return [];\n return parseSourceMap(JSON.stringify(map)).map((parsed) => ({ ...parsed, offset: { line, column } }));\n });\n }\n\n const sources = resolveSources(raw);\n if (sources === null || typeof raw.mappings !== 'string') return [];\n return [{ sources, lines: decodeMappings(raw.mappings, sources.length), offset: { line: 0, column: 0 } }];\n}\n\nfunction sectionsFor(maps: readonly SourceMap[], line: number, column: number): readonly SourceMap[] {\n if (\n maps.length === 0 ||\n (maps.length === 1 && maps[0]?.offset.line === 0 && maps[0]?.offset.column === 0)\n ) return maps;\n const zeroBasedLine = line - 1;\n const candidates = maps.filter(\n (map) =>\n map.offset.line < zeroBasedLine ||\n (map.offset.line === zeroBasedLine && map.offset.column < column),\n );\n return candidates.length === 0 ? [] : [candidates[candidates.length - 1]!];\n}\n\nfunction segmentAt(segments: readonly Segment[], column: number): Segment | undefined {\n let low = 0;\n let high = segments.length - 1;\n let found: Segment | undefined;\n\n while (low <= high) {\n const middle = (low + high) >> 1;\n const segment = segments[middle]!;\n if (segment.generatedColumn <= column) {\n found = segment;\n low = middle + 1;\n } else {\n high = middle - 1;\n }\n }\n\n return found;\n}\n\nexport function originalPositionFor(\n maps: readonly SourceMap[],\n line: number,\n column: number,\n): OriginalPosition | null {\n for (const map of sectionsFor(maps, line, column)) {\n const localLine = line - map.offset.line;\n const localColumn = localLine === 1 ? column - map.offset.column : column;\n const segments = map.lines[localLine - 1];\n if (segments === undefined || segments.length === 0) continue;\n\n const found = segmentAt(segments, localColumn - 1);\n if (found === undefined) continue;\n if (\n found.sourceIndex === null ||\n found.originalLine === null ||\n found.originalColumn === null\n ) return null;\n const source = map.sources[found.sourceIndex];\n if (source === undefined || source === '') continue;\n return { source, line: found.originalLine + 1, column: found.originalColumn + 1 };\n }\n\n return null;\n}\n\nexport function sourceMappingUrlOf(code: string): string | null {\n const found = [...code.matchAll(/^[/*]{2,}[#@]\\s*sourceMappingURL=(\\S+)\\s*(?:\\*\\/)?$/gm)];\n return found.at(-1)?.[1] ?? null;\n}\n\nexport function inlineSourceMapOf(url: string): string | null {\n const match = /^data:application\\/json[^,]*,(.*)$/s.exec(url);\n if (match === null) return null;\n const payload = match[1]!;\n\n if (!/;base64/i.test(url)) {\n try {\n return decodeURIComponent(payload);\n } catch {\n return null;\n }\n }\n\n try {\n const decode = (globalThis as { atob?: (input: string) => string }).atob;\n if (decode) {\n const binary = decode(payload);\n const Decoder = (globalThis as {\n TextDecoder?: new () => { decode(bytes: Uint8Array): string };\n }).TextDecoder;\n return Decoder\n ? new Decoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)))\n : binary;\n }\n const buffer = (globalThis as {\n Buffer?: { from(value: string, encoding: string): { toString(encoding: string): string } };\n }).Buffer;\n return buffer?.from(payload, 'base64').toString('utf8') ?? null;\n } catch {\n return null;\n }\n}\n", "import {\n inlineSourceMapOf,\n originalPositionFor,\n parseSourceMap,\n sourceMappingUrlOf,\n type OriginalPosition,\n type SourceMap,\n} from './source-map';\nimport { baseName, pageModuleUrls, recordedLiteralPosition } from './recorded-source';\n\nexport type FiberSourceLocation = {\n fileName: string;\n lineNumber: number;\n columnNumber: number | null;\n};\n\nexport type StackFrame = {\n url: string;\n line: number;\n column: number;\n function?: string;\n};\n\nexport type SourceFiber = {\n type?: unknown;\n _debugSource?: unknown;\n _debugStack?: unknown;\n};\n\nexport type FetchModule = (url: string) => Promise<string | null>;\n\n/** Module URLs that may serve a file named by React <=18 `_debugSource`. */\nexport type ModuleUrlsFor = (fileName: string) => readonly string[];\n\nexport type CallSiteResolver = {\n locate(frames: readonly StackFrame[]): Promise<FiberSourceLocation | null>;\n /** Map a recorded `_debugSource` position through its served module. */\n locateRecorded(source: FiberSourceLocation): Promise<FiberSourceLocation>;\n readonly stats: { modules: number; sites: number; located: number };\n};\n\ntype ResolvedMaps = {\n code: string;\n maps: readonly SourceMap[];\n sourceBaseUrl: string;\n};\n\nconst V8_FRAME = /^\\s*at\\s+(?:(.+?)\\s+\\()?(.+?):(\\d+):(\\d+)\\)?$/;\nconst AT_FRAME = /^\\s*(?:(.*?)@)(.+?):(\\d+):(\\d+)$/;\nconst MAX_CALL_SITE_FRAMES = 4;\n\nconst resolvedFiberSources = new WeakMap<object, FiberSourceLocation | null>();\nconst pendingFiberSources = new WeakMap<object, Promise<FiberSourceLocation | null>>();\nlet defaultResolver: CallSiteResolver | null = null;\n\nexport function parseStackFrames(stack: string): StackFrame[] {\n const frames: StackFrame[] = [];\n\n for (const raw of stack.split('\\n')) {\n const line = raw.trimEnd();\n if (line === '' || line.includes('(eval at ')) continue;\n const match = V8_FRAME.exec(line) ?? AT_FRAME.exec(line);\n if (!match) continue;\n\n const [, name, url = '', lineDigits = '', columnDigits = ''] = match;\n if (url === '' || url.startsWith('data:')) continue;\n const at = Number(lineDigits);\n const column = Number(columnDigits);\n if (!Number.isFinite(at) || !Number.isFinite(column) || at < 1) continue;\n frames.push({\n url,\n line: at,\n column,\n ...(name !== undefined && name !== '' ? { function: name } : {}),\n });\n }\n\n return frames;\n}\n\nexport function isVendorPath(path: string): boolean {\n return (\n path.includes('node_modules') ||\n path.includes('/.vite/deps/') ||\n path.includes('/.yarn/')\n );\n}\n\nfunction rawLocation(frame: StackFrame): FiberSourceLocation {\n return { fileName: frame.url, lineNumber: frame.line, columnNumber: frame.column };\n}\n\nfunction locationFromOriginal(original: OriginalPosition): FiberSourceLocation {\n return { fileName: original.source, lineNumber: original.line, columnNumber: original.column };\n}\n\nexport function writerLocationOf(\n frames: readonly StackFrame[],\n originalFor: (frame: StackFrame) => OriginalPosition | null,\n): FiberSourceLocation | null {\n for (const frame of frames) {\n if (isVendorPath(frame.url)) continue;\n const original = originalFor(frame);\n if (original === null) return rawLocation(frame);\n if (isVendorPath(original.source)) continue;\n return locationFromOriginal(original);\n }\n return null;\n}\n\nfunction parseUrl(url: string, base?: string): URL | null {\n try {\n return new URL(url, base);\n } catch {\n return null;\n }\n}\n\nfunction sourcePath(source: string, moduleUrl: string): string {\n if (/^\\/(?:Users|home|private|Volumes)\\//.test(source)) return source;\n const direct = parseUrl(source);\n if (direct?.protocol === 'file:') return decodeURIComponent(direct.pathname);\n const resolved = parseUrl(source, moduleUrl)?.href ?? source;\n const parsed = parseUrl(resolved);\n return parsed ? parsed.pathname.replace(/^\\/+/, '') : resolved;\n}\n\nexport function createCallSiteResolver(\n fetchModule: FetchModule,\n moduleUrlsFor: ModuleUrlsFor = () => [],\n): CallSiteResolver {\n const maps = new Map<string, Promise<ResolvedMaps | null>>();\n const sites = new Map<string, Promise<FiberSourceLocation | null>>();\n const recordedSites = new Map<string, Promise<FiberSourceLocation>>();\n const stats = { modules: 0, sites: 0, located: 0 };\n\n async function mapsFor(url: string): Promise<ResolvedMaps | null> {\n const cached = maps.get(url);\n if (cached) return cached;\n\n const pending = (async () => {\n stats.modules += 1;\n const code = await fetchModule(url);\n if (code === null) return null;\n const annotation = sourceMappingUrlOf(code);\n if (annotation === null) return null;\n const inline = inlineSourceMapOf(annotation);\n if (inline !== null) return { code, maps: parseSourceMap(inline), sourceBaseUrl: url };\n const mapUrl = parseUrl(annotation, url)?.href ?? null;\n const text = mapUrl === null ? null : await fetchModule(mapUrl);\n return text === null || mapUrl === null\n ? null\n : { code, maps: parseSourceMap(text), sourceBaseUrl: mapUrl };\n })();\n\n maps.set(url, pending);\n return pending;\n }\n\n async function locateFrame(frame: StackFrame): Promise<FiberSourceLocation | null> {\n const parsed = await mapsFor(frame.url);\n if (!parsed || parsed.maps.length === 0) return null;\n const original = originalPositionFor(parsed.maps, frame.line, frame.column);\n if (!original) return null;\n return {\n fileName: sourcePath(original.source, parsed.sourceBaseUrl),\n lineNumber: original.line,\n columnNumber: original.column,\n };\n }\n\n async function locate(frames: readonly StackFrame[]): Promise<FiberSourceLocation | null> {\n for (const frame of frames) {\n if (isVendorPath(frame.url)) continue;\n const key = `${frame.url}:${frame.line}:${frame.column}`;\n let answer = sites.get(key);\n if (!answer) {\n stats.sites += 1;\n answer = locateFrame(frame);\n sites.set(key, answer);\n }\n\n const located = await answer;\n const chosen = located === null\n ? writerLocationOf([frame], () => null)\n : isVendorPath(located.fileName) ? null : located;\n if (chosen) {\n stats.located += 1;\n return chosen;\n }\n }\n return null;\n }\n\n async function correctRecorded(source: FiberSourceLocation): Promise<FiberSourceLocation> {\n for (const url of moduleUrlsFor(source.fileName)) {\n const parsed = await mapsFor(url);\n if (!parsed || parsed.maps.length === 0) continue;\n const served = recordedLiteralPosition(parsed.code, source);\n if (served === null) continue;\n const original = originalPositionFor(parsed.maps, served.line, served.column);\n // The literal must map back into the recorded file, not a neighbor.\n if (!original || baseName(original.source) !== baseName(source.fileName)) continue;\n return { fileName: source.fileName, lineNumber: original.line, columnNumber: original.column };\n }\n return source;\n }\n\n function locateRecorded(source: FiberSourceLocation): Promise<FiberSourceLocation> {\n const key = `${source.fileName}:${source.lineNumber}:${source.columnNumber}`;\n let answer = recordedSites.get(key);\n if (!answer) {\n answer = correctRecorded(source).catch(() => source);\n recordedSites.set(key, answer);\n }\n return answer;\n }\n\n return { locate, locateRecorded, stats };\n}\n\nfunction debugSourceOf(value: unknown): FiberSourceLocation | null {\n if (!value || typeof value !== 'object') return null;\n const source = value as { fileName?: unknown; lineNumber?: unknown; columnNumber?: unknown };\n if (typeof source.fileName !== 'string' || typeof source.lineNumber !== 'number') return null;\n return {\n fileName: source.fileName,\n lineNumber: source.lineNumber,\n columnNumber: typeof source.columnNumber === 'number' ? source.columnNumber : null,\n };\n}\n\nfunction recordedSource(fiber: SourceFiber): FiberSourceLocation | null {\n const direct = debugSourceOf(fiber._debugSource);\n if (direct) return direct;\n const type = fiber.type;\n return type && (typeof type === 'object' || typeof type === 'function')\n ? debugSourceOf((type as { _debugSource?: unknown })._debugSource)\n : null;\n}\n\nexport function getFiberStackFrames(fiber: SourceFiber | null | undefined): StackFrame[] {\n if (!fiber || !fiber._debugStack || typeof fiber._debugStack !== 'object') return [];\n const stack = (fiber._debugStack as { stack?: unknown }).stack;\n if (typeof stack !== 'string' || stack === '') return [];\n const candidates: StackFrame[] = [];\n for (const frame of parseStackFrames(stack)) {\n if (isVendorPath(frame.url)) continue;\n candidates.push(frame);\n if (candidates.length >= MAX_CALL_SITE_FRAMES) break;\n }\n return candidates;\n}\n\n/** Stable synchronous source, before any async source-map upgrade. */\nexport function getFiberSourceCandidate(fiber: SourceFiber | null | undefined): FiberSourceLocation | null {\n if (!fiber) return null;\n const recorded = recordedSource(fiber);\n if (recorded) return recorded;\n return writerLocationOf(getFiberStackFrames(fiber), () => null);\n}\n\n/**\n * Synchronous source read used by fiber walks and snapshots: the resolved\n * source once `resolveFiberSource` has run for this fiber, else the candidate.\n */\nexport function getFiberSource(fiber: SourceFiber | null | undefined): FiberSourceLocation | null {\n if (!fiber) return null;\n const cached = resolvedFiberSources.get(fiber as object);\n if (cached !== undefined) return cached;\n return getFiberSourceCandidate(fiber);\n}\n\nasync function fetchModuleFromPage(url: string): Promise<string | null> {\n const fetchFn = (globalThis as { fetch?: typeof fetch }).fetch;\n if (!fetchFn) return null;\n const controller = typeof AbortController === 'function' ? new AbortController() : null;\n const timeout = controller ? setTimeout(() => controller.abort(), 2000) : null;\n try {\n const response = await fetchFn(url, {\n credentials: 'same-origin',\n signal: controller?.signal,\n });\n return response.ok ? await response.text() : null;\n } catch {\n return null;\n } finally {\n if (timeout !== null) clearTimeout(timeout);\n }\n}\n\nfunction pageResolver(): CallSiteResolver {\n if (!defaultResolver) defaultResolver = createCallSiteResolver(fetchModuleFromPage, pageModuleUrls);\n return defaultResolver;\n}\n\n/**\n * Resolve a fiber's authored source through the served module's inline or\n * external map: the React 19 `_debugStack` call site, or the React <=18\n * `_debugSource` literal when a transform shifted its recorded line.\n */\nexport function resolveFiberSource(\n fiber: SourceFiber | null | undefined,\n resolver: CallSiteResolver = pageResolver(),\n): Promise<FiberSourceLocation | null> {\n if (!fiber) return Promise.resolve(null);\n const cached = pendingFiberSources.get(fiber as object);\n if (cached) return cached;\n\n const recorded = recordedSource(fiber);\n if (recorded) {\n const pending = resolver.locateRecorded(recorded).then((located) => {\n resolvedFiberSources.set(fiber as object, located);\n return located;\n });\n pendingFiberSources.set(fiber as object, pending);\n return pending;\n }\n\n const frames = getFiberStackFrames(fiber);\n const fallback = writerLocationOf(frames, () => null);\n const pending = resolver.locate(frames)\n .catch(() => null)\n .then((source) => {\n const located = source ?? fallback;\n resolvedFiberSources.set(fiber as object, located);\n return located;\n });\n pendingFiberSources.set(fiber as object, pending);\n return pending;\n}\n", "/**\n * Pure fiber-walk helpers extracted from layout-lab.js.\n * No DOM writes, no side effects \u2014 read-only introspection of React fiber trees.\n */\n\nimport {\n getFiberSource as readFiberSource,\n resolveFiberSource as resolveSourceLocation,\n} from '../chippy-bridge/fiber-source';\n\n/**\n * Minimal duck-typed Fiber shape.\n * Only fields used by these helpers are typed; rest may exist but are not read.\n */\nexport type Fiber = {\n type?: unknown;\n elementType?: unknown;\n key?: string | null;\n memoizedState?: unknown;\n memoizedProps?: Record<string, unknown> | null;\n stateNode?: Element | null;\n return?: Fiber | null;\n child?: Fiber | null;\n sibling?: Fiber | null;\n _debugOwner?: Fiber | null;\n _debugSource?: SourceLocation | null;\n _debugStack?: unknown;\n};\n\nexport type SourceLocation = {\n fileName?: string | null;\n lineNumber?: number | null;\n columnNumber?: number | null;\n};\n\nexport type DescribedSource = {\n fileName: string | null;\n lineNumber: number | null;\n columnNumber: number | null;\n};\n\nexport type HookSummary = {\n hookCount: number;\n hookCountTruncated: boolean;\n hookCountMax: number;\n};\n\nexport type OwnerEntry = {\n name: string | null;\n key: string | null;\n props: string[];\n} & HookSummary & {\n source: DescribedSource | null;\n};\n\nexport type FiberTarget = {\n domTag: string | null;\n domText: string;\n fiberType: string | null;\n source: DescribedSource | null;\n};\n\nexport type ComponentDescription = {\n name: string | null;\n key: string | null;\n props: Record<string, unknown>;\n source: DescribedSource | null;\n stateNode: string | null;\n owners: OwnerEntry[];\n parents: OwnerEntry[];\n} & HookSummary;\n\n/**\n * Find the React fiber key in a DOM node's own properties.\n * Returns the property name (e.g. '__reactFiber$abc123') or null.\n */\nexport function getFiberKey(node: Node | Element | null | undefined): string | null {\n if (!node) return null;\n const keys = Object.getOwnPropertyNames(node as object);\n const key = keys.find((k) => k.startsWith('__reactFiber$'));\n return key || null;\n}\n\n/**\n * Get the React fiber attached to a DOM node, or null if not a React host node.\n */\nexport function getFiber(node: Node | Element | null | undefined): Fiber | null {\n if (!node) return null;\n const key = getFiberKey(node);\n // React stores the fiber as an expando property on the host node.\n return key ? ((node as unknown as Record<string, unknown>)[key] as Fiber | null) : null;\n}\n\n/**\n * Extract display name from a React type (class, function, or string).\n * Returns displayName > name > null for functions; 'string' for primitives.\n */\nexport function getDisplayName(type: unknown): string | null {\n if (!type) return null;\n if (typeof type === 'string') return type;\n const obj = type as { displayName?: unknown; name?: unknown };\n return (obj.displayName as string) || (obj.name as string) || null;\n}\n\n/**\n * True if fiber is a user-defined component (not a DOM string, not a hook-internal wrapper).\n */\nexport function isUserComponent(fiber: Fiber | null | undefined): boolean {\n if (!fiber) return false;\n const name = getDisplayName(fiber.type);\n if (!name || typeof fiber.type === 'string') return false;\n if (['hookified', 'unboundStoryFn', 'Anonymous'].includes(name)) return false;\n return true;\n}\n\n/**\n * Walk DOM parents to find the nearest fiber-attached element.\n */\nexport function getNearestFiber(domNode: Node | Element | null | undefined): Fiber | null {\n let current = domNode as Element | null;\n while (current) {\n const fiber = getFiber(current);\n if (fiber) return fiber;\n current = (current as Element).parentElement;\n }\n return null;\n}\n\n/**\n * Walk DOM parents, then walk fiber return chain, to find the nearest user component.\n */\nexport function getNearestComponent(domNode: Node | Element | null | undefined): Fiber | null {\n let fiber = getNearestFiber(domNode);\n while (fiber) {\n if (isUserComponent(fiber)) return fiber;\n fiber = fiber.return || null;\n }\n return null;\n}\n\n/**\n * Count hooks in fiber.memoizedState linked list; detect cycle.\n */\nexport function getHookSummary(fiber: Fiber | null | undefined): HookSummary {\n const MAX_HOOKS = 500;\n const seen = new Set<unknown>();\n let hookNode = fiber?.memoizedState || null;\n let hookCount = 0;\n let hookCountTruncated = false;\n\n while (hookNode) {\n if (seen.has(hookNode)) break;\n if (hookCount >= MAX_HOOKS) {\n hookCountTruncated = true;\n break;\n }\n seen.add(hookNode);\n hookCount += 1;\n hookNode = (hookNode as { next?: unknown }).next || null;\n }\n\n return { hookCount, hookCountTruncated, hookCountMax: MAX_HOOKS };\n}\n\n/**\n * Walk fiber._debugOwner chain; collect all user components with metadata.\n * Returns empty array if fiber or owner chain is null.\n */\nexport function getOwnerChain(fiber: Fiber | null | undefined): OwnerEntry[] {\n const chain: OwnerEntry[] = [];\n let current = fiber?._debugOwner || null;\n while (current) {\n if (isUserComponent(current)) {\n chain.push({\n name: getDisplayName(current.type),\n key: current.key || null,\n props: current.memoizedProps ? Object.keys(current.memoizedProps) : [],\n ...getHookSummary(current),\n source: describeFiberSource(current),\n });\n }\n current = current._debugOwner || null;\n }\n return chain;\n}\n\n/**\n * Walk fiber._debugOwner chain; return raw fiber nodes (for navigation).\n */\nexport function getOwnerFibers(fiber: Fiber | null | undefined): Fiber[] {\n const fibers: Fiber[] = [];\n let current = fiber?._debugOwner || null;\n while (current) {\n if (isUserComponent(current)) fibers.push(current);\n current = current._debugOwner || null;\n }\n return fibers;\n}\n\n/**\n * Recursively walk fiber.child/sibling to find the first host DOM element rendered by this subtree.\n */\nexport function getFiberDomNode(fiber: Fiber | null | undefined): Element | null {\n if (!fiber) return null;\n // Host fiber \u2014 stateNode is a DOM element\n if (fiber.stateNode instanceof Element) return fiber.stateNode;\n // Walk children\n let child = fiber.child || null;\n while (child) {\n const found = getFiberDomNode(child);\n if (found) return found;\n child = child.sibling || null;\n }\n return null;\n}\n\n/**\n * Walk fiber.return chain; collect all user component parents with metadata.\n */\nexport function getReturnComponentChain(fiber: Fiber | null | undefined): OwnerEntry[] {\n const chain: OwnerEntry[] = [];\n let current = fiber?.return || null;\n while (current) {\n if (isUserComponent(current)) {\n chain.push({\n name: getDisplayName(current.type),\n key: current.key || null,\n props: current.memoizedProps ? Object.keys(current.memoizedProps) : [],\n ...getHookSummary(current),\n source: describeFiberSource(current),\n });\n }\n current = current.return || null;\n }\n return chain;\n}\n\n/**\n * Walk fiber.return chain; return raw fiber nodes (for navigation).\n */\nexport function getReturnComponentFibers(fiber: Fiber | null | undefined): Fiber[] {\n const fibers: Fiber[] = [];\n let current = fiber?.return || null;\n while (current) {\n if (isUserComponent(current)) fibers.push(current);\n current = current.return || null;\n }\n return fibers;\n}\n\n/**\n * Normalize source location object to canonical shape.\n */\nexport function describeSource(source: SourceLocation | null | undefined): DescribedSource | null {\n if (!source) return null;\n return {\n fileName: source.fileName || null,\n lineNumber: source.lineNumber || null,\n columnNumber: source.columnNumber || null,\n };\n}\n\n/**\n * Read a fiber source across React versions.\n *\n * React <=18 exposes `_debugSource`. React 19 exposes `_debugStack`; its first\n * non-vendor frame is returned synchronously and may later be upgraded from the\n * bundle position to the original source position by `resolveFiberSource`.\n */\nexport function describeFiberSource(fiber: Fiber | null | undefined): DescribedSource | null {\n return describeSource(readFiberSource(fiber));\n}\n\nexport async function resolveFiberSource(fiber: Fiber | null | undefined): Promise<DescribedSource | null> {\n return describeSource(await resolveSourceLocation(fiber));\n}\n\n/**\n * Describe a fiber + DOM node pair; used for inspection UI.\n */\nexport function describeFiberTarget(fiber: Fiber | null | undefined, domNode: Element | null | undefined): FiberTarget | null {\n if (!fiber && !domNode) return null;\n return {\n domTag: domNode?.tagName?.toLowerCase() || null,\n domText: domNode?.textContent?.trim().slice(0, 60) || '',\n fiberType: getDisplayName(fiber?.type) || getDisplayName(fiber?.elementType) || null,\n source: describeFiberSource(fiber),\n };\n}\n\n/**\n * Full component description: name, props, hook summary, source, owners, parents.\n */\nexport function describeComponent(fiber: Fiber | null | undefined): ComponentDescription | null {\n if (!fiber) return null;\n return {\n name: getDisplayName(fiber.type),\n key: fiber.key || null,\n props: fiber.memoizedProps || {},\n ...getHookSummary(fiber),\n source: describeFiberSource(fiber),\n stateNode: fiber.stateNode instanceof Element ? fiber.stateNode.tagName.toLowerCase() : null,\n owners: getOwnerChain(fiber),\n parents: getReturnComponentChain(fiber),\n };\n}\n", "// React fiber constants and structural comparisons used by the commit detector.\n\nimport { getFiberSourceCandidate } from '../chippy-bridge/fiber-source';\n\nexport type ComponentType =\n | Function\n | symbol\n | {\n // ForwardRef/memo/etc may be objects.\n $$typeof?: symbol;\n render?: Function;\n displayName?: string;\n name?: string;\n };\n\ninterface StoreCheck {\n getSnapshot: () => unknown;\n value: unknown;\n}\n\nexport interface Fiber {\n tag: number;\n type: ComponentType;\n alternate: Fiber | null;\n return: Fiber | null;\n child: Fiber | null;\n sibling: Fiber | null;\n flags: number;\n subtreeFlags: number;\n lanes: number;\n memoizedState: Hook | null;\n memoizedProps?: unknown;\n pendingProps?: unknown;\n updateQueue: { stores: StoreCheck[] | null } | null;\n /**\n * React populates this array during the render phase with fibers that will be\n * deleted in the commit phase. Present when subtreeFlags & ChildDeletion !== 0.\n * Only available in React 18+ (replaces the older effectList pattern).\n */\n deletions?: Fiber[] | null;\n\n // Dev-only / optional debug fields (not always present).\n elementType?: unknown;\n _debugSource?: { fileName: string; lineNumber: number };\n _debugStack?: unknown;\n _debugHookTypes?: string[];\n}\n\nexport interface Hook {\n memoizedState: unknown;\n baseState: unknown;\n baseQueue: unknown;\n queue: HookQueue | null;\n next: Hook | null;\n}\n\ninterface HookQueue {\n dispatch?: Function;\n value?: unknown;\n getSnapshot?: Function;\n lastRenderedState?: unknown;\n}\n\nexport interface DepOrigin {\n type: ComponentType;\n hookIndex: number;\n depth: number;\n hookType: 'useCallback' | 'useMemo';\n}\n\nexport interface ThrashInfo {\n hookIndex: number;\n hookType: 'effect' | 'callback' | 'memo';\n deps: unknown[];\n changedDepIndices: number[];\n changedDepOrigins: (DepOrigin | null)[];\n effectTag?: number;\n memoValue?: unknown;\n /** The hook body function (for useCallback) or memoized value (for useMemo) */\n hookBody?: Function | unknown;\n}\n\nexport type ChangedHook =\n | {\n prevValue: unknown;\n currentValue: unknown;\n info?: string; // For structural changes\n }\n | 'repeat'; // Entire object replaced with string if identical to previous commit\n\nexport interface StateChangeResult {\n hasChange: boolean;\n actionable: boolean;\n changedIndices: number[];\n changedHooks: ChangedHook[];\n /** True if any of the changes came from useSyncExternalStore */\n hasExternalStoreChange: boolean;\n}\n\nexport interface ThrashingResult {\n effects: ThrashInfo[];\n callbacks: ThrashInfo[];\n memos: ThrashInfo[];\n}\n\n// React fiber flags (React 18.3.1)\nexport const PerformedWork = 1;\n\n// Flags relevant to filtering out non-preventable work from collateral counts.\n// - Placement: mount/insert work (initial render)\n// - Hydrating: work done while hydrating server-rendered markup\n// These are stable for React 18.x.\nexport const Placement = 2;\nexport const Hydrating = 4096;\n\n// Special sentinel values for hook change detection\n\n// Component fiber tags for React 18.3.1 (stable within 18.x)\n// 0: FunctionComponent, 1: ClassComponent, 2: IndeterminateComponent\n// 11: MemoComponent, 14: SimpleMemoComponent, 15: ForwardRef\nconst COMPONENT_TAGS = new Set([0, 1, 2, 11, 14, 15]);\n\n// Context Provider tag\nconst CONTEXT_PROVIDER_TAG = 10;\n\n// Suspense fiber tag (React 18.3.1 \u2014 stable within 18.x)\nexport const SuspenseTag = 13;\n\n// ChildDeletion \u2014 set on fiber.flags (NOT subtreeFlags) when the fiber has deletions[].\n// Value 16 is stable for React 18.3.1.\nexport const ChildDeletion = 16;\n\n// Update \u2014 set on fiber.flags when the fiber received an update this commit.\n// This is the flag React puts on Suspense fibers when they transition state.\n// Value 4 is stable for React 18.3.1.\nexport const Update = 4;\n\n// DidCapture \u2014 render-phase flag, cleared before commit. Do NOT check at commit time.\n// Kept for documentation. Value 128 in React 18.3.1.\nexport const DidCapture = 128;\n\nexport function isComponentFiber(tag: number): boolean {\n return COMPONENT_TAGS.has(tag);\n}\n\nexport function isContextProviderFiber(tag: number): boolean {\n return tag === CONTEXT_PROVIDER_TAG;\n}\n\nexport function isSuspenseFiber(tag: number): boolean {\n return tag === SuspenseTag;\n}\n\n/**\n * Detect whether a Suspense boundary transitioned between suspended/unsuspended states.\n *\n * Suspense memoizedState is:\n * null \u2192 primary content is showing (not suspended)\n * non-null (object) \u2192 boundary is showing the fallback (suspended/dehydrated)\n *\n * Returns:\n * { entered: true } \u2014 boundary just entered fallback\n * - null \u2192 non-null (update path)\n * - no alternate + memoizedState non-null (first render directly to fallback)\n * { left: true } \u2014 boundary just left fallback / revealed primary (non-null \u2192 null)\n * null \u2014 no state change (both null, both non-null, or first render not suspended)\n *\n * A Suspense that renders without delay never enters fallback, so memoizedState stays\n * null \u2192 returns null \u2192 correctly ignored by the detector.\n */\nexport function didSuspenseStateChange(\n fiber: Fiber\n): { entered: boolean; left: boolean } | null {\n const currSuspended = fiber.memoizedState !== null;\n\n if (!fiber.alternate) {\n // First render of this Suspense boundary.\n // If it went directly to fallback (memoizedState non-null), that IS an enter.\n // If it rendered primary content (memoizedState null), no transition.\n if (currSuspended) {\n return { entered: true, left: false };\n }\n return null;\n }\n\n const prevSuspended = fiber.alternate.memoizedState !== null;\n if (prevSuspended === currSuspended) return null;\n return { entered: currSuspended, left: prevSuspended };\n}\n\n/**\n * Check if a Context Provider's value changed.\n */\nexport function didContextValueChange(fiber: Fiber): boolean {\n if (fiber.tag !== CONTEXT_PROVIDER_TAG || !fiber.alternate) return false;\n\n type FiberWithProps = Fiber & { memoizedProps?: { value?: unknown } };\n const alternateFiber: FiberWithProps = fiber.alternate;\n const currentFiber: FiberWithProps = fiber;\n\n const prevProps = alternateFiber.memoizedProps;\n const currentProps = currentFiber.memoizedProps;\n\n return prevProps && currentProps && !Object.is(prevProps.value, currentProps.value);\n}\n\n// Detect React built-in fiber types (Suspense/Offscreen/etc).\n// IMPORTANT: Do NOT use component names. User components can be named anything.\n// In React 18, built-ins like Suspense/Offscreen are represented as symbols.\nexport function isReactInternalComponentType(type: ComponentType): boolean {\n return typeof type === 'symbol';\n}\n\nexport function getComponentName(fiber: Fiber): string {\n type TypeWithName = {\n displayName?: string;\n name?: string;\n };\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n const type = fiber.type as TypeWithName;\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n const elementType = fiber.elementType as TypeWithName;\n const named = (\n type?.displayName ||\n type?.name ||\n elementType?.displayName ||\n elementType?.name\n );\n if (named) return named;\n const source = getFiberSourceCandidate(fiber);\n return (source && `<${source.fileName}:${source.lineNumber}>`) || 'Anonymous';\n}\n\n/**\n * Check if hook is a state hook (useState/useReducer/useSyncExternalStore).\n *\n * All hooks share a base shape. The reliable differentiators:\n * - useState/useReducer: queue.dispatch is a function\n * - useSyncExternalStore: queue has 'value' and 'getSnapshot' properties\n */\nexport function isStateHook(hook: Hook): boolean {\n const queue = hook.queue;\n if (!queue) return false;\n\n return typeof queue.dispatch === 'function' || ('value' in queue && 'getSnapshot' in queue);\n}\n\n/** True if the hook is a useSyncExternalStore-like hook. */\nexport function isExternalStoreHook(hook: Hook): boolean {\n const queue = hook.queue;\n return (\n queue && typeof queue.dispatch !== 'function' && 'value' in queue && 'getSnapshot' in queue\n );\n}\n\n/**\n * Compare hook chains to detect state changes.\n *\n * IMPORTANT: Hook indices are React internal memoizedState indices, NOT code order.\n * `useContext` hooks are not in memoizedState, causing index misalignment.\n */\nexport function detectHookStateChanges(\n prevHookChain: Hook | null,\n currentHookChain: Hook | null\n): StateChangeResult {\n if (prevHookChain === currentHookChain) {\n return {\n hasChange: false,\n actionable: false,\n changedIndices: [],\n changedHooks: [],\n hasExternalStoreChange: false,\n };\n }\n\n let prevHook = prevHookChain;\n let currentHook = currentHookChain;\n let memoizedStateIndex = 0;\n const changedIndices: number[] = [];\n const changedHooks: ChangedHook[] = [];\n let hasExternalStoreChange = false;\n\n while (prevHook && currentHook) {\n if (isStateHook(currentHook)) {\n const prevValueRaw =\n prevHook.memoizedState === undefined\n ? prevHook.queue?.lastRenderedState\n : prevHook.memoizedState;\n const curValueRaw =\n currentHook.memoizedState === undefined\n ? currentHook.queue?.lastRenderedState\n : currentHook.memoizedState;\n\n if (!Object.is(prevValueRaw, curValueRaw)) {\n const isExternal = isExternalStoreHook(currentHook);\n if (isExternal) hasExternalStoreChange = true;\n\n changedIndices.push(memoizedStateIndex);\n changedHooks.push({\n prevValue: prevValueRaw,\n currentValue: curValueRaw,\n ...(isExternal && { isExternalStore: true }),\n });\n }\n }\n\n prevHook = prevHook.next;\n currentHook = currentHook.next;\n memoizedStateIndex++;\n }\n\n return {\n hasChange: changedIndices.length > 0,\n actionable: changedIndices.length > 0,\n changedIndices,\n changedHooks,\n hasExternalStoreChange,\n };\n}\n\n/**\n * Detect thrashing for hooks with deps: useEffect/useLayoutEffect/useInsertionEffect,\n * useCallback and useMemo.\n */\nexport function detectHookThrashing(\n prevHookChain: Hook | null,\n currentHookChain: Hook | null,\n mode: 'non-primitive' | 'all' = 'non-primitive',\n valueProvenance: WeakMap<object | Function, DepOrigin> | null = null\n): ThrashingResult {\n if (prevHookChain === currentHookChain) {\n return { effects: [], callbacks: [], memos: [] };\n }\n\n const thrashedEffects: ThrashInfo[] = [];\n const thrashedCallbacks: ThrashInfo[] = [];\n const thrashedMemos: ThrashInfo[] = [];\n\n let prevHook = prevHookChain;\n let currentHook = currentHookChain;\n let hookIndex = 0;\n\n while (prevHook && currentHook) {\n if (!isStateHook(currentHook)) {\n const prevValue = prevHook.memoizedState;\n const currentValue = currentHook.memoizedState;\n\n let prevDeps: unknown[] | null = null;\n let currentDeps: unknown[] | null = null;\n let hookType: ThrashInfo['hookType'] | null = null;\n\n // Effect hooks store an object with `deps`.\n if (\n prevValue &&\n currentValue &&\n typeof prevValue === 'object' &&\n typeof currentValue === 'object' &&\n 'deps' in prevValue &&\n 'deps' in currentValue &&\n 'tag' in currentValue\n ) {\n type EffectHook = { deps: unknown[] };\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n prevDeps = (prevValue as EffectHook).deps;\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n currentDeps = (currentValue as EffectHook).deps;\n hookType = 'effect';\n }\n // useMemo/useCallback store [result, deps].\n else if (\n Array.isArray(prevValue) &&\n Array.isArray(currentValue) &&\n prevValue.length === 2 &&\n currentValue.length === 2 &&\n Array.isArray(prevValue[1]) &&\n Array.isArray(currentValue[1])\n ) {\n prevDeps = prevValue[1];\n currentDeps = currentValue[1];\n hookType = typeof currentValue[0] === 'function' ? 'callback' : 'memo';\n }\n\n if (hookType && Array.isArray(prevDeps) && Array.isArray(currentDeps)) {\n const changedDepIndices: number[] = [];\n const changedDepOrigins: (DepOrigin | null)[] = [];\n\n // eslint-disable-next-line @atlassian/perf-linting/no-quadratic-complexity\n for (let j = 0; j < currentDeps.length; j++) {\n if (!Object.is(prevDeps[j], currentDeps[j])) {\n changedDepIndices.push(j);\n\n let origin: DepOrigin | null = null;\n const dep = currentDeps[j];\n if (\n valueProvenance &&\n dep != null &&\n (typeof dep === 'object' || typeof dep === 'function')\n ) {\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n origin = valueProvenance.get(dep as object | ((...args: never[]) => unknown)) || null;\n }\n changedDepOrigins.push(origin);\n }\n }\n\n let shouldReport = changedDepIndices.length > 0;\n if (shouldReport && mode === 'non-primitive') {\n // Only report if at least one non-primitive dep changed.\n // eslint-disable-next-line @atlassian/perf-linting/no-quadratic-complexity\n shouldReport = changedDepIndices.some((idx) => {\n const dep = currentDeps[idx];\n const t = typeof dep;\n return !(\n dep == null ||\n t === 'string' ||\n t === 'number' ||\n t === 'boolean' ||\n t === 'symbol' ||\n t === 'bigint'\n );\n });\n }\n\n if (shouldReport) {\n const thrashInfo: ThrashInfo = {\n hookIndex,\n hookType,\n deps: currentDeps,\n changedDepIndices,\n changedDepOrigins,\n };\n\n if (hookType === 'effect') {\n type EffectValue = { tag?: number; create?: Function };\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n const effectValue = currentValue as EffectValue;\n thrashInfo.effectTag = effectValue.tag;\n // Capture the effect function itself (the create function)\n if (effectValue.create) {\n thrashInfo.hookBody = effectValue.create;\n }\n thrashedEffects.push(thrashInfo);\n } else if (hookType === 'callback') {\n type CallbackValue = [Function, unknown[]];\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n const callbackFn = (currentValue as CallbackValue)[0];\n // Capture the callback function itself\n thrashInfo.hookBody = callbackFn;\n thrashedCallbacks.push(thrashInfo);\n } else {\n type MemoValue = [unknown, unknown[]];\n // eslint-disable-next-line @typescript-eslint/consistent-type-assertions\n const memoValue = (currentValue as MemoValue)[0];\n thrashInfo.memoValue = memoValue;\n // For useMemo, also expose the memoized value as hookBody\n thrashInfo.hookBody = memoValue;\n thrashedMemos.push(thrashInfo);\n }\n }\n }\n }\n\n prevHook = prevHook.next;\n currentHook = currentHook.next;\n hookIndex++;\n }\n\n return {\n effects: thrashedEffects,\n callbacks: thrashedCallbacks,\n memos: thrashedMemos,\n };\n}\n\nexport function didFiberUpdate(fiber: Fiber): boolean {\n // eslint-disable-next-line no-bitwise\n return (fiber.flags & PerformedWork) !== 0;\n}\n\n/**\n * Detect if fiber has actionable hook state changes by comparing with fiber.alternate.\n *\n * \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n * \u2502 \u26A0\uFE0F WARNING: fiber.alternate COMPARISON IS UNRELIABLE \u2502\n * \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n *\n * This function compares fiber.memoizedState vs fiber.alternate.memoizedState.\n *\n * THE PROBLEM:\n * fiber.alternate is NOT always \"state from the previous commit\".\n * It's \"state from last time this fiber was used as work-in-progress\".\n *\n * If a component re-renders due to parent/context changes (not self-update),\n * its alternate may be stale - from 1 commit ago, or 100 commits ago!\n *\n * This causes FALSE POSITIVES: we detect \"state changed\" when it didn't\n * change in THIS commit - it changed in some PAST commit.\n *\n * WHEN THIS FUNCTION IS RELIABLE:\n * \u2713 Component called setState (it IS the initiator)\n * \u2713 Component has useSyncExternalStore that just triggered\n * \u2713 Used alongside memoizedUpdaters validation\n *\n * WHEN THIS FUNCTION IS UNRELIABLE:\n * \u2717 Component re-rendered due to parent update\n * \u2717 Component re-rendered due to context change\n * \u2717 Component was memoized and skipped N commits\n * \u2717 Any scenario where alternate wasn't recently updated\n *\n * RECOMMENDED USAGE:\n * - In development: Use memoizedUpdaters to verify component is an initiator,\n * THEN use this function to get hook change details.\n * - In production: Use lastSeenHookState tracking (see detector.tsx)\n * instead of relying on fiber.alternate.\n *\n * See detector.tsx for the full explanation of the \"swap\" problem and solution.\n */\nexport function didFiberSelfUpdate(fiber: Fiber): StateChangeResult | null {\n // Early exit: only check fibers that actually rendered (have PerformedWork flag)\n if (!didFiberUpdate(fiber)) return null;\n\n const alt = fiber.alternate;\n if (!alt) return null;\n\n const prevState = alt.memoizedState;\n const currentState = fiber.memoizedState;\n\n const result = detectHookStateChanges(prevState, currentState);\n return result.hasChange ? result : null;\n}\n\nexport function isDevelopment(): boolean {\n return process.env.NODE_ENV !== 'production';\n}\n", "/**\n * Types and helpers for writing a Chippy plugin. `chippy pack` bundles a\n * plugin module's default export into a custom skill and extension build.\n */\nimport type { ChippyPlugin } from '../plugins/index';\n\nexport type { ChippyPlugin, PluginStatus } from '../plugins/index';\nexport type {\n ContextBoundaryKind,\n ContextContribution,\n ContextField,\n ContextProvider,\n} from '../layout-lab/marker-context';\nexport type { Fiber } from '../layout-lab/fiber-walk';\nexport type {\n CommitMetricsAdapter,\n CommitMetricsDelta,\n CommitMetricsMeasurement,\n CommitMetricsRoot,\n CommitMetricsSnapshot,\n} from '../avalanche/commit-metrics-adapter';\n/** Display name of a fiber's `type`: function, class, memo, or forwardRef. */\nexport { getDisplayName } from '../layout-lab/fiber-walk';\n/** Fiber name helpers that commit metrics adapters use for hook ancestry. */\nexport { getComponentName, isComponentFiber } from '../avalanche/internals';\n\n/** Identity helper that type-checks a plugin definition. */\nexport function definePlugin(plugin: ChippyPlugin): ChippyPlugin {\n return plugin;\n}\n"],
|
|
5
|
+
"mappings": ";AAOA,IAAM,SAAS;AACf,IAAM,WAAW,IAAI,UAAU,GAAG,EAAE,KAAK,EAAE;AAC3C,SAAS,QAAQ,GAAG,QAAQ,OAAO,QAAQ,SAAS,GAAG;AACrD,WAAS,OAAO,WAAW,KAAK,CAAC,IAAI;AACvC;;;ACoCA,IAAM,WAAW;AACjB,IAAM,WAAW;AACjB,IAAM,uBAAuB;AAMtB,SAAS,iBAAiB,OAA6B;AAC5D,QAAM,SAAuB,CAAC;AAE9B,aAAW,OAAO,MAAM,MAAM,IAAI,GAAG;AACnC,UAAM,OAAO,IAAI,QAAQ;AACzB,QAAI,SAAS,MAAM,KAAK,SAAS,WAAW,EAAG;AAC/C,UAAM,QAAQ,SAAS,KAAK,IAAI,KAAK,SAAS,KAAK,IAAI;AACvD,QAAI,CAAC,MAAO;AAEZ,UAAM,CAAC,EAAE,MAAM,MAAM,IAAI,aAAa,IAAI,eAAe,EAAE,IAAI;AAC/D,QAAI,QAAQ,MAAM,IAAI,WAAW,OAAO,EAAG;AAC3C,UAAM,KAAK,OAAO,UAAU;AAC5B,UAAM,SAAS,OAAO,YAAY;AAClC,QAAI,CAAC,OAAO,SAAS,EAAE,KAAK,CAAC,OAAO,SAAS,MAAM,KAAK,KAAK,EAAG;AAChE,WAAO,KAAK;AAAA,MACV;AAAA,MACA,MAAM;AAAA,MACN;AAAA,MACA,GAAI,SAAS,UAAa,SAAS,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;AAAA,IAChE,CAAC;AAAA,EACH;AAEA,SAAO;AACT;AAEO,SAAS,aAAa,MAAuB;AAClD,SACE,KAAK,SAAS,cAAc,KAC5B,KAAK,SAAS,cAAc,KAC5B,KAAK,SAAS,SAAS;AAE3B;AAEA,SAAS,YAAY,OAAwC;AAC3D,SAAO,EAAE,UAAU,MAAM,KAAK,YAAY,MAAM,MAAM,cAAc,MAAM,OAAO;AACnF;AAEA,SAAS,qBAAqB,UAAiD;AAC7E,SAAO,EAAE,UAAU,SAAS,QAAQ,YAAY,SAAS,MAAM,cAAc,SAAS,OAAO;AAC/F;AAEO,SAAS,iBACd,QACA,aAC4B;AAC5B,aAAW,SAAS,QAAQ;AAC1B,QAAI,aAAa,MAAM,GAAG,EAAG;AAC7B,UAAM,WAAW,YAAY,KAAK;AAClC,QAAI,aAAa,KAAM,QAAO,YAAY,KAAK;AAC/C,QAAI,aAAa,SAAS,MAAM,EAAG;AACnC,WAAO,qBAAqB,QAAQ;AAAA,EACtC;AACA,SAAO;AACT;AAiHA,SAAS,cAAc,OAA4C;AACjE,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,QAAM,SAAS;AACf,MAAI,OAAO,OAAO,aAAa,YAAY,OAAO,OAAO,eAAe,SAAU,QAAO;AACzF,SAAO;AAAA,IACL,UAAU,OAAO;AAAA,IACjB,YAAY,OAAO;AAAA,IACnB,cAAc,OAAO,OAAO,iBAAiB,WAAW,OAAO,eAAe;AAAA,EAChF;AACF;AAEA,SAAS,eAAe,OAAgD;AACtE,QAAM,SAAS,cAAc,MAAM,YAAY;AAC/C,MAAI,OAAQ,QAAO;AACnB,QAAM,OAAO,MAAM;AACnB,SAAO,SAAS,OAAO,SAAS,YAAY,OAAO,SAAS,cACxD,cAAe,KAAoC,YAAY,IAC/D;AACN;AAEO,SAAS,oBAAoB,OAAqD;AACvF,MAAI,CAAC,SAAS,CAAC,MAAM,eAAe,OAAO,MAAM,gBAAgB,SAAU,QAAO,CAAC;AACnF,QAAM,QAAS,MAAM,YAAoC;AACzD,MAAI,OAAO,UAAU,YAAY,UAAU,GAAI,QAAO,CAAC;AACvD,QAAM,aAA2B,CAAC;AAClC,aAAW,SAAS,iBAAiB,KAAK,GAAG;AAC3C,QAAI,aAAa,MAAM,GAAG,EAAG;AAC7B,eAAW,KAAK,KAAK;AACrB,QAAI,WAAW,UAAU,qBAAsB;AAAA,EACjD;AACA,SAAO;AACT;AAGO,SAAS,wBAAwB,OAAmE;AACzG,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,WAAW,eAAe,KAAK;AACrC,MAAI,SAAU,QAAO;AACrB,SAAO,iBAAiB,oBAAoB,KAAK,GAAG,MAAM,IAAI;AAChE;;;ACnKO,SAAS,eAAe,MAA8B;AAC3D,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,QAAM,MAAM;AACZ,SAAQ,IAAI,eAA2B,IAAI,QAAmB;AAChE;;;ACkBA,IAAM,iBAAiB,oBAAI,IAAI,CAAC,GAAG,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;AAqB7C,SAAS,iBAAiB,KAAsB;AACrD,SAAO,eAAe,IAAI,GAAG;AAC/B;AAsEO,SAAS,iBAAiB,OAAsB;AAMrD,QAAM,OAAO,MAAM;AAEnB,QAAM,cAAc,MAAM;AAC1B,QAAM,QACJ,MAAM,eACN,MAAM,QACN,aAAa,eACb,aAAa;AAEf,MAAI,MAAO,QAAO;AAClB,QAAM,SAAS,wBAAwB,KAAK;AAC5C,SAAQ,UAAU,IAAI,OAAO,QAAQ,IAAI,OAAO,UAAU,OAAQ;AACpE;;;AC5MO,SAAS,aAAa,QAAoC;AAC/D,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
|
-
export const CHIPPY_INSTALL_COMMAND = 'npx
|
|
3
|
+
export const CHIPPY_INSTALL_COMMAND = 'npx skills add atlassian-labs/chippy --skill chippy -g';
|
|
4
4
|
|
|
5
5
|
const unavailableConnectionStates = new Set(['failed', 'reconnecting']);
|
|
6
6
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The addon owns Chippy for the whole Storybook page, not only the preview
|
|
2
|
+
// iframe. The Chippy extension injects into the top frame, which is the
|
|
3
|
+
// Storybook manager, and stands down when a live owner of another integration
|
|
4
|
+
// holds window.__CHIPPY_OWNER__. Claiming the manager window keeps agents on the
|
|
5
|
+
// preview's `storybook` client instead of Storybook's own UI.
|
|
6
|
+
const OWNER_ID = 'storybook-addon-manager';
|
|
7
|
+
const STALE_AFTER_MS = 15_000;
|
|
8
|
+
const HEARTBEAT_MS = 5_000;
|
|
9
|
+
|
|
10
|
+
export const MANAGER_INTEGRATION = Object.freeze({
|
|
11
|
+
kind: 'storybook',
|
|
12
|
+
who: 'Chippy Storybook addon',
|
|
13
|
+
source: '.storybook/chippy-addon/manager-owner.js',
|
|
14
|
+
reinjectsOnReload: true,
|
|
15
|
+
reloadAction: 'The manager window holds no Chippy client; the preview iframe is the inspected page.',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function claimManagerOwner(scope = globalThis) {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
scope.__CHIPPY_OWNER__ = {
|
|
21
|
+
ownerId: OWNER_ID,
|
|
22
|
+
ownerType: OWNER_ID,
|
|
23
|
+
integration: MANAGER_INTEGRATION,
|
|
24
|
+
staleAfterMs: STALE_AFTER_MS,
|
|
25
|
+
startedAt: now,
|
|
26
|
+
heartbeatAt: now,
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
scope.dispatchEvent(new CustomEvent('chippy-owner-claimed', { detail: JSON.stringify({ ownerId: OWNER_ID }) }));
|
|
30
|
+
} catch (_) {}
|
|
31
|
+
const timer = setInterval(() => {
|
|
32
|
+
if (scope.__CHIPPY_OWNER__?.ownerId === OWNER_ID) scope.__CHIPPY_OWNER__.heartbeatAt = Date.now();
|
|
33
|
+
}, HEARTBEAT_MS);
|
|
34
|
+
return () => clearInterval(timer);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// An extension bridge that did not stand down, such as one from an older
|
|
38
|
+
// extension version, is still connected to the helper from this window.
|
|
39
|
+
export function extensionBridgeInManager(scope = globalThis) {
|
|
40
|
+
const state = scope.__CHIPPY?.pageBridge?.getState?.();
|
|
41
|
+
return state?.integration?.kind === 'extension' && state.connState !== 'yielded' ? state : null;
|
|
42
|
+
}
|
|
@@ -6,6 +6,7 @@ import { ADDON_ID, AVALANCHE_PANEL_ID, CHIPPY_PANEL_ID, EVENTS, PREVIEW_MESSAGE_
|
|
|
6
6
|
import { ChippyConnectionFallback } from './connection-fallback.jsx';
|
|
7
7
|
import { commitSpan, describeCommitAction, groupCommitRows } from './avalanche-panel-model.js';
|
|
8
8
|
import { ContextCard } from './context-card.jsx';
|
|
9
|
+
import { claimManagerOwner, extensionBridgeInManager } from './manager-owner.js';
|
|
9
10
|
import { ProbeAnnotationEditor } from './probe-annotation-editor.jsx';
|
|
10
11
|
|
|
11
12
|
const panelFont = "'Atlassian Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
|
|
@@ -19,6 +20,8 @@ const theme = {
|
|
|
19
20
|
danger: 'var(--color-negative-text, #ffb4ab)',
|
|
20
21
|
};
|
|
21
22
|
|
|
23
|
+
claimManagerOwner();
|
|
24
|
+
|
|
22
25
|
function postToPreview(message) {
|
|
23
26
|
const iframe = document.getElementById('storybook-preview-iframe');
|
|
24
27
|
iframe?.contentWindow?.postMessage({ source: PREVIEW_MESSAGE_SOURCE, ...message }, '*');
|
|
@@ -243,6 +246,23 @@ function SimpleTable({ columns, rows, empty, onRowClick }) {
|
|
|
243
246
|
);
|
|
244
247
|
}
|
|
245
248
|
|
|
249
|
+
// Shown when an extension bridge is still connected from the manager window, so
|
|
250
|
+
// a `pages` entry of kind `extension` would inspect Storybook's own UI.
|
|
251
|
+
function ExtensionConflict() {
|
|
252
|
+
const [conflict, setConflict] = useState(() => !!extensionBridgeInManager());
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
const timer = setInterval(() => setConflict(!!extensionBridgeInManager()), 3000);
|
|
255
|
+
return () => clearInterval(timer);
|
|
256
|
+
}, []);
|
|
257
|
+
if (!conflict) return null;
|
|
258
|
+
return (
|
|
259
|
+
<div role="alert" style={{ color: theme.danger, fontSize: 12, marginBottom: 12 }}>
|
|
260
|
+
The Chippy extension is also connected from the Storybook manager. Its `extension` page inspects
|
|
261
|
+
Storybook's UI; use the `storybook` page for the story, or update the extension.
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
246
266
|
function ChippyPanel({ active }) {
|
|
247
267
|
const { payload, command } = useChippyChannel('chippy', active);
|
|
248
268
|
const inspectBoundary = React.useCallback(
|
|
@@ -269,6 +289,7 @@ function ChippyPanel({ active }) {
|
|
|
269
289
|
<Header title="Chippy">
|
|
270
290
|
<Button
|
|
271
291
|
size="small"
|
|
292
|
+
ariaLabel={false}
|
|
272
293
|
variant={probesVisible ? 'outline' : undefined}
|
|
273
294
|
onClick={() => command(probesVisible ? 'clear-probes' : 'ensure-probes')}
|
|
274
295
|
>
|
|
@@ -276,6 +297,7 @@ function ChippyPanel({ active }) {
|
|
|
276
297
|
</Button>
|
|
277
298
|
</Header>
|
|
278
299
|
<ChippyConnectionFallback connState={state?.connState} />
|
|
300
|
+
<ExtensionConflict />
|
|
279
301
|
<Section>
|
|
280
302
|
{probesVisible ? (
|
|
281
303
|
<div style={{ color: theme.muted, fontSize: 12, marginBottom: 8 }}>
|
package/lib/storybook/preview.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { addons } from 'storybook/preview-api';
|
|
2
2
|
import { FORCE_REMOUNT, STORY_CHANGED, STORY_HOT_UPDATED } from 'storybook/internal/core-events';
|
|
3
3
|
import { EVENTS, PREVIEW_MESSAGE_SOURCE } from './constants.js';
|
|
4
|
-
import { OWNER_ID, ownerResult } from './setup.js';
|
|
4
|
+
import { OWNER_ID, avalancheArmReady, ownerResult } from './setup.js';
|
|
5
5
|
|
|
6
6
|
import '../extension/inspect-react.js';
|
|
7
7
|
import '../extension/panel-dom.js';
|
|
@@ -12,20 +12,6 @@ import '../extension/chippy-page-bridge.js';
|
|
|
12
12
|
|
|
13
13
|
if (ownerResult.ok) {
|
|
14
14
|
globalThis.__AVALANCHE__ = globalThis.__AVALANCHE__ || avalanche;
|
|
15
|
-
// Static ESM imports evaluate before setup.js can finish stamping
|
|
16
|
-
// __CHIPPY_STARTUP_CONFIG. Honor reload-with-avalanche autoStart here too,
|
|
17
|
-
// after setup.js has run and the addon owns the page.
|
|
18
|
-
const arm = globalThis.__CHIPPY_STARTUP_CONFIG?.avalanche || null;
|
|
19
|
-
const api = globalThis.__AVALANCHE__;
|
|
20
|
-
if (arm?.autoStart && api && typeof api.install === 'function' && !api.status?.().installed) {
|
|
21
|
-
try {
|
|
22
|
-
api.install({
|
|
23
|
-
detectionMode: arm.options?.detectionMode || 'sources',
|
|
24
|
-
historySize: arm.max || 1000,
|
|
25
|
-
captureFibers: arm.options?.captureFibers !== false,
|
|
26
|
-
});
|
|
27
|
-
} catch (_) {}
|
|
28
|
-
}
|
|
29
15
|
}
|
|
30
16
|
|
|
31
17
|
function emit(channel, event, payload) {
|
|
@@ -57,9 +43,44 @@ let avalancheStoryId = null;
|
|
|
57
43
|
let avalancheAutoCache = null;
|
|
58
44
|
|
|
59
45
|
function currentStoryId() {
|
|
60
|
-
return
|
|
46
|
+
return globalThis.__STORYBOOK_PREVIEW__?.selectionStore?.selection?.storyId
|
|
47
|
+
|| new URLSearchParams(globalThis.location.search || '').get('id')
|
|
48
|
+
|| globalThis.__STORYBOOK_STORY_STORE__?.selection?.storyId
|
|
49
|
+
|| null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Storybook remounts only the render whose id matches FORCE_REMOUNT's storyId.
|
|
53
|
+
// Return that id, or null when no rendered story matches.
|
|
54
|
+
function remountTargetId() {
|
|
55
|
+
const storyId = currentStoryId();
|
|
56
|
+
const renders = globalThis.__STORYBOOK_PREVIEW__?.storyRenders;
|
|
57
|
+
if (!Array.isArray(renders)) return storyId;
|
|
58
|
+
return renders.some((render) => render.id === storyId) ? storyId : null;
|
|
61
59
|
}
|
|
62
60
|
|
|
61
|
+
// Install Avalanche with the options of a reload-with-avalanche arm. Returns
|
|
62
|
+
// true when this call installed it.
|
|
63
|
+
function installFromArm(arm) {
|
|
64
|
+
const api = getAvalancheApi();
|
|
65
|
+
if (!ownerResult.ok || !arm?.autoStart || typeof api?.install !== 'function' || api.status?.().installed) return false;
|
|
66
|
+
try {
|
|
67
|
+
api.install({
|
|
68
|
+
detectionMode: arm.options?.detectionMode || 'sources',
|
|
69
|
+
historySize: arm.max || 1000,
|
|
70
|
+
captureFibers: arm.options?.captureFibers !== false,
|
|
71
|
+
});
|
|
72
|
+
} catch (_) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
avalancheStoryId = currentStoryId();
|
|
76
|
+
avalancheAutoCache = null;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The arm lookup in setup.js resolves after module evaluation; the global
|
|
81
|
+
// loader below holds the first story render until it has been applied.
|
|
82
|
+
const armApplied = avalancheArmReady.then(installFromArm, () => false);
|
|
83
|
+
|
|
63
84
|
function ensureAvalancheInstalled({ resetForStory = false } = {}) {
|
|
64
85
|
const storyId = currentStoryId();
|
|
65
86
|
const status = runAvalanche('status');
|
|
@@ -234,8 +255,15 @@ window.addEventListener('message', (event) => {
|
|
|
234
255
|
|
|
235
256
|
window.addEventListener('chippy-storybook-force-remount', (event) => {
|
|
236
257
|
// Keep Storybook's manager and preview alive. FORCE_REMOUNT replaces only
|
|
237
|
-
// the rendered SUT, unlike reloading the preview iframe document.
|
|
238
|
-
|
|
258
|
+
// the rendered SUT, unlike reloading the preview iframe document. The event
|
|
259
|
+
// stays unhandled, and the helper reports the remount as failed, when no
|
|
260
|
+
// rendered story can be remounted.
|
|
261
|
+
const storyId = remountTargetId();
|
|
262
|
+
if (!storyId) return;
|
|
263
|
+
// An armed remount installs Avalanche first; the FORCE_REMOUNT listener
|
|
264
|
+
// above then clears history, so the capture starts with the remount.
|
|
265
|
+
installFromArm(event.detail?.avalanche);
|
|
266
|
+
emit(channel, FORCE_REMOUNT, { storyId });
|
|
239
267
|
event.preventDefault();
|
|
240
268
|
});
|
|
241
269
|
|
|
@@ -261,3 +289,10 @@ publishState(channel, ownerResult.ok ? 'ready' : 'blocked');
|
|
|
261
289
|
export const decorators = [
|
|
262
290
|
(Story) => Story(),
|
|
263
291
|
];
|
|
292
|
+
|
|
293
|
+
export const loaders = [
|
|
294
|
+
async () => {
|
|
295
|
+
await armApplied;
|
|
296
|
+
return {};
|
|
297
|
+
},
|
|
298
|
+
];
|
package/lib/storybook/setup.js
CHANGED
|
@@ -22,8 +22,6 @@ async function lookupAvalancheArm() {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const avalancheArm = await lookupAvalancheArm();
|
|
26
|
-
|
|
27
25
|
function acquireOwner(mode = 'chippy') {
|
|
28
26
|
const now = Date.now();
|
|
29
27
|
const current = globalThis.__CHIPPY_OWNER__ || null;
|
|
@@ -83,11 +81,24 @@ if (!ownerResult.ok) {
|
|
|
83
81
|
transport: 'storybook-static',
|
|
84
82
|
mode: 'chippy',
|
|
85
83
|
integration: INTEGRATION,
|
|
86
|
-
avalanche:
|
|
84
|
+
avalanche: null,
|
|
87
85
|
staleAfterMs: STALE_AFTER_MS,
|
|
88
86
|
};
|
|
89
87
|
globalThis.__CHIPPY = globalThis.__CHIPPY || {};
|
|
90
88
|
globalThis.__CHIPPY.config = { ...(globalThis.__CHIPPY.config || {}), ...globalThis.__CHIPPY_STARTUP_CONFIG };
|
|
91
89
|
}
|
|
92
90
|
|
|
93
|
-
|
|
91
|
+
// The arm lookup must not block module evaluation: top-level await fails
|
|
92
|
+
// `storybook build` under Vite's default browser targets. preview.js waits on
|
|
93
|
+
// this promise in a global loader, so the first story renders after the arm is
|
|
94
|
+
// known and its mount is captured.
|
|
95
|
+
function applyAvalancheArm(arm) {
|
|
96
|
+
if (!arm || !ownerResult.ok) return null;
|
|
97
|
+
globalThis.__CHIPPY_STARTUP_CONFIG = { ...(globalThis.__CHIPPY_STARTUP_CONFIG || {}), avalanche: arm };
|
|
98
|
+
globalThis.__CHIPPY.config = { ...(globalThis.__CHIPPY.config || {}), avalanche: arm };
|
|
99
|
+
return arm;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const avalancheArmReady = ownerResult.ok ? lookupAvalancheArm().then(applyAvalancheArm) : Promise.resolve(null);
|
|
103
|
+
|
|
104
|
+
export { HELPER_BASE_URL, INTEGRATION, OWNER_ID, OWNER_TYPE, STALE_AFTER_MS, avalancheArmReady, ownerResult };
|
|
@@ -16,8 +16,12 @@ export type SourceFiber = {
|
|
|
16
16
|
_debugStack?: unknown;
|
|
17
17
|
};
|
|
18
18
|
export type FetchModule = (url: string) => Promise<string | null>;
|
|
19
|
+
/** Module URLs that may serve a file named by React <=18 `_debugSource`. */
|
|
20
|
+
export type ModuleUrlsFor = (fileName: string) => readonly string[];
|
|
19
21
|
export type CallSiteResolver = {
|
|
20
22
|
locate(frames: readonly StackFrame[]): Promise<FiberSourceLocation | null>;
|
|
23
|
+
/** Map a recorded `_debugSource` position through its served module. */
|
|
24
|
+
locateRecorded(source: FiberSourceLocation): Promise<FiberSourceLocation>;
|
|
21
25
|
readonly stats: {
|
|
22
26
|
modules: number;
|
|
23
27
|
sites: number;
|
|
@@ -27,11 +31,18 @@ export type CallSiteResolver = {
|
|
|
27
31
|
export declare function parseStackFrames(stack: string): StackFrame[];
|
|
28
32
|
export declare function isVendorPath(path: string): boolean;
|
|
29
33
|
export declare function writerLocationOf(frames: readonly StackFrame[], originalFor: (frame: StackFrame) => OriginalPosition | null): FiberSourceLocation | null;
|
|
30
|
-
export declare function createCallSiteResolver(fetchModule: FetchModule): CallSiteResolver;
|
|
34
|
+
export declare function createCallSiteResolver(fetchModule: FetchModule, moduleUrlsFor?: ModuleUrlsFor): CallSiteResolver;
|
|
31
35
|
export declare function getFiberStackFrames(fiber: SourceFiber | null | undefined): StackFrame[];
|
|
32
36
|
/** Stable synchronous source, before any async source-map upgrade. */
|
|
33
37
|
export declare function getFiberSourceCandidate(fiber: SourceFiber | null | undefined): FiberSourceLocation | null;
|
|
34
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Synchronous source read used by fiber walks and snapshots: the resolved
|
|
40
|
+
* source once `resolveFiberSource` has run for this fiber, else the candidate.
|
|
41
|
+
*/
|
|
35
42
|
export declare function getFiberSource(fiber: SourceFiber | null | undefined): FiberSourceLocation | null;
|
|
36
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a fiber's authored source through the served module's inline or
|
|
45
|
+
* external map: the React 19 `_debugStack` call site, or the React <=18
|
|
46
|
+
* `_debugSource` literal when a transform shifted its recorded line.
|
|
47
|
+
*/
|
|
37
48
|
export declare function resolveFiberSource(fiber: SourceFiber | null | undefined, resolver?: CallSiteResolver): Promise<FiberSourceLocation | null>;
|