sh3-core 0.11.8 → 0.12.0
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.
- package/dist/actions/listeners.js +17 -2
- package/dist/actions/listeners.test.js +46 -0
- package/dist/actions/scope-helpers.d.ts +17 -0
- package/dist/actions/scope-helpers.js +37 -0
- package/dist/actions/scope-helpers.test.js +33 -1
- package/dist/api.d.ts +2 -1
- package/dist/api.js +1 -1
- package/dist/app/store/InstalledView.svelte +2 -1
- package/dist/app/store/StoreView.svelte +2 -1
- package/dist/apps/lifecycle.d.ts +7 -0
- package/dist/apps/lifecycle.js +22 -5
- package/dist/apps/lifecycle.test.js +50 -0
- package/dist/host.js +18 -4
- package/dist/layout/LayoutRenderer.svelte +5 -1
- package/dist/layout/LayoutRenderer.test.js +42 -0
- package/dist/layout/SlotContainer.svelte +11 -2
- package/dist/layout/SlotContainer.svelte.d.ts +1 -0
- package/dist/layout/slotHostPool.svelte.js +10 -3
- package/dist/layout/slotHostPool.test.js +15 -0
- package/dist/shards/activate-error-isolation.test.d.ts +1 -0
- package/dist/shards/activate-error-isolation.test.js +98 -0
- package/dist/shards/activate.svelte.d.ts +30 -2
- package/dist/shards/activate.svelte.js +62 -17
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@ import { buildContextMenuModel, buildContextMenuSubmenu } from './contextMenuMod
|
|
|
13
13
|
import ActionPanel from './ActionPanel.svelte';
|
|
14
14
|
import CommandPalette from './CommandPalette.svelte';
|
|
15
15
|
import { buildPaletteCandidates } from './paletteModel';
|
|
16
|
+
import { parseScopeString } from './scope-helpers';
|
|
16
17
|
import { shell } from '../shellRuntime.svelte';
|
|
17
18
|
let attached = false;
|
|
18
19
|
function viewIdOfEl(el) {
|
|
@@ -23,10 +24,24 @@ function viewIdOfEl(el) {
|
|
|
23
24
|
return (_a = host === null || host === void 0 ? void 0 : host.getAttribute('data-sh3-view')) !== null && _a !== void 0 ? _a : null;
|
|
24
25
|
}
|
|
25
26
|
function resolveAnchor(args) {
|
|
27
|
+
var _a, _b;
|
|
26
28
|
if (args.explicit !== undefined)
|
|
27
29
|
return args.explicit;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
const target = (_a = args.event) === null || _a === void 0 ? void 0 : _a.target;
|
|
31
|
+
if (target instanceof Element) {
|
|
32
|
+
// Preferred path: data-sh3-scope carries the literal AtomicScope encoding
|
|
33
|
+
// (see ADR-021 amendment 2026-05-01). Walked first so a sub-region
|
|
34
|
+
// overrides its enclosing slot host's auto-stamped focus:<viewId>.
|
|
35
|
+
const scopeHost = target.closest('[data-sh3-scope]');
|
|
36
|
+
if (scopeHost) {
|
|
37
|
+
const parsed = parseScopeString((_b = scopeHost.getAttribute('data-sh3-scope')) !== null && _b !== void 0 ? _b : '');
|
|
38
|
+
if (parsed)
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
// Fallback: data-sh3-view alone still maps to focus:<viewId>. Defensive
|
|
42
|
+
// for stub views and external callers that haven't adopted the new
|
|
43
|
+
// attribute; framework-stamped slot hosts now carry both.
|
|
44
|
+
const viewId = viewIdOfEl(target);
|
|
30
45
|
if (viewId)
|
|
31
46
|
return `focus:${viewId}`;
|
|
32
47
|
}
|
|
@@ -149,6 +149,52 @@ describe('global contextmenu listener', () => {
|
|
|
149
149
|
expect(labels).toEqual(['App']);
|
|
150
150
|
target.remove();
|
|
151
151
|
});
|
|
152
|
+
it('right-click inside data-sh3-scope="element:..." anchors to that element atom', async () => {
|
|
153
|
+
setActiveApp('app.a', new Set(['shard.x']));
|
|
154
|
+
setMountedViewIds(new Set(['editor']));
|
|
155
|
+
registerAction({ id: 'el-only', label: 'El', scope: { element: 'svg-designer:layer' }, contextItem: true, run: () => { } }, 'shard.x');
|
|
156
|
+
registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
|
|
157
|
+
// Slot-host shape: framework auto-stamps both attributes. The inner div
|
|
158
|
+
// overrides scope only — viewId identity is unchanged. closest('[data-sh3-scope]')
|
|
159
|
+
// from the click target finds the inner div first.
|
|
160
|
+
const slotHost = document.createElement('div');
|
|
161
|
+
slotHost.setAttribute('data-sh3-view', 'editor');
|
|
162
|
+
slotHost.setAttribute('data-sh3-scope', 'focus:editor');
|
|
163
|
+
document.body.appendChild(slotHost);
|
|
164
|
+
const inner = document.createElement('div');
|
|
165
|
+
inner.setAttribute('data-sh3-scope', 'element:svg-designer:layer');
|
|
166
|
+
slotHost.appendChild(inner);
|
|
167
|
+
const target = document.createElement('button');
|
|
168
|
+
inner.appendChild(target);
|
|
169
|
+
const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
|
|
170
|
+
Object.defineProperty(ev, 'target', { value: target });
|
|
171
|
+
target.dispatchEvent(ev);
|
|
172
|
+
await Promise.resolve();
|
|
173
|
+
const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
|
|
174
|
+
.map((n) => n.textContent);
|
|
175
|
+
expect(labels).toEqual(['El']);
|
|
176
|
+
slotHost.remove();
|
|
177
|
+
});
|
|
178
|
+
it('right-click outside any data-sh3-scope override falls back to focus:<viewId>', async () => {
|
|
179
|
+
setActiveApp('app.a', new Set(['shard.x']));
|
|
180
|
+
setMountedViewIds(new Set(['editor']));
|
|
181
|
+
registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
|
|
182
|
+
registerAction({ id: 'el-only', label: 'El', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
|
|
183
|
+
const slotHost = document.createElement('div');
|
|
184
|
+
slotHost.setAttribute('data-sh3-view', 'editor');
|
|
185
|
+
slotHost.setAttribute('data-sh3-scope', 'focus:editor');
|
|
186
|
+
document.body.appendChild(slotHost);
|
|
187
|
+
const target = document.createElement('button');
|
|
188
|
+
slotHost.appendChild(target);
|
|
189
|
+
const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
|
|
190
|
+
Object.defineProperty(ev, 'target', { value: target });
|
|
191
|
+
target.dispatchEvent(ev);
|
|
192
|
+
await Promise.resolve();
|
|
193
|
+
const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
|
|
194
|
+
.map((n) => n.textContent);
|
|
195
|
+
expect(labels).toEqual(['View']);
|
|
196
|
+
slotHost.remove();
|
|
197
|
+
});
|
|
152
198
|
it('openContextMenu({scope}) uses the explicit anchor', async () => {
|
|
153
199
|
registerAction({ id: 'cell.copy', label: 'Copy Cell', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
|
|
154
200
|
registerAction({ id: 'home.x', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
|
|
@@ -15,3 +15,20 @@ export declare function innermostActiveScope(scope: ActionScope, state: Dispatch
|
|
|
15
15
|
* atom is never equal to an element atom.
|
|
16
16
|
*/
|
|
17
17
|
export declare function scopeEquals(a: AtomicScope, b: AtomicScope): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Canonical string encoding of an `AtomicScope` for transport over surfaces
|
|
20
|
+
* that can only carry strings — most notably `data-sh3-scope` DOM attribute
|
|
21
|
+
* values. String atoms (`home`, `app`, `view:<id>`, `focus:<id>`) pass
|
|
22
|
+
* through unchanged; element atoms encode as `element:<type>`. The element
|
|
23
|
+
* type itself may contain colons (e.g. `shard:type`) — only the leading
|
|
24
|
+
* `element:` is reserved by the encoding.
|
|
25
|
+
*/
|
|
26
|
+
export declare function scopeToString(scope: AtomicScope): string;
|
|
27
|
+
/**
|
|
28
|
+
* Inverse of `scopeToString`. Returns `null` for inputs that do not parse to
|
|
29
|
+
* a valid `AtomicScope` so callers (e.g. the contextmenu listener reading
|
|
30
|
+
* `data-sh3-scope` from arbitrary DOM) can fall back gracefully without
|
|
31
|
+
* throwing on a malformed attribute. Empty bodies after a known prefix
|
|
32
|
+
* (`view:`, `focus:`, `element:`) also return null.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseScopeString(s: string): AtomicScope | null;
|
|
@@ -59,3 +59,40 @@ export function scopeEquals(a, b) {
|
|
|
59
59
|
return a === b;
|
|
60
60
|
return a.element === b.element;
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Canonical string encoding of an `AtomicScope` for transport over surfaces
|
|
64
|
+
* that can only carry strings — most notably `data-sh3-scope` DOM attribute
|
|
65
|
+
* values. String atoms (`home`, `app`, `view:<id>`, `focus:<id>`) pass
|
|
66
|
+
* through unchanged; element atoms encode as `element:<type>`. The element
|
|
67
|
+
* type itself may contain colons (e.g. `shard:type`) — only the leading
|
|
68
|
+
* `element:` is reserved by the encoding.
|
|
69
|
+
*/
|
|
70
|
+
export function scopeToString(scope) {
|
|
71
|
+
if (typeof scope === 'string')
|
|
72
|
+
return scope;
|
|
73
|
+
return `element:${scope.element}`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Inverse of `scopeToString`. Returns `null` for inputs that do not parse to
|
|
77
|
+
* a valid `AtomicScope` so callers (e.g. the contextmenu listener reading
|
|
78
|
+
* `data-sh3-scope` from arbitrary DOM) can fall back gracefully without
|
|
79
|
+
* throwing on a malformed attribute. Empty bodies after a known prefix
|
|
80
|
+
* (`view:`, `focus:`, `element:`) also return null.
|
|
81
|
+
*/
|
|
82
|
+
export function parseScopeString(s) {
|
|
83
|
+
if (s === 'home' || s === 'app')
|
|
84
|
+
return s;
|
|
85
|
+
if (s.startsWith('view:')) {
|
|
86
|
+
const rest = s.slice('view:'.length);
|
|
87
|
+
return rest.length > 0 ? s : null;
|
|
88
|
+
}
|
|
89
|
+
if (s.startsWith('focus:')) {
|
|
90
|
+
const rest = s.slice('focus:'.length);
|
|
91
|
+
return rest.length > 0 ? s : null;
|
|
92
|
+
}
|
|
93
|
+
if (s.startsWith('element:')) {
|
|
94
|
+
const rest = s.slice('element:'.length);
|
|
95
|
+
return rest.length > 0 ? { element: rest } : null;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { scopeToTier, normalizeScope, scopeBadge, innermostActiveScope, scopeEquals, } from './scope-helpers';
|
|
2
|
+
import { scopeToTier, normalizeScope, scopeBadge, innermostActiveScope, scopeEquals, scopeToString, parseScopeString, } from './scope-helpers';
|
|
3
3
|
const mkState = (o = {}) => (Object.assign({ activeAppId: null, activeAppRequiredShards: new Set(), autostartShards: new Set(), mountedViewIds: new Set(), focusedViewId: null, selection: null, bindings: {}, platform: 'other' }, o));
|
|
4
4
|
describe('scopeToTier', () => {
|
|
5
5
|
it('maps atoms to tier names', () => {
|
|
@@ -83,3 +83,35 @@ describe('scopeEquals', () => {
|
|
|
83
83
|
expect(scopeEquals({ element: 'cell' }, 'home')).toBe(false);
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
|
+
describe('scopeToString / parseScopeString', () => {
|
|
87
|
+
const cases = [
|
|
88
|
+
'home',
|
|
89
|
+
'app',
|
|
90
|
+
'view:editor',
|
|
91
|
+
'focus:pane-1',
|
|
92
|
+
{ element: 'cell' },
|
|
93
|
+
// Element type containing a colon — common shape (e.g. shard:type).
|
|
94
|
+
{ element: 'svg-designer:layer' },
|
|
95
|
+
];
|
|
96
|
+
it('round-trips every AtomicScope kind', () => {
|
|
97
|
+
for (const s of cases) {
|
|
98
|
+
const parsed = parseScopeString(scopeToString(s));
|
|
99
|
+
expect(parsed).toEqual(s);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
it('encodes element atoms with the element: prefix', () => {
|
|
103
|
+
expect(scopeToString({ element: 'cell' })).toBe('element:cell');
|
|
104
|
+
expect(scopeToString({ element: 'svg-designer:layer' })).toBe('element:svg-designer:layer');
|
|
105
|
+
});
|
|
106
|
+
it('passes string atoms through unchanged', () => {
|
|
107
|
+
expect(scopeToString('home')).toBe('home');
|
|
108
|
+
expect(scopeToString('focus:pane-1')).toBe('focus:pane-1');
|
|
109
|
+
});
|
|
110
|
+
it('returns null on unknown / malformed inputs', () => {
|
|
111
|
+
expect(parseScopeString('')).toBeNull();
|
|
112
|
+
expect(parseScopeString('bogus')).toBeNull();
|
|
113
|
+
expect(parseScopeString('element:')).toBeNull();
|
|
114
|
+
expect(parseScopeString('view:')).toBeNull();
|
|
115
|
+
expect(parseScopeString('focus:')).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
package/dist/api.d.ts
CHANGED
|
@@ -27,7 +27,8 @@ export type { ConflictItem, ConflictBranch as ConflictManagerBranch, ResolveOpti
|
|
|
27
27
|
export { CONFLICT_RENDERER_POINT, ConflictPermissionError, ConflictSessionOrphanedError, } from './conflicts/api';
|
|
28
28
|
export type { ColorPickOptions, ColorContribution, ColorApi, } from './color/api';
|
|
29
29
|
export { COLOR_PICKER_POINT } from './color/api';
|
|
30
|
-
export { registeredShards, activeShards } from './shards/activate.svelte';
|
|
30
|
+
export { registeredShards, activeShards, erroredShards } from './shards/activate.svelte';
|
|
31
|
+
export type { ShardErrorEntry } from './shards/activate.svelte';
|
|
31
32
|
export type { RegistryIndex, PackageEntry, PackageVersion, RequiredDependency, InstalledPackage, InstallResult, PackageMeta, } from './registry/types';
|
|
32
33
|
export type { ResolvedPackage } from './registry/client';
|
|
33
34
|
export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
|
package/dist/api.js
CHANGED
|
@@ -38,7 +38,7 @@ export { COLOR_PICKER_POINT } from './color/api';
|
|
|
38
38
|
// and tooling shards that need to visualize framework state. Phase 9
|
|
39
39
|
// addition: diagnostic used to reach `activate.svelte` directly via $lib;
|
|
40
40
|
// the package boundary requires routing through the public surface.
|
|
41
|
-
export { registeredShards, activeShards } from './shards/activate.svelte';
|
|
41
|
+
export { registeredShards, activeShards, erroredShards } from './shards/activate.svelte';
|
|
42
42
|
export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
|
|
43
43
|
export { validateRegistryIndex } from './registry/schema';
|
|
44
44
|
// Key mint/revoke types — client shards that declare `keys:mint` get ctx.keys.
|
|
@@ -278,7 +278,8 @@
|
|
|
278
278
|
}
|
|
279
279
|
.installed-update-btn {
|
|
280
280
|
padding: 4px 12px;
|
|
281
|
-
background: var(--shell-warning, #
|
|
281
|
+
background: var(--shell-warning, #fbbf24);
|
|
282
|
+
color: var(--shell-fg-on-warning, #1a1b1e);
|
|
282
283
|
font-size: 0.8125rem;
|
|
283
284
|
}
|
|
284
285
|
.installed-update-btn:hover:not(:disabled) {
|
|
@@ -594,7 +594,8 @@
|
|
|
594
594
|
}
|
|
595
595
|
.store-update-btn {
|
|
596
596
|
padding: 5px 14px;
|
|
597
|
-
background: var(--shell-warning, #
|
|
597
|
+
background: var(--shell-warning, #fbbf24);
|
|
598
|
+
color: var(--shell-fg-on-warning, #1a1b1e);
|
|
598
599
|
font-size: 0.8125rem;
|
|
599
600
|
}
|
|
600
601
|
.store-update-btn:hover:not(:disabled) {
|
package/dist/apps/lifecycle.d.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
* returned to home or no app has ever been launched.
|
|
5
5
|
*/
|
|
6
6
|
export declare function readLastApp(): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Public reset for the last-active-app user-zone slot. Used by the host's
|
|
9
|
+
* boot sequence to recover from a sticky last-app launch failure: if the
|
|
10
|
+
* auto-launch fails, this clears the slot so the next reload lands on home
|
|
11
|
+
* instead of looping into the same failure.
|
|
12
|
+
*/
|
|
13
|
+
export declare function clearLastApp(): void;
|
|
7
14
|
/**
|
|
8
15
|
* Launch an app by id. Activates all required shards (idempotent for
|
|
9
16
|
* already-active shards), attaches the app's layout, calls `App.activate`,
|
package/dist/apps/lifecycle.js
CHANGED
|
@@ -20,6 +20,7 @@ import { PERMISSION_STATE_MANAGE } from '../state/types';
|
|
|
20
20
|
import { setActiveApp, setUserBindings } from '../actions/state.svelte';
|
|
21
21
|
import { clearSelectionUnconditional } from '../actions/selection.svelte';
|
|
22
22
|
import { loadUserBindings } from '../actions/bindings-store';
|
|
23
|
+
import { toastManager } from '../overlays/toast';
|
|
23
24
|
// ---------- last-active-app user zone ------------------------------------
|
|
24
25
|
/**
|
|
25
26
|
* Framework-reserved user-zone slot storing which app to boot into on
|
|
@@ -41,6 +42,15 @@ export function readLastApp() {
|
|
|
41
42
|
function writeLastApp(id) {
|
|
42
43
|
lastAppState.user.id = id;
|
|
43
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Public reset for the last-active-app user-zone slot. Used by the host's
|
|
47
|
+
* boot sequence to recover from a sticky last-app launch failure: if the
|
|
48
|
+
* auto-launch fails, this clears the slot so the next reload lands on home
|
|
49
|
+
* instead of looping into the same failure.
|
|
50
|
+
*/
|
|
51
|
+
export function clearLastApp() {
|
|
52
|
+
writeLastApp(null);
|
|
53
|
+
}
|
|
44
54
|
// ---------- app-context state factories ----------------------------------
|
|
45
55
|
const appContexts = new Map();
|
|
46
56
|
function getOrCreateAppContext(appId) {
|
|
@@ -72,7 +82,7 @@ function getOrCreateAppContext(appId) {
|
|
|
72
82
|
* @throws If the app is not registered or a required shard is not registered.
|
|
73
83
|
*/
|
|
74
84
|
export async function launchApp(id) {
|
|
75
|
-
var _a, _b, _c, _d, _e, _f, _g;
|
|
85
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
76
86
|
const app = getRegisteredApp(id);
|
|
77
87
|
if (!app) {
|
|
78
88
|
throw new Error(`Cannot launch app "${id}": not registered`);
|
|
@@ -118,23 +128,30 @@ export async function launchApp(id) {
|
|
|
118
128
|
attachApp(app);
|
|
119
129
|
try {
|
|
120
130
|
for (const shardId of app.manifest.requiredShards) {
|
|
121
|
-
await activateShard(shardId);
|
|
131
|
+
await activateShard(shardId, { phase: 'launch' });
|
|
122
132
|
}
|
|
123
133
|
}
|
|
124
134
|
catch (err) {
|
|
125
135
|
detachApp();
|
|
136
|
+
try {
|
|
137
|
+
toastManager.notify(`Couldn't launch "${(_e = app.manifest.label) !== null && _e !== void 0 ? _e : id}": ${err instanceof Error ? err.message : String(err)}`, { level: 'error', duration: 6000 });
|
|
138
|
+
}
|
|
139
|
+
catch (_j) {
|
|
140
|
+
// Toast layer not mounted (e.g. early boot, tests without Shell).
|
|
141
|
+
// Best-effort UX — original error must still propagate.
|
|
142
|
+
}
|
|
126
143
|
throw err;
|
|
127
144
|
}
|
|
128
145
|
// Shards have registered their view factories — safe to take the
|
|
129
146
|
// refcount holds on the app's slots now (pool's factory lookup
|
|
130
147
|
// happens in a microtask from this call).
|
|
131
148
|
acquireAppSlotHolds();
|
|
132
|
-
void ((
|
|
149
|
+
void ((_f = app.activate) === null || _f === void 0 ? void 0 : _f.call(app, getOrCreateAppContext(id)));
|
|
133
150
|
activeApp.id = id;
|
|
134
|
-
setActiveApp(id, new Set((
|
|
151
|
+
setActiveApp(id, new Set((_g = app.manifest.requiredShards) !== null && _g !== void 0 ? _g : []));
|
|
135
152
|
void loadUserBindings(id).then(setUserBindings);
|
|
136
153
|
switchToApp();
|
|
137
|
-
void ((
|
|
154
|
+
void ((_h = app.onAppReady) === null || _h === void 0 ? void 0 : _h.call(app, getOrCreateAppContext(id)));
|
|
138
155
|
writeLastApp(id);
|
|
139
156
|
breadcrumbApp.id = id;
|
|
140
157
|
}
|
|
@@ -517,3 +517,53 @@ describe('breadcrumbAppId', () => {
|
|
|
517
517
|
expect(getBreadcrumbAppId()).toBe('app-bc3b');
|
|
518
518
|
});
|
|
519
519
|
});
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// launchApp — error toast on required-shard activation failure
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
describe('launchApp — error toast on shard failure', () => {
|
|
524
|
+
beforeEach(resetFramework);
|
|
525
|
+
it('fires an error-level toast when a required shard fails to activate', async () => {
|
|
526
|
+
const { toastManager } = await import('../overlays/toast');
|
|
527
|
+
const notifySpy = vi
|
|
528
|
+
.spyOn(toastManager, 'notify')
|
|
529
|
+
.mockImplementation(() => ({ close: () => { } }));
|
|
530
|
+
const badShard = makeShard({
|
|
531
|
+
manifest: makeShardManifest({ id: 'bad-toast' }),
|
|
532
|
+
activate: () => {
|
|
533
|
+
throw new Error('shard "other" not registered');
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
registerShard(badShard);
|
|
537
|
+
registerApp(makeApp({
|
|
538
|
+
manifest: makeAppManifest({
|
|
539
|
+
id: 'app-toast',
|
|
540
|
+
label: 'Toast App',
|
|
541
|
+
requiredShards: ['bad-toast'],
|
|
542
|
+
}),
|
|
543
|
+
}));
|
|
544
|
+
await expect(launchApp('app-toast')).rejects.toThrow('shard "other" not registered');
|
|
545
|
+
expect(notifySpy).toHaveBeenCalledTimes(1);
|
|
546
|
+
const [message, options] = notifySpy.mock.calls[0];
|
|
547
|
+
expect(message).toContain('Toast App');
|
|
548
|
+
expect(message).toContain('shard "other" not registered');
|
|
549
|
+
expect(options === null || options === void 0 ? void 0 : options.level).toBe('error');
|
|
550
|
+
notifySpy.mockRestore();
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// clearLastApp — public reset for the last-active-app user-zone slot
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
describe('clearLastApp', () => {
|
|
557
|
+
beforeEach(resetFramework);
|
|
558
|
+
it('writes null to the last-app user zone', async () => {
|
|
559
|
+
const { clearLastApp, readLastApp } = await import('./lifecycle');
|
|
560
|
+
registerShard(makeShard({ manifest: makeShardManifest({ id: 's-cla' }) }));
|
|
561
|
+
registerApp(makeApp({
|
|
562
|
+
manifest: makeAppManifest({ id: 'app-cla', requiredShards: ['s-cla'] }),
|
|
563
|
+
}));
|
|
564
|
+
await launchApp('app-cla');
|
|
565
|
+
expect(readLastApp()).toBe('app-cla');
|
|
566
|
+
clearLastApp();
|
|
567
|
+
expect(readLastApp()).toBeNull();
|
|
568
|
+
});
|
|
569
|
+
});
|
package/dist/host.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { registerShard as registerShardInternal, activateShard, registeredShards, } from './shards/activate.svelte';
|
|
19
19
|
import { addAutostartShard } from './actions/state.svelte';
|
|
20
20
|
import { registerApp, registeredApps } from './apps/registry.svelte';
|
|
21
|
-
import { launchApp, readLastApp } from './apps/lifecycle';
|
|
21
|
+
import { launchApp, readLastApp, clearLastApp } from './apps/lifecycle';
|
|
22
22
|
import { sh3coreShard } from './sh3core-shard/sh3coreShard.svelte';
|
|
23
23
|
import { shellShard } from './shell-shard/shellShard.svelte';
|
|
24
24
|
import { storeShard } from './app/store/storeShard.svelte';
|
|
@@ -77,13 +77,27 @@ export async function bootstrap(config) {
|
|
|
77
77
|
for (const [id, shard] of registeredShards) {
|
|
78
78
|
if (shard.autostart) {
|
|
79
79
|
addAutostartShard(id);
|
|
80
|
-
|
|
80
|
+
try {
|
|
81
|
+
await activateShard(id, { phase: 'autostart' });
|
|
82
|
+
}
|
|
83
|
+
catch (_a) {
|
|
84
|
+
// Already logged + recorded in erroredShards by activateShard.
|
|
85
|
+
// One bad self-starting shard must not prevent the shell from booting.
|
|
86
|
+
}
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
|
-
// 5. Read the last-active app from the user zone
|
|
89
|
+
// 5. Read the last-active app from the user zone. If auto-launch fails,
|
|
90
|
+
// clear the slot so the next reload lands on home instead of looping
|
|
91
|
+
// into the same failure. No toast — the user did not initiate this.
|
|
84
92
|
const lastId = readLastApp();
|
|
85
93
|
if (lastId && registeredApps.has(lastId)) {
|
|
86
|
-
|
|
94
|
+
try {
|
|
95
|
+
await launchApp(lastId);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.error(`[sh3] Auto-launch of "${lastId}" failed:`, err);
|
|
99
|
+
clearLastApp();
|
|
100
|
+
}
|
|
87
101
|
}
|
|
88
102
|
}
|
|
89
103
|
export { installPackage, listInstalledPackages } from './registry/installer';
|
|
@@ -202,7 +202,11 @@
|
|
|
202
202
|
{@const entry = tabs?.tabs[i]}
|
|
203
203
|
{#if entry}
|
|
204
204
|
<div class="tab-slot-wrapper">
|
|
205
|
-
<SlotContainer
|
|
205
|
+
<SlotContainer
|
|
206
|
+
node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }}
|
|
207
|
+
label={entry.label}
|
|
208
|
+
meta={entry.meta}
|
|
209
|
+
/>
|
|
206
210
|
<SlotDropZone {rootRef} path={path} />
|
|
207
211
|
</div>
|
|
208
212
|
{/if}
|
|
@@ -119,6 +119,48 @@ describe('LayoutRenderer — C.4 tabless preset', () => {
|
|
|
119
119
|
expect(container.querySelector('[role="tab"]')).toBeNull();
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
|
+
describe('LayoutRenderer — C.6 TabEntry.meta threading', () => {
|
|
123
|
+
beforeEach(resetFramework);
|
|
124
|
+
it('forwards meta on a tab added post-launch (e.g. via floatManager.open)', async () => {
|
|
125
|
+
let captured;
|
|
126
|
+
registerView('test:meta-view', {
|
|
127
|
+
mount(_container, ctx) {
|
|
128
|
+
captured = ctx.meta;
|
|
129
|
+
return { unmount: () => { } };
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
// Launch with a placeholder tab — its slotId differs from the one we
|
|
133
|
+
// push later, so acquireAppSlotHolds takes no hold for the new tab.
|
|
134
|
+
// The new tab's first acquire is therefore SlotContainer's $effect,
|
|
135
|
+
// which is the gap the bug lives in.
|
|
136
|
+
registerApp(makeApp({
|
|
137
|
+
manifest: makeAppManifest({ id: 'c6' }),
|
|
138
|
+
initialLayout: [
|
|
139
|
+
{
|
|
140
|
+
name: 'default',
|
|
141
|
+
tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'placeholder', viewId: 'test:view' })])),
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
}));
|
|
145
|
+
await launchApp('c6');
|
|
146
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
147
|
+
await tick();
|
|
148
|
+
const root = layoutStore.root;
|
|
149
|
+
if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
|
|
150
|
+
throw new Error('expected tabs root');
|
|
151
|
+
root.tabs.push({
|
|
152
|
+
slotId: 'meta-slot',
|
|
153
|
+
viewId: 'test:meta-view',
|
|
154
|
+
label: 'M',
|
|
155
|
+
meta: { source: 'data:url', isBlob: false, label: 'pic.png' },
|
|
156
|
+
});
|
|
157
|
+
root.activeTab = 1;
|
|
158
|
+
await tick();
|
|
159
|
+
// acquireSlotHost defers factory.mount via queueMicrotask — flush it.
|
|
160
|
+
await Promise.resolve();
|
|
161
|
+
expect(captured).toEqual({ source: 'data:url', isBlob: false, label: 'pic.png' });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
122
164
|
describe('LayoutRenderer — C.5 invalid path', () => {
|
|
123
165
|
beforeEach(resetFramework);
|
|
124
166
|
it('renders nothing when the path no longer resolves to a node', async () => {
|
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
import { getView } from '../shards/registry';
|
|
39
39
|
import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
|
|
40
40
|
|
|
41
|
-
let {
|
|
41
|
+
let {
|
|
42
|
+
node,
|
|
43
|
+
label = '',
|
|
44
|
+
meta,
|
|
45
|
+
}: { node: SlotNode; label?: string; meta?: Record<string, unknown> } = $props();
|
|
42
46
|
|
|
43
47
|
let wrapper: HTMLDivElement | undefined = $state();
|
|
44
48
|
let width = $state(0);
|
|
@@ -62,7 +66,12 @@
|
|
|
62
66
|
// detach if still in our wrapper" guard.
|
|
63
67
|
const currentSlotId = node.slotId;
|
|
64
68
|
const wrapperEl = wrapper;
|
|
65
|
-
const host = acquireSlotHost(
|
|
69
|
+
const host = acquireSlotHost(
|
|
70
|
+
currentSlotId,
|
|
71
|
+
node.viewId,
|
|
72
|
+
label || node.viewId || currentSlotId,
|
|
73
|
+
meta,
|
|
74
|
+
);
|
|
66
75
|
wrapperEl.appendChild(host);
|
|
67
76
|
|
|
68
77
|
// Local observer exists only to drive the placeholder's dims text;
|
|
@@ -2,6 +2,7 @@ import type { SlotNode } from './types';
|
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
node: SlotNode;
|
|
4
4
|
label?: string;
|
|
5
|
+
meta?: Record<string, unknown>;
|
|
5
6
|
};
|
|
6
7
|
declare const SlotContainer: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
8
|
type SlotContainer = ReturnType<typeof SlotContainer>;
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
import { getView, __addViewRegistrationListener } from '../shards/registry';
|
|
36
36
|
import { locateSlotIn } from './ops';
|
|
37
37
|
import { activeLayout } from './store.svelte';
|
|
38
|
+
import { scopeToString } from '../actions/scope-helpers';
|
|
38
39
|
const pool = new Map();
|
|
39
40
|
const pendingDestroy = new Set();
|
|
40
41
|
/**
|
|
@@ -134,8 +135,10 @@ function createHost(slotId, viewId, label, meta) {
|
|
|
134
135
|
const host = document.createElement('div');
|
|
135
136
|
host.className = 'slot-host';
|
|
136
137
|
host.dataset.slotId = slotId;
|
|
137
|
-
if (viewId)
|
|
138
|
+
if (viewId) {
|
|
138
139
|
host.setAttribute('data-sh3-view', viewId);
|
|
140
|
+
host.setAttribute('data-sh3-scope', scopeToString(`focus:${viewId}`));
|
|
141
|
+
}
|
|
139
142
|
// Position:absolute inset:0 so the host fills whichever wrapper it is
|
|
140
143
|
// attached to. The wrapper is what the layout engine sizes; the host
|
|
141
144
|
// just tracks it. Styles are set inline (not in a class) so consumers
|
|
@@ -220,10 +223,14 @@ export function acquireSlotHost(slotId, viewId, label, meta) {
|
|
|
220
223
|
`but existing pooled entry has viewId "${entry.viewId}". Attribute synced; ` +
|
|
221
224
|
`view handle unchanged.`);
|
|
222
225
|
entry.viewId = viewId;
|
|
223
|
-
if (viewId)
|
|
226
|
+
if (viewId) {
|
|
224
227
|
entry.host.setAttribute('data-sh3-view', viewId);
|
|
225
|
-
|
|
228
|
+
entry.host.setAttribute('data-sh3-scope', scopeToString(`focus:${viewId}`));
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
226
231
|
entry.host.removeAttribute('data-sh3-view');
|
|
232
|
+
entry.host.removeAttribute('data-sh3-scope');
|
|
233
|
+
}
|
|
227
234
|
}
|
|
228
235
|
entry.refcount++;
|
|
229
236
|
return entry.host;
|
|
@@ -116,3 +116,18 @@ describe('slotHostPool — D.6 data-sh3-view attribute', () => {
|
|
|
116
116
|
releaseSlotHost('slot-2');
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
|
+
// ─── D.7 ─────────────────────────────────────────────────────────────────────
|
|
120
|
+
describe('slotHostPool — D.7 data-sh3-scope attribute', () => {
|
|
121
|
+
beforeEach(resetFramework);
|
|
122
|
+
it('pooled host has data-sh3-scope="focus:<viewId>" alongside data-sh3-view', () => {
|
|
123
|
+
const host = acquireSlotHost('slot-3', 'editor', 'Editor');
|
|
124
|
+
expect(host.getAttribute('data-sh3-view')).toBe('editor');
|
|
125
|
+
expect(host.getAttribute('data-sh3-scope')).toBe('focus:editor');
|
|
126
|
+
releaseSlotHost('slot-3');
|
|
127
|
+
});
|
|
128
|
+
it('pooled host has no data-sh3-scope when viewId is null', () => {
|
|
129
|
+
const host = acquireSlotHost('slot-4', null, 'Empty');
|
|
130
|
+
expect(host.hasAttribute('data-sh3-scope')).toBe(false);
|
|
131
|
+
releaseSlotHost('slot-4');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from '../documents/backends';
|
|
3
|
+
import { __setDocumentBackend, __setTenantId } from '../documents/config';
|
|
4
|
+
import { registerShard, activateShard, registeredShards, activeShards, __resetShardRegistryForTest, erroredShards, } from './activate.svelte';
|
|
5
|
+
describe('erroredShards map', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
__resetShardRegistryForTest();
|
|
8
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
9
|
+
__setTenantId('tenant-a');
|
|
10
|
+
});
|
|
11
|
+
it('is empty after reset', () => {
|
|
12
|
+
expect(erroredShards.size).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
it('supports the Map read API used by callers', () => {
|
|
15
|
+
expect(typeof erroredShards.has).toBe('function');
|
|
16
|
+
expect(typeof erroredShards.get).toBe('function');
|
|
17
|
+
expect(erroredShards.has('anything')).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe('activateShard — unwind on activation failure', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
__resetShardRegistryForTest();
|
|
23
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
24
|
+
__setTenantId('tenant-a');
|
|
25
|
+
});
|
|
26
|
+
it('unwinds partial state and records the error when activate throws', async () => {
|
|
27
|
+
const shard = {
|
|
28
|
+
manifest: {
|
|
29
|
+
id: 'broken',
|
|
30
|
+
label: 'Broken',
|
|
31
|
+
version: '0.0.0',
|
|
32
|
+
views: [],
|
|
33
|
+
},
|
|
34
|
+
activate(ctx) {
|
|
35
|
+
ctx.registerView('broken:view', { mount: () => ({ unmount() { } }) });
|
|
36
|
+
throw new Error('dependency missing');
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
registerShard(shard);
|
|
40
|
+
await expect(activateShard('broken')).rejects.toThrow('dependency missing');
|
|
41
|
+
expect(activeShards.has('broken')).toBe(false);
|
|
42
|
+
expect(registeredShards.has('broken')).toBe(true);
|
|
43
|
+
const entry = erroredShards.get('broken');
|
|
44
|
+
expect(entry).toBeDefined();
|
|
45
|
+
expect(entry === null || entry === void 0 ? void 0 : entry.id).toBe('broken');
|
|
46
|
+
expect(entry === null || entry === void 0 ? void 0 : entry.phase).toBe('launch');
|
|
47
|
+
expect(entry === null || entry === void 0 ? void 0 : entry.error).toBeInstanceOf(Error);
|
|
48
|
+
expect(typeof (entry === null || entry === void 0 ? void 0 : entry.timestamp)).toBe('number');
|
|
49
|
+
const { getView } = await import('./registry');
|
|
50
|
+
expect(getView('broken:view')).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
it('records phase "autostart" when called with that option', async () => {
|
|
53
|
+
var _a;
|
|
54
|
+
const shard = {
|
|
55
|
+
manifest: { id: 'broken-auto', label: 'B', version: '0.0.0', views: [] },
|
|
56
|
+
activate() {
|
|
57
|
+
throw new Error('no');
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
registerShard(shard);
|
|
61
|
+
await expect(activateShard('broken-auto', { phase: 'autostart' })).rejects.toThrow('no');
|
|
62
|
+
expect((_a = erroredShards.get('broken-auto')) === null || _a === void 0 ? void 0 : _a.phase).toBe('autostart');
|
|
63
|
+
});
|
|
64
|
+
it('clears the error entry when the shard is re-registered', async () => {
|
|
65
|
+
const broken = {
|
|
66
|
+
manifest: { id: 'reborn', label: 'R', version: '0.0.0', views: [] },
|
|
67
|
+
activate() {
|
|
68
|
+
throw new Error('first try');
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
registerShard(broken);
|
|
72
|
+
await expect(activateShard('reborn')).rejects.toThrow('first try');
|
|
73
|
+
expect(erroredShards.has('reborn')).toBe(true);
|
|
74
|
+
const fixed = {
|
|
75
|
+
manifest: { id: 'reborn', label: 'R', version: '0.0.1', views: [] },
|
|
76
|
+
activate() { },
|
|
77
|
+
};
|
|
78
|
+
registerShard(fixed);
|
|
79
|
+
expect(erroredShards.has('reborn')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
it('clears the error entry when activation eventually succeeds', async () => {
|
|
82
|
+
let shouldFail = true;
|
|
83
|
+
const shard = {
|
|
84
|
+
manifest: { id: 'flaky', label: 'F', version: '0.0.0', views: [] },
|
|
85
|
+
activate() {
|
|
86
|
+
if (shouldFail)
|
|
87
|
+
throw new Error('first try');
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
registerShard(shard);
|
|
91
|
+
await expect(activateShard('flaky')).rejects.toThrow('first try');
|
|
92
|
+
expect(erroredShards.has('flaky')).toBe(true);
|
|
93
|
+
shouldFail = false;
|
|
94
|
+
await activateShard('flaky');
|
|
95
|
+
expect(erroredShards.has('flaky')).toBe(false);
|
|
96
|
+
expect(activeShards.has('flaky')).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -9,6 +9,19 @@ import type { Shard, ShardContext } from './types';
|
|
|
9
9
|
*/
|
|
10
10
|
export declare const registeredShards: Map<string, Shard>;
|
|
11
11
|
export declare const activeShards: Map<string, Shard>;
|
|
12
|
+
/**
|
|
13
|
+
* Reactive map of shard ids that failed to activate. Populated by
|
|
14
|
+
* `activateShard`'s catch block; cleared when the shard is successfully
|
|
15
|
+
* re-registered or activated. Read-only for shards; intended for diagnostic
|
|
16
|
+
* and admin tooling that wants to surface broken shards to the user.
|
|
17
|
+
*/
|
|
18
|
+
export interface ShardErrorEntry {
|
|
19
|
+
id: string;
|
|
20
|
+
error: unknown;
|
|
21
|
+
phase: 'autostart' | 'launch';
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
export declare const erroredShards: Map<string, ShardErrorEntry>;
|
|
12
25
|
/**
|
|
13
26
|
* Register (or re-register) a shard with the framework so it can later be
|
|
14
27
|
* activated. Records the shard in `registeredShards` but does not run
|
|
@@ -20,16 +33,31 @@ export declare const activeShards: Map<string, Shard>;
|
|
|
20
33
|
* activated on next launch.
|
|
21
34
|
*/
|
|
22
35
|
export declare function registerShard(shard: Shard): void;
|
|
36
|
+
export interface ActivateShardOpts {
|
|
37
|
+
/**
|
|
38
|
+
* Where this activation was initiated from. Determines the `phase` field
|
|
39
|
+
* recorded in `erroredShards` if activation fails. Defaults to 'launch'
|
|
40
|
+
* (the common case — required by an app being launched).
|
|
41
|
+
*/
|
|
42
|
+
phase?: 'autostart' | 'launch';
|
|
43
|
+
}
|
|
23
44
|
/**
|
|
24
45
|
* Activate a registered shard. Builds a `ShardContext`, calls `shard.activate`,
|
|
25
46
|
* verifies that every view declared in the manifest received a factory, then
|
|
26
47
|
* calls `shard.autostart` if defined. Idempotent — calling on an already-active
|
|
27
48
|
* shard is a no-op.
|
|
28
49
|
*
|
|
50
|
+
* If `shard.activate` throws, partial state (registered views, verbs,
|
|
51
|
+
* contributions, document handles, actions, env subscription) is unwound
|
|
52
|
+
* and the failure is recorded in `erroredShards` before the error is
|
|
53
|
+
* re-thrown. Callers in `host.ts` (autostart loop) and `launchApp`
|
|
54
|
+
* (required-shard loop) decide how to react.
|
|
55
|
+
*
|
|
29
56
|
* @param id - The `ShardManifest.id` of the shard to activate. Must be registered.
|
|
30
|
-
* @
|
|
57
|
+
* @param opts - Optional. `phase` is recorded in `erroredShards` on failure (default 'launch').
|
|
58
|
+
* @throws If the shard is not registered, if `shard.activate` throws, or if a manifest view has no factory after activation.
|
|
31
59
|
*/
|
|
32
|
-
export declare function activateShard(id: string): Promise<void>;
|
|
60
|
+
export declare function activateShard(id: string, opts?: ActivateShardOpts): Promise<void>;
|
|
33
61
|
/**
|
|
34
62
|
* Deactivate an active shard. Calls `shard.deactivate`, flushes and disposes
|
|
35
63
|
* all document handles, unregisters all view factories, and removes the shard
|
|
@@ -47,6 +47,7 @@ export const registeredShards = $state(new Map());
|
|
|
47
47
|
*/
|
|
48
48
|
const active = new Map();
|
|
49
49
|
export const activeShards = $state(new Map());
|
|
50
|
+
export const erroredShards = $state(new Map());
|
|
50
51
|
/**
|
|
51
52
|
* Register (or re-register) a shard with the framework so it can later be
|
|
52
53
|
* activated. Records the shard in `registeredShards` but does not run
|
|
@@ -63,6 +64,9 @@ export function registerShard(shard) {
|
|
|
63
64
|
deactivateShard(id);
|
|
64
65
|
}
|
|
65
66
|
registeredShards.set(id, shard);
|
|
67
|
+
// Re-registering wipes any prior error: the new shard module gets a
|
|
68
|
+
// clean slate, and a hot-reload of a fixed shard removes the stale entry.
|
|
69
|
+
erroredShards.delete(id);
|
|
66
70
|
}
|
|
67
71
|
/**
|
|
68
72
|
* Activate a registered shard. Builds a `ShardContext`, calls `shard.activate`,
|
|
@@ -70,11 +74,18 @@ export function registerShard(shard) {
|
|
|
70
74
|
* calls `shard.autostart` if defined. Idempotent — calling on an already-active
|
|
71
75
|
* shard is a no-op.
|
|
72
76
|
*
|
|
77
|
+
* If `shard.activate` throws, partial state (registered views, verbs,
|
|
78
|
+
* contributions, document handles, actions, env subscription) is unwound
|
|
79
|
+
* and the failure is recorded in `erroredShards` before the error is
|
|
80
|
+
* re-thrown. Callers in `host.ts` (autostart loop) and `launchApp`
|
|
81
|
+
* (required-shard loop) decide how to react.
|
|
82
|
+
*
|
|
73
83
|
* @param id - The `ShardManifest.id` of the shard to activate. Must be registered.
|
|
74
|
-
* @
|
|
84
|
+
* @param opts - Optional. `phase` is recorded in `erroredShards` on failure (default 'launch').
|
|
85
|
+
* @throws If the shard is not registered, if `shard.activate` throws, or if a manifest view has no factory after activation.
|
|
75
86
|
*/
|
|
76
|
-
export async function activateShard(id) {
|
|
77
|
-
var _a, _b, _c, _d, _e;
|
|
87
|
+
export async function activateShard(id, opts) {
|
|
88
|
+
var _a, _b, _c, _d, _e, _f;
|
|
78
89
|
const shard = registeredShards.get(id);
|
|
79
90
|
if (!shard) {
|
|
80
91
|
throw new Error(`Cannot activate shard "${id}": not registered`);
|
|
@@ -201,24 +212,57 @@ export async function activateShard(id) {
|
|
|
201
212
|
}
|
|
202
213
|
active.set(id, entry);
|
|
203
214
|
activeShards.set(id, shard);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
215
|
+
try {
|
|
216
|
+
await shard.activate(ctx);
|
|
217
|
+
for (const view of shard.manifest.views) {
|
|
218
|
+
if (!entry.viewIds.has(view.id)) {
|
|
219
|
+
throw new Error(`Shard "${id}" declared view "${view.id}" in its manifest but registered no factory for it.`);
|
|
220
|
+
}
|
|
208
221
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
222
|
+
// Hydrate env state if the shard declared it via ctx.env().
|
|
223
|
+
if (envState.proxy && envState.defaults) {
|
|
224
|
+
try {
|
|
225
|
+
const stored = await fetchEnvState(id);
|
|
226
|
+
const merged = Object.assign({}, envState.defaults, stored);
|
|
227
|
+
Object.assign(envState.proxy, merged);
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
console.warn(`[sh3] Failed to hydrate env state for shard "${id}":`, err instanceof Error ? err.message : err);
|
|
231
|
+
}
|
|
216
232
|
}
|
|
217
|
-
|
|
218
|
-
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
// Unwind partial state. Mirror deactivateShard's body, minus the
|
|
236
|
+
// shard.deactivate?.() call — the shard never finished activating.
|
|
237
|
+
// Each cleanup fn runs inside its own swallow so a teardown failure
|
|
238
|
+
// cannot mask the original activation error.
|
|
239
|
+
for (const fn of entry.cleanupFns) {
|
|
240
|
+
try {
|
|
241
|
+
void fn();
|
|
242
|
+
}
|
|
243
|
+
catch (_g) {
|
|
244
|
+
// intentionally swallowed: original error is what matters.
|
|
245
|
+
}
|
|
219
246
|
}
|
|
247
|
+
for (const name of entry.verbNames)
|
|
248
|
+
fwUnregisterVerb(name);
|
|
249
|
+
for (const viewId of entry.viewIds)
|
|
250
|
+
unregisterView(viewId);
|
|
251
|
+
clearSelectionForShard(id);
|
|
252
|
+
active.delete(id);
|
|
253
|
+
activeShards.delete(id);
|
|
254
|
+
erroredShards.set(id, {
|
|
255
|
+
id,
|
|
256
|
+
error: err,
|
|
257
|
+
phase: (_e = opts === null || opts === void 0 ? void 0 : opts.phase) !== null && _e !== void 0 ? _e : 'launch',
|
|
258
|
+
timestamp: Date.now(),
|
|
259
|
+
});
|
|
260
|
+
console.error(`[sh3] Shard "${id}" failed to activate:`, err);
|
|
261
|
+
throw err;
|
|
220
262
|
}
|
|
221
|
-
|
|
263
|
+
// Activation succeeded — clear any prior error record for this shard.
|
|
264
|
+
erroredShards.delete(id);
|
|
265
|
+
void ((_f = shard.autostart) === null || _f === void 0 ? void 0 : _f.call(shard, ctx));
|
|
222
266
|
}
|
|
223
267
|
/**
|
|
224
268
|
* Deactivate an active shard. Calls `shard.deactivate`, flushes and disposes
|
|
@@ -288,4 +332,5 @@ export function __resetShardRegistryForTest() {
|
|
|
288
332
|
active.clear();
|
|
289
333
|
activeShards.clear();
|
|
290
334
|
registeredShards.clear();
|
|
335
|
+
erroredShards.clear();
|
|
291
336
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.12.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Auto-generated from package.json — do not edit manually. */
|
|
2
|
-
export const VERSION = '0.
|
|
2
|
+
export const VERSION = '0.12.0';
|