@poveste/shared 0.2.1 → 0.3.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.
- package/dist/state.d.ts +24 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +103 -3
- package/dist/types/prompt.d.ts +1 -1
- package/dist/types/prompt.d.ts.map +1 -1
- package/package.json +8 -5
- package/src/__tests__/state.spec.ts +189 -0
- package/src/state.ts +124 -3
- package/src/types/prompt.ts +4 -1
- package/tsconfig.json +1 -1
package/dist/state.d.ts
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
export declare function clone(data: any): any;
|
|
2
2
|
export declare function omit(data: any, keys: string[]): {};
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Structural comparison, deliberately narrow: it recurses into plain objects and
|
|
5
|
+
* arrays and compares everything else by `Object.is`.
|
|
6
|
+
*
|
|
7
|
+
* That narrowness is the point. Anything with a prototype of its own — a `Date`,
|
|
8
|
+
* a `Map`, a class instance, a DOM node — carries state that own enumerable keys
|
|
9
|
+
* do not describe, and two distinct `Date`s would otherwise compare equal on an
|
|
10
|
+
* empty key list. Reporting "not equivalent" for those is the safe answer: the
|
|
11
|
+
* caller writes, which is what it did before this existed.
|
|
12
|
+
*
|
|
13
|
+
* `Object.is` is not an arbitrary choice either — it is exactly the predicate
|
|
14
|
+
* Vue's `hasChanged` uses, so a value this reports as equivalent is a value Vue
|
|
15
|
+
* would have refused to trigger on anyway.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isEquivalent(a: any, b: any, seen?: WeakMap<object, WeakSet<object>>): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Copies `state` onto `target`, and reports whether it wrote anything.
|
|
20
|
+
*
|
|
21
|
+
* The return value is what the state syncs in `plugin-vue`, `plugin-svelte` and
|
|
22
|
+
* the sandbox bridge use to decide whether to expect an echo. Each of them holds
|
|
23
|
+
* a flag meaning "the next firing is mine, ignore it", and that flag is only
|
|
24
|
+
* safe to set when a firing is actually coming. See #95.
|
|
25
|
+
*/
|
|
26
|
+
export declare function applyState(target: any, state: any, override?: boolean): boolean;
|
|
4
27
|
//# sourceMappingURL=state.d.ts.map
|
package/dist/state.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAAA,wBAAgB,KAAK,CAAC,IAAI,KAAA,OAczB;AAED,wBAAgB,IAAI,CAAC,IAAI,KAAA,EAAE,IAAI,EAAE,MAAM,EAAE,MAQxC;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,QAAQ,UAAQ,
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAAA,wBAAgB,KAAK,CAAC,IAAI,KAAA,OAczB;AAED,wBAAgB,IAAI,CAAC,IAAI,KAAA,EAAE,IAAI,EAAE,MAAM,EAAE,MAQxC;AAWD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,EAAE,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,OAAO,CAsC7F;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,QAAQ,UAAQ,WAiEnE"}
|
package/dist/state.js
CHANGED
|
@@ -22,19 +22,119 @@ export function omit(data, keys) {
|
|
|
22
22
|
}
|
|
23
23
|
return copy;
|
|
24
24
|
}
|
|
25
|
+
function isPlainObject(value) {
|
|
26
|
+
if (value === null || typeof value !== 'object') {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const proto = Object.getPrototypeOf(value);
|
|
30
|
+
return proto === Object.prototype || proto === null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Structural comparison, deliberately narrow: it recurses into plain objects and
|
|
34
|
+
* arrays and compares everything else by `Object.is`.
|
|
35
|
+
*
|
|
36
|
+
* That narrowness is the point. Anything with a prototype of its own — a `Date`,
|
|
37
|
+
* a `Map`, a class instance, a DOM node — carries state that own enumerable keys
|
|
38
|
+
* do not describe, and two distinct `Date`s would otherwise compare equal on an
|
|
39
|
+
* empty key list. Reporting "not equivalent" for those is the safe answer: the
|
|
40
|
+
* caller writes, which is what it did before this existed.
|
|
41
|
+
*
|
|
42
|
+
* `Object.is` is not an arbitrary choice either — it is exactly the predicate
|
|
43
|
+
* Vue's `hasChanged` uses, so a value this reports as equivalent is a value Vue
|
|
44
|
+
* would have refused to trigger on anyway.
|
|
45
|
+
*/
|
|
46
|
+
export function isEquivalent(a, b, seen) {
|
|
47
|
+
if (Object.is(a, b)) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
const bothArrays = Array.isArray(a) && Array.isArray(b);
|
|
51
|
+
if (!bothArrays && !(isPlainObject(a) && isPlainObject(b))) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (bothArrays && a.length !== b.length) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
// State is user data and can be cyclic. A pair already on the stack is
|
|
58
|
+
// assumed equivalent — the usual co-inductive treatment, and it terminates.
|
|
59
|
+
const visited = seen ?? new WeakMap();
|
|
60
|
+
let peers = visited.get(a);
|
|
61
|
+
if (peers?.has(b)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (!peers) {
|
|
65
|
+
peers = new WeakSet();
|
|
66
|
+
visited.set(a, peers);
|
|
67
|
+
}
|
|
68
|
+
peers.add(b);
|
|
69
|
+
const keys = Object.keys(a);
|
|
70
|
+
if (keys.length !== Object.keys(b).length) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return keys.every(key => Object.hasOwn(b, key) && isEquivalent(a[key], b[key], visited));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Copies `state` onto `target`, and reports whether it wrote anything.
|
|
77
|
+
*
|
|
78
|
+
* The return value is what the state syncs in `plugin-vue`, `plugin-svelte` and
|
|
79
|
+
* the sandbox bridge use to decide whether to expect an echo. Each of them holds
|
|
80
|
+
* a flag meaning "the next firing is mine, ignore it", and that flag is only
|
|
81
|
+
* safe to set when a firing is actually coming. See #95.
|
|
82
|
+
*/
|
|
25
83
|
export function applyState(target, state, override = false) {
|
|
84
|
+
let wrote = false;
|
|
26
85
|
for (const key in state) {
|
|
86
|
+
const current = target[key];
|
|
87
|
+
// Skip writes that cannot change anything. Vue drops a re-assigned primitive
|
|
88
|
+
// on its own, but never an object: `state` here has always been rebuilt by a
|
|
89
|
+
// `toRawDeep`/`clone` on the way in, so assigning one lands a fresh identity
|
|
90
|
+
// over an equal-but-distinct old one, and triggers every watcher on it.
|
|
91
|
+
//
|
|
92
|
+
// Those spurious triggers were load-bearing, which is the whole of #95 — the
|
|
93
|
+
// syncs counted on them to keep their flags alternating. Skipping them is
|
|
94
|
+
// both the efficiency win and the thing that makes `wrote` mean something.
|
|
95
|
+
//
|
|
96
|
+
// The key has to exist first. `undefined` is equivalent to `undefined`, so a
|
|
97
|
+
// key the target does not have yet and whose incoming value is `undefined`
|
|
98
|
+
// would otherwise never be created — and a story that declares its shape
|
|
99
|
+
// upfront (`initState: () => ({ pending: undefined })`) would get no control
|
|
100
|
+
// for it, because the panel lists the keys the state actually has.
|
|
101
|
+
if (Object.hasOwn(target, key) && isEquivalent(current, state[key])) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
27
104
|
// iframe sync needs to update properties without overriding them
|
|
28
|
-
if (!override &&
|
|
29
|
-
Object.assign
|
|
105
|
+
if (!override && current && !key.startsWith('_h') && typeof current === 'object' && !Array.isArray(current)) {
|
|
106
|
+
// Not `Object.assign`, because the reason the two are not equivalent may
|
|
107
|
+
// be a key that `current` has and `state[key]` does not — a removal, which
|
|
108
|
+
// this merge cannot express. Assigning the rest would then change nothing
|
|
109
|
+
// while still looking like a write, and a caller that took that for a
|
|
110
|
+
// write would wait for an echo that never comes. Compare per key and
|
|
111
|
+
// report only what actually moved.
|
|
112
|
+
let merged = false;
|
|
113
|
+
for (const nested in state[key]) {
|
|
114
|
+
if (isEquivalent(current[nested], state[key][nested])) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
current[nested] = state[key][nested];
|
|
119
|
+
merged = true;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// noop
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!merged) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
30
128
|
}
|
|
31
129
|
else {
|
|
32
130
|
try {
|
|
33
131
|
target[key] = state[key];
|
|
34
132
|
}
|
|
35
|
-
catch
|
|
133
|
+
catch {
|
|
36
134
|
// noop
|
|
37
135
|
}
|
|
38
136
|
}
|
|
137
|
+
wrote = true;
|
|
39
138
|
}
|
|
139
|
+
return wrote;
|
|
40
140
|
}
|
package/dist/types/prompt.d.ts
CHANGED
|
@@ -16,5 +16,5 @@ export interface SelectPrompt extends PromptBase<string> {
|
|
|
16
16
|
type: 'select';
|
|
17
17
|
options: SelectPromptOption[] | ((search: string, answers: Record<string, any>) => Awaitable<SelectPromptOption[]>);
|
|
18
18
|
}
|
|
19
|
-
export type Prompt
|
|
19
|
+
export type Prompt = TextPrompt | SelectPrompt;
|
|
20
20
|
//# sourceMappingURL=prompt.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/types/prompt.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAEjD,MAAM,WAAW,UAAU,CAAC,MAAM;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,YAAY,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,MAAM,CAAC,CAAA;CACnE;AAED,MAAM,WAAW,UAAW,SAAQ,UAAU,CAAC,MAAM,CAAC;IACpD,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAE1E,MAAM,WAAW,YAAa,SAAQ,UAAU,CAAC,MAAM,CAAC;IACtD,IAAI,EAAE,QAAQ,CAAA;IACd,OAAO,EAAE,kBAAkB,EAAE,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,SAAS,CAAC,kBAAkB,EAAE,CAAC,CAAC,CAAA;CACpH;
|
|
1
|
+
{"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/types/prompt.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAEjD,MAAM,WAAW,UAAU,CAAC,MAAM;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,YAAY,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,MAAM,CAAC,CAAA;CACnE;AAED,MAAM,WAAW,UAAW,SAAQ,UAAU,CAAC,MAAM,CAAC;IACpD,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAE1E,MAAM,WAAW,YAAa,SAAQ,UAAU,CAAC,MAAM,CAAC;IACtD,IAAI,EAAE,QAAQ,CAAA;IACd,OAAO,EAAE,kBAAkB,EAAE,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,SAAS,CAAC,kBAAkB,EAAE,CAAC,CAAC,CAAA;CACpH;AAKD,MAAM,MAAM,MAAM,GAAG,UAAU,GAC3B,YAAY,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poveste/shared",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.1",
|
|
5
5
|
"description": "Shared utilities for Poveste",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Sorin Gitlan"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"module": "./dist/index.js",
|
|
26
26
|
"types": "./dist/index.d.ts",
|
|
27
27
|
"peerDependencies": {
|
|
28
|
-
"vite": "^
|
|
28
|
+
"vite": "^8.0.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@types/fs-extra": "^11.0.4",
|
|
@@ -34,14 +34,17 @@
|
|
|
34
34
|
"pathe": "^1.1.2",
|
|
35
35
|
"picocolors": "^1.1.1",
|
|
36
36
|
"unhead": "^3.1.0",
|
|
37
|
-
"@poveste/vendors": "^0.
|
|
37
|
+
"@poveste/vendors": "^0.3.1"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"typescript": "5.6.3",
|
|
41
|
-
"vite": "^
|
|
41
|
+
"vite": "^8.2.0",
|
|
42
|
+
"vitest": "^4.1.10"
|
|
42
43
|
},
|
|
43
44
|
"scripts": {
|
|
44
45
|
"build": "rimraf dist && tsc",
|
|
45
|
-
"watch": "tsc -w --sourceMap"
|
|
46
|
+
"watch": "tsc -w --sourceMap",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:dev": "vitest"
|
|
46
49
|
}
|
|
47
50
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { applyState, isEquivalent } from '../state.js'
|
|
3
|
+
|
|
4
|
+
describe('isEquivalent', () => {
|
|
5
|
+
it('compares primitives the way Vue decides whether to trigger', () => {
|
|
6
|
+
expect(isEquivalent(1, 1)).toBe(true)
|
|
7
|
+
expect(isEquivalent('a', 'a')).toBe(true)
|
|
8
|
+
expect(isEquivalent(null, null)).toBe(true)
|
|
9
|
+
expect(isEquivalent(undefined, undefined)).toBe(true)
|
|
10
|
+
// `Object.is`, not `===`: matches Vue's `hasChanged` on both of its oddities.
|
|
11
|
+
expect(isEquivalent(Number.NaN, Number.NaN)).toBe(true)
|
|
12
|
+
expect(isEquivalent(0, -0)).toBe(false)
|
|
13
|
+
expect(isEquivalent(1, '1')).toBe(false)
|
|
14
|
+
expect(isEquivalent(0, false)).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('recurses into plain objects and arrays', () => {
|
|
18
|
+
expect(isEquivalent({ a: 1, b: { c: [1, 2] } }, { a: 1, b: { c: [1, 2] } })).toBe(true)
|
|
19
|
+
expect(isEquivalent({ a: 1, b: { c: [1, 2] } }, { a: 1, b: { c: [1, 3] } })).toBe(false)
|
|
20
|
+
expect(isEquivalent([1, 2], [1, 2, 3])).toBe(false)
|
|
21
|
+
expect(isEquivalent({ a: 1 }, { a: 1, b: 2 })).toBe(false)
|
|
22
|
+
expect(isEquivalent({ a: 1, b: 2 }, { a: 1 })).toBe(false)
|
|
23
|
+
// Same key count, different keys.
|
|
24
|
+
expect(isEquivalent({ a: 1 }, { b: 1 })).toBe(false)
|
|
25
|
+
// An array and an object are never equivalent, however similar.
|
|
26
|
+
expect(isEquivalent([], {})).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('refuses to compare anything with a prototype of its own', () => {
|
|
30
|
+
// Both would report an empty key list, so a structural walk would call them
|
|
31
|
+
// equal. Reporting "different" makes the caller write, which is what it did
|
|
32
|
+
// before this guard existed.
|
|
33
|
+
expect(isEquivalent(new Date(0), new Date(1))).toBe(false)
|
|
34
|
+
expect(isEquivalent(new Date(0), new Date(0))).toBe(false)
|
|
35
|
+
expect(isEquivalent(new Map([['a', 1]]), new Map())).toBe(false)
|
|
36
|
+
expect(isEquivalent(new Set([1]), new Set([2]))).toBe(false)
|
|
37
|
+
expect(isEquivalent(/a/, /b/)).toBe(false)
|
|
38
|
+
|
|
39
|
+
class Point { constructor(public x = 1) {} }
|
|
40
|
+
expect(isEquivalent(new Point(), new Point())).toBe(false)
|
|
41
|
+
// ...but the identical instance is still identical.
|
|
42
|
+
const point = new Point()
|
|
43
|
+
expect(isEquivalent(point, point)).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('terminates on cyclic state', () => {
|
|
47
|
+
const a: any = { name: 'a' }
|
|
48
|
+
a.self = a
|
|
49
|
+
const b: any = { name: 'a' }
|
|
50
|
+
b.self = b
|
|
51
|
+
|
|
52
|
+
expect(isEquivalent(a, b)).toBe(true)
|
|
53
|
+
|
|
54
|
+
const c: any = { name: 'c' }
|
|
55
|
+
c.self = c
|
|
56
|
+
expect(isEquivalent(a, c)).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('handles objects created without a prototype', () => {
|
|
60
|
+
const bare = Object.create(null)
|
|
61
|
+
bare.a = 1
|
|
62
|
+
expect(isEquivalent(bare, { a: 1 })).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('applyState', () => {
|
|
67
|
+
it('copies values across', () => {
|
|
68
|
+
const target: any = { a: 1 }
|
|
69
|
+
applyState(target, { a: 2, b: 3 })
|
|
70
|
+
expect(target).toEqual({ a: 2, b: 3 })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('merges into an existing object rather than replacing it', () => {
|
|
74
|
+
const nested = { a: 1, b: 2 }
|
|
75
|
+
const target: any = { nested }
|
|
76
|
+
applyState(target, { nested: { a: 9 } })
|
|
77
|
+
// Same object, updated in place — the sandbox bridge depends on this.
|
|
78
|
+
expect(target.nested).toBe(nested)
|
|
79
|
+
expect(target.nested).toEqual({ a: 9, b: 2 })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('replaces the object when override is set', () => {
|
|
83
|
+
const nested = { a: 1, b: 2 }
|
|
84
|
+
const target: any = { nested }
|
|
85
|
+
applyState(target, { nested: { a: 9 } }, true)
|
|
86
|
+
expect(target.nested).not.toBe(nested)
|
|
87
|
+
expect(target.nested).toEqual({ a: 9 })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('leaves identity alone when the incoming value is equivalent', () => {
|
|
91
|
+
// The behaviour #95 turns on. `toRawDeep` rebuilds every object on its way
|
|
92
|
+
// through the state sync, so without this the assignment below would land a
|
|
93
|
+
// new identity over an equal one and trigger every watcher on it.
|
|
94
|
+
const list = [1, 2, 3]
|
|
95
|
+
const nested = { a: 1 }
|
|
96
|
+
const target: any = { list, nested, count: 0 }
|
|
97
|
+
|
|
98
|
+
applyState(target, { list: [1, 2, 3], nested: { a: 1 }, count: 0 })
|
|
99
|
+
|
|
100
|
+
expect(target.list).toBe(list)
|
|
101
|
+
expect(target.nested).toBe(nested)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('creates a key the target does not have, even when the value is undefined', () => {
|
|
105
|
+
// `undefined` is equivalent to `undefined`, so the skip has to check that
|
|
106
|
+
// the key exists before it trusts that. A story is allowed to declare its
|
|
107
|
+
// shape upfront — `initState: () => ({ text: 'hi', pending: undefined })` —
|
|
108
|
+
// and the controls panel lists the keys the state actually has.
|
|
109
|
+
const target: any = {}
|
|
110
|
+
applyState(target, { text: 'hi', pending: undefined })
|
|
111
|
+
|
|
112
|
+
expect(Object.keys(target)).toEqual(['text', 'pending'])
|
|
113
|
+
expect('pending' in target).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('still skips an existing key whose value is already undefined', () => {
|
|
117
|
+
const target: any = { pending: undefined }
|
|
118
|
+
expect(applyState(target, { pending: undefined })).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('still writes when only part of an equivalent-looking value differs', () => {
|
|
122
|
+
const target: any = { nested: { a: 1, b: 2 } }
|
|
123
|
+
applyState(target, { nested: { a: 1, b: 3 } })
|
|
124
|
+
expect(target.nested).toEqual({ a: 1, b: 3 })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('swallows writes to read-only properties', () => {
|
|
128
|
+
const target: any = {}
|
|
129
|
+
Object.defineProperty(target, 'ro', { get: () => 1, enumerable: true })
|
|
130
|
+
expect(() => applyState(target, { ro: 2 })).not.toThrow()
|
|
131
|
+
expect(target.ro).toBe(1)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('cannot express a key removal', () => {
|
|
135
|
+
// Not a wish list — every state sync in the repo is built around knowing
|
|
136
|
+
// this. `applyState` iterates the incoming keys, so a key the source has
|
|
137
|
+
// dropped is simply never visited and survives on the target.
|
|
138
|
+
const target: any = { a: 1, gone: 2 }
|
|
139
|
+
applyState(target, { a: 1 })
|
|
140
|
+
expect(target.gone).toBe(2)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Every state sync in the repo holds a flag meaning "ignore the next firing, it
|
|
145
|
+
// is the echo of my own write", and clears it when that firing arrives. This
|
|
146
|
+
// return value is what tells them whether a firing is coming at all. Report
|
|
147
|
+
// `true` when nothing moved and the flag stays set forever, swallowing the next
|
|
148
|
+
// real edit — which is #95.
|
|
149
|
+
describe('applyState reporting whether it wrote', () => {
|
|
150
|
+
it('reports a write', () => {
|
|
151
|
+
expect(applyState({ a: 1 }, { a: 2 })).toBe(true)
|
|
152
|
+
expect(applyState({}, { a: 1 })).toBe(true)
|
|
153
|
+
expect(applyState({ list: [1] }, { list: [1, 2] })).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('reports nothing for a state that already matches', () => {
|
|
157
|
+
expect(applyState({ a: 1 }, { a: 1 })).toBe(false)
|
|
158
|
+
expect(applyState({ nested: { a: 1 } }, { nested: { a: 1 } })).toBe(false)
|
|
159
|
+
expect(applyState({ list: [1, 2] }, { list: [1, 2] })).toBe(false)
|
|
160
|
+
expect(applyState({ a: 1 }, {})).toBe(false)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('reports nothing when a merge only drops keys it cannot drop', () => {
|
|
164
|
+
// The exact shape of #95, and the case that makes the naive answer wrong.
|
|
165
|
+
// `{ a: 1, b: 2 }` and `{ a: 1 }` are not equivalent, so this reaches the
|
|
166
|
+
// merge — but merging leaves the target byte for byte as it was, because
|
|
167
|
+
// the only difference is a key the merge has no way to remove.
|
|
168
|
+
const target: any = { items: { a: 1, b: 2 } }
|
|
169
|
+
expect(applyState(target, { items: { a: 1 } })).toBe(false)
|
|
170
|
+
expect(target.items).toEqual({ a: 1, b: 2 })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('reports a write when a merge does change something', () => {
|
|
174
|
+
const target: any = { items: { a: 1, b: 2 } }
|
|
175
|
+
expect(applyState(target, { items: { a: 9 } })).toBe(true)
|
|
176
|
+
expect(target.items).toEqual({ a: 9, b: 2 })
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('reports a write it could not actually make', () => {
|
|
180
|
+
// Known and deliberately left alone: a setter that silently drops the value
|
|
181
|
+
// — Vue's read-only `computed` is the one that occurs in practice — still
|
|
182
|
+
// counts. Getting this right means reading back after every write, and the
|
|
183
|
+
// consequence of being wrong here is the pre-existing behaviour rather than
|
|
184
|
+
// a new failure.
|
|
185
|
+
const target: any = {}
|
|
186
|
+
Object.defineProperty(target, 'ro', { get: () => 1, enumerable: true, configurable: true })
|
|
187
|
+
expect(applyState(target, { ro: 2 })).toBe(true)
|
|
188
|
+
})
|
|
189
|
+
})
|
package/src/state.ts
CHANGED
|
@@ -24,19 +24,140 @@ export function omit(data, keys: string[]) {
|
|
|
24
24
|
return copy
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function isPlainObject(value: any) {
|
|
28
|
+
if (value === null || typeof value !== 'object') {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const proto = Object.getPrototypeOf(value)
|
|
33
|
+
return proto === Object.prototype || proto === null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Structural comparison, deliberately narrow: it recurses into plain objects and
|
|
38
|
+
* arrays and compares everything else by `Object.is`.
|
|
39
|
+
*
|
|
40
|
+
* That narrowness is the point. Anything with a prototype of its own — a `Date`,
|
|
41
|
+
* a `Map`, a class instance, a DOM node — carries state that own enumerable keys
|
|
42
|
+
* do not describe, and two distinct `Date`s would otherwise compare equal on an
|
|
43
|
+
* empty key list. Reporting "not equivalent" for those is the safe answer: the
|
|
44
|
+
* caller writes, which is what it did before this existed.
|
|
45
|
+
*
|
|
46
|
+
* `Object.is` is not an arbitrary choice either — it is exactly the predicate
|
|
47
|
+
* Vue's `hasChanged` uses, so a value this reports as equivalent is a value Vue
|
|
48
|
+
* would have refused to trigger on anyway.
|
|
49
|
+
*/
|
|
50
|
+
export function isEquivalent(a: any, b: any, seen?: WeakMap<object, WeakSet<object>>): boolean {
|
|
51
|
+
if (Object.is(a, b)) {
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const bothArrays = Array.isArray(a) && Array.isArray(b)
|
|
56
|
+
|
|
57
|
+
if (!bothArrays && !(isPlainObject(a) && isPlainObject(b))) {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (bothArrays && a.length !== b.length) {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// State is user data and can be cyclic. A pair already on the stack is
|
|
66
|
+
// assumed equivalent — the usual co-inductive treatment, and it terminates.
|
|
67
|
+
const visited = seen ?? new WeakMap<object, WeakSet<object>>()
|
|
68
|
+
let peers = visited.get(a)
|
|
69
|
+
|
|
70
|
+
if (peers?.has(b)) {
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!peers) {
|
|
75
|
+
peers = new WeakSet<object>()
|
|
76
|
+
visited.set(a, peers)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
peers.add(b)
|
|
80
|
+
|
|
81
|
+
const keys = Object.keys(a)
|
|
82
|
+
|
|
83
|
+
if (keys.length !== Object.keys(b).length) {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return keys.every(key => Object.hasOwn(b, key) && isEquivalent(a[key], b[key], visited))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Copies `state` onto `target`, and reports whether it wrote anything.
|
|
92
|
+
*
|
|
93
|
+
* The return value is what the state syncs in `plugin-vue`, `plugin-svelte` and
|
|
94
|
+
* the sandbox bridge use to decide whether to expect an echo. Each of them holds
|
|
95
|
+
* a flag meaning "the next firing is mine, ignore it", and that flag is only
|
|
96
|
+
* safe to set when a firing is actually coming. See #95.
|
|
97
|
+
*/
|
|
27
98
|
export function applyState(target: any, state: any, override = false) {
|
|
99
|
+
let wrote = false
|
|
100
|
+
|
|
28
101
|
for (const key in state) {
|
|
102
|
+
const current = target[key]
|
|
103
|
+
|
|
104
|
+
// Skip writes that cannot change anything. Vue drops a re-assigned primitive
|
|
105
|
+
// on its own, but never an object: `state` here has always been rebuilt by a
|
|
106
|
+
// `toRawDeep`/`clone` on the way in, so assigning one lands a fresh identity
|
|
107
|
+
// over an equal-but-distinct old one, and triggers every watcher on it.
|
|
108
|
+
//
|
|
109
|
+
// Those spurious triggers were load-bearing, which is the whole of #95 — the
|
|
110
|
+
// syncs counted on them to keep their flags alternating. Skipping them is
|
|
111
|
+
// both the efficiency win and the thing that makes `wrote` mean something.
|
|
112
|
+
//
|
|
113
|
+
// The key has to exist first. `undefined` is equivalent to `undefined`, so a
|
|
114
|
+
// key the target does not have yet and whose incoming value is `undefined`
|
|
115
|
+
// would otherwise never be created — and a story that declares its shape
|
|
116
|
+
// upfront (`initState: () => ({ pending: undefined })`) would get no control
|
|
117
|
+
// for it, because the panel lists the keys the state actually has.
|
|
118
|
+
if (Object.hasOwn(target, key) && isEquivalent(current, state[key])) {
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
29
122
|
// iframe sync needs to update properties without overriding them
|
|
30
|
-
if (!override &&
|
|
31
|
-
Object.assign
|
|
123
|
+
if (!override && current && !key.startsWith('_h') && typeof current === 'object' && !Array.isArray(current)) {
|
|
124
|
+
// Not `Object.assign`, because the reason the two are not equivalent may
|
|
125
|
+
// be a key that `current` has and `state[key]` does not — a removal, which
|
|
126
|
+
// this merge cannot express. Assigning the rest would then change nothing
|
|
127
|
+
// while still looking like a write, and a caller that took that for a
|
|
128
|
+
// write would wait for an echo that never comes. Compare per key and
|
|
129
|
+
// report only what actually moved.
|
|
130
|
+
let merged = false
|
|
131
|
+
|
|
132
|
+
for (const nested in state[key]) {
|
|
133
|
+
if (isEquivalent(current[nested], state[key][nested])) {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
current[nested] = state[key][nested]
|
|
139
|
+
merged = true
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// noop
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!merged) {
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
32
149
|
}
|
|
33
150
|
else {
|
|
34
151
|
try {
|
|
35
152
|
target[key] = state[key]
|
|
36
153
|
}
|
|
37
|
-
catch
|
|
154
|
+
catch {
|
|
38
155
|
// noop
|
|
39
156
|
}
|
|
40
157
|
}
|
|
158
|
+
|
|
159
|
+
wrote = true
|
|
41
160
|
}
|
|
161
|
+
|
|
162
|
+
return wrote
|
|
42
163
|
}
|
package/src/types/prompt.ts
CHANGED
|
@@ -18,5 +18,8 @@ export interface SelectPrompt extends PromptBase<string> {
|
|
|
18
18
|
options: SelectPromptOption[] | ((search: string, answers: Record<string, any>) => Awaitable<SelectPromptOption[]>)
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// No type parameter: both members fix their value type to `string` via
|
|
22
|
+
// `PromptBase<string>`, so the `<TValue = any>` this used to carry never
|
|
23
|
+
// reached anything and no caller ever passed one.
|
|
24
|
+
export type Prompt = TextPrompt
|
|
22
25
|
| SelectPrompt
|