sh3-core 0.21.2 → 0.22.1

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.
Files changed (96) hide show
  1. package/dist/__test__/fixtures.js +1 -1
  2. package/dist/__test__/reset.js +1 -1
  3. package/dist/__test__/smoke.test.js +2 -2
  4. package/dist/actions/contextMenuModel.test.js +6 -3
  5. package/dist/actions/ctx-actions.svelte.test.js +9 -9
  6. package/dist/actions/dispatcher-v3.test.js +8 -0
  7. package/dist/actions/dispatcher.svelte.d.ts +1 -2
  8. package/dist/actions/dispatcher.svelte.js +6 -7
  9. package/dist/actions/dispatcher.test.js +9 -12
  10. package/dist/actions/listActionsFromEntries.test.js +1 -2
  11. package/dist/actions/listActive.test.js +2 -3
  12. package/dist/actions/menuBarModel.test.js +1 -7
  13. package/dist/actions/paletteModel.test.js +1 -3
  14. package/dist/actions/scope-helpers.test.js +4 -4
  15. package/dist/actions/shardContext.test.js +2 -2
  16. package/dist/actions/state.svelte.d.ts +12 -2
  17. package/dist/actions/state.svelte.js +15 -12
  18. package/dist/actions/state.test.js +4 -4
  19. package/dist/api.d.ts +3 -3
  20. package/dist/api.js +1 -1
  21. package/dist/app/admin/adminShard.svelte.js +1 -1
  22. package/dist/app/store/storeShard.svelte.js +10 -5
  23. package/dist/app-appearance/appearanceShard.svelte.js +1 -5
  24. package/dist/apps/lifecycle.js +65 -33
  25. package/dist/apps/lifecycle.test.js +198 -10
  26. package/dist/artifact.d.ts +2 -0
  27. package/dist/build.js +1 -1
  28. package/dist/conflicts/adapter-documents.js +1 -2
  29. package/dist/createShell.js +1 -1
  30. package/dist/documents/handle.d.ts +9 -4
  31. package/dist/documents/handle.js +69 -45
  32. package/dist/documents/handle.test.js +99 -27
  33. package/dist/documents/index.d.ts +1 -1
  34. package/dist/documents/types.d.ts +16 -20
  35. package/dist/host.d.ts +1 -1
  36. package/dist/host.js +9 -56
  37. package/dist/host.svelte.test.js +31 -63
  38. package/dist/layout/LayoutRenderer.svelte +1 -1
  39. package/dist/layout/SlotContainer.svelte +1 -0
  40. package/dist/layout/inspection.js +19 -14
  41. package/dist/layout/inspection.svelte.test.js +136 -1
  42. package/dist/layout/slotHostPool.svelte.d.ts +2 -1
  43. package/dist/layout/slotHostPool.svelte.js +6 -3
  44. package/dist/layout/slotHostPool.test.js +17 -0
  45. package/dist/layout/store.projectScope.test.js +76 -0
  46. package/dist/layout/store.svelte.d.ts +6 -0
  47. package/dist/layout/store.svelte.js +43 -13
  48. package/dist/layout/tree-walk.d.ts +8 -1
  49. package/dist/layout/tree-walk.js +11 -1
  50. package/dist/layout/tree-walk.test.js +53 -1
  51. package/dist/layout/types.d.ts +27 -0
  52. package/dist/layout/types.test.js +28 -0
  53. package/dist/layouts-shard/LayoutsSection.svelte +1 -1
  54. package/dist/layouts-shard/layoutsShard.svelte.js +2 -5
  55. package/dist/layouts-shard/layoutsShard.svelte.test.js +2 -2
  56. package/dist/overlays/FloatFrame.svelte +4 -1
  57. package/dist/overlays/float.d.ts +7 -1
  58. package/dist/overlays/float.js +4 -0
  59. package/dist/projects-shard/ProjectsSection.svelte +1 -5
  60. package/dist/projects-shard/projectsShard.svelte.js +1 -5
  61. package/dist/registry/installer.js +1 -1
  62. package/dist/registry/loader.d.ts +1 -1
  63. package/dist/registry/loader.js +3 -3
  64. package/dist/registry/permission-descriptions.test.js +2 -2
  65. package/dist/registry/register.js +1 -1
  66. package/dist/registry/register.test.js +1 -1
  67. package/dist/runtime/runVerb-shell.test.js +1 -1
  68. package/dist/runtime/runVerb.js +2 -2
  69. package/dist/runtime/runVerb.test.js +9 -9
  70. package/dist/sh3Api/headless.js +1 -1
  71. package/dist/sh3core-shard/sh3coreShard.svelte.js +1 -6
  72. package/dist/shards/ctx-fetch.test.js +9 -9
  73. package/dist/shards/lifecycle.svelte.d.ts +108 -0
  74. package/dist/shards/lifecycle.svelte.js +551 -0
  75. package/dist/shards/lifecycle.test.js +139 -0
  76. package/dist/shards/types.d.ts +56 -22
  77. package/dist/shell-shard/shellShard.svelte.js +11 -5
  78. package/dist/version.d.ts +1 -1
  79. package/dist/version.js +1 -1
  80. package/package.json +1 -1
  81. package/dist/shards/activate-browse.test.js +0 -120
  82. package/dist/shards/activate-contributions.test.js +0 -141
  83. package/dist/shards/activate-error-isolation.test.js +0 -98
  84. package/dist/shards/activate-fields.svelte.test.d.ts +0 -1
  85. package/dist/shards/activate-fields.svelte.test.js +0 -121
  86. package/dist/shards/activate-on-key-revoked.test.d.ts +0 -1
  87. package/dist/shards/activate-on-key-revoked.test.js +0 -60
  88. package/dist/shards/activate-runtime.test.d.ts +0 -1
  89. package/dist/shards/activate-runtime.test.js +0 -299
  90. package/dist/shards/activate-scopeid.test.d.ts +0 -1
  91. package/dist/shards/activate-scopeid.test.js +0 -21
  92. package/dist/shards/activate.svelte.d.ts +0 -102
  93. package/dist/shards/activate.svelte.js +0 -403
  94. /package/dist/{shards/activate-browse.test.d.ts → actions/dispatcher-v3.test.d.ts} +0 -0
  95. /package/dist/{shards/activate-contributions.test.d.ts → layout/store.projectScope.test.d.ts} +0 -0
  96. /package/dist/shards/{activate-error-isolation.test.d.ts → lifecycle.test.d.ts} +0 -0
@@ -13,8 +13,9 @@
13
13
  */
14
14
  import { createStateZones } from '../state/zones.svelte';
15
15
  import { createGestureRegistry } from '../gestures';
16
- import { activateShard, deactivateShard, getShardContext, registeredShards, } from '../shards/activate.svelte';
17
- import { attachApp, acquireAppSlotHolds, detachApp, switchToApp, switchToHome, } from '../layout/store.svelte';
16
+ import { registeredShards, } from '../shards/lifecycle.svelte';
17
+ import { shardEntries, runAppActivate, runAppDeactivate, registerAllShards, erroredShards } from '../shards/lifecycle.svelte';
18
+ import { attachApp, acquireAppSlotHolds, detachApp, switchToApp, switchToHome, getActiveAppTree, } from '../layout/store.svelte';
18
19
  import { activeApp, breadcrumbApp, getRegisteredApp, registeredApps } from './registry.svelte';
19
20
  import { createZoneManager } from '../state/manage';
20
21
  import { PERMISSION_STATE_MANAGE } from '../state/types';
@@ -25,6 +26,7 @@ import { toastManager } from '../overlays/toast';
25
26
  import { clearAppNavEntries } from '../navigation/back-stack';
26
27
  import { getActiveScopeId } from '../documents/config';
27
28
  import { sessionState } from '../projects/session-state.svelte';
29
+ import { collectRestoredSlots } from '../layout/tree-walk';
28
30
  // ---------- last-active-app user zone ------------------------------------
29
31
  /**
30
32
  * Framework-reserved user-zone slot storing which app to boot into on
@@ -95,7 +97,7 @@ function getOrCreateAppContext(appId, scopeId, args) {
95
97
  * @throws If the app is not registered or a required shard is not registered.
96
98
  */
97
99
  export async function launchApp(id, opts = {}) {
98
- var _a, _b, _c, _d, _e, _f, _g, _h;
100
+ var _a, _b, _c, _d, _e, _f, _g;
99
101
  const app = getRegisteredApp(id);
100
102
  if (!app) {
101
103
  throw new Error(`Cannot launch app "${id}": not registered`);
@@ -110,18 +112,17 @@ export async function launchApp(id, opts = {}) {
110
112
  else if (activeApp.id === id) {
111
113
  // Re-entering the same app from Home — fire resume hooks.
112
114
  for (const shardId of app.manifest.requiredShards) {
113
- const shard = registeredShards.get(shardId);
114
- const shardCtx = getShardContext(shardId);
115
- if (shard && shardCtx)
116
- void ((_a = shard.resume) === null || _a === void 0 ? void 0 : _a.call(shard, shardCtx));
115
+ const entry = shardEntries.get(shardId);
116
+ if ((entry === null || entry === void 0 ? void 0 : entry.shard.resume) && entry.ctx)
117
+ void entry.shard.resume(entry.ctx);
117
118
  }
118
- void ((_b = app.resume) === null || _b === void 0 ? void 0 : _b.call(app, getOrCreateAppContext(id)));
119
+ void ((_a = app.resume) === null || _a === void 0 ? void 0 : _a.call(app, getOrCreateAppContext(id)));
119
120
  switchToApp();
120
- void ((_c = app.onAppReady) === null || _c === void 0 ? void 0 : _c.call(app, getOrCreateAppContext(id)));
121
+ void ((_b = app.onAppReady) === null || _b === void 0 ? void 0 : _b.call(app, getOrCreateAppContext(id)));
121
122
  if (!opts.skipLastApp)
122
123
  writeLastApp(id);
123
124
  breadcrumbApp.id = id;
124
- setActiveApp(id, new Set((_d = app.manifest.requiredShards) !== null && _d !== void 0 ? _d : []));
125
+ setActiveApp(id, new Set((_c = app.manifest.requiredShards) !== null && _c !== void 0 ? _c : []));
125
126
  void loadUserBindings(id).then(setUserBindings);
126
127
  return;
127
128
  }
@@ -141,14 +142,34 @@ export async function launchApp(id, opts = {}) {
141
142
  // detach to keep the preset manager state consistent.
142
143
  attachApp(app);
143
144
  try {
145
+ // v3: ensure every registered shard has been run through register().
146
+ // In production, host.bootstrap calls registerAllShards once at boot.
147
+ // In tests that skip bootstrap, this catch-up makes launchApp self-sufficient.
148
+ await registerAllShards();
149
+ // Surface register-time failures for required shards as a launch error
150
+ // (mirrors v2 activate failure semantics for missing factories / thrown register).
144
151
  for (const shardId of app.manifest.requiredShards) {
145
- await activateShard(shardId, { phase: 'launch' });
152
+ const err = erroredShards.get(shardId);
153
+ if (err)
154
+ throw err.error;
155
+ }
156
+ // runAppActivate rotates the doc namespace binding AND fires
157
+ // onAppActivate. No per-shard activate() pass, no separate binding.
158
+ for (const shardId of app.manifest.requiredShards) {
159
+ await runAppActivate(shardId, id);
146
160
  }
147
161
  }
148
162
  catch (err) {
163
+ // Roll back any partial onAppActivate calls.
164
+ for (const shardId of app.manifest.requiredShards) {
165
+ try {
166
+ await runAppDeactivate(shardId, id);
167
+ }
168
+ catch ( /* swallow */_h) { /* swallow */ }
169
+ }
149
170
  detachApp();
150
171
  try {
151
- 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 });
172
+ toastManager.notify(`Couldn't launch "${(_d = app.manifest.label) !== null && _d !== void 0 ? _d : id}": ${err instanceof Error ? err.message : String(err)}`, { level: 'error', duration: 6000 });
152
173
  }
153
174
  catch (_j) {
154
175
  // Toast layer not mounted (e.g. early boot, tests without Sh3).
@@ -156,16 +177,33 @@ export async function launchApp(id, opts = {}) {
156
177
  }
157
178
  throw err;
158
179
  }
180
+ // Collect the layout's restored slots for the restore hooks.
181
+ const tree = getActiveAppTree();
182
+ const restoredSlots = tree ? collectRestoredSlots(tree) : [];
183
+ // Await onLayoutWillRestore so async slot-contribution registrations
184
+ // complete before acquireAppSlotHolds triggers view factory mount.
185
+ for (const shardId of app.manifest.requiredShards) {
186
+ const entry = shardEntries.get(shardId);
187
+ if ((entry === null || entry === void 0 ? void 0 : entry.shard.onLayoutWillRestore) && entry.ctx) {
188
+ await entry.shard.onLayoutWillRestore(entry.ctx, restoredSlots);
189
+ }
190
+ }
159
191
  // Shards have registered their view factories — safe to take the
160
192
  // refcount holds on the app's slots now (pool's factory lookup
161
193
  // happens in a microtask from this call).
162
194
  acquireAppSlotHolds();
163
- void ((_f = app.activate) === null || _f === void 0 ? void 0 : _f.call(app, getOrCreateAppContext(id, undefined, opts.args)));
195
+ void ((_e = app.activate) === null || _e === void 0 ? void 0 : _e.call(app, getOrCreateAppContext(id, undefined, opts.args)));
164
196
  activeApp.id = id;
165
- setActiveApp(id, new Set((_g = app.manifest.requiredShards) !== null && _g !== void 0 ? _g : []));
197
+ setActiveApp(id, new Set((_f = app.manifest.requiredShards) !== null && _f !== void 0 ? _f : []));
166
198
  void loadUserBindings(id).then(setUserBindings);
167
199
  switchToApp();
168
- void ((_h = app.onAppReady) === null || _h === void 0 ? void 0 : _h.call(app, getOrCreateAppContext(id)));
200
+ for (const shardId of app.manifest.requiredShards) {
201
+ const entry = shardEntries.get(shardId);
202
+ if ((entry === null || entry === void 0 ? void 0 : entry.shard.onLayoutRestored) && entry.ctx) {
203
+ entry.shard.onLayoutRestored(entry.ctx, restoredSlots);
204
+ }
205
+ }
206
+ void ((_g = app.onAppReady) === null || _g === void 0 ? void 0 : _g.call(app, getOrCreateAppContext(id)));
169
207
  if (!opts.skipLastApp)
170
208
  writeLastApp(id);
171
209
  breadcrumbApp.id = id;
@@ -189,6 +227,12 @@ export function unloadApp(id, skipSwitchToHome = false) {
189
227
  if (!app)
190
228
  return;
191
229
  void ((_a = app.deactivate) === null || _a === void 0 ? void 0 : _a.call(app));
230
+ // v3: call runAppDeactivate for every required shard. The shard stays
231
+ // active (its register() output is intact); only its per-app bindings
232
+ // and per-app contribution registrations are torn down.
233
+ for (const shardId of app.manifest.requiredShards) {
234
+ void runAppDeactivate(shardId, id);
235
+ }
192
236
  // Detach layout (releases the refcount holds; pool cleanup runs on
193
237
  // the next microtask for any slots that no longer have a renderer).
194
238
  // Switch to home first so LayoutRenderer stops reading the app's
@@ -196,21 +240,6 @@ export function unloadApp(id, skipSwitchToHome = false) {
196
240
  if (!skipSwitchToHome)
197
241
  switchToHome();
198
242
  detachApp();
199
- // Deactivate this app's required shards IF no other consumer needs
200
- // them. Phase 8 has at most one app active at a time, so "no other
201
- // consumer" reduces to "not self-starting AND not required by any
202
- // other registered app that happens to already be active" — but we
203
- // don't run multiple apps, so the only survivors are self-starters.
204
- // The simple rule: deactivate a required shard unless it was
205
- // self-starting (has an `autostart` field defined).
206
- for (const shardId of app.manifest.requiredShards) {
207
- const shard = registeredShards.get(shardId);
208
- if (!shard)
209
- continue;
210
- if (shard.autostart)
211
- continue; // self-starter stays running
212
- deactivateShard(shardId);
213
- }
214
243
  activeApp.id = null;
215
244
  setActiveApp(null, new Set());
216
245
  clearSelectionUnconditional();
@@ -257,9 +286,12 @@ export async function returnToHome() {
257
286
  const app = activeApp.id ? getRegisteredApp(activeApp.id) : null;
258
287
  if (app) {
259
288
  for (const shardId of app.manifest.requiredShards) {
260
- const shard = registeredShards.get(shardId);
261
- if ((shard === null || shard === void 0 ? void 0 : shard.suspend) && (await shard.suspend()) === false)
262
- return false;
289
+ const entry = shardEntries.get(shardId);
290
+ if ((entry === null || entry === void 0 ? void 0 : entry.shard.suspend) && entry.ctx) {
291
+ const result = await entry.shard.suspend(entry.ctx);
292
+ if (result === false)
293
+ return false;
294
+ }
263
295
  }
264
296
  if (app.suspend && (await app.suspend()) === false)
265
297
  return false;
@@ -3,7 +3,7 @@ import { resetFramework } from '../__test__/reset';
3
3
  import { makeApp, makeShard, makeAppManifest, makeShardManifest, makeTabsNode, makeTabEntry, makeSlotNode, makeTree, } from '../__test__/fixtures';
4
4
  import { launchApp, returnToHome, unregisterApp } from './lifecycle';
5
5
  import { registerApp } from './registry.svelte';
6
- import { registerShard } from '../shards/activate.svelte';
6
+ import { registerShard } from '../shards/lifecycle.svelte';
7
7
  import { presetManager } from '../overlays/presets';
8
8
  import { layoutStore, resetActivePresetToDefault } from '../layout/store.svelte';
9
9
  import LayoutRenderer from '../layout/LayoutRenderer.svelte';
@@ -19,7 +19,7 @@ describe('launchApp — scenario A.1 step order', () => {
19
19
  const order = [];
20
20
  const shard = makeShard({
21
21
  manifest: makeShardManifest({ id: 'shard-A' }),
22
- activate: () => {
22
+ register: () => {
23
23
  order.push('shard.activate');
24
24
  },
25
25
  });
@@ -54,7 +54,7 @@ describe('launchApp — scenario A.2 shard failure', () => {
54
54
  it('detaches the app and re-throws when a required shard throws during activate', async () => {
55
55
  const badShard = makeShard({
56
56
  manifest: makeShardManifest({ id: 'bad' }),
57
- activate: () => {
57
+ register: () => {
58
58
  throw new Error('boom');
59
59
  },
60
60
  });
@@ -77,7 +77,7 @@ describe('launchApp — scenario A.3 re-entry from home', () => {
77
77
  const shardActivate = vi.fn();
78
78
  const shard = makeShard({
79
79
  manifest: makeShardManifest({ id: 'shard-R' }),
80
- activate: shardActivate,
80
+ register: shardActivate,
81
81
  });
82
82
  registerShard(shard);
83
83
  const appResume = vi.fn();
@@ -107,7 +107,7 @@ describe('launchApp — scenario A.4 fast path', () => {
107
107
  const shardDeactivate = vi.fn();
108
108
  registerShard(makeShard({
109
109
  manifest: makeShardManifest({ id: 'shard-F' }),
110
- activate: shardActivate,
110
+ register: shardActivate,
111
111
  deactivate: shardDeactivate,
112
112
  }));
113
113
  registerApp(makeApp({
@@ -163,7 +163,7 @@ describe('presets — scenario B.2 switch from shard.activate', () => {
163
163
  it('does not throw "no app attached" when a shard calls presets.switch from activate', async () => {
164
164
  registerShard(makeShard({
165
165
  manifest: makeShardManifest({ id: 'switcher' }),
166
- activate: () => {
166
+ register: () => {
167
167
  presetManager.switch('alt');
168
168
  },
169
169
  }));
@@ -297,7 +297,7 @@ describe('installPackage evict-before-register (simulated via registerLoadedBund
297
297
  it('replaces an existing shard entry when a new version is registered', async () => {
298
298
  var _a, _b;
299
299
  const { registerLoadedBundle } = await import('../registry/register');
300
- const { deactivateShard, registeredShards } = await import('../shards/activate.svelte');
300
+ const { deactivateShard, registeredShards } = await import('../shards/lifecycle.svelte');
301
301
  const s1 = makeShard({ manifest: makeShardManifest({ id: 'S', version: '' }) });
302
302
  registerLoadedBundle({ shards: [s1], apps: [] }, { version: '1.0.0', sourceRegistry: '', contractVersion: '1' });
303
303
  expect((_a = registeredShards.get('S')) === null || _a === void 0 ? void 0 : _a.manifest.version).toBe('1.0.0');
@@ -434,7 +434,7 @@ describe('sh3coreShard — sh3.app.reset-layout registration', () => {
434
434
  // would — tests don't run bootstrap, so the shard's actions wouldn't
435
435
  // otherwise be present.
436
436
  const { sh3coreShard } = await import('../sh3core-shard/sh3coreShard.svelte');
437
- const { activateShard } = await import('../shards/activate.svelte');
437
+ const { activateShard } = await import('../shards/lifecycle.svelte');
438
438
  const { addAutostartShard } = await import('../actions/state.svelte');
439
439
  registerShard(sh3coreShard);
440
440
  // Mirror what bootstrap() does: mark the framework shard as autostart so
@@ -457,7 +457,7 @@ describe('sh3coreShard — sh3.app.reset-layout registration', () => {
457
457
  });
458
458
  it('hides "app"-scope actions after returnToHome', async () => {
459
459
  const { sh3coreShard } = await import('../sh3core-shard/sh3coreShard.svelte');
460
- const { activateShard } = await import('../shards/activate.svelte');
460
+ const { activateShard } = await import('../shards/lifecycle.svelte');
461
461
  const { addAutostartShard } = await import('../actions/state.svelte');
462
462
  registerShard(sh3coreShard);
463
463
  addAutostartShard(sh3coreShard.manifest.id);
@@ -529,7 +529,7 @@ describe('launchApp — error toast on shard failure', () => {
529
529
  .mockImplementation(() => ({ close: () => { } }));
530
530
  const badShard = makeShard({
531
531
  manifest: makeShardManifest({ id: 'bad-toast' }),
532
- activate: () => {
532
+ register: () => {
533
533
  throw new Error('shard "other" not registered');
534
534
  },
535
535
  });
@@ -660,3 +660,191 @@ describe('launchApp — ctx.args', () => {
660
660
  expect(receivedArgs).toEqual({});
661
661
  });
662
662
  });
663
+ // ---------------------------------------------------------------------------
664
+ // Scenario — onAppActivate / onAppDeactivate / layout hooks
665
+ // ---------------------------------------------------------------------------
666
+ describe('launchApp — onAppActivate hook', () => {
667
+ beforeEach(resetFramework);
668
+ it('calls onAppActivate on required shards after activation with correct appId', async () => {
669
+ const calls = [];
670
+ const shard = makeShard({
671
+ manifest: makeShardManifest({ id: 'activate-hook-shard' }),
672
+ register() { },
673
+ onAppActivate(_ctx, appId) { calls.push({ shardId: 'activate-hook-shard', appId }); },
674
+ });
675
+ registerShard(shard);
676
+ const app = makeApp({
677
+ manifest: makeAppManifest({ id: 'activate-hook-app', requiredShards: ['activate-hook-shard'] }),
678
+ });
679
+ registerApp(app);
680
+ await launchApp('activate-hook-app');
681
+ expect(calls).toEqual([{ shardId: 'activate-hook-shard', appId: 'activate-hook-app' }]);
682
+ });
683
+ // v3: AppActivateContext is gone. ctx.documents (the shard's pre-minted
684
+ // handle) auto-resolves to the active app's namespace because
685
+ // runAppActivate rotated the binding. See lifecycle.test.ts for v3 coverage.
686
+ });
687
+ describe('unloadApp — onAppDeactivate hook', () => {
688
+ beforeEach(resetFramework);
689
+ it('calls onAppDeactivate on required shards when the app is unloaded', async () => {
690
+ const deactivated = [];
691
+ const shard = makeShard({
692
+ manifest: makeShardManifest({ id: 'deact-shard' }),
693
+ register() { },
694
+ onAppDeactivate(_ctx, appId) { deactivated.push(appId); },
695
+ });
696
+ registerShard(shard);
697
+ const app = makeApp({
698
+ manifest: makeAppManifest({ id: 'deact-app', requiredShards: ['deact-shard'] }),
699
+ });
700
+ registerApp(app);
701
+ await launchApp('deact-app');
702
+ unregisterApp('deact-app');
703
+ expect(deactivated).toEqual(['deact-app']);
704
+ });
705
+ });
706
+ describe('launchApp — onLayoutWillRestore / onLayoutRestored hooks', () => {
707
+ beforeEach(resetFramework);
708
+ it('calls onLayoutWillRestore before slot acquisition and onLayoutRestored after switchToApp', async () => {
709
+ const order = [];
710
+ const capturedSlots = [];
711
+ const shard = makeShard({
712
+ manifest: makeShardManifest({ id: 'restore-shard' }),
713
+ register() { },
714
+ onLayoutWillRestore(_ctx, slots) {
715
+ order.push('onLayoutWillRestore');
716
+ capturedSlots.push([...slots]);
717
+ },
718
+ onLayoutRestored(_ctx, slots) {
719
+ order.push('onLayoutRestored');
720
+ capturedSlots.push([...slots]);
721
+ },
722
+ });
723
+ registerShard(shard);
724
+ const app = makeApp({
725
+ manifest: makeAppManifest({ id: 'restore-app', requiredShards: ['restore-shard'] }),
726
+ initialLayout: [
727
+ { name: 'default', tree: makeTree(makeSlotNode('r-slot', 'restore:view')) },
728
+ ],
729
+ });
730
+ registerApp(app);
731
+ await launchApp('restore-app');
732
+ await Promise.resolve();
733
+ expect(order).toEqual(['onLayoutWillRestore', 'onLayoutRestored']);
734
+ // Both calls receive the same slot list
735
+ expect(capturedSlots[0]).toEqual(capturedSlots[1]);
736
+ expect(capturedSlots[0]).toHaveLength(1);
737
+ expect(capturedSlots[0][0]).toMatchObject({ slotId: 'r-slot', viewId: 'restore:view' });
738
+ });
739
+ it('awaits onLayoutWillRestore before slot factories mount', async () => {
740
+ // This is the load-bearing assertion: a slow async hook must complete
741
+ // before any acquireAppSlotHosts triggers a view factory mount() call.
742
+ // If launchApp uses `void shard.onLayoutWillRestore(...)`, the Promise
743
+ // resolves AFTER the microtask that mounts views — failing this test.
744
+ const order = [];
745
+ let resolveHook = null;
746
+ const hookGate = new Promise((r) => { resolveHook = r; });
747
+ registerView('slow-restore:view', {
748
+ mount() {
749
+ order.push('mount');
750
+ return { unmount() { } };
751
+ },
752
+ });
753
+ const shard = makeShard({
754
+ manifest: makeShardManifest({ id: 'slow-restore-shard' }),
755
+ register() { },
756
+ async onLayoutWillRestore() {
757
+ order.push('onLayoutWillRestore:start');
758
+ await hookGate;
759
+ order.push('onLayoutWillRestore:end');
760
+ },
761
+ });
762
+ registerShard(shard);
763
+ const app = makeApp({
764
+ manifest: makeAppManifest({ id: 'slow-restore-app', requiredShards: ['slow-restore-shard'] }),
765
+ initialLayout: [
766
+ { name: 'default', tree: makeTree(makeSlotNode('sr-slot', 'slow-restore:view')) },
767
+ ],
768
+ });
769
+ registerApp(app);
770
+ const launching = launchApp('slow-restore-app');
771
+ // Let microtasks run so the hook has started (launchApp awaits shard
772
+ // activation and onAppActivate before reaching onLayoutWillRestore, so
773
+ // we need several drains to get past those).
774
+ for (let i = 0; i < 10; i++)
775
+ await Promise.resolve();
776
+ // At this point the hook is suspended on the gate; mount must NOT have run yet.
777
+ expect(order).toEqual(['onLayoutWillRestore:start']);
778
+ resolveHook();
779
+ await launching;
780
+ for (let i = 0; i < 5; i++)
781
+ await Promise.resolve();
782
+ // The hook completed before any view factory ran.
783
+ const hookEndIdx = order.indexOf('onLayoutWillRestore:end');
784
+ const mountIdx = order.indexOf('mount');
785
+ expect(hookEndIdx).toBeGreaterThanOrEqual(0);
786
+ expect(mountIdx).toBeGreaterThanOrEqual(0);
787
+ expect(hookEndIdx).toBeLessThan(mountIdx);
788
+ });
789
+ });
790
+ // ---------------------------------------------------------------------------
791
+ // Scenario E — document auto-scope: two shards in same app share namespace
792
+ // ---------------------------------------------------------------------------
793
+ describe('launchApp — document auto-scope', () => {
794
+ beforeEach(resetFramework);
795
+ it('routes ctx.documents() from two non-autostart shards in the same app to the appId namespace', async () => {
796
+ const { MemoryDocumentBackend } = await import('../documents/backends');
797
+ const { __setDocumentBackend } = await import('../documents/config');
798
+ const backend = new MemoryDocumentBackend();
799
+ __setDocumentBackend(backend);
800
+ let handleA = null;
801
+ let handleB = null;
802
+ registerShard(makeShard({
803
+ manifest: makeShardManifest({ id: 'shard-share-A' }),
804
+ register(ctx) { handleA = ctx.documents; },
805
+ }));
806
+ registerShard(makeShard({
807
+ manifest: makeShardManifest({ id: 'shard-share-B' }),
808
+ register(ctx) { handleB = ctx.documents; },
809
+ }));
810
+ registerApp(makeApp({
811
+ manifest: makeAppManifest({
812
+ id: 'app-share',
813
+ requiredShards: ['shard-share-A', 'shard-share-B'],
814
+ }),
815
+ }));
816
+ await launchApp('app-share');
817
+ await handleA.writeText('shared.json', '{"from":"A"}');
818
+ expect(await handleB.readText('shared.json')).toBe('{"from":"A"}');
819
+ expect(await backend.list('local', 'app-share')).toHaveLength(1);
820
+ expect(await backend.list('local', 'shard-share-A')).toHaveLength(0);
821
+ expect(await backend.list('local', 'shard-share-B')).toHaveLength(0);
822
+ });
823
+ });
824
+ // v3: autostart is gone. Every shard's document handle rotates to the
825
+ // active app's namespace via runAppActivate, no exceptions. See
826
+ // lifecycle.test.ts for the v3 binding-rotation coverage.
827
+ describe('unloadApp — clears document binding', () => {
828
+ beforeEach(resetFramework);
829
+ it('reverts a non-autostart shard back to shard-scope when the app is unloaded', async () => {
830
+ const { MemoryDocumentBackend } = await import('../documents/backends');
831
+ const { __setDocumentBackend } = await import('../documents/config');
832
+ const backend = new MemoryDocumentBackend();
833
+ __setDocumentBackend(backend);
834
+ const { getShardBinding } = await import('../shards/lifecycle.svelte');
835
+ registerShard(makeShard({
836
+ manifest: makeShardManifest({ id: 'binding-shard' }),
837
+ register() { },
838
+ }));
839
+ registerApp(makeApp({
840
+ manifest: makeAppManifest({
841
+ id: 'binding-app',
842
+ requiredShards: ['binding-shard'],
843
+ }),
844
+ }));
845
+ await launchApp('binding-app');
846
+ expect(getShardBinding('binding-shard')).toBe('binding-app');
847
+ unregisterApp('binding-app');
848
+ expect(getShardBinding('binding-shard')).toBeNull();
849
+ });
850
+ });
@@ -31,4 +31,6 @@ export interface ArtifactManifest {
31
31
  }>;
32
32
  /** Shard ids this app requires to be installed on the server. Written by sh3Artifact for app/combo bundles. */
33
33
  requiredShards?: string[];
34
+ /** Shard ids that are bundled inside this combo artifact. Written by sh3Artifact for combo bundles. */
35
+ bundledShards?: string[];
34
36
  }
package/dist/build.js CHANGED
@@ -362,7 +362,7 @@ export function sh3Artifact(options = {}) {
362
362
  if (!finalAuthor) {
363
363
  throw new Error('[sh3-artifact] Missing "author". Add it to package.json or pass it via sh3Artifact({ manifest: { author } }).');
364
364
  }
365
- const manifest = Object.assign(Object.assign(Object.assign(Object.assign({ id: id || 'unknown', type, label: label || id || 'unknown', version: artifactVersion, contractVersion: 1 }, (hasServer ? { server: 'server.js' } : {})), { description: finalDescription, author: finalAuthor }), ((type === 'app' || type === 'combo') ? { requiredShards } : {})), overrides);
365
+ const manifest = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ id: id || 'unknown', type, label: label || id || 'unknown', version: artifactVersion, contractVersion: 1 }, (hasServer ? { server: 'server.js' } : {})), { description: finalDescription, author: finalAuthor }), ((type === 'app' || type === 'combo') ? { requiredShards } : {})), (type === 'combo' && bundledShardIds.size > 0 ? { bundledShards: [...bundledShardIds] } : {})), overrides);
366
366
  // Read the emitted JS files as bytes for the archive
367
367
  const clientBytes = readFileSync(join(outDir, 'client.js'));
368
368
  const serverBytes = hasServer ? readFileSync(join(outDir, 'server.js')) : undefined;
@@ -19,8 +19,7 @@
19
19
  import { ConflictPermissionError, } from './api';
20
20
  import { resolvePrimitive } from './resolve-primitive';
21
21
  function ownShardHandle(ctx) {
22
- // No extension filter — the adapter takes caller-supplied paths, any type.
23
- const h = ctx.documents({ format: 'text' });
22
+ const h = ctx.documents;
24
23
  return {
25
24
  status: (p) => h.status(p),
26
25
  readBranch: (p, o) => h.readBranch(p, o),
@@ -14,7 +14,7 @@ import { apiFetch } from './transport/apiFetch';
14
14
  import { hydrateTokenOverrides } from './theme';
15
15
  import { __setEnvServerUrl, getEnvServerUrl } from './env/index';
16
16
  import { __setActiveScope, __setScopeResolver } from './documents/config';
17
- import { __setScopeResolver as __setShardScopeResolver } from './shards/activate.svelte';
17
+ import { __setScopeResolver as __setShardScopeResolver } from './shards/lifecycle.svelte';
18
18
  import { initFromBoot } from './auth/index';
19
19
  import SignInWall from './auth/SignInWall.svelte';
20
20
  import { loadBundleModule } from './registry/loader';
@@ -1,6 +1,11 @@
1
- import type { DocumentBackend, DocumentHandle, DocumentHandleOptions } from './types';
1
+ import type { DocumentBackend, DocumentHandle } from './types';
2
2
  /**
3
- * Create a document handle scoped to a tenant, shard, and file filter.
4
- * The framework calls this from `ShardContext.documents()`.
3
+ * Create a document handle scoped to a tenant and namespace. The framework
4
+ * pre-mints one handle per shard at boot; the namespace resolves lazily on
5
+ * every operation via `getShardBinding(shardId) ?? shardId` so it follows
6
+ * the shard's currently-bound app without re-minting.
7
+ *
8
+ * Format moves from the handle to per-call (readText/writeText/readJson/
9
+ * writeJson/readBinary/writeBinary) — see ADR-027.
5
10
  */
6
- export declare function createDocumentHandle(tenantId: string, shardId: string, backend: DocumentBackend, options: DocumentHandleOptions): DocumentHandle;
11
+ export declare function createDocumentHandle(tenantId: string | (() => string), shardOrNamespace: string | (() => string), backend: DocumentBackend): DocumentHandle;