Skip to content

fix(component-tree): make maxNodes honest, and stop offering untappable coordinates - #665

Open
filip131311 wants to merge 1 commit into
mainfrom
filip/component-tree-maxnodes
Open

fix(component-tree): make maxNodes honest, and stop offering untappable coordinates#665
filip131311 wants to merge 1 commit into
mainfrom
filip/component-tree-maxnodes

Conversation

@filip131311

Copy link
Copy Markdown
Collaborator

Fixes #631 (the maxNodes half — see below on onScreenOnly).

maxNodes

Confirmed: {maxNodes: 5} returned a 24-node tree. maxNodes only ever collapsed single-child wrapper chains of length ≥ 2 — it never dropped a node. And there's a self-defeating interaction: the TS-side filter already removes content-free wrappers before this runs (Content-free wrapper: 47 in the same session), so the nodes it wants to collapse are mostly gone by then.

Three separate defects, each measured on a probe rather than read off the source:

1. Accounting could exceed the tree. Chain discovery pushed every suffix of a chain as its own chain, because the recursion guard if (!collapsed.has(cid)) reads a map that isn't populated until the greedy pass below it. The greedy pass then collapsed an outer chain and one nested inside it, counting shared nodes twice:

8-node tree, maxNodes: 1   →   "... 11 wrapper nodes collapsed"

Members are now consumed as chains are taken, and the total is measured as the difference between rendered counts rather than accumulated — so it cannot disagree with the tree printed above it.

2. Off-by-one in the inline marker. The chain start is printed directly above the marker, so a 7-wrapper chain hid 6 but said via 7 wrappers.

3. The budget was computed against a count the renderer doesn't produce. totalVisible was a plain subtree count, but renderNode also skips same-name overlapping siblings. Budgeting against the larger number would shrink trees that were already within budget.

What it deliberately does not do

It does not drop nodes carrying a name, text, testID, or a branch. Truncating those is how a caller concludes an element doesn't exist when it was only trimmed — a wrong answer, and worse than an oversized response. This matters concretely: the collector emits children before siblings, so BottomTabBar, footers and sticky CTAs are always in the tail of the tree. Any "cut the last N" strategy systematically deletes the actionable bottom of the screen.

So when the budget can't be met, it says so instead:

... maxNodes=5 could not be met: 28 nodes over budget. Only structural wrappers are
collapsible; the rest carry a name, text, testID or a branch, so they are kept rather
than dropped. Narrow the tree instead — inspect a subtree with debugger-inspect-element,
or raise maxNodes.

The schema now describes that contract rather than promising "Maximum total nodes to include".

Verified on a 7-content-node tree at maxNodes: 2: every named node survives and the shortfall is stated.

Untappable coordinates (not in the issue)

An element whose centre is outside the viewport rendered as (tap: 0.22,-0.11). The on-screen filter keeps such nodes deliberately — it has a 10% margin so a partially-visible row still appears — which means the default response offered coordinates that cannot land, formatted exactly like valid ones:

before:  ThemedText "This app includes example code…" (tap: 0.50,-0.04)
after:   ThemedText "This app includes example code…" (off-screen: 0.50,-0.04)

The numbers stay (their sign says which way to scroll); the tap: affordance goes. Rejected clamping — it converts a detectable error into a silent tap on a different element. Nothing parses this format (verified: only the emitter and prose), so the change is safe; the tool description and the three SKILL references are updated.

onScreenOnly — not a bug

Reproduced the reporter's observation and then disproved the diagnosis; full evidence on the issue. Short version: with genuinely off-screen content, true reports Off-screen: 2 and omits two nodes while false includes them at the identical scroll position. The 207 JS-skipped figure the report cites is host components filtered by name (View, RCTView, RCTText), not visibility. There's also a pre-existing passing test, component-tree.test.ts:101, asserting the behaviour.

What was real is that you couldn't tell: Off-screen: N was suppressed at zero, so an inactive filter looked identical to a broken one. It's now reported even at zero — one line, and it's the signal the reporter went looking for.

Found while here — filed separately

renderNode skips a same-name overlapping sibling and its entire subtree, including labelled content, with no entry in includeSkipped. Probe: two sibling Cards, the second holding Inner "SECOND-CARD-CONTENT" and Submit [testID=submit-btn] — both silently absent, --- Filtered --- empty. Bigger wrong-answer risk than either claim here, so it gets its own issue rather than riding along.

Checks

  • 3095 tests pass; 9 new, of which 6 fail against the pre-fix source (the 3 that pass on both are invariance checks)
  • all 17 pre-existing tests pass unmodified — the new phases are additive
  • live-verified on iPhone 17 Pro / Expo SDK 54: maxNodes: 5 now explains itself, the off-screen marker appears in the default mode, BottomTabBar preserved
  • SpiderShield average 9.10 (this tool 10.0); extract-tools 46/46; skills gate 10.0; prettier, eslint, both typechecks clean; lock untouched

…le coordinates

maxNodes promised 'Maximum total nodes to include' but only ever collapsed
single-child wrapper chains of length >= 2 — it never dropped a node, so
maxNodes:5 returned a 24-node tree. It also fired against a count the renderer
does not produce, and its accounting could exceed the tree.

Three defects, all measured:

Chain discovery pushed every suffix of a chain as its own chain, because the
recursion guard read a map that is not populated until the greedy pass below
it. The greedy pass could then collapse an outer chain and one nested inside
it, counting the shared nodes twice: an 8-node tree reported '11 wrapper nodes
collapsed'. Members are now consumed as chains are taken, and the total is
measured as the difference between rendered counts rather than accumulated, so
it cannot disagree with the tree above it.

The inline marker counted the chain start, which is printed directly above it,
so a 7-wrapper chain said 'via 7 wrappers' while hiding 6.

The budget was computed from a plain subtree count, but the renderer also skips
same-name overlapping siblings. Budgeting against the larger number would have
shrunk trees that were already within budget.

What maxNodes will not do is drop a node carrying a name, text, testID, or a
branch. Truncating those is how a caller concludes an element does not exist
when it was merely trimmed — a wrong answer, which is worse than an oversized
response. When the budget cannot be reached the response says so and by how
much, and points at debugger-inspect-element for narrowing. The schema now
describes that contract instead of promising a hard cap.

Separately: an element whose centre lies outside the viewport was rendered as
'(tap: 0.22,-0.11)'. The on-screen filter keeps such nodes deliberately (a 10%
margin, so a partially-visible row still appears), which means the DEFAULT
response offered coordinates that cannot land, formatted exactly like real
ones. Those now read '(off-screen: x,y)' — the numbers stay, since their sign
says which way to scroll, but the tap affordance is gone.

Finally, the off-screen count is reported even at zero. Suppressing it is why
onScreenOnly:true and false look identical on a screen with nothing off-view,
which reads as a broken parameter rather than an inactive filter.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

debugger-component-tree: maxNodes is not enforced and onScreenOnly: false returns the same tree as the default

1 participant