Skip to content

perf: cut per-frame cost of widget layout edit mode - #439

Open
shakurt wants to merge 3 commits into
feat/free-widget-canvasfrom
perf/canvas-edit-mode-lag
Open

shakurt wants to merge 3 commits into
feat/free-widget-canvasfrom
perf/canvas-edit-mode-lag

Conversation

@shakurt

@shakurt shakurt commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

Users on weaker machines reported the canvas lagging while editing the widget layout. The lag starts the moment edit mode opens, before anything is dragged.

What is not the problem

The drag path is already well optimised, and worth stating so it does not get "fixed" later:

  • previews are throttled through requestAnimationFrame and only run the layout engine when the target grid cell actually changes
  • reconcileIdentity preserves the object identity of untouched widgets, so the memo on CanvasWidgetOuter genuinely holds
  • a preview never touches storage or the server — only the drop commits

The cost is rendering.

Root cause

In edit mode every unselected widget gets an infinite rotation:

animation: widget-wiggle 0.46s ease-in-out infinite alternate;

and every widget contains a WidgetContainer carrying bg-glass, which on the glass and icy themes resolves to backdrop-filter: blur(16px) saturate(150%).

Rotating an element that contains a backdrop-filter forces the browser to re-sample and re-blur the backdrop every frame, once per widget. Ten widgets means ten blur passes per frame at 60fps. Nothing set will-change either, so rounded corners, borders and text were re-rasterised on the main thread each frame on top of that.

Changes

  1. Live blur is dropped while editing. The canvas root gains a canvas-editing class; #widgets-canvas.canvas-editing .bg-glass clears backdrop-filter. Specificity is (1,2,0) against the theme rule's (0,2,0), so it wins regardless of file order, and background-color is untouched — widgets keep their translucent look, they just stop recomputing a blur 60×/sec while being rearranged.
  2. will-change: transform on the wiggle classes so the rotation is composited rather than re-rasterised.
  3. Wiggle switched off under html.optimal-mode and prefers-reduced-motion. optimal-mode only zeroed transition-duration, so the single most expensive thing on the page kept running for exactly the users most likely to have enabled it. Scoped to the three wiggle classes, leaving the deliberate choice to keep keyframe animations alive for spinners and the notification ping intact.

Also raises the edit-mode grid border from border-base-content/15 to /26 — on glass/icy --color-base-content is near-white, so 15% white all but vanished over a light wallpaper.

Deliberately left out

Widgets still run live in edit mode — clocks tick, pets move. Pausing them is likely the next win, but it touches each widget individually, so it belongs in its own change.

Testing

  • npm run compile
  • npm test
  • npx biome check src
  • npm run build
  • All four new CSS rules verified present in the built newtab-*.css (this repo has a history of classes compiling to nothing)
  • Manual check by owner on a glass theme

Users on weaker machines reported the canvas lagging while editing
the widget layout. The lag starts the moment edit mode opens, before
anything is dragged.

The drag path itself is not the problem. Previews are throttled
through requestAnimationFrame and only run the layout engine when
the target grid cell actually changes, reconcileIdentity keeps the
object identity of untouched widgets so the memo on CanvasWidgetOuter
holds, and a preview never touches storage or the server - only the
drop commits. The cost is rendering.

In edit mode every unselected widget gets an infinite rotation:

  animation: widget-wiggle 0.46s ease-in-out infinite alternate;

and every widget contains a WidgetContainer carrying bg-glass, which
on the glass and icy themes resolves to backdrop-filter: blur(16px)
saturate(150%). Rotating an element that contains a backdrop-filter
forces the browser to re-sample and re-blur the backdrop every frame,
once per widget. With ten widgets that is ten blur passes per frame.
Nothing set will-change either, so the rounded corners, borders and
text were re-rasterised each frame on the main thread as well.

Three changes:

Live blur is dropped while the canvas is in edit mode. The canvas
root now carries a canvas-editing class and
#widgets-canvas.canvas-editing .bg-glass clears backdrop-filter.
Specificity is (1,2,0) against the theme rule's (0,2,0), so it wins
regardless of file order, and background-color is left alone - the
widgets keep their translucent look, they just stop recomputing a
blur sixty times a second while being rearranged.

The wiggle classes get will-change: transform so the rotation is
composited instead of re-rasterised.

The wiggle is switched off under html.optimal-mode and
prefers-reduced-motion. optimal-mode only zeroed transition-duration,
so the single most expensive thing on the page kept running for the
users most likely to have turned it on. This is scoped to the three
wiggle classes, leaving the deliberate decision to keep keyframe
animations alive for spinners and the notification ping intact.

Widgets still run live in edit mode - clocks tick, pets move. Pausing
them is likely the next win but touches each widget, so it is left
out here.
The dashed grid squares shown while editing the layout were hard to
make out over a light wallpaper.

border-base-content/15 was the cause: on the glass and icy themes
--color-base-content is close to white, so a 15% white border all but
disappeared against a bright background.

Raised to 26%, which reads clearly without competing with the widgets
being arranged.
Widgets showed a shadow with sharp points along their bottom edge
while the layout was being rearranged, on some widgets but not
others.

Two wrappers clipped a rounded, shadowed surface with a square
cornered box. WidgetContainer:

  widget-outer relative h-full w-full overflow-hidden

wraps a body carrying bg-content bg-glass rounded-widget, and on the
icy theme .bg-glass resolves to

  box-shadow: 0 6px 24px #0000000f, inset 0 1px #ffffff1f

The body's downward shadow was therefore clipped by a square box the
same size as the body, so in the bottom corners - where the rounded
body curves away and leaves the square corner empty - the shadow
filled that corner and came to a point.

bookmark-folder.tsx had the same square clip around a tile carrying
rounded-widget shadow-xs. That one explains why only part of the
bookmarks widget was affected: only folders render through this
component, while plain bookmarks and empty slots have no such
wrapper. In an RTL grid the folders sit on the right, which is
exactly where the artifact showed.

Both wrappers now carry rounded-widget so the clip follows the shape
it is clipping. The bookmarks widget needed its own fix because it
does not render through WidgetContainer at all.

Other overflow-hidden wrappers in the widget tree clip inner content
rather than a rounded shadowed surface at the widget edge, so they
do not produce this and are left alone.
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.

1 participant