@rohal12/spindle 0.59.15 → 0.59.17
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/README.md +1 -0
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +354 -109
- package/dist/pkg/story-variables.js +36 -3
- package/package.json +1 -1
- package/src/components/macros/For.tsx +16 -9
- package/src/components/macros/PassageDisplay.tsx +9 -1
- package/src/components/macros/Repeat.tsx +6 -1
- package/src/components/macros/Timed.tsx +5 -1
- package/src/components/macros/Watch.tsx +24 -4
- package/src/components/macros/detached-body.tsx +31 -9
- package/src/define-macro.ts +21 -4
- package/src/hooks/use-modal-focus.ts +2 -0
- package/src/hooks/use-story-fields.ts +8 -0
- package/src/runtime-errors.ts +4 -8
- package/src/saves/save-manager.ts +63 -0
- package/src/store.ts +42 -8
- package/src/story-api.ts +4 -3
- package/src/story-variables.ts +15 -3
- package/src/structural.ts +46 -13
- package/src/triggers.ts +17 -0
- package/src/utils/control-edits.ts +81 -30
- package/src/utils/listeners.ts +23 -0
- package/src/utils/object-path.ts +31 -8
- package/src/utils/stable-key.ts +73 -44
- package/types/index.d.ts +4 -1
- package/types/tooling.d.ts +5 -1
package/src/story-variables.ts
CHANGED
|
@@ -68,18 +68,30 @@ const PRIMITIVE_SAMPLES: Partial<Record<VarType, object>> = {
|
|
|
68
68
|
boolean: Object(false),
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The schema of a default value. An object met again (a backreference, a
|
|
73
|
+
* shared subtree) has the schema made for it the first time, so a cyclic
|
|
74
|
+
* default gives a schema that refers to itself instead of recursing forever.
|
|
75
|
+
*/
|
|
76
|
+
function inferSchema(
|
|
77
|
+
value: unknown,
|
|
78
|
+
seen = new Map<object, FieldSchema>(),
|
|
79
|
+
): FieldSchema {
|
|
72
80
|
if (Array.isArray(value)) {
|
|
73
81
|
return { type: 'array' };
|
|
74
82
|
}
|
|
75
83
|
// A default of null means "nothing yet"; the value may later be anything
|
|
76
84
|
if (value === null) return { type: 'null' };
|
|
77
85
|
if (typeof value === 'object') {
|
|
86
|
+
const known = seen.get(value);
|
|
87
|
+
if (known) return known;
|
|
78
88
|
const fields = new Map<string, FieldSchema>();
|
|
89
|
+
const schema: FieldSchema = { type: 'object', fields };
|
|
90
|
+
seen.set(value, schema);
|
|
79
91
|
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
80
|
-
fields.set(key, inferSchema(val));
|
|
92
|
+
fields.set(key, inferSchema(val, seen));
|
|
81
93
|
}
|
|
82
|
-
return
|
|
94
|
+
return schema;
|
|
83
95
|
}
|
|
84
96
|
const jsType = typeof value;
|
|
85
97
|
if (!VALID_VAR_TYPES.has(jsType)) {
|
package/src/structural.ts
CHANGED
|
@@ -859,6 +859,28 @@ function setEntryValue(
|
|
|
859
859
|
return true;
|
|
860
860
|
}
|
|
861
861
|
|
|
862
|
+
/**
|
|
863
|
+
* The own enumerable fields of a Map, Set, Date or RegExp (an instance of a
|
|
864
|
+
* registered subclass may hold some besides its built-in content, #412);
|
|
865
|
+
* none for any other value.
|
|
866
|
+
*/
|
|
867
|
+
function builtinFields(value: object): string[] {
|
|
868
|
+
return value instanceof Map ||
|
|
869
|
+
value instanceof Set ||
|
|
870
|
+
value instanceof Date ||
|
|
871
|
+
value instanceof RegExp
|
|
872
|
+
? extraKeys(value)
|
|
873
|
+
: [];
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** Whether `x` and `y` are built-ins of one class that may hold fields. */
|
|
877
|
+
function sameBuiltin(x: object, y: object): boolean {
|
|
878
|
+
return (
|
|
879
|
+
Object.getPrototypeOf(x) === Object.getPrototypeOf(y) &&
|
|
880
|
+
(builtinFields(x).length > 0 || builtinFields(y).length > 0)
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
|
|
862
884
|
/**
|
|
863
885
|
* Every path each object (or array, or other value) of `root` is at, the
|
|
864
886
|
* first appearance first, in depth-first order. Plain objects and arrays are
|
|
@@ -888,12 +910,17 @@ function objectPaths(
|
|
|
888
910
|
all.set(value, [at]);
|
|
889
911
|
if (isMergeable(value) || Array.isArray(value)) {
|
|
890
912
|
walk(value as Record<string, unknown>, at);
|
|
891
|
-
} else
|
|
892
|
-
|
|
913
|
+
} else {
|
|
914
|
+
walk(value as Record<string, unknown>, at, builtinFields(value));
|
|
915
|
+
if (entries && entryChildren(value)) collections.push([value, at]);
|
|
893
916
|
}
|
|
894
917
|
}
|
|
895
|
-
function walk(
|
|
896
|
-
|
|
918
|
+
function walk(
|
|
919
|
+
node: Record<string, unknown>,
|
|
920
|
+
path: string[],
|
|
921
|
+
keys: string[] = Object.keys(node),
|
|
922
|
+
): void {
|
|
923
|
+
for (const key of keys) {
|
|
897
924
|
const value = node[key];
|
|
898
925
|
if (!isObjectValue(value)) continue;
|
|
899
926
|
const at = [...path, key];
|
|
@@ -963,12 +990,16 @@ function aliasMoved(
|
|
|
963
990
|
for (const [segment, a] of xs) {
|
|
964
991
|
if (held.has(segment)) pairs.push([segment, a, held.get(segment)]);
|
|
965
992
|
}
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
993
|
+
}
|
|
994
|
+
// The properties both hold: of objects that merge, and the fields of a
|
|
995
|
+
// built-in subclass instance besides its entries (#412)
|
|
996
|
+
const keys = mergesWith(x, y, true)
|
|
997
|
+
? Object.keys(y)
|
|
998
|
+
: sameBuiltin(x, y)
|
|
999
|
+
? builtinFields(y)
|
|
1000
|
+
: [];
|
|
1001
|
+
for (const key of keys) {
|
|
1002
|
+
if (hasOwn(x, key)) pairs.push([key, (x as any)[key], (y as any)[key]]);
|
|
972
1003
|
}
|
|
973
1004
|
return pairs.some(([segment, a, b]) => {
|
|
974
1005
|
if (!isObjectValue(a) || !isObjectValue(b)) return false;
|
|
@@ -1002,7 +1033,9 @@ export function aliasChanges(
|
|
|
1002
1033
|
a: Record<string, unknown>,
|
|
1003
1034
|
path: string[],
|
|
1004
1035
|
): void {
|
|
1005
|
-
for (const key of
|
|
1036
|
+
for (const key of Array.isArray(a) || isMergeable(a)
|
|
1037
|
+
? Object.keys(a)
|
|
1038
|
+
: builtinFields(a)) {
|
|
1006
1039
|
if (!hasOwn(b, key)) continue;
|
|
1007
1040
|
const x = b[key];
|
|
1008
1041
|
const y = a[key];
|
|
@@ -1023,8 +1056,8 @@ export function aliasChanges(
|
|
|
1023
1056
|
// Only paths that are first appearances are entered: elsewhere the
|
|
1024
1057
|
// contents are those of the object met first.
|
|
1025
1058
|
if (
|
|
1026
|
-
(isMergeable(x) || Array.isArray(x)) &&
|
|
1027
|
-
|
|
1059
|
+
(((isMergeable(x) || Array.isArray(x)) && mergesWith(x, y, true)) ||
|
|
1060
|
+
sameBuiltin(x, y)) &&
|
|
1028
1061
|
pathKey(isAt) === k &&
|
|
1029
1062
|
pathKey(wasAt) === k
|
|
1030
1063
|
) {
|
package/src/triggers.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { evaluate } from './expression';
|
|
2
2
|
import { executeMutation, readState } from './execute-mutation';
|
|
3
3
|
import { useStoryStore } from './store';
|
|
4
|
+
import { createListeners } from './utils/listeners';
|
|
4
5
|
|
|
5
6
|
export interface WatchOptions {
|
|
6
7
|
goto?: string;
|
|
@@ -301,9 +302,25 @@ export function reinitTriggerState(): void {
|
|
|
301
302
|
*/
|
|
302
303
|
export function resetTriggers(): void {
|
|
303
304
|
triggers = [];
|
|
305
|
+
resets++;
|
|
304
306
|
closeAllOpenDialogs();
|
|
307
|
+
resetListeners.notify();
|
|
305
308
|
}
|
|
306
309
|
|
|
310
|
+
let resets = 0;
|
|
311
|
+
const resetListeners = createListeners();
|
|
312
|
+
|
|
313
|
+
/** How many times resetTriggers has forgotten every watcher. */
|
|
314
|
+
export function triggerResets(): number {
|
|
315
|
+
return resets;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Call `listener` after each resetTriggers (a restart): a {watch} that stays
|
|
320
|
+
* mounted, in the story interface, registers its watcher again (#402).
|
|
321
|
+
*/
|
|
322
|
+
export const subscribeTriggerResets = resetListeners.subscribe;
|
|
323
|
+
|
|
307
324
|
export function subscribeTriggerDialogs(cb: () => void): () => void {
|
|
308
325
|
dialogNotify = cb;
|
|
309
326
|
// Flush any queued dialogs
|
|
@@ -1,48 +1,99 @@
|
|
|
1
|
+
import { createContext } from 'preact';
|
|
2
|
+
import { readState } from '../execute-mutation';
|
|
3
|
+
import { diffPaths } from '../structural';
|
|
1
4
|
import { getByPath } from './object-path';
|
|
5
|
+
import type { VariableNamespaces } from '../store';
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
|
-
* Records the edits
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
+
* Records the edits made to the items of a list from inside a {for}
|
|
9
|
+
* iteration, which {for} tells apart from a list that is replaced:
|
|
10
|
+
*
|
|
11
|
+
* - a reader editing a variable through a control (`{textbox}`,
|
|
12
|
+
* `{checkbox}`, ...): typing into a control inside an iteration changes
|
|
13
|
+
* its item, but must not remount the iteration and drop the control's
|
|
14
|
+
* focus (#369);
|
|
15
|
+
* - mutation code an iteration runs as it renders (`{set $party[@i].hp +=
|
|
16
|
+
* 1}`): remounting would run the iteration's mount-only macros, and so the
|
|
17
|
+
* write, again for every write it makes (#400).
|
|
18
|
+
*
|
|
8
19
|
* An edit is attributed to the variable path it wrote, so a replacement made
|
|
9
|
-
* elsewhere (a watcher reacting to the edit
|
|
20
|
+
* elsewhere (a watcher reacting to the edit, a button outside the loop)
|
|
21
|
+
* still remounts.
|
|
10
22
|
*/
|
|
11
23
|
let version = 0;
|
|
12
|
-
const MAX_LOG =
|
|
13
|
-
|
|
24
|
+
const MAX_LOG = 4096;
|
|
25
|
+
/** Edits in the order made: paths below the namespaces (`['variables', 'a']`). */
|
|
26
|
+
const log: { version: number; path: readonly string[] }[] = [];
|
|
14
27
|
|
|
15
|
-
|
|
16
|
-
log.push({ version: ++version,
|
|
28
|
+
function note(path: readonly string[]): void {
|
|
29
|
+
log.push({ version: ++version, path });
|
|
17
30
|
if (log.length > MAX_LOG) log.shift();
|
|
18
31
|
}
|
|
19
32
|
|
|
33
|
+
/** Note an edit a control made to the story variable at `segments`. */
|
|
34
|
+
export function noteControlEdit(segments: readonly string[]): void {
|
|
35
|
+
note(['variables', ...segments]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether mutation code runs inside a {for} iteration: {for} provides true
|
|
40
|
+
* around its iterations, and the writes of such code are noted as edits of
|
|
41
|
+
* the items they reach (see runAsItemEdit).
|
|
42
|
+
*/
|
|
43
|
+
export const ItemEditContext = createContext(false);
|
|
44
|
+
|
|
45
|
+
const NAMESPACES = ['variables', 'temporary', 'transient'] as const;
|
|
46
|
+
|
|
47
|
+
/** Run `run`, noting each variable path it changes as an edit. */
|
|
48
|
+
export function runAsItemEdit(run: () => void): void {
|
|
49
|
+
const before = readState();
|
|
50
|
+
try {
|
|
51
|
+
run();
|
|
52
|
+
} finally {
|
|
53
|
+
const after = readState();
|
|
54
|
+
for (const ns of NAMESPACES) {
|
|
55
|
+
if (before[ns] === after[ns]) continue;
|
|
56
|
+
for (const { path } of diffPaths(before[ns], after[ns], true)) {
|
|
57
|
+
note([ns, ...path]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
20
63
|
export function controlEditVersion(): number {
|
|
21
64
|
return version;
|
|
22
65
|
}
|
|
23
66
|
|
|
24
67
|
/**
|
|
25
|
-
*
|
|
26
|
-
* `list`: the path leads through the item (compared by reference
|
|
27
|
-
* `
|
|
68
|
+
* A test of whether an edit made since `since` wrote inside `item`, element
|
|
69
|
+
* `index` of `list`: the path leads through the item (compared by reference
|
|
70
|
+
* in `state`), or, for a primitive item, ends at its slot in the list.
|
|
28
71
|
*/
|
|
29
|
-
export function
|
|
72
|
+
export function editedItems(
|
|
30
73
|
since: number,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
74
|
+
state: VariableNamespaces,
|
|
75
|
+
): (list: readonly unknown[], item: unknown, index: number) => boolean {
|
|
76
|
+
// The objects the paths lead through, and the keys they end at
|
|
77
|
+
const through = new Set<unknown>();
|
|
78
|
+
const ends = new Map<unknown, Set<string>>();
|
|
79
|
+
for (let i = log.length - 1; i >= 0 && log[i]!.version > since; i--) {
|
|
80
|
+
const { path } = log[i]!;
|
|
81
|
+
let node: unknown = state;
|
|
82
|
+
path.forEach((seg, n) => {
|
|
83
|
+
if (n === path.length - 1 && node !== null && typeof node === 'object') {
|
|
84
|
+
const keys = ends.get(node) ?? new Set<string>();
|
|
85
|
+
keys.add(seg);
|
|
86
|
+
ends.set(node, keys);
|
|
87
|
+
}
|
|
88
|
+
node =
|
|
89
|
+
node !== null && typeof node === 'object'
|
|
90
|
+
? getByPath(node, [seg])
|
|
91
|
+
: undefined;
|
|
92
|
+
if (node !== null && typeof node === 'object') through.add(node);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return (list, item, index) =>
|
|
96
|
+
typeof item === 'object' && item !== null
|
|
97
|
+
? through.has(item)
|
|
98
|
+
: ends.get(list)?.has(String(index)) === true;
|
|
48
99
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A set of listeners to call when something happens: `subscribe` adds one and
|
|
3
|
+
* returns the function that removes it, `notify` calls each one. A listener
|
|
4
|
+
* removed or added while they are called is called or not as the set was
|
|
5
|
+
* when `notify` started.
|
|
6
|
+
*/
|
|
7
|
+
export function createListeners(): {
|
|
8
|
+
subscribe: (listener: () => void) => () => void;
|
|
9
|
+
notify: () => void;
|
|
10
|
+
} {
|
|
11
|
+
const listeners = new Set<() => void>();
|
|
12
|
+
return {
|
|
13
|
+
subscribe(listener) {
|
|
14
|
+
listeners.add(listener);
|
|
15
|
+
return () => {
|
|
16
|
+
listeners.delete(listener);
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
notify() {
|
|
20
|
+
for (const listener of [...listeners]) listener();
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
package/src/utils/object-path.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { current as draftState, isDraft } from 'immer';
|
|
1
|
+
import { current as draftState, isDraft, original } from 'immer';
|
|
2
|
+
import { registeredClassName } from '../class-registry';
|
|
2
3
|
import { hasOwn } from './namespace';
|
|
3
4
|
import { atomicName } from './value-kinds';
|
|
4
5
|
|
|
@@ -17,7 +18,18 @@ export function getByPath(obj: object, segments: readonly string[]): unknown {
|
|
|
17
18
|
if (seg in Object.prototype && !hasOwn(Object(current), seg)) {
|
|
18
19
|
return undefined;
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
const holder = current as Record<string, unknown>;
|
|
22
|
+
current = holder[seg];
|
|
23
|
+
// An Immer draft of a Map or Set does not carry the own properties of a
|
|
24
|
+
// registered subclass instance: they are those of its base (#412)
|
|
25
|
+
if (
|
|
26
|
+
current === undefined &&
|
|
27
|
+
isDraft(holder) &&
|
|
28
|
+
(holder instanceof Map || holder instanceof Set)
|
|
29
|
+
) {
|
|
30
|
+
const base = original(holder) as Record<string, unknown> | undefined;
|
|
31
|
+
if (base && hasOwn(base, seg)) current = base[seg];
|
|
32
|
+
}
|
|
21
33
|
}
|
|
22
34
|
return current;
|
|
23
35
|
}
|
|
@@ -74,10 +86,12 @@ export interface SetByPathOptions {
|
|
|
74
86
|
* written in place.
|
|
75
87
|
*
|
|
76
88
|
* The path goes through own properties of plain objects, class instances
|
|
77
|
-
* and arrays (by index)
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
89
|
+
* and arrays (by index), and, outside a draft, any key of an instance of a
|
|
90
|
+
* registered subclass of a built-in (#408); anything else throws a
|
|
91
|
+
* TypeError rather than writing where no clone or save would see it: an
|
|
92
|
+
* inherited property such as a method counts as missing, and Map, Set,
|
|
93
|
+
* Date and RegExp values and other keys of arrays are refused, as is a
|
|
94
|
+
* "__proto__" segment.
|
|
81
95
|
*/
|
|
82
96
|
export function setByPath(
|
|
83
97
|
root: Record<string, unknown>,
|
|
@@ -134,13 +148,21 @@ function checkSegments(segments: readonly string[]): void {
|
|
|
134
148
|
}
|
|
135
149
|
}
|
|
136
150
|
|
|
137
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Throw unless `holder` can take `key` as story state (see setByPath). An
|
|
153
|
+
* instance of a registered subclass of a built-in keeps its own fields
|
|
154
|
+
* through clones and saves, so it takes any key (#408) when written in
|
|
155
|
+
* place: a draft of one, or one a draft shares, is refused as other
|
|
156
|
+
* built-ins are, and the caller writes the whole value instead.
|
|
157
|
+
*/
|
|
138
158
|
function checkHolder(
|
|
139
159
|
holder: object,
|
|
140
160
|
key: string,
|
|
141
161
|
segments: readonly string[],
|
|
142
162
|
depth: number,
|
|
163
|
+
inPlace: boolean,
|
|
143
164
|
): void {
|
|
165
|
+
if (inPlace && registeredClassName(holder) !== undefined) return;
|
|
144
166
|
const builtin = builtinName(holder);
|
|
145
167
|
const kind =
|
|
146
168
|
builtin ?? (Array.isArray(holder) && !isArrayKey(key) ? 'an array' : '');
|
|
@@ -167,7 +189,7 @@ function walkToParent(
|
|
|
167
189
|
let current: Record<string, unknown> = root;
|
|
168
190
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
169
191
|
const seg = segments[i]!;
|
|
170
|
-
checkHolder(current, seg, segments, i);
|
|
192
|
+
checkHolder(current, seg, segments, i, !copyOnWrite);
|
|
171
193
|
// An inherited property (a method, "constructor") counts as missing
|
|
172
194
|
let next = hasOwn(current, seg) ? current[seg] : undefined;
|
|
173
195
|
if (next == null || typeof next !== 'object') {
|
|
@@ -189,6 +211,7 @@ function walkToParent(
|
|
|
189
211
|
segments[segments.length - 1]!,
|
|
190
212
|
segments,
|
|
191
213
|
segments.length - 1,
|
|
214
|
+
!copyOnWrite,
|
|
192
215
|
);
|
|
193
216
|
return current;
|
|
194
217
|
}
|
package/src/utils/stable-key.ts
CHANGED
|
@@ -1,28 +1,37 @@
|
|
|
1
1
|
import { registeredClassName } from '../class-registry';
|
|
2
|
-
import { mapEntries, setMembers } from './value-kinds';
|
|
2
|
+
import { extraKeys, mapEntries, setMembers } from './value-kinds';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Content-derived string key for a value, used to remount components when
|
|
6
6
|
* the value's contents change (e.g. {for} iterations, #45).
|
|
7
7
|
*
|
|
8
|
-
* JSON-compatible values (strings, finite numbers, booleans, null,
|
|
9
|
-
* plain objects of those)
|
|
10
|
-
* values get distinct, unambiguous markers so their contents
|
|
11
|
-
* too: Map and Set entries (nested at any depth), Date,
|
|
12
|
-
* undefined, NaN/±Infinity, symbols (by description),
|
|
13
|
-
* and registered class instances
|
|
14
|
-
*
|
|
15
|
-
*
|
|
8
|
+
* A tree of JSON-compatible values (strings, finite numbers, booleans, null,
|
|
9
|
+
* arrays and plain objects of those) produces exactly its `JSON.stringify`
|
|
10
|
+
* output. Other values get distinct, unambiguous markers so their contents
|
|
11
|
+
* are reflected too: Map and Set entries (nested at any depth), Date,
|
|
12
|
+
* RegExp, BigInt, undefined, NaN/±Infinity, symbols (by description),
|
|
13
|
+
* functions (by name) and registered class instances, with the class name
|
|
14
|
+
* and, for a subclass of a built-in, its own fields (#407). A hole in an
|
|
15
|
+
* array reads as undefined.
|
|
16
|
+
*
|
|
17
|
+
* An object met again, through a cycle or a second reference to it, is a
|
|
18
|
+
* marker naming where it was first met rather than another copy of its
|
|
19
|
+
* contents, so the key grows with the objects a value holds, not with the
|
|
20
|
+
* paths to them (#406). The function never throws.
|
|
16
21
|
*/
|
|
17
22
|
export function stableKey(value: unknown): string {
|
|
18
23
|
try {
|
|
19
|
-
return keyOf(value,
|
|
24
|
+
return keyOf(value, new Map());
|
|
20
25
|
} catch {
|
|
21
26
|
return '<unkeyable>';
|
|
22
27
|
}
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The key of `val`. `seen` numbers the objects keyed so far, in the order
|
|
32
|
+
* they were first met.
|
|
33
|
+
*/
|
|
34
|
+
function keyOf(val: unknown, seen: Map<object, number>): string {
|
|
26
35
|
switch (typeof val) {
|
|
27
36
|
case 'string':
|
|
28
37
|
return JSON.stringify(val);
|
|
@@ -46,42 +55,62 @@ function keyOf(val: unknown, ancestors: object[]): string {
|
|
|
46
55
|
if (val === null) return 'null';
|
|
47
56
|
|
|
48
57
|
const obj = val as object;
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
58
|
+
const first = seen.get(obj);
|
|
59
|
+
if (first !== undefined) return `<ref ${first}>`;
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
const className = registeredClassName(obj);
|
|
62
|
+
const named = (key: string) =>
|
|
63
|
+
className === undefined ? key : `Class(${JSON.stringify(className)})${key}`;
|
|
64
|
+
// The own fields of a registered subclass of a built-in, which clones and
|
|
65
|
+
// saves keep with its contents
|
|
66
|
+
const withFields = (key: string) => {
|
|
67
|
+
const keys = className === undefined ? [] : extraKeys(obj);
|
|
68
|
+
return named(keys.length ? `${key}${fieldsOf(obj, keys, seen)}` : key);
|
|
69
|
+
};
|
|
57
70
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// hole from an undefined element (deepEqual does)
|
|
64
|
-
return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(',')}]`;
|
|
65
|
-
}
|
|
66
|
-
if (val instanceof Map) {
|
|
67
|
-
const entries = [...mapEntries(val)].map(
|
|
68
|
-
([k, v]) => `[${keyOf(k, ancestors)},${keyOf(v, ancestors)}]`,
|
|
69
|
-
);
|
|
70
|
-
return `Map[${entries.join(',')}]`;
|
|
71
|
-
}
|
|
72
|
-
if (val instanceof Set) {
|
|
73
|
-
return `Set[${[...setMembers(val)].map((v) => keyOf(v, ancestors)).join(',')}]`;
|
|
71
|
+
// Read with the built-in methods: a subclass may override them. A Date or
|
|
72
|
+
// RegExp holds no other value, so it needs no number unless it has fields.
|
|
73
|
+
if (val instanceof Date || val instanceof RegExp) {
|
|
74
|
+
if (className !== undefined && extraKeys(obj).length) {
|
|
75
|
+
seen.set(obj, seen.size);
|
|
74
76
|
}
|
|
77
|
+
return withFields(
|
|
78
|
+
val instanceof Date
|
|
79
|
+
? `Date(${Date.prototype.getTime.call(val)})`
|
|
80
|
+
: `RegExp(${RegExp.prototype.toString.call(val)}@${val.lastIndex})`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
75
83
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
seen.set(obj, seen.size);
|
|
85
|
+
if (Array.isArray(val)) {
|
|
86
|
+
// Array.from reads a hole as undefined (map would skip it, giving
|
|
87
|
+
// `[,]` the key of `[]`): as JSON.stringify, a key does not tell a
|
|
88
|
+
// hole from an undefined element (deepEqual does)
|
|
89
|
+
return withFields(`[${Array.from(val, (v) => keyOf(v, seen)).join(',')}]`);
|
|
90
|
+
}
|
|
91
|
+
if (val instanceof Map) {
|
|
92
|
+
const entries = Array.from(
|
|
93
|
+
mapEntries(val),
|
|
94
|
+
([k, v]) => `[${keyOf(k, seen)},${keyOf(v, seen)}]`,
|
|
95
|
+
);
|
|
96
|
+
return withFields(`Map[${entries.join(',')}]`);
|
|
86
97
|
}
|
|
98
|
+
if (val instanceof Set) {
|
|
99
|
+
const members = Array.from(setMembers(val), (v) => keyOf(v, seen));
|
|
100
|
+
return withFields(`Set[${members.join(',')}]`);
|
|
101
|
+
}
|
|
102
|
+
return named(fieldsOf(obj, Object.keys(obj), seen));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `{"key":value,...}` for the `keys` of `obj`. */
|
|
106
|
+
function fieldsOf(
|
|
107
|
+
obj: object,
|
|
108
|
+
keys: readonly string[],
|
|
109
|
+
seen: Map<object, number>,
|
|
110
|
+
): string {
|
|
111
|
+
const record = obj as Record<string, unknown>;
|
|
112
|
+
const body = keys.map(
|
|
113
|
+
(k) => `${JSON.stringify(k)}:${keyOf(record[k], seen)}`,
|
|
114
|
+
);
|
|
115
|
+
return `{${body.join(',')}}`;
|
|
87
116
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -866,7 +866,10 @@ export interface StoryAPI {
|
|
|
866
866
|
/** Set a one-time transition for the next navigation only. Pass `null` to clear. */
|
|
867
867
|
setNextTransition(config: TransitionConfig | null): void;
|
|
868
868
|
|
|
869
|
-
/**
|
|
869
|
+
/**
|
|
870
|
+
* Defer initial passage rendering until `ready()` is called. Calling it
|
|
871
|
+
* again before `ready()` keeps the one deferral: a single `ready()` ends it.
|
|
872
|
+
*/
|
|
870
873
|
deferRender(): void;
|
|
871
874
|
|
|
872
875
|
/** Unblock deferred rendering (call after `deferRender()`). */
|
package/types/tooling.d.ts
CHANGED
|
@@ -111,7 +111,11 @@ export type VarType =
|
|
|
111
111
|
/** Inferred shape of a declared variable (or one of its object fields). */
|
|
112
112
|
export interface FieldSchema {
|
|
113
113
|
type: VarType;
|
|
114
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* Field schemas, only present for objects. An object the default refers
|
|
116
|
+
* to more than once has one schema, so a cyclic default (`node.self =
|
|
117
|
+
* node`) gives a schema that refers to itself.
|
|
118
|
+
*/
|
|
115
119
|
fields?: Map<string, FieldSchema>;
|
|
116
120
|
}
|
|
117
121
|
|