From 895bf896e30e53fb67d19a5a12357450931662c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Sun, 16 Aug 2026 18:54:24 +0200 Subject: [PATCH] refactor(static): load the image editor on first use (#6074) galleryEditor.js and its js/editor/ graph are 54 modules / 576 KB, and gallery.js imported them statically. Every page load paid for the whole image editor even though most sessions never open the Edit tab: 54 of the 173 JS files on a cold load, and 576 KB of the decoded JS, were for a panel that was never displayed. Add a small panel-loader registry (static/js/panels.js) that imports a panel's module on first use and memoises the promise, so a double-click cannot start two loads and a failed load can still be retried. Convert the image editor to it, and route the two existing dynamic imports in chat.js and chatRenderer.js through the same entry so all three call sites share one module instance instead of two. closeEditor() and isEditorOpen() stay synchronous: if the module was never loaded there is no edit session to close and none can be open. The service worker keeps precaching the editor, in a separate PANEL_PRECACHE list, so the panel stays available offline even though index.html no longer loads it. The two lists now serve different purposes and the header comment says so. --- static/js/chat.js | 3 +- static/js/chatRenderer.js | 3 +- static/js/gallery.js | 50 ++++++++- static/js/panels.js | 53 +++++++++ static/sw.js | 81 +++++++++++++- tests/test_panel_loader_js.py | 201 ++++++++++++++++++++++++++++++++++ 6 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 static/js/panels.js create mode 100644 tests/test_panel_loader_js.py diff --git a/static/js/chat.js b/static/js/chat.js index 31f632636..b19730050 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -35,6 +35,7 @@ import { inheritModelRouteState, } from './chatModelProvenance.js'; import { createTerminalStreamError, isRecoverableStreamError } from './chatStreamErrors.js'; +import { loadPanel } from './panels.js'; const RESEARCH_TIMEOUT_MS = 360000; const DEFAULT_TIMEOUT_MS = 120000; @@ -6606,7 +6607,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea // Images → Gallery editor. if (isImage) { try { - const gx = await import('./galleryEditor.js'); + const gx = await loadPanel('editor'); if (gx.openEditor) { gx.openEditor(url, id, null, name); return; } } catch (e) { console.warn('gallery open failed', e); } window.open(url, '_blank'); diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 023465095..b17d53179 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -9,6 +9,7 @@ import { providerLogo, providerLabel } from './providers.js'; import settingsModule from './settings.js'; import spinnerModule from './spinner.js'; import { bindMenuDismiss } from './escMenuStack.js'; +import { loadPanel } from './panels.js'; import { matchModelKey } from './model/matchKey.js'; const SEARCH_ICON = ''; @@ -1548,7 +1549,7 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId try { const [galleryMod, editorMod] = await Promise.all([ import('./gallery.js'), - import('./galleryEditor.js'), + loadPanel('editor'), ]); // Ensure the Gallery modal is open so the editor has a container // to render into; switch its tabs to the Edit tab. diff --git a/static/js/gallery.js b/static/js/gallery.js index 93e0b5f2b..3e4aaa28c 100644 --- a/static/js/gallery.js +++ b/static/js/gallery.js @@ -3,7 +3,7 @@ */ import uiModule from './ui.js'; -import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js?v=20260708match1'; +import { loadPanel } from './panels.js'; import spinnerModule from './spinner.js'; import { makeWindowDraggable } from './windowDrag.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; @@ -15,6 +15,54 @@ const API_BASE = window.location.origin; let _open = false; let _galleryResizeHandler = null; +// ── Image editor, loaded on first use ── +// galleryEditor.js plus everything under js/editor/ is 54 modules / 576 KB. +// It used to be a static import here, so every page load paid for it even +// though most sessions never touch the Edit tab. The wrappers below keep the +// three call shapes the rest of this file already uses. +// +// closeEditor() and isEditorOpen() stay synchronous on purpose: if the module +// was never loaded there is no edit session to close, and none can be open. +let _editorMod = null; +let _editorLoading = false; + +async function _loadEditor() { + _editorLoading = true; + try { + _editorMod = await loadPanel('editor'); + return _editorMod; + } finally { + _editorLoading = false; + } +} + +async function openEditor(...args) { + let mod = _editorMod; + if (!mod) { + try { + mod = await _loadEditor(); + } catch (e) { + // Previously unreachable — a static import either loaded or the whole + // page failed. Now it can fail on its own (offline before the panel was + // ever cached), so say so instead of doing nothing. + console.error('[gallery] image editor failed to load', e); + uiModule?.showError?.('Failed to load the image editor'); + return; + } + } + return mod.openEditor(...args); +} + +function closeEditor(...args) { + return _editorMod ? _editorMod.closeEditor(...args) : undefined; +} + +// True while the module is still in flight as well — the gallery-close paths +// use this to refuse to tear the container down under an edit that is opening. +function isEditorOpen() { + return _editorLoading || (_editorMod ? _editorMod.isEditorOpen() : false); +} + // Auto-refresh gallery when new image is generated window.addEventListener('gallery-refresh', (e) => { if (e?.detail?.source === 'chat-upload' && _sort !== 'recent') { diff --git a/static/js/panels.js b/static/js/panels.js new file mode 100644 index 000000000..2ea672ae0 --- /dev/null +++ b/static/js/panels.js @@ -0,0 +1,53 @@ +/** + * Panel loader registry — imports the modules behind a feature panel the + * first time that panel is actually used. + * + * The panels already populate themselves on open (each fetches its own data). + * They were just not *loaded* on demand: every one of them sat on the critical + * path of every page load, opened or not. + * + * A panel only belongs here once it has been checked for import-time side + * effects that something outside the panel depends on at startup. Entries get + * added one panel at a time, not in bulk. + * + * Note the service worker still precaches these modules (PANEL_PRECACHE in + * sw.js) — they are off the critical path, not off the offline manifest. + */ + +const LOADERS = { + editor: () => import('./galleryEditor.js'), +}; + +/** + * Build a memoising loader over a name -> import-thunk map. Exported so the + * behaviour can be tested without pulling a real panel's module graph in. + */ +export function createPanelLoader(loaders) { + const cache = new Map(); + return function load(name) { + const cached = cache.get(name); + if (cached) return cached; + const loader = loaders[name]; + if (!loader) throw new Error(`loadPanel: unknown panel "${name}"`); + // A failed load (offline, 404, syntax error) is not memoised — caching the + // rejection would leave the panel broken for the rest of the session even + // after the network came back. + const pending = Promise.resolve().then(loader).catch((err) => { + cache.delete(name); + throw err; + }); + cache.set(name, pending); + return pending; + }; +} + +/** + * Load a panel's module, once. Returns the same promise on every call for the + * same panel; throws for a name that is not registered. + */ +export const loadPanel = createPanelLoader(LOADERS); + +/** Registered panel names — the registry is the list, not a second copy of it. */ +export function panelNames() { + return Object.keys(LOADERS); +} diff --git a/static/sw.js b/static/sw.js index dea990b71..d72332d57 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,11 +7,22 @@ // - Other static assets (images/fonts/libs): cache-first with bg refresh. // - API / non-GET: never cached. // Bump CACHE_NAME whenever the precache list or SW logic changes. -const CACHE_NAME = 'odysseus-v376-settings-title-icons'; +const CACHE_NAME = 'odysseus-v377-lazy-image-editor'; -// Core shell precached on install so repeat opens are instant without any -// network wait. Keep this list in sync with the