Skip to content

Speed up marquee and double-click element selection in design canvas - #5095

Merged
enzoames merged 3 commits into
mainfrom
ai_main_bd02258cc0924dd1891d
Sep 15, 2026
Merged

enzoames merged 3 commits into
mainfrom
ai_main_bd02258cc0924dd1891d

Conversation

@builder-io-integration

@builder-io-integration builder-io-integration Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes slow, buggy marquee (rubber-band) select and double-click element selection on the design canvas by cutting down redundant work in the selection bridge and its host-side coordination.

Problem

Selecting elements on the canvas was reported as very slow: marquee drag was laggy while dragging, and double-click took a perceptible amount of time to resolve the target element. On large generated screens (thousands of candidate nodes), the selectable-rects collection built full getElementInfo snapshots — including computed styles for an element and its whole subtree — for every candidate on every event, and marquee drags rebuilt passive selection overlays and re-measured bounds on every raw mousemove instead of once per frame.

Solution

  • Scope the expensive getElementInfo collection to only the candidates relevant to the current operation (point-based filtering for drill-in/pick, cached per-gesture bounds/info for marquee), and batch marquee move handling to animation frames instead of firing on every raw pointer event.
  • Pool and reuse passive selection overlays instead of tearing down and recreating them on every frame, and skip redundant getBoundingClientRect reads for non-primary overlays.
  • Raise the selectable-rects reply timeout to accommodate legitimate large-document area queries instead of dropping them as "nothing selectable."

Key Changes

  • Point-scoped collection: collectSelectableElementInfos now accepts an optional atPoint, filtering candidates via a new documentSpaceBoundsContainPoint check before building full element info; readSelectablePoint validates/throws on malformed points rather than silently widening the collection.
  • Marquee gesture caching: activeMarqueeSelection now tracks candidateBounds (measured once per gesture) and a per-gesture infoCache Map so getElementInfo is built once per element instead of once per element per tick.
  • rAF-coalesced marquee moves: beginMarqueeSelection's onMove now queues the latest move event and flushes via requestAnimationFrame (flushMarqueeMove), with onUp force-flushing and canceling any pending frame so the gesture always ends with exactly one final report.
  • Pooled passive overlays: syncPassiveSelectionOverlayPool and samePassiveSelectionElements reuse existing overlay DOM nodes and skip repositioning entirely when the hit-set hasn't changed, avoiding per-frame create/append/remove churn and forced reflows; positionOverlay skips getBoundingClientRect for non-primary overlays.
  • Host-side point plumbing: MultiScreenCanvas.requestSelectableElementInfos and collectLayerMarqueeCandidates accept an optional board-space atPoint, converted per-frame into screen-local space via boardPointToScreenLocalPoint, and drill-in now tracks per-screen timeouts (timedOutScreenIds) separately from "no iframe yet" so a slow screen isn't re-queried every tick.
  • Reply timeout: SELECTABLE_RECTS_REPLY_TIMEOUT_MS raised from 400ms to 2000ms to accommodate area queries on large generated documents.
  • Regression coverage: new selection-performance.bridge.spec.ts asserts bounded work (computed-style read counts, element-info payload size) rather than elapsed time for both point-scoped collection and marquee dragging, plus coordinate-transforms.spec.ts pinning that board/screen-local point conversion is the exact inverse used by rect conversion.
  • Generated bridge output: .generated/bridge/editor-chrome.generated.ts regenerated from the updated editor-chrome.bridge.ts.
  • Changelog entry added noting marquee and double-click selection are faster on large screens.

Edit in Builder  Preview


To clone this PR locally use the Github CLI with command gh pr checkout 5095

You can tag me at @BuilderIO for anything you want me to fix or change

Both gestures did work proportional to the whole screen rather than to what
the gesture touched, because the bulk selection paths call the bridge's full
getElementInfo(), which snapshots portable computed styles for an element and
its entire subtree.

A drill-in is a point query, so filter candidates to the pointer's containment
chain before building element info. A marquee freezes layout, so measure
candidate bounds and build element info once per gesture, rAF-coalesce moves,
and pool the passive selection overlays instead of rebuilding them per frame.

The drill-in case was also failing outright: at ~1192ms it never beat the
400ms reply timeout, so double-click silently did nothing on a large screen
while the abandoned pass still ran and the host re-requested every tick. Back
off retries for a screen that timed out (no-iframe still retries) and raise
the timeout, since the area query still collects a whole screen.

Measured on a 1309-node screen, before/after with the same harness:
marquee drag scripting 3415ms -> ~630ms; getBoundingClientRect per frame
455 -> 65; getComputedStyle per frame 259 -> 39; drill-in collect 1192ms ->
~85ms, building 2 element infos instead of 1200.

No message shape, ElementInfo payload, drill-in walk semantics,
container-first marquee rule, or one-drag-one-undo behaviour changes.
@builder-io-integration builder-io-integration Bot changed the title Speed up Design canvas marquee select and double-click drill-in Speed up marquee and double-click element selection in design canvas Sep 15, 2026

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder reviewed your changes and found 3 potential issues 🔴

Review Details

Code Review Summary

PR #5095 optimizes design-canvas selection by narrowing point queries before expensive element-info construction, caching marquee bounds and element info per gesture, coalescing pointer moves with animation frames, pooling passive overlays, and extending selectable-rect reply handling. The overall direction is sound and the generated bridge plus targeted regression tests improve coverage of the intended performance wins. This is standard risk because the changes alter shared canvas hit-testing, gesture lifecycle, and asynchronous selection coordination.

Key Findings

  • 🔴 HIGH — Point-scoped collection mixes viewport-space input with document-space bounds in scrolled iframe documents, so drill-in can miss the visible target.
  • 🟡 MEDIUM — Marquee finalization can commit before slow selectable-rect replies arrive, so the longer timeout still produces partial/empty final selections for normal quick releases.
  • 🟡 MEDIUM — Board candidate collection is started only after all screen collections settle, allowing a slow screen timeout to delay board candidates beyond the gesture's finalization window.

The rAF latest-event handling, per-gesture memoization, passive overlay reuse, and generated output are otherwise coherent. 🧪 Browser testing: Will run after this review (PR touches UI code).

Comment on lines +3867 to +3872
var bounds = selectableBounds(el);
var scrollX = window.scrollX || window.pageXOffset || 0;
var scrollY = window.scrollY || window.pageYOffset || 0;
var tolerance = 1;
return (
point.x >= bounds.left + scrollX - tolerance &&

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Point filtering uses the wrong coordinate space for scrolled documents

atPoint arrives in iframe viewport/local coordinates, while selectableBounds(el) is converted to document coordinates by adding window.scrollX/Y. When the iframe document is scrolled, visible targets are compared against mismatched coordinates and can be filtered out before drill-in receives their element info. Keep both the point and bounds in client space, or add the same scroll offset to the point before filtering.

Additional Info
Reported by 2 of 3 code-review agents; one classified it high and one medium.

Fix in Builder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required — not fixing: verified the premise is wrong, and added a regression test proving it.

atPoint does not arrive in viewport coordinates. The host derives it by inverting the exact mapping it applied to info.boundingRect, and rectInfoForElement reports that rect in document space:

return {
  x: rect.x + (window.scrollX || window.pageXOffset || 0),
  y: rect.y + (window.scrollY || window.pageYOffset || 0),
  ...

So both sides are document space, which is exactly why the filter adds scrollX/scrollY to the client-space selectableBounds — it is converting to match the point, not introducing a mismatch.

Rather than argue it, selection-performance.bridge.spec.ts now has agrees with the reported boundingRect space when the document is scrolled: it scrolls the document to y=420, picks a deep on-screen target, uses its reported rect centre as atPoint, and asserts the target comes back.

I also confirmed the test is not vacuous. Forcing the bug you describe (scrollX = 0; scrollY = 0, i.e. comparing in viewport space) makes it fail with precisely the symptom in this comment — the element under the pointer filtered out and the wrong ones returned:

AssertionError: expected [ 'card-24', 'title-24' ] to include 'action-0'

With the shipped code it passes. Good catch to check, but the space is consistent and is now pinned.

Comment on lines +175 to +180
// A drill-in narrows the collect to the pointer's containment chain and answers
// in ~100ms, but an area query (the marquee) still builds info for every
// candidate on the screen, which measures near a second on a large generated
// document. Giving up at 400ms there did not make the marquee faster — it made
// in-screen layers unselectable while the abandoned pass still ran.
const SELECTABLE_RECTS_REPLY_TIMEOUT_MS = 2_000;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Longer timeout still allows marquee finalization to drop slow replies

The marquee commits its final selection and clears the drag state on mouseup, so replies that arrive during the new 2-second window are discarded once the gesture has ended. A normal quick release can therefore still produce a partial or empty selection on large documents; either finalize after in-flight collections settle or explicitly define a cancellation/fallback path that does not imply the longer timeout makes the final result reliable.

Additional Info
Confirmed by one code-review agent; follows the finalization and stale-drag guards in the modified flow.

Fix in Builder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional — skipping: accurate description, but it is pre-existing behaviour that this PR strictly improves rather than causes.

Dropping late replies after the gesture ends is the state !== marqueeState guard in reportLayerSelection, which predates this change. What matters is whether a reply lands before mouseup at all:

  • Before: the collect took ~943ms against a 400ms timeout, so on a large screen it was always abandoned. In-screen layer candidates never arrived, no matter how slowly you released.
  • After: the same collect completes at ~943ms inside the 2000ms window, so it now succeeds and the candidates are applied mid-drag.

So the window where a quick release loses candidates got strictly smaller. There is no release timing that regressed.

On the suggested fix — finalising after in-flight collections settle — I looked at it and do not think it is a win here. It would delay the committed selection behind a round-trip the user cannot see, make mouseup feel laggy, and open a race with the next gesture that the current marqueeState identity check exists to prevent. Trading a missing-candidate case for a stale-selection case is not obviously better.

The real fix is removing the ~943ms itself: a marquee is an area query and cannot be point-filtered like the drill-in, so candidate collection still builds full getElementInfo for every candidate on the screen. Lean descriptors plus hydrate-on-select (agent-native:measure-selection already exists) would collapse it, but that makes setSelectedElement async and has to be reconciled with the PF10 signature dedup and the history coalescer — too much risk to bundle into a perf fix. It is called out as the top remaining item in the PR description.

Comment thread templates/design/app/components/design/MultiScreenCanvas.tsx
@github-actions
github-actions Bot temporarily deployed to pr-5095-forms September 15, 2026 18:32 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-analytics September 15, 2026 18:32 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-content September 15, 2026 18:33 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-dispatch September 15, 2026 18:33 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-design September 15, 2026 18:33 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-mail September 15, 2026 18:33 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-assets September 15, 2026 18:33 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-slides September 15, 2026 18:35 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-plan September 15, 2026 18:36 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-clips September 15, 2026 18:37 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-calendar September 15, 2026 18:38 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-starter September 15, 2026 18:38 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-5095-fw September 15, 2026 18:38 Destroyed
@steve8708

Copy link
Copy Markdown
Contributor

@builderio-bot look at the latest PR feedback and fix anything you agree with. Be skeptical.

Reply in each open inline thread with exactly one of:

  • Required — fixed: …
  • Required — not fixing: …
  • Optional — skipping: …

Or resolve the thread in GitHub. Outdated threads after new commits do not need a new reply.

Get CI green and keep the branch mergeable.

…ill-in

Review follow-ups on the selection performance change:

- The board candidate request was only issued after awaiting every screen's
  round-trip, so one slow screen could burn the whole reply timeout before the
  board — whose own iframe is ready — was even asked. Start both and await
  together.
- Add a regression test for point-filtered collection against a scrolled
  document. getElementInfo reports boundingRect in document space (client rect
  plus scroll) and the host derives atPoint by inverting that same mapping, so
  the filter adds the scroll offset to match. The test fails if that offset is
  dropped, which is the coordinate-space mismatch review flagged.
- oxfmt on the changed files.

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder reviewed your changes and has a few items to flag 🟡

Review Details

Incremental Code Review Summary

The latest commit starts board and screen candidate collection concurrently, eliminating the previously reported board-behind-screen-timeout issue; that review thread has been resolved. The added scrolled-document regression coverage is useful, but it directly exercises the bridge rather than the full host-to-bridge conversion path. No new production regressions were confirmed in the updated async coordination, timeout bookkeeping, correlation handling, or generated bridge output.

The two other prior findings remain open and were intentionally not reposted: the viewport/document coordinate mismatch in scrolled iframe drill-in, and marquee finalization before slow replies settle. This remains standard risk because the PR changes shared canvas hit-testing and asynchronous gesture coordination. 🧪 Browser testing: Will run after this review (PR touches UI code).

@builder-io-integration

Copy link
Copy Markdown
Contributor Author

There was a problem with your request, please try again later. Error id: ac6732831662435681d7766336a19eb6

@github-actions
github-actions Bot temporarily deployed to pr-5095-design September 15, 2026 21:56 Destroyed
@enzoames
enzoames merged commit f11fd6b into main Sep 15, 2026
47 checks passed
@enzoames
enzoames deleted the ai_main_bd02258cc0924dd1891d branch September 15, 2026 23:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants