perf(widget-canvas): replace exponential collision solver and cut drag re-renders - #424
Merged
sajjadmrx merged 1 commit intoAug 29, 2026
Conversation
…g re-renders Beta testers reported the extension freezing completely while arranging widgets, especially with many widgets on screen and most reliably when dragging the search widget. Root cause was a backtracking DFS in the collision solver (branching factor 15, depth 30, no visited set). Because candidate positions were only rejected when they overlapped the blocker or a pinned widget, nearly every candidate produced a new collision, so the depth limit was hit constantly and the tree unrolled. Measured on 21 widgets, a single move ran 20M recursion nodes over 112s without terminating. From 21 widgets up, every drag target blew the budget. updateDragPreview called this on every grid-cell change and silently discarded a null result, so the user got no feedback and each further cell restarted another unbounded search. Engine: - Replace the solver with a deterministic downward cascade. Each widget keeps its column and slides down to the first free row starting from its own. Rows only increase, so it always terminates: no recursion, no depth cap, no null. Same input now resolves in 0.335ms. - Vertical gaps are preserved; compaction stays opt-in via an option. - move no longer returns null for geometry, only for malformed input, which removes the silent-discard path in the drag preview. - Preserve widget object identity for untouched widgets so React.memo and state bail-outs actually engage. - Guard against overlapping pinned widgets instead of the previous silent-success path in findFirstCollision. Rendering: - Add a memoized WidgetSlot so widget subtrees are no longer rebuilt every frame; the bookmarks widget was remounting a full DndContext per frame. - Memoize CanvasWidgetOuter and pass canvasMode/isSelected as props. - Split the widget context into stable actions and volatile layout, and move the six mutation callbacks onto refs so their identities stop changing on every drag frame. - Drive the drag offset straight to the DOM instead of through state. - Extract the grid overlay into a memoized component and drop the per-cell transitions. - Narrow the bookmarks widget subscription to a derived primary id. - Guard deferred previews with a drag sequence so a late transition cannot clobber the committed drop. CSS: - Position widgets with transform and transition only transform, instead of animating left/top/width/height on every widget. - Move the wiggle animation to the inner element and replace the nth-child selectors with explicit variants, so it no longer overrides the positioning transform. Adds a bun test suite covering the cascade, determinism, identity retention and a timing budget, including a regression test for the reported search-widget freeze.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Beta testers reported the extension freezing completely while arranging widgets. It got worse with more widgets on screen and was most reliable when dragging the search widget.
Root cause
resolveCollisionswas a backtracking DFS with branching factor 15 and depth 30, with no visited set.generatePushCandidatesonly rejected candidates overlapping the blocker or a pinned widget, never other movable widgets, so almost every candidate produced a new collision. The depth limit was reached constantly and each failure at depth 30 forced a sibling retry, unrolling the tree.Measured on 21 widgets (20x
2x3plus one search4x1, 8 columns), a single move:Sweeping drag targets with a 300k node cap:
The search widget is
4x1, wide and short, so it straddles several2x3columns at once and forces the deepest cascades, which matches the reports.updateDragPreviewcalled this on every grid-cell change and silently discarded anullresult, so the user got no feedback, kept dragging, and each new cell restarted another unbounded search, all synchronously inside arequestAnimationFramecallback.Changes
Engine
null. Same input: 112,201ms to 0.335ms.moveno longer returnsnullfor geometry, only for malformed input, removing the silent-discard path.React.memoand state bail-outs engage.findFirstCollision.Rendering
WidgetSlot; widget subtrees were being rebuilt every frame, and the bookmarks widget was remounting a wholeDndContexteach time.CanvasWidgetOuter, passcanvasMode/isSelectedas props.runtimeLayoutin their deps, so every callback identity changed on every drag frame.CSS
transformand transition onlytransform, instead of animatingleft/top/width/heighton every widget.nth-childselectors with explicit variants. The keyframes animatetransform, and CSS animations beat inline styles, so without this every wiggling widget would snap to the canvas origin once positioning moved totransform.Behaviour change
A displaced widget is now pushed straight down instead of possibly jumping sideways. Deliberate empty space between widgets is still preserved.
Testing
npm test,npm run compile,biome checkandnpm run buildall pass. New suite covers the cascade, determinism, identity retention and a timing budget, including a regression test named after the reported search-widget freeze that asserts under 16ms for the call that previously ran 112s.Still worth a manual pass: 25-30 widgets with the search widget dragged across the canvas, and edit mode with several unselected widgets to confirm the wiggle and transform compose correctly.