@forgeax/interface 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@
15
15
  // the extension's cleanup, so a pure-UI extension needs no setup at all.
16
16
  import { createAppHost, } from './core/app-shell';
17
17
  import { createExtensionLoader } from './core/extension-foundation/loader';
18
+ import { ExtensionCleanupDeferredError, ExtensionSetupRollbackDeferredError } from '@forgeax/extension-platform/extensions';
18
19
  import { foundationCommandsExtension } from './core/extensions/foundation-commands';
19
20
  import { foundationBusExtension } from './core/extensions/foundation-bus';
20
21
  import { foundationStorageExtension } from './core/extensions/foundation-storage';
@@ -63,6 +64,12 @@ export async function bootstrapAppHost(overrides = {}) {
63
64
  control.beginSetup(m);
64
65
  const cleanups = [];
65
66
  let pageCleanup;
67
+ const rollback = async () => {
68
+ // Page preparation is the barrier before any destructive cleanup.
69
+ await pageCleanup?.();
70
+ for (const cleanup of cleanups.slice().reverse())
71
+ await cleanup();
72
+ };
66
73
  try {
67
74
  for (const menu of m.contributes?.menus ?? []) {
68
75
  cleanups.push(host.menus.register(menu));
@@ -89,20 +96,18 @@ export async function bootstrapAppHost(overrides = {}) {
89
96
  cleanups.push(r);
90
97
  if (cleanups.length === 0 && !pageCleanup)
91
98
  return undefined;
92
- return async () => {
93
- // Controllers get the first say. If one vetoes/needs a user decision,
94
- // no declarative or imperative contribution is torn down underneath it.
95
- await pageCleanup?.();
96
- for (const c of cleanups.slice().reverse())
97
- await c();
98
- };
99
+ return rollback;
99
100
  }
100
101
  catch (error) {
101
- // Preserve the same page-close decision boundary on failed setup.
102
- // A dirty page must not lose its surrounding contributions underneath it.
103
- await pageCleanup?.();
104
- for (const cleanup of cleanups.slice().reverse())
105
- await cleanup();
102
+ try {
103
+ await rollback();
104
+ }
105
+ catch (rollbackError) {
106
+ if (rollbackError instanceof ExtensionCleanupDeferredError) {
107
+ throw new ExtensionSetupRollbackDeferredError({ cause: error, rollback });
108
+ }
109
+ throw rollbackError;
110
+ }
106
111
  throw error;
107
112
  }
108
113
  finally {
@@ -4,6 +4,7 @@ import { listExtensions, listExtensionsShared, } from '../../lib/extension-api';
4
4
  import { ExtensionHostPanel } from '../../components/DockShell/ExtensionHostPanel';
5
5
  import { isExtensionHostExtension } from '../../components/DockShell/extensionRuntime';
6
6
  import { getLocale } from '../../i18n';
7
+ import { ExtensionCleanupDeferredError } from '@forgeax/extension-platform/extensions';
7
8
  function title(value) {
8
9
  if (typeof value === 'string')
9
10
  return value;
@@ -162,22 +163,42 @@ export function createCatalogPageExtensionRuntime(options) {
162
163
  let events;
163
164
  let chain = Promise.resolve();
164
165
  let disposed = false;
166
+ const reportError = (error, id, phase) => {
167
+ try {
168
+ onError(error, id, phase);
169
+ }
170
+ catch { /* Isolate the diagnostic sink. */ }
171
+ };
172
+ const retire = async (id, record) => {
173
+ try {
174
+ await record.cleanup();
175
+ }
176
+ catch (error) {
177
+ reportError(error, id, 'cleanup');
178
+ if (error instanceof ExtensionCleanupDeferredError)
179
+ throw error;
180
+ // Ordinary teardown may already be destructive; never retry it.
181
+ }
182
+ active.delete(id);
183
+ generation = undefined;
184
+ };
165
185
  const reconcile = async (response) => {
166
186
  if (generation !== undefined && response.generation === generation)
167
187
  return;
168
188
  const itemsById = new Map(response.items.map((item) => [item.id, item]));
169
189
  const next = new Map(catalogPageExtensions(response.items, options.overriddenIds)
170
190
  .map((extension) => [extension.id, extension]));
171
- for (const [id, record] of [...active]) {
191
+ for (const [id, record] of [...active].reverse()) {
172
192
  const item = itemsById.get(id);
173
193
  const signature = catalogItemSignature(item);
174
194
  if (!next.has(id) || signature !== record.signature) {
175
195
  try {
176
- await record.cleanup();
177
- active.delete(id);
196
+ await retire(id, record);
178
197
  }
179
198
  catch (error) {
180
- onError(error, id, 'cleanup');
199
+ // Keep the barrier and allow a later refresh of this same generation.
200
+ generation = undefined;
201
+ return;
181
202
  }
182
203
  }
183
204
  }
@@ -195,7 +216,7 @@ export function createCatalogPageExtensionRuntime(options) {
195
216
  active.set(id, { signature: catalogItemSignature(itemsById.get(id)), cleanup });
196
217
  }
197
218
  catch (error) {
198
- onError(error, id, 'register');
219
+ reportError(error, id, 'register');
199
220
  }
200
221
  }
201
222
  generation = response.generation;
@@ -221,14 +242,17 @@ export function createCatalogPageExtensionRuntime(options) {
221
242
  }
222
243
  }
223
244
  },
224
- async dispose() {
225
- disposed = true;
226
- events?.close();
227
- events = undefined;
228
- await chain.catch(() => undefined);
229
- for (const record of [...active.values()].reverse())
230
- await record.cleanup();
231
- active.clear();
245
+ dispose() {
246
+ chain = chain.catch(() => undefined).then(async () => {
247
+ if (disposed)
248
+ return;
249
+ for (const [id, record] of [...active].reverse())
250
+ await retire(id, record);
251
+ disposed = true;
252
+ events?.close();
253
+ events = undefined;
254
+ });
255
+ return chain;
232
256
  },
233
257
  };
234
258
  }
@@ -7,13 +7,14 @@ import { createCapabilityRegistry } from '../extension-foundation/capabilities';
7
7
  import { createContributionRegistry } from '../extension-foundation/contribution-registry';
8
8
  import { ExtensionConflictError } from '../extension-foundation/errors';
9
9
  import { createPanelActionRegistry, createPanelControlRegistry } from '../panels';
10
- import { createPageRegistry, createPageSession, createActivityRegistry, createResourceEditorResolver, } from '../page-platform';
10
+ import { createPageRegistry, createPageSession, PageClosePreparationDeferredError, createActivityRegistry, createResourceEditorResolver, } from '../page-platform';
11
11
  import { consoleLogger } from './logger';
12
12
  import { derivePanelRenderers } from './derive-panel-renderers';
13
13
  import { DEFAULT_PANEL_RENDERERS } from '../../components/DockShell/panelRenderers';
14
14
  import { installPageNavigation } from '../page-navigation';
15
15
  import { createContextualKeybindings } from '../contextual-keybindings';
16
16
  import { createApplicationShortcutRegistry, createApplicationMenuRegistry } from '@forgeax/app-shell/application';
17
+ import { ExtensionCleanupDeferredError } from '@forgeax/extension-platform/extensions';
17
18
  const BUILT_IN_CAPS = [
18
19
  'commands', 'keybindings', 'shortcuts', 'menus', 'bus', 'storage', 'panels', 'panelActions', 'panelControls', 'contextKeys', 'pages',
19
20
  'activities', 'resourceEditors',
@@ -158,7 +159,15 @@ export function createAppHost(deps = {}) {
158
159
  return;
159
160
  // The registry still knows the owner while controllers preflight close.
160
161
  // A dirty/vetoed page rejects cleanup and leaves the contribution live.
161
- await pageSession.closeOwnedBy(owner);
162
+ try {
163
+ await pageSession.closeOwnedBy(owner);
164
+ }
165
+ catch (error) {
166
+ if (error instanceof PageClosePreparationDeferredError) {
167
+ throw new ExtensionCleanupDeferredError(error.message);
168
+ }
169
+ throw error;
170
+ }
162
171
  removeContribution();
163
172
  removed = true;
164
173
  };
@@ -181,8 +190,8 @@ export function createAppHost(deps = {}) {
181
190
  }
182
191
  },
183
192
  async dispose() {
184
- removePageNavigation();
185
193
  await pageSession.dispose();
194
+ removePageNavigation();
186
195
  keybindings.dispose();
187
196
  shortcuts.dispose();
188
197
  menus.dispose();
@@ -1,4 +1,9 @@
1
1
  import type { PageCloseDecision, PageCloseReason, PagePort, PageRegistry } from './types';
2
+ import { PagePlatformError } from './types';
3
+ /** Explicit close decision barrier, emitted before any controller teardown. */
4
+ export declare class PageClosePreparationDeferredError extends PagePlatformError {
5
+ constructor(code: 'PAGE_CLOSE_VETOED' | 'PAGE_CLOSE_REQUIRES_DECISION', message: string, details?: Readonly<Record<string, unknown>>);
6
+ }
2
7
  export interface PageSession extends PagePort {
3
8
  closeOwnedBy(owner: string, reason?: PageCloseReason, decision?: PageCloseDecision): Promise<void>;
4
9
  dispose(): Promise<void>;
@@ -1,6 +1,13 @@
1
1
  import { decodePageKey, encodePageKey, } from '@forgeax/types';
2
2
  import { PagePlatformError } from './types';
3
3
  import { getCurrentProject, subscribeCurrentProject } from '../../lib/project-context';
4
+ /** Explicit close decision barrier, emitted before any controller teardown. */
5
+ export class PageClosePreparationDeferredError extends PagePlatformError {
6
+ constructor(code, message, details) {
7
+ super(code, message, details);
8
+ this.name = 'PageClosePreparationDeferredError';
9
+ }
10
+ }
4
11
  const inertController = {
5
12
  prepareClose: () => ({ status: 'ready' }),
6
13
  dispose: () => undefined,
@@ -103,17 +110,17 @@ export function createPageSession(registry, options = {}) {
103
110
  if (preparation.status === 'ready')
104
111
  return;
105
112
  if (preparation.status === 'vetoed') {
106
- throw new PagePlatformError('PAGE_CLOSE_VETOED', preparation.message ?? 'page refused to close', {
113
+ throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', preparation.message ?? 'page refused to close', {
107
114
  key: entry.instance.encodedKey,
108
115
  reason,
109
116
  });
110
117
  }
111
118
  if (!decision || decision === 'cancel') {
112
- throw new PagePlatformError('PAGE_CLOSE_REQUIRES_DECISION', preparation.message ?? 'page has unsaved changes', { key: entry.instance.encodedKey, reason });
119
+ throw new PageClosePreparationDeferredError('PAGE_CLOSE_REQUIRES_DECISION', preparation.message ?? 'page has unsaved changes', { key: entry.instance.encodedKey, reason });
113
120
  }
114
121
  if (decision === 'save') {
115
122
  if (!entry.controller.save) {
116
- throw new PagePlatformError('PAGE_CLOSE_VETOED', 'page cannot save its dirty state', {
123
+ throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', 'page cannot save its dirty state', {
117
124
  key: entry.instance.encodedKey,
118
125
  reason,
119
126
  });
@@ -122,7 +129,7 @@ export function createPageSession(registry, options = {}) {
122
129
  }
123
130
  else {
124
131
  if (!entry.controller.discard) {
125
- throw new PagePlatformError('PAGE_CLOSE_VETOED', 'page cannot discard its dirty state', {
132
+ throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', 'page cannot discard its dirty state', {
126
133
  key: entry.instance.encodedKey,
127
134
  reason,
128
135
  });
@@ -1,6 +1,4 @@
1
- import type { Cleanup } from '../extension-foundation/types';
2
- import type { PanelActionContribution, PanelActionsApi } from './types';
3
- export interface PanelActionRegistry extends PanelActionsApi {
4
- contribute(owner: string, actions: readonly PanelActionContribution[]): Cleanup;
5
- }
6
- export declare function createPanelActionRegistry(): PanelActionRegistry;
1
+ import { type PanelActionRegistry as Registry } from '@forgeax/app-shell/application';
2
+ import type { PanelActionContribution } from './types';
3
+ export type PanelActionRegistry = Registry<PanelActionContribution>;
4
+ export declare const createPanelActionRegistry: () => Registry<PanelActionContribution>;
@@ -1,62 +1,2 @@
1
- export function createPanelActionRegistry() {
2
- const entries = [];
3
- const listeners = new Set();
4
- let cache = null;
5
- let version = 0;
6
- const emit = () => {
7
- cache = null;
8
- version++;
9
- for (const listener of [...listeners])
10
- listener();
11
- };
12
- // Batch-deferred notification: data mutations (push/splice) happen
13
- // immediately so reads (list/all) always return fresh data, but listener
14
- // notifications are deferred to a microtask. This avoids triggering
15
- // forceStoreRerender (useSyncExternalStore) during React 19's commit
16
- // phase, which otherwise causes "Maximum update depth exceeded".
17
- let emitScheduled = false;
18
- const scheduleEmit = () => {
19
- cache = null;
20
- if (emitScheduled)
21
- return;
22
- emitScheduled = true;
23
- queueMicrotask(() => {
24
- emitScheduled = false;
25
- emit();
26
- });
27
- };
28
- const all = () => {
29
- if (cache)
30
- return cache;
31
- cache = entries.flatMap((entry) => entry.actions).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
32
- return cache;
33
- };
34
- return {
35
- contribute(owner, actions) {
36
- const entry = { owner, actions };
37
- entries.push(entry);
38
- scheduleEmit();
39
- let removed = false;
40
- return () => {
41
- if (removed)
42
- return;
43
- removed = true;
44
- const index = entries.indexOf(entry);
45
- if (index >= 0)
46
- entries.splice(index, 1);
47
- scheduleEmit();
48
- };
49
- },
50
- list(panelId) {
51
- return all().filter((action) => action.panelId === panelId);
52
- },
53
- all,
54
- onChange(listener) {
55
- listeners.add(listener);
56
- return () => { listeners.delete(listener); };
57
- },
58
- version() {
59
- return version;
60
- },
61
- };
62
- }
1
+ import { createPanelActionRegistry as createRegistry, } from '@forgeax/app-shell/application';
2
+ export const createPanelActionRegistry = (createRegistry);
@@ -1,6 +1,4 @@
1
- import type { Cleanup } from '../extension-foundation/types';
2
- import type { PanelControlContribution, PanelControlsApi } from './types';
3
- export interface PanelControlRegistry extends PanelControlsApi {
4
- contribute(owner: string, controls: readonly PanelControlContribution[]): Cleanup;
5
- }
6
- export declare function createPanelControlRegistry(): PanelControlRegistry;
1
+ import { type PanelControlRegistry as Registry } from '@forgeax/app-shell/application';
2
+ import type { PanelControlContribution } from './types';
3
+ export type PanelControlRegistry = Registry<PanelControlContribution>;
4
+ export declare const createPanelControlRegistry: () => Registry<PanelControlContribution>;
@@ -1,54 +1,2 @@
1
- export function createPanelControlRegistry() {
2
- const entries = [];
3
- const listeners = new Set();
4
- let version = 0;
5
- const emit = () => {
6
- version++;
7
- for (const listener of [...listeners])
8
- listener();
9
- };
10
- // Batch-deferred notification: data mutations (push/splice) happen
11
- // immediately so reads (list/get) always return fresh data, but listener
12
- // notifications are deferred to a microtask. This avoids triggering
13
- // forceStoreRerender (useSyncExternalStore) during React 19's commit
14
- // phase, which otherwise causes "Maximum update depth exceeded".
15
- let emitScheduled = false;
16
- const scheduleEmit = () => {
17
- if (emitScheduled)
18
- return;
19
- emitScheduled = true;
20
- queueMicrotask(() => {
21
- emitScheduled = false;
22
- emit();
23
- });
24
- };
25
- const list = () => entries.flatMap((entry) => entry.controls);
26
- return {
27
- contribute(owner, controls) {
28
- const entry = { owner, controls };
29
- entries.push(entry);
30
- scheduleEmit();
31
- let removed = false;
32
- return () => {
33
- if (removed)
34
- return;
35
- removed = true;
36
- const index = entries.indexOf(entry);
37
- if (index >= 0)
38
- entries.splice(index, 1);
39
- scheduleEmit();
40
- };
41
- },
42
- get(id) {
43
- return list().find((control) => control.id === id);
44
- },
45
- list,
46
- onChange(listener) {
47
- listeners.add(listener);
48
- return () => { listeners.delete(listener); };
49
- },
50
- version() {
51
- return version;
52
- },
53
- };
54
- }
1
+ import { createPanelControlRegistry as createRegistry, } from '@forgeax/app-shell/application';
2
+ export const createPanelControlRegistry = (createRegistry);
@@ -15,6 +15,7 @@
15
15
  // the extension's cleanup, so a pure-UI extension needs no setup at all.
16
16
  import { createAppHost, } from './core/app-shell/index.js';
17
17
  import { createExtensionLoader } from './core/extension-foundation/loader.js';
18
+ import { ExtensionCleanupDeferredError, ExtensionSetupRollbackDeferredError } from '@forgeax/extension-platform/extensions';
18
19
  import { foundationCommandsExtension } from './core/extensions/foundation-commands.js';
19
20
  import { foundationBusExtension } from './core/extensions/foundation-bus.js';
20
21
  import { foundationStorageExtension } from './core/extensions/foundation-storage.js';
@@ -63,6 +64,12 @@ export async function bootstrapAppHost(overrides = {}) {
63
64
  control.beginSetup(m);
64
65
  const cleanups = [];
65
66
  let pageCleanup;
67
+ const rollback = async () => {
68
+ // Page preparation is the barrier before any destructive cleanup.
69
+ await pageCleanup?.();
70
+ for (const cleanup of cleanups.slice().reverse())
71
+ await cleanup();
72
+ };
66
73
  try {
67
74
  for (const menu of m.contributes?.menus ?? []) {
68
75
  cleanups.push(host.menus.register(menu));
@@ -89,20 +96,18 @@ export async function bootstrapAppHost(overrides = {}) {
89
96
  cleanups.push(r);
90
97
  if (cleanups.length === 0 && !pageCleanup)
91
98
  return undefined;
92
- return async () => {
93
- // Controllers get the first say. If one vetoes/needs a user decision,
94
- // no declarative or imperative contribution is torn down underneath it.
95
- await pageCleanup?.();
96
- for (const c of cleanups.slice().reverse())
97
- await c();
98
- };
99
+ return rollback;
99
100
  }
100
101
  catch (error) {
101
- // Preserve the same page-close decision boundary on failed setup.
102
- // A dirty page must not lose its surrounding contributions underneath it.
103
- await pageCleanup?.();
104
- for (const cleanup of cleanups.slice().reverse())
105
- await cleanup();
102
+ try {
103
+ await rollback();
104
+ }
105
+ catch (rollbackError) {
106
+ if (rollbackError instanceof ExtensionCleanupDeferredError) {
107
+ throw new ExtensionSetupRollbackDeferredError({ cause: error, rollback });
108
+ }
109
+ throw rollbackError;
110
+ }
106
111
  throw error;
107
112
  }
108
113
  finally {
@@ -4,6 +4,7 @@ import { listExtensions, listExtensionsShared, } from '../../lib/extension-api.j
4
4
  import { ExtensionHostPanel } from '../../components/DockShell/ExtensionHostPanel.js';
5
5
  import { isExtensionHostExtension } from '../../components/DockShell/extensionRuntime.js';
6
6
  import { getLocale } from '../../i18n/index.js';
7
+ import { ExtensionCleanupDeferredError } from '@forgeax/extension-platform/extensions';
7
8
  function title(value) {
8
9
  if (typeof value === 'string')
9
10
  return value;
@@ -162,22 +163,42 @@ export function createCatalogPageExtensionRuntime(options) {
162
163
  let events;
163
164
  let chain = Promise.resolve();
164
165
  let disposed = false;
166
+ const reportError = (error, id, phase) => {
167
+ try {
168
+ onError(error, id, phase);
169
+ }
170
+ catch { /* Isolate the diagnostic sink. */ }
171
+ };
172
+ const retire = async (id, record) => {
173
+ try {
174
+ await record.cleanup();
175
+ }
176
+ catch (error) {
177
+ reportError(error, id, 'cleanup');
178
+ if (error instanceof ExtensionCleanupDeferredError)
179
+ throw error;
180
+ // Ordinary teardown may already be destructive; never retry it.
181
+ }
182
+ active.delete(id);
183
+ generation = undefined;
184
+ };
165
185
  const reconcile = async (response) => {
166
186
  if (generation !== undefined && response.generation === generation)
167
187
  return;
168
188
  const itemsById = new Map(response.items.map((item) => [item.id, item]));
169
189
  const next = new Map(catalogPageExtensions(response.items, options.overriddenIds)
170
190
  .map((extension) => [extension.id, extension]));
171
- for (const [id, record] of [...active]) {
191
+ for (const [id, record] of [...active].reverse()) {
172
192
  const item = itemsById.get(id);
173
193
  const signature = catalogItemSignature(item);
174
194
  if (!next.has(id) || signature !== record.signature) {
175
195
  try {
176
- await record.cleanup();
177
- active.delete(id);
196
+ await retire(id, record);
178
197
  }
179
198
  catch (error) {
180
- onError(error, id, 'cleanup');
199
+ // Keep the barrier and allow a later refresh of this same generation.
200
+ generation = undefined;
201
+ return;
181
202
  }
182
203
  }
183
204
  }
@@ -195,7 +216,7 @@ export function createCatalogPageExtensionRuntime(options) {
195
216
  active.set(id, { signature: catalogItemSignature(itemsById.get(id)), cleanup });
196
217
  }
197
218
  catch (error) {
198
- onError(error, id, 'register');
219
+ reportError(error, id, 'register');
199
220
  }
200
221
  }
201
222
  generation = response.generation;
@@ -221,14 +242,17 @@ export function createCatalogPageExtensionRuntime(options) {
221
242
  }
222
243
  }
223
244
  },
224
- async dispose() {
225
- disposed = true;
226
- events?.close();
227
- events = undefined;
228
- await chain.catch(() => undefined);
229
- for (const record of [...active.values()].reverse())
230
- await record.cleanup();
231
- active.clear();
245
+ dispose() {
246
+ chain = chain.catch(() => undefined).then(async () => {
247
+ if (disposed)
248
+ return;
249
+ for (const [id, record] of [...active].reverse())
250
+ await retire(id, record);
251
+ disposed = true;
252
+ events?.close();
253
+ events = undefined;
254
+ });
255
+ return chain;
232
256
  },
233
257
  };
234
258
  }
@@ -7,13 +7,14 @@ import { createCapabilityRegistry } from '../extension-foundation/capabilities.j
7
7
  import { createContributionRegistry } from '../extension-foundation/contribution-registry.js';
8
8
  import { ExtensionConflictError } from '../extension-foundation/errors.js';
9
9
  import { createPanelActionRegistry, createPanelControlRegistry } from '../panels/index.js';
10
- import { createPageRegistry, createPageSession, createActivityRegistry, createResourceEditorResolver, } from '../page-platform/index.js';
10
+ import { createPageRegistry, createPageSession, PageClosePreparationDeferredError, createActivityRegistry, createResourceEditorResolver, } from '../page-platform/index.js';
11
11
  import { consoleLogger } from './logger.js';
12
12
  import { derivePanelRenderers } from './derive-panel-renderers.js';
13
13
  import { DEFAULT_PANEL_RENDERERS } from '../../components/DockShell/panelRenderers.js';
14
14
  import { installPageNavigation } from '../page-navigation.js';
15
15
  import { createContextualKeybindings } from '../contextual-keybindings.js';
16
16
  import { createApplicationShortcutRegistry, createApplicationMenuRegistry } from '@forgeax/app-shell/application';
17
+ import { ExtensionCleanupDeferredError } from '@forgeax/extension-platform/extensions';
17
18
  const BUILT_IN_CAPS = [
18
19
  'commands', 'keybindings', 'shortcuts', 'menus', 'bus', 'storage', 'panels', 'panelActions', 'panelControls', 'contextKeys', 'pages',
19
20
  'activities', 'resourceEditors',
@@ -158,7 +159,15 @@ export function createAppHost(deps = {}) {
158
159
  return;
159
160
  // The registry still knows the owner while controllers preflight close.
160
161
  // A dirty/vetoed page rejects cleanup and leaves the contribution live.
161
- await pageSession.closeOwnedBy(owner);
162
+ try {
163
+ await pageSession.closeOwnedBy(owner);
164
+ }
165
+ catch (error) {
166
+ if (error instanceof PageClosePreparationDeferredError) {
167
+ throw new ExtensionCleanupDeferredError(error.message);
168
+ }
169
+ throw error;
170
+ }
162
171
  removeContribution();
163
172
  removed = true;
164
173
  };
@@ -181,8 +190,8 @@ export function createAppHost(deps = {}) {
181
190
  }
182
191
  },
183
192
  async dispose() {
184
- removePageNavigation();
185
193
  await pageSession.dispose();
194
+ removePageNavigation();
186
195
  keybindings.dispose();
187
196
  shortcuts.dispose();
188
197
  menus.dispose();
@@ -1,6 +1,13 @@
1
1
  import { decodePageKey, encodePageKey, } from '@forgeax/types';
2
2
  import { PagePlatformError } from './types.js';
3
3
  import { getCurrentProject, subscribeCurrentProject } from '../../lib/project-context.js';
4
+ /** Explicit close decision barrier, emitted before any controller teardown. */
5
+ export class PageClosePreparationDeferredError extends PagePlatformError {
6
+ constructor(code, message, details) {
7
+ super(code, message, details);
8
+ this.name = 'PageClosePreparationDeferredError';
9
+ }
10
+ }
4
11
  const inertController = {
5
12
  prepareClose: () => ({ status: 'ready' }),
6
13
  dispose: () => undefined,
@@ -103,17 +110,17 @@ export function createPageSession(registry, options = {}) {
103
110
  if (preparation.status === 'ready')
104
111
  return;
105
112
  if (preparation.status === 'vetoed') {
106
- throw new PagePlatformError('PAGE_CLOSE_VETOED', preparation.message ?? 'page refused to close', {
113
+ throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', preparation.message ?? 'page refused to close', {
107
114
  key: entry.instance.encodedKey,
108
115
  reason,
109
116
  });
110
117
  }
111
118
  if (!decision || decision === 'cancel') {
112
- throw new PagePlatformError('PAGE_CLOSE_REQUIRES_DECISION', preparation.message ?? 'page has unsaved changes', { key: entry.instance.encodedKey, reason });
119
+ throw new PageClosePreparationDeferredError('PAGE_CLOSE_REQUIRES_DECISION', preparation.message ?? 'page has unsaved changes', { key: entry.instance.encodedKey, reason });
113
120
  }
114
121
  if (decision === 'save') {
115
122
  if (!entry.controller.save) {
116
- throw new PagePlatformError('PAGE_CLOSE_VETOED', 'page cannot save its dirty state', {
123
+ throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', 'page cannot save its dirty state', {
117
124
  key: entry.instance.encodedKey,
118
125
  reason,
119
126
  });
@@ -122,7 +129,7 @@ export function createPageSession(registry, options = {}) {
122
129
  }
123
130
  else {
124
131
  if (!entry.controller.discard) {
125
- throw new PagePlatformError('PAGE_CLOSE_VETOED', 'page cannot discard its dirty state', {
132
+ throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', 'page cannot discard its dirty state', {
126
133
  key: entry.instance.encodedKey,
127
134
  reason,
128
135
  });
@@ -1,62 +1,2 @@
1
- export function createPanelActionRegistry() {
2
- const entries = [];
3
- const listeners = new Set();
4
- let cache = null;
5
- let version = 0;
6
- const emit = () => {
7
- cache = null;
8
- version++;
9
- for (const listener of [...listeners])
10
- listener();
11
- };
12
- // Batch-deferred notification: data mutations (push/splice) happen
13
- // immediately so reads (list/all) always return fresh data, but listener
14
- // notifications are deferred to a microtask. This avoids triggering
15
- // forceStoreRerender (useSyncExternalStore) during React 19's commit
16
- // phase, which otherwise causes "Maximum update depth exceeded".
17
- let emitScheduled = false;
18
- const scheduleEmit = () => {
19
- cache = null;
20
- if (emitScheduled)
21
- return;
22
- emitScheduled = true;
23
- queueMicrotask(() => {
24
- emitScheduled = false;
25
- emit();
26
- });
27
- };
28
- const all = () => {
29
- if (cache)
30
- return cache;
31
- cache = entries.flatMap((entry) => entry.actions).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
32
- return cache;
33
- };
34
- return {
35
- contribute(owner, actions) {
36
- const entry = { owner, actions };
37
- entries.push(entry);
38
- scheduleEmit();
39
- let removed = false;
40
- return () => {
41
- if (removed)
42
- return;
43
- removed = true;
44
- const index = entries.indexOf(entry);
45
- if (index >= 0)
46
- entries.splice(index, 1);
47
- scheduleEmit();
48
- };
49
- },
50
- list(panelId) {
51
- return all().filter((action) => action.panelId === panelId);
52
- },
53
- all,
54
- onChange(listener) {
55
- listeners.add(listener);
56
- return () => { listeners.delete(listener); };
57
- },
58
- version() {
59
- return version;
60
- },
61
- };
62
- }
1
+ import { createPanelActionRegistry as createRegistry, } from '@forgeax/app-shell/application';
2
+ export const createPanelActionRegistry = (createRegistry);
@@ -1,54 +1,2 @@
1
- export function createPanelControlRegistry() {
2
- const entries = [];
3
- const listeners = new Set();
4
- let version = 0;
5
- const emit = () => {
6
- version++;
7
- for (const listener of [...listeners])
8
- listener();
9
- };
10
- // Batch-deferred notification: data mutations (push/splice) happen
11
- // immediately so reads (list/get) always return fresh data, but listener
12
- // notifications are deferred to a microtask. This avoids triggering
13
- // forceStoreRerender (useSyncExternalStore) during React 19's commit
14
- // phase, which otherwise causes "Maximum update depth exceeded".
15
- let emitScheduled = false;
16
- const scheduleEmit = () => {
17
- if (emitScheduled)
18
- return;
19
- emitScheduled = true;
20
- queueMicrotask(() => {
21
- emitScheduled = false;
22
- emit();
23
- });
24
- };
25
- const list = () => entries.flatMap((entry) => entry.controls);
26
- return {
27
- contribute(owner, controls) {
28
- const entry = { owner, controls };
29
- entries.push(entry);
30
- scheduleEmit();
31
- let removed = false;
32
- return () => {
33
- if (removed)
34
- return;
35
- removed = true;
36
- const index = entries.indexOf(entry);
37
- if (index >= 0)
38
- entries.splice(index, 1);
39
- scheduleEmit();
40
- };
41
- },
42
- get(id) {
43
- return list().find((control) => control.id === id);
44
- },
45
- list,
46
- onChange(listener) {
47
- listeners.add(listener);
48
- return () => { listeners.delete(listener); };
49
- },
50
- version() {
51
- return version;
52
- },
53
- };
54
- }
1
+ import { createPanelControlRegistry as createRegistry, } from '@forgeax/app-shell/application';
2
+ export const createPanelControlRegistry = (createRegistry);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/interface",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "description": "ForgeaX product interface shell and application composition boundary.",
6
6
  "license": "Apache-2.0",
@@ -92,9 +92,9 @@
92
92
  "lint:dep": "depcruise -c .dependency-cruiser.cjs src"
93
93
  },
94
94
  "dependencies": {
95
- "@forgeax/app-shell": "0.88.0",
95
+ "@forgeax/app-shell": "0.89.0",
96
96
  "@forgeax/extension-host": "0.3.2",
97
- "@forgeax/extension-platform": "0.5.0",
97
+ "@forgeax/extension-platform": "0.7.0",
98
98
  "@forgeax/toolkit": "0.1.2",
99
99
  "@forgeax/types": "^0.1.3",
100
100
  "@google/model-viewer": "^4.2.0",