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.
This commit is contained in:
Léo
2026-08-16 17:54:24 +01:00
committed by GitHub
parent cc42f38a89
commit 895bf896e3
6 changed files with 383 additions and 8 deletions
+2 -1
View File
@@ -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');
+2 -1
View File
@@ -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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
@@ -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.
+49 -1
View File
@@ -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') {
+53
View File
@@ -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);
}
+76 -5
View File
@@ -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 <script type="module"> tags
// and <link rel="stylesheet"> in index.html.
// Two lists, two jobs — they are no longer the same set and must not be
// "resynced" back into one:
//
// PRECACHE = the app shell. Mirrors the <script type="module"> tags
// and <link rel="stylesheet"> in index.html — i.e. what
// loads before first paint.
// PANEL_PRECACHE = modules that index.html deliberately does NOT load,
// because js/panels.js imports them on first use. They are
// off the critical path, not out of the offline manifest:
// without them here, a panel the user never opened while
// online could not open offline at all.
//
// Both are fetched at install time, in the background. Entries must match the
// exact URL the browser requests, query string included.
const PRECACHE = [
'/',
'/static/style.css',
@@ -63,13 +74,73 @@ const PRECACHE = [
'/static/lib/highlight.min.js',
];
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
// precached so the panel still opens with no network.
const PANEL_PRECACHE = [
// Image editor — galleryEditor.js and its js/editor/ graph.
'/static/js/galleryEditor.js',
'/static/js/editor/ai-inpaint.js?v=20260708match1',
'/static/js/editor/ai-models.js',
'/static/js/editor/ai-rembg.js',
'/static/js/editor/ai-tool-runner.js',
'/static/js/editor/ai-tools-misc.js',
'/static/js/editor/build/controls.js?v=20260708match1',
'/static/js/editor/build/popups.js',
'/static/js/editor/build/right-panel.js',
'/static/js/editor/build/toolbar.js?v=20260708sam3',
'/static/js/editor/build/topbar.js',
'/static/js/editor/build/transform-popup.js',
'/static/js/editor/canvas-coords.js',
'/static/js/editor/canvas-events.js',
'/static/js/editor/canvas-transforms.js',
'/static/js/editor/checkerboard.js',
'/static/js/editor/clipboard-and-drop.js',
'/static/js/editor/composite-helpers.js',
'/static/js/editor/filters/blur.js',
'/static/js/editor/filters/edge-feather.js',
'/static/js/editor/fx/adj-popup.js',
'/static/js/editor/fx/filter-string.js',
'/static/js/editor/fx/histogram.js',
'/static/js/editor/fx/pixel-pass.js',
'/static/js/editor/harmonize-masks.js',
'/static/js/editor/history-panel.js',
'/static/js/editor/keyboard-shortcuts.js',
'/static/js/editor/layer-helpers.js',
'/static/js/editor/layer-panel.js',
'/static/js/editor/mask-utils.js',
'/static/js/editor/shortcuts-popover.js',
'/static/js/editor/slider-ux.js',
'/static/js/editor/snap.js',
'/static/js/editor/state.js',
'/static/js/editor/stroke-pipeline.js',
'/static/js/editor/stroke-tool-sliders.js',
'/static/js/editor/tools/clone.js',
'/static/js/editor/tools/crop.js',
'/static/js/editor/tools/flood-fill.js',
'/static/js/editor/tools/lasso-mask.js',
'/static/js/editor/tools/lasso.js',
'/static/js/editor/tools/move.js',
'/static/js/editor/tools/stroke.js',
'/static/js/editor/tools/transform-drag.js',
'/static/js/editor/tools/transform-handles.js',
'/static/js/editor/tools/transform-session.js',
'/static/js/editor/tools/wand.js',
'/static/js/editor/wire-import.js',
'/static/js/editor/wire-inpaint-controls.js?v=20260708match1',
'/static/js/editor/wire-merge-buttons.js',
'/static/js/editor/wire-selection-controls.js',
'/static/js/editor/wire-topbar-menus.js',
'/static/js/editor/wire-topbar-overflow.js',
'/static/js/editor/wire-topbar.js',
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then(cache =>
// addAll is atomic — if any item fails, none are cached. Use individual
// puts so a single 404 can't block the whole install.
Promise.all(
PRECACHE.map(url =>
[...PRECACHE, ...PANEL_PRECACHE].map(url =>
fetch(url, { cache: 'reload' })
.then(res => res.ok ? cache.put(url, res) : null)
.catch(() => null)
+201
View File
@@ -0,0 +1,201 @@
r"""Behaviour of the panel-loader registry in `static/js/panels.js`.
The registry is what keeps a lazily-loaded panel honest: it must import a
panel's module exactly once no matter how many times the user clicks, it must
fail loudly on a name nobody registered (rather than handing back `undefined`
and blowing up somewhere unrelated), and it must not memoise a *failed* load —
otherwise a panel that failed to load once while offline would stay broken for
the rest of the session.
`createPanelLoader` is exercised with stub thunks so these tests need no DOM and
no real panel module graph. The production `loadPanel` is the same function
applied to the real registry, checked here only for its registered names.
"""
import json
import os
import re
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_PANELS = (_REPO / "static" / "js" / "panels.js").as_posix()
_HAS_NODE = shutil.which("node") is not None
pytestmark = pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def _run(js: str) -> str:
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return proc.stdout.strip()
def test_repeated_loads_import_once_and_share_one_promise():
# A double-click on a rail button calls the loader twice before the first
# import has resolved. Both calls must ride the same in-flight import.
js = textwrap.dedent(
f"""
const {{ createPanelLoader }} = await import('{_PANELS}');
let calls = 0;
const load = createPanelLoader({{
demo: () => {{ calls += 1; return Promise.resolve({{ open: () => 'opened' }}); }},
}});
const a = load('demo');
const b = load('demo');
const mod = await a;
const c = load('demo'); // after it resolved, still cached
console.log(JSON.stringify({{
calls,
sameWhilePending: a === b,
sameAfterResolve: a === c,
opened: (await c).open(),
modOpened: mod.open(),
}}));
"""
)
out = json.loads(_run(js))
assert out["calls"] == 1
assert out["sameWhilePending"] is True
assert out["sameAfterResolve"] is True
assert out["opened"] == "opened"
assert out["modOpened"] == "opened"
def test_unknown_panel_throws_instead_of_returning_undefined():
js = textwrap.dedent(
f"""
const {{ createPanelLoader }} = await import('{_PANELS}');
const load = createPanelLoader({{ demo: () => Promise.resolve({{}}) }});
let message = null, returned = 'not-reached';
try {{ returned = load('nope'); }} catch (e) {{ message = e.message; }}
console.log(JSON.stringify({{ message, returned: String(returned) }}));
"""
)
out = json.loads(_run(js))
assert out["message"] is not None
assert 'nope' in out["message"]
assert out["returned"] == "not-reached"
def test_a_failed_load_is_not_memoised_so_a_retry_can_succeed():
# First open happens offline and the import 404s; the second, back online,
# must actually retry rather than replay the cached rejection.
js = textwrap.dedent(
f"""
const {{ createPanelLoader }} = await import('{_PANELS}');
let calls = 0;
const load = createPanelLoader({{
demo: () => {{
calls += 1;
return calls === 1
? Promise.reject(new Error('offline'))
: Promise.resolve({{ open: () => 'opened' }});
}},
}});
let firstError = null;
try {{ await load('demo'); }} catch (e) {{ firstError = e.message; }}
const second = await load('demo');
console.log(JSON.stringify({{ calls, firstError, opened: second.open() }}));
"""
)
out = json.loads(_run(js))
assert out["firstError"] == "offline"
assert out["calls"] == 2
assert out["opened"] == "opened"
def test_a_thunk_that_throws_synchronously_rejects_rather_than_escaping():
js = textwrap.dedent(
f"""
const {{ createPanelLoader }} = await import('{_PANELS}');
const load = createPanelLoader({{ demo: () => {{ throw new Error('boom'); }} }});
let sync = null, rejected = null;
let p;
try {{ p = load('demo'); }} catch (e) {{ sync = e.message; }}
try {{ await p; }} catch (e) {{ rejected = e.message; }}
console.log(JSON.stringify({{ sync, rejected }}));
"""
)
out = json.loads(_run(js))
assert out["sync"] is None, "a broken thunk must not throw at call time"
assert out["rejected"] == "boom"
def test_the_image_editor_is_registered():
js = textwrap.dedent(
f"""
const {{ panelNames }} = await import('{_PANELS}');
console.log(JSON.stringify(panelNames()));
"""
)
assert "editor" in json.loads(_run(js))
# ── Offline coverage ──────────────────────────────────────────────────────
# A lazily-loaded panel is never fetched during a normal page load, so it only
# lands in the service-worker cache if sw.js precaches it explicitly. Miss one
# module and the panel opens fine online and dies offline — the failure mode is
# invisible until someone is on a plane. This walks the editor's import graph
# and pins that every file in it is listed in PANEL_PRECACHE, query string
# included (the SW matches on the full URL).
_SW = _REPO / "static" / "sw.js"
_JS_DIR = _REPO / "static" / "js"
_STATIC_IMPORT = re.compile(
r"""(?:^|\n)\s*(?:import\s+(?:[^;'"()]*?\s+from\s+)?|export\s+(?:\*|\{[^}]*\})\s+from\s+)['"]([^'"]+)['"]"""
)
def _editor_module_graph() -> set[str]:
"""Every module statically reachable from galleryEditor.js, as '<path>[?query]'."""
seen: set[str] = set()
stack = ["galleryEditor.js"]
while stack:
current = stack.pop()
if current in seen:
continue
seen.add(current)
source = (_JS_DIR / current.split("?")[0]).read_text(encoding="utf-8")
for spec in _STATIC_IMPORT.findall(source):
if not spec.startswith("."):
continue
path, _, query = spec.partition("?")
target = os.path.normpath(
os.path.join(os.path.dirname(current.split("?")[0]), path)
)
stack.append(f"{target}?{query}" if query else target)
return seen
def _panel_precache_entries() -> set[str]:
block = re.search(
r"const PANEL_PRECACHE = \[(.*?)\];", _SW.read_text(encoding="utf-8"), re.S
)
assert block, "PANEL_PRECACHE not found in static/sw.js"
return set(re.findall(r"'([^']+)'", block.group(1)))
def test_every_lazy_editor_module_is_precached_for_offline_use():
graph = _editor_module_graph()
precached = _panel_precache_entries()
# Shared modules (ui.js, spinner.js, ...) are still eagerly loaded by
# index.html, so they are cached by the normal page load. Only the part of
# the graph that nothing else pulls in needs an explicit entry.
lazy_only = {
m for m in graph
if m == "galleryEditor.js" or m.split("?")[0].startswith("editor/")
}
missing = {f"/static/js/{m}" for m in lazy_only} - precached
assert not missing, (
"these editor modules load lazily but are not in PANEL_PRECACHE, so the "
f"editor would not open offline: {sorted(missing)}"
)