@rohal12/spindle 0.52.4 → 0.52.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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +819 -945
- package/package.json +1 -1
- package/src/class-registry.ts +6 -182
- package/src/execute-mutation.ts +17 -97
- package/src/saves/save-manager.ts +190 -259
- package/src/saves/storage.ts +229 -402
- package/src/store.ts +244 -310
- package/src/structural.ts +388 -0
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// Class registry for preserving class instances across clone/save/load cycles.
|
|
2
2
|
|
|
3
|
+
import { setOwn } from './structural';
|
|
4
|
+
import { hasOwn } from './utils/namespace';
|
|
5
|
+
|
|
6
|
+
// Copying and comparing story values live with the structural diff
|
|
7
|
+
export { deepClone, deepEqual, type DeepCloneOptions } from './structural';
|
|
8
|
+
|
|
3
9
|
type Constructor = new (...args: any[]) => any;
|
|
4
10
|
|
|
5
11
|
const registry = new Map<string, Constructor>();
|
|
@@ -33,188 +39,6 @@ export function clearRegistry(): void {
|
|
|
33
39
|
ctorToName.clear();
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
// --- Deep Clone ---
|
|
37
|
-
|
|
38
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
39
|
-
if (typeof value !== 'object' || value === null) return false;
|
|
40
|
-
const proto = Object.getPrototypeOf(value);
|
|
41
|
-
return proto === Object.prototype || proto === null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Set an own enumerable property. A "__proto__" key is defined as an own
|
|
46
|
-
* property (as JSON.parse does) instead of replacing the prototype.
|
|
47
|
-
*/
|
|
48
|
-
function setOwn(target: object, key: string, value: unknown): void {
|
|
49
|
-
if (key === '__proto__') {
|
|
50
|
-
Object.defineProperty(target, key, {
|
|
51
|
-
value,
|
|
52
|
-
enumerable: true,
|
|
53
|
-
writable: true,
|
|
54
|
-
configurable: true,
|
|
55
|
-
});
|
|
56
|
-
} else {
|
|
57
|
-
(target as Record<string, unknown>)[key] = value;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface DeepCloneOptions {
|
|
62
|
-
/**
|
|
63
|
-
* Return instances of unregistered classes (DOM nodes, promises, other
|
|
64
|
-
* library objects) by reference instead of copying their own keys into a
|
|
65
|
-
* plain object, which would lose their prototype and identity.
|
|
66
|
-
*/
|
|
67
|
-
keepUnregistered?: boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
71
|
-
const seen = new Map<object, object>();
|
|
72
|
-
|
|
73
|
-
function clone(val: unknown): unknown {
|
|
74
|
-
if (val === null || typeof val !== 'object') return val;
|
|
75
|
-
|
|
76
|
-
const obj = val as object;
|
|
77
|
-
if (seen.has(obj)) return seen.get(obj);
|
|
78
|
-
|
|
79
|
-
if (val instanceof Date) return new Date(val.getTime()) as unknown;
|
|
80
|
-
if (val instanceof RegExp)
|
|
81
|
-
return new RegExp(val.source, val.flags) as unknown;
|
|
82
|
-
|
|
83
|
-
if (Array.isArray(val)) {
|
|
84
|
-
const arr: unknown[] = [];
|
|
85
|
-
seen.set(obj, arr);
|
|
86
|
-
for (let i = 0; i < val.length; i++) {
|
|
87
|
-
arr[i] = clone(val[i]);
|
|
88
|
-
}
|
|
89
|
-
return arr;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (val instanceof Map) {
|
|
93
|
-
const copy = new Map();
|
|
94
|
-
seen.set(obj, copy);
|
|
95
|
-
for (const [k, v] of val) {
|
|
96
|
-
copy.set(clone(k), clone(v));
|
|
97
|
-
}
|
|
98
|
-
return copy;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (val instanceof Set) {
|
|
102
|
-
const copy = new Set();
|
|
103
|
-
seen.set(obj, copy);
|
|
104
|
-
for (const v of val) {
|
|
105
|
-
copy.add(clone(v));
|
|
106
|
-
}
|
|
107
|
-
return copy;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Registered class instance
|
|
111
|
-
const name = registeredClassName(obj);
|
|
112
|
-
if (name !== undefined) {
|
|
113
|
-
const copy = Object.create(Object.getPrototypeOf(obj)) as Record<
|
|
114
|
-
string,
|
|
115
|
-
unknown
|
|
116
|
-
>;
|
|
117
|
-
seen.set(obj, copy);
|
|
118
|
-
for (const key of Object.keys(obj)) {
|
|
119
|
-
setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
|
|
120
|
-
}
|
|
121
|
-
return copy;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Plain object (or unregistered class — treat as plain). A plain object
|
|
125
|
-
// keeps its prototype, which may be null.
|
|
126
|
-
const plain = isPlainObject(val);
|
|
127
|
-
if (!plain && options.keepUnregistered) return val;
|
|
128
|
-
const copy = (
|
|
129
|
-
plain ? Object.create(Object.getPrototypeOf(obj) as object | null) : {}
|
|
130
|
-
) as Record<string, unknown>;
|
|
131
|
-
seen.set(obj, copy);
|
|
132
|
-
for (const key of Object.keys(obj)) {
|
|
133
|
-
setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
|
|
134
|
-
}
|
|
135
|
-
return copy;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return clone(value) as T;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// --- Deep Equal ---
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Structural equality over the value types deepClone() supports: primitives,
|
|
145
|
-
* arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
|
|
146
|
-
* at any depth). Map and Set entries are compared in insertion order. Arrays
|
|
147
|
-
* are compared by length and index, so a hole equals an undefined element
|
|
148
|
-
* (deepClone() and save/load turn holes into undefined elements).
|
|
149
|
-
*/
|
|
150
|
-
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
151
|
-
return equal(a, b, new Map());
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* `assumed` holds the pairs already being compared: meeting one again (a
|
|
156
|
-
* cycle) assumes it equal. Every object may pair with several others, since
|
|
157
|
-
* cycles of different lengths can still unfold to the same value.
|
|
158
|
-
*/
|
|
159
|
-
function equal(
|
|
160
|
-
a: unknown,
|
|
161
|
-
b: unknown,
|
|
162
|
-
assumed: Map<object, Set<object>>,
|
|
163
|
-
): boolean {
|
|
164
|
-
if (Object.is(a, b)) return true;
|
|
165
|
-
if (
|
|
166
|
-
a === null ||
|
|
167
|
-
b === null ||
|
|
168
|
-
typeof a !== 'object' ||
|
|
169
|
-
typeof b !== 'object'
|
|
170
|
-
) {
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
174
|
-
const pairs = assumed.get(a);
|
|
175
|
-
if (pairs?.has(b)) return true;
|
|
176
|
-
if (pairs) pairs.add(b);
|
|
177
|
-
else assumed.set(a, new Set([b]));
|
|
178
|
-
|
|
179
|
-
if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
|
|
180
|
-
if (a instanceof RegExp) return String(a) === String(b);
|
|
181
|
-
if (a instanceof Map || a instanceof Set) {
|
|
182
|
-
const bc = b as Map<unknown, unknown> | Set<unknown>;
|
|
183
|
-
if (a.size !== bc.size) return false;
|
|
184
|
-
const ai = a.entries();
|
|
185
|
-
const bi = bc.entries();
|
|
186
|
-
for (
|
|
187
|
-
let x = ai.next(), y = bi.next();
|
|
188
|
-
!x.done;
|
|
189
|
-
x = ai.next(), y = bi.next()
|
|
190
|
-
) {
|
|
191
|
-
if (!equal(x.value, y.value, assumed)) return false;
|
|
192
|
-
}
|
|
193
|
-
return true;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (Array.isArray(a)) {
|
|
197
|
-
const bc = b as unknown[];
|
|
198
|
-
if (a.length !== bc.length) return false;
|
|
199
|
-
for (let i = 0; i < a.length; i++) {
|
|
200
|
-
if (!equal(a[i], bc[i], assumed)) return false;
|
|
201
|
-
}
|
|
202
|
-
return true;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const ao = a as Record<string, unknown>;
|
|
206
|
-
const bo = b as Record<string, unknown>;
|
|
207
|
-
const keys = Object.keys(ao);
|
|
208
|
-
if (keys.length !== Object.keys(bo).length) return false;
|
|
209
|
-
for (const key of keys) {
|
|
210
|
-
if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
|
|
211
|
-
}
|
|
212
|
-
return true;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const hasOwn = (obj: object, key: string): boolean =>
|
|
216
|
-
Object.prototype.hasOwnProperty.call(obj, key);
|
|
217
|
-
|
|
218
42
|
// --- Serialize ---
|
|
219
43
|
|
|
220
44
|
const CLASS_TAG = '__spindle_class__';
|
package/src/execute-mutation.ts
CHANGED
|
@@ -9,9 +9,18 @@ import {
|
|
|
9
9
|
import { useStoryStore } from './store';
|
|
10
10
|
import type { StoryState, VariableNamespaces } from './store';
|
|
11
11
|
import { execute } from './expression';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
import {
|
|
13
|
+
applyChange,
|
|
14
|
+
deepClone,
|
|
15
|
+
deepEqual,
|
|
16
|
+
diffPaths,
|
|
17
|
+
isApplied,
|
|
18
|
+
isMergeable,
|
|
19
|
+
mergeKeys,
|
|
20
|
+
type PathChange,
|
|
21
|
+
} from './structural';
|
|
22
|
+
import { getByPath } from './utils/object-path';
|
|
23
|
+
import { asNamespace, hasOwn } from './utils/namespace';
|
|
15
24
|
|
|
16
25
|
type NamespaceName = keyof VariableNamespaces;
|
|
17
26
|
|
|
@@ -21,11 +30,6 @@ const NAMESPACES: readonly NamespaceName[] = [
|
|
|
21
30
|
'transient',
|
|
22
31
|
];
|
|
23
32
|
|
|
24
|
-
/** A write to one property path of a namespace, or its deletion. */
|
|
25
|
-
type PathChange =
|
|
26
|
-
| { path: string[]; deleted: false; value: unknown }
|
|
27
|
-
| { path: string[]; deleted: true };
|
|
28
|
-
|
|
29
33
|
/**
|
|
30
34
|
* The state of one executing mutation. The code reads and writes `work`.
|
|
31
35
|
* `base` is what `work` started as, with every write made elsewhere during
|
|
@@ -76,80 +80,6 @@ export function frozenCopy<T>(value: T): T {
|
|
|
76
80
|
return freeze(cloneValue(value), true);
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
/**
|
|
80
|
-
* Objects merged property by property: plain objects and (registered)
|
|
81
|
-
* class instances. Arrays, Map, Set, Date and RegExp are values that are
|
|
82
|
-
* replaced as a whole, since their elements have no stable identity to
|
|
83
|
-
* merge by (a shift moves every index).
|
|
84
|
-
*/
|
|
85
|
-
export function isMergeable(value: unknown): value is Record<string, unknown> {
|
|
86
|
-
return (
|
|
87
|
-
typeof value === 'object' &&
|
|
88
|
-
value !== null &&
|
|
89
|
-
!Array.isArray(value) &&
|
|
90
|
-
!(value instanceof Map) &&
|
|
91
|
-
!(value instanceof Set) &&
|
|
92
|
-
!(value instanceof Date) &&
|
|
93
|
-
!(value instanceof RegExp)
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const hasOwn = (obj: object, key: string): boolean =>
|
|
98
|
-
Object.prototype.hasOwnProperty.call(obj, key);
|
|
99
|
-
|
|
100
|
-
/** Collect the property paths where `work` differs from `base`. */
|
|
101
|
-
function diff(
|
|
102
|
-
base: Record<string, unknown>,
|
|
103
|
-
work: Record<string, unknown>,
|
|
104
|
-
path: string[],
|
|
105
|
-
changes: PathChange[],
|
|
106
|
-
ancestors: Set<object>,
|
|
107
|
-
): void {
|
|
108
|
-
// Stop at cycles; shared (non-cyclic) references are visited per path.
|
|
109
|
-
if (ancestors.has(work)) return;
|
|
110
|
-
ancestors.add(work);
|
|
111
|
-
for (const key of Object.keys(work)) {
|
|
112
|
-
const b = base[key];
|
|
113
|
-
const w = work[key];
|
|
114
|
-
if (!hasOwn(base, key)) {
|
|
115
|
-
changes.push({ path: [...path, key], deleted: false, value: w });
|
|
116
|
-
} else if (Object.is(b, w)) {
|
|
117
|
-
// The same value (an untouched subtree of an Immer update)
|
|
118
|
-
continue;
|
|
119
|
-
} else if (
|
|
120
|
-
isMergeable(b) &&
|
|
121
|
-
isMergeable(w) &&
|
|
122
|
-
Object.getPrototypeOf(b) === Object.getPrototypeOf(w)
|
|
123
|
-
) {
|
|
124
|
-
diff(b, w, [...path, key], changes, ancestors);
|
|
125
|
-
} else if (!deepEqual(b, w)) {
|
|
126
|
-
changes.push({ path: [...path, key], deleted: false, value: w });
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
for (const key of Object.keys(base)) {
|
|
130
|
-
if (!hasOwn(work, key))
|
|
131
|
-
changes.push({ path: [...path, key], deleted: true });
|
|
132
|
-
}
|
|
133
|
-
ancestors.delete(work);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Whether `ns` already holds what `change` would write. */
|
|
137
|
-
function isApplied(ns: Record<string, unknown>, change: PathChange): boolean {
|
|
138
|
-
const parent = getByPath(ns, change.path.slice(0, -1));
|
|
139
|
-
if (parent === null || typeof parent !== 'object') return change.deleted;
|
|
140
|
-
const key = change.path[change.path.length - 1]!;
|
|
141
|
-
if (!hasOwn(parent, key)) return change.deleted;
|
|
142
|
-
return (
|
|
143
|
-
!change.deleted &&
|
|
144
|
-
deepEqual((parent as Record<string, unknown>)[key], change.value)
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function applyChange(ns: Record<string, unknown>, change: PathChange): void {
|
|
149
|
-
if (change.deleted) deleteByPath(ns, change.path);
|
|
150
|
-
else setByPath(ns, change.path, change.value);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
83
|
/**
|
|
154
84
|
* Apply a write that reached the store while mutations execute to their
|
|
155
85
|
* `work` and `base` copies (see MutationScope), so the code sees it and
|
|
@@ -381,11 +311,9 @@ function commitScopes(commits: readonly Commit[]): void {
|
|
|
381
311
|
const all = commits.map(({ scope, keepRunning }) => ({
|
|
382
312
|
scope,
|
|
383
313
|
keepRunning,
|
|
384
|
-
changes: NAMESPACES.map(
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
return [ns, list] as const;
|
|
388
|
-
}),
|
|
314
|
+
changes: NAMESPACES.map(
|
|
315
|
+
(ns) => [ns, diffPaths(scope.base[ns], scope.work[ns])] as const,
|
|
316
|
+
),
|
|
389
317
|
}));
|
|
390
318
|
const changed = all.filter(({ changes }) =>
|
|
391
319
|
changes.some(([, list]) => list.length > 0),
|
|
@@ -450,16 +378,8 @@ const running = (): Commit[] =>
|
|
|
450
378
|
function resync(scope: MutationScope): void {
|
|
451
379
|
const state = useStoryStore.getState();
|
|
452
380
|
for (const ns of NAMESPACES) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
for (const key of Object.keys(work)) {
|
|
456
|
-
if (!hasOwn(stored, key)) delete work[key];
|
|
457
|
-
}
|
|
458
|
-
for (const key of Object.keys(stored)) {
|
|
459
|
-
if (!hasOwn(work, key) || !deepEqual(work[key], stored[key])) {
|
|
460
|
-
work[key] = deepClone(stored[key]);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
381
|
+
// Root by root: a root that differs is replaced with a copy of the store's
|
|
382
|
+
mergeKeys(scope.work[ns], scope.work[ns], state[ns]);
|
|
463
383
|
}
|
|
464
384
|
scope.base = cloneNamespaces(state);
|
|
465
385
|
}
|