@rohal12/spindle 0.59.14 → 0.59.16

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.
@@ -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 a reader made through a control bound to a story
5
- * variable (`{textbox}`, `{checkbox}`, ...). {for} tells these apart from a
6
- * list that is replaced: typing into a control inside an iteration changes
7
- * its item, but must not remount the iteration and drop the control's focus.
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) still remounts.
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 = 256;
13
- const log: { version: number; segments: readonly string[] }[] = [];
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
- export function noteControlEdit(segments: readonly string[]): void {
16
- log.push({ version: ++version, segments });
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
- * Whether an edit since `since` wrote inside `item`, element `index` of
26
- * `list`: the path leads through the item (compared by reference in
27
- * `variables`), or, for a primitive item, ends at its slot in the list.
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 editedItem(
72
+ export function editedItems(
30
73
  since: number,
31
- variables: object,
32
- list: readonly unknown[],
33
- item: unknown,
34
- index: number,
35
- ): boolean {
36
- return log.some(({ version: v, segments }) => {
37
- if (v <= since) return false;
38
- if (typeof item === 'object' && item !== null) {
39
- return segments.some(
40
- (_, n) => getByPath(variables, segments.slice(0, n + 1)) === item,
41
- );
42
- }
43
- return (
44
- segments[segments.length - 1] === String(index) &&
45
- getByPath(variables, segments.slice(0, -1)) === list
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
+ }
@@ -1,4 +1,5 @@
1
1
  import { current as draftState, isDraft } from 'immer';
2
+ import { registeredClassName } from '../class-registry';
2
3
  import { hasOwn } from './namespace';
3
4
  import { atomicName } from './value-kinds';
4
5
 
@@ -74,10 +75,12 @@ export interface SetByPathOptions {
74
75
  * written in place.
75
76
  *
76
77
  * The path goes through own properties of plain objects, class instances
77
- * and arrays (by index); anything else throws a TypeError rather than
78
- * writing where no clone or save would see it: an inherited property such
79
- * as a method counts as missing, and Map, Set, Date and RegExp values and
80
- * other keys of arrays are refused, as is a "__proto__" segment.
78
+ * and arrays (by index), and, outside a draft, any key of an instance of a
79
+ * registered subclass of a built-in (#408); anything else throws a
80
+ * TypeError rather than writing where no clone or save would see it: an
81
+ * inherited property such as a method counts as missing, and Map, Set,
82
+ * Date and RegExp values and other keys of arrays are refused, as is a
83
+ * "__proto__" segment.
81
84
  */
82
85
  export function setByPath(
83
86
  root: Record<string, unknown>,
@@ -134,13 +137,21 @@ function checkSegments(segments: readonly string[]): void {
134
137
  }
135
138
  }
136
139
 
137
- /** Throw unless `holder` can take `key` as story state (see setByPath). */
140
+ /**
141
+ * Throw unless `holder` can take `key` as story state (see setByPath). An
142
+ * instance of a registered subclass of a built-in keeps its own fields
143
+ * through clones and saves, so it takes any key (#408) when written in
144
+ * place: a draft of one, or one a draft shares, is refused as other
145
+ * built-ins are, and the caller writes the whole value instead.
146
+ */
138
147
  function checkHolder(
139
148
  holder: object,
140
149
  key: string,
141
150
  segments: readonly string[],
142
151
  depth: number,
152
+ inPlace: boolean,
143
153
  ): void {
154
+ if (inPlace && registeredClassName(holder) !== undefined) return;
144
155
  const builtin = builtinName(holder);
145
156
  const kind =
146
157
  builtin ?? (Array.isArray(holder) && !isArrayKey(key) ? 'an array' : '');
@@ -167,7 +178,7 @@ function walkToParent(
167
178
  let current: Record<string, unknown> = root;
168
179
  for (let i = 0; i < segments.length - 1; i++) {
169
180
  const seg = segments[i]!;
170
- checkHolder(current, seg, segments, i);
181
+ checkHolder(current, seg, segments, i, !copyOnWrite);
171
182
  // An inherited property (a method, "constructor") counts as missing
172
183
  let next = hasOwn(current, seg) ? current[seg] : undefined;
173
184
  if (next == null || typeof next !== 'object') {
@@ -189,6 +200,7 @@ function walkToParent(
189
200
  segments[segments.length - 1]!,
190
201
  segments,
191
202
  segments.length - 1,
203
+ !copyOnWrite,
192
204
  );
193
205
  return current;
194
206
  }
@@ -1,27 +1,37 @@
1
1
  import { registeredClassName } from '../class-registry';
2
+ import { extraKeys, mapEntries, setMembers } from './value-kinds';
2
3
 
3
4
  /**
4
5
  * Content-derived string key for a value, used to remount components when
5
6
  * the value's contents change (e.g. {for} iterations, #45).
6
7
  *
7
- * JSON-compatible values (strings, finite numbers, booleans, null, arrays and
8
- * plain objects of those) produce exactly their `JSON.stringify` output. Other
9
- * values get distinct, unambiguous markers so their contents are reflected
10
- * too: Map and Set entries (nested at any depth), Date, RegExp, BigInt,
11
- * undefined, NaN/±Infinity, symbols (by description), functions (by name)
12
- * and registered class instances. A hole in an array reads as undefined.
13
- * Cyclic references become a back-reference marker instead of throwing,
14
- * and the function never throws.
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.
15
21
  */
16
22
  export function stableKey(value: unknown): string {
17
23
  try {
18
- return keyOf(value, []);
24
+ return keyOf(value, new Map());
19
25
  } catch {
20
26
  return '<unkeyable>';
21
27
  }
22
28
  }
23
29
 
24
- function keyOf(val: unknown, ancestors: object[]): string {
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 {
25
35
  switch (typeof val) {
26
36
  case 'string':
27
37
  return JSON.stringify(val);
@@ -45,39 +55,62 @@ function keyOf(val: unknown, ancestors: object[]): string {
45
55
  if (val === null) return 'null';
46
56
 
47
57
  const obj = val as object;
48
- const depth = ancestors.indexOf(obj);
49
- if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
58
+ const first = seen.get(obj);
59
+ if (first !== undefined) return `<ref ${first}>`;
50
60
 
51
- if (val instanceof Date) return `Date(${val.getTime()})`;
52
- if (val instanceof RegExp) return `RegExp(${String(val)}@${val.lastIndex})`;
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
+ };
53
70
 
54
- ancestors.push(obj);
55
- try {
56
- if (Array.isArray(val)) {
57
- // Array.from reads a hole as undefined (map would skip it, giving
58
- // `[,]` the key of `[]`): as JSON.stringify, a key does not tell a
59
- // hole from an undefined element (deepEqual does)
60
- return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(',')}]`;
61
- }
62
- if (val instanceof Map) {
63
- const entries = [...val].map(
64
- ([k, v]) => `[${keyOf(k, ancestors)},${keyOf(v, ancestors)}]`,
65
- );
66
- return `Map[${entries.join(',')}]`;
67
- }
68
- if (val instanceof Set) {
69
- return `Set[${[...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);
70
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
+ }
71
83
 
72
- const record = obj as Record<string, unknown>;
73
- const body = Object.keys(record)
74
- .map((k) => `${JSON.stringify(k)}:${keyOf(record[k], ancestors)}`)
75
- .join(',');
76
- const className = registeredClassName(obj);
77
- return className === undefined
78
- ? `{${body}}`
79
- : `Class(${JSON.stringify(className)}){${body}}`;
80
- } finally {
81
- ancestors.pop();
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(',')}]`);
97
+ }
98
+ if (val instanceof Set) {
99
+ const members = Array.from(setMembers(val), (v) => keyOf(v, seen));
100
+ return withFields(`Set[${members.join(',')}]`);
82
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(',')}}`;
83
116
  }
@@ -1,6 +1,8 @@
1
1
  // Kinds of story values that structural operations (clone, equality, merge)
2
2
  // and property paths treat as a whole, by their content.
3
3
 
4
+ import { isDraft } from 'immer';
5
+
4
6
  export const toStringTag = (value: object): string =>
5
7
  Object.prototype.toString.call(value).slice(8, -1);
6
8
 
@@ -57,3 +59,43 @@ export function extraKeys(collection: object): string[] {
57
59
  const keys = Object.keys(collection);
58
60
  return Array.isArray(collection) ? keys.filter((k) => !isIndexKey(k)) : keys;
59
61
  }
62
+
63
+ // A subclass of Map or Set may override how it is iterated, read or written
64
+ // (an inventory that iterates only the items on hand). What it holds is in
65
+ // its built-in slots, so structural operations read and write those with the
66
+ // built-in methods (#391, #394). An Immer draft keeps its entries in its own
67
+ // state instead, so it is read through its methods.
68
+
69
+ /** The entries of a Map, as the built-in iterator gives them. */
70
+ export function mapEntries<K, V>(map: Map<K, V>): MapIterator<[K, V]> {
71
+ return isDraft(map)
72
+ ? map.entries()
73
+ : (Map.prototype.entries.call(map) as MapIterator<[K, V]>);
74
+ }
75
+
76
+ /** The members of a Set, as the built-in iterator gives them. */
77
+ export function setMembers<T>(set: Set<T>): SetIterator<T> {
78
+ return isDraft(set)
79
+ ? set.values()
80
+ : (Set.prototype.values.call(set) as SetIterator<T>);
81
+ }
82
+
83
+ /** The number of entries of a Map or members of a Set. */
84
+ export function collectionSize(
85
+ collection: Map<unknown, unknown> | Set<unknown>,
86
+ ) {
87
+ if (isDraft(collection)) return collection.size;
88
+ const proto = collection instanceof Map ? Map.prototype : Set.prototype;
89
+ return Reflect.get(proto, 'size', collection) as number;
90
+ }
91
+
92
+ /** The value a Map holds for `key`. */
93
+ export function mapGet<K, V>(map: Map<K, V>, key: K): V | undefined {
94
+ return isDraft(map) ? map.get(key) : Map.prototype.get.call(map, key);
95
+ }
96
+
97
+ /** Make a Map hold `value` for `key`. */
98
+ export function mapSet<K, V>(map: Map<K, V>, key: K, value: V): void {
99
+ if (isDraft(map)) map.set(key, value);
100
+ else Map.prototype.set.call(map, key, value);
101
+ }
package/types/index.d.ts CHANGED
@@ -794,7 +794,9 @@ export interface StoryAPI {
794
794
  /**
795
795
  * Register a class so its instances keep their class through clones,
796
796
  * history, saves and loads. A save refuses instances of classes that are
797
- * not registered.
797
+ * not registered. A class may extend Array, Map, Set, Date, RegExp or
798
+ * Error; one extending another built-in whose value a save cannot hold (a
799
+ * typed array, URL, Promise...) throws.
798
800
  */
799
801
  registerClass(name: string, ctor: new (...args: any[]) => any): void;
800
802
 
@@ -864,7 +866,10 @@ export interface StoryAPI {
864
866
  /** Set a one-time transition for the next navigation only. Pass `null` to clear. */
865
867
  setNextTransition(config: TransitionConfig | null): void;
866
868
 
867
- /** Defer initial passage rendering until `ready()` is called. */
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
+ */
868
873
  deferRender(): void;
869
874
 
870
875
  /** Unblock deferred rendering (call after `deferRender()`). */
@@ -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
- /** Field schemas, only present for objects. */
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