experiments in a post-browser web
10
fork

Configure Feed

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

refactor(electron): delete v1 lazy-extension command machinery

CONSOLIDATED_EXTENSION_IDS, registerLazyExtensionCommands,
registerLazyCommand, registerLazyEventInterceptors,
handleLazyExtensionReady, getExpectedCommandTopics, lazyExtensionLoaded
+ companion Maps were a parallel v1 mechanism doing what the v2
tile-lazy.ts pre-publish dispatch hook now handles for every tile in
the list (all 19 IDs are manifestVersion 3 tiles already). The list
was a hardcoded allowlist that v2 derives from `lazy: true` /
`resident: true` manifest flags via tile-compat.

Rename EAGER_EXTENSION_IDS → EAGER_TILE_IDS (still used by
initializeFeatures' v2 path). Drop unused `unsubscribe` and
`hasSubscriber` imports; ManifestCommand stays for the still-live
declarative-only path (background:false manifests).

Also delete lazy-loading.test.ts — it simulated the deleted v1
buffering pattern with hand-rolled subscribe/publish; tile-lazy has
its own test coverage (tile-lazy-events.test.ts,
tile-command-registration.test.ts, tile-lazy-timeout.test.ts).

+10 -511
-257
backend/electron/lazy-loading.test.ts
··· 1 - /** 2 - * Unit tests for lazy extension loading patterns. 3 - * 4 - * These test the pubsub-level behavior of lazy stubs and interceptors 5 - * by simulating the same subscribe/unsubscribe/publish patterns used 6 - * in main.ts, without requiring Electron or extension host windows. 7 - */ 8 - 9 - import { describe, it, before, beforeEach, afterEach } from 'node:test'; 10 - import * as assert from 'node:assert'; 11 - 12 - let pubsub: typeof import('./pubsub.js'); 13 - 14 - describe('Lazy Loading Patterns', () => { 15 - before(async () => { 16 - pubsub = await import('./pubsub.js'); 17 - }); 18 - 19 - describe('command stub buffering pattern', () => { 20 - // Simulates the registerLazyCommand pattern from main.ts 21 - 22 - it('should buffer message and replay after load', async () => { 23 - const topic = 'cmd:execute:test-stub-1'; 24 - const stubSource = 'lazy-stub/test-stub-1'; 25 - let replayed: unknown = null; 26 - let loadTriggered = false; 27 - let pendingMsg: unknown = null; 28 - 29 - // Simulate the lazy stub 30 - pubsub.subscribe(stubSource, topic, async (msg: unknown) => { 31 - pendingMsg = msg; 32 - loadTriggered = true; 33 - 34 - // Simulate async extension load 35 - await Promise.resolve(); 36 - 37 - // Unsubscribe stub 38 - pubsub.unsubscribe(stubSource, topic); 39 - 40 - // Subscribe the "real" handler 41 - pubsub.subscribe('peek://test/', topic, (m: unknown) => { 42 - replayed = m; 43 - }); 44 - 45 - // Replay buffered message 46 - pubsub.publish(stubSource, topic, pendingMsg); 47 - }); 48 - 49 - // Invoke the command 50 - pubsub.publish('peek://cmd/', topic, { arg: 'hello' }); 51 - assert.strictEqual(loadTriggered, true); 52 - 53 - // Wait for async replay 54 - await Promise.resolve(); 55 - await Promise.resolve(); 56 - 57 - assert.deepStrictEqual(replayed, { arg: 'hello' }); 58 - 59 - // Cleanup 60 - pubsub.unsubscribe('peek://test/', topic); 61 - }); 62 - 63 - it('concurrent invocations should buffer latest message only', async () => { 64 - const topic = 'cmd:execute:test-stub-2'; 65 - const stubSource = 'lazy-stub/test-stub-2'; 66 - let replayed: unknown = null; 67 - let loadCount = 0; 68 - let pendingMsg: unknown = null; 69 - let alreadyLoading = false; 70 - 71 - // Resolve function to control when "load" completes 72 - let resolveLoad: () => void; 73 - const loadPromise = new Promise<void>(r => { resolveLoad = r; }); 74 - 75 - pubsub.subscribe(stubSource, topic, async (msg: unknown) => { 76 - const wasLoading = alreadyLoading; 77 - pendingMsg = msg; 78 - 79 - if (wasLoading) { 80 - // Second invocation during load — just buffer, don't load again 81 - return; 82 - } 83 - 84 - alreadyLoading = true; 85 - loadCount++; 86 - 87 - // Simulate async extension load 88 - await loadPromise; 89 - 90 - pubsub.unsubscribe(stubSource, topic); 91 - 92 - // Subscribe the "real" handler 93 - pubsub.subscribe('peek://test2/', topic, (m: unknown) => { 94 - replayed = m; 95 - }); 96 - 97 - // Replay buffered message (latest wins) 98 - pubsub.publish(stubSource, topic, pendingMsg); 99 - }); 100 - 101 - // First invocation 102 - pubsub.publish('peek://cmd/', topic, { version: 1 }); 103 - // Second invocation while loading 104 - pubsub.publish('peek://cmd/', topic, { version: 2 }); 105 - 106 - assert.strictEqual(loadCount, 1, 'Should only trigger load once'); 107 - 108 - // Complete the load 109 - resolveLoad!(); 110 - await Promise.resolve(); 111 - await Promise.resolve(); 112 - 113 - assert.deepStrictEqual(replayed, { version: 2 }, 'Should replay latest message'); 114 - 115 - // Cleanup 116 - pubsub.unsubscribe('peek://test2/', topic); 117 - }); 118 - 119 - it('per-command stub sources should be independent', () => { 120 - const stub1 = 'lazy-stub/cmd-a'; 121 - const stub2 = 'lazy-stub/cmd-b'; 122 - let a = false, b = false; 123 - 124 - pubsub.subscribe(stub1, 'cmd:execute:cmd-a', () => { a = true; }); 125 - pubsub.subscribe(stub2, 'cmd:execute:cmd-b', () => { b = true; }); 126 - 127 - // Unsubscribing one shouldn't affect the other 128 - pubsub.unsubscribe(stub1, 'cmd:execute:cmd-a'); 129 - 130 - pubsub.publish('peek://cmd/', 'cmd:execute:cmd-b', {}); 131 - assert.strictEqual(b, true, 'cmd-b stub should still work'); 132 - 133 - pubsub.unsubscribe(stub2, 'cmd:execute:cmd-b'); 134 - }); 135 - }); 136 - 137 - describe('event interceptor buffering pattern', () => { 138 - it('should buffer and replay event after load', async () => { 139 - const topic = 'test:editor:open'; 140 - const interceptorSource = `lazy-interceptor/${topic}`; 141 - let replayed: unknown = null; 142 - let pendingMsg: unknown = null; 143 - 144 - pubsub.subscribe(interceptorSource, topic, async (msg: unknown) => { 145 - pendingMsg = msg; 146 - 147 - // Simulate async load 148 - await Promise.resolve(); 149 - 150 - pubsub.unsubscribe(interceptorSource, topic); 151 - 152 - // Subscribe the "real" handler 153 - pubsub.subscribe('peek://editor/', topic, (m: unknown) => { 154 - replayed = m; 155 - }); 156 - 157 - pubsub.publish(interceptorSource, topic, pendingMsg); 158 - }); 159 - 160 - pubsub.publish('peek://cmd/', topic, { file: 'test.txt' }); 161 - 162 - await Promise.resolve(); 163 - await Promise.resolve(); 164 - 165 - assert.deepStrictEqual(replayed, { file: 'test.txt' }); 166 - 167 - pubsub.unsubscribe('peek://editor/', topic); 168 - }); 169 - 170 - it('concurrent events should buffer latest', async () => { 171 - const topic = 'test:editor:add'; 172 - const interceptorSource = `lazy-interceptor/${topic}`; 173 - let replayed: unknown = null; 174 - let pendingMsg: unknown = null; 175 - let alreadyLoading = false; 176 - 177 - let resolveLoad: () => void; 178 - const loadPromise = new Promise<void>(r => { resolveLoad = r; }); 179 - 180 - pubsub.subscribe(interceptorSource, topic, async (msg: unknown) => { 181 - const wasLoading = alreadyLoading; 182 - pendingMsg = msg; 183 - 184 - if (wasLoading) return; 185 - alreadyLoading = true; 186 - 187 - await loadPromise; 188 - 189 - pubsub.unsubscribe(interceptorSource, topic); 190 - 191 - pubsub.subscribe('peek://editor2/', topic, (m: unknown) => { 192 - replayed = m; 193 - }); 194 - 195 - pubsub.publish(interceptorSource, topic, pendingMsg); 196 - }); 197 - 198 - pubsub.publish('peek://cmd/', topic, { file: 'a.txt' }); 199 - pubsub.publish('peek://cmd/', topic, { file: 'b.txt' }); 200 - 201 - resolveLoad!(); 202 - await Promise.resolve(); 203 - await Promise.resolve(); 204 - 205 - assert.deepStrictEqual(replayed, { file: 'b.txt' }, 'Should replay latest event'); 206 - 207 - pubsub.unsubscribe('peek://editor2/', topic); 208 - }); 209 - 210 - it('per-topic interceptor sources should be independent', () => { 211 - const src1 = 'lazy-interceptor/topic-x'; 212 - const src2 = 'lazy-interceptor/topic-y'; 213 - let x = false, y = false; 214 - 215 - pubsub.subscribe(src1, 'topic-x', () => { x = true; }); 216 - pubsub.subscribe(src2, 'topic-y', () => { y = true; }); 217 - 218 - pubsub.unsubscribe(src1, 'topic-x'); 219 - 220 - pubsub.publish('peek://test/', 'topic-y', {}); 221 - assert.strictEqual(y, true); 222 - 223 - pubsub.unsubscribe(src2, 'topic-y'); 224 - }); 225 - }); 226 - 227 - describe('hasSubscriber verification pattern', () => { 228 - it('should detect when all command subscribers are present', () => { 229 - const topics = ['cmd:execute:a', 'cmd:execute:b', 'cmd:execute:c']; 230 - for (const t of topics) { 231 - pubsub.subscribe(`peek://test/${t}`, t, () => {}); 232 - } 233 - 234 - const allPresent = topics.every(t => pubsub.hasSubscriber(t)); 235 - assert.strictEqual(allPresent, true); 236 - 237 - for (const t of topics) { 238 - pubsub.unsubscribe(`peek://test/${t}`, t); 239 - } 240 - }); 241 - 242 - it('should detect missing subscribers', () => { 243 - pubsub.subscribe('peek://test/x', 'cmd:execute:present', () => {}); 244 - 245 - assert.strictEqual(pubsub.hasSubscriber('cmd:execute:present'), true); 246 - assert.strictEqual(pubsub.hasSubscriber('cmd:execute:missing'), false); 247 - 248 - pubsub.unsubscribe('peek://test/x', 'cmd:execute:present'); 249 - }); 250 - 251 - it('should work with zero expected topics', () => { 252 - const topics: string[] = []; 253 - const allPresent = topics.every(t => pubsub.hasSubscriber(t)); 254 - assert.strictEqual(allPresent, true); // vacuous truth 255 - }); 256 - }); 257 - });
+10 -254
backend/electron/main.ts
··· 25 25 import { installLoadOnDispatchHook } from './tile-lazy.js'; 26 26 import { initTray } from './tray.js'; 27 27 import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js'; 28 - import { publish, subscribe, unsubscribe, hasSubscriber, setPubsubBroadcaster, getSystemAddress } from './pubsub.js'; 28 + import { publish, subscribe, setPubsubBroadcaster, getSystemAddress } from './pubsub.js'; 29 29 import { installDirectSendGuard, installOffPathWindowGuard, unguardedWebContentsSend } from './tile-ipc-gate.js'; 30 30 import { WEB_CORE_ADDRESS, isTestProfile, isDevProfile, isEphemeralProfile, isHeadless, getProfile, setTilePreloadPath, getTilePreloadPath, DEBUG } from './config.js'; 31 31 import { getSystemThemeBackgroundColor } from './windows.js'; ··· 72 72 const _sessionRestoreDonePromise = new Promise<void>(resolve => { _sessionRestoreDoneResolve = resolve; }); 73 73 let _sessionRestoreDone = false; 74 74 75 - // Built-in extensions that load in consolidated mode (iframes) 76 - // External extensions (including 'example') load in separate windows 77 - // 78 - // NOTE: 'cmd', 'hud', and 'page' were removed from this list when they were 79 - // extracted from features/ into core app code (app/cmd/, app/hud/, app/page/). 80 - // cmd's registry + shortcut wiring and page's `open`/`modal` commands now 81 - // run inside the core background renderer (app/index.js calls initCmd() 82 - // and initPage() directly). hud still uses its own resident renderer 83 - // (hud-glue); pending consolidation into core next. 84 - const CONSOLIDATED_EXTENSION_IDS = ['editor', 'groups', 'lex', 'lists', 'peeks', 'search', 'slides', 'spaces', 'websearch', 'windows', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'entities', 'scripts', 'timers', 'wonderwall']; 85 - 86 - // Extensions that must load eagerly (not lazy) — needed at startup 87 - const EAGER_EXTENSION_IDS = new Set(['entities']); 88 - 89 - // Track which lazy extensions have been loaded 90 - const lazyExtensionLoaded = new Set<string>(); 91 - // Pending lazy load callbacks (extId -> resolve[]) 92 - const lazyLoadCallbacks = new Map<string, Array<() => void>>(); 75 + // Tiles that must load eagerly (not lazy) — needed at startup. 76 + // Passed to the v2 tile-compat path via `eagerIds`; tile-compat checks 77 + // this set in addition to the manifest `resident: true` flag. 78 + const EAGER_TILE_IDS = new Set(['entities']); 93 79 94 80 // Dev extensions loaded via --load-extension CLI flag 95 81 // These are transient (not persisted) and always have devtools open ··· 779 765 } 780 766 781 767 /** 782 - * Load a lazy extension on demand. 783 - * All consolidated extensions are now tiles launched eagerly, so this path 784 - * is a no-op that resolves immediately. If a true lazy-load mechanism is 785 - * needed in future it should target the tile launcher. 786 - */ 787 - function loadLazyExtension(extId: string): Promise<void> { 788 - if (lazyExtensionLoaded.has(extId)) { 789 - return Promise.resolve(); 790 - } 791 - console.warn(`[ext:lazy] loadLazyExtension('${extId}') called but no lazy-load mechanism exists. If this extension needs lazy loading, migrate it to a tile.`); 792 - lazyExtensionLoaded.add(extId); 793 - return Promise.resolve(); 794 - } 795 - 796 - /** 797 - * Get the expected pubsub topics for an extension's execute-type commands. 798 - */ 799 - function getExpectedCommandTopics(extId: string): string[] { 800 - const extPath = getExtensionPath(extId); 801 - if (!extPath) return []; 802 - const manifest = loadExtensionManifest(extPath); 803 - if (!manifest) return []; 804 - return (manifest.commands || []) 805 - .filter((cmd: ManifestCommand) => cmd.action.type === 'execute') 806 - .map((cmd: ManifestCommand) => `cmd:execute:${cmd.name}`); 807 - } 808 - 809 - function handleLazyExtensionReady(extId: string, registeredTopics?: string[]): void { 810 - lazyExtensionLoaded.add(extId); 811 - 812 - const callbacks = lazyLoadCallbacks.get(extId) || []; 813 - lazyLoadCallbacks.delete(extId); 814 - 815 - // Assert-and-trust: verify subscribers are registered, log errors but resolve immediately 816 - const expectedTopics = getExpectedCommandTopics(extId); 817 - if (expectedTopics.length > 0) { 818 - const missing = expectedTopics.filter(t => !hasSubscriber(t, undefined, 'lazy-stub/')); 819 - if (missing.length > 0) { 820 - // Cross-check with registeredTopics from the extension if provided 821 - if (registeredTopics && registeredTopics.length > 0) { 822 - const extMissing = expectedTopics.filter(t => !registeredTopics.includes(t)); 823 - if (extMissing.length > 0) { 824 - console.error(`[ext:lazy] Extension ${extId} did not register expected topics: ${extMissing.join(', ')}`); 825 - } 826 - } 827 - console.error(`[ext:lazy] Extension ${extId} ready but missing subscribers: ${missing.join(', ')}`); 828 - } 829 - } 830 - 831 - DEBUG && console.log(`[ext:lazy] Extension ${extId} ready, resolving ${callbacks.length} callbacks`); 832 - 833 - // Resolve immediately — the extension said it's ready, trust it 834 - for (const cb of callbacks) { 835 - cb(); 836 - } 837 - } 838 - 839 - /** 840 - * Register a lazy command stub. 841 - * When the command is invoked, the extension is loaded first, then the 842 - * command is re-published for the extension's own handler to pick up. 843 - */ 844 - // Track pending messages for lazy command stubs (cmd name -> buffered msg) 845 - const lazyCommandPending = new Map<string, unknown>(); 846 - 847 - function registerLazyCommand(extId: string, cmd: ManifestCommand): void { 848 - const stubSource = `lazy-stub/${cmd.name}`; 849 - const topic = `cmd:execute:${cmd.name}`; 850 - 851 - subscribe(stubSource, topic, async (msg: unknown) => { 852 - // Extension already loaded — forward directly and clean up 853 - if (lazyExtensionLoaded.has(extId)) { 854 - unsubscribe(stubSource, topic); 855 - publish(stubSource, topic, msg); 856 - return; 857 - } 858 - 859 - // Buffer the message (latest wins for concurrent invocations) 860 - const alreadyLoading = lazyCommandPending.has(cmd.name); 861 - lazyCommandPending.set(cmd.name, msg); 862 - 863 - if (alreadyLoading) { 864 - return; 865 - } 866 - 867 - DEBUG && console.log(`[ext:lazy] Lazy command invoked: ${cmd.name}, loading extension: ${extId}`); 868 - await loadLazyExtension(extId); 869 - 870 - // Unsubscribe stub and replay buffered message exactly once 871 - unsubscribe(stubSource, topic); 872 - const bufferedMsg = lazyCommandPending.get(cmd.name); 873 - lazyCommandPending.delete(cmd.name); 874 - DEBUG && console.log(`[ext:lazy] Extension ${extId} loaded, re-publishing command: ${cmd.name}`); 875 - publish(stubSource, topic, bufferedMsg); 876 - }); 877 - 878 - // Register the command metadata with the cmd registry 879 - const extSource = `peek://${extId}/`; 880 - publish(extSource, 'cmd:register', { 881 - name: cmd.name, 882 - description: cmd.description || '', 883 - source: extSource, 884 - scope: cmd.scope || 'global', 885 - modes: cmd.modes || [], 886 - accepts: cmd.accepts || [], 887 - produces: cmd.produces || [], 888 - params: cmd.params || [], 889 - }); 890 - 891 - DEBUG && console.log(`[ext:lazy] Registered lazy command: ${cmd.name} (extension: ${extId})`); 892 - } 893 - 894 - /** 895 - * Register declarative commands and shortcuts from lazy extension manifests. 896 - * For extensions marked as lazy, registers command stubs that load the extension 897 - * on first use. For declarative actions (window/publish), registers them directly. 898 - */ 899 - export function registerLazyExtensionCommands(): void { 900 - const registeredExtIds = getRegisteredExtensionIds(); 901 - 902 - for (const extId of registeredExtIds) { 903 - if (!isBuiltinExtensionEnabled(extId)) continue; 904 - if (declarativeExtensions.has(extId)) continue; // Already declarative (background: false) 905 - 906 - const extPath = getExtensionPath(extId); 907 - if (!extPath) continue; 908 - 909 - const manifest = loadExtensionManifest(extPath); 910 - if (!manifest) continue; 911 - 912 - // Only process consolidated extensions that have declared commands 913 - if (!CONSOLIDATED_EXTENSION_IDS.includes(extId)) continue; 914 - const commands = manifest.commands || []; 915 - const shortcuts = manifest.shortcuts || []; 916 - if (commands.length === 0 && shortcuts.length === 0) continue; 917 - 918 - // Eager extensions: register manifest shortcuts only (they handle their own commands) 919 - if (EAGER_EXTENSION_IDS.has(extId)) { 920 - DEBUG && console.log(`[ext:eager] Registering manifest shortcuts for ${extId}: ${shortcuts.length} shortcuts`); 921 - for (const shortcut of shortcuts) { 922 - registerDeclarativeShortcut(extId, shortcut, commands); 923 - } 924 - continue; 925 - } 926 - 927 - DEBUG && console.log(`[ext:lazy] Registering lazy commands for ${extId}: ${commands.length} commands, ${shortcuts.length} shortcuts`); 928 - 929 - for (const cmd of commands) { 930 - if (cmd.action.type === 'execute') { 931 - // Execute type: register a lazy stub that loads the extension on first use 932 - registerLazyCommand(extId, cmd); 933 - } else { 934 - // Window/publish type: register declaratively (no extension loading needed) 935 - registerDeclarativeCommand(extId, cmd); 936 - } 937 - } 938 - 939 - for (const shortcut of shortcuts) { 940 - registerDeclarativeShortcut(extId, shortcut, commands); 941 - } 942 - } 943 - } 944 - 945 - /** 946 - * Register pubsub interceptors for lazy extension events. 947 - * Some events (like editor:open) are published by the cmd panel before 948 - * the target extension is loaded. These interceptors ensure the extension 949 - * is loaded first, then re-publish the event so the extension receives it. 950 - */ 951 - // Track pending messages for lazy event interceptors (topic -> buffered msg) 952 - const lazyInterceptorPending = new Map<string, unknown>(); 953 - 954 - export function registerLazyEventInterceptors(options?: { skipExtensions?: Set<string> }): void { 955 - const skip = options?.skipExtensions; 956 - 957 - // Legacy v1 editor interceptors. Under v2, editor is handled by the 958 - // tile-lazy event interceptor system (see registerEventInterceptor in 959 - // tile-lazy.ts), so we skip registering v1 stubs for it here. 960 - if (!skip || !skip.has('editor')) { 961 - const editorEvents = ['editor:open', 'editor:add']; 962 - 963 - for (const topic of editorEvents) { 964 - const interceptorSource = `lazy-interceptor/${topic}`; 965 - 966 - subscribe(interceptorSource, topic, async (msg: unknown) => { 967 - // If editor is already loaded, nothing to do — the extension's own handler 968 - // will fire from the same publish (pubsub iterates all subscribers). 969 - if (lazyExtensionLoaded.has('editor')) return; 970 - 971 - // Buffer the message (latest wins for concurrent events) 972 - const alreadyLoading = lazyInterceptorPending.has(topic); 973 - lazyInterceptorPending.set(topic, msg); 974 - 975 - if (alreadyLoading) { 976 - DEBUG && console.log(`[ext:lazy-interceptor] ${topic} fired again during load, buffering`); 977 - return; 978 - } 979 - 980 - DEBUG && console.log(`[ext:lazy-interceptor] ${topic} fired but editor not loaded, loading...`); 981 - await loadLazyExtension('editor'); 982 - 983 - // Unsubscribe and replay buffered message exactly once 984 - unsubscribe(interceptorSource, topic); 985 - const bufferedMsg = lazyInterceptorPending.get(topic); 986 - lazyInterceptorPending.delete(topic); 987 - DEBUG && console.log(`[ext:lazy-interceptor] Editor loaded, re-publishing ${topic}`); 988 - publish(interceptorSource, topic, bufferedMsg); 989 - }); 990 - } 991 - 992 - DEBUG && console.log('[ext:lazy-interceptor] Registered editor event interceptors'); 993 - } else { 994 - DEBUG && console.log('[ext:lazy-interceptor] Editor is v2 — skipping legacy interceptor'); 995 - } 996 - } 997 - 998 - /** 999 768 * Required CSS variables that every theme must define. 1000 769 * Used by validateThemeCSS() to verify theme completeness. 1001 770 */ ··· 1123 892 const v1FeatureIds = new Set<string>(); 1124 893 const v2FeatureIds = new Set<string>(); 1125 894 1126 - // Subscribe to ext:ready — extensions publish this after registering their handlers. 1127 - subscribe(getSystemAddress(), 'ext:ready', (data: unknown) => { 1128 - const extId = (data as { id?: string })?.id; 1129 - const registeredTopics = (data as { registeredTopics?: string[] })?.registeredTopics; 1130 - if (extId) { 1131 - handleLazyExtensionReady(extId, registeredTopics); 1132 - } 1133 - }); 1134 - 1135 895 // cmd, hud, and page's command/shortcut wiring all run inside the 1136 896 // core background renderer (app/index.js awaits initCmd(), initHud(), 1137 897 // and initPage() at the top of its init). initCore — called earlier ··· 1160 920 appDataDir: config.userDataPath, 1161 921 builtinFeaturesDir: featuresDir, 1162 922 tilePreloadPath, 1163 - eagerIds: EAGER_EXTENSION_IDS, 923 + eagerIds: EAGER_TILE_IDS, 1164 924 onV1Feature: (id: string, _featurePath: string, _manifest: unknown) => { 1165 925 // Collect v1 feature IDs — they'll be loaded via the legacy path below 1166 926 v1FeatureIds.add(id); ··· 1187 947 // Register declarative commands/shortcuts from manifests (after cmd is ready) 1188 948 registerDeclarativeCommands(); 1189 949 1190 - // Register lazy extension commands from manifests (after cmd is ready) 1191 - registerLazyExtensionCommands(); 1192 - 1193 - // Register event interceptors for lazy extensions (e.g. editor:open). 1194 - // V2 features get their event interceptors from tile-lazy (via manifest 1195 - // `lazyEvents`), so skip them here to avoid duplicate registration that 1196 - // would try to load the v2 tile through the v1 iframe path. 1197 - registerLazyEventInterceptors({ skipExtensions: v2FeatureIds }); 950 + // Lazy command stubs and lazy event interceptors live in `tile-lazy.ts` 951 + // now — `registerLazyTile()` (called from tile-compat) publishes 952 + // `cmd:register-batch` and the pre-publish dispatch hook handles 953 + // load-on-dispatch for both `cmd:execute:*` and declared `lazyEvents`. 1198 954 1199 955 DEBUG && console.log(`[ext:timing] hybrid total: ${Date.now() - extStart}ms`); 1200 956