@rohal12/spindle 0.59.16 → 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.
@@ -3,7 +3,7 @@ import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, u
3
3
  import { useSyncExternalStore } from "preact/compat";
4
4
  import { createStore } from "zustand/vanilla";
5
5
  import { immer } from "zustand/middleware/immer";
6
- import { Immer, current, enableMapSet, enablePatches, freeze, isDraft } from "immer";
6
+ import { Immer, current, enableMapSet, enablePatches, freeze, isDraft, original } from "immer";
7
7
  import { shallow } from "zustand/vanilla/shallow";
8
8
  import { Fragment as Fragment$1, jsx, jsxs } from "preact/jsx-runtime";
9
9
  import { micromark } from "micromark";
@@ -10900,6 +10900,13 @@ function registerWidgetDefinitions(nodes, passageName) {
10900
10900
  */
10901
10901
  var FrozenStateContext = createContext(null);
10902
10902
  /**
10903
+ * Tells a timer ({repeat}, {timed}) whether the passage it belongs to has
10904
+ * been left: it must not run a new body then, for that would write into the
10905
+ * destination's state until the outgoing tree unmounts (#410). Null outside
10906
+ * a passage.
10907
+ */
10908
+ var OutgoingContext = createContext(null);
10909
+ /**
10903
10910
  * Story state fields `keys`, re-rendering when any of them changes (as one
10904
10911
  * useStoryStore() selector per field would). The result keeps its identity
10905
10912
  * while the fields don't change, as the store's snapshot must.
@@ -14417,7 +14424,12 @@ function getByPath(obj, segments) {
14417
14424
  for (const seg of segments) {
14418
14425
  if (current == null) return void 0;
14419
14426
  if (seg in Object.prototype && !hasOwn(Object(current), seg)) return;
14420
- current = current[seg];
14427
+ const holder = current;
14428
+ current = holder[seg];
14429
+ if (current === void 0 && isDraft(holder) && (holder instanceof Map || holder instanceof Set)) {
14430
+ const base = original(holder);
14431
+ if (base && hasOwn(base, seg)) current = base[seg];
14432
+ }
14421
14433
  }
14422
14434
  return current;
14423
14435
  }
@@ -15048,6 +15060,18 @@ function setEntryValue(root, path, value) {
15048
15060
  return true;
15049
15061
  }
15050
15062
  /**
15063
+ * The own enumerable fields of a Map, Set, Date or RegExp (an instance of a
15064
+ * registered subclass may hold some besides its built-in content, #412);
15065
+ * none for any other value.
15066
+ */
15067
+ function builtinFields(value) {
15068
+ return value instanceof Map || value instanceof Set || value instanceof Date || value instanceof RegExp ? extraKeys(value) : [];
15069
+ }
15070
+ /** Whether `x` and `y` are built-ins of one class that may hold fields. */
15071
+ function sameBuiltin(x, y) {
15072
+ return Object.getPrototypeOf(x) === Object.getPrototypeOf(y) && (builtinFields(x).length > 0 || builtinFields(y).length > 0);
15073
+ }
15074
+ /**
15051
15075
  * Every path each object (or array, or other value) of `root` is at, the
15052
15076
  * first appearance first, in depth-first order. Plain objects and arrays are
15053
15077
  * entered, where first met only (an object held in an array is one object,
@@ -15071,10 +15095,13 @@ function objectPaths(root, skip = /* @__PURE__ */ new Set(), entries = false) {
15071
15095
  }
15072
15096
  all.set(value, [at]);
15073
15097
  if (isMergeable(value) || Array.isArray(value)) walk(value, at);
15074
- else if (entries && entryChildren(value)) collections.push([value, at]);
15098
+ else {
15099
+ walk(value, at, builtinFields(value));
15100
+ if (entries && entryChildren(value)) collections.push([value, at]);
15101
+ }
15075
15102
  }
15076
- function walk(node, path) {
15077
- for (const key of Object.keys(node)) {
15103
+ function walk(node, path, keys = Object.keys(node)) {
15104
+ for (const key of keys) {
15078
15105
  const value = node[key];
15079
15106
  if (!isObjectValue(value)) continue;
15080
15107
  const at = [...path, key];
@@ -15122,13 +15149,13 @@ function aliasMoved(x, y, at, earlier, later) {
15122
15149
  a,
15123
15150
  held.get(segment)
15124
15151
  ]);
15125
- } else if ((isMergeable(x) || Array.isArray(x)) && mergesWith(x, y, true)) {
15126
- for (const key of Object.keys(y)) if (hasOwn(x, key)) pairs.push([
15127
- key,
15128
- x[key],
15129
- y[key]
15130
- ]);
15131
15152
  }
15153
+ const keys = mergesWith(x, y, true) ? Object.keys(y) : sameBuiltin(x, y) ? builtinFields(y) : [];
15154
+ for (const key of keys) if (hasOwn(x, key)) pairs.push([
15155
+ key,
15156
+ x[key],
15157
+ y[key]
15158
+ ]);
15132
15159
  return pairs.some(([segment, a, b]) => {
15133
15160
  if (!isObjectValue(a) || !isObjectValue(b)) return false;
15134
15161
  const here = [...at, segment];
@@ -15149,7 +15176,7 @@ function aliasChanges(before, after) {
15149
15176
  const later = firstPaths(after, void 0, true);
15150
15177
  const changes = [];
15151
15178
  (function walk(b, a, path) {
15152
- for (const key of Object.keys(a)) {
15179
+ for (const key of Array.isArray(a) || isMergeable(a) ? Object.keys(a) : builtinFields(a)) {
15153
15180
  if (!hasOwn(b, key)) continue;
15154
15181
  const x = b[key];
15155
15182
  const y = a[key];
@@ -15170,7 +15197,7 @@ function aliasChanges(before, after) {
15170
15197
  value: y,
15171
15198
  alias: true
15172
15199
  });
15173
- if ((isMergeable(x) || Array.isArray(x)) && mergesWith(x, y, true) && pathKey(isAt) === k && pathKey(wasAt) === k) walk(x, y, at);
15200
+ if (((isMergeable(x) || Array.isArray(x)) && mergesWith(x, y, true) || sameBuiltin(x, y)) && pathKey(isAt) === k && pathKey(wasAt) === k) walk(x, y, at);
15174
15201
  }
15175
15202
  })(before, after, []);
15176
15203
  return changes;
@@ -17923,6 +17950,8 @@ var FOCUSABLE = [
17923
17950
  "select:not([disabled])",
17924
17951
  "textarea:not([disabled])",
17925
17952
  "iframe",
17953
+ "audio[controls]",
17954
+ "video[controls]",
17926
17955
  "summary",
17927
17956
  "[contenteditable]",
17928
17957
  "[tabindex]"
@@ -18822,7 +18851,12 @@ function defineMacro(config, source = "builtin") {
18822
18851
  }
18823
18852
  if (config.storeVar) {
18824
18853
  const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, "");
18825
- if (firstToken.startsWith("%")) return h("span", { class: "error" }, `{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`);
18854
+ const kind = {
18855
+ "%": "transient variables",
18856
+ _: "temporary variables",
18857
+ "@": "locals"
18858
+ }[firstToken.charAt(0)];
18859
+ if (kind) return h("span", { class: "error" }, `{${config.name}}: ${kind} (${firstToken}) cannot be bound to input macros`);
18826
18860
  const varExpr = firstToken.replace(/["']/g, "").replace(/^\$/, "");
18827
18861
  const segments = varExpr.split(".");
18828
18862
  if (segments.includes("__proto__")) return h("span", { class: "error" }, `{${config.name}}: "$${varExpr}" cannot be bound: ${RESERVED_NAME} is reserved`);
@@ -20406,14 +20440,16 @@ defineMacro({
20406
20440
  interpolate: true,
20407
20441
  parameters: [DELAY_PARAMETER],
20408
20442
  render({ children = [] }, ctx) {
20409
- const { useState, useEffect, useCallback } = ctx.hooks;
20443
+ const { useState, useEffect, useCallback, useContext } = ctx.hooks;
20410
20444
  const delay = ctx.args.delay ?? 0;
20411
20445
  const [count, setCount] = useState(0);
20412
20446
  const [stopped, setStopped] = useState(false);
20447
+ const hasLeft = useContext(OutgoingContext);
20413
20448
  const stop = useCallback(() => setStopped(true), []);
20414
20449
  useEffect(() => {
20415
20450
  if (stopped) return;
20416
20451
  const interval = setInterval(() => {
20452
+ if (hasLeft?.()) return;
20417
20453
  setCount((c) => c + 1);
20418
20454
  }, delay);
20419
20455
  return () => clearInterval(interval);
@@ -20450,7 +20486,8 @@ var frozenAlways = () => true;
20450
20486
  * the owning macro sees and provides them again: the locals scope (read
20451
20487
  * through a live view, so a local assigned earlier in the body is visible),
20452
20488
  * nobr/inline rendering, the enclosing block widget's {@children}, the
20453
- * enclosing {repeat}'s {stop} and the enclosing dialog's close callback.
20489
+ * enclosing {repeat}'s {stop} and the enclosing dialog's close callback and whether it runs in a {for} iteration
20490
+ * (#411) and the owning passage's departure (#410).
20454
20491
  * The body then behaves as if it rendered in place.
20455
20492
  *
20456
20493
  * The body's macros read story state from the store. Run from mutation code
@@ -20465,6 +20502,8 @@ function useDetachedBody() {
20465
20502
  const widgetChildren = useContext(WidgetChildrenContext);
20466
20503
  const repeat = useContext(RepeatContext);
20467
20504
  const closeDialog = useContext(DialogCloseContext);
20505
+ const inIteration = useContext(ItemEditContext);
20506
+ const hasLeft = useContext(OutgoingContext);
20468
20507
  const mounted = useRef([]);
20469
20508
  useEffect(() => {
20470
20509
  const unsubscribe = subscribeStateReplacement(() => {
@@ -20497,10 +20536,16 @@ function useDetachedBody() {
20497
20536
  value: repeat,
20498
20537
  children: /* @__PURE__ */ jsx(DialogCloseContext.Provider, {
20499
20538
  value: closeDialog,
20500
- children: renderNodes(children, {
20501
- nobr,
20502
- inline,
20503
- locals
20539
+ children: /* @__PURE__ */ jsx(ItemEditContext.Provider, {
20540
+ value: inIteration,
20541
+ children: /* @__PURE__ */ jsx(OutgoingContext.Provider, {
20542
+ value: hasLeft,
20543
+ children: renderNodes(children, {
20544
+ nobr,
20545
+ inline,
20546
+ locals
20547
+ })
20548
+ })
20504
20549
  })
20505
20550
  })
20506
20551
  })
@@ -20659,7 +20704,8 @@ defineMacro({
20659
20704
  interpolate: true,
20660
20705
  parameters: TIMED_PARAMETERS,
20661
20706
  render({ branches = [] }, ctx) {
20662
- const { useState, useEffect, useMemo } = ctx.hooks;
20707
+ const { useState, useEffect, useMemo, useContext } = ctx.hooks;
20708
+ const hasLeft = useContext(OutgoingContext);
20663
20709
  const sections = useMemo(() => {
20664
20710
  return branches.map((branch) => ({
20665
20711
  delay: parseMacroArgs(branch.rawArgs, TIMED_PARAMETERS).delay ?? 0,
@@ -20674,6 +20720,7 @@ defineMacro({
20674
20720
  const nextIndex = visibleIndex + 1;
20675
20721
  const delay = sections[nextIndex].delay;
20676
20722
  const timer = setTimeout(() => {
20723
+ if (hasLeft?.()) return;
20677
20724
  setVisibleIndex(nextIndex);
20678
20725
  }, delay);
20679
20726
  return () => clearTimeout(timer);
@@ -21981,6 +22028,7 @@ defineMacro({
21981
22028
  const prevHistoryLenRef = useRef(0);
21982
22029
  const displayedId = displayed.id;
21983
22030
  const isOutgoing = useCallback((state) => state.navigationId !== displayedId, [displayedId]);
22031
+ const hasLeft = useCallback(() => isOutgoing(useStoryStore.getState()), [isOutgoing]);
21984
22032
  const lastElement = useRef(null);
21985
22033
  const containerRef = useRef(null);
21986
22034
  const timeoutsRef = useRef([]);
@@ -22087,7 +22135,10 @@ defineMacro({
22087
22135
  ref: containerRef,
22088
22136
  children: passageElement && /* @__PURE__ */ jsx(FrozenStateContext.Provider, {
22089
22137
  value: isOutgoing,
22090
- children: passageElement
22138
+ children: /* @__PURE__ */ jsx(OutgoingContext.Provider, {
22139
+ value: hasLeft,
22140
+ children: passageElement
22141
+ })
22091
22142
  })
22092
22143
  })]
22093
22144
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.16",
3
+ "version": "0.59.17",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -9,6 +9,7 @@ import { useStoryStore, type StoryState } from '../../store';
9
9
  import {
10
10
  useStoryFields,
11
11
  FrozenStateContext,
12
+ OutgoingContext,
12
13
  } from '../../hooks/use-story-fields';
13
14
  import { Passage, renderPassageContent } from '../Passage';
14
15
  import { defineMacro } from '../../define-macro';
@@ -140,6 +141,11 @@ defineMacro({
140
141
  (state: StoryState) => state.navigationId !== displayedId,
141
142
  [displayedId],
142
143
  );
144
+ // Read live: a timer asks it before the outgoing tree has unmounted
145
+ const hasLeft = useCallback(
146
+ () => isOutgoing(useStoryStore.getState()),
147
+ [isOutgoing],
148
+ );
143
149
  const lastElement = useRef<preact.VNode | null>(null);
144
150
 
145
151
  // Container ref for snapshot insertion
@@ -350,7 +356,9 @@ defineMacro({
350
356
  >
351
357
  {passageElement && (
352
358
  <FrozenStateContext.Provider value={isOutgoing}>
353
- {passageElement}
359
+ <OutgoingContext.Provider value={hasLeft}>
360
+ {passageElement}
361
+ </OutgoingContext.Provider>
354
362
  </FrozenStateContext.Provider>
355
363
  )}
356
364
  </div>
@@ -1,6 +1,7 @@
1
1
  import { createContext } from 'preact';
2
2
  import { defineMacro } from '../../define-macro';
3
3
  import { DELAY_PARAMETER } from './macro-args';
4
+ import { OutgoingContext } from '../../hooks/use-story-fields';
4
5
 
5
6
  export const RepeatContext = createContext<{ stop: () => void }>({
6
7
  stop: () => {},
@@ -12,17 +13,21 @@ defineMacro({
12
13
  interpolate: true,
13
14
  parameters: [DELAY_PARAMETER],
14
15
  render({ children = [] }, ctx) {
15
- const { useState, useEffect, useCallback } = ctx.hooks;
16
+ const { useState, useEffect, useCallback, useContext } = ctx.hooks;
16
17
 
17
18
  const delay = ctx.args.delay ?? 0;
18
19
  const [count, setCount] = useState(0);
19
20
  const [stopped, setStopped] = useState(false);
20
21
 
22
+ const hasLeft = useContext(OutgoingContext);
23
+
21
24
  const stop = useCallback(() => setStopped(true), []);
22
25
 
23
26
  useEffect(() => {
24
27
  if (stopped) return;
25
28
  const interval = setInterval(() => {
29
+ // The passage was left: its body must not run again (#410)
30
+ if (hasLeft?.()) return;
26
31
  setCount((c) => c + 1);
27
32
  }, delay);
28
33
  return () => clearInterval(interval);
@@ -1,6 +1,7 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { DELAY_PARAMETER, parseMacroArgs } from './macro-args';
3
3
  import { wrapContent } from './display';
4
+ import { OutgoingContext } from '../../hooks/use-story-fields';
4
5
 
5
6
  /** The parameters of {timed} and of each {next}: the delay before it shows. */
6
7
  const TIMED_PARAMETERS = [DELAY_PARAMETER] as const;
@@ -11,7 +12,8 @@ defineMacro({
11
12
  interpolate: true,
12
13
  parameters: TIMED_PARAMETERS,
13
14
  render({ branches = [] }, ctx) {
14
- const { useState, useEffect, useMemo } = ctx.hooks;
15
+ const { useState, useEffect, useMemo, useContext } = ctx.hooks;
16
+ const hasLeft = useContext(OutgoingContext);
15
17
 
16
18
  const sections = useMemo(() => {
17
19
  return branches.map((branch) => ({
@@ -31,6 +33,8 @@ defineMacro({
31
33
  const delay = sections[nextIndex]!.delay;
32
34
 
33
35
  const timer = setTimeout(() => {
36
+ // The passage was left: its body must not run (#410)
37
+ if (hasLeft?.()) return;
34
38
  setVisibleIndex(nextIndex);
35
39
  }, delay);
36
40
 
@@ -10,12 +10,16 @@ import {
10
10
  } from '../../markup/render';
11
11
  import type { ASTNode } from '../../markup/ast';
12
12
  import { useRenderOptions } from '../../hooks/use-render-options';
13
- import { FrozenStateContext } from '../../hooks/use-story-fields';
13
+ import {
14
+ FrozenStateContext,
15
+ OutgoingContext,
16
+ } from '../../hooks/use-story-fields';
14
17
  import { liveLocalsView } from '../../utils/live-locals';
15
18
  import { runWithCommittedMutations } from '../../execute-mutation';
16
19
  import { subscribeStateReplacement } from '../../store';
17
20
  import { RepeatContext } from './Repeat';
18
21
  import { DialogCloseContext } from '../PassageDialog';
22
+ import { ItemEditContext } from '../../utils/control-edits';
19
23
 
20
24
  const frozenAlways = () => true;
21
25
 
@@ -35,7 +39,8 @@ const frozenAlways = () => true;
35
39
  * the owning macro sees and provides them again: the locals scope (read
36
40
  * through a live view, so a local assigned earlier in the body is visible),
37
41
  * nobr/inline rendering, the enclosing block widget's {@children}, the
38
- * enclosing {repeat}'s {stop} and the enclosing dialog's close callback.
42
+ * enclosing {repeat}'s {stop} and the enclosing dialog's close callback and whether it runs in a {for} iteration
43
+ * (#411) and the owning passage's departure (#410).
39
44
  * The body then behaves as if it rendered in place.
40
45
  *
41
46
  * The body's macros read story state from the store. Run from mutation code
@@ -50,6 +55,8 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
50
55
  const widgetChildren = useContext(WidgetChildrenContext);
51
56
  const repeat = useContext(RepeatContext);
52
57
  const closeDialog = useContext(DialogCloseContext);
58
+ const inIteration = useContext(ItemEditContext);
59
+ const hasLeft = useContext(OutgoingContext);
53
60
 
54
61
  const mounted = useRef<HTMLElement[]>([]);
55
62
  useEffect(() => {
@@ -81,7 +88,11 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
81
88
  <WidgetChildrenContext.Provider value={widgetChildren}>
82
89
  <RepeatContext.Provider value={repeat}>
83
90
  <DialogCloseContext.Provider value={closeDialog}>
84
- {renderNodes(children, { nobr, inline, locals })}
91
+ <ItemEditContext.Provider value={inIteration}>
92
+ <OutgoingContext.Provider value={hasLeft}>
93
+ {renderNodes(children, { nobr, inline, locals })}
94
+ </OutgoingContext.Provider>
95
+ </ItemEditContext.Provider>
85
96
  </DialogCloseContext.Provider>
86
97
  </RepeatContext.Provider>
87
98
  </WidgetChildrenContext.Provider>
@@ -216,11 +216,19 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
216
216
  if (config.storeVar) {
217
217
  const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, '');
218
218
 
219
- if (firstToken.startsWith('%')) {
219
+ // Only story variables ($name) can be bound: another sigil would be
220
+ // read as a story key spelled with it (#414)
221
+ const unbindable: Record<string, string> = {
222
+ '%': 'transient variables',
223
+ _: 'temporary variables',
224
+ '@': 'locals',
225
+ };
226
+ const kind = unbindable[firstToken.charAt(0)];
227
+ if (kind) {
220
228
  return h(
221
229
  'span',
222
230
  { class: 'error' },
223
- `{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`,
231
+ `{${config.name}}: ${kind} (${firstToken}) cannot be bound to input macros`,
224
232
  );
225
233
  }
226
234
 
@@ -9,6 +9,8 @@ const FOCUSABLE = [
9
9
  'select:not([disabled])',
10
10
  'textarea:not([disabled])',
11
11
  'iframe',
12
+ 'audio[controls]',
13
+ 'video[controls]',
12
14
  'summary',
13
15
  '[contenteditable]',
14
16
  '[tabindex]',
@@ -15,6 +15,14 @@ export const FrozenStateContext = createContext<
15
15
  ((state: StoryState) => boolean) | null
16
16
  >(null);
17
17
 
18
+ /**
19
+ * Tells a timer ({repeat}, {timed}) whether the passage it belongs to has
20
+ * been left: it must not run a new body then, for that would write into the
21
+ * destination's state until the outgoing tree unmounts (#410). Null outside
22
+ * a passage.
23
+ */
24
+ export const OutgoingContext = createContext<(() => boolean) | null>(null);
25
+
18
26
  /**
19
27
  * Story state fields `keys`, re-rendering when any of them changes (as one
20
28
  * useStoryStore() selector per field would). The result keeps its identity
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
  ) {
@@ -1,4 +1,4 @@
1
- import { current as draftState, isDraft } from 'immer';
1
+ import { current as draftState, isDraft, original } from 'immer';
2
2
  import { registeredClassName } from '../class-registry';
3
3
  import { hasOwn } from './namespace';
4
4
  import { atomicName } from './value-kinds';
@@ -18,7 +18,18 @@ export function getByPath(obj: object, segments: readonly string[]): unknown {
18
18
  if (seg in Object.prototype && !hasOwn(Object(current), seg)) {
19
19
  return undefined;
20
20
  }
21
- 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
+ }
22
33
  }
23
34
  return current;
24
35
  }