Skip to content

perf(ui): overlays in #overlay-root and an attribute scroll lock (no whole-page restyle) - #217

Merged
HTExplicit merged 3 commits into
mainfrom
fix/overlay-root
Sep 30, 2026
Merged

HTExplicit merged 3 commits into
mainfrom
fix/overlay-root

Conversation

@HTExplicit

Copy link
Copy Markdown
Owner

Summary

Opening or closing any overlay (more-actions panel, row menus, dialogs, confirm boxes, the announcement popup) re-styled the whole page and took 130-300 ms from click to paint. There were two causes:

  1. Every <Teleport to="body"> inserts its content among <body>'s own children. <body> carries positional and sibling-combinator invalidation flags, from Tailwind space-y/divide-y and :first-child/:last-child variants that no stylesheet can avoid, so each insertion invalidates the whole document. The fix: index.html gets a fixed <div id="overlay-root"> after #app, and all 34 teleports target it. The test setup recreates the container for each spec.
  2. The scroll lock toggled body.modal-open, and announcements set an inline overflow. A class or inline-style change on <body> re-styles the page; a data attribute does not. The lock is now body[data-modal-open] (reference-counted in dialogStack.ts) and body[data-announcement-open], and both map to overflow: hidden in style.css.

The console theme's selectors that assumed overlays are direct children of body now go through #overlay-root, with the same specificity.

Measured

Production builds, real clicks, Event Timing click → next paint, medians:

overlay before (d8eb415) after
accounts more-actions panel open / close 160 / 152 ms 24 / 0 ms
row "more" menu open / close 192 / 152 ms 32 / 24 ms
export confirm open / close 296 / 168 ms 24 / 16 ms
create-account dialog open / close 192 / 168 ms 32 / 32 ms
use-key dialog open / close 72 / 64 ms 24 / 24 ms
announcement popup open / close 176 / 160 ms 24 / 0 ms
selection tray open / tick / close 32 / 24 / 24 ms 32 / 24 / 24 ms

The 130-290 ms long tasks on every overlay are gone.

Visual and behaviour

12 overlays, light and dark: identical computed styles on all 1514 overlay elements, identical rects and z-order, and 0 px diff inside the overlays. Scroll lock and focus trap behave the same.

Tests

  • vitest: 68 files / 627 tests covering every component with a teleport or body query. BackupView and SubscriptionsView: 3 files / 19 tests. All exit 0.
  • vue-tsc --noEmit exit 0.

🤖 Generated with Claude Code

Codex and others added 3 commits September 30, 2026 13:58
Every overlay (BaseDialog, Select, the accounts tools panel and row menu,
tooltips, drawers, toasts: 31 <Teleport to="body"> in 25 files) inserted
its content among <body>'s own children, next to the anchors Vue appends
at mount time. <body> carries the positional (:first/:last-child) and
sibling (space-y / divide-y ~) invalidation flags, so each open or close
restyled the whole document: 130-290 ms event->paint on /admin/accounts.

index.html now ends <body> with an empty #overlay-root and every Teleport
targets it, so opening an overlay only touches that container (~0.5 ms
forced recalc instead of ~170 ms). The container has no styles and makes
no stacking context: overlays keep their z-order.

The login agreement dialog selectors (body > div.z-[140]) follow the
overlay into #overlay-root; :where() keeps them at their old specificity.
The test setup recreates #overlay-root before each test, since many specs
reset document.body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With overlays out of <body>'s child list, dialogs still cost ~170 ms per
open/close on /admin/accounts: the scroll lock toggled body.modal-open
(BaseDialog) or an inline body overflow (announcement list and popup),
and a class or style change on <body> invalidates every element (the
console theme matches [class~=]/[class*=] and :not([style]) widely).

The lock is now an attribute no other selector uses, which restyles only
<body>: body[data-modal-open] (dialogStack, still reference counted) and
body[data-announcement-open] (announcement bell and popup, same set/clear
points as before). Same overflow: hidden; the scroll-lock specs assert
the attribute instead of the class / inline style.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BackupView (2) and SubscriptionsView (1) spell the tag in lowercase, so the
first pass missed them; every teleport now targets #overlay-root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HTExplicit
HTExplicit merged commit bb71e8c into main Sep 30, 2026
18 checks passed
@HTExplicit
HTExplicit deleted the fix/overlay-root branch September 30, 2026 06:21
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