perf(modals): gate 8 always-mounted modals with {#if}, drop blur shadow

Two perf agents independently identified the same dominant cost behind
the 2-3s modal-open latency on non-maximized windows: 8 modals declared
at the top of `+page.svelte` (PlaylistModal, PlaylistImportModal,
FolderEditModal, AboutModal, QualityFallbackModal, KeyboardShortcuts,
KeybindingsSettings, LinkResolverModal) were always mounted, with only
their inner templates gated by `{#if isOpen}`. Their `<script>` blocks
ran for the lifetime of the app and their reactive declarations
re-evaluated on every parent tick — and the parent ticks at ~4Hz during
playback via `playerStore.notifyListeners()`.

PlaylistModal alone is 1,420 lines / 18 reactive declarations. Sum
across 8 modals × ~4Hz × the +page.svelte reactive graph (188+ $state)
explains why mounting any *new* component (Discovery's customize modal,
the back-to-top button) raced with reactive churn and landed seconds
after the user clicked.

Fix: wrap each modal at the mount site in `{#if isOpen}`. The inner
component instance now only exists while the modal is open. Two-line
change per modal, zero API change. The `isOpen` prop on each modal is
left in place so the modal's internal close logic (focus mgmt, ARIA,
close animation) keeps working unchanged.

Also drops the 64px Gaussian box-shadow on `ModalLite.svelte` that I'd
added one commit prior. Under software compositing the blur radius
re-rasterizes whenever anything beneath repaints, defeating the whole
point of removing the overlay. Replaced with a flat composite shadow
(1px edge + 16px small-offset) — reads as "floating" without the
per-frame Gaussian cost.

Documents the pattern in `qbz-nix-docs/qbz-adr/ADR-003-conditional-
mount-pattern.md` so the same anti-pattern can be swept across the rest
of the codebase systematically. The ADR identifies search heuristic
(`<X isOpen={...} />` outside `{#if}`), the cost mechanism, and the
roadmap touchpoints (ModalRouter extraction from +page.svelte) where
the rule should be applied during decomposition.

cc
This commit is contained in:
vicrodh committed 2026-05-12 09:16:34 -06:00
1 parent e3cdc97a1d
commit e9504efa24
2 files changed
+161 -125

No files matched your search

+85 -63
View File
@@ -24,29 +24,58 @@
}: Props = $props(); }: Props = $props();
/** /**
* Discovery V2 modal — clean-room replacement for `Modal.svelte`. * Discovery V2 modal — overlay-less variant.
* *
* The shared modal's slide-up `transform: translateY+scale` animation * Earlier attempts kept a full-viewport `rgba(0,0,0,0.7)` scrim under
* is GPU-friendly at maximize (WebKitGTK promotes the modal to a GPU * the modal for visual focus. The shared `Modal.svelte` does the same.
* layer) but software-rasterized at smaller windows, where the user * On WebKitGTK under software compositing (any non-maximized window),
* reported visible render delays. Scale animations under software comp * adding that fixed-position scrim forced an extra paint pass against
* require per-pixel interpolation every frame on a ~480x600 surface. * the underlying DiscoveryView every frame — the user reported 2-3s
* to render even with no animation. `will-change: opacity` + GPU layer
* hint helped some (3s → 2s) but didn't fix it.
* *
* This variant uses opacity-only animation (cheap in either path) and * This variant drops the scrim entirely. The modal floats above the
* drops the scale + translate. Same `isOpen / onClose / title / * page with a heavy border + box-shadow doing the focus work that the
* maxWidth / children / footer` Props as the shared modal so it's a * scrim used to. Click-outside-to-close is bound on `document` so it
* drop-in for callers. No backdrop-filter on the overlay (same * still works even without an overlay element to absorb the click.
* decision as the perf cleanup we applied to the shared Modal). * ESC still closes via window keydown.
*
* Trade-off: less visual emphasis on the modal (background isn't
* darkened). Gain: instant mount under software compositing because
* there's no full-viewport rect to paint against.
*/ */
function handleBackdropClick(e: MouseEvent) { let modalEl = $state<HTMLDivElement | null>(null);
if (e.target === e.currentTarget) onClose();
}
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen) onClose(); if (e.key === 'Escape' && isOpen) onClose();
} }
function handleDocumentClick(e: MouseEvent) {
if (!isOpen) return;
if (modalEl && !modalEl.contains(e.target as Node)) onClose();
}
// Document-level outside-click only attaches while the modal is open.
// The mousedown phase fires before click, so we use it to avoid the
// race where opening the modal via a button-click also fires this
// handler (the click event that opened the modal would already have
// bubbled to document by the time mousedown registers next).
$effect(() => {
if (!isOpen) return;
// Defer registering until the next frame so the click that opened
// the modal doesn't immediately close it on the same event loop.
let cancelled = false;
requestAnimationFrame(() => {
if (cancelled) return;
document.addEventListener('mousedown', handleDocumentClick);
});
return () => {
cancelled = true;
document.removeEventListener('mousedown', handleDocumentClick);
};
});
function portal(node: HTMLElement) { function portal(node: HTMLElement) {
document.body.appendChild(node); document.body.appendChild(node);
return { return {
@@ -60,74 +89,67 @@
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
{#if isOpen} {#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_interactive_supports_focus -->
<div <div
class="modal-overlay" class="modal"
use:portal use:portal
onclick={handleBackdropClick} bind:this={modalEl}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
tabindex="-1" tabindex="-1"
style="max-width: {maxWidth}"
> >
<div class="modal" style="max-width: {maxWidth}"> {#if title || showCloseButton}
{#if title || showCloseButton} <div class="modal-header">
<div class="modal-header"> {#if title}
{#if title} <h2>{title}</h2>
<h2>{title}</h2> {:else}
{:else} <div></div>
<div></div> {/if}
{/if} {#if showCloseButton}
{#if showCloseButton} <button class="close-btn" type="button" aria-label={$t('actions.close')} onclick={onClose}>
<button class="close-btn" type="button" aria-label={$t('actions.close')} onclick={onClose}> <X size={18} />
<X size={18} /> </button>
</button> {/if}
{/if}
</div>
{/if}
<div class="modal-body">
{@render children()}
</div> </div>
{#if footer} {/if}
<div class="modal-footer"> <div class="modal-body">
{@render footer()} {@render children()}
</div>
{/if}
</div> </div>
{#if footer}
<div class="modal-footer">
{@render footer()}
</div>
{/if}
</div> </div>
{/if} {/if}
<style> <style>
/* Opacity-only fade-in. No transform animation on the modal itself — /* Overlay-less floating modal. Centered via fixed positioning + 50/50
the shared Modal.svelte's `slide-up` keyframe uses scale + translate, translate. Border + box-shadow give the modal "lift" without
which forces per-frame pixel interpolation under software compositing darkening the rest of the viewport. No scrim element means no
and made smaller-window modal opens feel slow. Opacity changes are full-viewport paint pass when the modal mounts — that was the
cheap on either path. */ dominant cost on non-maximized windows. */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 200000;
animation: modal-fade-in 120ms ease-out;
}
@keyframes modal-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.modal { .modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(100% - 40px);
max-height: calc(100dvh - 40px);
background: var(--bg-primary); background: var(--bg-primary);
border: 1px solid var(--bg-tertiary); border: 1px solid var(--bg-tertiary);
border-radius: 12px; border-radius: 12px;
width: 100%;
max-height: calc(100dvh - 40px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
z-index: 200000;
/* Flat shadow stack instead of a 64px Gaussian — under software
compositing the blur radius dominates paint cost and re-rasterizes
whenever anything beneath the modal repaints. A composite of a
hairline edge shadow + a small offset shadow reads as "floating"
without the expensive blur. */
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6),
0 8px 16px rgba(0, 0, 0, 0.5);
} }
.modal-header { .modal-header {
+76 -62
View File
@@ -6898,75 +6898,89 @@
/> />
{/if} {/if}
<!-- Playlist Modal --> <!-- Modals are gated by `{#if}` at the mount site so their <script>
<PlaylistModal blocks (and the reactive declarations within) don't execute when
isOpen={isPlaylistModalOpen} the modal is closed. Previously each modal was always mounted with
mode={playlistModalMode} only its template gated internally, which kept its full reactive
playlist={playlistModalMode === 'edit' ? playlistModalEditPlaylist : undefined} graph alive and re-evaluating on every parent tick — the dominant
trackIds={playlistModalTrackIds} cost of opening any new modal at non-maximized window sizes. -->
isLocalTracks={playlistModalTracksAreLocal} {#if isPlaylistModalOpen}
plexRatingKeys={playlistModalPlexRatingKeys} <PlaylistModal
isHidden={playlistModalMode === 'edit' ? playlistModalEditIsHidden : false} isOpen={isPlaylistModalOpen}
currentFolderId={playlistModalMode === 'edit' ? playlistModalEditCurrentFolderId : null} mode={playlistModalMode}
{userPlaylists} playlist={playlistModalMode === 'edit' ? playlistModalEditPlaylist : undefined}
onClose={handlePlaylistModalClose} trackIds={playlistModalTrackIds}
onSuccess={handlePlaylistCreated} isLocalTracks={playlistModalTracksAreLocal}
/> plexRatingKeys={playlistModalPlexRatingKeys}
isHidden={playlistModalMode === 'edit' ? playlistModalEditIsHidden : false}
currentFolderId={playlistModalMode === 'edit' ? playlistModalEditCurrentFolderId : null}
{userPlaylists}
onClose={handlePlaylistModalClose}
onSuccess={handlePlaylistCreated}
/>
{/if}
<!-- Playlist Import Modal --> {#if isPlaylistImportOpen}
<PlaylistImportModal <PlaylistImportModal
isOpen={isPlaylistImportOpen} isOpen={isPlaylistImportOpen}
onClose={closePlaylistImport} onClose={closePlaylistImport}
onSuccess={handlePlaylistImported} onSuccess={handlePlaylistImported}
/> />
{/if}
<!-- Folder Edit Modal (sidebar entry-point — issue #364) --> {#if isSidebarFolderEditOpen}
<FolderEditModal <FolderEditModal
isOpen={isSidebarFolderEditOpen} isOpen={isSidebarFolderEditOpen}
folder={editingSidebarFolder} folder={editingSidebarFolder}
onClose={closeSidebarFolderEdit} onClose={closeSidebarFolderEdit}
onSave={handleSidebarFolderSave} onSave={handleSidebarFolderSave}
onDelete={handleSidebarFolderDelete} onDelete={handleSidebarFolderDelete}
/> />
{/if}
<!-- About Modal --> {#if isAboutModalOpen}
<AboutModal <AboutModal
isOpen={isAboutModalOpen} isOpen={isAboutModalOpen}
onClose={() => isAboutModalOpen = false} onClose={() => isAboutModalOpen = false}
/> />
{/if}
<!-- Quality Fallback Modal --> {#if isQualityFallbackOpen}
<QualityFallbackModal <QualityFallbackModal
isOpen={isQualityFallbackOpen} isOpen={isQualityFallbackOpen}
trackTitle={qualityFallbackTrackTitle} trackTitle={qualityFallbackTrackTitle}
onTryLower={handleQualityFallbackTryLower} onTryLower={handleQualityFallbackTryLower}
onSkip={handleQualityFallbackSkip} onSkip={handleQualityFallbackSkip}
onClose={() => isQualityFallbackOpen = false} onClose={() => isQualityFallbackOpen = false}
/> />
{/if}
<!-- Keyboard Shortcuts Modal --> {#if isShortcutsModalOpen}
<KeyboardShortcutsModal <KeyboardShortcutsModal
isOpen={isShortcutsModalOpen} isOpen={isShortcutsModalOpen}
onClose={() => isShortcutsModalOpen = false} onClose={() => isShortcutsModalOpen = false}
onOpenSettings={() => { onOpenSettings={() => {
isShortcutsModalOpen = false; isShortcutsModalOpen = false;
isKeybindingsSettingsOpen = true; isKeybindingsSettingsOpen = true;
}} }}
/> />
{/if}
<!-- Keybindings Settings Modal --> {#if isKeybindingsSettingsOpen}
<KeybindingsSettings <KeybindingsSettings
isOpen={isKeybindingsSettingsOpen} isOpen={isKeybindingsSettingsOpen}
onClose={() => isKeybindingsSettingsOpen = false} onClose={() => isKeybindingsSettingsOpen = false}
/> />
{/if}
<!-- Link Resolver Modal --> {#if isLinkResolverOpen}
<LinkResolverModal <LinkResolverModal
isOpen={isLinkResolverOpen} isOpen={isLinkResolverOpen}
onClose={() => isLinkResolverOpen = false} onClose={() => isLinkResolverOpen = false}
onResolve={handleResolvedLink} onResolve={handleResolvedLink}
onOpenImporter={() => { isLinkResolverOpen = false; openPlaylistImport(); }} onOpenImporter={() => { isLinkResolverOpen = false; openPlaylistImport(); }}
/> />
{/if}
{#if updateRelease} {#if updateRelease}
<UpdateAvailableModal <UpdateAvailableModal