@poveste/plugin-vue 0.5.3 → 0.6.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.
@@ -4,15 +4,22 @@ import { reactive as s } from "vue";
4
4
  //#region src/client/app/MountStory.ts
5
5
  var c = t({
6
6
  name: "MountStory",
7
- props: { story: {
8
- type: Object,
9
- required: !0
10
- } },
7
+ props: {
8
+ story: {
9
+ type: Object,
10
+ required: !0
11
+ },
12
+ targetVariantId: {
13
+ type: String,
14
+ default: null
15
+ }
16
+ },
11
17
  setup(t) {
12
18
  let n = a(), c = s({
13
19
  mode: "mount",
14
20
  slotName: "default",
15
21
  currentVariant: null,
22
+ targetVariantId: t.targetVariantId,
16
23
  externalState: null,
17
24
  nextVariantIndex: { value: 0 }
18
25
  }), l;
@@ -34,6 +41,8 @@ var c = t({
34
41
  await u();
35
42
  }), o(() => t.story.variants, () => {
36
43
  l?.forceUpdate();
44
+ }), o(() => t.targetVariantId, (e) => {
45
+ c.targetVariantId = e;
37
46
  }), i(() => {
38
47
  d();
39
48
  }), { el: n };
@@ -59,29 +59,46 @@ var f = i({
59
59
  updateStory: _
60
60
  };
61
61
  function v() {
62
- let e = 0, r = (a) => {
63
- let s = [];
64
- for (let c of a) {
65
- if (c.type?.__povesteType === "variant") {
66
- let r = o.story.variants[e];
67
- if (e++, !r) continue;
68
- let a = { variant: r };
69
- !c.props?.initState && !c.props?.["init-state"] && (a.initState = t.initState);
70
- for (let e in i.proxy.$attrs) c.props?.[e] === void 0 && (a[e] = i.proxy.$attrs[e]);
71
- for (let e in o.story) !d.includes(e) && c.props?.[e] === void 0 && (a[e] = o.story[e]);
72
- s.push(n(c, a));
62
+ let e = 0, r = f?.targetVariantId ? o.story.variants.findIndex((e) => e.id === f.targetVariantId) : -1, a = (s) => {
63
+ let c = [];
64
+ for (let l of s) {
65
+ if (l.type?.__povesteType === "variant") {
66
+ let a = o.story.variants[e];
67
+ if (e++, !a || r !== -1 && e - 1 !== r) continue;
68
+ let s = { variant: a };
69
+ l.key ?? (s.key = a.id), !l.props?.initState && !l.props?.["init-state"] && (s.initState = t.initState);
70
+ for (let e in i.proxy.$attrs) l.props?.[e] === void 0 && (s[e] = i.proxy.$attrs[e]);
71
+ for (let e in o.story) !d.includes(e) && l.props?.[e] === void 0 && (s[e] = o.story[e]);
72
+ c.push(n(l, s));
73
73
  continue;
74
74
  }
75
- Array.isArray(c.children) && (c.children = r(c.children)), s.push(c);
75
+ Array.isArray(l.children) && (l.children = a(l.children)), c.push(l);
76
76
  }
77
- return s;
77
+ return c;
78
78
  };
79
- return r(i.proxy.$slots.default?.() ?? []);
79
+ return a(i.proxy.$slots.default?.() ?? []);
80
80
  }
81
81
  function y() {
82
82
  f.nextVariantIndex.value = 0;
83
83
  let e = { state: f.externalState }, t = [];
84
- return f.slotName === "controls" && t.push(...i.proxy.$slots.controls?.(e) ?? []), (f.slotName === "default" || o.story.meta?.hasVariantChildComponents) && t.push(...i.proxy.$slots.default?.(e) ?? []), t;
84
+ if (f.slotName === "controls" && t.push(...i.proxy.$slots.controls?.(e) ?? []), (f.slotName === "default" || o.story.meta?.hasVariantChildComponents) && t.push(...i.proxy.$slots.default?.(e) ?? []), f.slotName === "default" && f.currentVariant && !o.story.meta?.hasVariantChildComponents) {
85
+ let e = o.story.variants.findIndex((e) => e.id === f.currentVariant.id);
86
+ if (e !== -1) {
87
+ let n = 0, r = !1, i = (t) => {
88
+ let a = [];
89
+ for (let o of t) {
90
+ if (o?.type?.__povesteType === "variant") {
91
+ n === e && (a.push(o), r = !0), n++;
92
+ continue;
93
+ }
94
+ Array.isArray(o?.children) && o.children.length && (o.children = i(o.children)), a.push(o);
95
+ }
96
+ return a;
97
+ }, a = i(t);
98
+ if (r) return f.nextVariantIndex.value = e, a;
99
+ }
100
+ }
101
+ return t;
85
102
  }
86
103
  },
87
104
  render() {
@@ -1,73 +1,65 @@
1
1
  import { isRef as e, unref as t, watch as n } from "@poveste/vendors/vue";
2
2
  import { isRef as r, unref as i, watch as a } from "vue";
3
- import { applyState as o } from "@poveste/shared";
3
+ import { applyState as o, createStateBaseline as s } from "@poveste/shared";
4
4
  //#region src/client/app/util.ts
5
- var s = (e) => typeof e == "object" && !!e;
6
- function c(e, t = /* @__PURE__ */ new WeakMap()) {
5
+ var c = (e) => typeof e == "object" && !!e;
6
+ function l(e, t = /* @__PURE__ */ new WeakMap()) {
7
7
  let n = r(e) ? i(e) : e;
8
8
  if (typeof n == "symbol") return n.toString();
9
- if (!s(n)) return n;
9
+ if (!c(n)) return n;
10
10
  if (t.has(n)) return t.get(n);
11
11
  if (Array.isArray(n)) {
12
12
  let e = [];
13
- return t.set(n, e), e.push(...n.map((e) => c(e, t))), e;
13
+ return t.set(n, e), e.push(...n.map((e) => l(e, t))), e;
14
14
  }
15
15
  {
16
16
  let e = {};
17
- return t.set(n, e), l(n, e, t), e;
17
+ return t.set(n, e), u(n, e, t), e;
18
18
  }
19
19
  }
20
- function l(e, t, n = /* @__PURE__ */ new WeakMap()) {
20
+ function u(e, t, n = /* @__PURE__ */ new WeakMap()) {
21
21
  Object.keys(e).forEach((r) => {
22
- t[r] = c(e[r], n);
22
+ t[r] = l(e[r], n);
23
23
  });
24
24
  }
25
- function u(n, r = /* @__PURE__ */ new WeakMap()) {
25
+ function d(n, r = /* @__PURE__ */ new WeakMap()) {
26
26
  let i = e(n) ? t(n) : n;
27
27
  if (typeof i == "symbol") return i.toString();
28
- if (!s(i)) return i;
28
+ if (!c(i)) return i;
29
29
  if (r.has(i)) return r.get(i);
30
30
  if (Array.isArray(i)) {
31
31
  let e = [];
32
- return r.set(i, e), e.push(...i.map((e) => u(e, r))), e;
32
+ return r.set(i, e), e.push(...i.map((e) => d(e, r))), e;
33
33
  }
34
34
  {
35
35
  let e = {};
36
- return r.set(i, e), d(i, e, r), e;
36
+ return r.set(i, e), f(i, e, r), e;
37
37
  }
38
38
  }
39
- function d(e, t, n = /* @__PURE__ */ new WeakMap()) {
39
+ function f(e, t, n = /* @__PURE__ */ new WeakMap()) {
40
40
  Object.keys(e).forEach((r) => {
41
- t[r] = c(e[r], n);
41
+ t[r] = l(e[r], n);
42
42
  });
43
43
  }
44
- function f(e, t) {
45
- let r = !1, i = n(() => e, (e) => {
46
- if (e != null) {
47
- if (r) {
48
- r = !1;
49
- return;
50
- }
51
- r = o(t, u(e));
52
- }
44
+ function p(e, t) {
45
+ let r = s(), i = n(() => e, (e) => {
46
+ if (e == null) return;
47
+ let n = r.take(d(e));
48
+ n && o(t, n);
53
49
  }, {
54
50
  deep: !0,
55
51
  immediate: !0
56
- }), s = a(() => t, (t) => {
57
- if (t != null) {
58
- if (r) {
59
- r = !1;
60
- return;
61
- }
62
- r = o(e, c(t));
63
- }
52
+ }), c = a(() => t, (t) => {
53
+ if (t == null) return;
54
+ let n = r.take(l(t));
55
+ n && o(e, n);
64
56
  }, {
65
57
  deep: !0,
66
58
  immediate: !0
67
59
  });
68
60
  return { stop() {
69
- i(), s();
61
+ i(), c();
70
62
  } };
71
63
  }
72
64
  //#endregion
73
- export { u as _toRawDeep, f as syncStateBundledAndExternal, c as toRawDeep };
65
+ export { d as _toRawDeep, p as syncStateBundledAndExternal, l as toRawDeep };
@@ -5,6 +5,10 @@ declare const _default: import("vue").DefineComponent<import("vue").ExtractPropT
5
5
  type: _PropType<Story>;
6
6
  required: true;
7
7
  };
8
+ targetVariantId: {
9
+ type: StringConstructor;
10
+ default: any;
11
+ };
8
12
  }>, {
9
13
  el: import("vue").Ref<HTMLDivElement, HTMLDivElement>;
10
14
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
@@ -12,5 +16,11 @@ declare const _default: import("vue").DefineComponent<import("vue").ExtractPropT
12
16
  type: _PropType<Story>;
13
17
  required: true;
14
18
  };
15
- }>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
19
+ targetVariantId: {
20
+ type: StringConstructor;
21
+ default: any;
22
+ };
23
+ }>> & Readonly<{}>, {
24
+ targetVariantId: string;
25
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
16
26
  export default _default;
@@ -3,6 +3,12 @@ export interface PreviewRenderContext {
3
3
  mode: 'mount' | 'render';
4
4
  slotName: string;
5
5
  currentVariant: Variant | null;
6
+ /**
7
+ * In a sandbox realm: the one variant this realm exists to serve, so the
8
+ * mount pass can skip every other variant's component (#197). Null in the
9
+ * app realm, where the story view needs all of them mounted.
10
+ */
11
+ targetVariantId?: string | null;
6
12
  externalState: Variant['state'] | null;
7
13
  nextVariantIndex: {
8
14
  value: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@poveste/plugin-vue",
3
3
  "type": "module",
4
- "version": "0.5.3",
4
+ "version": "0.6.1",
5
5
  "description": "Poveste plugin for Vue.js support",
6
6
  "author": {
7
7
  "name": "Sorin Gitlan"
@@ -44,16 +44,16 @@
44
44
  "types": "dist/index.node.d.ts",
45
45
  "peerDependencies": {
46
46
  "vue": "^3.5.26",
47
- "poveste": "^0.5.3"
47
+ "poveste": "^0.6.1"
48
48
  },
49
49
  "dependencies": {
50
50
  "change-case": "^5.4.4",
51
51
  "globby": "^14.0.2",
52
52
  "launch-editor": "^2.9.1",
53
53
  "pathe": "^1.1.2",
54
- "@poveste/controls": "^0.5.3",
55
- "@poveste/shared": "^0.5.3",
56
- "@poveste/vendors": "^0.5.3"
54
+ "@poveste/controls": "^0.6.1",
55
+ "@poveste/shared": "^0.6.1",
56
+ "@poveste/vendors": "^0.6.1"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/node": "^22.10.1",
@@ -62,7 +62,7 @@
62
62
  "vite": "^8.2.0",
63
63
  "vitest": "^4.1.10",
64
64
  "vue": "^3.5.26",
65
- "poveste": "0.5.3"
65
+ "poveste": "0.6.1"
66
66
  },
67
67
  "scripts": {
68
68
  "build": "rimraf dist && vite build && tsc -d -P tsconfig.build.json && pnpm run build:types",
@@ -21,6 +21,11 @@ export default _defineComponent({
21
21
  type: Object as _PropType<Story>,
22
22
  required: true,
23
23
  },
24
+
25
+ targetVariantId: {
26
+ type: String,
27
+ default: null,
28
+ },
24
29
  },
25
30
 
26
31
  setup(props) {
@@ -29,6 +34,7 @@ export default _defineComponent({
29
34
  mode: 'mount' as const,
30
35
  slotName: 'default',
31
36
  currentVariant: null,
37
+ targetVariantId: props.targetVariantId,
32
38
  externalState: null,
33
39
  nextVariantIndex: {
34
40
  value: 0,
@@ -66,6 +72,14 @@ export default _defineComponent({
66
72
  host?.forceUpdate()
67
73
  })
68
74
 
75
+ // A retargeted realm (#240) serves another variant of the same story: the
76
+ // mount pass has to run that variant's setup — its `initState` above all —
77
+ // which it skipped as a non-target. The context is reactive, so the story
78
+ // re-renders its mount pass on the change.
79
+ _watch(() => props.targetVariantId, (id) => {
80
+ renderContext.targetVariantId = id
81
+ })
82
+
69
83
  _onUnmounted(() => {
70
84
  unmountStory()
71
85
  })
@@ -115,6 +115,21 @@ export default defineComponent({
115
115
  function renderMountStory() {
116
116
  let index = 0
117
117
 
118
+ // A sandbox exists to serve one variant, but this pass used to mount a
119
+ // Variant component per <Variant> — 1000 setups, state syncs and
120
+ // auto-props walks per grid cell to render one button (#197). Skip the
121
+ // rest; variants resolve positionally, so `index` still advances past
122
+ // them. Variants that live in child components never reach this walk,
123
+ // so they mount as they always did.
124
+ //
125
+ // Not gated on `hasVariantChildComponents`: every explicit <Variant> sets
126
+ // that as it registers, so the gate held for the first render only and
127
+ // turned the re-render a retarget needs into a full mount of every
128
+ // sibling.
129
+ const targetIndex = renderContext?.targetVariantId
130
+ ? attrs.story.variants.findIndex(v => v.id === renderContext.targetVariantId)
131
+ : -1
132
+
118
133
  const applyAttrs = (vnodes: VNode[]) => {
119
134
  const result: VNode[] = []
120
135
 
@@ -128,10 +143,22 @@ export default defineComponent({
128
143
  continue
129
144
  }
130
145
 
146
+ if (targetIndex !== -1 && index - 1 !== targetIndex) {
147
+ continue
148
+ }
149
+
131
150
  const nextProps: any = {
132
151
  variant,
133
152
  }
134
153
 
154
+ // Keyed by variant: a retarget (#240) moves `targetIndex`, and an
155
+ // unkeyed walk would patch the old target's instance into the new
156
+ // one's props instead of mounting it — setup, and with it
157
+ // `initState`, has to run for the variant this realm now serves.
158
+ if (vnode.key == null) {
159
+ nextProps.key = variant.id
160
+ }
161
+
135
162
  if (!vnode.props?.initState && !vnode.props?.['init-state']) {
136
163
  nextProps.initState = props.initState
137
164
  }
@@ -182,6 +209,48 @@ export default defineComponent({
182
209
  children.push(...(vm.proxy.$slots.default?.(slotProps) ?? []))
183
210
  }
184
211
 
212
+ // Same story as the mount pass above: every Variant component here runs
213
+ // its full setup and then all but the current one render null (#197).
214
+ // Keep only the current one, and seed the positional index so it still
215
+ // resolves to the right variant data. Filtering only the default slot —
216
+ // the controls slot carries controls, and stories with variants in child
217
+ // components are invisible to a vnode walk.
218
+ if (
219
+ renderContext.slotName === 'default'
220
+ && renderContext.currentVariant
221
+ && !attrs.story.meta?.hasVariantChildComponents
222
+ ) {
223
+ const targetIndex = attrs.story.variants.findIndex(v => v.id === renderContext.currentVariant.id)
224
+ if (targetIndex !== -1) {
225
+ let seen = 0
226
+ let kept = false
227
+ const keepTargetVariant = (vnodes: VNode[]) => {
228
+ const result: VNode[] = []
229
+ for (const vnode of vnodes) {
230
+ // @ts-expect-error custom option
231
+ if (vnode?.type?.__povesteType === 'variant') {
232
+ if (seen === targetIndex) {
233
+ result.push(vnode)
234
+ kept = true
235
+ }
236
+ seen++
237
+ continue
238
+ }
239
+ if (Array.isArray(vnode?.children) && vnode.children.length) {
240
+ vnode.children = keepTargetVariant(vnode.children as VNode[])
241
+ }
242
+ result.push(vnode)
243
+ }
244
+ return result
245
+ }
246
+ const filtered = keepTargetVariant(children)
247
+ if (kept) {
248
+ renderContext.nextVariantIndex.value = targetIndex
249
+ return filtered
250
+ }
251
+ }
252
+ }
253
+
185
254
  return children
186
255
  }
187
256
  },
@@ -1,6 +1,6 @@
1
1
  import { nextTick as bundledNextTick, reactive as bundledReactive } from '@poveste/vendors/vue'
2
2
  import { describe, expect, it } from 'vitest'
3
- import { nextTick, reactive } from 'vue'
3
+ import { nextTick, reactive, watch } from 'vue'
4
4
  import { syncStateBundledAndExternal } from '../util.js'
5
5
 
6
6
  // These run against both Vue copies for real: `@poveste/vendors/vue` is the one
@@ -139,6 +139,193 @@ describe('syncStateBundledAndExternal', () => {
139
139
  sync.stop()
140
140
  })
141
141
 
142
+ // The #96 regression, and the reason the `syncing` flag is gone rather than
143
+ // repaired. Both watchers mirrored the whole of their side, so the second one
144
+ // to fire carried its own edit *and* its copy of the key the first side had
145
+ // just changed — one edit stale, because the mirror had not reached it yet.
146
+ // Last writer won, and it won across every key rather than just its own.
147
+ it('keeps both edits when the two sides change in the same tick', async () => {
148
+ const { bundled, external, sync } = setup({ a: 0, b: 0 })
149
+ await settle()
150
+
151
+ external.a = 1
152
+ bundled.b = 1
153
+ await settle()
154
+
155
+ expect({ a: external.a, b: external.b }).toEqual({ a: 1, b: 1 })
156
+ expect({ a: bundled.a, b: bundled.b }).toEqual({ a: 1, b: 1 })
157
+
158
+ sync.stop()
159
+ })
160
+
161
+ it('keeps both edits when the two sides change in the same tick, bundled first', async () => {
162
+ // Which watcher happens to flush first decided who lost, so pin both orders.
163
+ const { bundled, external, sync } = setup({ a: 0, b: 0 })
164
+ await settle()
165
+
166
+ bundled.b = 1
167
+ external.a = 1
168
+ await settle()
169
+
170
+ expect({ a: external.a, b: external.b }).toEqual({ a: 1, b: 1 })
171
+ expect({ a: bundled.a, b: bundled.b }).toEqual({ a: 1, b: 1 })
172
+
173
+ sync.stop()
174
+ })
175
+
176
+ it('keeps both edits when they land in different keys of the same object', async () => {
177
+ // The nested form: two entries of one map, which whole-object mirroring
178
+ // could not separate even in principle.
179
+ const { bundled, external, sync } = setup({ items: { a: 0, b: 0 } })
180
+ await settle()
181
+
182
+ external.items.a = 1
183
+ bundled.items.b = 1
184
+ await settle()
185
+
186
+ expect(external.items).toEqual({ a: 1, b: 1 })
187
+ expect(bundled.items).toEqual({ a: 1, b: 1 })
188
+
189
+ sync.stop()
190
+ })
191
+
192
+ it('keeps a concurrent edit made while the other side is mid-flight', async () => {
193
+ // Not the same tick, and it passed before the fix too — kept because it
194
+ // pins the interleaving the flag was most delicate about: the far side edits
195
+ // after a mirror has landed but before its echo has been observed.
196
+ const { bundled, external, sync } = setup({ a: 0, b: 0 })
197
+ await settle()
198
+
199
+ external.a = 1
200
+ await nextTick()
201
+ bundled.b = 1
202
+ await settle()
203
+
204
+ expect({ a: external.a, b: external.b }).toEqual({ a: 1, b: 1 })
205
+ expect({ a: bundled.a, b: bundled.b }).toEqual({ a: 1, b: 1 })
206
+
207
+ sync.stop()
208
+ })
209
+
210
+ it('mirrors an edit made to a key the other side removed', async () => {
211
+ // A removal cannot cross, so the two sides disagree about that key from then
212
+ // on. The baseline must not mistake that standing disagreement for a change
213
+ // and start replaying it — nor let it mask the next real edit.
214
+ const { bundled, external, sync } = setup({ count: 0, gone: 1 })
215
+ await settle()
216
+
217
+ delete external.gone
218
+ await settle()
219
+
220
+ bundled.count = 5
221
+ await settle()
222
+ expect(external.count).toBe(5)
223
+
224
+ external.count = 6
225
+ await settle()
226
+ expect(bundled.count).toBe(6)
227
+
228
+ sync.stop()
229
+ })
230
+
231
+ it('does not let a mirrored array alias the baseline', async () => {
232
+ // An array always crosses whole — `applyState` assigns it rather than
233
+ // merging — so the receiving side ends up holding the very object the
234
+ // baseline recorded. Reactive writes go through to the raw target, so
235
+ // without a copy the next `push` lands in the baseline as well, and the sync
236
+ // reads the edit back as already agreed and drops it.
237
+ const { bundled, external, sync } = setup({ list: [1, 2] })
238
+ await settle()
239
+
240
+ external.list = [1, 2, 3]
241
+ await settle()
242
+ expect(bundled.list).toEqual([1, 2, 3])
243
+
244
+ bundled.list.push(4)
245
+ await settle()
246
+ expect(external.list).toEqual([1, 2, 3, 4])
247
+
248
+ sync.stop()
249
+ })
250
+
251
+ it('does not let a mirrored object alias the baseline', async () => {
252
+ // Same hazard by the other route into the wholesale branch: a key the far
253
+ // side does not have yet is assigned rather than merged.
254
+ const { bundled, external, sync } = setup({ count: 0 })
255
+ await settle()
256
+
257
+ external.added = { a: 1 }
258
+ await settle()
259
+ expect(bundled.added).toEqual({ a: 1 })
260
+
261
+ bundled.added.a = 2
262
+ await settle()
263
+ expect(external.added.a).toBe(2)
264
+
265
+ sync.stop()
266
+ })
267
+
268
+ it('mirrors a write the story makes in reaction to a control edit', async () => {
269
+ // The realistic form of #96, and the one a book actually hits: a story that
270
+ // derives one state key from another. Editing `a` in the controls panel is
271
+ // one edit, but the story answering it with `b` is a second, and it lands
272
+ // while the first is still in flight — so the two sides are changing at the
273
+ // same time without anyone having to do anything unusual.
274
+ //
275
+ // The story's watcher is installed before the sync here, and that ordering
276
+ // is the whole of it: Vue flushes by creation order, so the story's write
277
+ // lands before the sync watcher runs and rides in on the very firing the
278
+ // flag was waiting to discard. Install the sync first and the same code
279
+ // survived, which is why this never showed up as a reliable bug.
280
+ //
281
+ // Both orders occur. `Variant` attaches its sync in `setup()`, before the
282
+ // story mounts; `RenderStory` tears the sync down and re-attaches it when
283
+ // the variant changes, by which time the story's watchers are long since
284
+ // registered. A flag whose correctness turns on which of those you got is
285
+ // not correct, it is lucky.
286
+ const bundled = bundledReactive({ a: 0, b: 0 })
287
+ const external = reactive({ a: 0, b: 0 })
288
+
289
+ const stop = watch(() => external.a, (value) => {
290
+ external.b = value * 2
291
+ })
292
+ const sync = syncStateBundledAndExternal(bundled, external)
293
+ await settle()
294
+
295
+ bundled.a = 1
296
+ await settle()
297
+
298
+ expect(external.b).toBe(2)
299
+ expect(bundled.b).toBe(2)
300
+
301
+ stop()
302
+ sync.stop()
303
+ })
304
+
305
+ it('mirrors only what moved without taking the neighbouring keys with it', async () => {
306
+ // Sending less than the whole object is what stops the two sides clobbering
307
+ // each other, and it is only safe as far as `applyState` merges — one level,
308
+ // and never for an `_h` key, which it replaces outright for the sandbox's
309
+ // sake. Narrow past either and the subset lands *as* the object.
310
+ //
311
+ // Every Vue story carries `_hPropState`, so getting that wrong empties the
312
+ // auto-props of every story in the book rather than failing somewhere quiet.
313
+ const initial = { _hPropState: { a: 1, b: 2 }, deep: { inner: { x: 1, y: 2 } } }
314
+ const bundled = bundledReactive(structuredClone(initial))
315
+ const external = reactive(structuredClone(initial))
316
+ const sync = syncStateBundledAndExternal(bundled, external)
317
+ await settle()
318
+
319
+ external._hPropState.a = 9
320
+ external.deep.inner.x = 9
321
+ await settle()
322
+
323
+ expect(bundled._hPropState).toEqual({ a: 9, b: 2 })
324
+ expect(bundled.deep.inner).toEqual({ x: 9, y: 2 })
325
+
326
+ sync.stop()
327
+ })
328
+
142
329
  it('mirrors many edits in a row without falling behind', async () => {
143
330
  const { bundled, external, sync } = setup({ count: 0 })
144
331
  await settle()
@@ -6,6 +6,12 @@ export interface PreviewRenderContext {
6
6
  mode: 'mount' | 'render'
7
7
  slotName: string
8
8
  currentVariant: Variant | null
9
+ /**
10
+ * In a sandbox realm: the one variant this realm exists to serve, so the
11
+ * mount pass can skip every other variant's component (#197). Null in the
12
+ * app realm, where the story view needs all of them mounted.
13
+ */
14
+ targetVariantId?: string | null
9
15
  externalState: Variant['state'] | null
10
16
  nextVariantIndex: {
11
17
  value: number
@@ -1,4 +1,4 @@
1
- import { applyState } from '@poveste/shared'
1
+ import { applyState, createStateBaseline } from '@poveste/shared'
2
2
  import {
3
3
  isRef as _isRef,
4
4
  unref as _unref,
@@ -90,33 +90,27 @@ function _toRawObject(obj: Record<any, any>, target: Record<any, any>, seen = ne
90
90
  * @param externalState Reactive state created with the external/user Vue
91
91
  */
92
92
  export function syncStateBundledAndExternal(bundledState, externalState) {
93
- // `syncing` means "the next firing on the other side is the echo of a write I
94
- // just made, ignore it". The subtlety, and the whole of #95, is that it is
95
- // only safe to set when a firing is actually coming.
93
+ // Each side is asked only for what *it* changed, and that is all that crosses.
96
94
  //
97
- // It used to be set unconditionally, on the assumption that every apply
98
- // provokes exactly one counterpart firing. Nothing enforced that. A change
99
- // `applyState` cannot express a removed key is the simplestprovokes none,
100
- // and the flag then sat set through the next genuine edit and swallowed it.
95
+ // Both watchers used to mirror the whole of their side. That works while the
96
+ // edits take turns, and coordinating the echoes needed a shared `syncing`
97
+ // boolean that had to be right about whether a firing was coming the whole
98
+ // of #95. It stops working the moment both sides change in the same tick: the
99
+ // second watcher to fire carries not just its own edit but its stale copy of
100
+ // the other side's key, and writes the first edit back out from under it. The
101
+ // edit was not delayed, it was gone, from both sides (#96).
101
102
  //
102
- // What held the assumption up in practice was an accident: `applyState`
103
- // rebuilt every object it copied, so any state carrying an object or an array
104
- // triggered the far side whether or not a value had moved. Vue stories always
105
- // carry `_hPropState` and `_hPropDefs`, so in this plugin it never actually
106
- // bit which is exactly why the issue had no repro, and why making
107
- // `applyState` skip no-op writes had to come with this change rather than
108
- // before it.
109
- //
110
- // `applyState` now reports whether it wrote, and that is what the flag holds.
111
- let syncing = false
103
+ // A baseline of the last agreed state answers both. It is what the far side is
104
+ // known to hold, so diffing against it yields this side's own edits and never
105
+ // the far side's there is nothing stale left to send. And an echo diffs to
106
+ // nothing, so no flag is needed to recognise one; the boolean is gone, and
107
+ // with it the question of what happens when the alternation slips.
108
+ const baseline = createStateBaseline()
112
109
 
113
110
  const _stop = _watch(() => bundledState, (value) => {
114
111
  if (value == null) return
115
- if (syncing) {
116
- syncing = false
117
- return
118
- }
119
- syncing = applyState(externalState, _toRawDeep(value))
112
+ const changes = baseline.take(_toRawDeep(value))
113
+ if (changes) applyState(externalState, changes)
120
114
  }, {
121
115
  deep: true,
122
116
  immediate: true,
@@ -124,11 +118,8 @@ export function syncStateBundledAndExternal(bundledState, externalState) {
124
118
 
125
119
  const stop = watch(() => externalState, (value) => {
126
120
  if (value == null) return
127
- if (syncing) {
128
- syncing = false
129
- return
130
- }
131
- syncing = applyState(bundledState, toRawDeep(value))
121
+ const changes = baseline.take(toRawDeep(value))
122
+ if (changes) applyState(bundledState, changes)
132
123
  }, {
133
124
  deep: true,
134
125
  immediate: true,