diff --git a/packages/tool-server/src/utils/debugger/scripts/component-tree.ts b/packages/tool-server/src/utils/debugger/scripts/component-tree.ts index aef7f7fe..818bebf5 100644 --- a/packages/tool-server/src/utils/debugger/scripts/component-tree.ts +++ b/packages/tool-server/src/utils/debugger/scripts/component-tree.ts @@ -122,14 +122,35 @@ export function makeComponentTreeScript(opts: { return t.displayName || t.name || null; } + // Per-host dedup/measure cache key. MUST be a primitive: on Fabric the host + // "node" is a shadow-node OBJECT, so the old key ('f' + hi.n) stringified it + // to "f[object Object]" for EVERY node — collapsing all hosts to one entry and + // giving every component the first (root) view's full-screen rect, i.e. a + // (0.5, 0.5) tap for everything. Key by the numeric nativeTag instead, with a + // WeakMap-by-identity fallback so distinct nodes never share a key. + var _hostKeySeq = 0; + var _fabricKeyMap = typeof WeakMap !== 'undefined' ? new WeakMap() : null; + function getHostInfo(fiber) { if (typeof fiber.type !== 'string' || !fiber.stateNode) return null; - if (useFabric && fiber.stateNode.node) return { f: true, n: fiber.stateNode.node }; + if (useFabric && fiber.stateNode.node) { + var sn = fiber.stateNode; + var fKey; + if (sn.canonical && typeof sn.canonical.nativeTag === 'number') { + fKey = 'f' + sn.canonical.nativeTag; + } else if (_fabricKeyMap) { + fKey = _fabricKeyMap.get(sn.node); + if (fKey === undefined) { fKey = 'fo' + (++_hostKeySeq); _fabricKeyMap.set(sn.node, fKey); } + } else { + fKey = 'fo' + (++_hostKeySeq); + } + return { f: true, n: sn.node, key: fKey }; + } if (!useFabric) { if (fiber.stateNode.canonical && typeof fiber.stateNode.canonical.nativeTag === 'number') - return { f: false, n: fiber.stateNode.canonical.nativeTag }; + return { f: false, n: fiber.stateNode.canonical.nativeTag, key: 'p' + fiber.stateNode.canonical.nativeTag }; if (typeof fiber.stateNode._nativeTag === 'number') - return { f: false, n: fiber.stateNode._nativeTag }; + return { f: false, n: fiber.stateNode._nativeTag, key: 'p' + fiber.stateNode._nativeTag }; } return null; } @@ -249,7 +270,7 @@ export function makeComponentTreeScript(opts: { for (var ci = 0; ci < candidates.length; ci++) { var hi = candidates[ci].hostInfo; if (!hi) continue; - var key = (hi.f ? 'f' : 'p') + hi.n; + var key = hi.key; if (!(key in hostKeyMap)) { hostKeyMap[key] = uniqueHosts.length; uniqueHosts.push(hi); @@ -277,7 +298,7 @@ export function makeComponentTreeScript(opts: { var rects = await Promise.all(uniqueHosts.map(measureOne)); for (var ri = 0; ri < uniqueHosts.length; ri++) { - var rKey = (uniqueHosts[ri].f ? 'f' : 'p') + uniqueHosts[ri].n; + var rKey = uniqueHosts[ri].key; rectCache[rKey] = rects[ri]; } @@ -285,7 +306,7 @@ export function makeComponentTreeScript(opts: { for (var ai = 0; ai < candidates.length; ai++) { var h = candidates[ai].hostInfo; if (h) { - var rk = (h.f ? 'f' : 'p') + h.n; + var rk = h.key; candidates[ai].rect = rectCache[rk] || null; } } diff --git a/packages/tool-server/test/debugger/component-tree-script.test.ts b/packages/tool-server/test/debugger/component-tree-script.test.ts new file mode 100644 index 00000000..5fc652b0 --- /dev/null +++ b/packages/tool-server/test/debugger/component-tree-script.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { makeComponentTreeScript } from "../../src/utils/debugger/scripts/component-tree"; + +/** + * Reproduces the Fabric tap-coordinate collapse bug at the injected-script + * level (the existing component-tree.test.ts only tests the pure post-processor + * with pre-filled rects, so it never exercised this path). + * + * Builds a minimal Fabric fiber tree of three sibling app components, each + * wrapping a host RCTView whose shadow node is a DISTINCT object with a DISTINCT + * nativeTag, measured at a DISTINCT on-screen position. The bug keyed the + * per-host measure cache by the shadow-node OBJECT ("f" + node → "f[object + * Object]" for every node), collapsing all hosts to one entry so every + * component inherited the first host's rect — i.e. a (0.5, 0.5) tap for all. + */ + +interface Rect { + x: number; + y: number; + w: number; + h: number; + px: number; + py: number; +} + +function buildFabricTree() { + const rectByNode = new Map(); + let nextTag = 100; + + function hostFiber(rect: Rect) { + const node = {}; // a distinct Fabric shadow-node object per host + rectByNode.set(node, rect); + return { + type: "RCTView", + stateNode: { node, canonical: { nativeTag: ++nextTag } }, + memoizedProps: {}, + child: null, + sibling: null, + } as Record; + } + + function compFiber(name: string, rect: Rect, sibling: unknown) { + const type = function () {} as { displayName?: string }; + type.displayName = name; + return { + type, + stateNode: null, + memoizedProps: { children: name }, + child: hostFiber(rect), + sibling, + } as Record; + } + + // Distinct vertical positions (px/py are what the script records). + const compC = compFiber("CompC", { x: 0, y: 0, w: 200, h: 50, px: 10, py: 600 }, null); + const compB = compFiber("CompB", { x: 0, y: 0, w: 200, h: 50, px: 10, py: 300 }, compC); + const compA = compFiber("CompA", { x: 0, y: 0, w: 200, h: 50, px: 10, py: 100 }, compB); + + return { root: { current: { child: compA } }, rectByNode }; +} + +async function runInjectedScript() { + const { root, rectByNode } = buildFabricTree(); + const script = makeComponentTreeScript({ requestId: "test", includeSkipped: false }); + + let captured: { result: string } | undefined; + const sandbox = { + window: { + __REACT_DEVTOOLS_GLOBAL_HOOK__: { getFiberRoots: () => new Set([root]) }, + }, + nativeFabricUIManager: { + measure( + node: object, + cb: (x: number, y: number, w: number, h: number, px: number, py: number) => void + ) { + const r = rectByNode.get(node)!; + cb(r.x, r.y, r.w, r.h, r.px, r.py); + }, + }, + __r: Object.assign((_id: number) => ({ Dimensions: { get: () => ({ width: 400, height: 800 }) } }), { + getModules: () => [[0, { isInitialized: true }]], + }), + __argent_callback: (json: string) => { + captured = JSON.parse(json); + }, + }; + + const runner = new Function( + "window", + "nativeFabricUIManager", + "__r", + "__argent_callback", + `return ${script}` + ); + await runner(sandbox.window, sandbox.nativeFabricUIManager, sandbox.__r, sandbox.__argent_callback); + + if (!captured) throw new Error("script did not invoke __argent_callback"); + return JSON.parse(captured.result) as { + screenW: number; + screenH: number; + components: Array<{ name: string; rect: { x: number; y: number; w: number; h: number } | null }>; + }; +} + +describe("makeComponentTreeScript — Fabric measurement", () => { + it("gives each host its own measured rect instead of collapsing onto one", async () => { + const result = await runInjectedScript(); + + expect(result.screenW).toBe(400); + expect(result.screenH).toBe(800); + + const comps = Object.fromEntries(result.components.map((c) => [c.name, c.rect])); + expect(comps.CompA).toMatchObject({ y: 100 }); + expect(comps.CompB).toMatchObject({ y: 300 }); + expect(comps.CompC).toMatchObject({ y: 600 }); + + // The regression: all three rects were identical (collapsed onto the first + // host), which renders as the same centre-of-screen tap for every element. + const distinctY = new Set(result.components.map((c) => c.rect?.y)); + expect(distinctY.size).toBe(3); + }); +});