Skip to content

perf(widget-canvas): replace exponential collision solver and cut drag re-renders - #424

Merged
sajjadmrx merged 1 commit into
feat/free-widget-canvasfrom
perf/widget-canvas-drag-performance
Aug 29, 2026
Merged

perf(widget-canvas): replace exponential collision solver and cut drag re-renders#424
sajjadmrx merged 1 commit into
feat/free-widget-canvasfrom
perf/widget-canvas-drag-performance

Conversation

@shakurt

@shakurt shakurt commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

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

resolveCollisions was a backtracking DFS with branching factor 15 and depth 30, with no visited set. generatePushCandidates only 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 2x3 plus one search 4x1, 8 columns), a single move:

nodes = 20,000,001   ms = 112,201   still had not terminated

Sweeping drag targets with a 300k node cap:

widgets worst single move targets hitting the cap
9 326ms 15 / 60
13 391ms 37 / 60
17 727ms 52 / 60
21 940ms 60 / 60
29 1284ms 60 / 60

The search widget is 4x1, wide and short, so it straddles several 2x3 columns at once and forces the deepest cascades, which matches the reports.

updateDragPreview called this on every grid-cell change and silently discarded a null result, so the user got no feedback, kept dragging, and each new cell restarted another unbounded search, all synchronously inside a requestAnimationFrame callback.

Changes

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: 112,201ms to 0.335ms.
  • Vertical gaps are preserved; compaction is opt-in behind an option.
  • move no longer returns null for geometry, only for malformed input, removing the silent-discard path.
  • Preserve widget object identity for untouched widgets so React.memo and state bail-outs engage.
  • Guard against overlapping pinned widgets instead of the previous silent-success path in findFirstCollision.

Rendering

  • Add a memoized WidgetSlot; widget subtrees were being rebuilt every frame, and the bookmarks widget was remounting a whole DndContext each time.
  • Memoize CanvasWidgetOuter, pass canvasMode / isSelected as props.
  • Split the widget context into stable actions and volatile layout, and move the six mutation callbacks onto refs. They all listed runtimeLayout in their deps, so every callback identity changed 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 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. The keyframes animate transform, and CSS animations beat inline styles, so without this every wiggling widget would snap to the canvas origin once positioning moved to transform.

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 check and npm run build all 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.

…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.
@sajjadmrx
sajjadmrx merged commit 63fd800 into feat/free-widget-canvas Aug 29, 2026
2 checks passed
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.

2 participants