Skip to content

Commit f2ca9bb

Browse files
authored
Merge pull request #506 from AtalayaLabs/claude/frontend-performance-analysis-iwtyx1
2 parents fd83fe7 + f831636 commit f2ca9bb

11 files changed

Lines changed: 186 additions & 37 deletions

File tree

frontend/src/lib/components/AppShell.svelte

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { searchFiles } from '$lib/api/endpoints/search';
77
import { fileInlineUrl } from '$lib/api/endpoints/files';
88
import type { FileItem, FolderItem } from '$lib/api/types';
9-
import CommandPalette from '$lib/components/CommandPalette.svelte';
9+
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
1010
import Icon from '$lib/icons/Icon.svelte';
1111
import { iconNameFromClass } from '$lib/utils/display';
1212
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
@@ -19,6 +19,10 @@
1919
2020
let { children }: { children: Snippet } = $props();
2121
22+
// The command palette is loaded on its first Cmd/Ctrl+K and mounted open.
23+
// Until then its ~400-line module stays out of the initial bundle.
24+
const palette = lazyComponent(() => import('$lib/components/CommandPalette.svelte'));
25+
2226
interface NavLink {
2327
href: string;
2428
label: string;
@@ -232,6 +236,13 @@
232236
<svelte:window
233237
onclick={closeMenus}
234238
onkeydown={(e) => {
239+
// First Cmd/Ctrl+K loads the palette and mounts it open; once mounted,
240+
// the palette's own handler takes over toggling/closing.
241+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k' && !palette.component) {
242+
e.preventDefault();
243+
void palette.load();
244+
return;
245+
}
235246
if (e.key !== 'Escape') return;
236247
if (aboutOpen) aboutOpen = false;
237248
else if (searchActive) closeMobileSearch();
@@ -692,7 +703,10 @@
692703
</div>
693704
{/if}
694705

695-
<CommandPalette />
706+
{#if palette.component}
707+
{@const CommandPalette = palette.component}
708+
<CommandPalette autoOpen />
709+
{/if}
696710

697711
<style>
698712
/* Body becomes the sidebar+main flex row only while the shell is mounted. */

frontend/src/lib/components/CommandPalette.svelte

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
run: () => void;
1818
}
1919
20+
// `autoOpen` lets a lazy host (AppShell) mount us already-open on the first
21+
// Cmd/Ctrl+K, since our own key listener only exists once we're mounted.
22+
let { autoOpen = false }: { autoOpen?: boolean } = $props();
23+
2024
let open = $state(false);
2125
// Drives the enter animation: flipped on after mount so the overlay/panel
2226
// transition from their initial (faded/offset) state.
@@ -30,6 +34,18 @@
3034
// Element focused before the palette opened, restored on close.
3135
let prevFocus: HTMLElement | null = null;
3236
37+
// When mounted already-open (autoOpen), run the same enter sequence the
38+
// keyboard path uses. Guarded so it fires once, not on every reopen.
39+
let didAutoOpen = false;
40+
$effect(() => {
41+
if (didAutoOpen || !autoOpen) return;
42+
didAutoOpen = true;
43+
open = true;
44+
prevFocus = document.activeElement as HTMLElement | null;
45+
requestAnimationFrame(() => (entered = true));
46+
queueMicrotask(() => input?.focus());
47+
});
48+
3349
function close() {
3450
open = false;
3551
entered = false;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Defer loading a heavy Svelte component until it is first needed.
3+
*
4+
* The dynamic `import()` puts the component in its own chunk, keeping it out of
5+
* the initial bundle. The component type is inferred from the module's default
6+
* export, so binding and prop typing at the call site stay fully checked. Call
7+
* `load()` right before the component is shown, then render it once `component`
8+
* is non-null:
9+
*
10+
* ```svelte
11+
* const viewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
12+
* $effect(() => { if (open) void viewer.load(); });
13+
* …
14+
* {#if viewer.component}
15+
* {@const Viewer = viewer.component}
16+
* <Viewer bind:open {file} />
17+
* {/if}
18+
* ```
19+
*/
20+
export function lazyComponent<C>(loader: () => Promise<{ default: C }>) {
21+
let component = $state<C | null>(null);
22+
let pending: Promise<void> | null = null;
23+
24+
return {
25+
get component() {
26+
return component;
27+
},
28+
/** Idempotent: kicks off the import once, resolves when the chunk is ready. */
29+
load(): Promise<void> {
30+
if (component) return Promise.resolve();
31+
if (!pending) {
32+
pending = loader().then((mod) => {
33+
component = mod.default;
34+
});
35+
}
36+
return pending;
37+
}
38+
};
39+
}

frontend/src/lib/composables/useOwnerCache.svelte.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@ export class OwnerCache {
3131

3232
/** Resolve every not-yet-cached id in parallel; nullish ids are skipped. */
3333
async resolve(ids: Iterable<string | null | undefined>): Promise<void> {
34-
const unique = [...new Set([...ids].filter((id): id is string => !!id))];
35-
await Promise.all(
36-
unique.map(async (id) => {
37-
if (this.#names[id]) return;
38-
const name = await this.#resolver(id);
39-
this.#names = { ...this.#names, [id]: name };
40-
})
34+
const pending = [...new Set([...ids].filter((id): id is string => !!id))].filter(
35+
(id) => !this.#names[id]
4136
);
37+
if (pending.length === 0) return;
38+
const resolved = await Promise.all(
39+
pending.map(async (id) => [id, await this.#resolver(id)] as const)
40+
);
41+
// One reactive assignment for the whole batch instead of one per id, so a
42+
// large resolve doesn't spread-copy the record N times (and re-run derives N times).
43+
this.#names = { ...this.#names, ...Object.fromEntries(resolved) };
4244
}
4345
}
4446

frontend/src/lib/stores/files.svelte.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,15 @@ class FilesStore {
9696
this.selection = new Set();
9797
}
9898

99+
// Soft ceiling so the per-item toggle can't grow the set without bound.
100+
// (Bulk "select all" lives in the views and intentionally isn't capped —
101+
// silently dropping ids there would break batch delete/move.)
102+
static readonly MAX_SELECTION = 10_000;
103+
99104
toggleSelected(id: string): void {
100105
const next = new Set(this.selection);
101106
if (next.has(id)) next.delete(id);
102-
else next.add(id);
107+
else if (next.size < FilesStore.MAX_SELECTION) next.add(id);
103108
this.selection = next;
104109
}
105110
}

frontend/src/routes/admin/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@
320320
try {
321321
migration = await getMigration();
322322
if (migration.status === 'running') {
323-
if (!migrationTimer) migrationTimer = setInterval(loadMigration, 2000);
323+
if (!migrationTimer) migrationTimer = setInterval(loadMigration, 5000);
324324
} else {
325325
stopMigrationPoll();
326326
}

frontend/src/routes/favorites/+page.svelte

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import { renameFile, deleteFile } from '$lib/api/endpoints/files';
1818
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
1919
import type { FileItem } from '$lib/api/types';
20-
import FileViewer from '$lib/components/FileViewer.svelte';
20+
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
2121
import MoveDialog from '$lib/components/MoveDialog.svelte';
2222
import ShareDialog from '$lib/components/ShareDialog.svelte';
2323
import ResourceList, {
@@ -123,6 +123,13 @@
123123
let viewerOpen = $state(false);
124124
let viewerFile = $state<FileItem | null>(null);
125125
126+
// The file preview is loaded the first time a file is opened, keeping its
127+
// module out of this route's initial chunk.
128+
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
129+
$effect(() => {
130+
if (viewerOpen) void fileViewer.load();
131+
});
132+
126133
function open(entry: ResourceEntry) {
127134
if (entry.kind === 'folder') {
128135
goto(`/files/${entry.id}`);
@@ -305,7 +312,10 @@
305312
{/snippet}
306313
</ResourceList>
307314

308-
<FileViewer bind:open={viewerOpen} file={viewerFile} />
315+
{#if fileViewer.component}
316+
{@const FileViewer = fileViewer.component}
317+
<FileViewer bind:open={viewerOpen} file={viewerFile} />
318+
{/if}
309319
<MoveDialog
310320
bind:open={moveOpen}
311321
item={moveTarget}

frontend/src/routes/files/[...path]/+page.svelte

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,11 @@
3737
import { apiFetch } from '$lib/api/client';
3838
import { getCsrfHeaders } from '$lib/api/csrf';
3939
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
40-
import FileViewer from '$lib/components/FileViewer.svelte';
4140
import ListToolbar from '$lib/components/ListToolbar.svelte';
4241
import VirtualList from '$lib/components/VirtualList.svelte';
4342
import MoveDialog from '$lib/components/MoveDialog.svelte';
4443
import ShareDialog from '$lib/components/ShareDialog.svelte';
45-
import WopiEditor from '$lib/components/WopiEditor.svelte';
44+
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
4645
import { t } from '$lib/i18n/index.svelte';
4746
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
4847
import { files as filesStore } from '$lib/stores/files.svelte';
@@ -59,6 +58,12 @@
5958
import { formatDate, iconNameFromClass } from '$lib/utils/display';
6059
import { gridColumns } from '$lib/utils/grid';
6160
61+
// File preview and the WOPI editor are heavy and only appear on demand, so
62+
// their modules load the first time the user opens one (see the effects that
63+
// call `.load()` when `viewerOpen` / `wopiOpen` flip true).
64+
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
65+
const wopiEditor = lazyComponent(() => import('$lib/components/WopiEditor.svelte'));
66+
6267
// The URL rest param is the trail of folder ids from home's children down.
6368
// /files → home root; /files/a/b → folder b inside a inside home.
6469
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
@@ -806,6 +811,13 @@
806811
let wopiOpen = $state(false);
807812
let wopiAction = $state<'edit' | 'view'>('edit');
808813
let wopiFile = $state<{ id: string; name: string } | null>(null);
814+
815+
// Pull in the on-demand modules the moment they're first needed; after that
816+
// the chunk is cached and the component stays mounted (controlled by `open`).
817+
$effect(() => {
818+
if (viewerOpen) void fileViewer.load();
819+
if (wopiOpen) void wopiEditor.load();
820+
});
809821
// Editability of the current context-menu target file, resolved async.
810822
let ctxCanEditWopi = $state(false);
811823
@@ -1636,13 +1648,19 @@
16361648
item={actionTarget}
16371649
onshared={(id) => (sharedIds = new Set(sharedIds).add(id))}
16381650
/>
1639-
<FileViewer bind:open={viewerOpen} file={viewerFile} />
1640-
<WopiEditor
1641-
bind:open={wopiOpen}
1642-
fileId={wopiFile?.id ?? null}
1643-
fileName={wopiFile?.name ?? ''}
1644-
action={wopiAction}
1645-
/>
1651+
{#if fileViewer.component}
1652+
{@const FileViewer = fileViewer.component}
1653+
<FileViewer bind:open={viewerOpen} file={viewerFile} />
1654+
{/if}
1655+
{#if wopiEditor.component}
1656+
{@const WopiEditor = wopiEditor.component}
1657+
<WopiEditor
1658+
bind:open={wopiOpen}
1659+
fileId={wopiFile?.id ?? null}
1660+
fileName={wopiFile?.name ?? ''}
1661+
action={wopiAction}
1662+
/>
1663+
{/if}
16461664

16471665
{#if ctxOpen && ctxTarget}
16481666
<div

frontend/src/routes/photos/+page.svelte

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
<script lang="ts">
22
import Button from '$lib/components/Button.svelte';
33
import EmptyState from '$lib/components/EmptyState.svelte';
4-
import PeopleView from '$lib/components/PeopleView.svelte';
5-
import PhotoLightbox from '$lib/components/PhotoLightbox.svelte';
6-
import PlacesMap from '$lib/components/PlacesMap.svelte';
74
import VirtualRows from '$lib/components/VirtualRows.svelte';
5+
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
86
import { useSelection } from '$lib/composables/useSelection.svelte';
97
import { errorToast } from '$lib/utils/errors';
108
import { onMount } from 'svelte';
@@ -24,6 +22,13 @@
2422
2523
type Tab = 'moments' | 'places' | 'people';
2624
let tab = $state<Tab>('moments');
25+
26+
// The lightbox, the (maplibre-backed) places map and the people view are all
27+
// heavy and off the initial path, so each loads on first use: the lightbox
28+
// when a photo is opened, the map/people views when their tab is selected.
29+
const photoLightbox = lazyComponent(() => import('$lib/components/PhotoLightbox.svelte'));
30+
const placesMap = lazyComponent(() => import('$lib/components/PlacesMap.svelte'));
31+
const peopleView = lazyComponent(() => import('$lib/components/PeopleView.svelte'));
2732
let peopleAvailable = $state(false);
2833
2934
let items = $state<PhotoItem[]>([]);
@@ -44,6 +49,12 @@
4449
const selected = useSelection();
4550
let lightbox = $state(-1); // index into `items`, -1 = closed
4651
52+
$effect(() => {
53+
if (lightbox >= 0) void photoLightbox.load();
54+
if (tab === 'places') void placesMap.load();
55+
else if (tab === 'people') void peopleView.load();
56+
});
57+
4758
/** Client-generated video frame thumbnails (file id → data/URL). */
4859
let videoThumbs = $state<Record<string, string>>({});
4960
@@ -290,11 +301,16 @@
290301
['large', 800, 800]
291302
];
292303
let previewData = '';
293-
for (const [size, w, h] of SIZES) {
294-
const blob = await bitmapToBlob(bitmap, w, h);
295-
if (size === 'preview') previewData = await blobToDataUrl(blob);
296-
await uploadThumbnail(file.id, size, blob).catch(() => {});
297-
}
304+
// Render the blobs and push all three sizes in parallel; `previewData`
305+
// is captured before its upload so the local preview shows even if that
306+
// upload fails (allSettled swallows per-size failures, as before).
307+
await Promise.allSettled(
308+
SIZES.map(async ([size, w, h]) => {
309+
const blob = await bitmapToBlob(bitmap, w, h);
310+
if (size === 'preview') previewData = await blobToDataUrl(blob);
311+
await uploadThumbnail(file.id, size, blob);
312+
})
313+
);
298314
if (previewData) videoThumbs = { ...videoThumbs, [file.id]: previewData };
299315
} catch {
300316
// Keep the generic play badge on failure.
@@ -487,11 +503,20 @@
487503
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
488504
{#if loading}<p class="status">{t('common.loading', 'Loading…')}</p>{/if}
489505

490-
<PhotoLightbox {items} bind:index={lightbox} onDelete={onDeletePhoto} />
506+
{#if photoLightbox.component}
507+
{@const PhotoLightbox = photoLightbox.component}
508+
<PhotoLightbox {items} bind:index={lightbox} onDelete={onDeletePhoto} />
509+
{/if}
491510
{:else if tab === 'places'}
492-
<PlacesMap />
511+
{#if placesMap.component}
512+
{@const PlacesMap = placesMap.component}
513+
<PlacesMap />
514+
{/if}
493515
{:else if tab === 'people'}
494-
<PeopleView />
516+
{#if peopleView.component}
517+
{@const PeopleView = peopleView.component}
518+
<PeopleView />
519+
{/if}
495520
{/if}
496521

497522
{#snippet tile(photo: PhotoItem, sizeStyle?: string)}

frontend/src/routes/recent/+page.svelte

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
1818
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
1919
import type { FileItem, ItemType } from '$lib/api/types';
20-
import FileViewer from '$lib/components/FileViewer.svelte';
20+
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
2121
import MoveDialog from '$lib/components/MoveDialog.svelte';
2222
import ShareDialog from '$lib/components/ShareDialog.svelte';
2323
import ResourceList, {
@@ -134,6 +134,13 @@
134134
let viewerOpen = $state(false);
135135
let viewerFile = $state<FileItem | null>(null);
136136
137+
// The file preview is loaded the first time a file is opened, keeping its
138+
// module out of this route's initial chunk.
139+
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
140+
$effect(() => {
141+
if (viewerOpen) void fileViewer.load();
142+
});
143+
137144
function open(entry: ResourceEntry) {
138145
if (entry.kind === 'folder') {
139146
goto(`/files/${entry.id}`);
@@ -349,7 +356,10 @@
349356
{/snippet}
350357
</ResourceList>
351358

352-
<FileViewer bind:open={viewerOpen} file={viewerFile} />
359+
{#if fileViewer.component}
360+
{@const FileViewer = fileViewer.component}
361+
<FileViewer bind:open={viewerOpen} file={viewerFile} />
362+
{/if}
353363
<MoveDialog
354364
bind:open={moveOpen}
355365
item={moveTarget}

0 commit comments

Comments
 (0)