@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
@@ -163,6 +163,22 @@ export type VariantCode = CodeMeta & {
163
163
  * decompressing `hastCompressed` payloads.
164
164
  */
165
165
  fallback?: FallbackNode[];
166
+ /**
167
+ * Staging-only highlighted-visible fallback, stored **sparsely**: a map from frame
168
+ * index to the highlighted `FallbackNode` for ONLY the frames visible on the initial
169
+ * (collapsed, `collapseToEmpty: false`) render. Off-screen frames are omitted — they
170
+ * are byte-identical to `fallback`'s plain output, so storing them would just
171
+ * duplicate `fallback` in the precompute. Computed at load time (where the source is
172
+ * still a live `HastRoot`, so no decompression) and carried in precomputed payloads
173
+ * alongside `fallback`. Under `highlightAt: 'init'` the server/client boundary
174
+ * `promoteCriticalFallback`s it over `fallback` so the first paint is highlighted with
175
+ * no client-side decompression, then deletes it: `fallbackCritical` must NEVER reach
176
+ * the `Content`/`ContentLoading` components. The promoted `fallback` has byte-identical
177
+ * text, so it stays a valid DEFLATE dictionary. See `resolveFallbackCritical`.
178
+ */
179
+ fallbackCritical?: {
180
+ [frameIndex: number]: FallbackNode;
181
+ };
166
182
  /**
167
183
  * Line counts for this variant's main source — the same for the full (highlighted)
168
184
  * source and its `fallback`, since they're the same file. `totalLines` is the whole
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react';
4
4
  import { useChunkContext } from "../ChunkProvider/ChunkContext.mjs";
5
+ import { requestIdle } from "../useCoordinated/scheduleTasks.mjs";
5
6
 
6
7
  /** Result of {@link useChunk}. */
7
8
 
@@ -40,11 +41,15 @@ export function useChunk(config, props = {}) {
40
41
  return undefined;
41
42
  }, [preloaded, config, options]);
42
43
  const [data, setData] = React.useState(isLoaded ? preloaded : initialData);
43
- const [loading, setLoading] = React.useState(!isLoaded);
44
+ // `true` once the async `data`-mode load has resolved. Derive `loading` during
45
+ // render so that when `isLoaded` becomes true after mount (a `preloaded` value
46
+ // or `controlled` flag arriving later via props) `loading` flips to false on
47
+ // the next render without an extra effect pass.
48
+ const [loaded, setLoaded] = React.useState(false);
44
49
  const [revalidating, setRevalidating] = React.useState(false);
50
+ const loading = !isLoaded && !loaded;
45
51
  React.useEffect(() => {
46
52
  if (isLoaded) {
47
- setLoading(false);
48
53
  return undefined;
49
54
  }
50
55
  const controller = new AbortController();
@@ -65,7 +70,7 @@ export function useChunk(config, props = {}) {
65
70
  const result = await source.load(options, controller.signal);
66
71
  if (!controller.signal.aborted) {
67
72
  setData(result);
68
- setLoading(false);
73
+ setLoaded(true);
69
74
  }
70
75
  } catch {
71
76
  // Aborted by a newer load, or the load failed - leave the loading
@@ -95,7 +100,7 @@ export function useChunk(config, props = {}) {
95
100
  const result = await source.load(options, controller.signal);
96
101
  if (!controller.signal.aborted) {
97
102
  setData(result);
98
- setLoading(false);
103
+ setLoaded(true);
99
104
  setRevalidating(false);
100
105
  }
101
106
  } catch {
@@ -112,12 +117,9 @@ export function useChunk(config, props = {}) {
112
117
  if (!config.revalidateOnIdle || loading || typeof window === 'undefined') {
113
118
  return undefined;
114
119
  }
115
- const requestIdle = window.requestIdleCallback ?? (callback => setTimeout(callback, 1));
116
- const cancelIdle = window.cancelIdleCallback ?? clearTimeout;
117
- const handle = requestIdle(() => {
120
+ return requestIdle(() => {
118
121
  refresh().catch(() => {});
119
122
  });
120
- return () => cancelIdle(handle);
121
123
  }, [config.revalidateOnIdle, loading, refresh]);
122
124
 
123
125
  // Abort any in-flight refresh on unmount.
@@ -61,6 +61,7 @@ export function useCoordinatedSwap(options) {
61
61
  // flip `fallbackMounted` and the swap proceeds.
62
62
  React.useEffect(() => {
63
63
  if (!fallbackMounted && hasFallback && !skipFallback) {
64
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- force-mount-once latch: must flip AFTER the first fallback commit so child hoist effects run first; see comment above
64
65
  setFallbackMounted(true);
65
66
  }
66
67
  }, [fallbackMounted, hasFallback, skipFallback]);
@@ -4,6 +4,7 @@ import * as React from 'react';
4
4
  import { decompressHast, hastToJsx } from "../pipeline/hastUtils/index.mjs";
5
5
  import { useCodeComponents } from "../useCode/CodeComponentsContext.mjs";
6
6
  import { fallbackToHast, fallbackToText } from "../pipeline/hastUtils/fallbackFormat.mjs";
7
+ import { requestIdle } from "../useCoordinated/scheduleTasks.mjs";
7
8
  /**
8
9
  * Find the children of the first `<code>` element in a parsed HAST tree.
9
10
  */
@@ -50,10 +51,15 @@ export function TypeCode({
50
51
  const [isVisible, setIsVisible] = React.useState(effectiveMode !== 'visible');
51
52
  const [codeElement, setCodeElement] = React.useState(null);
52
53
 
53
- // Synchronize visibility state when the effective mode changes.
54
- React.useEffect(() => {
54
+ // Re-seed visibility state during render when the effective mode changes
55
+ // (the 'store previous prop value, set state during render' pattern). The
56
+ // IntersectionObserver effect still owns runtime true/false toggling because
57
+ // it runs after this render-time seed.
58
+ const [prevEffectiveMode, setPrevEffectiveMode] = React.useState(effectiveMode);
59
+ if (prevEffectiveMode !== effectiveMode) {
60
+ setPrevEffectiveMode(effectiveMode);
55
61
  setIsVisible(effectiveMode !== 'visible');
56
- }, [effectiveMode]);
62
+ }
57
63
 
58
64
  // Convert compact fallback to HAST for rendering.
59
65
  const fallbackHastRoot = React.useMemo(() => fallback ? fallbackToHast(fallback) : undefined, [fallback]);
@@ -143,14 +149,9 @@ export function TypeCode({
143
149
  }
144
150
 
145
151
  // 'idle' and 'visible' both defer to idle time to avoid blocking the main thread.
146
- if (typeof requestIdleCallback !== 'undefined') {
147
- const id = requestIdleCallback(parse, {
148
- timeout: 2000
149
- });
150
- return () => cancelIdleCallback(id);
151
- }
152
- const id = setTimeout(parse, 0);
153
- return () => clearTimeout(id);
152
+ return requestIdle(parse, {
153
+ timeout: 2000
154
+ });
154
155
  }, [isVisible, hastJson, hastCompressed, effectiveMode, textDictionary]);
155
156
  const highlighted = React.useMemo(() => hast !== null ? hastToJsx(hast, components) : null, [hast, components]);
156
157
  const content = highlighted ?? fallbackJsx;
@@ -313,14 +313,19 @@ function hastToJsxDeferred(hastOrJson, components, enhancers, highlightAt) {
313
313
  // Derive dictionary text from the fallback, then compress the full
314
314
  // highlighted HAST with that dictionary. On the client, TypeCode
315
315
  // calls fallbackToText(fallback) to reconstruct the same dictionary.
316
- const textContent = fallbackToText(fallback);
317
-
318
- // Serialize the enhanced HAST (post-enhancer) for the client.
319
- // Compress using the fallback text as a DEFLATE dictionary.
320
- // TypeCode derives the same text from the fallback prop
321
- // to decompress on the client.
316
+ // Serialize the enhanced HAST (post-enhancer) for the client. DEFLATE-compressing it
317
+ // (with the fallback text as the dictionary) only shrinks the server→client wire, so
318
+ // do it ONLY on the server. On the client there is no wire — the JSON is consumed
319
+ // in-process by TypeCode — so skip the synchronous, main-thread compress and hand it
320
+ // the JSON directly; TypeCode reads `hastJson` without decompressing (it only rebuilds
321
+ // the DEFLATE dictionary from `fallback` when it actually has a `hastCompressed`
322
+ // payload). `typeof window` is the isomorphic server check.
322
323
  const enhancedJson = JSON.stringify(hast);
323
- const hastCompressed = compressHast(enhancedJson, textContent);
324
+ const hastPayload = typeof window === 'undefined' ? {
325
+ hastCompressed: compressHast(enhancedJson, fallbackToText(fallback))
326
+ } : {
327
+ hastJson: enhancedJson
328
+ };
324
329
 
325
330
  // Find the <pre> element for wrapper props
326
331
  const preElement = findPreElement(hast);
@@ -330,7 +335,7 @@ function hastToJsxDeferred(hastOrJson, components, enhancers, highlightAt) {
330
335
  // fallback crosses the boundary as a serialized prop — no separate
331
336
  // text dictionary string is needed.
332
337
  return /*#__PURE__*/React.createElement(PreComponent, hastPropsToReactProps(preElement?.properties), /*#__PURE__*/React.createElement(TypeCode, {
333
- hastCompressed,
338
+ ...hastPayload,
334
339
  highlightAt,
335
340
  fallback,
336
341
  codeProps: hastPropsToReactProps(codeElement.properties)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-docs-infra",
3
- "version": "0.11.1-canary.21",
3
+ "version": "0.11.1-canary.22",
4
4
  "author": "MUI Team",
5
5
  "description": "MUI Infra - internal documentation creation tools.",
6
6
  "license": "MIT",
@@ -768,5 +768,5 @@
768
768
  "bin": {
769
769
  "docs-infra": "./cli/index.mjs"
770
770
  },
771
- "gitSha": "335d2d92539a04d65a56580de65b240bf4a2273b"
771
+ "gitSha": "c0380f22916148bebba5dee261c1a2a3a9211a5e"
772
772
  }
@@ -1,6 +1,7 @@
1
1
  import * as path from 'path-module';
2
2
  import { compressHastAsync } from "../hastUtils/index.mjs";
3
- import { buildRootFallback, fallbackToText } from "../../CodeHighlighter/fallbackFormat.mjs";
3
+ import { buildRootFallback, buildCriticalFallback, fallbackToText } from "../../CodeHighlighter/fallbackFormat.mjs";
4
+ import { getInitialVisibleFrames } from "../parseSource/frameVisibility.mjs";
4
5
  import { transformSource } from "./transformSource.mjs";
5
6
  import { diffHast } from "./diffHast.mjs";
6
7
  import { isFrameSpan } from "../parseSource/isFrameSpan.mjs";
@@ -182,6 +183,7 @@ async function loadSingleFile(variantName, fileName, source, url, loadSource, so
182
183
  } = options;
183
184
  let finalSource = source;
184
185
  let finalFallback;
186
+ let finalFallbackCritical;
185
187
  let finalTotalLines;
186
188
  let finalFocusedLines;
187
189
  let finalCollapsible;
@@ -373,6 +375,32 @@ async function loadSingleFile(variantName, fileName, source, url, loadSource, so
373
375
  // decompressed on the client once the fallback travels over via context.
374
376
  if (finalSource && typeof finalSource === 'object' && !('hastJson' in finalSource) && !('hastCompressed' in finalSource)) {
375
377
  finalFallback = buildRootFallback(finalSource);
378
+ // Sparse highlighted-visible companion (see `VariantCode.fallbackCritical`),
379
+ // computed here while the source is still a live `HastRoot` (no
380
+ // decompression) and BEFORE `stripFrameFallbacks` removes the per-frame
381
+ // text it reuses. `false` builds the `collapseToEmpty: false` form (the only
382
+ // one carrying highlighting); the boundary skips promoting it under
383
+ // `collapseToEmpty`. Empty (no visible frames) → omit it entirely.
384
+ const critical = buildCriticalFallback(finalSource, getInitialVisibleFrames(finalSource, false));
385
+ finalFallbackCritical = Object.keys(critical).length > 0 ? critical : undefined;
386
+
387
+ // Hoist the window counts off `root.data` while the source is still a live
388
+ // `HastRoot`. They then ride on the variant (see the return below) so every
389
+ // downstream reader (`prepareInitialSource`, `getVariantFileLineCounts`,
390
+ // layout-shift classification) gets `totalLines`/`focusedLines`/`collapsible`
391
+ // WITHOUT decompressing the payload — the compact fallback and the compressed
392
+ // source both drop `root.data`, so without this the only way to recover the
393
+ // counts is to decode the hast (the first-render decompression we want to avoid).
394
+ const rootData = finalSource.data;
395
+ if (rootData?.totalLines !== undefined) {
396
+ const total = Number(rootData.totalLines);
397
+ if (Number.isFinite(total) && total >= 0) {
398
+ finalTotalLines = total;
399
+ const focused = Number(rootData.focusedLines);
400
+ finalFocusedLines = Number.isFinite(focused) && focused >= 0 ? focused : total;
401
+ finalCollapsible = rootData.collapsible === true;
402
+ }
403
+ }
376
404
  }
377
405
  if (options.output === 'hastCompressed' && process.env.NODE_ENV === 'production') {
378
406
  if (finalFallback) {
@@ -439,6 +467,7 @@ async function loadSingleFile(variantName, fileName, source, url, loadSource, so
439
467
  return {
440
468
  source: finalSource,
441
469
  fallback: finalFallback,
470
+ fallbackCritical: finalFallbackCritical,
442
471
  totalLines: finalTotalLines,
443
472
  focusedLines: finalFocusedLines,
444
473
  collapsible: finalCollapsible,
@@ -782,6 +811,7 @@ export async function loadIsomorphicCodeVariant(url, variantName, variant, optio
782
811
  if (!fileName && !url) {
783
812
  let finalSource = variant.source;
784
813
  let finalFallback;
814
+ let finalFallbackCritical;
785
815
 
786
816
  // Parse the source if we have language and sourceParser
787
817
  if (typeof finalSource === 'string' && language && sourceParser && !disableParsing) {
@@ -814,6 +844,10 @@ export async function loadIsomorphicCodeVariant(url, variantName, variant, optio
814
844
  // Always derive a variant-level root fallback from the per-frame text so a
815
845
  // `ContentLoading` component can render before the hast is decoded.
816
846
  finalFallback = buildRootFallback(finalSource);
847
+ // Sparse highlighted-visible companion (see `VariantCode.fallbackCritical`),
848
+ // built from the live `HastRoot` before `stripFrameFallbacks` runs.
849
+ const critical = buildCriticalFallback(finalSource, getInitialVisibleFrames(finalSource, false));
850
+ finalFallbackCritical = Object.keys(critical).length > 0 ? critical : undefined;
817
851
  if (options.output === 'hastCompressed' && process.env.NODE_ENV === 'production') {
818
852
  if (finalFallback) {
819
853
  stripFrameFallbacks(finalSource);
@@ -832,6 +866,9 @@ export async function loadIsomorphicCodeVariant(url, variantName, variant, optio
832
866
  source: finalSource,
833
867
  ...(finalFallback ? {
834
868
  fallback: finalFallback
869
+ } : {}),
870
+ ...(finalFallbackCritical ? {
871
+ fallbackCritical: finalFallbackCritical
835
872
  } : {})
836
873
  };
837
874
  return {
@@ -1084,6 +1121,9 @@ export async function loadIsomorphicCodeVariant(url, variantName, variant, optio
1084
1121
  ...(mainFileResult.fallback && {
1085
1122
  fallback: mainFileResult.fallback
1086
1123
  }),
1124
+ ...(mainFileResult.fallbackCritical && {
1125
+ fallbackCritical: mainFileResult.fallbackCritical
1126
+ }),
1087
1127
  ...(mainFileResult.totalLines !== undefined && {
1088
1128
  totalLines: mainFileResult.totalLines
1089
1129
  }),
@@ -1,3 +1,4 @@
1
+ import type { HastRoot } from "../../CodeHighlighter/types.mjs";
1
2
  /**
2
3
  * Set form of {@link COLLAPSED_VISIBLE_FRAME_TYPE_LIST} for fast membership
3
4
  * checks. Typed as `ReadonlySet<string>` so callers can pass a raw
@@ -28,4 +29,19 @@ export declare const COLLAPSED_VISIBLE_FRAME_TYPES: ReadonlySet<string>;
28
29
  * @param frameType - The frame's `data-frame-type` (may be `undefined` for `normal`)
29
30
  * @param collapseToEmpty - Whether the block is rendered collapse-to-empty
30
31
  */
31
- export declare function resolveCollapsedFrameType(frameType: string | undefined, collapseToEmpty: boolean): string | undefined;
32
+ export declare function resolveCollapsedFrameType(frameType: string | undefined, collapseToEmpty: boolean): string | undefined;
33
+ /**
34
+ * The set of frame indices that are visible on the initial (collapsed) render of
35
+ * a code block: the contiguous focused window
36
+ * ({@link COLLAPSED_VISIBLE_FRAME_TYPES}), falling back to the first frame when no
37
+ * frame carries an emphasis type. Returns an empty set for `collapseToEmpty` (an
38
+ * empty collapsed window) and for a `focusedLines === 0` carve-out
39
+ * (`oversizedFocus: 'hide'`).
40
+ *
41
+ * Shared by the runtime rule in `useCode/Pre.tsx` and the server-side
42
+ * highlighted-visible fallback builder, so the frames highlighted on the first
43
+ * paint match exactly. Isomorphic — reads only precomputed HAST attributes.
44
+ */
45
+ export declare function getInitialVisibleFrames(hast: HastRoot | null, collapseToEmpty?: boolean): {
46
+ [key: number]: boolean;
47
+ };
@@ -1,3 +1,5 @@
1
+ import { isFrameSpan } from "./isFrameSpan.mjs";
2
+
1
3
  /**
2
4
  * The `data-frame-type` values whose frames make up the window a collapsible
3
5
  * code block shows while collapsed (the contiguous focused window:
@@ -58,4 +60,55 @@ export function resolveCollapsedFrameType(frameType, collapseToEmpty) {
58
60
  default:
59
61
  return frameType;
60
62
  }
63
+ }
64
+
65
+ /**
66
+ * The set of frame indices that are visible on the initial (collapsed) render of
67
+ * a code block: the contiguous focused window
68
+ * ({@link COLLAPSED_VISIBLE_FRAME_TYPES}), falling back to the first frame when no
69
+ * frame carries an emphasis type. Returns an empty set for `collapseToEmpty` (an
70
+ * empty collapsed window) and for a `focusedLines === 0` carve-out
71
+ * (`oversizedFocus: 'hide'`).
72
+ *
73
+ * Shared by the runtime rule in `useCode/Pre.tsx` and the server-side
74
+ * highlighted-visible fallback builder, so the frames highlighted on the first
75
+ * paint match exactly. Isomorphic — reads only precomputed HAST attributes.
76
+ */
77
+ export function getInitialVisibleFrames(hast, collapseToEmpty = false) {
78
+ if (!hast) {
79
+ return collapseToEmpty ? {} : {
80
+ 0: true
81
+ };
82
+ }
83
+
84
+ // Collapse-to-empty renders an empty collapsed window — no frame is visible while
85
+ // collapsed, regardless of the precomputed frame types.
86
+ if (collapseToEmpty) {
87
+ return {};
88
+ }
89
+ const visibleFrames = {};
90
+ let frameIndex = 0;
91
+ let hasVisibleEmphasisFrame = false;
92
+ hast.children.forEach(child => {
93
+ if (child.type !== 'element' || !isFrameSpan(child)) {
94
+ return;
95
+ }
96
+ const frameType = child.properties.dataFrameType;
97
+ if (typeof frameType === 'string' && COLLAPSED_VISIBLE_FRAME_TYPES.has(frameType)) {
98
+ visibleFrames[frameIndex] = true;
99
+ hasVisibleEmphasisFrame = true;
100
+ }
101
+ frameIndex += 1;
102
+ });
103
+
104
+ // Collapse-to-nothing (oversizedFocus: 'hide'): `focusedLines === 0` means
105
+ // the collapsed window is intentionally empty, so skip the first-frame
106
+ // fallback and keep every frame hidden when collapsed.
107
+ if (hast.data?.focusedLines === 0) {
108
+ return visibleFrames;
109
+ }
110
+ if (!hasVisibleEmphasisFrame && frameIndex > 0) {
111
+ visibleFrames[0] = true;
112
+ }
113
+ return visibleFrames;
61
114
  }
@@ -939,10 +939,16 @@ export const createEditableEngine = ctx => {
939
939
  const beforePosition = getPosition(element);
940
940
  const range = getCurrentRange();
941
941
  if (!range.collapsed) {
942
- // Whether the selection started at column 0 i.e. the deletion removes
943
- // whole lines from the first line down, so its comment-map anchor sits
944
- // one line higher than the post-delete caret (see `deletedFromLineStart`).
945
- const deletedFromLineStart = beforePosition.content.length === 0;
942
+ // Whether the deletion removed WHOLE lines from the first line down. True
943
+ // only when the selection BOTH started at column 0 (no content before it)
944
+ // AND ended at a line boundary (its text ends with a newline) — then the
945
+ // first line is gone and the post-delete caret lands on the line that
946
+ // shifted up from below, so the comment-map anchor sits one line higher
947
+ // (see `deletedFromLineStart`). A selection that ends MID-line instead
948
+ // collapses the spanned lines INTO the first line, which survives (emptied)
949
+ // under the caret — no shift-up — so the flag must stay false, or a marker
950
+ // on that surviving line is dragged one line too high.
951
+ const deletedFromLineStart = beforePosition.content.length === 0 && range.toString().endsWith('\n');
946
952
  edit.insert('', 0);
947
953
  // A multi-line selection delete can natively remove whole `.frame`
948
954
  // wrapper elements (e.g. selecting exactly one emphasis frame). That
@@ -1014,7 +1020,11 @@ export const createEditableEngine = ctx => {
1014
1020
  event.preventDefault();
1015
1021
  const range = getCurrentRange();
1016
1022
  if (!range.collapsed) {
1017
- const deletedFromLineStart = getPosition(element).content.length === 0;
1023
+ // See the Backspace branch above: deletedFromLineStart holds only when the
1024
+ // selection removed whole lines (started at column 0 AND ended at a line
1025
+ // boundary). A mid-line end collapses the lines in place, leaving the first
1026
+ // line emptied under the caret — no shift-up — so the flag must stay false.
1027
+ const deletedFromLineStart = getPosition(element).content.length === 0 && range.toString().endsWith('\n');
1018
1028
  edit.insert('', 0);
1019
1029
  // Same frame-wrapper detach crash as the Backspace branch above: a
1020
1030
  // multi-line selection delete must reconcile synchronously so React
package/useCode/Pre.mjs CHANGED
@@ -3,12 +3,12 @@
3
3
  var _kbd;
4
4
  import * as React from 'react';
5
5
  import { useEditable } from "./useEditable.mjs";
6
- import { fallbackToHast } from "../CodeHighlighter/fallbackFormat.mjs";
6
+ import { fallbackToHast, fallbackIsHighlighted } from "../CodeHighlighter/fallbackFormat.mjs";
7
7
  import { useCodeContext } from "../CodeProvider/CodeContext.mjs";
8
8
  import { hastToJsx, frameFallbackFromSpans } from "../pipeline/hastUtils/index.mjs";
9
9
  import { stripHighlightingSpans } from "../pipeline/hastUtils/stripHighlightingSpans.mjs";
10
10
  import { decodeHastSource } from "../pipeline/loadIsomorphicCodeVariant/decodeHastSource.mjs";
11
- import { COLLAPSED_VISIBLE_FRAME_TYPES, resolveCollapsedFrameType } from "../pipeline/parseSource/frameVisibility.mjs";
11
+ import { COLLAPSED_VISIBLE_FRAME_TYPES, resolveCollapsedFrameType, getInitialVisibleFrames } from "../pipeline/parseSource/frameVisibility.mjs";
12
12
  import { isFrameSpan } from "../pipeline/parseSource/isFrameSpan.mjs";
13
13
  import { getSourceLineCounts } from "./sourceLineCounts.mjs";
14
14
  import { subscribeToggleNudge } from "./subscribeToggleNudge.mjs";
@@ -25,44 +25,6 @@ function resolveFrameTypeAttribute(frameType, collapseToEmpty) {
25
25
  const resolved = resolveCollapsedFrameType(frameType, collapseToEmpty);
26
26
  return resolved && resolved !== 'normal' ? resolved : undefined;
27
27
  }
28
- function getInitialVisibleFrames(hast, collapseToEmpty = false) {
29
- if (!hast) {
30
- return collapseToEmpty ? {} : {
31
- 0: true
32
- };
33
- }
34
-
35
- // Collapse-to-empty renders an empty collapsed window — no frame is visible while
36
- // collapsed, regardless of the precomputed frame types.
37
- if (collapseToEmpty) {
38
- return {};
39
- }
40
- const visibleFrames = {};
41
- let frameIndex = 0;
42
- let hasVisibleEmphasisFrame = false;
43
- hast.children.forEach(child => {
44
- if (child.type !== 'element' || !isFrameSpan(child)) {
45
- return;
46
- }
47
- const frameType = child.properties.dataFrameType;
48
- if (typeof frameType === 'string' && COLLAPSED_VISIBLE_FRAME_TYPES.has(frameType)) {
49
- visibleFrames[frameIndex] = true;
50
- hasVisibleEmphasisFrame = true;
51
- }
52
- frameIndex += 1;
53
- });
54
-
55
- // Collapse-to-nothing (oversizedFocus: 'hide'): `focusedLines === 0` means
56
- // the collapsed window is intentionally empty, so skip the first-frame
57
- // fallback and keep every frame hidden when collapsed.
58
- if (hast.data?.focusedLines === 0) {
59
- return visibleFrames;
60
- }
61
- if (!hasVisibleEmphasisFrame && frameIndex > 0) {
62
- visibleFrames[0] = true;
63
- }
64
- return visibleFrames;
65
- }
66
28
 
67
29
  /**
68
30
  * Bounds describing the visible region of a collapsible code block in its
@@ -280,16 +242,32 @@ export function Pre({
280
242
  editActivation,
281
243
  onActivate
282
244
  }) {
245
+ // Defer the decompressing `decodeHastSource` to a post-paint render ONLY when the
246
+ // first-paint `.fallback` is ALREADY highlighted — i.e. the promoted highlighted-visible
247
+ // fallback the server ships for `highlightAt: 'init'`. Then paint that highlighted
248
+ // fallback first (no decompression on the critical path) and swap in the full decoded
249
+ // tree after. When the fallback is plain — every other mode, including a late-mounted
250
+ // `'hydration'` block where `shouldHighlight` is also true on the first render — decode
251
+ // on mount instead, so we never flash plain → highlighted.
252
+ const [deferInitialDecode] = React.useState(() => shouldHighlight === true && !!fallback && fallbackIsHighlighted(fallback) && typeof children !== 'string');
253
+ const [decodeAllowed, setDecodeAllowed] = React.useState(!deferInitialDecode);
254
+ React.useEffect(() => {
255
+ if (deferInitialDecode) {
256
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional post-paint latch: flip after the first paint so the highlighted fallback shows before the decompressing decode runs
257
+ setDecodeAllowed(true);
258
+ }
259
+ }, [deferInitialDecode]);
260
+
283
261
  // The variant `fallback` is forwarded to `decodeHastSource` so the
284
262
  // `hastCompressed` payload is decompressed with the matching DEFLATE
285
263
  // dictionary and each frame's `data.fallback` is restored. The decoded
286
264
  // tree stays shared (read-only), since `Pre` only reads it.
287
265
  const hast = React.useMemo(() => {
288
- if (!children || typeof children === 'string') {
266
+ if (!children || typeof children === 'string' || !decodeAllowed) {
289
267
  return null;
290
268
  }
291
269
  return decodeHastSource(children, fallback);
292
- }, [children, fallback]);
270
+ }, [children, fallback, decodeAllowed]);
293
271
 
294
272
  // Variant-swap bridge descriptor. While a variant swap is in flight
295
273
  // and the partner variant is taller than this one, we render an
@@ -356,6 +334,12 @@ export function Pre({
356
334
  // cursor on first paint.
357
335
  const [editableReady, setEditableReady] = React.useState(false);
358
336
  React.useLayoutEffect(() => {
337
+ // Deliberate two-pass mount gate: defer engine activation until the
338
+ // `bindPre` callback ref has committed (see 527-533). Flipping this true
339
+ // in a layout effect is the documented trigger for the no-flash /
340
+ // cursor-retention behavior and isn't derivable during render (the ref is
341
+ // intentionally null in render 1).
342
+ // eslint-disable-next-line react-hooks/set-state-in-effect
359
343
  setEditableReady(true);
360
344
  }, []);
361
345
  const onEditableChange = React.useCallback((text, position, preParsed) => {
@@ -398,6 +382,12 @@ export function Pre({
398
382
  // keeping the update outside the render phase while still avoiding a
399
383
  // visible flash of un-hydrated emphasis frames.
400
384
  React.useLayoutEffect(() => {
385
+ // Next state unions the new initial-visible set onto `prev` rather than
386
+ // replacing it (see 564-597): replacing would drop frames already
387
+ // hydrated by IO/editing on the prior tree, causing the visible flash this
388
+ // guards against. Depends on both a prop (`hast`) and prior state, so it
389
+ // can't be derived during render.
390
+ // eslint-disable-next-line react-hooks/set-state-in-effect
401
391
  setVisibleFrames(prev => {
402
392
  const initial = getInitialVisibleFrames(hast, collapseToEmpty);
403
393
  let merged;
@@ -976,13 +966,18 @@ export function Pre({
976
966
  // FRAMED — the compact `fallback` (the loader's windowed plain-text frames) when one
977
967
  // travelled with it, otherwise a single-frame wrap — so the `<code>` is never bare
978
968
  // text. The highlighted tree swaps in via `frames` once parsing completes.
979
- const stringFramed = React.useMemo(() => {
980
- if (typeof children !== 'string') {
969
+ // The fallback render, used whenever the decoded `hast` isn't in hand: a string
970
+ // source (highlighted on the client after hydration) or an object source whose
971
+ // decode is deferred off the first paint (`deferInitialDecode`). For `init` the
972
+ // server-built `fallback` already carries the initially-visible frames
973
+ // highlighted, so this first paint is highlighted with no decompression.
974
+ const framedFallback = React.useMemo(() => {
975
+ const frameNodes = fallback ?? (typeof children === 'string' ? [['span', 'frame', {
976
+ dataFrameType: 'focus'
977
+ }, children]] : null);
978
+ if (!frameNodes) {
981
979
  return null;
982
980
  }
983
- const frameNodes = fallback ?? [['span', 'frame', {
984
- dataFrameType: 'focus'
985
- }, children]];
986
981
  const root = fallbackToHast(frameNodes);
987
982
  if (collapseToEmpty) {
988
983
  for (const child of root.children) {
@@ -1020,7 +1015,7 @@ export function Pre({
1020
1015
  "data-collapsible": hasCollapsibleFrames ? '' : undefined,
1021
1016
  "data-total-lines": sourceTotalLines,
1022
1017
  "data-focused-lines": sourceFocusedLines,
1023
- children: typeof children === 'string' ? stringFramed : frames
1018
+ children: hast ? frames : framedFallback
1024
1019
  })
1025
1020
  });
1026
1021
  if (!isEditable) {
@@ -207,20 +207,33 @@ export function shiftComments(comments, lineDelta, position, existingCollapseMap
207
207
  // O(1) lookup against the precomputed empty-line set from the old source.
208
208
  const oldEmptyLineSet = oldEmptyLines && oldEmptyLines.length > 0 ? new Set(oldEmptyLines) : undefined;
209
209
 
210
- // For a deletion, find range bases whose BOTH `-start` and `-end` markers fall
211
- // inside the deleted block. Such a range is removed entirely there is nowhere
212
- // to shift its markers to so we stash BOTH ends in the collapseMap and leave
213
- // neither visible: the frame disappears now and an undo rebuilds it intact at
214
- // its original offsets. (A range whose start survives OUTSIDE the block only
215
- // shrinks, so its `-end` keeps the editLine+1 placement below and stays
216
- // untracked, matching the expand/contract behavior.)
210
+ // For a deletion, find range bases that are removed entirely every line they
211
+ // highlighted is gone so the frame must disappear (its `-start`, which is in
212
+ // the deleted block, is stashed rather than collapsed onto a surviving line
213
+ // above as a phantom highlight) and an undo can rebuild it.
214
+ //
215
+ // Two shapes qualify. Both require the `-start` to fall inside the deleted block
216
+ // `(editLine, editLine - lineDelta]`:
217
+ // 1. The `-end` is also inside the block (the selection deleted right through
218
+ // it). Both markers are stashed and an undo restores them at their offsets.
219
+ // 2. The `-end` is on `boundaryLine` — the first SURVIVING line just past the
220
+ // block. A range's `-end` is EXCLUSIVE, sitting one line below its last
221
+ // highlighted line, so a selection that removes every highlighted line stops
222
+ // with the caret on that `-end` line, leaving it intact. The range is still
223
+ // empty, so it is fully deleted; the surviving `-end` then shifts up as a
224
+ // lone marker (rendering nothing) and is the undo memory that re-pairs with
225
+ // the restored `-start`.
226
+ // (A range whose `-start` survives OUTSIDE the block only shrinks, so its `-end`
227
+ // keeps the editLine+1 placement below and stays untracked.)
217
228
  let fullyDeletedRanges;
218
229
  if (lineDelta < 0) {
219
230
  const startBases = new Set();
220
231
  const endBases = new Set();
232
+ const boundaryLine = editLine - lineDelta + 1;
221
233
  for (const [lineStr, commentArr] of Object.entries(comments ?? {})) {
222
234
  const line = Number(lineStr);
223
- if (line > editLine && line <= editLine - lineDelta) {
235
+ const inBlock = line > editLine && line <= editLine - lineDelta;
236
+ if (inBlock) {
224
237
  for (const comment of commentArr) {
225
238
  if (comment.endsWith('-end')) {
226
239
  endBases.add(comment.slice(0, -'-end'.length));
@@ -228,6 +241,14 @@ export function shiftComments(comments, lineDelta, position, existingCollapseMap
228
241
  startBases.add(comment.slice(0, -'-start'.length));
229
242
  }
230
243
  }
244
+ } else if (line === boundaryLine) {
245
+ // Only an exclusive `-end` on the boundary closes a fully-deleted range; a
246
+ // `-start` here belongs to a surviving line below and must not be paired.
247
+ for (const comment of commentArr) {
248
+ if (comment.endsWith('-end')) {
249
+ endBases.add(comment.slice(0, -'-end'.length));
250
+ }
251
+ }
231
252
  }
232
253
  }
233
254
  for (const base of startBases) {