@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.
- package/CodeHighlighter/CodeHighlighter.mjs +11 -2
- package/CodeHighlighter/CodeHighlighterClient.mjs +60 -51
- package/CodeHighlighter/createClientProps.mjs +14 -3
- package/CodeHighlighter/fallbackFormat.d.mts +38 -0
- package/CodeHighlighter/fallbackFormat.mjs +96 -3
- package/CodeHighlighter/prepareInitialSource.d.mts +11 -0
- package/CodeHighlighter/prepareInitialSource.mjs +67 -8
- package/CodeHighlighter/resolveFallbackCritical.d.mts +23 -0
- package/CodeHighlighter/resolveFallbackCritical.mjs +44 -0
- package/CodeHighlighter/types.d.mts +16 -0
- package/CoordinatedLazy/useChunk.mjs +10 -8
- package/CoordinatedLazy/useCoordinatedSwap.mjs +1 -0
- package/abstractCreateTypes/TypeCode.mjs +12 -11
- package/abstractCreateTypes/typesToJsx.mjs +13 -8
- package/package.json +2 -2
- package/pipeline/loadIsomorphicCodeVariant/loadIsomorphicCodeVariant.mjs +41 -1
- package/pipeline/parseSource/frameVisibility.d.mts +17 -1
- package/pipeline/parseSource/frameVisibility.mjs +53 -0
- package/useCode/EditableEngine.mjs +15 -5
- package/useCode/Pre.mjs +43 -48
- package/useCode/SourceEditingEngine.mjs +29 -8
- package/useCode/useCode.mjs +11 -3
- package/useCode/{liveEditingBugs.browser.mjs → useEditable.integration.browser.mjs} +114 -69
- package/useCode/useFileNavigation.mjs +20 -16
- package/useCode/useTransformManagement.mjs +13 -5
- package/useCode/useUIState.mjs +6 -6
- package/useCode/useVariantSelection.mjs +20 -6
- package/useCoordinated/coordinatePreference.mjs +4 -25
- package/useCoordinated/scheduleTasks.d.mts +23 -0
- package/useCoordinated/scheduleTasks.mjs +45 -0
- package/useCoordinated/useCoordinated.mjs +33 -6
- package/useStream/useStream.mjs +2 -4
- package/useStream/useStreamController.mjs +6 -1
- /package/useCode/{liveEditingBugs.browser.d.mts → useEditable.integration.browser.d.mts} +0 -0
package/useCode/useCode.mjs
CHANGED
|
@@ -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 (
|
|
51
|
-
enhancers.push(...
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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('
|
|
271
|
+
describe('useEditable — caret & selection across re-highlights', () => {
|
|
274
272
|
// -------------------------------------------------------------------------
|
|
275
|
-
//
|
|
273
|
+
// Caret stability when typing x, =, Backspace (must not jump to column 0)
|
|
276
274
|
// -------------------------------------------------------------------------
|
|
277
|
-
it('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
//
|
|
434
|
+
// Caret restoration when erasing the last indent on a clipped (collapsed-window) line
|
|
437
435
|
// -------------------------------------------------------------------------
|
|
438
|
-
it('
|
|
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('
|
|
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
|
-
//
|
|
486
|
+
// ArrowUp at the visible top fires onBoundary (scroll anchor)
|
|
497
487
|
// -------------------------------------------------------------------------
|
|
498
|
-
it('
|
|
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
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
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('
|
|
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
|
-
|
|
589
|
-
//
|
|
590
|
-
|
|
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
|
-
//
|
|
577
|
+
// ArrowUp navigation across consecutive empty lines
|
|
609
578
|
// -------------------------------------------------------------------------
|
|
610
|
-
it('
|
|
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('
|
|
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
|
-
//
|
|
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('
|
|
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
|
-
//
|
|
719
|
+
// Focus retention on the first keystroke (async preParse path)
|
|
751
720
|
// -------------------------------------------------------------------------
|
|
752
|
-
it('
|
|
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
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
package/useCode/useUIState.mjs
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
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,
|
|
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
|
-
|
|
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;
|