From 97d65b70af41621a7f28d1c785fe1b3487062bf7 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Wed, 6 May 2026 13:05:50 +0200 Subject: [PATCH 1/6] add installAndRegisterToolbox helper with in-flight dedupe --- src/lib/toolbox/index.ts | 4 ++ src/lib/toolbox/installFlow.ts | 91 ++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/lib/toolbox/installFlow.ts diff --git a/src/lib/toolbox/index.ts b/src/lib/toolbox/index.ts index 89d5be01..8e38e5ee 100644 --- a/src/lib/toolbox/index.ts +++ b/src/lib/toolbox/index.ts @@ -34,4 +34,8 @@ export { TOOLBOX_CATALOG, getCatalogEntry, type CatalogEntry } from './catalog'; export { bootstrapToolboxes } from './bootstrap'; +export { installAndRegisterToolbox, type InstallSpec } from './installFlow'; + +export { seedPreloadedToolboxes } from './store'; + export { collectRequiredToolboxes, findMissingRequirements } from './dependencies'; diff --git a/src/lib/toolbox/installFlow.ts b/src/lib/toolbox/installFlow.ts new file mode 100644 index 00000000..42a4a5de --- /dev/null +++ b/src/lib/toolbox/installFlow.ts @@ -0,0 +1,91 @@ +/** + * High-level orchestrator for installing a toolbox end-to-end: + * `performInstall` → `discoverToolbox` → `registerToolbox` → `upsertToolbox`. + * + * Used by both the startup bootstrap and the per-file `requiredToolboxes` + * install path. Deduplicates concurrent calls keyed by toolbox `id` so the + * two paths can run in parallel without firing the same install twice. + * + * Reconciles selections against the current persisted store entry when one + * exists, so the user's enable/disable choices survive a re-install. + */ + +import { get } from 'svelte/store'; +import { toolboxes, upsertToolbox } from './store'; +import { performInstall, discoverToolbox, registerToolbox } from './register'; +import { getCatalogEntry } from './catalog'; +import type { ToolboxConfig, ToolboxSource } from './types'; + +export interface InstallSpec { + id: string; + displayName: string; + source: ToolboxSource; + importPath?: string; + eventsImportPath?: string; +} + +const inFlight = new Map>(); + +export async function installAndRegisterToolbox(spec: InstallSpec): Promise { + const existing = inFlight.get(spec.id); + if (existing) return existing; + + const promise = (async (): Promise => { + const installResult = await performInstall(spec.source, spec.importPath); + const discovered = await discoverToolbox({ + importPath: installResult.importPath, + eventsImportPath: spec.eventsImportPath + }); + + // Reconcile against the current persisted entry, if any: preserves + // user enable/disable choices, defaults newly discovered entries to + // enabled, drops entries whose classes no longer exist upstream. + const current = get(toolboxes).find((t) => t.id === spec.id); + const config: ToolboxConfig = { + id: spec.id, + displayName: spec.displayName, + source: spec.source, + importPath: installResult.importPath, + eventsImportPath: spec.eventsImportPath, + installedVersion: installResult.installedVersion, + blocks: discovered.blocks.map( + (b) => + current?.blocks.find((s) => s.className === b.className) ?? { + className: b.className, + enabled: true + } + ), + events: discovered.events.map( + (e) => + current?.events.find((s) => s.className === e.className) ?? { + className: e.className, + enabled: true + } + ) + }; + + const catalog = getCatalogEntry(spec.id); + registerToolbox(config, { + blocks: discovered.blocks, + events: discovered.events, + defaultCategory: catalog?.defaultCategory, + categoryByClass: catalog?.categoryByClass + }); + upsertToolbox(config); + + return config; + })(); + + inFlight.set(spec.id, promise); + promise + .catch(() => { + // Error is propagated to the original awaiter; we only swallow + // here so the in-flight cleanup below doesn't trigger an + // unhandled rejection warning. + }) + .finally(() => { + if (inFlight.get(spec.id) === promise) inFlight.delete(spec.id); + }); + + return promise; +} From b27659c8eeaa5802a4ab52f4ccb1a6f0faa9ec79 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Wed, 6 May 2026 13:06:13 +0200 Subject: [PATCH 2/6] route bootstrap through installAndRegisterToolbox helper --- src/lib/toolbox/bootstrap.ts | 48 ++++++------------------------------ 1 file changed, 7 insertions(+), 41 deletions(-) diff --git a/src/lib/toolbox/bootstrap.ts b/src/lib/toolbox/bootstrap.ts index 93445c30..c7cc02a9 100644 --- a/src/lib/toolbox/bootstrap.ts +++ b/src/lib/toolbox/bootstrap.ts @@ -12,11 +12,9 @@ */ import { get } from 'svelte/store'; -import { toolboxes, upsertToolbox, seedPreloadedToolboxes } from './store'; -import { performInstall, discoverToolbox, registerToolbox } from './register'; -import { getCatalogEntry } from './catalog'; +import { toolboxes, seedPreloadedToolboxes } from './store'; +import { installAndRegisterToolbox } from './installFlow'; import { primePathsimVersion } from './pathsimVersion'; -import type { ToolboxConfig } from './types'; let bootstrapped = false; @@ -35,45 +33,13 @@ export async function bootstrapToolboxes(): Promise { for (const config of list) { try { - const installResult = await performInstall(config.source, config.importPath || undefined); - const discovered = await discoverToolbox({ - importPath: installResult.importPath, + await installAndRegisterToolbox({ + id: config.id, + displayName: config.displayName, + source: config.source, + importPath: config.importPath || undefined, eventsImportPath: config.eventsImportPath }); - - // Reconcile selections against current discovery: preserves the - // user's enabled/override choices, adds new classes the upstream - // package introduced (enabled by default), and drops entries - // whose classes no longer exist. - const reconciled: ToolboxConfig = { - ...config, - importPath: installResult.importPath, - installedVersion: installResult.installedVersion, - blocks: discovered.blocks.map( - (b) => - config.blocks.find((s) => s.className === b.className) ?? { - className: b.className, - enabled: true - } - ), - events: discovered.events.map( - (e) => - config.events.find((s) => s.className === e.className) ?? { - className: e.className, - enabled: true - } - ) - }; - - const catalog = getCatalogEntry(config.id); - registerToolbox(reconciled, { - blocks: discovered.blocks, - events: discovered.events, - defaultCategory: catalog?.defaultCategory, - categoryByClass: catalog?.categoryByClass - }); - - upsertToolbox(reconciled); } catch (e) { console.error(`[toolbox] bootstrap failed for "${config.id}":`, e); } From 28b06245cea4cd11ffeaca7fc93218fb84a53356 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Wed, 6 May 2026 13:06:40 +0200 Subject: [PATCH 3/6] route requiredToolboxes install through installAndRegisterToolbox helper --- src/lib/schema/fileOps.ts | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/src/lib/schema/fileOps.ts b/src/lib/schema/fileOps.ts index 61558a34..eff823ec 100644 --- a/src/lib/schema/fileOps.ts +++ b/src/lib/schema/fileOps.ts @@ -23,11 +23,7 @@ import { simulationState, resetSimulation } from '$lib/pyodide/bridge'; import { collectRequiredToolboxes, findMissingRequirements, - performInstall, - discoverToolbox, - registerToolbox, - upsertToolbox, - getCatalogEntry + installAndRegisterToolbox } from '$lib/toolbox'; import { getCachedPathsimVersion } from '$lib/toolbox/pathsimVersion'; import type { ToolboxRequirement } from '$lib/types/schema'; @@ -197,33 +193,13 @@ async function installRequiredToolboxes(reqs: ToolboxRequirement[]): Promise ({ className: b.className, enabled: true })), - events: discovered.events.map((e) => ({ className: e.className, enabled: true })) - }; - registerToolbox(config, { - blocks: discovered.blocks, - events: discovered.events, - defaultCategory: catalog?.defaultCategory, - categoryByClass: catalog?.categoryByClass + importPath: req.importPath || undefined, + eventsImportPath: req.eventsImportPath }); - upsertToolbox(config); consoleStore.info(`[toolbox] installed ${req.displayName}`); } catch (e) { const msg = e instanceof Error ? e.message : String(e); From 916351f8718ffec0877b8f172493f87bfd59137e Mon Sep 17 00:00:00 2001 From: milanofthe Date: Wed, 6 May 2026 13:07:15 +0200 Subject: [PATCH 4/6] run toolbox bootstrap and URL-param model load in parallel --- src/routes/+page.svelte | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 775ebb34..115688e1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -18,7 +18,7 @@ import EventDetail from '$lib/components/panels/library-detail/EventDetail.svelte'; import SubsystemTree from '$lib/components/panels/SubsystemTree.svelte'; import ToolboxManagerDialog from '$lib/components/dialogs/ToolboxManagerDialog.svelte'; - import { bootstrapToolboxes, type ToolboxConfig } from '$lib/toolbox'; + import { bootstrapToolboxes, seedPreloadedToolboxes, type ToolboxConfig } from '$lib/toolbox'; import ContextMenu from '$lib/components/ContextMenu.svelte'; import { buildContextMenuItems, type ContextMenuCallbacks } from '$lib/components/contextMenuBuilders'; import ExportDialog from '$lib/components/dialogs/ExportDialog.svelte'; @@ -525,19 +525,22 @@ onMount(() => { // Bring up the Python backend the moment the page loads so the - // runtime is ready by the time the user clicks Run. Order matters: - // detect the active backend first, then initialise it, then run - // the toolbox bootstrap on top. The URL-param model load runs at - // the end so toolbox blocks are registered in nodeRegistry by the - // time BaseNode tries to resolve them — otherwise blocks from - // installed-but-not-yet-bootstrapped toolboxes render as (missing). + // runtime is ready by the time the user clicks Run. Pyodide must + // finish before any toolbox work, then we seed preloaded catalog + // entries synchronously so `findMissingRequirements` (used inside + // `loadFromUrlParam`) sees a correct toolbox store. After that, + // bootstrap and the URL-param load run in parallel: BaseNode reacts + // to `registryVersion` bumps, so blocks rendered as (missing) while + // toolboxes are still installing upgrade themselves automatically. + // `installAndRegisterToolbox` deduplicates by toolbox id, so the + // two paths are safe to overlap. (async () => { try { await autoDetectBackend(); await initBackendFromUrl(); await initPyodide(); - await bootstrapToolboxes(); - await loadFromUrlParam(); + seedPreloadedToolboxes(); + await Promise.all([bootstrapToolboxes(), loadFromUrlParam()]); } catch (e) { console.error('[startup]', e); } From 1e60db9e3c41cc71ffdafca61076e8ca4f579ed2 Mon Sep 17 00:00:00 2001 From: milanofthe Date: Wed, 6 May 2026 13:11:31 +0200 Subject: [PATCH 5/6] fill graph before installing required toolboxes; add deferToolboxInstall option --- src/lib/schema/fileOps.ts | 71 ++++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/src/lib/schema/fileOps.ts b/src/lib/schema/fileOps.ts index eff823ec..83232d5c 100644 --- a/src/lib/schema/fileOps.ts +++ b/src/lib/schema/fileOps.ts @@ -211,7 +211,10 @@ async function installRequiredToolboxes(reqs: ToolboxRequirement[]): Promise { +export async function loadGraphFile( + file: GraphFile, + options: { deferToolboxInstall?: boolean; backendReady?: Promise } = {} +): Promise { // Migrate old format if needed file = migrateGraphFile(file); // Validate version @@ -222,12 +225,6 @@ export async function loadGraphFile(file: GraphFile): Promise { // Reset simulation state (stops running simulation, clears results and Python state) resetSimulation(); // Fire and forget - synchronous part stops immediately - // Install any runtime toolboxes the file declared as required. Files - // saved before this field existed simply skip this step. - if (file.requiredToolboxes && file.requiredToolboxes.length > 0) { - await installRequiredToolboxes(file.requiredToolboxes); - } - // Clear previous state and wait for UI to update // This ensures FlowCanvas sees empty state before new data arrives graphStore.clear(); @@ -235,23 +232,17 @@ export async function loadGraphFile(file: GraphFile): Promise { consoleStore.clear(); await tick(); - // Load graph (including annotations) + // Load graph (including annotations) — happens before toolbox install so + // the user sees the model immediately. Blocks whose toolbox isn't yet + // registered render as (missing) placeholders and upgrade themselves + // reactively via `registryVersion` once `installRequiredToolboxes` + // (below) completes. graphStore.fromJSON( file.graph?.nodes || [], file.graph?.connections || [], file.graph?.annotations || [] ); - // Surface any block types that ended up unregistered after the install - // step (either because the user skipped install, or because the file - // has no requiredToolboxes block list — old files / hand-edited files). - const unknownTypes = validateNodeTypes(file.graph?.nodes || []); - if (unknownTypes.length > 0) { - consoleStore.warn( - `[toolbox] unknown block types in this file: ${unknownTypes.join(', ')}. They will render as placeholders.` - ); - } - // Load events if (file.events && file.events.length > 0) { eventStore.fromJSON(file.events); @@ -293,6 +284,37 @@ export async function loadGraphFile(file: GraphFile): Promise { // Trigger assembly animation for loaded graph requestAssemblyAnimation(); + + // Install runtime toolboxes the file declared as required, then surface + // any block types that remain unregistered (user skipped install, or file + // has no requiredToolboxes — old / hand-edited files). In defer mode this + // runs in the background after `backendReady` resolves, so the graph + // shows up before Pyodide is even initialised. + const installAndWarn = async (): Promise => { + if (file.requiredToolboxes && file.requiredToolboxes.length > 0) { + await installRequiredToolboxes(file.requiredToolboxes); + } + const unknownTypes = validateNodeTypes(file.graph?.nodes || []); + if (unknownTypes.length > 0) { + consoleStore.warn( + `[toolbox] unknown block types in this file: ${unknownTypes.join(', ')}. They will render as placeholders.` + ); + } + }; + + if (options.deferToolboxInstall) { + void (async () => { + try { + if (options.backendReady) await options.backendReady; + await installAndWarn(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + consoleStore.error(`[toolbox] deferred install failed: ${msg}`); + } + })(); + } else { + await installAndWarn(); + } } /** @@ -480,6 +502,14 @@ export interface ImportOptions { position?: Position; // Where to place components (ignored for models) fileHandle?: FileSystemFileHandle; // For native file picker (enables Save) fileName?: string; // Display name (for URL imports or fallback) + // When true, the toolbox install step (which requires Pyodide) is fired + // off in the background — the graph fills immediately and (missing) + // blocks upgrade themselves via `registryVersion` once their toolbox + // registers. Used by the URL-param load on app start, where Pyodide may + // still be initialising. The deferred install awaits `backendReady` + // first, so it's safe to pass a not-yet-ready promise. + deferToolboxInstall?: boolean; + backendReady?: Promise; } /** @@ -635,7 +665,10 @@ async function importModel( simulationSettings: content.simulationSettings || INITIAL_SIMULATION_SETTINGS }; - await loadGraphFile(graphFile); + await loadGraphFile(graphFile, { + deferToolboxInstall: options.deferToolboxInstall, + backendReady: options.backendReady + }); // Update current file tracking currentFileHandle = options.fileHandle || null; From 32fa95db41e98a471011c688a05fea15d90c2bed Mon Sep 17 00:00:00 2001 From: milanofthe Date: Wed, 6 May 2026 13:12:25 +0200 Subject: [PATCH 6/6] load URL-param model in parallel with backend init via deferred toolbox install --- src/routes/+page.svelte | 48 +++++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 115688e1..2db8ae9a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -524,27 +524,37 @@ const continueTooltip = { text: "Continue", shortcut: "Shift+Enter" }; onMount(() => { - // Bring up the Python backend the moment the page loads so the - // runtime is ready by the time the user clicks Run. Pyodide must - // finish before any toolbox work, then we seed preloaded catalog - // entries synchronously so `findMissingRequirements` (used inside - // `loadFromUrlParam`) sees a correct toolbox store. After that, - // bootstrap and the URL-param load run in parallel: BaseNode reacts - // to `registryVersion` bumps, so blocks rendered as (missing) while - // toolboxes are still installing upgrade themselves automatically. - // `installAndRegisterToolbox` deduplicates by toolbox id, so the - // two paths are safe to overlap. - (async () => { + // The URL-param model load runs *parallel* to backend startup: fetch, + // parse, and graphStore.fromJSON happen immediately so the user sees + // the model right away. The toolbox install step inside loadGraphFile + // is deferred and waits on `backendReady` before touching Pyodide. + // `seedPreloadedToolboxes()` runs first synchronously so the store + // has all preloaded entries before `findMissingRequirements` runs; + // `installAndRegisterToolbox` deduplicates by id, so bootstrap and + // the deferred required-install can overlap safely. BaseNode reacts + // to `registryVersion` bumps, so any (missing) placeholders upgrade + // themselves as soon as their toolbox registers. + seedPreloadedToolboxes(); + const backendReady = (async () => { try { await autoDetectBackend(); await initBackendFromUrl(); await initPyodide(); - seedPreloadedToolboxes(); - await Promise.all([bootstrapToolboxes(), loadFromUrlParam()]); + await bootstrapToolboxes(); } catch (e) { - console.error('[startup]', e); + console.error('[startup] backend init failed', e); + throw e; } })(); + void loadFromUrlParam(backendReady).catch((e) => { + console.error('[startup] URL-param model load failed', e); + }); + void backendReady.catch(() => { + // Already logged above. Swallow here so the unhandled rejection + // from this branch (independent of the loadFromUrlParam branch) + // doesn't trip console noise — loadFromUrlParam awaits the same + // promise and surfaces install errors via consoleStore. + }); // Subscribe to stores (with cleanup) const unsubPinnedPreviews = pinnedPreviewsStore.subscribe((pinned) => { @@ -1056,7 +1066,7 @@ /** * Load model from URL parameter on page load */ - async function loadFromUrlParam(): Promise { + async function loadFromUrlParam(backendReady: Promise): Promise { if (!urlModelConfig) return; let url: string; @@ -1071,7 +1081,13 @@ return; } - const result = await importFromUrl(url); + // Defer the toolbox install step so the graph appears as soon as + // the file is fetched and parsed, even if Pyodide is still loading. + // `backendReady` gates the install step inside loadGraphFile. + const result = await importFromUrl(url, { + deferToolboxInstall: true, + backendReady + }); if (result.success) { setTimeout(() => triggerFitView(), 100); } else if (result.error) {