experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tiles): load-on-dispatch replaces lazy stubs (Phase E)

Phase E of the tile-lifecycle rewrite. The lazy-stub subscription
pattern is gone; dispatch now runs through one pubsub pre-publish hook
that awaits the owning tile's boot before delivery.

Why: N per-command stub subscribers bloated the pubsub registry and
split "load" logic across three nearly-identical code paths (command
stub, event interceptor, declarative window short-circuit). The hook
is a single dispatch-site intercept with topic-predicate filtering —
one code path, one place the policy lives.

- `pubsub.ts` — new `registerPrePublishHook(predicate, hook)`. The
hook runs before any subscriber; its return value (`'skip'` /
`'continue'` / `Promise<...>`) controls delivery. Async hooks defer
delivery until the promise resolves. Errors in the hook log and
fall through to normal delivery so a broken loader can't hang every
matching publish. `deliver(...)` extracted so both the sync and
deferred paths share one body. Session stats still count publish
intent (incremented once per call) regardless of skip/defer.

- `tile-lazy.ts` — rewritten around the hook. Removed:
`registerCommandStub`, `registerEventInterceptor`,
`lazyCommandPending`, `lazyEventPending`. Added
`installLoadOnDispatchHook` (called once at init) and a single
`dispatchHookBody` that handles both `cmd:execute:{name}` and
declared lazyEvent topics.
* Predicate fast-rejects `cmd:execute:X:result` sub-topics
(`rest.includes(':')`) so result publishes from handlers don't
re-enter the hook and deadlock.
* Declarative `action.type === 'window'` commands publish
`window:reopen-request` directly and return `'skip'` — the
original cmd:execute never reaches delivery, same behavior as
the old stub shortcut.
* Concurrent dispatches during load share one launch via the
existing `loadingTiles` set + `waitForTileReady` promise; all
three deferred publishes reach the real handler after ready
(test coverage added for this — the old latest-wins stub
buffer dropped earlier messages).
* `registerLazyTile` still publishes one `cmd:register-batch` per
tile so the cmd panel's command list is unchanged.

- `main.ts` — wires `installLoadOnDispatchHook()` in `initialize`,
right after `configureTileLauncher`, before `initializeFeatures` so
every subsequent cmd publish flows through the hook.

- `tile-lazy-events.test.ts` / `tile-command-registration.test.ts` —
rewritten to exercise hook semantics instead of stub-subscription
shape. No more `hasSubscriber('lazy-stub/*')` assertions; tests
verify launch counts, delivery after ready, no relaunch on
subsequent events, declarative-window bypass, and the concurrent-
fires-during-load invariant. One test removed (stub-subscriber
existence check) because the concept no longer exists.

Tests: 2249/2249 unit + 238/238 Playwright (desktop + desktop-serial).

Deliberately NOT removed: `registerLazyTile`, `getLazyTileManifest`,
`isLazyTileRegistered`, `getLazyTileIds`. They're the public API that
callers (tile-compat, ipc.ts) rely on. The internal stub machinery is
gone.

+421 -344
+8
backend/electron/main.ts
··· 18 18 import { initializeFeatures, type FeatureStartupResult } from './feature-startup.js'; 19 19 import { ensureTileIpcHandlers } from './tile-compat.js'; 20 20 import { getLoadedTileIds, getTileManifest, getAllTileWindows, unloadAllTiles, relaunchTile, configureTileLauncher } from './tile-launcher.js'; 21 + import { installLoadOnDispatchHook } from './tile-lazy.js'; 21 22 import { initTray } from './tray.js'; 22 23 import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js'; 23 24 import { scopes, publish, subscribe, unsubscribe, hasSubscriber, setExtensionBroadcaster, getSystemAddress } from './pubsub.js'; ··· 196 197 getProfileSession, 197 198 getSystemThemeBackgroundColor, 198 199 }); 200 + 201 + // Install the load-on-dispatch pre-publish hook. Every subsequent 202 + // `cmd:execute:{name}` or declared-lazyEvent publish passes through 203 + // this hook, which awaits the owning tile's boot before letting the 204 + // publish reach subscribers. Must run before any command can fire 205 + // (i.e. before loadExtensions / initializeFeatures). 206 + installLoadOnDispatchHook(); 199 207 200 208 // Initialize database 201 209 const dbPath = path.join(config.userDataPath, config.profile, 'datastore.sqlite');
+103 -2
backend/electron/pubsub.ts
··· 30 30 // Callback for broadcasting to extension windows 31 31 let extensionBroadcaster: ((topic: string, msg: unknown, source: string) => void) | null = null; 32 32 33 + /** 34 + * Pre-publish hooks let a module intercept publish calls before delivery. 35 + * Return value / resolved value controls what happens next: 36 + * - `undefined` / `'continue'` — proceed with normal delivery 37 + * - `'skip'` — drop this publish; no subscriber runs, no extension broadcast 38 + * - `Promise` — deliver is deferred until the promise resolves; the 39 + * promise's resolved value is then interpreted the same way 40 + * 41 + * Matched in registration order — first hook whose predicate returns true 42 + * wins (so more-specific hooks should register first, or the predicate 43 + * itself should be selective). Multiple hooks for the same topic aren't 44 + * composed; that path is load-on-dispatch only today. 45 + * 46 + * Rationale: lets the tile lifecycle module enforce "load the owning tile 47 + * before a `cmd:execute:X` or declared lazyEvent topic reaches its 48 + * handler" without every caller having to know about tile state, and 49 + * without littering the pubsub registry with per-command stub subscribers. 50 + */ 51 + export type PrePublishHookResult = void | 'continue' | 'skip'; 52 + export type PrePublishHook = (topic: string, msg: unknown) => PrePublishHookResult | Promise<PrePublishHookResult>; 53 + export type PrePublishHookPredicate = (topic: string) => boolean; 54 + 55 + const prePublishHooks: Array<{ predicate: PrePublishHookPredicate; hook: PrePublishHook }> = []; 56 + 33 57 // Session stats tracking 34 58 const sessionStats = { 35 59 messagesPublished: 0, ··· 75 99 } 76 100 77 101 /** 78 - * Publish a message to a topic 102 + * Publish a message to a topic. 103 + * 104 + * If a pre-publish hook matches `topic`, its result is consulted before 105 + * any subscriber runs. An async hook (returning a Promise) defers 106 + * delivery until the promise resolves; a `'skip'` result aborts the 107 + * publish entirely. See `registerPrePublishHook` for the mechanism. 79 108 */ 80 109 export function publish(source: string, scope: Scope, topic: string, msg: unknown): void { 81 - // Track session stats 110 + // Track session stats — counted once per publish call regardless of 111 + // whether a hook defers/skips delivery. Stats reflect publish intent. 82 112 sessionStats.messagesPublished++; 83 113 sessionStats.topicsUsed.add(topic); 84 114 115 + const hook = findPrePublishHook(topic); 116 + if (!hook) { 117 + deliver(source, scope, topic, msg); 118 + return; 119 + } 120 + 121 + let result: PrePublishHookResult | Promise<PrePublishHookResult>; 122 + try { 123 + result = hook(topic, msg); 124 + } catch (err) { 125 + console.error(`[pubsub] pre-publish hook threw for ${topic}:`, err); 126 + deliver(source, scope, topic, msg); 127 + return; 128 + } 129 + 130 + if (result && typeof (result as Promise<PrePublishHookResult>).then === 'function') { 131 + (result as Promise<PrePublishHookResult>).then( 132 + (val) => { 133 + if (val === 'skip') return; 134 + deliver(source, scope, topic, msg); 135 + }, 136 + (err) => { 137 + // Hook failed mid-flight — best-effort deliver anyway so a 138 + // broken loader can't hang every matching publish forever. 139 + console.error(`[pubsub] pre-publish hook rejected for ${topic}:`, err); 140 + deliver(source, scope, topic, msg); 141 + }, 142 + ); 143 + return; 144 + } 145 + 146 + if (result === 'skip') return; 147 + deliver(source, scope, topic, msg); 148 + } 149 + 150 + function findPrePublishHook(topic: string): PrePublishHook | null { 151 + for (const { predicate, hook } of prePublishHooks) { 152 + try { 153 + if (predicate(topic)) return hook; 154 + } catch (err) { 155 + console.error('[pubsub] pre-publish predicate threw:', err); 156 + } 157 + } 158 + return null; 159 + } 160 + 161 + function deliver(source: string, scope: Scope, topic: string, msg: unknown): void { 85 162 // Route to traditional subscribers (via IPC callbacks) 86 163 if (topics.has(topic)) { 87 164 const t = topics.get(topic)!; ··· 97 174 if (scope === scopes.GLOBAL && extensionBroadcaster) { 98 175 extensionBroadcaster(topic, msg, source); 99 176 } 177 + } 178 + 179 + /** 180 + * Register a pre-publish hook. Returns an unsubscribe function. 181 + * 182 + * `predicate` runs on every publish; keep it fast (no I/O, no 183 + * allocation where avoidable). Only when it returns true does `hook` 184 + * run. See `PrePublishHook` for result semantics. 185 + */ 186 + export function registerPrePublishHook( 187 + predicate: PrePublishHookPredicate, 188 + hook: PrePublishHook, 189 + ): () => void { 190 + const entry = { predicate, hook }; 191 + prePublishHooks.push(entry); 192 + return () => { 193 + const idx = prePublishHooks.indexOf(entry); 194 + if (idx >= 0) prePublishHooks.splice(idx, 1); 195 + }; 196 + } 197 + 198 + /** @internal Test hook: clear all registered pre-publish hooks. */ 199 + export function __clearPrePublishHooksForTest(): void { 200 + prePublishHooks.length = 0; 100 201 } 101 202 102 203 /**
+45 -38
backend/electron/tile-command-registration.test.ts
··· 1 1 /** 2 - * Unit tests for v2 tile command registration end-to-end plumbing. 2 + * Unit tests for v2 tile command registration plumbing. 3 3 * 4 - * Validates that: 5 - * 1. registerLazyTile() publishes cmd:register-batch for each command 6 - * 2. When cmd:execute:{name} fires, the stub loads the tile 7 - * 3. The buffered message is replayed to the real handler 8 - * 4. Commands with params are registered with their params metadata 4 + * Phase E rewrote the dispatch machinery — per-command stubs are gone, 5 + * replaced by a single dispatch hook. These tests cover what survives: 6 + * 7 + * 1. `registerLazyTile` publishes `cmd:register-batch` so the cmd 8 + * panel can list declared commands without loading the tile. 9 + * 2. Commands with params preserve their params metadata in the 10 + * announcement. 11 + * 3. When `cmd:execute:{name}` fires for a lazy tile, the dispatch 12 + * hook loads the tile and the message reaches its real handler. 13 + * 4. Window-action commands still appear in the announcement. 9 14 */ 10 15 11 16 import { describe, it, beforeEach } from 'node:test'; 12 17 import * as assert from 'node:assert'; 13 18 14 - import { publish, subscribe, unsubscribe, scopes, hasSubscriber } from './pubsub.js'; 19 + import { 20 + publish, subscribe, unsubscribe, scopes, 21 + __clearPrePublishHooksForTest, 22 + } from './pubsub.js'; 15 23 import { 16 24 registerLazyTile, 25 + installLoadOnDispatchHook, 17 26 __setTileLazyLauncherForTest, 18 27 __resetTileLazyStateForTest, 19 28 } from './tile-lazy.js'; ··· 86 95 87 96 beforeEach(() => { 88 97 __resetTileLazyStateForTest(); 98 + __clearPrePublishHooksForTest(); 99 + installLoadOnDispatchHook(); 89 100 launcherState = { 90 101 launchCalls: [], 91 102 loaded: new Set(), ··· 95 106 __setTileLazyLauncherForTest(makeFakeLauncher(launcherState)); 96 107 }); 97 108 98 - it('publishes cmd:register-batch for each manifest command', () => { 109 + it('publishes cmd:register-batch announcing every manifest command', () => { 99 110 const registered: unknown[] = []; 100 111 subscribe('test-observer', scopes.GLOBAL, 'cmd:register-batch', (msg: unknown) => { 101 112 registered.push(msg); ··· 108 119 109 120 registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 110 121 111 - // Expect one batch per command (tile-lazy publishes per command) 112 - assert.strictEqual(registered.length, 2, 'expected one batch publish per command'); 113 - 114 - const names = registered 115 - .flatMap((m: unknown) => (m as { commands: Array<{ name: string }> }).commands.map(c => c.name)) 116 - .sort(); 122 + // Phase E emits one batch per tile, not per command. 123 + assert.strictEqual(registered.length, 1, 'expected a single batch publish per tile'); 124 + const batch = registered[0] as { commands: Array<{ name: string }> }; 125 + const names = batch.commands.map(c => c.name).sort(); 117 126 assert.deepStrictEqual(names, ['bar', 'foo']); 118 127 119 128 unsubscribe('test-observer', 'cmd:register-batch'); 120 129 }); 121 130 122 - it('registers subscriber for cmd:execute:{name} for each command', () => { 123 - const manifest = makeManifestWithCommands('cmd-test-2', [ 124 - { name: 'baz', action: { type: 'execute' } }, 125 - ]); 126 - registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 127 - 128 - // lazy-stub subscribes to cmd:execute:baz 129 - assert.ok( 130 - hasSubscriber('cmd:execute:baz', 'lazy-stub/baz'), 131 - 'lazy-stub/baz should be subscribed to cmd:execute:baz' 132 - ); 133 - }); 134 - 135 131 it('preserves params metadata in cmd:register-batch', () => { 136 132 const received: unknown[] = []; 137 133 subscribe('test-observer-2', scopes.GLOBAL, 'cmd:register-batch', (msg: unknown) => { ··· 157 153 unsubscribe('test-observer-2', 'cmd:register-batch'); 158 154 }); 159 155 160 - it('loads tile and replays message when command is invoked', async () => { 156 + it('loads tile and delivers message when cmd:execute fires', async () => { 161 157 const manifest = makeManifestWithCommands('cmd-test-4', [ 162 158 { name: 'doit', action: { type: 'execute' } }, 163 159 ]); 164 160 registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 165 161 166 - // Simulate the tile registering its real handler on launch. 167 162 const realHandlerReceived: unknown[] = []; 168 163 launcherState.onLaunch = () => { 169 164 subscribe('peek://cmd-test-4/bg', scopes.GLOBAL, 'cmd:execute:doit', (msg: unknown) => { ··· 172 167 signalReady(launcherState, 'cmd-test-4', 'background'); 173 168 }; 174 169 175 - // Invoke the command — lazy stub intercepts, loads, replays 176 170 publish('test-caller', scopes.GLOBAL, 'cmd:execute:doit', { payload: 42 }); 177 171 178 - // Wait for microtasks and the async replay 179 - await flushMicrotasks(); 180 - await flushMicrotasks(); 181 - await flushMicrotasks(); 172 + // Dispatch hook is async — yield enough microtasks for load, 173 + // ready-signal, hook continuation, and delivery. 174 + for (let i = 0; i < 10; i++) await flushMicrotasks(); 182 175 183 176 assert.strictEqual(launcherState.launchCalls.length, 1, 'tile should be launched once'); 184 - assert.strictEqual(realHandlerReceived.length, 1, 'real handler should receive the replay'); 177 + assert.strictEqual(realHandlerReceived.length, 1, 'real handler should receive the deferred message'); 185 178 assert.deepStrictEqual(realHandlerReceived[0], { payload: 42 }); 186 179 187 180 unsubscribe('peek://cmd-test-4/bg', 'cmd:execute:doit'); 188 181 }); 189 182 190 - it('window-action commands still get registered in cmd batch', () => { 183 + it('window-action commands are announced in the batch and skip tile load on dispatch', async () => { 191 184 const received: unknown[] = []; 192 185 subscribe('test-observer-3', scopes.GLOBAL, 'cmd:register-batch', (msg: unknown) => { 193 186 received.push(msg); 194 187 }); 195 188 189 + const windowOpenReceived: unknown[] = []; 190 + subscribe('test-window-observer', scopes.GLOBAL, 'window:reopen-request', (msg: unknown) => { 191 + windowOpenReceived.push(msg); 192 + }); 193 + 196 194 const manifest = makeManifestWithCommands('cmd-test-5', [ 197 195 { 198 - name: 'open window', 196 + name: 'open-window', 199 197 description: 'Open a window', 200 198 action: { type: 'window', url: 'peek://cmd-test-5/home.html' }, 201 199 } as TileCommand, ··· 204 202 205 203 assert.strictEqual(received.length, 1); 206 204 const batch = received[0] as { commands: Array<{ name: string; action?: { type: string } }> }; 207 - assert.strictEqual(batch.commands[0].name, 'open window'); 205 + assert.strictEqual(batch.commands[0].name, 'open-window'); 208 206 assert.strictEqual(batch.commands[0].action?.type, 'window'); 209 207 208 + // Firing the command should publish window:reopen-request and NOT 209 + // load the tile (declarative window actions bypass tile boot). 210 + publish('test-caller', scopes.GLOBAL, 'cmd:execute:open-window', {}); 211 + for (let i = 0; i < 3; i++) await flushMicrotasks(); 212 + 213 + assert.strictEqual(launcherState.launchCalls.length, 0, 'declarative window action must not load the tile'); 214 + assert.strictEqual(windowOpenReceived.length, 1, 'window:reopen-request should be published'); 215 + 210 216 unsubscribe('test-observer-3', 'cmd:register-batch'); 217 + unsubscribe('test-window-observer', 'window:reopen-request'); 211 218 }); 212 219 });
+56 -74
backend/electron/tile-lazy-events.test.ts
··· 1 1 /** 2 - * Unit tests for tile-lazy event interceptor ("lazyEvents") behavior. 2 + * Unit tests for tile-lazy dispatch-hook behavior ("lazyEvents"). 3 3 * 4 - * Validates that a lazy v2 tile declaring `lazyEvents` in its manifest 5 - * gets loaded when any of those pubsub topics are first published, and 6 - * the buffered message is replayed to the real handler afterward. 4 + * Phase E replaced the per-topic stub subscriber pattern with a single 5 + * pre-publish hook on the pubsub bus. These tests verify the 6 + * behaviors that matter end-to-end: tile launches on first matching 7 + * topic, messages reach the real handler, subsequent events don't 8 + * re-launch, concurrent publishes during load are handled cleanly. 7 9 */ 8 10 9 11 import { describe, it, beforeEach, before } from 'node:test'; 10 12 import * as assert from 'node:assert'; 11 13 12 - import { publish, subscribe, unsubscribe, scopes, hasSubscriber } from './pubsub.js'; 14 + import { 15 + publish, subscribe, unsubscribe, scopes, 16 + __clearPrePublishHooksForTest, 17 + } from './pubsub.js'; 13 18 import { 14 19 registerLazyTile, 15 20 isLazyTileRegistered, 21 + installLoadOnDispatchHook, 16 22 __setTileLazyLauncherForTest, 17 23 __resetTileLazyStateForTest, 18 24 } from './tile-lazy.js'; ··· 98 104 99 105 // ─── Tests ──────────────────────────────────────────────────────────── 100 106 101 - describe('tile-lazy event interceptors', () => { 107 + describe('tile-lazy load-on-dispatch (lazyEvents)', () => { 102 108 let launcherState: FakeLauncherState; 103 109 104 110 beforeEach(() => { 105 111 __resetTileLazyStateForTest(); 112 + __clearPrePublishHooksForTest(); 113 + installLoadOnDispatchHook(); 106 114 launcherState = { 107 115 launchCalls: [], 108 116 loaded: new Set(), ··· 112 120 __setTileLazyLauncherForTest(makeFakeLauncher(launcherState)); 113 121 }); 114 122 115 - it('registers lazy tile with lazyEvents', () => { 123 + it('registers lazy tile', () => { 116 124 const manifest = makeManifest('test-tile-1', ['test1:open']); 117 125 registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 118 - 119 126 assert.ok(isLazyTileRegistered('test-tile-1')); 120 - // Interceptor stub subscribed to the topic 121 - assert.ok(hasSubscriber('test1:open', 'lazy-interceptor/test-tile-1/test1:open')); 122 127 }); 123 128 124 - it('loads tile when a lazyEvents topic is published', async () => { 129 + it('loads tile when a declared lazyEvent topic is published', async () => { 125 130 const manifest = makeManifest('test-tile-2', ['test2:open']); 126 131 registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 127 132 128 - // On launch, simulate the tile registering its real handler and signal ready. 133 + // On launch, simulate the tile registering its real handler and signaling ready. 129 134 launcherState.onLaunch = () => { 130 135 subscribe('peek://test-tile-2/bg', scopes.GLOBAL, 'test2:open', () => { 131 136 /* real handler */ ··· 134 139 }; 135 140 136 141 publish('peek://test-publisher', scopes.GLOBAL, 'test2:open', { id: 'abc' }); 137 - // Yield for async subscriber 138 - await flushMicrotasks(); 139 - await flushMicrotasks(); 142 + 143 + // The dispatch hook defers delivery until load+ready. Yield enough 144 + // microtasks for: launch → microtask that sets loaded + onLaunch → 145 + // waitForTileReady resolution → hook continuation → deliver. 146 + for (let i = 0; i < 10; i++) await flushMicrotasks(); 140 147 141 148 assert.strictEqual(launcherState.launchCalls.length, 1, 'tile should have been launched'); 142 149 assert.strictEqual(launcherState.launchCalls[0].manifest.id, 'test-tile-2'); 143 150 144 - // Clean up 145 151 unsubscribe('peek://test-tile-2/bg', 'test2:open'); 146 152 }); 147 153 148 - it('replays the buffered event to the real handler after load', async () => { 154 + it('the real handler receives the deferred message after load', async () => { 149 155 const manifest = makeManifest('test-tile-3', ['test3:open']); 150 156 registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 151 157 ··· 159 165 160 166 const payload = { itemId: 'note-42', cursor: { line: 3, col: 5 } }; 161 167 publish('peek://test-publisher', scopes.GLOBAL, 'test3:open', payload); 162 - 163 - // Allow async load + replay 164 168 for (let i = 0; i < 10; i++) await flushMicrotasks(); 165 169 166 - assert.strictEqual(received.length, 1, 'real handler should receive replayed message'); 170 + assert.strictEqual(received.length, 1, 'real handler should receive the deferred message'); 167 171 assert.deepStrictEqual(received[0], payload); 168 172 169 173 unsubscribe('peek://test-tile-3/bg', 'test3:open'); ··· 187 191 assert.strictEqual(launcherState.launchCalls.length, 1); 188 192 assert.strictEqual(received.length, 1); 189 193 190 - // Second publish — real handler already in place, interceptor stub unsubscribed 194 + // Second publish — tile already loaded + ready; hook sees that and 195 + // continues synchronously. No second launch. 191 196 publish('peek://pub', scopes.GLOBAL, 'test4:open', { n: 2 }); 192 197 for (let i = 0; i < 5; i++) await flushMicrotasks(); 193 198 ··· 195 200 assert.strictEqual(received.length, 2); 196 201 assert.deepStrictEqual(received[1], { n: 2 }); 197 202 198 - // Interceptor should have unsubscribed itself 199 - assert.strictEqual( 200 - hasSubscriber('test4:open', 'lazy-interceptor/test-tile-4/test4:open'), 201 - false 202 - ); 203 - 204 203 unsubscribe('peek://test-tile-4/bg', 'test4:open'); 205 204 }); 206 205 207 - it('multiple lazyEvents topics load tile independently (first wins)', async () => { 206 + it('multiple lazyEvents on one tile share a single launch', async () => { 208 207 const manifest = makeManifest('test-tile-5', ['test5:open', 'test5:add']); 209 208 registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 210 209 211 - // Both stubs registered 212 - assert.ok(hasSubscriber('test5:open', 'lazy-interceptor/test-tile-5/test5:open')); 213 - assert.ok(hasSubscriber('test5:add', 'lazy-interceptor/test-tile-5/test5:add')); 214 - 215 210 const received: Array<{ topic: string; msg: unknown }> = []; 216 211 launcherState.onLaunch = () => { 217 212 subscribe('peek://test-tile-5/bg', scopes.GLOBAL, 'test5:open', (msg) => { ··· 223 218 signalReady(launcherState, 'test-tile-5', 'background'); 224 219 }; 225 220 226 - // Publish one topic — should trigger load 221 + // First topic triggers load. 227 222 publish('peek://pub', scopes.GLOBAL, 'test5:open', { kind: 'open' }); 228 223 for (let i = 0; i < 10; i++) await flushMicrotasks(); 229 224 ··· 231 226 assert.strictEqual(received.length, 1); 232 227 assert.strictEqual(received[0].topic, 'test5:open'); 233 228 234 - // Now publish the other topic — tile already loaded, should go straight to real handler 229 + // Second topic: tile is already loaded, goes straight to real handler. 235 230 publish('peek://pub', scopes.GLOBAL, 'test5:add', { kind: 'add' }); 236 231 for (let i = 0; i < 5; i++) await flushMicrotasks(); 237 232 ··· 243 238 unsubscribe('peek://test-tile-5/bg', 'test5:add'); 244 239 }); 245 240 246 - it('only launches once even when topic fires multiple times before ready', async () => { 241 + it('concurrent fires during load: one launch, every message is delivered', async () => { 247 242 const manifest = makeManifest('test-tile-6', ['test6:open']); 248 243 registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 249 244 250 - // Defer ready signal so the first event is still "loading" when #2/#3 fire. 251 - // Note: the fake launchTile synchronously adds to `loaded` and calls onLaunch, 252 - // so subsequent events see tile-loaded + real-handler-present and deliver directly. 253 - // We only assert that: (a) tile is launched exactly once, (b) the real handler 254 - // eventually sees the replayed buffered message. 245 + // Defer ready so we can fire multiple publishes while still loading. 255 246 let readyTrigger: (() => void) | null = null; 247 + const received: unknown[] = []; 256 248 launcherState.onLaunch = () => { 257 249 subscribe('peek://test-tile-6/bg', scopes.GLOBAL, 'test6:open', (msg) => { 258 250 received.push(msg); 259 251 }); 260 252 readyTrigger = () => signalReady(launcherState, 'test-tile-6', 'background'); 261 253 }; 262 - 263 - const received: unknown[] = []; 264 254 265 255 publish('peek://pub', scopes.GLOBAL, 'test6:open', { n: 1 }); 266 256 await flushMicrotasks(); ··· 269 259 publish('peek://pub', scopes.GLOBAL, 'test6:open', { n: 3 }); 270 260 await flushMicrotasks(); 271 261 272 - // First event launched the tile; subsequent events short-circuit (stub unsubscribes). 262 + // All three hooks are awaiting waitForTileReady; none resolved yet. 273 263 assert.strictEqual(launcherState.launchCalls.length, 1, 'tile should launch exactly once'); 274 264 275 - // Finish the load so the buffered replay fires. 276 - const trigger = readyTrigger as (() => void) | null; 277 - assert.ok(trigger, 'onLaunch should have captured a ready trigger'); 278 - trigger(); 265 + // Resolve readiness — the three deferred publishes all fan out. 266 + readyTrigger!(); 279 267 for (let i = 0; i < 10; i++) await flushMicrotasks(); 280 268 281 - // Real handler must have received at least the replayed n:1 (first event, buffered) 282 - // plus any subsequent directly-delivered events (n:2, n:3 hit the real handler 283 - // after the stub unsubscribed itself). 284 - const payloads = received.map(r => (r as { n: number }).n); 285 - assert.ok(payloads.includes(1), `expected n:1 in delivered payloads, got ${JSON.stringify(payloads)}`); 269 + const payloads = received.map(r => (r as { n: number }).n).sort(); 270 + assert.deepStrictEqual(payloads, [1, 2, 3], 271 + `expected all three deferred publishes to reach the real handler, got ${JSON.stringify(payloads)}`); 286 272 287 273 unsubscribe('peek://test-tile-6/bg', 'test6:open'); 288 274 }); 289 275 290 - it('does not register event interceptor when manifest has no lazyEvents', () => { 291 - const manifest: TileManifestV2 = { 292 - manifestVersion: 2, 293 - id: 'test-tile-7', 294 - name: 'test-tile-7', 295 - builtin: true, 296 - tiles: [ 297 - { 298 - id: 'background', 299 - type: 'background', 300 - url: 'background.html', 301 - lazy: true, 302 - // no lazyEvents 303 - }, 304 - ], 305 - capabilities: { pubsub: { scopes: ['self', 'global'] } }, 306 - }; 276 + it('topic not declared anywhere bypasses the hook entirely', async () => { 277 + // Register a tile with a specific lazyEvent topic — an unrelated 278 + // publish must not go through the dispatch hook (no launch, no defer). 279 + const manifest = makeManifest('test-tile-7', ['test7:only-this-topic']); 307 280 registerLazyTile(manifest, '/fake/path', '/fake/preload.js'); 308 281 309 - // No interceptor subscribed for any arbitrary topic 310 - assert.strictEqual( 311 - hasSubscriber('editor:open', 'lazy-interceptor/test-tile-7/editor:open'), 312 - false 313 - ); 282 + const received: unknown[] = []; 283 + subscribe('peek://unrelated', scopes.GLOBAL, 'unrelated:topic', (msg) => { 284 + received.push(msg); 285 + }); 286 + 287 + publish('peek://pub', scopes.GLOBAL, 'unrelated:topic', { x: 1 }); 288 + // No awaits needed — unmatched topics deliver synchronously. 289 + // But give the loop a tick just to be safe for any microtask work. 290 + await flushMicrotasks(); 291 + 292 + assert.strictEqual(launcherState.launchCalls.length, 0, 'unrelated publish should not trigger a launch'); 293 + assert.strictEqual(received.length, 1); 294 + 295 + unsubscribe('peek://unrelated', 'unrelated:topic'); 314 296 }); 315 297 }); 316 298
+209 -230
backend/electron/tile-lazy.ts
··· 1 1 /** 2 - * Lazy Loading for Tiles 2 + * Lazy Loading for Tiles — load-on-dispatch 3 3 * 4 - * Runtime-level lazy loading: tiles don't need to know about it. 5 - * - Register command stubs from manifest (tile not loaded yet) 6 - * - On first command invocation, load the tile 7 - * - Buffer the message, wait for tile:ready, replay it 4 + * A lazy v2 tile is registered at boot with its manifest + launch 5 + * config, but no BrowserWindow is created. The tile's declared 6 + * commands are announced to the cmd panel via `cmd:register-batch` so 7 + * it can list them; its background process boots the first time it 8 + * needs to handle a command or a declared lazyEvent topic. 8 9 * 9 - * This mirrors the existing lazy loading in main.ts but adapted 10 - * for the v2 tile manifest format. 10 + * Load-on-dispatch mechanism (Phase E): 11 + * 12 + * - A single pre-publish hook on the pubsub bus intercepts 13 + * `cmd:execute:{name}` publishes and any topic declared in some 14 + * tile's `lazyEvents`. When the hook matches, it awaits the 15 + * owning tile's boot + ready signal, then lets the publish 16 + * continue on to subscribers — by which point the tile's real 17 + * handler is registered. 18 + * 19 + * - No per-command stub subscribers. The old Phase A-C code 20 + * subscribed `lazy-stub/{name}` to every `cmd:execute:{name}` at 21 + * boot; that's gone. The hook is the one and only dispatch-site 22 + * intercept, scoped by topic predicate. 23 + * 24 + * - Declarative `type: 'window'` commands bypass tile load — the 25 + * hook publishes `window:reopen-request` directly and returns 26 + * 'skip' so the cmd:execute publish never reaches a handler 27 + * (there wouldn't have been one anyway; the tile never loads). 11 28 */ 12 29 13 - import { publish, subscribe, unsubscribe, scopes, hasSubscriber, getSystemAddress } from './pubsub.js'; 30 + import { 31 + publish, scopes, registerPrePublishHook, getSystemAddress, 32 + type PrePublishHookResult, 33 + } from './pubsub.js'; 14 34 import { 15 35 launchTile as _launchTile, 16 36 waitForTileReady as _waitForTileReady, ··· 39 59 isTileLoaded: _isTileLoaded, 40 60 }; 41 61 42 - /** 43 - * Override the launcher hooks. Intended for tests only. 44 - * Pass `null` to restore the real launcher. 45 - */ 46 62 export function __setTileLazyLauncherForTest(override: Partial<TileLazyLauncher> | null): void { 47 63 if (override === null) { 48 64 launcher = { ··· 59 75 }; 60 76 } 61 77 62 - /** 63 - * Reset internal state. Tests only. 64 - */ 65 78 export function __resetTileLazyStateForTest(): void { 66 - lazyCommandPending.clear(); 67 - lazyEventPending.clear(); 68 79 lazyTileRegistry.clear(); 69 80 loadingTiles.clear(); 81 + declaredLazyEventTopics.clear(); 82 + _hookInstalled = false; 70 83 } 71 84 72 85 // ─── State ─────────────────────────────────────────────────────────── 73 86 74 - /** Pending lazy load: command name -> buffered message */ 75 - const lazyCommandPending = new Map<string, unknown>(); 76 - 77 - /** Pending lazy load: `${tileId}:${topic}` -> buffered message */ 78 - const lazyEventPending = new Map<string, unknown>(); 79 - 80 87 /** Tiles registered for lazy loading: tileId -> launch options (without window) */ 81 88 const lazyTileRegistry = new Map<string, { 82 89 manifest: TileManifestV2; ··· 84 91 preloadPath: string; 85 92 }>(); 86 93 87 - /** Track which lazy tiles are currently being loaded */ 94 + /** Tiles currently being loaded — prevents duplicate launch on concurrent dispatch. */ 88 95 const loadingTiles = new Set<string>(); 89 96 97 + /** 98 + * Topics declared across every registered tile's `lazyEvents`. Used 99 + * by the dispatch-hook predicate as a fast O(1) check so publishes of 100 + * unrelated topics pay only a Set lookup, not a manifest scan. 101 + */ 102 + const declaredLazyEventTopics = new Set<string>(); 103 + 90 104 /** Timeout for lazy load (ms) */ 91 105 const LAZY_LOAD_TIMEOUT_MS = 10000; 92 106 93 - // ─── Stub Registration ────────────────────────────────────────────── 107 + // ─── Registration ──────────────────────────────────────────────────── 94 108 95 109 /** 96 - * Register a tile for lazy loading 110 + * Register a tile for lazy loading. 97 111 * 98 - * Creates command stubs that trigger tile load on first invocation. 99 - * The tile's background entry point is loaded only when needed. 112 + * Publishes `cmd:register-batch` so the cmd panel can list the tile's 113 + * declared commands, stores the launch config for the dispatch hook to 114 + * use when it needs to boot the tile, and adds the tile's lazyEvent 115 + * topics to the hook predicate set. 116 + * 117 + * Unlike pre-Phase-E, this does NOT subscribe any per-command stubs. 118 + * The pre-publish dispatch hook (`dispatchLoadHook`) is the single 119 + * intercept point for load-on-dispatch. 100 120 */ 101 121 export function registerLazyTile( 102 122 manifest: TileManifestV2, 103 123 tilePath: string, 104 - preloadPath: string 124 + preloadPath: string, 105 125 ): void { 106 126 const tileId = manifest.id; 107 - 108 - // Store launch config 109 127 lazyTileRegistry.set(tileId, { manifest, tilePath, preloadPath }); 110 128 111 - // Register command stubs 112 - if (manifest.commands) { 113 - for (const cmd of manifest.commands) { 114 - registerCommandStub(tileId, cmd); 115 - } 129 + // Add any declared lazyEvents topics to the predicate set. 130 + for (const topic of collectLazyEventTopics(manifest)) { 131 + declaredLazyEventTopics.add(topic); 116 132 } 117 133 118 - // Register event interceptors for any tile entries that declare lazyEvents 119 - const eventTopics = collectLazyEventTopics(manifest); 120 - for (const topic of eventTopics) { 121 - registerEventInterceptor(tileId, topic); 134 + // Announce declared commands to the cmd panel. The panel's 135 + // subscriber builds proxy entries from this batch; it doesn't care 136 + // whether a stub or a real handler sits behind the source address. 137 + if (Array.isArray(manifest.commands) && manifest.commands.length > 0) { 138 + publish( 139 + `peek://${tileId}/lazy-stub`, 140 + scopes.GLOBAL, 141 + 'cmd:register-batch', 142 + { 143 + commands: manifest.commands.map(cmd => ({ 144 + name: cmd.name, 145 + description: cmd.description || '', 146 + source: `peek://${tileId}/background`, 147 + action: cmd.action, 148 + scope: cmd.scope, 149 + modes: cmd.modes, 150 + accepts: cmd.accepts, 151 + produces: cmd.produces, 152 + params: cmd.params, 153 + })), 154 + }, 155 + ); 122 156 } 123 157 124 158 DEBUG && console.log( 125 - `[tile-lazy] Registered lazy tile: ${tileId} with ${manifest.commands?.length || 0} commands, ${eventTopics.length} lazy events` 159 + `[tile-lazy] Registered lazy tile: ${tileId} (${manifest.commands?.length || 0} commands, ${collectLazyEventTopics(manifest).length} lazyEvents)` 126 160 ); 127 161 } 128 162 129 - /** 130 - * Collect all lazyEvents topics declared across a manifest's tile entries. 131 - * Deduplicates topics that appear in multiple entries. 132 - */ 133 163 function collectLazyEventTopics(manifest: TileManifestV2): string[] { 134 164 const topics = new Set<string>(); 165 + if (!Array.isArray(manifest.tiles)) return []; 135 166 for (const tile of manifest.tiles) { 136 167 if (!tile.lazyEvents) continue; 137 168 for (const topic of tile.lazyEvents) { ··· 143 174 return Array.from(topics); 144 175 } 145 176 177 + // ─── Dispatch hook ─────────────────────────────────────────────────── 178 + 146 179 /** 147 - * Register a single command stub for lazy loading 148 - * 149 - * The stub subscribes to cmd:execute:{name}. When invoked: 150 - * 1. Buffer the message 151 - * 2. Load the tile 152 - * 3. Wait for tile:ready 153 - * 4. Verify the tile registered a real handler 154 - * 5. Replay the buffered message 180 + * Install the single pre-publish hook that enforces load-on-dispatch. 181 + * Call once at app init, before any command can fire. Idempotent. 155 182 */ 156 - function registerCommandStub(tileId: string, cmd: TileCommand): void { 157 - const stubSource = `lazy-stub/${cmd.name}`; 158 - const executeTopic = `cmd:execute:${cmd.name}`; 159 - 160 - // Subscribe to the execute topic as a stub 161 - subscribe(stubSource, scopes.GLOBAL, executeTopic, async (msg: unknown) => { 162 - DEBUG && console.log(`[tile-lazy] Stub invoked for ${cmd.name}, loading tile ${tileId}`); 163 - 164 - // Declarative `type: window` commands don't need the tile loaded — 165 - // the main process can open the window directly. This avoids the race 166 - // condition where tile:ready fires before api.commands.register runs. 167 - if (cmd.action?.type === 'window' && cmd.action.url) { 168 - DEBUG && console.log(`[tile-lazy] Declarative window action for ${cmd.name}, opening directly`); 169 - publish(getSystemAddress(), scopes.GLOBAL, 'window:reopen-request', { 170 - url: cmd.action.url, 171 - options: { 172 - ...(cmd.action.options || {}), 173 - trackingSource: 'declarative', 174 - trackingSourceId: cmd.name, 175 - }, 176 - }); 177 - // Publish result so cmd panel spinner resolves 178 - const typed = msg as { resultTopic?: string } | null; 179 - if (typed?.resultTopic) { 180 - publish(stubSource, scopes.GLOBAL, typed.resultTopic, { success: true }); 181 - } 182 - return; 183 - } 184 - 185 - // Buffer the latest message (latest wins for concurrent invocations) 186 - lazyCommandPending.set(cmd.name, msg); 187 - 188 - // Unsubscribe the stub immediately to prevent re-entry 189 - unsubscribe(stubSource, executeTopic); 190 - 191 - // Load the tile 192 - try { 193 - await loadLazyTile(tileId); 194 - 195 - // Wait for the tile to be ready 196 - const bgEntry = getBackgroundEntry(tileId); 197 - if (bgEntry) { 198 - await launcher.waitForTileReady(tileId, bgEntry); 199 - } 200 - 201 - // Verify the real handler registered (exclude our stub prefix) 202 - const hasRealHandler = hasSubscriber(executeTopic, undefined, 'lazy-stub/'); 203 - if (!hasRealHandler) { 204 - console.warn(`[tile-lazy] Tile ${tileId} loaded but no handler registered for ${cmd.name}`); 205 - } 206 - 207 - // Replay the buffered message 208 - const buffered = lazyCommandPending.get(cmd.name); 209 - if (buffered !== undefined) { 210 - lazyCommandPending.delete(cmd.name); 211 - publish( 212 - `peek://${tileId}/lazy-replay`, 213 - scopes.GLOBAL, 214 - executeTopic, 215 - buffered 216 - ); 217 - } 218 - } catch (err) { 219 - console.error(`[tile-lazy] Failed to load tile ${tileId} for command ${cmd.name}:`, err); 220 - lazyCommandPending.delete(cmd.name); 221 - 222 - // Re-register the stub so the command can be retried 223 - registerCommandStub(tileId, cmd); 224 - } 225 - }); 226 - 227 - // Register the command in the command registry (so it shows up in cmd panel) 228 - // Use the same batch mechanism as v1 extensions 229 - publish( 230 - `peek://${tileId}/lazy-stub`, 231 - scopes.GLOBAL, 232 - 'cmd:register-batch', 233 - { 234 - commands: [{ 235 - name: cmd.name, 236 - description: cmd.description || '', 237 - source: `peek://${tileId}/background`, 238 - action: cmd.action, 239 - scope: cmd.scope, 240 - modes: cmd.modes, 241 - accepts: cmd.accepts, 242 - produces: cmd.produces, 243 - params: cmd.params, 244 - }], 245 - } 246 - ); 183 + let _hookInstalled = false; 184 + export function installLoadOnDispatchHook(): void { 185 + if (_hookInstalled) return; 186 + registerPrePublishHook(dispatchHookPredicate, dispatchHookBody); 187 + _hookInstalled = true; 247 188 } 248 189 249 190 /** 250 - * Register a single event interceptor stub for lazy loading 251 - * 252 - * Mirrors the command stub pattern but for pubsub topics. When the topic 253 - * fires before the tile is loaded: 254 - * 1. Buffer the message (latest wins for concurrent fires) 255 - * 2. Load the tile 256 - * 3. Wait for tile:ready 257 - * 4. Verify the real handler subscribed (exclude our stub prefix) 258 - * 5. Re-publish the buffered message for the real handler 191 + * Predicate: does this topic need the hook? 259 192 * 260 - * After successful load, the stub unsubscribes itself so future publishes 261 - * reach the real handler directly. 193 + * Yes if it's a primary `cmd:execute:{name}` topic (NOT a sub-topic 194 + * like `cmd:execute:{name}:result`), or if it's a declared lazyEvent 195 + * topic. Kept to an O(1) Set/startsWith/slice check — runs on every 196 + * publish. 262 197 */ 263 - function registerEventInterceptor(tileId: string, topic: string): void { 264 - const stubSource = `lazy-interceptor/${tileId}/${topic}`; 265 - const pendingKey = `${tileId}:${topic}`; 198 + function dispatchHookPredicate(topic: string): boolean { 199 + if (topic.startsWith('cmd:execute:')) { 200 + const rest = topic.slice('cmd:execute:'.length); 201 + // `cmd:execute:name` — no further colons. `cmd:execute:name:result` 202 + // has a colon in the rest and must not be intercepted. 203 + return !rest.includes(':') && rest.length > 0; 204 + } 205 + return declaredLazyEventTopics.has(topic); 206 + } 266 207 267 - subscribe(stubSource, scopes.GLOBAL, topic, async (msg: unknown) => { 268 - // If the tile is already loaded AND the real handler is subscribed, the 269 - // real handler fired from this same publish call (pubsub iterates all 270 - // subscribers). Just unsubscribe the stub and skip replay to avoid 271 - // duplicate delivery. 272 - const bgEntry = getBackgroundEntry(tileId); 273 - const tileLoaded = bgEntry ? launcher.isTileLoaded(tileId, bgEntry) : false; 274 - const realHandlerPresent = hasSubscriber(topic, undefined, `lazy-interceptor/${tileId}/`); 275 - if (tileLoaded && realHandlerPresent) { 276 - unsubscribe(stubSource, topic); 277 - return; 278 - } 208 + async function dispatchHookBody(topic: string, msg: unknown): Promise<PrePublishHookResult> { 209 + if (topic.startsWith('cmd:execute:')) { 210 + const name = topic.slice('cmd:execute:'.length); 211 + return handleCommandDispatch(name, msg); 212 + } 213 + // lazyEvent topic — find owner and ensure loaded. 214 + return handleLazyEventDispatch(topic); 215 + } 279 216 280 - // Buffer the latest message (latest wins for concurrent fires during load) 281 - const alreadyLoading = lazyEventPending.has(pendingKey); 282 - lazyEventPending.set(pendingKey, msg); 217 + async function handleCommandDispatch( 218 + name: string, 219 + msg: unknown, 220 + ): Promise<PrePublishHookResult> { 221 + const match = findTileByCommand(name); 222 + if (!match) { 223 + // Unknown command (wasn't declared in any registered lazy tile). 224 + // It may be a dynamically registered handler — let delivery proceed 225 + // and any real subscriber handles it. 226 + return 'continue'; 227 + } 283 228 284 - if (alreadyLoading) { 285 - DEBUG && console.log(`[tile-lazy] ${topic} fired again during load of ${tileId}, buffering`); 286 - return; 229 + const { tileId, cmd } = match; 230 + 231 + // Declarative `type: 'window'` commands: the main process opens the 232 + // window directly. No handler on the tile side; the cmd:execute 233 + // publish would go nowhere. Short-circuit: publish window-open, 234 + // resolve the caller's result topic if they provided one, and skip 235 + // the original publish. 236 + if (cmd.action?.type === 'window' && cmd.action.url) { 237 + DEBUG && console.log(`[tile-lazy] Declarative window action for ${name}, opening directly`); 238 + publish(getSystemAddress(), scopes.GLOBAL, 'window:reopen-request', { 239 + url: cmd.action.url, 240 + options: { 241 + ...(cmd.action.options || {}), 242 + trackingSource: 'declarative', 243 + trackingSourceId: name, 244 + }, 245 + }); 246 + const typed = msg as { resultTopic?: string } | null; 247 + if (typed?.resultTopic) { 248 + publish(getSystemAddress(), scopes.GLOBAL, typed.resultTopic, { success: true }); 287 249 } 250 + return 'skip'; 251 + } 288 252 289 - DEBUG && console.log(`[tile-lazy] ${topic} fired, ensuring tile ${tileId} is loaded`); 253 + // Programmatic command: load the tile (if needed), then let the 254 + // publish continue — by which point the tile's background has 255 + // subscribed its real handler. 256 + try { 257 + await ensureTileLoaded(tileId); 258 + } catch (err) { 259 + console.error(`[tile-lazy] Failed to load tile ${tileId} for command ${name}:`, err); 260 + // Delivery continues anyway so that any already-registered 261 + // fallback subscriber sees the message. If none exist, the cmd 262 + // panel's result-timeout will eventually surface the failure. 263 + } 264 + return 'continue'; 265 + } 290 266 291 - try { 292 - await loadLazyTile(tileId); 267 + async function handleLazyEventDispatch(topic: string): Promise<PrePublishHookResult> { 268 + const owner = findTileByLazyEventTopic(topic); 269 + if (!owner) { 270 + // Topic is in the predicate set but no registered tile claims it 271 + // anymore (e.g. tile was uninstalled between predicate match and 272 + // hook run). Just proceed. 273 + return 'continue'; 274 + } 275 + try { 276 + await ensureTileLoaded(owner.tileId); 277 + } catch (err) { 278 + console.error(`[tile-lazy] Failed to load tile ${owner.tileId} for lazyEvent ${topic}:`, err); 279 + } 280 + return 'continue'; 281 + } 293 282 294 - // Wait for the tile to be ready (loadLazyTile already waits, but be defensive) 295 - const bgEntry = getBackgroundEntry(tileId); 296 - if (bgEntry) { 297 - await launcher.waitForTileReady(tileId, bgEntry); 298 - } 283 + // ─── Load orchestration ───────────────────────────────────────────── 299 284 300 - // Unsubscribe the stub now that the real handler should be in place 301 - unsubscribe(stubSource, topic); 302 - 303 - // Verify the real handler registered (exclude our stub prefix) 304 - const hasRealHandler = hasSubscriber(topic, undefined, `lazy-interceptor/${tileId}/`); 305 - if (!hasRealHandler) { 306 - console.warn(`[tile-lazy] Tile ${tileId} loaded but no handler registered for ${topic}`); 307 - } 308 - 309 - // Replay the buffered (latest) message exactly once 310 - const buffered = lazyEventPending.get(pendingKey); 311 - lazyEventPending.delete(pendingKey); 312 - DEBUG && console.log(`[tile-lazy] Tile ${tileId} loaded, re-publishing ${topic}`); 313 - publish(stubSource, scopes.GLOBAL, topic, buffered); 314 - } catch (err) { 315 - console.error(`[tile-lazy] Failed to load tile ${tileId} for event ${topic}:`, err); 316 - lazyEventPending.delete(pendingKey); 317 - } 318 - }); 285 + /** 286 + * Ensure a tile is loaded AND ready. Handles the three cases: 287 + * - not loaded yet: start load + wait for ready 288 + * - loading in progress: wait for the existing load to complete 289 + * - already loaded + ready: resolves immediately 290 + */ 291 + async function ensureTileLoaded(tileId: string): Promise<void> { 292 + const bgEntry = getBackgroundEntry(tileId) || 'background'; 293 + await loadLazyTile(tileId); 294 + await launcher.waitForTileReady(tileId, bgEntry); 319 295 } 320 296 321 - // ─── Tile Loading ──────────────────────────────────────────────────── 322 - 323 297 /** 324 - * Load a lazy tile on demand 298 + * Load a lazy tile on demand. Used by the dispatch hook when it needs 299 + * to boot a tile. Idempotent: concurrent calls for the same tile 300 + * share the same in-flight launch. 325 301 */ 326 302 async function loadLazyTile(tileId: string): Promise<void> { 327 - // Already loaded or loading 328 - if (launcher.isTileLoaded(tileId, getBackgroundEntry(tileId) || 'background')) { 329 - return; 330 - } 303 + const bgEntry = getBackgroundEntry(tileId) || 'background'; 304 + if (launcher.isTileLoaded(tileId, bgEntry)) return; 331 305 if (loadingTiles.has(tileId)) { 332 - // Wait for the existing load to complete 333 - const bgEntry = getBackgroundEntry(tileId) || 'background'; 334 306 await launcher.waitForTileReady(tileId, bgEntry); 335 307 return; 336 308 } ··· 341 313 } 342 314 343 315 loadingTiles.add(tileId); 344 - 345 316 try { 346 - // Find the background tile entry 347 317 const bgTile = config.manifest.tiles.find(t => t.type === 'background'); 348 318 if (!bgTile) { 349 319 throw new Error(`[tile-lazy] No background tile entry in ${tileId}`); 350 320 } 351 321 352 - // Launch the tile 353 322 launcher.launchTile({ 354 323 tilePath: config.tilePath, 355 324 manifest: config.manifest, ··· 357 326 entryId: bgTile.id, 358 327 }); 359 328 360 - // Set up a safety timeout 361 329 let timeoutHandle: ReturnType<typeof setTimeout> | null = null; 362 330 const timeoutPromise = new Promise<void>((_, reject) => { 363 331 timeoutHandle = setTimeout(() => { ··· 365 333 }, LAZY_LOAD_TIMEOUT_MS); 366 334 }); 367 335 368 - // Wait for ready or timeout 369 336 try { 370 337 await Promise.race([ 371 338 launcher.waitForTileReady(tileId, bgTile.id), ··· 381 348 } 382 349 } 383 350 384 - /** 385 - * Get the background entry ID for a tile 386 - */ 387 351 function getBackgroundEntry(tileId: string): string | null { 388 352 const config = lazyTileRegistry.get(tileId); 389 353 if (!config) return null; 390 - 391 354 const bgTile = config.manifest.tiles.find(t => t.type === 'background'); 392 355 return bgTile ? bgTile.id : null; 393 356 } 394 357 395 - // ─── Queries ───────────────────────────────────────────────────────── 358 + // ─── Lookups ──────────────────────────────────────────────────────── 396 359 397 - /** 398 - * Check if a tile is registered for lazy loading 399 - */ 360 + function findTileByCommand( 361 + name: string, 362 + ): { tileId: string; cmd: TileCommand } | null { 363 + for (const [tileId, config] of lazyTileRegistry) { 364 + if (!Array.isArray(config.manifest.commands)) continue; 365 + const cmd = config.manifest.commands.find(c => c.name === name); 366 + if (cmd) return { tileId, cmd }; 367 + } 368 + return null; 369 + } 370 + 371 + function findTileByLazyEventTopic(topic: string): { tileId: string } | null { 372 + for (const [tileId, config] of lazyTileRegistry) { 373 + if (collectLazyEventTopics(config.manifest).includes(topic)) { 374 + return { tileId }; 375 + } 376 + } 377 + return null; 378 + } 379 + 380 + // ─── Public queries ───────────────────────────────────────────────── 381 + 400 382 export function isLazyTileRegistered(tileId: string): boolean { 401 383 return lazyTileRegistry.has(tileId); 402 384 } 403 385 404 386 /** 405 387 * Get the manifest for a lazy-registered tile (without forcing load). 406 - * Used by the window-open IPC handler to recognise v2 tile URLs before 407 - * the tile has actually booted. 388 + * Used by the window-open IPC handler to recognise v2 tile URLs 389 + * before the tile has actually booted. 408 390 */ 409 391 export function getLazyTileManifest(tileId: string): TileManifestV2 | null { 410 392 return lazyTileRegistry.get(tileId)?.manifest || null; 411 393 } 412 394 413 - /** 414 - * Get all registered lazy tile IDs 415 - */ 416 395 export function getLazyTileIds(): string[] { 417 396 return Array.from(lazyTileRegistry.keys()); 418 397 }