perf(frontend): lazy-load heavy components to shrink initial bundle

Defer loading components that are off the initial render path until they
are first needed, keeping them out of the bundle that loads on page entry.

- Add `lazyComponent` composable: a tiny rune-based holder that dynamic-
  imports a component on first `load()` and exposes it for `{@const}`
  rendering, with the component type inferred from the module so prop/
  binding type-checking stays intact.
- CommandPalette: loaded on the first Cmd/Ctrl+K and mounted open via a
  new `autoOpen` prop. It was previously imported by AppShell, i.e. in the
  initial chunk of every authenticated route (~27 KB). AppShell now owns
  the shortcut that triggers the load.
- FileViewer + WopiEditor: loaded when a preview/editor first opens, across
  files, recent, favorites and shared-with-me.
- PhotoLightbox / PlacesMap / PeopleView: loaded on first lightbox open or
  when the places/people tab is selected (the latter also defers maplibre).

Once loaded a component stays mounted and behaves exactly as before, so
behavior is unchanged on the second use onward.
This commit is contained in:
Claude
2026-06-19 22:56:00 +00:00
parent b54c9a3e71
commit c60506cc36
8 changed files with 160 additions and 23 deletions
+16 -2
View File
@@ -6,7 +6,7 @@
import { searchFiles } from '$lib/api/endpoints/search';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import type { FileItem, FolderItem } from '$lib/api/types';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { iconNameFromClass } from '$lib/utils/display';
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
@@ -19,6 +19,10 @@
let { children }: { children: Snippet } = $props();
// The command palette is loaded on its first Cmd/Ctrl+K and mounted open.
// Until then its ~400-line module stays out of the initial bundle.
const palette = lazyComponent(() => import('$lib/components/CommandPalette.svelte'));
interface NavLink {
href: string;
label: string;
@@ -224,6 +228,13 @@
<svelte:window
onclick={closeMenus}
onkeydown={(e) => {
// First Cmd/Ctrl+K loads the palette and mounts it open; once mounted,
// the palette's own handler takes over toggling/closing.
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k' && !palette.component) {
e.preventDefault();
void palette.load();
return;
}
if (e.key !== 'Escape') return;
if (aboutOpen) aboutOpen = false;
else if (searchActive) closeMobileSearch();
@@ -683,7 +694,10 @@
</div>
{/if}
<CommandPalette />
{#if palette.component}
{@const CommandPalette = palette.component}
<CommandPalette autoOpen />
{/if}
<style>
/* Body becomes the sidebar+main flex row only while the shell is mounted. */
@@ -17,6 +17,10 @@
run: () => void;
}
// `autoOpen` lets a lazy host (AppShell) mount us already-open on the first
// Cmd/Ctrl+K, since our own key listener only exists once we're mounted.
let { autoOpen = false }: { autoOpen?: boolean } = $props();
let open = $state(false);
// Drives the enter animation: flipped on after mount so the overlay/panel
// transition from their initial (faded/offset) state.
@@ -30,6 +34,18 @@
// Element focused before the palette opened, restored on close.
let prevFocus: HTMLElement | null = null;
// When mounted already-open (autoOpen), run the same enter sequence the
// keyboard path uses. Guarded so it fires once, not on every reopen.
let didAutoOpen = false;
$effect(() => {
if (didAutoOpen || !autoOpen) return;
didAutoOpen = true;
open = true;
prevFocus = document.activeElement as HTMLElement | null;
requestAnimationFrame(() => (entered = true));
queueMicrotask(() => input?.focus());
});
function close() {
open = false;
entered = false;
@@ -0,0 +1,39 @@
/**
* Defer loading a heavy Svelte component until it is first needed.
*
* The dynamic `import()` puts the component in its own chunk, keeping it out of
* the initial bundle. The component type is inferred from the module's default
* export, so binding and prop typing at the call site stay fully checked. Call
* `load()` right before the component is shown, then render it once `component`
* is non-null:
*
* ```svelte
* const viewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
* $effect(() => { if (open) void viewer.load(); });
* …
* {#if viewer.component}
* {@const Viewer = viewer.component}
* <Viewer bind:open {file} />
* {/if}
* ```
*/
export function lazyComponent<C>(loader: () => Promise<{ default: C }>) {
let component = $state<C | null>(null);
let pending: Promise<void> | null = null;
return {
get component() {
return component;
},
/** Idempotent: kicks off the import once, resolves when the chunk is ready. */
load(): Promise<void> {
if (component) return Promise.resolve();
if (!pending) {
pending = loader().then((mod) => {
component = mod.default;
});
}
return pending;
}
};
}
+12 -2
View File
@@ -17,7 +17,7 @@
import { renameFile, deleteFile } from '$lib/api/endpoints/files';
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
import type { FileItem } from '$lib/api/types';
import FileViewer from '$lib/components/FileViewer.svelte';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import MoveDialog from '$lib/components/MoveDialog.svelte';
import ShareDialog from '$lib/components/ShareDialog.svelte';
import ResourceList, {
@@ -123,6 +123,13 @@
let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null);
// The file preview is loaded the first time a file is opened, keeping its
// module out of this route's initial chunk.
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
$effect(() => {
if (viewerOpen) void fileViewer.load();
});
function open(entry: ResourceEntry) {
if (entry.kind === 'folder') {
goto(`/files/${entry.id}`);
@@ -305,7 +312,10 @@
{/snippet}
</ResourceList>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
{#if fileViewer.component}
{@const FileViewer = fileViewer.component}
<FileViewer bind:open={viewerOpen} file={viewerFile} />
{/if}
<MoveDialog
bind:open={moveOpen}
item={moveTarget}
@@ -37,12 +37,11 @@
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import FileViewer from '$lib/components/FileViewer.svelte';
import ListToolbar from '$lib/components/ListToolbar.svelte';
import VirtualList from '$lib/components/VirtualList.svelte';
import MoveDialog from '$lib/components/MoveDialog.svelte';
import ShareDialog from '$lib/components/ShareDialog.svelte';
import WopiEditor from '$lib/components/WopiEditor.svelte';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { t } from '$lib/i18n/index.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
@@ -59,6 +58,12 @@
import { formatDate, iconNameFromClass } from '$lib/utils/display';
import { gridColumns } from '$lib/utils/grid';
// File preview and the WOPI editor are heavy and only appear on demand, so
// their modules load the first time the user opens one (see the effects that
// call `.load()` when `viewerOpen` / `wopiOpen` flip true).
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
const wopiEditor = lazyComponent(() => import('$lib/components/WopiEditor.svelte'));
// The URL rest param is the trail of folder ids from home's children down.
// /files → home root; /files/a/b → folder b inside a inside home.
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
@@ -806,6 +811,13 @@
let wopiOpen = $state(false);
let wopiAction = $state<'edit' | 'view'>('edit');
let wopiFile = $state<{ id: string; name: string } | null>(null);
// Pull in the on-demand modules the moment they're first needed; after that
// the chunk is cached and the component stays mounted (controlled by `open`).
$effect(() => {
if (viewerOpen) void fileViewer.load();
if (wopiOpen) void wopiEditor.load();
});
// Editability of the current context-menu target file, resolved async.
let ctxCanEditWopi = $state(false);
@@ -1636,13 +1648,19 @@
item={actionTarget}
onshared={(id) => (sharedIds = new Set(sharedIds).add(id))}
/>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
<WopiEditor
bind:open={wopiOpen}
fileId={wopiFile?.id ?? null}
fileName={wopiFile?.name ?? ''}
action={wopiAction}
/>
{#if fileViewer.component}
{@const FileViewer = fileViewer.component}
<FileViewer bind:open={viewerOpen} file={viewerFile} />
{/if}
{#if wopiEditor.component}
{@const WopiEditor = wopiEditor.component}
<WopiEditor
bind:open={wopiOpen}
fileId={wopiFile?.id ?? null}
fileName={wopiFile?.name ?? ''}
action={wopiAction}
/>
{/if}
{#if ctxOpen && ctxTarget}
<div
+26 -6
View File
@@ -1,10 +1,8 @@
<script lang="ts">
import Button from '$lib/components/Button.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import PeopleView from '$lib/components/PeopleView.svelte';
import PhotoLightbox from '$lib/components/PhotoLightbox.svelte';
import PlacesMap from '$lib/components/PlacesMap.svelte';
import VirtualRows from '$lib/components/VirtualRows.svelte';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { useSelection } from '$lib/composables/useSelection.svelte';
import { errorToast } from '$lib/utils/errors';
import { onMount } from 'svelte';
@@ -24,6 +22,13 @@
type Tab = 'moments' | 'places' | 'people';
let tab = $state<Tab>('moments');
// The lightbox, the (maplibre-backed) places map and the people view are all
// heavy and off the initial path, so each loads on first use: the lightbox
// when a photo is opened, the map/people views when their tab is selected.
const photoLightbox = lazyComponent(() => import('$lib/components/PhotoLightbox.svelte'));
const placesMap = lazyComponent(() => import('$lib/components/PlacesMap.svelte'));
const peopleView = lazyComponent(() => import('$lib/components/PeopleView.svelte'));
let peopleAvailable = $state(false);
let items = $state<PhotoItem[]>([]);
@@ -44,6 +49,12 @@
const selected = useSelection();
let lightbox = $state(-1); // index into `items`, -1 = closed
$effect(() => {
if (lightbox >= 0) void photoLightbox.load();
if (tab === 'places') void placesMap.load();
else if (tab === 'people') void peopleView.load();
});
/** Client-generated video frame thumbnails (file id → data/URL). */
let videoThumbs = $state<Record<string, string>>({});
@@ -487,11 +498,20 @@
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
{#if loading}<p class="status">{t('common.loading', 'Loading…')}</p>{/if}
<PhotoLightbox {items} bind:index={lightbox} onDelete={onDeletePhoto} />
{#if photoLightbox.component}
{@const PhotoLightbox = photoLightbox.component}
<PhotoLightbox {items} bind:index={lightbox} onDelete={onDeletePhoto} />
{/if}
{:else if tab === 'places'}
<PlacesMap />
{#if placesMap.component}
{@const PlacesMap = placesMap.component}
<PlacesMap />
{/if}
{:else if tab === 'people'}
<PeopleView />
{#if peopleView.component}
{@const PeopleView = peopleView.component}
<PeopleView />
{/if}
{/if}
{#snippet tile(photo: PhotoItem, sizeStyle?: string)}
+12 -2
View File
@@ -17,7 +17,7 @@
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
import type { FileItem, ItemType } from '$lib/api/types';
import FileViewer from '$lib/components/FileViewer.svelte';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import MoveDialog from '$lib/components/MoveDialog.svelte';
import ShareDialog from '$lib/components/ShareDialog.svelte';
import ResourceList, {
@@ -134,6 +134,13 @@
let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null);
// The file preview is loaded the first time a file is opened, keeping its
// module out of this route's initial chunk.
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
$effect(() => {
if (viewerOpen) void fileViewer.load();
});
function open(entry: ResourceEntry) {
if (entry.kind === 'folder') {
goto(`/files/${entry.id}`);
@@ -349,7 +356,10 @@
{/snippet}
</ResourceList>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
{#if fileViewer.component}
{@const FileViewer = fileViewer.component}
<FileViewer bind:open={viewerOpen} file={viewerFile} />
{/if}
<MoveDialog
bind:open={moveOpen}
item={moveTarget}
@@ -4,7 +4,7 @@
import { onMount } from 'svelte';
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
import type { FileItem } from '$lib/api/types';
import FileViewer from '$lib/components/FileViewer.svelte';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import ResourceList, { type ResourceEntry } from '$lib/components/ResourceList.svelte';
import { t } from '$lib/i18n/index.svelte';
@@ -48,6 +48,13 @@
let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null);
// The file preview is loaded the first time a file is opened, keeping its
// module out of this route's initial chunk.
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
$effect(() => {
if (viewerOpen) void fileViewer.load();
});
function open(entry: ResourceEntry) {
if (entry.kind === 'folder') {
goto(`/files/${entry.id}`);
@@ -76,4 +83,7 @@
onopen={open}
/>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
{#if fileViewer.component}
{@const FileViewer = fileViewer.component}
<FileViewer bind:open={viewerOpen} file={viewerFile} />
{/if}