@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.
@@ -68,18 +68,30 @@ const PRIMITIVE_SAMPLES: Partial<Record<VarType, object>> = {
68
68
  boolean: Object(false),
69
69
  };
70
70
 
71
- function inferSchema(value: unknown): FieldSchema {
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 { type: 'object', fields };
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 if (entries && entryChildren(value)) {
892
- collections.push([value, at]);
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(node: Record<string, unknown>, path: string[]): void {
896
- for (const key of Object.keys(node)) {
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
- } else if ((isMergeable(x) || Array.isArray(x)) && mergesWith(x, y, true)) {
967
- for (const key of Object.keys(y)) {
968
- if (hasOwn(x, key)) {
969
- pairs.push([key, (x as any)[key], (y as any)[key]]);
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 Object.keys(a)) {
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
- mergesWith(x, y, true) &&
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 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
- 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
- current = (current as Record<string, unknown>)[seg];
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); 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.
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
- /** Throw unless `holder` can take `key` as story state (see setByPath). */
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
  }
@@ -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, arrays and
9
- * plain objects of those) produce exactly their `JSON.stringify` output. Other
10
- * values get distinct, unambiguous markers so their contents are reflected
11
- * too: Map and Set entries (nested at any depth), Date, RegExp, BigInt,
12
- * undefined, NaN/±Infinity, symbols (by description), functions (by name)
13
- * and registered class instances. A hole in an array reads as undefined.
14
- * Cyclic references become a back-reference marker instead of throwing,
15
- * 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.
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
- 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 {
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 depth = ancestors.indexOf(obj);
50
- if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
58
+ const first = seen.get(obj);
59
+ if (first !== undefined) return `<ref ${first}>`;
51
60
 
52
- // Read with the built-in methods: a subclass may override them
53
- if (val instanceof Date) return `Date(${Date.prototype.getTime.call(val)})`;
54
- if (val instanceof RegExp) {
55
- return `RegExp(${RegExp.prototype.toString.call(val)}@${val.lastIndex})`;
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
- ancestors.push(obj);
59
- try {
60
- if (Array.isArray(val)) {
61
- // Array.from reads a hole as undefined (map would skip it, giving
62
- // `[,]` the key of `[]`): as JSON.stringify, a key does not tell a
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
- const record = obj as Record<string, unknown>;
77
- const body = Object.keys(record)
78
- .map((k) => `${JSON.stringify(k)}:${keyOf(record[k], ancestors)}`)
79
- .join(',');
80
- const className = registeredClassName(obj);
81
- return className === undefined
82
- ? `{${body}}`
83
- : `Class(${JSON.stringify(className)}){${body}}`;
84
- } finally {
85
- 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(',')}]`);
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
- /** 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
+ */
870
873
  deferRender(): void;
871
874
 
872
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