@rohal12/spindle 0.59.8 → 0.59.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.8",
3
+ "version": "0.59.10",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -51,7 +51,9 @@ export function clearRegistry(): void {
51
51
  // with its message, cause, errors and own enumerable keys (not its stack);
52
52
  // - `S`: a symbol from the global registry (Symbol.for);
53
53
  // - `K`: a plain object holding a one-character key that JSON escapes (see
54
- // GUARD_V8_KEYS).
54
+ // GUARD_V8_KEYS);
55
+ // - `R`: a RegExp whose lastIndex is not zero (the scanning cursor of a
56
+ // global or sticky pattern), with its source, flags and lastIndex.
55
57
  //
56
58
  // Everything else is refused when saving, with the path to the value:
57
59
  // functions, unique symbols, symbol keys, instances of unregistered classes,
@@ -61,6 +63,7 @@ const CLASS_PREFIX = 'c:';
61
63
  const ERROR_PREFIX = 'E:';
62
64
  const SYMBOL_TAG = 'S';
63
65
  const KEYS_TAG = 'K';
66
+ const REGEXP_TAG = 'R';
64
67
 
65
68
  /**
66
69
  * Work around Chromium issue 521080746 (V8 in Chrome/Chromium 14x-153+,
@@ -167,6 +170,9 @@ function reducers(): Record<string, (value: unknown) => unknown> {
167
170
  const key = Symbol.keyFor(v);
168
171
  return key !== undefined && [key];
169
172
  };
173
+ out[REGEXP_TAG] = (v) =>
174
+ v instanceof RegExp &&
175
+ v.lastIndex !== 0 && [v.source, v.flags, v.lastIndex];
170
176
  if (GUARD_V8_KEYS) {
171
177
  out[KEYS_TAG] = (v) => {
172
178
  if (!isObject(v) || Object.getPrototypeOf(v) !== Object.prototype) {
@@ -264,6 +270,21 @@ function reviverFor(key: string | symbol) {
264
270
  };
265
271
  }
266
272
  if (key === KEYS_TAG) return reviveKeyed;
273
+ if (key === REGEXP_TAG) {
274
+ return (data: unknown) => {
275
+ if (
276
+ !Array.isArray(data) ||
277
+ typeof data[0] !== 'string' ||
278
+ typeof data[1] !== 'string' ||
279
+ !Number.isSafeInteger(data[2])
280
+ ) {
281
+ throw new TypeError('spindle: Malformed regular expression');
282
+ }
283
+ const re = new RegExp(data[0], data[1]);
284
+ re.lastIndex = data[2] as number;
285
+ return re;
286
+ };
287
+ }
267
288
  if (key.startsWith(CLASS_PREFIX)) {
268
289
  return reviveClass(key.slice(CLASS_PREFIX.length));
269
290
  }
@@ -83,9 +83,9 @@ export function PassageDialog({
83
83
 
84
84
  const handleBackdrop = (e: MouseEvent) => {
85
85
  if (!dismissible) return;
86
- if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
87
- stableOnClose();
88
- }
86
+ // Only a click on this overlay itself: a nested dialog's backdrop click
87
+ // bubbles through the outer overlay, whose target is not its own.
88
+ if (e.target === e.currentTarget) stableOnClose();
89
89
  };
90
90
 
91
91
  const cls = panelClass ? `dialog-panel ${panelClass}` : 'dialog-panel';
@@ -105,6 +105,7 @@ export function PassageDialog({
105
105
  >
106
106
  {showCloseButton && (
107
107
  <button
108
+ type="button"
108
109
  class="dialog-close"
109
110
  aria-label="Close"
110
111
  onClick={stableOnClose}
@@ -26,6 +26,7 @@ defineMacro({
26
26
 
27
27
  return (
28
28
  <button
29
+ type="button"
29
30
  id={ctx.id}
30
31
  class={ctx.cls}
31
32
  onClick={handleClick}
@@ -32,6 +32,7 @@ defineMacro({
32
32
 
33
33
  return (
34
34
  <button
35
+ type="button"
35
36
  id={ctx.id}
36
37
  class={ctx.cls}
37
38
  onClick={handleClick}
@@ -22,6 +22,7 @@ defineMacro({
22
22
  return (
23
23
  <>
24
24
  <button
25
+ type="button"
25
26
  id={ctx.id}
26
27
  class={ctx.cls}
27
28
  onClick={() => setOpen(true)}
@@ -1,5 +1,5 @@
1
1
  import { defineMacro } from '../../define-macro';
2
- import { logMacroError } from './MacroError';
2
+ import { useRunOnce } from './MacroError';
3
3
 
4
4
  defineMacro({
5
5
  name: 'do',
@@ -8,16 +8,8 @@ defineMacro({
8
8
  // The parser keeps the body verbatim as a single text node
9
9
  const code = ctx.collectText(children);
10
10
 
11
- ctx.hooks.useLayoutEffect(() => {
12
- // Before the code runs: it may navigate away from this passage
13
- const location = ctx.sourceLocation();
14
- try {
15
- ctx.mutate(code);
16
- } catch (err) {
17
- logMacroError('do', err, location);
18
- }
19
- }, []);
20
-
21
- return null;
11
+ // During the first render, like {set}: the macros after it in the
12
+ // passage run later and see its writes (a layout effect ran after them).
13
+ return useRunOnce('do', '', () => ctx.mutate(code));
22
14
  },
23
15
  });
@@ -49,7 +49,7 @@ export function useRunOnce(macro: string, rawArgs: string, effect: () => void) {
49
49
  effect();
50
50
  } catch (error) {
51
51
  failure.current = { error };
52
- logMacroError(`${macro} ${rawArgs}`, error, location);
52
+ logMacroError(`${macro} ${rawArgs}`.trim(), error, location);
53
53
  }
54
54
  }
55
55
 
@@ -63,6 +63,7 @@ export function defineMenubarAction(config: MenubarActionConfig) {
63
63
  return (
64
64
  <>
65
65
  <button
66
+ type="button"
66
67
  id={ctx.id}
67
68
  class={cls}
68
69
  onClick={perform}
@@ -1,6 +1,15 @@
1
- import { useRef, useEffect, useCallback, useState } from 'preact/hooks';
2
- import { useStoryStore } from '../../store';
3
- import { useStoryFields } from '../../hooks/use-story-fields';
1
+ import {
2
+ useRef,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useCallback,
6
+ useState,
7
+ } from 'preact/hooks';
8
+ import { useStoryStore, type StoryState } from '../../store';
9
+ import {
10
+ useStoryFields,
11
+ FrozenStateContext,
12
+ } from '../../hooks/use-story-fields';
4
13
  import { Passage, renderPassageContent } from '../Passage';
5
14
  import { defineMacro } from '../../define-macro';
6
15
  import { resolveTransition, type ResolvedTransition } from '../../transition';
@@ -8,7 +17,7 @@ import { resolveTransition, type ResolvedTransition } from '../../transition';
8
17
  /**
9
18
  * Create a snapshot clone of a .passage element for outgoing transitions.
10
19
  * - Strips `id` attributes from the clone tree
11
- * - Disables interaction (pointer-events, user-select)
20
+ * - Disables interaction (pointer-events, user-select, inert, aria-hidden)
12
21
  * - Pauses/mutes any <audio>/<video>
13
22
  * - Adds `.passage-snapshot` class and `data-transition` attribute
14
23
  */
@@ -28,6 +37,10 @@ function createSnapshot(
28
37
  // Disable interaction
29
38
  clone.style.pointerEvents = 'none';
30
39
  clone.style.userSelect = 'none';
40
+ // Visual only: out of the tab order and the accessibility tree, and its
41
+ // controls (copied with their inline handlers) can not be activated
42
+ clone.setAttribute('inert', '');
43
+ clone.setAttribute('aria-hidden', 'true');
31
44
 
32
45
  // Pause/mute media
33
46
  const mediaEls = clone.querySelectorAll('audio, video');
@@ -122,6 +135,13 @@ defineMacro({
122
135
  // Track previous history length to detect restart/load
123
136
  const prevHistoryLenRef = useRef(0);
124
137
 
138
+ const displayedId = displayed.id;
139
+ const isOutgoing = useCallback(
140
+ (state: StoryState) => state.navigationId !== displayedId,
141
+ [displayedId],
142
+ );
143
+ const lastElement = useRef<preact.VNode | null>(null);
144
+
125
145
  // Container ref for snapshot insertion
126
146
  const containerRef = useRef<HTMLDivElement>(null);
127
147
 
@@ -261,6 +281,22 @@ defineMacro({
261
281
  // eslint-disable-next-line react-hooks/exhaustive-deps
262
282
  }, [navigationId]);
263
283
 
284
+ // A new passage opens at its beginning, not where the previous one was
285
+ // scrolled to (#354). The page keeps its position on first display
286
+ // (a refresh restores it) and when the beginning is already in view.
287
+ const shownId = displayedPassage && !renderDeferred ? displayed.id : null;
288
+ const lastShownId = useRef(shownId);
289
+ useLayoutEffect(() => {
290
+ if (shownId === null) return; // between passages of a transition
291
+ const previous = lastShownId.current;
292
+ lastShownId.current = shownId;
293
+ if (previous === null || previous === shownId) return;
294
+ const el = containerRef.current?.querySelector('.passage');
295
+ if (el && el.getBoundingClientRect().top < 0) {
296
+ el.scrollIntoView?.({ block: 'start' });
297
+ }
298
+ }, [shownId]);
299
+
264
300
  // When render is deferred, show StoryLoading passage or nothing
265
301
  const effectivePassage = renderDeferred
266
302
  ? (storyData?.passages.get('StoryLoading') ?? null)
@@ -278,6 +314,23 @@ defineMacro({
278
314
 
279
315
  const readyPassage = storyData?.passages.get('PassageReady');
280
316
 
317
+ // Between a navigation and the commit of its passage, the old passage is
318
+ // still mounted. It must not execute against the destination's state, so
319
+ // it keeps the element and the state it last rendered with (#352).
320
+ const outgoing = !renderDeferred && displayed.id !== navigationId;
321
+ const passageElement =
322
+ outgoing && lastElement.current
323
+ ? lastElement.current
324
+ : effectivePassage && (
325
+ <Passage
326
+ passage={effectivePassage}
327
+ key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
328
+ dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
329
+ navigationId={renderDeferred ? undefined : displayed.id}
330
+ />
331
+ );
332
+ lastElement.current = passageElement || null;
333
+
281
334
  return (
282
335
  <div
283
336
  id={ctx.id ?? 'story'}
@@ -295,13 +348,10 @@ defineMacro({
295
348
  class="passage-container"
296
349
  ref={containerRef}
297
350
  >
298
- {effectivePassage && (
299
- <Passage
300
- passage={effectivePassage}
301
- key={renderDeferred ? 'loading' : `nav-${displayed.id}`}
302
- dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
303
- navigationId={renderDeferred ? undefined : displayed.id}
304
- />
351
+ {passageElement && (
352
+ <FrozenStateContext.Provider value={isOutgoing}>
353
+ {passageElement}
354
+ </FrozenStateContext.Provider>
305
355
  )}
306
356
  </div>
307
357
  </div>
@@ -21,6 +21,7 @@ import {
21
21
  saveWithHooks,
22
22
  type PlaythroughGroup,
23
23
  } from '../../saves/save-manager';
24
+ import { getBackendType } from '../../saves/storage';
24
25
  import { DialogCloseContext } from '../PassageDialog';
25
26
  import { defineMacro } from '../../define-macro';
26
27
  import { errorMessage } from '../../utils/error-message';
@@ -71,10 +72,15 @@ export function SaveManagerContent() {
71
72
  'loadFromPayload',
72
73
  );
73
74
  const ifid = storyData?.ifid ?? '';
75
+ // Saves held in memory only (no browser storage is available) are lost
76
+ // when the page is reloaded or closed: told to the player, who can export
77
+ const [temporary, setTemporary] = useState(false);
74
78
 
75
79
  const refresh = useCallback(async () => {
76
80
  if (!ifid) return;
77
81
  const data = await getSavesGrouped(ifid);
82
+ // After the read: the storage in use is known by now
83
+ setTemporary(getBackendType() === 'memory');
78
84
  setGroups(data);
79
85
  setLoading(false);
80
86
  }, [ifid]);
@@ -131,7 +137,7 @@ export function SaveManagerContent() {
131
137
  // Queued now, in call order with other storage operations
132
138
  return createSave(ifid, playthrough, payload);
133
139
  });
134
- showStatus('Save created');
140
+ showStatus(temporary ? 'Save created (temporary)' : 'Save created');
135
141
  await refresh();
136
142
  } catch {
137
143
  showStatus('Failed to create save', 'error');
@@ -276,12 +282,14 @@ export function SaveManagerContent() {
276
282
  <div class="saves-header-left">
277
283
  <div class="saves-mode-toggle">
278
284
  <button
285
+ type="button"
279
286
  class={mode === 'save' ? 'active' : ''}
280
287
  onClick={() => setMode('save')}
281
288
  >
282
289
  Save
283
290
  </button>
284
291
  <button
292
+ type="button"
285
293
  class={mode === 'load' ? 'active' : ''}
286
294
  onClick={() => setMode('load')}
287
295
  >
@@ -291,8 +299,19 @@ export function SaveManagerContent() {
291
299
  </div>
292
300
  </div>
293
301
 
302
+ {temporary && (
303
+ <div
304
+ class="saves-notice"
305
+ role="status"
306
+ >
307
+ Saves can not be kept in this browser: they are lost when the page is
308
+ reloaded or closed. Export a save to keep it.
309
+ </div>
310
+ )}
311
+
294
312
  <div class="saves-toolbar">
295
313
  <button
314
+ type="button"
296
315
  class="saves-toolbar-button"
297
316
  onClick={handleImport}
298
317
  >
@@ -376,6 +395,7 @@ export function SaveManagerContent() {
376
395
  <div class="save-slot-actions">
377
396
  {mode === 'save' ? (
378
397
  <button
398
+ type="button"
379
399
  class="save-slot-action primary"
380
400
  onClick={() => handleOverwrite(save.meta.id)}
381
401
  >
@@ -383,6 +403,7 @@ export function SaveManagerContent() {
383
403
  </button>
384
404
  ) : (
385
405
  <button
406
+ type="button"
386
407
  class="save-slot-action primary"
387
408
  onClick={() => handleLoad(save)}
388
409
  >
@@ -390,18 +411,21 @@ export function SaveManagerContent() {
390
411
  </button>
391
412
  )}
392
413
  <button
414
+ type="button"
393
415
  class="save-slot-action"
394
416
  onClick={() => handleRenameStart(save)}
395
417
  >
396
418
  Rename
397
419
  </button>
398
420
  <button
421
+ type="button"
399
422
  class="save-slot-action"
400
423
  onClick={() => handleExport(save.meta.id)}
401
424
  >
402
425
  Export
403
426
  </button>
404
427
  <button
428
+ type="button"
405
429
  class="save-slot-action danger"
406
430
  onClick={() => handleDelete(save.meta.id)}
407
431
  >
@@ -413,6 +437,7 @@ export function SaveManagerContent() {
413
437
 
414
438
  {mode === 'save' && isCurrentPt && (
415
439
  <button
440
+ type="button"
416
441
  class="save-slot-new"
417
442
  onClick={handleNewSave}
418
443
  >
@@ -432,6 +457,7 @@ export function SaveManagerContent() {
432
457
  <div class="playthrough-group">
433
458
  <div class="playthrough-saves">
434
459
  <button
460
+ type="button"
435
461
  class="save-slot-new"
436
462
  onClick={handleNewSave}
437
463
  >
@@ -1,15 +1,54 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { DELAY_PARAMETER } from './macro-args';
3
3
 
4
+ /**
5
+ * The clip-path that shows the first `count` characters of `root`'s text in
6
+ * reading order: the boxes the browser laid out for them, each wrapped line
7
+ * as far as its last revealed character, as one path in the coordinates of
8
+ * `inner`'s box. Undefined where there is no layout to measure.
9
+ */
10
+ function revealedPath(
11
+ root: HTMLElement,
12
+ inner: HTMLElement,
13
+ count: number,
14
+ ): string | undefined {
15
+ const range = document.createRange();
16
+ if (!range.getClientRects) return undefined;
17
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
18
+ let left = count;
19
+ let end: { node: Node; offset: number } | undefined;
20
+ for (let n = walker.nextNode(); n && left > 0; n = walker.nextNode()) {
21
+ const length = n.nodeValue?.length ?? 0;
22
+ end = { node: n, offset: Math.min(left, length) };
23
+ left -= length;
24
+ }
25
+ if (!end) return 'inset(0 100% 0 0)';
26
+ range.setStart(root, 0);
27
+ range.setEnd(end.node, end.offset);
28
+ const rects = range.getClientRects();
29
+ if (rects.length === 0) return undefined;
30
+ const origin = inner.getBoundingClientRect();
31
+ let path = '';
32
+ for (const r of Array.from(rects)) {
33
+ if (r.width === 0 && r.height === 0) continue;
34
+ const x = (r.left - origin.left).toFixed(2);
35
+ const y = (r.top - origin.top).toFixed(2);
36
+ path += `M${x} ${y}h${r.width.toFixed(2)}v${r.height.toFixed(2)}h-${r.width.toFixed(2)}z`;
37
+ }
38
+ return path ? `path("${path}")` : undefined;
39
+ }
40
+
4
41
  defineMacro({
5
42
  name: 'type',
6
43
  block: true,
7
44
  interpolate: true,
8
45
  parameters: [DELAY_PARAMETER],
9
46
  render({ children = [] }, ctx) {
10
- const { useState, useEffect, useRef } = ctx.hooks;
47
+ const { useState, useEffect, useLayoutEffect, useRef } = ctx.hooks;
11
48
  const speed = ctx.args.delay ?? 0;
12
49
  const containerRef = useRef<HTMLSpanElement>(null);
50
+ const innerRef = useRef<HTMLSpanElement>(null);
51
+ const [clip, setClip] = useState<string | undefined>();
13
52
  const [totalChars, setTotalChars] = useState(0);
14
53
  const [visibleChars, setVisibleChars] = useState(0);
15
54
  const visibleCharsRef = useRef(0);
@@ -44,6 +83,21 @@ defineMacro({
44
83
 
45
84
  const done = visibleChars >= totalChars && totalChars > 0;
46
85
 
86
+ // Clip to the characters revealed so far, as laid out: remeasured when
87
+ // the count changes and when the width the text wraps in does.
88
+ useLayoutEffect(() => {
89
+ const root = containerRef.current;
90
+ const inner = innerRef.current;
91
+ if (!root || !inner || totalChars === 0 || done) {
92
+ setClip(undefined);
93
+ return;
94
+ }
95
+ const measure = () => setClip(revealedPath(root, inner, visibleChars));
96
+ measure();
97
+ window.addEventListener('resize', measure);
98
+ return () => window.removeEventListener('resize', measure);
99
+ }, [visibleChars, totalChars, done]);
100
+
47
101
  const cls = [
48
102
  'macro-type',
49
103
  done ? 'macro-type-done' : '',
@@ -57,19 +111,14 @@ defineMacro({
57
111
  id={ctx.id}
58
112
  class={cls}
59
113
  ref={containerRef}
60
- style={{
61
- clipPath: totalChars > 0 && !done ? undefined : undefined,
62
- }}
63
114
  >
64
115
  <span
116
+ ref={innerRef}
65
117
  class="macro-type-inner"
66
118
  style={{
67
119
  display: 'inline',
68
120
  visibility: totalChars === 0 ? 'hidden' : 'visible',
69
- clipPath:
70
- totalChars > 0 && !done
71
- ? `inset(0 ${((totalChars - visibleChars) / totalChars) * 100}% 0 0)`
72
- : undefined,
121
+ clipPath: clip,
73
122
  }}
74
123
  >
75
124
  {ctx.renderInlineNodes(children)}
@@ -15,13 +15,32 @@ interface WidgetInvocationProps {
15
15
  params: string[];
16
16
  rawArgs?: string;
17
17
  invocationChildren?: ASTNode[];
18
+ className?: string;
19
+ id?: string;
18
20
  }
19
21
 
20
- export function WidgetInvocation({
22
+ export function WidgetInvocation(props: WidgetInvocationProps) {
23
+ const { className, id } = props;
24
+ const content = <WidgetContent {...props} />;
25
+ // Invocation selectors (`{.badge#hero Stats}`) need an element to land on.
26
+ if (!className && !id) return content;
27
+ return (
28
+ <span
29
+ class={className}
30
+ id={id}
31
+ >
32
+ {content}
33
+ </span>
34
+ );
35
+ }
36
+
37
+ function WidgetContent({
21
38
  body,
22
39
  params,
23
40
  rawArgs,
24
41
  invocationChildren,
42
+ className,
43
+ id,
25
44
  }: WidgetInvocationProps) {
26
45
  const renderOptions = useRenderOptions();
27
46
  const parentValues = renderOptions.locals;
@@ -10,16 +10,20 @@ 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
14
  import { liveLocalsView } from '../../utils/live-locals';
14
15
  import { runWithCommittedMutations } from '../../execute-mutation';
15
16
  import { RepeatContext } from './Repeat';
16
17
  import { DialogCloseContext } from '../PassageDialog';
17
18
 
19
+ const frozenAlways = () => true;
20
+
18
21
  /**
19
22
  * Return a function that runs a macro body once, outside the passage tree:
20
23
  * it renders `children` into a detached node, so the body's macros ({set},
21
24
  * {if}, {goto}, ...) fire their side effects through the normal Preact
22
- * pipeline. Used by {button} and {link} on click. The body stays mounted
25
+ * pipeline. Used by {button} and {link} on click. The body's state is frozen
26
+ * once it has rendered, so it runs once per click. It stays mounted
23
27
  * until the owning macro unmounts (its passage changes, say), so work it
24
28
  * starts in an effect, such as the timer of a {timed} or {repeat}, can
25
29
  * finish; the passage leaving cancels it.
@@ -58,21 +62,23 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
58
62
  const locals = liveLocalsView(updater.getValues);
59
63
  const container = document.createElement('div');
60
64
  render(
61
- <LocalsUpdateContext.Provider value={updater}>
62
- <LocalsValuesContext.Provider value={locals}>
63
- <NobrContext.Provider value={nobr}>
64
- <InlineContext.Provider value={inline}>
65
- <WidgetChildrenContext.Provider value={widgetChildren}>
66
- <RepeatContext.Provider value={repeat}>
67
- <DialogCloseContext.Provider value={closeDialog}>
68
- {renderNodes(children, { nobr, inline, locals })}
69
- </DialogCloseContext.Provider>
70
- </RepeatContext.Provider>
71
- </WidgetChildrenContext.Provider>
72
- </InlineContext.Provider>
73
- </NobrContext.Provider>
74
- </LocalsValuesContext.Provider>
75
- </LocalsUpdateContext.Provider>,
65
+ <FrozenStateContext.Provider value={frozenAlways}>
66
+ <LocalsUpdateContext.Provider value={updater}>
67
+ <LocalsValuesContext.Provider value={locals}>
68
+ <NobrContext.Provider value={nobr}>
69
+ <InlineContext.Provider value={inline}>
70
+ <WidgetChildrenContext.Provider value={widgetChildren}>
71
+ <RepeatContext.Provider value={repeat}>
72
+ <DialogCloseContext.Provider value={closeDialog}>
73
+ {renderNodes(children, { nobr, inline, locals })}
74
+ </DialogCloseContext.Provider>
75
+ </RepeatContext.Provider>
76
+ </WidgetChildrenContext.Provider>
77
+ </InlineContext.Provider>
78
+ </NobrContext.Provider>
79
+ </LocalsValuesContext.Provider>
80
+ </LocalsUpdateContext.Provider>
81
+ </FrozenStateContext.Provider>,
76
82
  container,
77
83
  );
78
84
  mounted.current.push(container);
@@ -59,14 +59,29 @@ export function defineInputMacro(
59
59
  perform: (v) => ctx.setValue!(toValue(v)),
60
60
  });
61
61
 
62
+ // What the reader last typed. A number input reports an unfinished
63
+ // number ("-", "2e") as an empty value, so the text is kept for as
64
+ // long as it still converts to the variable's value, instead of being
65
+ // replaced by that value on every keystroke (#353).
66
+ const typed = ctx.hooks.useRef<string | null>(null);
67
+ const shown =
68
+ typed.current !== null &&
69
+ toValue(typed.current) === ctx.value &&
70
+ type === 'number'
71
+ ? typed.current
72
+ : display(ctx.value);
73
+
62
74
  return h(type ? 'input' : 'textarea', {
63
75
  type,
64
76
  id: ctx.id,
65
77
  class: ctx.cls,
66
- value: display(ctx.value),
78
+ value: shown,
67
79
  placeholder,
68
- onInput: (e: Event) =>
69
- ctx.setValue!(toValue((e.target as HTMLInputElement).value)),
80
+ onInput: (e: Event) => {
81
+ const text = (e.target as HTMLInputElement).value;
82
+ typed.current = text;
83
+ ctx.setValue!(toValue(text));
84
+ },
70
85
  });
71
86
  },
72
87
  });
@@ -12,14 +12,17 @@ export function useMergedLocals(): readonly [
12
12
  Record<string, unknown>,
13
13
  Record<string, unknown>,
14
14
  ] {
15
- const { variables, temporary, transient } = useStoryFields(
15
+ // renderCounts is read by rendered()/hasRendered() inside expressions, so
16
+ // it re-renders them (and renews the tuple) when an inclusion mounts.
17
+ const { variables, temporary, transient, renderCounts } = useStoryFields(
16
18
  'variables',
17
19
  'temporary',
18
20
  'transient',
21
+ 'renderCounts',
19
22
  );
20
23
  const localsValues = useContext(LocalsValuesContext);
21
24
 
22
25
  return useMemo(() => {
23
26
  return [variables, temporary, localsValues, transient] as const;
24
- }, [variables, temporary, localsValues, transient]);
27
+ }, [variables, temporary, localsValues, transient, renderCounts]);
25
28
  }