web_snapshot O(tree-size) slow: enrichNodeWithLocatorBounds does per-node locator round-trips (~5s budget-cap on heavy pages)
- Dominant language
- Kotlin
- Stars
- 310
- Forks
- 28
- Avg merge
- 7h 45m
- Merged PRs (30d)
- 8
Description
## Summary
Capturing screen state on a DOM-heavy page (e.g. a flight-search results page, ~8k DOM nodes) is dramatically slower than it should be — on the order of **~9–12 s**, vs **~1.5 s** for the accessibility-only path on the same page. Profiling shows the cost is entirely in **per-node Playwright locator round-trips** during view-hierarchy bounds enrichment, not in the DOM/JS work.
## Root cause (profiled)
`jstack`-sampling the running daemon during a capture shows the hot method is:
```
xyz.block.trailblaze.playwright.PlaywrightScreenState.enrichNodeWithLocatorBounds
```
`enrichNodeWithLocatorBounds` recurses the ~thousand-node view-hierarchy tree and, **for every node**, makes individual Playwright locator round-trips:
```
PlaywrightAriaSnapshot.resolveElementRef(page, ElementRef(descriptor, nth))
locator.count()
locator.boundingBox(...)
```
That's ~2–3 CDP round-trips × N nodes. It's budget-capped at `ENRICHMENT_BUDGET_MS = 5_000L`, so on heavy pages it burns the **entire 5 s budget and still returns partial/incomplete bounds** (the "enrichment budget exceeded — returning partial bounds" log).
## Evidence (isolated Playwright benchmark, same page, ~8.3k DOM nodes)
```
A) per-node handle.boundingBox() × 800 nodes: 304 ms (0.4 ms/node, 1 round-trip)
B) batched getBoundingClientRect over ALL 8316 nodes: 5 ms (one page.evaluate)
→ 61× on boundingBox alone
```
The real code does ~3 round-trips per node over the full tree, which is why it saturates the 5 s budget. Batching the whole tree into one `page.evaluate` is ~1000× fewer round-trips.
For reference, the individual DOM ops are all cheap (measured on the same page): full-DOM walk + `getComputedStyle` on all 8386 elements ≈ 24 ms; `computedRole`/`computedName` reads ≈ 1 ms; `ariaSnapshot` ≈ 55 ms; `screenshot` ≈ 75 ms. None of these are the cost — it is exclusively the per-node locator RPC fan-out.
## Suggested fix
Resolve **all** node bounds in a single batched `page.evaluate` (a `getBoundingClientRect` pass over the resolved elements) instead of per-node locator round-trips. The role/name/nth → element resolution already exists batched in `BATCH_VIEWPORT_CHECK_JS` (used by `computeElementVisibility` for the annotation overlay) — so the same bounds are effectively computed twice today (once batched for the overlay, once per-node for the tree). Reuse the batched result for the tree.
Keep the existing per-node `locator.boundingBox()` **only as a fallback** for nodes whose batched DOM rect is zero-size — that's the Compose-Web-Wasm case the locator path exists for (accessibility overlay elements have zero-size DOM rects but valid positions in Playwright's a11y tree). On such pages the fallback set is small, so the fan-out is bounded.
Expected impact: view-hierarchy enrichment on heavy pages drops from ~5 s (budget-capped, partial bounds) to ~single-digit ms (complete bounds).
## Repro
Capture screen state (any path that enriches the view hierarchy with bounds) on a large, dynamic results page and profile the daemon thread — `enrichNodeWithLocatorBounds` dominates. A ~1000+ node tree is enough to hit the 5 s budget cap.
Contributor guide
Research direction
Start at PlaywrightScreenState.enrichNodeWithLocatorBounds and inspect the existing BATCH_VIEWPORT_CHECK_JS path used by computeElementVisibility. Compare the batched DOM bounds with the current per-node locator calls, preserving the fallback for zero-size rects. Reproduce on a DOM-heavy page and confirm enrichment completes within the budget with complete bounds.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- kotlin
- Domain
- performance, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100