@pinagent/react-native 0.2.3 → 0.2.4

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.
@@ -76,6 +76,9 @@ interface RawInspectorData {
76
76
  interface FiberLike {
77
77
  tag?: number;
78
78
  return?: FiberLike | null;
79
+ /** First child / next sibling — the render-tree walk for the measure fallback. */
80
+ child?: FiberLike | null;
81
+ sibling?: FiberLike | null;
79
82
  stateNode?: {
80
83
  canonical?: {
81
84
  publicInstance?: unknown;
@@ -137,6 +140,48 @@ export declare function crumbsOf(data: RawInspectorData): RawCrumb[];
137
140
  * stuck measure can't hang the pick).
138
141
  */
139
142
  export declare function measureFrame(measure?: (cb: RawMeasureCb) => void): Promise<Frame | null>;
143
+ /** True when window-coordinate point (x, y) lies within frame `f`. */
144
+ export declare function frameContains(f: Frame, x: number, y: number): boolean;
145
+ /** A measure-resolved hit: the deepest tagged host whose frame holds the tap. */
146
+ interface MeasuredHit {
147
+ fiber: FiberLike;
148
+ loc: Loc;
149
+ name: string | null;
150
+ frame: Frame;
151
+ }
152
+ /**
153
+ * Measure-based hit-test — the fallback for when RN's geometric
154
+ * `findNodeAtPoint` can't descend to the tapped element.
155
+ *
156
+ * `react-native-pager-view` (and anything that hosts content in a native
157
+ * container) detaches a page's *native* views from the Fabric shadow tree
158
+ * `findNodeAtPoint` walks, so the native hit-test bottoms out at the page
159
+ * wrapper and never reaches the widget. But the React **fiber** tree is intact
160
+ * and the widgets are on-screen, hence measurable — so we hit-test ourselves:
161
+ * DFS the fiber subtree under `root`, measuring each host, and keep the DEEPEST
162
+ * tagged host whose window frame contains the tap.
163
+ *
164
+ * Pruned like the browser's `elementFromPoint`: a host whose frame misses the
165
+ * point can't have a (non-overflowing) descendant that hits, so its subtree is
166
+ * skipped. Composite / fragment fibers carry no frame, so we always descend
167
+ * through them to reach their hosts. `measure` is injected so the traversal is
168
+ * unit-testable without an RN runtime.
169
+ */
170
+ export declare function measureHitTest(root: FiberLike | null, x: number, y: number, measure: (fiber: FiberLike) => Promise<Frame | null>): Promise<MeasuredHit | null>;
171
+ /**
172
+ * The chain of distinctly-located tagged hosts from `leaf` up to the root,
173
+ * root-first (matching {@link crumbsOf}'s order). Built from the fiber `return`
174
+ * chain for the measure-fallback breadcrumb. Consecutive hosts sharing a
175
+ * `data-pa-loc` (a forwarding wrapper re-emitting the call site) collapse to a
176
+ * single crumb; names come from the babel plugin's `data-pa-comp`. Pure over a
177
+ * fiber-like chain (capped against a malformed `return` cycle); exported for
178
+ * unit testing.
179
+ */
180
+ export declare function taggedAncestors(leaf: FiberLike | null): {
181
+ name: string;
182
+ loc: Loc;
183
+ fiber: FiberLike;
184
+ }[];
140
185
  /**
141
186
  * Resolve a tap (in window coordinates) to a {@link PickResult}.
142
187
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinagent/react-native",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "license": "Apache-2.0",
5
5
  "description": "React Native & Expo plugin for Pinagent — tap a view, leave a comment, and a coding agent fixes it with file:line + a screenshot. The native widget ships as source for Metro; the dev-server middleware and Babel source-tagging plugin are built for Node.",
6
6
  "keywords": [
@@ -80,8 +80,8 @@
80
80
  "@types/ws": "^8.18.1",
81
81
  "tsdown": "^0.22.0",
82
82
  "typescript": "^6.0.3",
83
- "@pinagent/db": "0.0.1",
84
- "@pinagent/agent-runner": "0.0.0"
83
+ "@pinagent/agent-runner": "0.0.0",
84
+ "@pinagent/db": "0.0.1"
85
85
  },
86
86
  "scripts": {
87
87
  "prebuild": "node scripts/copy-drizzle.mjs",
@@ -140,6 +140,9 @@ const HOST_COMPONENT = 5;
140
140
  interface FiberLike {
141
141
  tag?: number;
142
142
  return?: FiberLike | null;
143
+ /** First child / next sibling — the render-tree walk for the measure fallback. */
144
+ child?: FiberLike | null;
145
+ sibling?: FiberLike | null;
143
146
  stateNode?: { canonical?: { publicInstance?: unknown } } | null;
144
147
  /** Host fibers carry the committed props — where `data-pa-loc` rides. */
145
148
  memoizedProps?: RawProps;
@@ -224,6 +227,12 @@ function paLocOf(props: RawProps): Loc | null {
224
227
  return parsePaLoc(props?.['data-pa-loc']);
225
228
  }
226
229
 
230
+ /** The enclosing component name the babel plugin records as `data-pa-comp`. */
231
+ function compOf(props: RawProps): string | null {
232
+ const c = props?.['data-pa-comp'];
233
+ return typeof c === 'string' && c.length > 0 ? c : null;
234
+ }
235
+
227
236
  /**
228
237
  * Walk a host fiber's render-tree parent (`return`) chain, returning the first
229
238
  * `data-pa-loc` found on a fiber's `memoizedProps` — the tapped element itself,
@@ -429,6 +438,132 @@ export function measureFrame(measure?: (cb: RawMeasureCb) => void): Promise<Fram
429
438
  });
430
439
  }
431
440
 
441
+ /** True when window-coordinate point (x, y) lies within frame `f`. */
442
+ export function frameContains(f: Frame, x: number, y: number): boolean {
443
+ return x >= f.x && y >= f.y && x <= f.x + f.width && y <= f.y + f.height;
444
+ }
445
+
446
+ /** A measure-resolved hit: the deepest tagged host whose frame holds the tap. */
447
+ interface MeasuredHit {
448
+ fiber: FiberLike;
449
+ loc: Loc;
450
+ name: string | null;
451
+ frame: Frame;
452
+ }
453
+
454
+ /**
455
+ * Measure a host fiber's on-screen rect via its public instance's
456
+ * `measureInWindow` (window coordinates). Guarded + timed-out like
457
+ * {@link measureFrame}; resolves null for a non-host fiber, a missing instance,
458
+ * or a zero-size / never-firing measure. The RN-runtime half of the measure
459
+ * fallback — not unit-tested (see {@link measureHitTest}, which injects this).
460
+ */
461
+ function measureFiberInWindow(fiber: FiberLike): Promise<Frame | null> {
462
+ const inst = fiber.stateNode?.canonical?.publicInstance as
463
+ | { measureInWindow?: (cb: (x: number, y: number, w: number, h: number) => void) => void }
464
+ | undefined;
465
+ const measure = inst?.measureInWindow;
466
+ if (typeof measure !== 'function') return Promise.resolve(null);
467
+ return new Promise((resolve) => {
468
+ let settled = false;
469
+ const finish = (f: Frame | null) => {
470
+ if (!settled) {
471
+ settled = true;
472
+ resolve(f);
473
+ }
474
+ };
475
+ const timer = setTimeout(() => finish(null), 120);
476
+ try {
477
+ measure.call(inst, (x, y, width, height) => {
478
+ clearTimeout(timer);
479
+ finish(width > 0 || height > 0 ? { x, y, width, height } : null);
480
+ });
481
+ } catch {
482
+ clearTimeout(timer);
483
+ finish(null);
484
+ }
485
+ });
486
+ }
487
+
488
+ /**
489
+ * Measure-based hit-test — the fallback for when RN's geometric
490
+ * `findNodeAtPoint` can't descend to the tapped element.
491
+ *
492
+ * `react-native-pager-view` (and anything that hosts content in a native
493
+ * container) detaches a page's *native* views from the Fabric shadow tree
494
+ * `findNodeAtPoint` walks, so the native hit-test bottoms out at the page
495
+ * wrapper and never reaches the widget. But the React **fiber** tree is intact
496
+ * and the widgets are on-screen, hence measurable — so we hit-test ourselves:
497
+ * DFS the fiber subtree under `root`, measuring each host, and keep the DEEPEST
498
+ * tagged host whose window frame contains the tap.
499
+ *
500
+ * Pruned like the browser's `elementFromPoint`: a host whose frame misses the
501
+ * point can't have a (non-overflowing) descendant that hits, so its subtree is
502
+ * skipped. Composite / fragment fibers carry no frame, so we always descend
503
+ * through them to reach their hosts. `measure` is injected so the traversal is
504
+ * unit-testable without an RN runtime.
505
+ */
506
+ export async function measureHitTest(
507
+ root: FiberLike | null,
508
+ x: number,
509
+ y: number,
510
+ measure: (fiber: FiberLike) => Promise<Frame | null>,
511
+ ): Promise<MeasuredHit | null> {
512
+ let best: MeasuredHit | null = null;
513
+ let bestDepth = -1;
514
+
515
+ async function visitChildren(parent: FiberLike, depth: number): Promise<void> {
516
+ let n = 0;
517
+ for (let node = parent.child ?? null; node && n < 100_000; node = node.sibling ?? null, n++) {
518
+ await visitNode(node, depth);
519
+ }
520
+ }
521
+
522
+ async function visitNode(node: FiberLike, depth: number): Promise<void> {
523
+ if (node.tag === HOST_COMPONENT) {
524
+ const frame = await measure(node);
525
+ // A host that doesn't contain the point prunes its whole subtree.
526
+ if (!frame || !frameContains(frame, x, y)) return;
527
+ const loc = paLocOf(node.memoizedProps);
528
+ // Deepest tagged host wins; on a tie the later (over-painted) sibling does.
529
+ if (loc && depth >= bestDepth) {
530
+ best = { fiber: node, loc, name: compOf(node.memoizedProps), frame };
531
+ bestDepth = depth;
532
+ }
533
+ }
534
+ await visitChildren(node, depth + 1);
535
+ }
536
+
537
+ if (root) await visitNode(root, 0);
538
+ return best;
539
+ }
540
+
541
+ /**
542
+ * The chain of distinctly-located tagged hosts from `leaf` up to the root,
543
+ * root-first (matching {@link crumbsOf}'s order). Built from the fiber `return`
544
+ * chain for the measure-fallback breadcrumb. Consecutive hosts sharing a
545
+ * `data-pa-loc` (a forwarding wrapper re-emitting the call site) collapse to a
546
+ * single crumb; names come from the babel plugin's `data-pa-comp`. Pure over a
547
+ * fiber-like chain (capped against a malformed `return` cycle); exported for
548
+ * unit testing.
549
+ */
550
+ export function taggedAncestors(
551
+ leaf: FiberLike | null,
552
+ ): { name: string; loc: Loc; fiber: FiberLike }[] {
553
+ const out: { name: string; loc: Loc; fiber: FiberLike }[] = [];
554
+ const seen = new Set<string>();
555
+ for (let f = leaf, i = 0; f && i < 10_000; f = f.return ?? null, i++) {
556
+ if (f.tag !== HOST_COMPONENT) continue;
557
+ const loc = paLocOf(f.memoizedProps);
558
+ if (!loc) continue;
559
+ const key = `${loc.file}:${loc.line}:${loc.col}`;
560
+ if (seen.has(key)) continue;
561
+ seen.add(key);
562
+ out.push({ name: compOf(f.memoizedProps) ?? 'View', loc, fiber: f });
563
+ }
564
+ return out.reverse();
565
+ }
566
+
432
567
  /**
433
568
  * Resolve a tap (in window coordinates) to a {@link PickResult}.
434
569
  *
@@ -466,42 +601,62 @@ export function resolvePick(
466
601
  try {
467
602
  fn(inspectedView, x, y, (data) => {
468
603
  clearTimeout(timer);
469
- const frame = data.frame
470
- ? {
471
- x: data.frame.left,
472
- y: data.frame.top,
473
- width: data.frame.width,
474
- height: data.frame.height,
604
+ void (async () => {
605
+ const frame = data.frame
606
+ ? {
607
+ x: data.frame.left,
608
+ y: data.frame.top,
609
+ width: data.frame.width,
610
+ height: data.frame.height,
611
+ }
612
+ : null;
613
+ const rawCrumbs = crumbsOf(data);
614
+ const loc = pickLoc(data, projectRoot);
615
+ const nameChain = nameChainOf(data);
616
+
617
+ // Measure fallback: when RN's native `findNodeAtPoint` couldn't
618
+ // descend to a tagged element (`tappedLeafLoc` is null — e.g.
619
+ // react-native-pager-view detaches its pages from the Fabric shadow
620
+ // tree the hit-test walks, so it bottoms out at the page wrapper),
621
+ // resolve the tap by measuring the still-intact fiber subtree under
622
+ // the touched host. Skipped entirely when the native pick already
623
+ // found the leaf, so every non-pager screen keeps its existing path.
624
+ if (!tappedLeafLoc(data)) {
625
+ const root = getHandleFromPublicInstance(data.closestPublicInstance);
626
+ const hit = root ? await measureHitTest(root, x, y, measureFiberInWindow) : null;
627
+ if (hit) {
628
+ const ancestors = taggedAncestors(hit.fiber);
629
+ const crumbFrames = await Promise.all(
630
+ ancestors.map((a) => measureFiberInWindow(a.fiber)),
631
+ );
632
+ done({
633
+ loc: hit.loc,
634
+ nameChain: ancestors.map((a) => a.name),
635
+ chain: ancestors.map((a, i) => ({
636
+ name: a.name,
637
+ loc: a.loc,
638
+ frame: crumbFrames[i] ?? null,
639
+ })),
640
+ frame: hit.frame,
641
+ });
642
+ return;
475
643
  }
476
- : null;
477
- const rawCrumbs = crumbsOf(data);
478
- const loc = pickLoc(data, projectRoot);
479
- const nameChain = nameChainOf(data);
480
- // Measure each crumb so pressing one can move the on-screen highlight
481
- // to that ancestor. Concurrent, each guarded — adds ~one measure pass.
482
- Promise.all(rawCrumbs.map((c) => measureFrame(c.measure)))
483
- .then((frames) => {
484
- done({
485
- loc,
486
- nameChain,
487
- chain: rawCrumbs.map((c, i) => ({
488
- name: c.name,
489
- loc: c.loc,
490
- frame: frames[i] ?? null,
491
- })),
492
- frame,
493
- });
494
- })
495
- .catch(() => {
496
- // Measuring failed wholesale — still return the pick without
497
- // per-crumb frames rather than hang.
498
- done({
499
- loc,
500
- nameChain,
501
- chain: rawCrumbs.map((c) => ({ name: c.name, loc: c.loc, frame: null })),
502
- frame,
503
- });
644
+ }
645
+
646
+ // Native pick: measure each crumb so pressing one can move the
647
+ // on-screen highlight to that ancestor. Concurrent, each guarded.
648
+ const frames = await Promise.all(rawCrumbs.map((c) => measureFrame(c.measure)));
649
+ done({
650
+ loc,
651
+ nameChain,
652
+ chain: rawCrumbs.map((c, i) => ({
653
+ name: c.name,
654
+ loc: c.loc,
655
+ frame: frames[i] ?? null,
656
+ })),
657
+ frame,
504
658
  });
659
+ })().catch(() => done({ loc: null, nameChain: [], chain: [], frame: null }));
505
660
  });
506
661
  } catch {
507
662
  clearTimeout(timer);