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