@mui/internal-docs-infra 0.11.1-canary.21 → 0.11.1-canary.22

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.
Files changed (34) hide show
  1. package/CodeHighlighter/CodeHighlighter.mjs +11 -2
  2. package/CodeHighlighter/CodeHighlighterClient.mjs +60 -51
  3. package/CodeHighlighter/createClientProps.mjs +14 -3
  4. package/CodeHighlighter/fallbackFormat.d.mts +38 -0
  5. package/CodeHighlighter/fallbackFormat.mjs +96 -3
  6. package/CodeHighlighter/prepareInitialSource.d.mts +11 -0
  7. package/CodeHighlighter/prepareInitialSource.mjs +67 -8
  8. package/CodeHighlighter/resolveFallbackCritical.d.mts +23 -0
  9. package/CodeHighlighter/resolveFallbackCritical.mjs +44 -0
  10. package/CodeHighlighter/types.d.mts +16 -0
  11. package/CoordinatedLazy/useChunk.mjs +10 -8
  12. package/CoordinatedLazy/useCoordinatedSwap.mjs +1 -0
  13. package/abstractCreateTypes/TypeCode.mjs +12 -11
  14. package/abstractCreateTypes/typesToJsx.mjs +13 -8
  15. package/package.json +2 -2
  16. package/pipeline/loadIsomorphicCodeVariant/loadIsomorphicCodeVariant.mjs +41 -1
  17. package/pipeline/parseSource/frameVisibility.d.mts +17 -1
  18. package/pipeline/parseSource/frameVisibility.mjs +53 -0
  19. package/useCode/EditableEngine.mjs +15 -5
  20. package/useCode/Pre.mjs +43 -48
  21. package/useCode/SourceEditingEngine.mjs +29 -8
  22. package/useCode/useCode.mjs +11 -3
  23. package/useCode/{liveEditingBugs.browser.mjs → useEditable.integration.browser.mjs} +114 -69
  24. package/useCode/useFileNavigation.mjs +20 -16
  25. package/useCode/useTransformManagement.mjs +13 -5
  26. package/useCode/useUIState.mjs +6 -6
  27. package/useCode/useVariantSelection.mjs +20 -6
  28. package/useCoordinated/coordinatePreference.mjs +4 -25
  29. package/useCoordinated/scheduleTasks.d.mts +23 -0
  30. package/useCoordinated/scheduleTasks.mjs +45 -0
  31. package/useCoordinated/useCoordinated.mjs +33 -6
  32. package/useStream/useStream.mjs +2 -4
  33. package/useStream/useStreamController.mjs +6 -1
  34. /package/useCode/{liveEditingBugs.browser.d.mts → useEditable.integration.browser.d.mts} +0 -0
@@ -42,19 +42,25 @@ export function useCode(contentProps, opts) {
42
42
  // configure the baseline (e.g., `@highlight` / `@focus` framing) while
43
43
  // individual `useCode` callers add demo-specific extras without losing the
44
44
  // shared defaults.
45
+ // Hoist the optional controller member into a stable local so the memo's
46
+ // inferred dependency matches the source dependency. `useControlledCode()`
47
+ // always returns a fresh object, so `controllerContext` is never null but
48
+ // changes identity every render; depending on the narrowed value keeps the
49
+ // memo correct (and lets the compiler preserve the manual memoization).
50
+ const controllerEnhancers = controllerContext?.sourceEnhancers;
45
51
  const mergedEnhancers = React.useMemo(() => {
46
52
  const enhancers = [];
47
53
  if (codeContext.sourceEnhancers) {
48
54
  enhancers.push(...codeContext.sourceEnhancers);
49
55
  }
50
- if (controllerContext?.sourceEnhancers) {
51
- enhancers.push(...controllerContext.sourceEnhancers);
56
+ if (controllerEnhancers) {
57
+ enhancers.push(...controllerEnhancers);
52
58
  }
53
59
  if (sourceEnhancers) {
54
60
  enhancers.push(...sourceEnhancers);
55
61
  }
56
62
  return enhancers.length > 0 ? enhancers : undefined;
57
- }, [codeContext.sourceEnhancers, controllerContext?.sourceEnhancers, sourceEnhancers]);
63
+ }, [codeContext.sourceEnhancers, controllerEnhancers, sourceEnhancers]);
58
64
 
59
65
  // Get the effective code - context overrides contentProps if available
60
66
  const effectiveCode = React.useMemo(() => {
@@ -299,8 +305,10 @@ export function useCode(contentProps, opts) {
299
305
  }, []);
300
306
  React.useEffect(() => {
301
307
  if (pendingExpand && !swapInFlight) {
308
+ /* eslint-disable react-hooks/set-state-in-effect -- intentional queue drain: commit deferred expand only after in-flight swaps settle (swapInFlight transitions false on a later render); see comment above re: flicker-avoidance and same-batch synchronous-expand semantics */
302
309
  setPendingExpand(false);
303
310
  setExpanded(true);
311
+ /* eslint-enable react-hooks/set-state-in-effect */
304
312
  }
305
313
  }, [pendingExpand, swapInFlight, setExpanded]);
306
314
 
@@ -1,19 +1,17 @@
1
1
  var _style;
2
2
  /**
3
- * Reproduction harness + tests for four live-editing bugs reported against the
4
- * docs live code editor:
3
+ * Browser integration tests for `useEditable`: the full type flush → re-highlight →
4
+ * restore cycle the docs live code editor runs on every keystroke. They cover the
5
+ * behaviors that only emerge when the highlighted DOM is replaced underneath the caret —
6
+ * caret stability while typing and deleting indents, scroll-anchor `onBoundary` firing at
7
+ * the visible top/bottom, focus retention on the first keystroke, and a selection's
8
+ * direction surviving a re-render.
5
9
  *
6
- * 1. Erasing the last indent (tab) unit on a line makes the view jump.
7
- * 2. Pressing ArrowUp at the visible top doesn't trigger the scroll anchor.
8
- * 3. Typing `x`, then `=`, then Backspace sends the caret to column 0.
9
- * 4. The first keystroke in the editable loses focus.
10
- *
11
- * Unlike `useEditable.browser.ts` (which drives a static highlighted DOM with a
12
- * `vi.fn()` onChange and only asserts the *text* handed to onChange), these
13
- * tests wire `useEditable` to a real React component whose `onChange` updates
14
- * source state, re-highlights it into the production `.line`/`pl-*` span
15
- * structure, and lets the engine's `observeAndRestore` restore the caret — i.e.
16
- * the full type → flush → re-highlight → restore cycle that the bugs live in.
10
+ * Unlike `useEditable.browser.ts` (which drives a static highlighted DOM with a `vi.fn()`
11
+ * onChange and only asserts the *text* handed to onChange), these tests wire `useEditable`
12
+ * to a real React component whose `onChange` updates source state, re-highlights it into
13
+ * the production `.line`/`pl-*` span structure, and lets the engine's `observeAndRestore`
14
+ * restore the caret.
17
15
  */
18
16
  import * as React from 'react';
19
17
  import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
@@ -270,11 +268,11 @@ function deferredPreParse() {
270
268
  }
271
269
  };
272
270
  }
273
- describe('live-editing bug repros', () => {
271
+ describe('useEditable caret & selection across re-highlights', () => {
274
272
  // -------------------------------------------------------------------------
275
- // Bug 3: type x, =, Backspace -> caret jumps to column 0
273
+ // Caret stability when typing x, =, Backspace (must not jump to column 0)
276
274
  // -------------------------------------------------------------------------
277
- it('Bug 3a: fresh line x, =, Backspace keeps caret after x', async () => {
275
+ it('keeps the caret after the typed char when typing x, =, Backspace on a fresh line', async () => {
278
276
  const {
279
277
  handle,
280
278
  element
@@ -295,7 +293,7 @@ describe('live-editing bug repros', () => {
295
293
  column: 1
296
294
  });
297
295
  });
298
- it('Bug 3b: x, =, Backspace with async preParse keeps caret after x', async () => {
296
+ it('keeps the caret after the typed char through an async re-highlight (preParse)', async () => {
299
297
  const {
300
298
  handle,
301
299
  element
@@ -320,7 +318,7 @@ describe('live-editing bug repros', () => {
320
318
  column: 1
321
319
  });
322
320
  });
323
- it('Bug 3c: typing =, Backspace at end of an existing indented JSX-ish line', async () => {
321
+ it('keeps the caret when typing = then Backspace at the end of an indented line', async () => {
324
322
  // ` <p style=` then backspace the `=`. This mirrors typing an attribute.
325
323
  const initial = 'function App() {\n return <p style\n}\n';
326
324
  const {
@@ -344,7 +342,7 @@ describe('live-editing bug repros', () => {
344
342
  column: ' return <p style'.length
345
343
  });
346
344
  });
347
- it('Bug 3d: collapsed (minColumn) x, =, Backspace at end of an indented line keeps caret after x', async () => {
345
+ it('keeps the caret at the end of an indented line in a collapsed (minColumn) gutter', async () => {
348
346
  // The collapsed editor (clipped indent gutter) is where the real bug shows.
349
347
  const initial = 'function foo() {\n doStuff()\n}\n';
350
348
  const {
@@ -372,7 +370,7 @@ describe('live-editing bug repros', () => {
372
370
  column: ' doStuff()x'.length
373
371
  });
374
372
  });
375
- it('Bug 3e: collapsed + async preParse x, =, Backspace keeps caret after x', async () => {
373
+ it('keeps the caret in a collapsed gutter through an async re-highlight', async () => {
376
374
  const initial = 'function foo() {\n doStuff()\n}\n';
377
375
  const {
378
376
  handle,
@@ -403,7 +401,7 @@ describe('live-editing bug repros', () => {
403
401
  column: ' doStuff()x'.length
404
402
  });
405
403
  });
406
- it('Bug 3f: End then x, =, Backspace at a line end (caret lands at the line/gap boundary)', async () => {
404
+ it('lands the caret at the line/gap boundary when editing at the end of a line', async () => {
407
405
  // The real bug uses the `End` key to land the caret at the line end — which
408
406
  // in the framed `.line` structure is the boundary with the inter-line gap
409
407
  // node. Native typing there can flatten the spans / split across lines.
@@ -433,14 +431,13 @@ describe('live-editing bug repros', () => {
433
431
  });
434
432
 
435
433
  // -------------------------------------------------------------------------
436
- // Bug 1: erasing the last indent on a clipped (collapsed-window) line jumps
434
+ // Caret restoration when erasing the last indent on a clipped (collapsed-window) line
437
435
  // -------------------------------------------------------------------------
438
- it('Bug 1a: Backspace of last indent on a blank clipped line (minColumn) — where does caret land?', async () => {
436
+ it('restores the caret when backspacing the last indent of a blank clipped line (minColumn)', async () => {
439
437
  // Simulate a collapsed window: indentation clipped to minColumn=2, the
440
438
  // visible region is rows 1..3. Line 2 is a blank line with exactly 2 spaces.
441
439
  const initial = 'function foo() {\n const a = 1;\n \n return a;\n}\n';
442
440
  const {
443
- handle,
444
441
  element
445
442
  } = await setupEditor(initial, {
446
443
  indentation: 2,
@@ -459,17 +456,12 @@ describe('live-editing bug repros', () => {
459
456
  await userEvent.keyboard('{Backspace}');
460
457
  await settle();
461
458
  const after = caretLineColumn(element);
462
- // The reported source + caret movement reveal whether the whole line
463
- // collapsed (caret jumps to the previous line) or just one indent went away.
464
- // eslint-disable-next-line no-console
465
- console.log('Bug1a before', before, 'after', after, 'source', JSON.stringify(handle.getSource()));
466
459
  // Assert the (arguably correct) behavior: stay on the same line, now empty.
467
460
  expect(after.line).toBe(before.line);
468
461
  });
469
- it('Bug 1b: Backspace of indent on a content line in a clipped gutter (minColumn)', async () => {
462
+ it('restores the caret when backspacing an indent on a content line in a clipped gutter (minColumn)', async () => {
470
463
  const initial = 'function foo() {\n const a = 1;\n}\n';
471
464
  const {
472
- handle,
473
465
  element
474
466
  } = await setupEditor(initial, {
475
467
  indentation: 2,
@@ -487,15 +479,13 @@ describe('live-editing bug repros', () => {
487
479
  await userEvent.keyboard('{Backspace}');
488
480
  await settle();
489
481
  const after = caretLineColumn(element);
490
- // eslint-disable-next-line no-console
491
- console.log('Bug1b after', after, 'source', JSON.stringify(handle.getSource()));
492
482
  expect(after).toBeTruthy();
493
483
  });
494
484
 
495
485
  // -------------------------------------------------------------------------
496
- // Bug 2: ArrowUp at the visible top should fire onBoundary (scroll anchor)
486
+ // ArrowUp at the visible top fires onBoundary (scroll anchor)
497
487
  // -------------------------------------------------------------------------
498
- it('Bug 2: ArrowUp at minRow fires onBoundary; ArrowDown at maxRow fires onBoundary', async () => {
488
+ it('fires onBoundary on ArrowUp at the first row and ArrowDown at the last row', async () => {
499
489
  const onBoundary = vi.fn();
500
490
  const initial = 'line0\nline1\nline2\nline3\nline4\n';
501
491
  const {
@@ -522,26 +512,22 @@ describe('live-editing bug repros', () => {
522
512
  await userEvent.keyboard('{ArrowDown}');
523
513
  await settle();
524
514
  const downCalls = onBoundary.mock.calls.length - upCalls;
525
-
526
- // eslint-disable-next-line no-console
527
- console.log('Bug2 onBoundary up-calls', upCalls, 'down-calls', downCalls);
528
515
  expect(upCalls).toBeGreaterThan(0); // ArrowUp must fire the boundary
529
516
  expect(downCalls).toBeGreaterThan(0); // ArrowDown must fire the boundary
530
517
  });
531
518
 
532
519
  // -------------------------------------------------------------------------
533
- // Clue A: backspace last indent + async re-highlight shows the wrong thing
534
- // transiently (during the worker round-trip the line is collapsed but the
535
- // committed source hasn't caught up yet).
520
+ // Transient DOM vs committed source during an async re-highlight: backspacing the
521
+ // last indent collapses the line during the worker round-trip, before the committed
522
+ // source catches up.
536
523
  // -------------------------------------------------------------------------
537
- it('Clue A: async backspace of last indent transient DOM vs committed source', async () => {
524
+ it('keeps the transient DOM consistent with the committed source when async-backspacing the last indent', async () => {
538
525
  const {
539
526
  preParse,
540
527
  resolvePending
541
528
  } = deferredPreParse();
542
529
  const initial = 'function foo() {\n const a = 1;\n \n return a;\n}\n';
543
530
  const {
544
- handle,
545
531
  element
546
532
  } = await setupEditor(initial, {
547
533
  indentation: 2,
@@ -577,37 +563,20 @@ describe('live-editing bug repros', () => {
577
563
  await settle();
578
564
 
579
565
  // DURING the async gap (preParse not yet resolved): what does the DOM show?
580
- const transientText = element.textContent ?? '';
581
- const transientLineCount = element.querySelectorAll('.line').length;
582
566
  const transientSig = signature();
583
- const committedDuringGap = handle.onChange.mock.calls.length;
584
567
  await resolvePending();
585
568
  await settle();
586
- const finalText = element.textContent ?? '';
587
569
  const finalSig = signature();
588
- const finalSource = handle.getSource();
589
- // eslint-disable-next-line no-console
590
- console.log('ClueA transient', JSON.stringify(transientText), 'lines', transientLineCount, 'committedDuringGap', committedDuringGap, '| final', JSON.stringify(finalText), 'source', JSON.stringify(finalSource));
591
- // The transient DOM should already match the final committed text — i.e.
592
- // the user must NOT see a wrong intermediate state. If they differ, the
593
- // async path is showing the wrong thing for a moment.
594
- void transientText;
595
- void transientLineCount;
596
- void committedDuringGap;
597
- void finalText;
598
- void finalSource;
599
- // DESIRED: the DOM the user sees during the async worker round-trip should
600
- // already be structurally consistent with the committed result. Currently
601
- // it is NOT — `edit.insert` leaves a dangling empty `.line` span and drops
602
- // the gap newline, so this fails (reproducing "shows the wrong thing for a
603
- // second") until the async commit cleans it up.
570
+ // The transient DOM the user sees during the async worker round-trip must already
571
+ // be structurally consistent with the final committed result — no wrong intermediate
572
+ // state (no dangling empty `.line` span, no dropped gap newline), so the signatures match.
604
573
  expect(transientSig).toEqual(finalSig);
605
574
  });
606
575
 
607
576
  // -------------------------------------------------------------------------
608
- // Clue B: ArrowUp navigation across empty lines is "weird".
577
+ // ArrowUp navigation across consecutive empty lines
609
578
  // -------------------------------------------------------------------------
610
- it('Clue B: ArrowUp across an empty line lands on the empty line, then the line above', async () => {
579
+ it('moves ArrowUp onto an empty line first, then the line above', async () => {
611
580
  const initial = 'aaa\n\nbbb\nccc\n';
612
581
  const {
613
582
  element
@@ -643,7 +612,7 @@ describe('live-editing bug repros', () => {
643
612
  // -------------------------------------------------------------------------
644
613
  // New bug: TWO consecutive empty lines — ArrowUp skips both at once.
645
614
  // -------------------------------------------------------------------------
646
- it('Clue B2: ArrowUp stops on each of two consecutive empty lines (does not skip both)', async () => {
615
+ it('stops ArrowUp on each of two consecutive empty lines (does not skip both)', async () => {
647
616
  // Lines: 0 "aaa", 1 "" , 2 "", 3 "bbb", 4 "ccc".
648
617
  const initial = 'aaa\n\n\nbbb\nccc\n';
649
618
  const {
@@ -682,10 +651,10 @@ describe('live-editing bug repros', () => {
682
651
  });
683
652
 
684
653
  // -------------------------------------------------------------------------
685
- // Bug 5: a BACKWARD Shift+Arrow selection keeps its focus at the top across a
654
+ // A BACKWARD Shift+Arrow selection keeps its focus at the top across a
686
655
  // host re-render (the restore must not flip the focus to the bottom end).
687
656
  // -------------------------------------------------------------------------
688
- it('Bug 5: backward Shift+ArrowUp selection survives a re-render with the focus still at the top', async () => {
657
+ it('preserves a backward Shift+ArrowUp selection across a re-render, focus still at the top', async () => {
689
658
  const initial = 'aaa\nbbb\nccc\nddd\n';
690
659
  const {
691
660
  handle,
@@ -747,9 +716,9 @@ describe('live-editing bug repros', () => {
747
716
  });
748
717
 
749
718
  // -------------------------------------------------------------------------
750
- // Bug 4: first keystroke loses focus (async preParse path)
719
+ // Focus retention on the first keystroke (async preParse path)
751
720
  // -------------------------------------------------------------------------
752
- it('Bug 4: editable keeps focus after the first keystroke (async preParse)', async () => {
721
+ it('keeps focus after the first keystroke through an async re-highlight', async () => {
753
722
  const {
754
723
  element
755
724
  } = await setupEditor('hello\n', {
@@ -822,4 +791,80 @@ describe('live-editing bug repros', () => {
822
791
  expect(caretLineColumn(element).line, 'caret jumped to the top edge').toBe(2);
823
792
  }
824
793
  });
794
+ });
795
+
796
+ // ---------------------------------------------------------------------------
797
+ // deletedFromLineStart: whole-line removals only, not in-place collapses
798
+ // ---------------------------------------------------------------------------
799
+ // A selection delete reports `deletedFromLineStart` so the controlled
800
+ // comment/highlight map drops its anchor one line — the post-delete caret sits on
801
+ // a line that shifted up from below the deletion. That must be limited to
802
+ // deletions that removed WHOLE lines. A selection that ends mid-line collapses the
803
+ // spanned lines INTO the first line, which survives (emptied) under the caret;
804
+ // reporting the flag there drags a marker on that surviving line one line too high
805
+ // (the live-editor "the highlight shifts up instead of being deleted" bug). The
806
+ // source stands in for an @highlight block on lines 4-6 with blank padding (3, 7).
807
+ describe('useEditable — selection delete reports deletedFromLineStart only for whole-line removals', () => {
808
+ const SRC = 'const a = 1;\nconst b = 2;\n\ndoThing();\ndoOther();\ndoLast();\n\nconst c = 3;\n';
809
+ const lastPosition = handle => handle.onChange.mock.calls.at(-1)?.[1];
810
+ it('reports the flag when a column-0 selection removes whole lines', async () => {
811
+ const {
812
+ handle,
813
+ element
814
+ } = await setupEditor(SRC, {
815
+ indentation: 2,
816
+ caretSelector: '.line'
817
+ });
818
+ // Column 0 of the blank line 3 → column 0 of the blank line 7 (a line boundary):
819
+ // whole lines 3-6 are removed and line 7 shifts up under the caret.
820
+ await placeCaret(element, 26);
821
+ await userEvent.keyboard('{Shift>}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{/Shift}');
822
+ await settle();
823
+ await userEvent.keyboard('{Backspace}');
824
+ await settle();
825
+ expect(handle.getSource()).toBe('const a = 1;\nconst b = 2;\n\nconst c = 3;\n');
826
+ expect(lastPosition(handle)?.deletedFromLineStart).toBe(true);
827
+ });
828
+ it('reports the flag when a column-0 selection stops on the region’s exclusive -end line', async () => {
829
+ const {
830
+ handle,
831
+ element
832
+ } = await setupEditor(SRC, {
833
+ indentation: 2,
834
+ caretSelector: '.line'
835
+ });
836
+ // Column 0 of the blank line 3 → column 0 of line 6 (still a line boundary), one
837
+ // ArrowDown short of scenario A. A range's @highlight-end is EXCLUSIVE — it sits
838
+ // on the line just below the last highlighted line — so selecting "from the line
839
+ // above through the last newline of the region" lands here, deleting the whole
840
+ // highlighted body (lines 4-5) while doLast() (the -end line) survives. This is
841
+ // still a whole-line removal, so the flag holds; the matching comment-map case is
842
+ // `useSourceEditing`'s "removes the highlight when the selection stops on the
843
+ // region’s exclusive -end line".
844
+ await placeCaret(element, 26);
845
+ await userEvent.keyboard('{Shift>}{ArrowDown}{ArrowDown}{ArrowDown}{/Shift}');
846
+ await settle();
847
+ await userEvent.keyboard('{Backspace}');
848
+ await settle();
849
+ expect(handle.getSource()).toBe('const a = 1;\nconst b = 2;\ndoLast();\n\nconst c = 3;\n');
850
+ expect(lastPosition(handle)?.deletedFromLineStart).toBe(true);
851
+ });
852
+ it('does NOT report the flag when the selection ends mid-line and collapses in place', async () => {
853
+ const {
854
+ handle,
855
+ element
856
+ } = await setupEditor(SRC, {
857
+ indentation: 2,
858
+ caretSelector: '.line'
859
+ });
860
+ // Column 0 of line 4 → the END of line 6 (mid-line, NOT a line boundary): the three
861
+ // region lines collapse into one empty line that survives under the caret.
862
+ await placeCaret(element, 27);
863
+ await userEvent.keyboard('{Shift>}{ArrowDown}{ArrowDown}{End}{/Shift}');
864
+ await settle();
865
+ await userEvent.keyboard('{Backspace}');
866
+ await settle();
867
+ expect(handle.getSource()).toBe('const a = 1;\nconst b = 2;\n\n\n\nconst c = 3;\n');
868
+ expect(lastPosition(handle)?.deletedFromLineStart).not.toBe(true);
869
+ });
825
870
  });
@@ -103,19 +103,12 @@ export function useFileNavigation({
103
103
  // Detect if the current variant change was driven by a hash change
104
104
  // A variant change is hash-driven if the hash has a variant that matches where we're going
105
105
  // AND we weren't already on that variant (i.e., the hash is what triggered the change)
106
- const [prevHashVariant, setPrevHashVariant] = React.useState(hashVariant || null);
107
106
  const isHashDrivenVariantChange = hashVariant === selectedVariantKey && prevVariantKeyState !== selectedVariantKey;
108
107
 
109
- // Update prevHashVariant when hashVariant changes
110
- React.useEffect(() => {
111
- if (hashVariant !== prevHashVariant) {
112
- setPrevHashVariant(hashVariant || null);
113
- }
114
- }, [hashVariant, prevHashVariant]);
115
-
116
108
  // Update prevVariantKeyState when variant changes
117
109
  React.useEffect(() => {
118
110
  if (selectedVariantKey !== prevVariantKeyState) {
111
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- previous-value tracker; render-time rewrite changes effect ordering for the hash-update effect that reads isHashDrivenVariantChange as a dep
119
112
  setPrevVariantKeyState(selectedVariantKey);
120
113
  }
121
114
  }, [selectedVariantKey, prevVariantKeyState]);
@@ -567,18 +560,29 @@ export function useFileNavigation({
567
560
  if (selectedFile == null) {
568
561
  return 0;
569
562
  }
570
- if (!transformedFiles && selectedFileLineCounts && selectedFileLineCounts.totalLines > 0) {
571
- return selectedFileLineCounts.totalLines;
572
- }
573
563
 
574
- // If it's a string, split by newlines and count
564
+ // If it's a string, split by newlines and count.
575
565
  if (typeof selectedFile === 'string') {
576
566
  return selectedFile.split('\n').length;
577
567
  }
578
568
 
579
- // If it's a hast object, count the children length. The selected file's
580
- // `fallback` is forwarded to `decodeHastSource` so the `hastCompressed`
581
- // payload is decompressed with the matching DEFLATE dictionary.
569
+ // A compressed payload (`hastCompressed` / `hastJson`) is never a transformed
570
+ // tree transforms produce live HAST — so the stored variant count is
571
+ // authoritative for it. Use it instead of decompressing the source just to read
572
+ // the count, even when OTHER files in the variant are transformed. Only a live
573
+ // (transformed or in-hand) tree, or a legacy payload with no stored count, falls
574
+ // through to read the hast below.
575
+ const isCompressedSource = 'hastJson' in selectedFile || 'hastCompressed' in selectedFile;
576
+ if (isCompressedSource && selectedFileLineCounts && selectedFileLineCounts.totalLines > 0) {
577
+ return selectedFileLineCounts.totalLines;
578
+ }
579
+
580
+ // A live HAST tree (e.g. a transformed source) carries its own up-to-date counts
581
+ // in `root.data`; `decodeHastSource` returns such a tree unchanged (no
582
+ // decompression). A compressed source with no stored count (a legacy precompute)
583
+ // is the only case that actually decompresses here — and it's the same tree
584
+ // `<Pre>` decodes to render, so the shared decode cache amortizes it. The
585
+ // selected file's `fallback` is forwarded as the matching DEFLATE dictionary.
582
586
  const hastSelectedFile = decodeHastSource(selectedFile, selectedFileFallback);
583
587
  if (hastSelectedFile) {
584
588
  if (hastSelectedFile.data && 'totalLines' in hastSelectedFile.data) {
@@ -598,7 +602,7 @@ export function useFileNavigation({
598
602
  }
599
603
  }
600
604
  return 0;
601
- }, [selectedFile, selectedFileFallback, selectedFileLineCounts, transformedFiles]);
605
+ }, [selectedFile, selectedFileFallback, selectedFileLineCounts]);
602
606
 
603
607
  // Convert files for the return interface
604
608
  const files = React.useMemo(() => {
@@ -89,6 +89,10 @@ export function useTransformManagement({
89
89
  }
90
90
  const warm = peekTransformEngine();
91
91
  if (warm) {
92
+ // Adopt a sibling-warmed engine synchronously; the surrounding effect is a
93
+ // real async load. `peekTransformEngine()` is an impure read of a
94
+ // module-mutable cache, so this cannot be derived during render.
95
+ // eslint-disable-next-line react-hooks/set-state-in-effect
92
96
  setTransformEngine(() => warm);
93
97
  return undefined;
94
98
  }
@@ -440,21 +444,25 @@ export function useTransformManagement({
440
444
  setPostSwapWindowActive(true);
441
445
  }
442
446
  }
447
+ // The window only ever opens under `hasDelay` (line above), so an open window
448
+ // when `!hasDelay` means `hasDelay` flipped true→false — a derivable invariant,
449
+ // not a side-effect. Clear it during render so it lands on the same commit.
450
+ if (postSwapWindowActive && !hasDelay) {
451
+ setPostSwapWindowActive(false);
452
+ }
443
453
  React.useEffect(() => {
444
454
  if (!postSwapWindowActive) {
445
455
  return undefined;
446
456
  }
447
- if (!hasDelay) {
448
- setPostSwapWindowActive(false);
449
- return undefined;
450
- }
451
457
  // `delayedAppliedTransform` is in the dep array so a fresh swap
452
458
  // during an already-open window (A → B → C in rapid succession)
453
459
  // re-arms the timer for the full `transformDelay` instead of
454
460
  // inheriting whatever was left over from B's window.
455
461
  const timerId = setTimeout(() => setPostSwapWindowActive(false), transformDelay);
456
462
  return () => clearTimeout(timerId);
457
- }, [postSwapWindowActive, hasDelay, transformDelay, delayedAppliedTransform]);
463
+ // `hasDelay` is intentionally not a dependency: the body never reads it (the
464
+ // `!hasDelay` window teardown is the render-time clear above).
465
+ }, [postSwapWindowActive, transformDelay, delayedAppliedTransform]);
458
466
 
459
467
  // If both phases are technically eligible (e.g. user clicked a third
460
468
  // target during a post-swap window), the pending pre-swap takes
@@ -14,12 +14,12 @@ export function useUIState({
14
14
  const [expanded, setExpanded] = React.useState(initialExpanded || hasRelevantHash);
15
15
  const expand = React.useCallback(() => setExpanded(true), []);
16
16
 
17
- // Auto-expand if hash becomes relevant
18
- React.useEffect(() => {
19
- if (hasRelevantHash && !expanded) {
20
- setExpanded(true);
21
- }
22
- }, [hasRelevantHash, expanded]);
17
+ // Auto-expand if hash becomes relevant. This is a one-way OR-latch: it ratchets
18
+ // `expanded` to true but never collapses, so adjusting state during render is safe
19
+ // (the branch is skipped once `expanded` is true, avoiding an extra render).
20
+ if (hasRelevantHash && !expanded) {
21
+ setExpanded(true);
22
+ }
23
23
  return {
24
24
  expanded,
25
25
  expand,
@@ -198,6 +198,10 @@ export function useVariantSelection({
198
198
  if (deferHighlight) {
199
199
  return;
200
200
  }
201
+ // Intentional later-tick latch: see the bootstrap-gate comment above.
202
+ // Flipping this during render skips the receiver-flow swap animation and
203
+ // prevents `pendingBootstrap` from ever latching.
204
+ // eslint-disable-next-line react-hooks/set-state-in-effect
201
205
  setAllowStoredBootstrap(true);
202
206
  }, [storedVariantSourceLoaded, deferHighlight]);
203
207
 
@@ -389,11 +393,18 @@ export function useVariantSelection({
389
393
  if (hasCommittedPastInitial || !committedVariantKey) {
390
394
  return;
391
395
  }
396
+ // Intentional later-tick latch: see the bootstrap-gate comment above.
397
+ // This freezes the first non-empty committed value and only later detects
398
+ // moving past it; moving the detection into render shifts exactly when
399
+ // `pendingBootstrap` releases relative to paint, which the
400
+ // highlight-suppression sequence was tuned around.
401
+ /* eslint-disable react-hooks/set-state-in-effect */
392
402
  if (initialCommittedVariantKey === null) {
393
403
  setInitialCommittedVariantKey(committedVariantKey);
394
404
  } else if (committedVariantKey !== initialCommittedVariantKey) {
395
405
  setHasCommittedPastInitial(true);
396
406
  }
407
+ /* eslint-enable react-hooks/set-state-in-effect */
397
408
  }, [committedVariantKey, hasCommittedPastInitial, initialCommittedVariantKey]);
398
409
 
399
410
  // Reset both bootstrap latches whenever the storage bucket
@@ -526,21 +537,24 @@ export function useVariantSelection({
526
537
  }
527
538
  setPrevAppliedVariant(committedVariantKey);
528
539
  }
540
+ // Tear down a stale window synchronously at render time: no animation
541
+ // window should exist without a delay, so a window left open after
542
+ // `hasDelay` flips to false is cleared a tick earlier than an effect would,
543
+ // with no animation to disrupt (there is no delay).
544
+ if (postSwapWindowActive && !hasDelay) {
545
+ setPostSwapWindowActive(false);
546
+ setCollapseSourceVariantKey(null);
547
+ }
529
548
  React.useEffect(() => {
530
549
  if (!postSwapWindowActive) {
531
550
  return undefined;
532
551
  }
533
- if (!hasDelay) {
534
- setPostSwapWindowActive(false);
535
- setCollapseSourceVariantKey(null);
536
- return undefined;
537
- }
538
552
  const timerId = setTimeout(() => {
539
553
  setPostSwapWindowActive(false);
540
554
  setCollapseSourceVariantKey(null);
541
555
  }, effectiveSwapWindowMs);
542
556
  return () => clearTimeout(timerId);
543
- }, [postSwapWindowActive, hasDelay, effectiveSwapWindowMs, committedVariantKey]);
557
+ }, [postSwapWindowActive, effectiveSwapWindowMs, committedVariantKey]);
544
558
 
545
559
  // If both phases are technically eligible, the pending pre-swap
546
560
  // takes priority — the visible tree IS the just-applied one and it
@@ -1,29 +1,8 @@
1
1
  import { performanceMeasure } from "../pipeline/loadPrecomputedCodeHighlighter/performanceLogger.mjs";
2
-
3
- /**
4
- * Yield to the browser before invoking a user-supplied `preload`.
5
- *
6
- * Coordinator entries that announce a target have already triggered
7
- * a render (loading indicator, coordinating state, etc.) on the
8
- * preceding event-loop turn. Yielding here pushes the (potentially
9
- * CPU-bound) preload work into a fresh macrotask so the browser can
10
- * paint that intermediate state before the preload monopolizes the
11
- * main thread. Without this, preload runs inline on the same task
12
- * as the originating event and starves paint until it completes.
13
- *
14
- * Uses `scheduler.yield()` when available (modern Chromium) for
15
- * better priority handling; falls back to `setTimeout(_, 0)` which
16
- * macrotask-defers in every browser and in fake-timer environments.
17
- */
18
- function yieldToMain() {
19
- const sch = globalThis.scheduler;
20
- if (typeof sch?.yield === 'function') {
21
- return sch.yield();
22
- }
23
- return new Promise(resolve => {
24
- setTimeout(resolve, 0);
25
- });
26
- }
2
+ // `yieldToMain` is used here to push a user-supplied `preload` into a fresh
3
+ // macrotask so the browser can paint the just-announced loading state before the
4
+ // (potentially CPU-bound) preload monopolizes the main thread.
5
+ import { yieldToMain } from "./scheduleTasks.mjs";
27
6
 
28
7
  /**
29
8
  * Generic same-tab preference coordinator. Its primary purpose is
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Yield the current task back to the browser so it can paint and process input
3
+ * before the awaited continuation runs.
4
+ *
5
+ * Uses `scheduler.yield()` when available (modern Chromium) for better priority
6
+ * handling; falls back to `setTimeout(_, 0)`, which macrotask-defers in every
7
+ * browser, in Node/SSR, and in fake-timer test environments.
8
+ */
9
+ export declare function yieldToMain(): Promise<void>;
10
+ /**
11
+ * Run `task` during the browser's first idle period, falling back to
12
+ * `setTimeout(task, 0)` where `requestIdleCallback` is unavailable (Safari,
13
+ * Node/SSR, fake timers). Use this for genuinely deferrable background work
14
+ * (e.g. stale-while-revalidate refreshes, the `idle` highlight/enhance swap)
15
+ * that should wait for the main thread to be free.
16
+ *
17
+ * @param options.timeout forwarded to `requestIdleCallback` so the task still
18
+ * runs even if the browser never goes idle.
19
+ * @returns a function that cancels the task if it has not run yet.
20
+ */
21
+ export declare function requestIdle(task: () => void, options?: {
22
+ timeout?: number;
23
+ }): () => void;