sh3-core 0.7.1 → 0.7.5

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 (61) hide show
  1. package/dist/Shell.svelte +3 -2
  2. package/dist/__test__/fixtures.d.ts +12 -0
  3. package/dist/__test__/fixtures.js +62 -0
  4. package/dist/__test__/render.d.ts +3 -0
  5. package/dist/__test__/render.js +11 -0
  6. package/dist/__test__/reset.d.ts +14 -0
  7. package/dist/__test__/reset.js +34 -0
  8. package/dist/__test__/setup-dom.d.ts +1 -0
  9. package/dist/__test__/setup-dom.js +26 -0
  10. package/dist/__test__/smoke.test.d.ts +1 -0
  11. package/dist/__test__/smoke.test.js +28 -0
  12. package/dist/api.d.ts +4 -0
  13. package/dist/apps/lifecycle.js +27 -10
  14. package/dist/apps/lifecycle.test.d.ts +1 -0
  15. package/dist/apps/lifecycle.test.js +260 -0
  16. package/dist/apps/registry.svelte.d.ts +2 -0
  17. package/dist/apps/registry.svelte.js +5 -0
  18. package/dist/apps/types.d.ts +17 -0
  19. package/dist/contract.d.ts +10 -0
  20. package/dist/contract.js +10 -0
  21. package/dist/layout/LayoutRenderer.browser.test.d.ts +1 -0
  22. package/dist/layout/LayoutRenderer.browser.test.js +274 -0
  23. package/dist/layout/LayoutRenderer.svelte +2 -1
  24. package/dist/layout/LayoutRenderer.test.d.ts +1 -0
  25. package/dist/layout/LayoutRenderer.test.js +143 -0
  26. package/dist/layout/SlotContainer.svelte +8 -2
  27. package/dist/layout/SlotDropZone.svelte +19 -0
  28. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-1-drag-tab-between-groups-moves-a-tab-from-one-tabs-group-to-another-1.png +0 -0
  29. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-2-drag-tab-to-quadrant-creates-a-split-when-dropping-a-tab-on-a-quadrant-drop-zone-1.png +0 -0
  30. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
  31. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-4-close-policy-removes-closable-tabs--keeps-non-closable--and-awaits-canClose-1.png +0 -0
  32. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-1.png +0 -0
  33. package/dist/layout/drag.svelte.d.ts +5 -0
  34. package/dist/layout/drag.svelte.js +15 -0
  35. package/dist/layout/inspection.js +58 -7
  36. package/dist/layout/ops.js +25 -6
  37. package/dist/layout/ops.test.js +51 -1
  38. package/dist/layout/slotHostPool.svelte.d.ts +16 -1
  39. package/dist/layout/slotHostPool.svelte.js +124 -6
  40. package/dist/layout/slotHostPool.test.d.ts +1 -0
  41. package/dist/layout/slotHostPool.test.js +104 -0
  42. package/dist/layout/store.svelte.d.ts +22 -0
  43. package/dist/layout/store.svelte.js +80 -18
  44. package/dist/layout/tree-walk.d.ts +2 -0
  45. package/dist/layout/tree-walk.js +1 -1
  46. package/dist/layout/types.d.ts +5 -0
  47. package/dist/overlays/FloatFrame.svelte +1 -0
  48. package/dist/overlays/float.d.ts +2 -0
  49. package/dist/overlays/float.js +4 -1
  50. package/dist/overlays/float.test.js +102 -1
  51. package/dist/primitives/ResizableSplitter.svelte +2 -0
  52. package/dist/primitives/TabbedPanel.svelte +4 -0
  53. package/dist/primitives/TabbedPanel.svelte.d.ts +2 -0
  54. package/dist/shards/activate.svelte.d.ts +6 -0
  55. package/dist/shards/activate.svelte.js +10 -0
  56. package/dist/shards/registry.d.ts +4 -0
  57. package/dist/shards/registry.js +18 -0
  58. package/dist/shards/types.d.ts +6 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/package.json +9 -1
@@ -195,6 +195,24 @@ export function makeSplitWithNewTab(existing, entry, side) {
195
195
  children,
196
196
  };
197
197
  }
198
+ /**
199
+ * Shallow-clone a layout node. Used by splitNodeAtPath's root case to
200
+ * break the circular reference that would occur if the root object
201
+ * (mutated in-place) were embedded as its own child.
202
+ */
203
+ function snapshotNode(node) {
204
+ if (node.type === 'tabs')
205
+ return Object.assign(Object.assign({}, node), { tabs: [...node.tabs] });
206
+ if (node.type === 'split') {
207
+ const clone = Object.assign(Object.assign({}, node), { children: [...node.children], sizes: [...node.sizes] });
208
+ if (node.pinned)
209
+ clone.pinned = [...node.pinned];
210
+ if (node.collapsed)
211
+ clone.collapsed = [...node.collapsed];
212
+ return clone;
213
+ }
214
+ return Object.assign({}, node); // slot — plain shallow copy
215
+ }
198
216
  /**
199
217
  * Apply a slot-split as a tree mutation: find the target node at the
200
218
  * given path and replace it with a new split. Handles the root case
@@ -205,14 +223,13 @@ export function splitNodeAtPath(root, path, entry, side) {
205
223
  const target = nodeAtPath(root, path);
206
224
  if (!target)
207
225
  return;
208
- const replacement = makeSplitWithNewTab(target, entry, side);
209
226
  if (path.length === 0) {
210
- // Replace root contents in place. The root is a discriminated
211
- // union; to rewrite it we cast once through `unknown` and then to
212
- // a loose record shape, overwrite fields, and clear stale keys
213
- // from the previous node kind so the union stays well-formed.
227
+ // Root case: target IS root. Snapshot it so makeSplitWithNewTab
228
+ // embeds the clone, not root itself avoids a circular reference
229
+ // when we Object.assign the replacement back onto root.
230
+ const snapshot = snapshotNode(target);
231
+ const replacement = makeSplitWithNewTab(snapshot, entry, side);
214
232
  const rootAsRecord = root;
215
- // Clear stale keys first so Object.assign doesn't leave a hybrid.
216
233
  delete rootAsRecord.tabs;
217
234
  delete rootAsRecord.activeTab;
218
235
  delete rootAsRecord.slotId;
@@ -220,10 +237,12 @@ export function splitNodeAtPath(root, path, entry, side) {
220
237
  delete rootAsRecord.direction;
221
238
  delete rootAsRecord.sizes;
222
239
  delete rootAsRecord.pinned;
240
+ delete rootAsRecord.collapsed;
223
241
  delete rootAsRecord.children;
224
242
  Object.assign(rootAsRecord, replacement);
225
243
  return;
226
244
  }
245
+ const replacement = makeSplitWithNewTab(target, entry, side);
227
246
  const parentPath = path.slice(0, -1);
228
247
  const indexInParent = path[path.length - 1];
229
248
  const parent = nodeAtPath(root, parentPath);
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { findTabInTree } from './ops';
2
+ import { findTabInTree, splitNodeAtPath, cleanupTree, findTabBySlotId } from './ops';
3
3
  describe('findTabInTree', () => {
4
4
  const tree = {
5
5
  docked: {
@@ -34,3 +34,53 @@ describe('findTabInTree', () => {
34
34
  expect(findTabInTree(tree, 'nonexistent')).toBeNull();
35
35
  });
36
36
  });
37
+ describe('splitNodeAtPath — root case (path = [])', () => {
38
+ it('does not create a circular reference when splitting the root', () => {
39
+ // splitNodeAtPath mutates root in-place (tabs → split). Cast to
40
+ // LayoutNode so TS doesn't narrow from the initializer.
41
+ const root = {
42
+ type: 'tabs',
43
+ tabs: [{ slotId: 's1', viewId: 'v1', label: 'One' }],
44
+ activeTab: 0,
45
+ };
46
+ const entry = { slotId: 's2', viewId: 'v2', label: 'Two' };
47
+ splitNodeAtPath(root, [], entry, 'right');
48
+ // After split, root should be a split node with two children.
49
+ // Neither child should be root itself (no circular ref).
50
+ expect(root.type).toBe('split');
51
+ if (root.type === 'split') {
52
+ for (const child of root.children) {
53
+ expect(child).not.toBe(root);
54
+ }
55
+ }
56
+ });
57
+ it('cleanupTree does not infinite-loop after splitting the root', () => {
58
+ const root = {
59
+ type: 'tabs',
60
+ tabs: [{ slotId: 's1', viewId: 'v1', label: 'One' }],
61
+ activeTab: 0,
62
+ };
63
+ const entry = { slotId: 's2', viewId: 'v2', label: 'Two' };
64
+ splitNodeAtPath(root, [], entry, 'right');
65
+ // This must terminate. Before the fix it infinite-loops.
66
+ cleanupTree(root);
67
+ // Both tabs should still be findable.
68
+ expect(findTabBySlotId(root, 's1')).not.toBeNull();
69
+ expect(findTabBySlotId(root, 's2')).not.toBeNull();
70
+ });
71
+ it('works when root is a slot leaf', () => {
72
+ const root = {
73
+ type: 'slot',
74
+ slotId: 'leaf',
75
+ viewId: 'v',
76
+ };
77
+ const entry = { slotId: 's2', viewId: 'v2', label: 'Two' };
78
+ splitNodeAtPath(root, [], entry, 'bottom');
79
+ expect(root.type).toBe('split');
80
+ if (root.type === 'split') {
81
+ for (const child of root.children) {
82
+ expect(child).not.toBe(root);
83
+ }
84
+ }
85
+ });
86
+ });
@@ -5,7 +5,7 @@ import type { ViewHandle } from '../shards/types';
5
5
  * the pool does not know which wrapper owns the host at any given time,
6
6
  * and that is intentional. The same host may be passed around.
7
7
  */
8
- export declare function acquireSlotHost(slotId: string, viewId: string | null, label: string): HTMLDivElement;
8
+ export declare function acquireSlotHost(slotId: string, viewId: string | null, label: string, meta?: Record<string, unknown>): HTMLDivElement;
9
9
  /**
10
10
  * Release the pooled host. If this was the last reference, a
11
11
  * destruction is queued to run in a microtask; a later acquire before
@@ -34,3 +34,18 @@ export declare function isSlotDirty(slotId: string): boolean;
34
34
  * re-render when the deferred mount sets the flag.
35
35
  */
36
36
  export declare function isSlotClosable(slotId: string): boolean;
37
+ /**
38
+ * Test-only: set the closable policy for a slot without going through a
39
+ * ViewFactory. Call BEFORE launchApp so the tab strip reads the right
40
+ * closable state when it renders.
41
+ *
42
+ * `true` — tab shows a close button and is removed on click.
43
+ * `false` — tab shows no close button (non-closable).
44
+ */
45
+ export declare function setSlotClosableForTest(slotId: string, closable: boolean): void;
46
+ /**
47
+ * Test-only: attach a canClose guard to a slot. The slot must have been
48
+ * marked closable via `setSlotClosableForTest` first (or the guard will
49
+ * be installed alongside a `true` closable flag).
50
+ */
51
+ export declare function setSlotCanCloseForTest(slotId: string, canClose: () => Promise<boolean>): void;
@@ -32,9 +32,59 @@
32
32
  * edge-case escape hatch reserved by the design. Not wired yet —
33
33
  * phase 6 has no view that needs it.
34
34
  */
35
- import { getView } from '../shards/registry';
35
+ import { getView, __addViewRegistrationListener } from '../shards/registry';
36
36
  const pool = new Map();
37
37
  const pendingDestroy = new Set();
38
+ /**
39
+ * Called by the registry whenever a new ViewFactory is registered.
40
+ * Scans the pool for entries that have the matching viewId but were not
41
+ * mounted (because the factory wasn't available at acquire-time). Mounts
42
+ * them now.
43
+ *
44
+ * This handles the "late factory registration" case: a shard activates
45
+ * after the layout has already acquired a slot for its view. Rather than
46
+ * requiring the layout to re-acquire, the pool retries the mount here.
47
+ */
48
+ function onViewRegistered(viewId, factory) {
49
+ for (const [slotId, entry] of pool.entries()) {
50
+ if (entry.viewId !== viewId || entry.handle !== undefined)
51
+ continue;
52
+ // Entry is in the pool, matches the view, and has no handle yet —
53
+ // the factory wasn't available when the microtask ran. Mount now.
54
+ const ctx = {
55
+ slotId,
56
+ viewId,
57
+ label: entry.label,
58
+ meta: entry.meta,
59
+ setDirty(dirty) {
60
+ dirtyState[slotId] = dirty;
61
+ },
62
+ };
63
+ queueMicrotask(() => {
64
+ var _a, _b;
65
+ // Re-check: entry may have been released before this microtask fires.
66
+ if (!pool.has(slotId))
67
+ return;
68
+ if (entry.handle !== undefined)
69
+ return; // already mounted by a race
70
+ entry.handle = factory.mount(entry.host, ctx);
71
+ if (((_a = entry.handle) === null || _a === void 0 ? void 0 : _a.closable) || slotId.startsWith('float:')) {
72
+ closableState[slotId] = true;
73
+ }
74
+ if ((_b = entry.handle) === null || _b === void 0 ? void 0 : _b.onResize) {
75
+ const onResize = entry.handle.onResize.bind(entry.handle);
76
+ entry.resizeObserver = new ResizeObserver((entries) => {
77
+ for (const e of entries) {
78
+ const box = e.contentRect;
79
+ onResize(Math.round(box.width), Math.round(box.height));
80
+ }
81
+ });
82
+ entry.resizeObserver.observe(entry.host);
83
+ }
84
+ });
85
+ }
86
+ }
87
+ __addViewRegistrationListener(onViewRegistered);
38
88
  /**
39
89
  * Reactive dirty-state map. Keyed by slotId, values are $state so
40
90
  * Svelte tracks reads in `isSlotDirty()` and re-renders the tab strip
@@ -75,7 +125,7 @@ const closableState = $state({});
75
125
  * is destroyed before its deferred mount ever runs (e.g. rapid
76
126
  * add-then-remove of a slot during a drag).
77
127
  */
78
- function createHost(slotId, viewId, label) {
128
+ function createHost(slotId, viewId, label, meta) {
79
129
  const host = document.createElement('div');
80
130
  host.className = 'slot-host';
81
131
  host.dataset.slotId = slotId;
@@ -93,6 +143,7 @@ function createHost(slotId, viewId, label) {
93
143
  handle: undefined,
94
144
  viewId,
95
145
  label,
146
+ meta,
96
147
  refcount: 0,
97
148
  resizeObserver: undefined,
98
149
  cancelPendingMount: () => {
@@ -108,12 +159,13 @@ function createHost(slotId, viewId, label) {
108
159
  slotId,
109
160
  viewId: viewId !== null && viewId !== void 0 ? viewId : '',
110
161
  label,
162
+ meta,
111
163
  setDirty(dirty) {
112
164
  dirtyState[slotId] = dirty;
113
165
  },
114
166
  };
115
167
  entry.handle = factory === null || factory === void 0 ? void 0 : factory.mount(host, ctx);
116
- if ((_a = entry.handle) === null || _a === void 0 ? void 0 : _a.closable) {
168
+ if (((_a = entry.handle) === null || _a === void 0 ? void 0 : _a.closable) || slotId.startsWith('float:')) {
117
169
  closableState[slotId] = true;
118
170
  }
119
171
  // The pool owns the ResizeObserver so its lifetime matches the
@@ -140,13 +192,13 @@ function createHost(slotId, viewId, label) {
140
192
  * the pool does not know which wrapper owns the host at any given time,
141
193
  * and that is intentional. The same host may be passed around.
142
194
  */
143
- export function acquireSlotHost(slotId, viewId, label) {
195
+ export function acquireSlotHost(slotId, viewId, label, meta) {
144
196
  // If the slot was about to be destroyed, cancel — this acquire is the
145
197
  // "other half" of a re-parent (teardown was the previous container).
146
198
  pendingDestroy.delete(slotId);
147
199
  let entry = pool.get(slotId);
148
200
  if (!entry) {
149
- entry = createHost(slotId, viewId, label);
201
+ entry = createHost(slotId, viewId, label, meta);
150
202
  pool.set(slotId, entry);
151
203
  }
152
204
  entry.refcount++;
@@ -162,8 +214,16 @@ export function releaseSlotHost(slotId) {
162
214
  if (!entry)
163
215
  return;
164
216
  entry.refcount--;
165
- if (entry.refcount > 0)
217
+ if (entry.refcount > 0) {
218
+ // Refcount is still > 0 (e.g. acquireAppSlotHolds holds a ref), but
219
+ // the renderer releasing this slot is done with it. Detach the host
220
+ // from its current DOM parent so it doesn't remain visible in the old
221
+ // SlotContainer. The pool entry (and view) stays alive for re-acquisition
222
+ // — for example, a preset switch back to this slot's preset will re-append
223
+ // the host to a new SlotContainer without destroying the view.
224
+ entry.host.remove();
166
225
  return;
226
+ }
167
227
  pendingDestroy.add(slotId);
168
228
  queueMicrotask(() => {
169
229
  var _a, _b;
@@ -200,6 +260,7 @@ export function resetSlotHostPool() {
200
260
  delete dirtyState[key];
201
261
  for (const key of Object.keys(closableState))
202
262
  delete closableState[key];
263
+ handleOverrides.clear();
203
264
  }
204
265
  /**
205
266
  * Read the current ViewHandle for a slot. Returns undefined if the slot
@@ -208,6 +269,9 @@ export function resetSlotHostPool() {
208
269
  */
209
270
  export function getSlotHandle(slotId) {
210
271
  var _a;
272
+ const override = handleOverrides.get(slotId);
273
+ if (override)
274
+ return override;
211
275
  return (_a = pool.get(slotId)) === null || _a === void 0 ? void 0 : _a.handle;
212
276
  }
213
277
  /**
@@ -225,5 +289,59 @@ export function isSlotDirty(slotId) {
225
289
  */
226
290
  export function isSlotClosable(slotId) {
227
291
  var _a;
292
+ if (handleOverrides.has(slotId)) {
293
+ const h = handleOverrides.get(slotId);
294
+ return !!h.closable;
295
+ }
228
296
  return (_a = closableState[slotId]) !== null && _a !== void 0 ? _a : false;
229
297
  }
298
+ // ---------------------------------------------------------------------------
299
+ // Test-only handle override map — lets tests inject closable/canClose policy
300
+ // without needing a real ViewFactory. Not exported from src/index.ts.
301
+ // ---------------------------------------------------------------------------
302
+ /**
303
+ * Map of slotId → synthetic ViewHandle injected by tests. These take
304
+ * priority over the pool's real handles in `getSlotHandle` and
305
+ * `isSlotClosable`. Cleared by `resetSlotHostPool`.
306
+ */
307
+ const handleOverrides = new Map();
308
+ /**
309
+ * Test-only: set the closable policy for a slot without going through a
310
+ * ViewFactory. Call BEFORE launchApp so the tab strip reads the right
311
+ * closable state when it renders.
312
+ *
313
+ * `true` — tab shows a close button and is removed on click.
314
+ * `false` — tab shows no close button (non-closable).
315
+ */
316
+ export function setSlotClosableForTest(slotId, closable) {
317
+ const existing = handleOverrides.get(slotId);
318
+ if (existing) {
319
+ existing.closable = closable;
320
+ }
321
+ else {
322
+ handleOverrides.set(slotId, { unmount() { }, closable });
323
+ }
324
+ // Keep reactive closableState in sync so the tab strip re-renders.
325
+ if (closable) {
326
+ closableState[slotId] = true;
327
+ }
328
+ else {
329
+ delete closableState[slotId];
330
+ }
331
+ }
332
+ /**
333
+ * Test-only: attach a canClose guard to a slot. The slot must have been
334
+ * marked closable via `setSlotClosableForTest` first (or the guard will
335
+ * be installed alongside a `true` closable flag).
336
+ */
337
+ export function setSlotCanCloseForTest(slotId, canClose) {
338
+ const existing = handleOverrides.get(slotId);
339
+ if (existing) {
340
+ existing.closable = { canClose };
341
+ }
342
+ else {
343
+ handleOverrides.set(slotId, { unmount() { }, closable: { canClose } });
344
+ }
345
+ // Guarded slots are still considered "closable" for tab strip rendering.
346
+ closableState[slotId] = true;
347
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { tick } from 'svelte';
3
+ import { resetFramework } from '../__test__/reset';
4
+ import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
5
+ import { registerView } from '../shards/registry';
6
+ import SlotContainer from './SlotContainer.svelte';
7
+ import { renderWithShell } from '../__test__/render';
8
+ import { switchToHome, switchToApp } from './store.svelte';
9
+ import { makeApp, makeAppManifest, makeSlotNode, makeTree } from '../__test__/fixtures';
10
+ import { registerApp } from '../apps/registry.svelte';
11
+ import { launchApp } from '../apps/lifecycle';
12
+ // ─── D.1 ─────────────────────────────────────────────────────────────────────
13
+ describe('slotHostPool — D.1 refcount destroys once', () => {
14
+ beforeEach(resetFramework);
15
+ it('runs the factory teardown exactly once after the final release, in a microtask', async () => {
16
+ const teardown = vi.fn();
17
+ // ViewFactory is an object: { mount(el, ctx): ViewHandle }
18
+ // ViewHandle has unmount(), NOT dispose()
19
+ registerView('v', {
20
+ mount: () => ({
21
+ unmount: teardown,
22
+ }),
23
+ });
24
+ acquireSlotHost('s1', 'v', 's1');
25
+ acquireSlotHost('s1', 'v', 's1');
26
+ releaseSlotHost('s1');
27
+ await Promise.resolve();
28
+ expect(teardown).not.toHaveBeenCalled();
29
+ releaseSlotHost('s1');
30
+ await Promise.resolve();
31
+ expect(teardown).toHaveBeenCalledTimes(1);
32
+ });
33
+ });
34
+ // ─── D.2 ─────────────────────────────────────────────────────────────────────
35
+ describe('slotHostPool — D.2 re-acquire cancels destroy', () => {
36
+ beforeEach(resetFramework);
37
+ it('does not destroy the host when released and re-acquired in the same microtask', async () => {
38
+ const teardown = vi.fn();
39
+ registerView('v', {
40
+ mount: () => ({
41
+ unmount: teardown,
42
+ }),
43
+ });
44
+ acquireSlotHost('s2', 'v', 's2');
45
+ releaseSlotHost('s2');
46
+ acquireSlotHost('s2', 'v', 's2');
47
+ await Promise.resolve();
48
+ expect(teardown).not.toHaveBeenCalled();
49
+ });
50
+ });
51
+ // ─── D.3 ─────────────────────────────────────────────────────────────────────
52
+ describe('slotHostPool — D.3 late factory registration', () => {
53
+ beforeEach(resetFramework);
54
+ it('mounts the view when a factory registers after acquireSlotHost', async () => {
55
+ const mount = vi.fn((el) => {
56
+ const span = document.createElement('span');
57
+ span.dataset.mountedFor = 'late';
58
+ el.appendChild(span);
59
+ return {
60
+ unmount: () => span.remove(),
61
+ };
62
+ });
63
+ acquireSlotHost('late-slot', 'late:view', 'late-slot');
64
+ await Promise.resolve();
65
+ registerView('late:view', { mount });
66
+ await Promise.resolve();
67
+ expect(mount).toHaveBeenCalledTimes(1);
68
+ });
69
+ });
70
+ // ─── D.4 ─────────────────────────────────────────────────────────────────────
71
+ describe('slotHostPool — D.4 shared host across containers', () => {
72
+ beforeEach(resetFramework);
73
+ it('mounts the factory once across two SlotContainers for the same slotId', async () => {
74
+ const mount = vi.fn((_el) => ({ unmount: () => { } }));
75
+ registerView('shared:view', { mount });
76
+ // SlotNode for both containers — same slotId, same viewId
77
+ const node = { type: 'slot', slotId: 'shared', viewId: 'shared:view' };
78
+ renderWithShell(SlotContainer, { node, label: 'A' });
79
+ renderWithShell(SlotContainer, { node, label: 'B' });
80
+ await tick();
81
+ expect(mount).toHaveBeenCalledTimes(1);
82
+ });
83
+ });
84
+ // ─── D.5 ─────────────────────────────────────────────────────────────────────
85
+ describe('slotHostPool — D.5 root swap preserves app slots', () => {
86
+ beforeEach(resetFramework);
87
+ it('does not destroy app-held pooled hosts when swapping app to home and back', async () => {
88
+ const teardown = vi.fn();
89
+ registerView('persist:view', { mount: () => ({ unmount: teardown }) });
90
+ registerApp(makeApp({
91
+ manifest: makeAppManifest({ id: 'd5' }),
92
+ initialLayout: [
93
+ { name: 'default', tree: makeTree(makeSlotNode('d5-slot', 'persist:view')) },
94
+ ],
95
+ }));
96
+ await launchApp('d5');
97
+ await Promise.resolve();
98
+ switchToHome();
99
+ await Promise.resolve();
100
+ switchToApp();
101
+ await Promise.resolve();
102
+ expect(teardown).not.toHaveBeenCalled();
103
+ });
104
+ });
@@ -7,6 +7,22 @@ import type { App } from '../apps/types';
7
7
  * Does NOT switch the active root. Call switchToApp() separately.
8
8
  */
9
9
  export declare function attachApp(app: App): void;
10
+ /**
11
+ * Second-phase attach: take refcount holds on every slot in the active
12
+ * preset's tree. Must run AFTER required shards have activated (and
13
+ * therefore registered their view factories), so the pool's microtask
14
+ * factory lookup sees them. See the refcount-hold discipline comment at
15
+ * the top of this module and the createHost microtask in slotHostPool.
16
+ *
17
+ * TODO(preset-switch leak): holds are taken for the INITIAL preset only
18
+ * and released only in detachApp. On presetManager.switch(), the old
19
+ * preset's slot hosts stay pool-resident with refcount >= 1 (view handle
20
+ * and ResizeObserver not torn down) for the lifetime of the app attach.
21
+ * Acceptable for small preset sets; revisit if presets become churnier.
22
+ * Proper fix: re-scope holds on preset switch, or make this first-frame
23
+ * only and let renderers own all refcounts.
24
+ */
25
+ export declare function acquireAppSlotHolds(): void;
10
26
  /**
11
27
  * Detach the currently-attached app. Releases its refcount holds; the
12
28
  * pool's microtask cleanup drops the pooled hosts if they also have no
@@ -41,3 +57,9 @@ export declare const layoutStore: {
41
57
  readonly tree: LayoutTree;
42
58
  readonly floats: FloatEntry[];
43
59
  };
60
+ /**
61
+ * Test-only reset. Restores the layout store to its boot state: no app
62
+ * attached, active root = 'home'. Not exported from `src/index.ts` —
63
+ * tests import this submodule path directly.
64
+ */
65
+ export declare function __resetLayoutStoreForTest(): void;
@@ -50,10 +50,10 @@ const HOME_LAYOUT = {
50
50
  slotId: 'sh3core.home',
51
51
  viewId: 'sh3core:home',
52
52
  };
53
- const HOME_TREE = {
53
+ const HOME_TREE = $state({
54
54
  docked: HOME_LAYOUT,
55
55
  floats: [],
56
- };
56
+ });
57
57
  let appEntry = $state(null);
58
58
  let activeRoot = $state('home');
59
59
  // ---------- read-side adapter helpers -------------------------------------
@@ -133,17 +133,40 @@ export function attachApp(app) {
133
133
  workspace: defaultBlob,
134
134
  });
135
135
  const proxy = state.workspace;
136
- // Take refcount holds on every slot in the active preset's tree
137
- // (including floats). See the refcount-hold discipline comment at top.
138
- const tree = currentTree(proxy);
136
+ // Create the entry with no slot holds yet; `acquireAppSlotHolds` does
137
+ // that as a second phase, after shards have had a chance to register
138
+ // their view factories. Binding the preset manager proxy happens here
139
+ // so shards can read/switch presets from their activate() hook.
140
+ appEntry = { appId: app.manifest.id, proxy, heldSlotIds: [] };
141
+ bindPresetBlob(proxy);
142
+ }
143
+ /**
144
+ * Second-phase attach: take refcount holds on every slot in the active
145
+ * preset's tree. Must run AFTER required shards have activated (and
146
+ * therefore registered their view factories), so the pool's microtask
147
+ * factory lookup sees them. See the refcount-hold discipline comment at
148
+ * the top of this module and the createHost microtask in slotHostPool.
149
+ *
150
+ * TODO(preset-switch leak): holds are taken for the INITIAL preset only
151
+ * and released only in detachApp. On presetManager.switch(), the old
152
+ * preset's slot hosts stay pool-resident with refcount >= 1 (view handle
153
+ * and ResizeObserver not torn down) for the lifetime of the app attach.
154
+ * Acceptable for small preset sets; revisit if presets become churnier.
155
+ * Proper fix: re-scope holds on preset switch, or make this first-frame
156
+ * only and let renderers own all refcounts.
157
+ */
158
+ export function acquireAppSlotHolds() {
159
+ if (!appEntry) {
160
+ throw new Error('acquireAppSlotHolds: no app attached');
161
+ }
162
+ if (appEntry.heldSlotIds.length > 0)
163
+ return; // idempotent
164
+ const tree = currentTree(appEntry.proxy);
139
165
  const refs = collectTreeSlotRefs(tree);
140
- const heldSlotIds = [];
141
- for (const { slotId, viewId, label } of refs) {
142
- acquireSlotHost(slotId, viewId, label);
143
- heldSlotIds.push(slotId);
166
+ for (const { slotId, viewId, label, meta } of refs) {
167
+ acquireSlotHost(slotId, viewId, label, meta);
168
+ appEntry.heldSlotIds.push(slotId);
144
169
  }
145
- appEntry = { appId: app.manifest.id, proxy, heldSlotIds };
146
- bindPresetBlob(proxy);
147
170
  }
148
171
  /**
149
172
  * Detach the currently-attached app. Releases its refcount holds; the
@@ -171,6 +194,33 @@ export function switchToApp() {
171
194
  }
172
195
  activeRoot = 'app';
173
196
  }
197
+ // ---------- `layoutStore` back-compat shim -------------------------------
198
+ /**
199
+ * Reactive-derived active LayoutTree. Using `$derived.by` gives us a
200
+ * stable, memoized reactive node that downstream accessors
201
+ * (`layoutStore.root`, `.tree`, `.floats`) all subscribe to once —
202
+ * instead of re-running the proxy-read chain on every access.
203
+ *
204
+ * Reading `blob.activePreset` by name inside this derived is load-bearing:
205
+ * it registers the preset-name signal as a dependency, so
206
+ * `presetManager.switch()` invalidates the derived and triggers re-render.
207
+ * Do not collapse this into a plain function that only reads the preset
208
+ * object — the derived would still recompute, but consumers that cached
209
+ * the result via a different path could miss the invalidation.
210
+ */
211
+ const activeTree = $derived.by(() => {
212
+ if (activeRoot === 'app' && appEntry) {
213
+ // Read activePreset by name explicitly so Svelte tracks this signal.
214
+ const blob = appEntry.proxy;
215
+ const presetName = blob.activePreset;
216
+ const preset = blob.presets[presetName];
217
+ if (!preset) {
218
+ throw new Error(`AppLayoutBlob active preset "${presetName}" not found in presets map`);
219
+ }
220
+ return preset.default;
221
+ }
222
+ return HOME_TREE;
223
+ });
174
224
  /**
175
225
  * The currently-rendered LayoutTree. LayoutRenderer reads this via
176
226
  * layoutStore.tree. Home uses a framework constant; app reads the
@@ -178,9 +228,11 @@ export function switchToApp() {
178
228
  * mutations from splitter/drag/ops reach the renderer unchanged).
179
229
  */
180
230
  export function activeLayout() {
181
- if (activeRoot === 'app' && appEntry)
182
- return currentTree(appEntry.proxy);
183
- return HOME_TREE;
231
+ // Delegates to the $derived.by so callers outside component contexts
232
+ // (e.g. inspection.ts, ops.ts) still read the correct tree. The $derived
233
+ // is recalculated whenever activeRoot, appEntry, or blob.activePreset
234
+ // changes, so these callers always see a fresh snapshot.
235
+ return activeTree;
184
236
  }
185
237
  export function getActiveRoot() {
186
238
  return activeRoot;
@@ -189,7 +241,6 @@ export function getAttachedAppId() {
189
241
  var _a;
190
242
  return (_a = appEntry === null || appEntry === void 0 ? void 0 : appEntry.appId) !== null && _a !== void 0 ? _a : null;
191
243
  }
192
- // ---------- `layoutStore` back-compat shim -------------------------------
193
244
  /**
194
245
  * Preserved for callers that still read `layoutStore.root`. The `root`
195
246
  * getter is an alias for `tree.docked` so existing callers still compile
@@ -204,12 +255,23 @@ export function getAttachedAppId() {
204
255
  */
205
256
  export const layoutStore = {
206
257
  get root() {
207
- return activeLayout().docked;
258
+ return activeTree.docked;
208
259
  },
209
260
  get tree() {
210
- return activeLayout();
261
+ return activeTree;
211
262
  },
212
263
  get floats() {
213
- return activeLayout().floats;
264
+ return activeTree.floats;
214
265
  },
215
266
  };
267
+ /**
268
+ * Test-only reset. Restores the layout store to its boot state: no app
269
+ * attached, active root = 'home'. Not exported from `src/index.ts` —
270
+ * tests import this submodule path directly.
271
+ */
272
+ export function __resetLayoutStoreForTest() {
273
+ appEntry = null;
274
+ activeRoot = 'home';
275
+ HOME_TREE.floats.length = 0;
276
+ HOME_TREE.docked = HOME_LAYOUT;
277
+ }
@@ -12,6 +12,7 @@ export declare function collectSlotRefs(tree: LayoutNode): {
12
12
  slotId: string;
13
13
  viewId: string | null;
14
14
  label: string;
15
+ meta?: Record<string, unknown>;
15
16
  }[];
16
17
  /**
17
18
  * Multi-root version of `collectSlotRefs`: walks the docked tree first
@@ -23,4 +24,5 @@ export declare function collectTreeSlotRefs(tree: LayoutTree): {
23
24
  slotId: string;
24
25
  viewId: string | null;
25
26
  label: string;
27
+ meta?: Record<string, unknown>;
26
28
  }[];
@@ -20,7 +20,7 @@ export function collectSlotRefs(tree) {
20
20
  }
21
21
  if (node.type === 'tabs') {
22
22
  for (const t of node.tabs) {
23
- out.push({ slotId: t.slotId, viewId: t.viewId, label: t.label });
23
+ out.push({ slotId: t.slotId, viewId: t.viewId, label: t.label, meta: t.meta });
24
24
  }
25
25
  return;
26
26
  }
@@ -38,6 +38,11 @@ export interface TabEntry {
38
38
  label: string;
39
39
  /** Optional icon hint (not yet rendered in phase 8). */
40
40
  icon?: string;
41
+ /**
42
+ * Caller-supplied instance data, threaded to `MountContext.meta`.
43
+ * Ephemeral — not serialized with the layout tree.
44
+ */
45
+ meta?: Record<string, unknown>;
41
46
  }
42
47
  /**
43
48
  * A layout node that groups one or more slots as tabs, showing one at a time.