@mui/internal-docs-infra 0.11.1-canary.6 → 0.11.1-canary.8

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.
@@ -174,9 +174,13 @@ function insertClientProviderImport(source, importsAndComments) {
174
174
  /**
175
175
  * Converts a Turbopack-style glob (e.g. `./app/**\/demos/*\/index.ts`) to a
176
176
  * RegExp that matches absolute filesystem paths. Mirrors the logic used by
177
- * `withDocsInfra` for webpack rule generation.
177
+ * `withDocsInfra` for webpack rule generation. Pass-through when the input is
178
+ * already a RegExp (webpack-rule `test` regexes).
178
179
  */
179
180
  function patternToRegExp(pattern) {
181
+ if (pattern instanceof RegExp) {
182
+ return pattern;
183
+ }
180
184
  const SEP = '\u0000SEP\u0000';
181
185
  const NOT_SEP = '\u0000NOT_SEP\u0000';
182
186
  const DOUBLE_STAR = '\u0000DOUBLE_STAR\u0000';
@@ -187,9 +191,13 @@ function patternToRegExp(pattern) {
187
191
 
188
192
  /**
189
193
  * Finds the longest fixed-prefix directory in a glob pattern so we can avoid
190
- * walking the entire workspace.
194
+ * walking the entire workspace. Webpack `test` regexes have no extractable
195
+ * prefix, so we fall back to walking from `baseDir`.
191
196
  */
192
197
  function patternBaseDir(pattern, baseDir) {
198
+ if (pattern instanceof RegExp) {
199
+ return baseDir;
200
+ }
193
201
  const stripped = pattern.replace(/^\.\//, '');
194
202
  const segments = stripped.split('/');
195
203
  const fixed = [];
@@ -1,8 +1,12 @@
1
1
  import type { DescriptionReplacement } from "../pipeline/loadServerTypesMeta/format.mjs";
2
2
  import type { OrderingConfig } from "../pipeline/loadServerTypesText/order.mjs";
3
3
  export interface DemoClientRequirement {
4
- /** Glob pattern (Turbopack-style) used as the rule key, e.g. `./app/**\/demos/*\/index.ts`. */
5
- pattern: string;
4
+ /**
5
+ * Either a Turbopack-style glob pattern (e.g. `./app/**\/demos/*\/index.ts`)
6
+ * or a webpack-style RegExp used as the rule's `test`. Globs are extracted
7
+ * from `turbopack.rules`; RegExps are extracted from `webpack` rules.
8
+ */
9
+ pattern: string | RegExp;
6
10
  /** Import specifier passed verbatim into the generated `client.ts`. */
7
11
  requireClient: string;
8
12
  }
@@ -1,6 +1,7 @@
1
1
  import { access } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
+ import { createJiti } from 'jiti';
4
5
  const TYPES_LOADER = '@mui/internal-docs-infra/pipeline/loadPrecomputedTypes';
5
6
  const CODE_HIGHLIGHTER_LOADER = '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter';
6
7
  const TRANSFORM_METADATA_PLUGIN = '@mui/internal-docs-infra/pipeline/transformMarkdownMetadata';
@@ -83,43 +84,90 @@ function extractOptionsFromTurbopack(config) {
83
84
  }
84
85
 
85
86
  /**
86
- * Calls the webpack function with a minimal config and extracts docs-infra
87
- * options (ordering, descriptionReplacements, socketDir, useVisibleDescription)
88
- * from the resulting rules.
87
+ * Builds a mock webpack config rich enough to satisfy common patterns used by
88
+ * real `next.config` webpack functions (e.g. `config.resolve.extensions.filter`,
89
+ * `config.module.rules.forEach`, `config.externals.slice`). Real webpack passes
90
+ * an object with these properties populated, so a too-minimal mock causes
91
+ * configs to throw before we can read their rules.
89
92
  */
90
- function extractOptionsFromWebpack(config) {
91
- if (typeof config?.webpack !== 'function') {
92
- return {};
93
- }
94
- const webpackConfig = {
93
+ function createMockWebpackConfig() {
94
+ return {
95
95
  module: {
96
96
  rules: []
97
97
  },
98
98
  resolve: {
99
- alias: {}
99
+ alias: {},
100
+ extensions: ['.mjs', '.js', '.jsx', '.ts', '.tsx', '.json'],
101
+ modules: [],
102
+ fallback: {}
100
103
  },
101
- plugins: []
104
+ plugins: [],
105
+ externals: [],
106
+ optimization: {},
107
+ output: {},
108
+ experiments: {}
102
109
  };
103
- try {
104
- const result = config.webpack(webpackConfig, {
105
- defaultLoaders: {
106
- babel: {}
107
- }
108
- });
109
- const merged = {};
110
- for (const rule of result?.module?.rules ?? []) {
111
- const useEntries = Array.isArray(rule?.use) ? rule.use : [];
112
- const extracted = extractOptionsFromLoaderEntries(useEntries);
113
- merged.ordering ??= extracted.ordering;
114
- merged.descriptionReplacements ??= extracted.descriptionReplacements;
115
- merged.useVisibleDescription ??= extracted.useVisibleDescription;
116
- merged.socketDir ??= extracted.socketDir;
117
- }
118
- return merged;
119
- } catch {
120
- // webpack function may throw without real webpack context — ignore
110
+ }
111
+
112
+ /**
113
+ * Calls the webpack function with mock config + options pairs for both client
114
+ * and server builds, returning a merged config or `null` if both variants
115
+ * throw. Some Next.js configs only add loader rules when `options.isServer`
116
+ * is true, so we need to evaluate both branches.
117
+ */
118
+ function callWebpackSafely(config) {
119
+ if (typeof config?.webpack !== 'function') {
120
+ return null;
121
+ }
122
+ const results = [];
123
+ for (const isServer of [false, true]) {
124
+ try {
125
+ results.push(config.webpack(createMockWebpackConfig(), {
126
+ defaultLoaders: {
127
+ babel: {}
128
+ },
129
+ isServer,
130
+ nextRuntime: isServer ? 'nodejs' : undefined,
131
+ dev: false,
132
+ buildId: 'docs-infra-validate',
133
+ config: {
134
+ env: {}
135
+ },
136
+ webpack: () => ({})
137
+ }));
138
+ } catch {
139
+ // try next variant
140
+ }
141
+ }
142
+ if (results.length === 0) {
143
+ return null;
144
+ }
145
+ const mergedRules = results.flatMap(result => Array.isArray(result?.module?.rules) ? result.module.rules : []);
146
+ return {
147
+ ...results[0],
148
+ module: {
149
+ ...(results[0]?.module ?? {}),
150
+ rules: mergedRules
151
+ }
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Calls the webpack function with a minimal config and extracts docs-infra
157
+ * options (ordering, descriptionReplacements, socketDir, useVisibleDescription)
158
+ * from the resulting rules.
159
+ */
160
+ function extractOptionsFromWebpackResult(result) {
161
+ const merged = {};
162
+ for (const rule of result?.module?.rules ?? []) {
163
+ const useEntries = Array.isArray(rule?.use) ? rule.use : [];
164
+ const extracted = extractOptionsFromLoaderEntries(useEntries);
165
+ merged.ordering ??= extracted.ordering;
166
+ merged.descriptionReplacements ??= extracted.descriptionReplacements;
167
+ merged.useVisibleDescription ??= extracted.useVisibleDescription;
168
+ merged.socketDir ??= extracted.socketDir;
121
169
  }
122
- return {};
170
+ return merged;
123
171
  }
124
172
  const NEXT_CONFIG_EXTENSIONS = ['.mjs', '.js', '.ts'];
125
173
 
@@ -154,28 +202,71 @@ function extractDemoClientRequirementsFromTurbopack(config) {
154
202
  }
155
203
  return requirements;
156
204
  }
205
+
206
+ /**
207
+ * Walks webpack rules to collect demo `test` regexes that opted into automatic
208
+ * `client.ts` generation via the `requireClient` option. Mirrors the Turbopack
209
+ * extractor but uses the rule's RegExp `test` as the pattern.
210
+ */
211
+ function extractDemoClientRequirementsFromWebpackResult(result) {
212
+ const requirements = [];
213
+ for (const rule of result?.module?.rules ?? []) {
214
+ if (!(rule?.test instanceof RegExp)) {
215
+ continue;
216
+ }
217
+ const useEntries = Array.isArray(rule.use) ? rule.use : [];
218
+ for (const loader of useEntries) {
219
+ if (loader?.loader === CODE_HIGHLIGHTER_LOADER && typeof loader?.options?.requireClient === 'string') {
220
+ requirements.push({
221
+ pattern: rule.test,
222
+ requireClient: loader.options.requireClient
223
+ });
224
+ break;
225
+ }
226
+ }
227
+ }
228
+ return requirements;
229
+ }
157
230
  export async function extractDocsInfraOptionsFromNextConfig(dir) {
158
231
  const configPath = await findNextConfig(dir);
159
232
  if (!configPath) {
160
233
  return {};
161
234
  }
235
+ let config;
162
236
  try {
163
- const configModule = await import(pathToFileURL(configPath).href);
164
- const config = configModule.default;
165
- const turbopack = extractOptionsFromTurbopack(config);
166
- const webpack = extractOptionsFromWebpack(config);
167
- const demoClientRequirements = extractDemoClientRequirementsFromTurbopack(config);
168
- return {
169
- ordering: turbopack.ordering ?? webpack.ordering,
170
- descriptionReplacements: turbopack.descriptionReplacements ?? webpack.descriptionReplacements,
171
- useVisibleDescription: turbopack.useVisibleDescription ?? webpack.useVisibleDescription,
172
- socketDir: turbopack.socketDir ?? webpack.socketDir,
173
- demoClientRequirements: demoClientRequirements.length > 0 ? demoClientRequirements : undefined
174
- };
175
- } catch {
176
- // Config not importable use defaults
237
+ if (configPath.endsWith('.ts')) {
238
+ // Use jiti so TypeScript configs (and their transitive .ts imports
239
+ // without extensions) load the same way Next.js itself loads them.
240
+ const jiti = createJiti(configPath, {
241
+ interopDefault: true
242
+ });
243
+ const configModule = await jiti.import(configPath);
244
+ config = configModule?.default ?? configModule;
245
+ } else {
246
+ const configModule = await import(pathToFileURL(configPath).href);
247
+ config = configModule.default;
248
+ }
249
+ } catch (error) {
250
+ // Surface the failure: a silently-swallowed import error here means
251
+ // demoClientRequirements (and other extracted options) end up empty,
252
+ // which usually presents to the user as `validate` doing nothing.
253
+ const message = error instanceof Error ? error.message : String(error);
254
+ console.warn(`[docs-infra] Failed to load ${path.relative(dir, configPath)} for option extraction: ${message}`);
255
+ return {};
177
256
  }
178
- return {};
257
+ const turbopack = extractOptionsFromTurbopack(config);
258
+ const webpackResult = callWebpackSafely(config);
259
+ const webpack = webpackResult ? extractOptionsFromWebpackResult(webpackResult) : {};
260
+ const turbopackDemoClientRequirements = extractDemoClientRequirementsFromTurbopack(config);
261
+ const webpackDemoClientRequirements = webpackResult ? extractDemoClientRequirementsFromWebpackResult(webpackResult) : [];
262
+ const demoClientRequirements = [...new Map([...turbopackDemoClientRequirements, ...webpackDemoClientRequirements].map(requirement => [`${typeof requirement.pattern === 'string' ? requirement.pattern : requirement.pattern.toString()}::${requirement.requireClient}`, requirement])).values()];
263
+ return {
264
+ ordering: turbopack.ordering ?? webpack.ordering,
265
+ descriptionReplacements: turbopack.descriptionReplacements ?? webpack.descriptionReplacements,
266
+ useVisibleDescription: turbopack.useVisibleDescription ?? webpack.useVisibleDescription,
267
+ socketDir: turbopack.socketDir ?? webpack.socketDir,
268
+ demoClientRequirements: demoClientRequirements.length > 0 ? demoClientRequirements : undefined
269
+ };
179
270
  }
180
271
  async function findNextConfig(dir) {
181
272
  const checks = NEXT_CONFIG_EXTENSIONS.map(async ext => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-docs-infra",
3
- "version": "0.11.1-canary.6",
3
+ "version": "0.11.1-canary.8",
4
4
  "author": "MUI Team",
5
5
  "description": "MUI Infra - internal documentation creation tools.",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  "hast-util-to-jsx-runtime": "^2.3.6",
36
36
  "hast-util-to-text": "^4.0.2",
37
37
  "import-meta-resolve": "^4.2.0",
38
+ "jiti": "^2.7.0",
38
39
  "jsondiffpatch": "^0.7.3",
39
40
  "kebab-case": "^2.0.2",
40
41
  "lz-string": "^1.5.0",
@@ -686,5 +687,5 @@
686
687
  "bin": {
687
688
  "docs-infra": "./cli/index.mjs"
688
689
  },
689
- "gitSha": "dc47acc4c60d02db487df3900491752cf51fe989"
690
+ "gitSha": "2d9566eb356cded76a38a33ebb312f2b3c46126c"
690
691
  }
package/useCode/Pre.mjs CHANGED
@@ -9,6 +9,102 @@ import { hastToJsx, decompressHast } from "../pipeline/hastUtils/index.mjs";
9
9
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  const hastChildrenCache = new WeakMap();
11
11
  const textChildrenCache = new WeakMap();
12
+
13
+ // Document-level subscriber registry for `<details>` toggle events. Each
14
+ // `<Pre>` would otherwise install its own capture-phase listener; on docs
15
+ // pages with many code blocks that's N listeners all firing on every
16
+ // toggle anywhere in the document. A single shared listener fans out to
17
+ // the relevant subscribers instead.
18
+ //
19
+ // Subscribers register their `<pre>` element so the dispatcher can do a
20
+ // single `target.contains(pre)` ancestry check per subscriber and skip
21
+ // the nudge entirely for unrelated toggles — no JS-side work runs in
22
+ // `<Pre>` instances whose subtree the toggle didn't touch.
23
+ //
24
+ // The value is a Set rather than a single function so the registry
25
+ // tolerates the (unlikely but possible) case where two `<Pre>` instances
26
+ // transiently share the same DOM node — e.g. a fast unmount/remount
27
+ // where the next mount's setup runs before the prior mount's cleanup.
28
+ // Without the set the second subscribe would silently overwrite the
29
+ // first nudge and a single unsubscribe would orphan the other instance.
30
+
31
+ const toggleSubscribers = new Map();
32
+ let toggleListenerAttached = false;
33
+ let sharedToggleListener = null;
34
+
35
+ // Reconcile the document-level capture listener with the current
36
+ // subscriber set. Idempotent: callable from any code path (including
37
+ // test teardowns that want to defensively assert no leaked listener)
38
+ // without risk of leaving the document in a half-attached state.
39
+ function syncToggleListener() {
40
+ if (typeof document === 'undefined') {
41
+ if (toggleSubscribers.size === 0) {
42
+ sharedToggleListener = null;
43
+ toggleListenerAttached = false;
44
+ }
45
+ return;
46
+ }
47
+ if (toggleSubscribers.size === 0) {
48
+ if (toggleListenerAttached && sharedToggleListener) {
49
+ document.removeEventListener('toggle', sharedToggleListener, true);
50
+ }
51
+ sharedToggleListener = null;
52
+ toggleListenerAttached = false;
53
+ return;
54
+ }
55
+ if (!toggleListenerAttached || !sharedToggleListener) {
56
+ sharedToggleListener = event => {
57
+ const target = event.target;
58
+ if (!(target instanceof Node)) {
59
+ return;
60
+ }
61
+ // Snapshot before iterating: a nudge may synchronously trigger an
62
+ // unmount that mutates `toggleSubscribers` mid-dispatch. Iterating
63
+ // a snapshot keeps dispatch order independent of subscriber
64
+ // mutations and matches the snapshot pattern used by
65
+ // `sweepDetachedFrames` / `nudgeFrameObserver`.
66
+ Array.from(toggleSubscribers).forEach(([preNode, nudges]) => {
67
+ // Centralized ancestry filter: only nudge subscribers whose `<pre>`
68
+ // is a descendant of the toggled element. Done here (rather than
69
+ // in each subscriber) so unrelated toggles short-circuit before
70
+ // any subscriber-side work runs.
71
+ if (!target.contains(preNode)) {
72
+ return;
73
+ }
74
+ Array.from(nudges).forEach(nudge => nudge());
75
+ });
76
+ };
77
+ document.addEventListener('toggle', sharedToggleListener, true);
78
+ toggleListenerAttached = true;
79
+ }
80
+ }
81
+ function subscribeToggleNudge(preNode, nudge) {
82
+ // Defensive SSR no-op: there is no `document` to attach a listener to,
83
+ // and module state in Node persists across requests — leaking a
84
+ // subscriber here would also leak the closure it captures. `useEffect`
85
+ // already won't run on the server, but make the contract explicit so
86
+ // any future non-effect caller can't strand entries in the registry.
87
+ if (typeof document === 'undefined') {
88
+ return () => {};
89
+ }
90
+ let nudges = toggleSubscribers.get(preNode);
91
+ if (!nudges) {
92
+ nudges = new Set();
93
+ toggleSubscribers.set(preNode, nudges);
94
+ }
95
+ nudges.add(nudge);
96
+ syncToggleListener();
97
+ return () => {
98
+ const existing = toggleSubscribers.get(preNode);
99
+ if (existing) {
100
+ existing.delete(nudge);
101
+ if (existing.size === 0) {
102
+ toggleSubscribers.delete(preNode);
103
+ }
104
+ }
105
+ syncToggleListener();
106
+ };
107
+ }
12
108
  const INITIAL_VISIBLE_FRAME_TYPES = new Set(['highlighted', 'focus', 'padding-top', 'padding-bottom']);
13
109
  function getInitialVisibleFrames(hast) {
14
110
  if (!hast) {
@@ -281,26 +377,142 @@ export function Pre({
281
377
  preParse
282
378
  });
283
379
  const observer = React.useRef(null);
380
+ const observedFrames = React.useRef(new Set());
284
381
  const frameIndexMap = React.useRef(new WeakMap());
285
- const bindIntersectionObserver = React.useCallback(root => {
286
- preRef.current = root;
287
- if (!root) {
288
- if (observer.current) {
289
- observer.current.disconnect();
382
+
383
+ // Drop frame spans that have been detached from the DOM. Used as a
384
+ // defensive sweep in `nudgeFrameObserver` (and the IO effect) so the
385
+ // tracking sets don't grow unboundedly across re-renders, even on
386
+ // React 17/18 where the cleanup return value of `observeFrame` is
387
+ // ignored. `node.isConnected` is the cheapest available signal.
388
+ const sweepDetachedFrames = React.useCallback(() => {
389
+ const io = observer.current;
390
+ // Snapshot before iterating: we mutate `observedFrames` inside the
391
+ // loop, so iterating the live Set would rely on its (well-defined
392
+ // but subtle) skip-deleted-entries semantics. An array snapshot makes
393
+ // the intent explicit and decouples iteration order from insertion
394
+ // order should the storage ever change.
395
+ Array.from(observedFrames.current).forEach(frame => {
396
+ if (!frame.isConnected) {
397
+ observedFrames.current.delete(frame);
398
+ frameIndexMap.current.delete(frame);
399
+ io?.unobserve(frame);
290
400
  }
291
- observer.current = null;
401
+ });
402
+ }, []);
403
+
404
+ // Re-observe every tracked frame so the IntersectionObserver re-evaluates
405
+ // visibility without a synchronous `getBoundingClientRect()` call. Used
406
+ // when ancestor layout changes (CSS-driven collapse/expand, <details>
407
+ // toggle, tab/accordion swaps) clip or unclip frames in ways that don't
408
+ // themselves trigger an IntersectionObserver entry. Mirrors the
409
+ // `nudgeObserver` pattern in `<TypeCode>`.
410
+ const nudgeFrameObserver = React.useCallback(() => {
411
+ const io = observer.current;
412
+ if (!io) {
413
+ return;
414
+ }
415
+ // Snapshot before iterating — see `sweepDetachedFrames` above.
416
+ Array.from(observedFrames.current).forEach(frame => {
417
+ if (!frame.isConnected) {
418
+ observedFrames.current.delete(frame);
419
+ frameIndexMap.current.delete(frame);
420
+ io.unobserve(frame);
421
+ return;
422
+ }
423
+ io.unobserve(frame);
424
+ io.observe(frame);
425
+ });
426
+ }, []);
427
+
428
+ // Holds the mounted `<pre>` element so the IO/RO/toggle setup effect can
429
+ // key on it. Using a state + callback-ref pair (rather than driving the
430
+ // setup from inside the ref callback) lets React's effect lifecycle
431
+ // guarantee teardown — including under StrictMode's double-invoke and
432
+ // for any abrupt unmount path — instead of relying on the ref callback
433
+ // being called with `null`.
434
+ const [preNode, setPreNode] = React.useState(null);
435
+
436
+ // Mirror the latest forwarded `ref` so `bindPre` can read it without
437
+ // depending on `ref` in its deps (which would re-create `bindPre` on
438
+ // every parent re-render and tear down the IO/RO/toggle setup effect
439
+ // below). Using `useLayoutEffect` (React 17 safe) keeps this in sync
440
+ // before any consumer's layout effect or imperative-handle read.
441
+ const forwardedRef = React.useRef(ref);
442
+ React.useLayoutEffect(() => {
443
+ const previous = forwardedRef.current;
444
+ forwardedRef.current = ref;
445
+ if (previous === ref) {
292
446
  return;
293
447
  }
294
- const indexMap = frameIndexMap.current;
295
- observer.current = new IntersectionObserver(entries => setVisibleFrames(prev => {
448
+ // Consumer swapped to a different ref function/object on a render
449
+ // where the DOM node didn't change (so `bindPre` wasn't called by
450
+ // React). Reconcile manually: detach the old, attach the new with
451
+ // the current node, matching React's standard callback-ref
452
+ // semantics for ref swaps.
453
+ const current = preRef.current;
454
+ if (typeof previous === 'function') {
455
+ previous(null);
456
+ } else if (previous) {
457
+ previous.current = null;
458
+ }
459
+ if (typeof ref === 'function') {
460
+ ref(current);
461
+ } else if (ref) {
462
+ ref.current = current;
463
+ }
464
+ }, [ref]);
465
+
466
+ // `bindPre` is stable (empty deps): if it depended on the forwarded
467
+ // `ref`, a parent re-render that supplies a new ref function would
468
+ // recreate `bindPre`, causing React to invoke the previous callback
469
+ // with `null` and the new one with the same DOM node. That sequence
470
+ // would tear down and rebuild the IO/RO/toggle subscription on every
471
+ // parent render. Ref-function swaps that don't change the DOM node
472
+ // are reconciled by the layout effect above.
473
+ //
474
+ // Forward the consumer's ref synchronously inside the callback (not in
475
+ // a separate `useEffect`) so any parent `useLayoutEffect` or
476
+ // imperative handle that reads `ref.current` right after mount sees
477
+ // the `<pre>` rather than `null`.
478
+ const bindPre = React.useCallback(root => {
479
+ // React 18+ StrictMode (and some normal-update paths) can invoke a
480
+ // ref callback with the same node it already holds. Short-circuit
481
+ // so we don't trigger an extra render via `setPreNode` or a
482
+ // redundant ref-forward cycle for the consumer.
483
+ if (preRef.current === root) {
484
+ return;
485
+ }
486
+ preRef.current = root;
487
+ const current = forwardedRef.current;
488
+ if (typeof current === 'function') {
489
+ current(root);
490
+ } else if (current) {
491
+ current.current = root;
492
+ }
493
+ setPreNode(root);
494
+ }, []);
495
+ const handleIntersection = React.useCallback(entries => {
496
+ setVisibleFrames(prev => {
296
497
  const visible = [];
297
498
  const invisible = [];
298
499
  entries.forEach(entry => {
299
- const index = indexMap.get(entry.target);
500
+ const index = frameIndexMap.current.get(entry.target);
300
501
  if (index === undefined) {
301
502
  return;
302
503
  }
303
- if (entry.isIntersecting) {
504
+ // A frame counts as visible only when it intersects the
505
+ // viewport AND its intersection rect has non-zero area.
506
+ // Frames hidden by a CSS-driven collapse (`max-height: 0;
507
+ // overflow: hidden;` or `visibility: hidden`) collapse to a
508
+ // zero-area rect; some browsers still report
509
+ // `isIntersecting: true` for them based on their geometric
510
+ // position in the document. Checking the rect dimensions
511
+ // matches what the user actually sees and prevents hidden
512
+ // frames from being upgraded to highlighted HAST.
513
+ const rect = entry.intersectionRect;
514
+ const isVisuallyVisible = entry.isIntersecting && rect.width > 0 && rect.height > 0;
515
+ if (isVisuallyVisible) {
304
516
  visible.push(index);
305
517
  } else {
306
518
  invisible.push(index);
@@ -330,54 +542,92 @@ export function Pre({
330
542
  }
331
543
  });
332
544
  return frames || prev;
333
- }), {
334
- rootMargin: hydrateMargin
335
545
  });
546
+ }, []);
336
547
 
337
- // <pre><code><span class="frame">...</span><span class="frame">...</span>...</code></pre>
338
- const codeElement = root.querySelector('code');
339
- if (!codeElement) {
340
- return;
548
+ // Set up IntersectionObserver, ResizeObserver, and the shared
549
+ // <details> toggle subscription whenever the pre element changes.
550
+ // Running this in `useEffect` (rather than in the ref callback)
551
+ // delegates teardown to React's effect lifecycle, so cleanup is
552
+ // guaranteed even under StrictMode's double-invoke and for any
553
+ // unmount path.
554
+ React.useEffect(() => {
555
+ if (!preNode) {
556
+ return undefined;
341
557
  }
342
- let frameIndex = 0;
343
- codeElement.childNodes.forEach(node => {
344
- if (node.nodeType === Node.ELEMENT_NODE) {
345
- const element = node;
346
- if (!element.classList.contains('frame')) {
347
- console.warn('Expected frame element in useCode <Pre>', element);
348
- return;
349
- }
350
- indexMap.set(element, frameIndex);
351
- frameIndex += 1;
352
- observer.current?.observe(element);
353
- }
558
+ const io = new IntersectionObserver(handleIntersection, {
559
+ rootMargin: hydrateMargin
354
560
  });
355
- if (ref) {
356
- if (typeof ref === 'function') {
357
- ref(root);
358
- } else {
359
- ref.current = root;
360
- }
561
+ observer.current = io;
562
+
563
+ // Sweep any spans that detached between the previous IO's teardown
564
+ // and this one, then start observing every frame whose ref callback
565
+ // has registered it.
566
+ sweepDetachedFrames();
567
+ observedFrames.current.forEach(frame => io.observe(frame));
568
+
569
+ // Watch the `<pre>` itself for size changes (CSS-driven collapse
570
+ // animations resize ancestors, accordions/tabs swap layout). When
571
+ // the pre resizes, re-observe every frame so the IO re-evaluates
572
+ // their clipped-vs-unclipped state. Guarded so older runtimes (and
573
+ // JSDOM in unit tests) without ResizeObserver still work.
574
+ let ro = null;
575
+ if (typeof ResizeObserver !== 'undefined') {
576
+ ro = new ResizeObserver(nudgeFrameObserver);
577
+ ro.observe(preNode);
361
578
  }
362
- }, [ref, hydrateMargin]);
579
+
580
+ // Native <details> toggle events do not bubble, but a capture-phase
581
+ // listener on the document still intercepts them. A single shared
582
+ // listener (see `subscribeToggleNudge`) fans the event out only to
583
+ // mounted `<Pre>` instances whose `<pre>` is a descendant of the
584
+ // toggled element, so unrelated toggles elsewhere in the document
585
+ // don't trigger any per-instance work.
586
+ const unsubscribeToggle = subscribeToggleNudge(preNode, nudgeFrameObserver);
587
+ return () => {
588
+ io.disconnect();
589
+ observer.current = null;
590
+ ro?.disconnect();
591
+ unsubscribeToggle();
592
+ };
593
+ }, [preNode, hydrateMargin, handleIntersection, nudgeFrameObserver, sweepDetachedFrames]);
363
594
  const observeFrame = React.useCallback(node => {
364
- if (node) {
365
- // Derive frame index from DOM position among .frame siblings.
366
- // This avoids putting data-frame in server-rendered HTML.
367
- let index = 0;
368
- let sibling = node.previousElementSibling;
369
- while (sibling) {
370
- if (sibling.classList.contains('frame')) {
371
- index += 1;
372
- }
373
- sibling = sibling.previousElementSibling;
374
- }
375
- frameIndexMap.current.set(node, index);
376
- if (observer.current) {
377
- observer.current.observe(node);
595
+ if (!node) {
596
+ // React 17/18 invoke ref callbacks with `null` on detach but
597
+ // ignore the cleanup return value below, and a single shared
598
+ // callback can't tell which span detached. Prune any tracked
599
+ // frame that's no longer in the DOM so detached nodes don't
600
+ // accumulate strongly-referenced inside `observedFrames` for
601
+ // the lifetime of the `<Pre>` instance.
602
+ sweepDetachedFrames();
603
+ return undefined;
604
+ }
605
+ // Derive frame index from DOM position among .frame siblings.
606
+ // This avoids putting data-frame in server-rendered HTML.
607
+ let index = 0;
608
+ let sibling = node.previousElementSibling;
609
+ while (sibling) {
610
+ if (sibling.classList.contains('frame')) {
611
+ index += 1;
378
612
  }
613
+ sibling = sibling.previousElementSibling;
379
614
  }
380
- }, []);
615
+ frameIndexMap.current.set(node, index);
616
+ observedFrames.current.add(node);
617
+ if (observer.current) {
618
+ observer.current.observe(node);
619
+ }
620
+ // React 19 ref-callback cleanup. On React 17/18 the return value is
621
+ // ignored; the `if (!node)` branch above + `sweepDetachedFrames`
622
+ // (also called from `nudgeFrameObserver` and the IO setup effect)
623
+ // drop entries whose `node.isConnected` is false, so the tracking
624
+ // sets stay bounded on those versions too.
625
+ return () => {
626
+ observedFrames.current.delete(node);
627
+ frameIndexMap.current.delete(node);
628
+ observer.current?.unobserve(node);
629
+ };
630
+ }, [sweepDetachedFrames]);
381
631
  const frames = React.useMemo(() => {
382
632
  let frameIndex = 0;
383
633
  return hast?.children.map((child, index) => {
@@ -505,7 +755,7 @@ export function Pre({
505
755
  // useEditable). jsx-a11y can't see that, so disable its rule here.
506
756
  // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
507
757
  _jsx("pre", {
508
- ref: bindIntersectionObserver,
758
+ ref: bindPre,
509
759
  className: className,
510
760
  spellCheck: false,
511
761
  tabIndex: isEditable ? -1 : undefined,