@poveste/plugin-vue 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundled/client/app/MountStory.js +13 -4
- package/dist/bundled/client/app/Story.js +32 -15
- package/dist/bundled/client/app/util.js +25 -33
- package/dist/client/app/MountStory.d.ts +11 -1
- package/dist/client/app/render-context.d.ts +6 -0
- package/package.json +20 -20
- package/src/client/app/MountStory.ts +14 -0
- package/src/client/app/Story.ts +69 -0
- package/src/client/app/__tests__/state-sync.spec.ts +188 -1
- package/src/client/app/render-context.ts +6 -0
- package/src/client/app/util.ts +19 -28
- package/LICENSE +0 -22
|
@@ -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: {
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
64
|
-
for (let
|
|
65
|
-
if (
|
|
66
|
-
let
|
|
67
|
-
if (e++, !r) continue;
|
|
68
|
-
let
|
|
69
|
-
!
|
|
70
|
-
for (let e in i.proxy.$attrs)
|
|
71
|
-
for (let e in o.story) !d.includes(e) &&
|
|
72
|
-
|
|
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(
|
|
75
|
+
Array.isArray(l.children) && (l.children = a(l.children)), c.push(l);
|
|
76
76
|
}
|
|
77
|
-
return
|
|
77
|
+
return c;
|
|
78
78
|
};
|
|
79
|
-
return
|
|
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
|
-
|
|
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
|
|
6
|
-
function
|
|
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 (!
|
|
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) =>
|
|
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),
|
|
17
|
+
return t.set(n, e), u(n, e, t), e;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
function
|
|
20
|
+
function u(e, t, n = /* @__PURE__ */ new WeakMap()) {
|
|
21
21
|
Object.keys(e).forEach((r) => {
|
|
22
|
-
t[r] =
|
|
22
|
+
t[r] = l(e[r], n);
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
|
-
function
|
|
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 (!
|
|
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) =>
|
|
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),
|
|
36
|
+
return r.set(i, e), f(i, e, r), e;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
-
function
|
|
39
|
+
function f(e, t, n = /* @__PURE__ */ new WeakMap()) {
|
|
40
40
|
Object.keys(e).forEach((r) => {
|
|
41
|
-
t[r] =
|
|
41
|
+
t[r] = l(e[r], n);
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
|
-
function
|
|
45
|
-
let r =
|
|
46
|
-
if (e
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
}),
|
|
57
|
-
if (t
|
|
58
|
-
|
|
59
|
-
|
|
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(),
|
|
61
|
+
i(), c();
|
|
70
62
|
} };
|
|
71
63
|
}
|
|
72
64
|
//#endregion
|
|
73
|
-
export {
|
|
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
|
-
|
|
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.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"description": "Poveste plugin for Vue.js support",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Sorin Gitlan"
|
|
@@ -42,33 +42,33 @@
|
|
|
42
42
|
},
|
|
43
43
|
"main": "dist/index.js",
|
|
44
44
|
"types": "dist/index.node.d.ts",
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "rimraf dist && vite build && tsc -d -P tsconfig.build.json && pnpm run build:types",
|
|
47
|
+
"build:types": "tsc --declaration --emitDeclarationOnly",
|
|
48
|
+
"watch": "concurrently \"vite build --watch\" \"tsc -d -P tsconfig.build.json --watch\" \"pnpm run build:types --watch\"",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:dev": "vitest"
|
|
51
|
+
},
|
|
45
52
|
"peerDependencies": {
|
|
46
|
-
"
|
|
47
|
-
"
|
|
53
|
+
"poveste": "workspace:^",
|
|
54
|
+
"vue": "^3.5.26"
|
|
48
55
|
},
|
|
49
56
|
"dependencies": {
|
|
57
|
+
"@poveste/controls": "workspace:^",
|
|
58
|
+
"@poveste/shared": "workspace:^",
|
|
59
|
+
"@poveste/vendors": "workspace:^",
|
|
50
60
|
"change-case": "^5.4.4",
|
|
51
61
|
"globby": "^14.0.2",
|
|
52
62
|
"launch-editor": "^2.9.1",
|
|
53
|
-
"pathe": "^1.1.2"
|
|
54
|
-
"@poveste/controls": "^0.5.2",
|
|
55
|
-
"@poveste/vendors": "^0.5.2",
|
|
56
|
-
"@poveste/shared": "^0.5.2"
|
|
63
|
+
"pathe": "^1.1.2"
|
|
57
64
|
},
|
|
58
65
|
"devDependencies": {
|
|
59
66
|
"@types/node": "^22.10.1",
|
|
60
67
|
"concurrently": "^9.1.0",
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
},
|
|
67
|
-
"scripts": {
|
|
68
|
-
"build": "rimraf dist && vite build && tsc -d -P tsconfig.build.json && pnpm run build:types",
|
|
69
|
-
"build:types": "tsc --declaration --emitDeclarationOnly",
|
|
70
|
-
"watch": "concurrently \"vite build --watch\" \"tsc -d -P tsconfig.build.json --watch\" \"pnpm run build:types --watch\"",
|
|
71
|
-
"test": "vitest run",
|
|
72
|
-
"test:dev": "vitest"
|
|
68
|
+
"poveste": "workspace:*",
|
|
69
|
+
"typescript": "catalog:",
|
|
70
|
+
"vite": "catalog:",
|
|
71
|
+
"vitest": "catalog:",
|
|
72
|
+
"vue": "^3.5.26"
|
|
73
73
|
}
|
|
74
|
-
}
|
|
74
|
+
}
|
|
@@ -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
|
})
|
package/src/client/app/Story.ts
CHANGED
|
@@ -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
|
package/src/client/app/util.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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,
|
package/LICENSE
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2022 Guillaume Chau (original "histoire" project)
|
|
4
|
-
Copyright (c) 2026 Sorin Gitlan (poveste fork)
|
|
5
|
-
|
|
6
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
-
in the Software without restriction, including without limitation the rights
|
|
9
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
-
furnished to do so, subject to the following conditions:
|
|
12
|
-
|
|
13
|
-
The above copyright notice and this permission notice shall be included in all
|
|
14
|
-
copies or substantial portions of the Software.
|
|
15
|
-
|
|
16
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
-
SOFTWARE.
|