@beyondwork/docx-react-component 1.0.87 → 1.0.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.87",
4
+ "version": "1.0.88",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -124,6 +124,7 @@ import {
124
124
  MAIN_STORY_TARGET,
125
125
  storyTargetsEqual,
126
126
  type EditorAnchorProjection as InternalEditorAnchorProjection,
127
+ type TransactionMapping,
127
128
  } from "../core/selection/mapping.ts";
128
129
  import {
129
130
  toInternalAnchorProjection,
@@ -1174,6 +1175,8 @@ export function createDocumentRuntime(
1174
1175
  // to a single microtask-deferred emit. Declared early because emit() (which
1175
1176
  // checks this flag) runs during construction.
1176
1177
  let analyticsEmitScheduled = false;
1178
+ let analyticsEmitScheduleMode: "none" | "microtask" | "idle" = "none";
1179
+ let deferNextContextAnalyticsEmit = false;
1177
1180
 
1178
1181
  // V6c — heading fingerprint for TOC auto-invalidation. Compared in notify();
1179
1182
  // a mismatch schedules a microtask refresh of TOC fields.
@@ -1380,6 +1383,8 @@ export function createDocumentRuntime(
1380
1383
  let viewportBlockRanges: readonly { start: number; end: number }[] | null = null;
1381
1384
  /** Serialized fingerprint of the active ranges — used as the idempotency key for `refreshSurfaceOnly`. */
1382
1385
  let viewportRangesKey: string = serializeViewportRanges(null);
1386
+ const EDITING_CORRIDOR_BLOCK_RADIUS = 8;
1387
+ const EDITING_CORRIDOR_MIN_BLOCKS = 24;
1383
1388
 
1384
1389
  function applyViewportRanges(
1385
1390
  incoming: readonly { start: number; end: number }[] | null,
@@ -1567,19 +1572,35 @@ export function createDocumentRuntime(
1567
1572
  function getCachedSurface(
1568
1573
  document: CanonicalDocumentEnvelope,
1569
1574
  nextActiveStory: EditorStoryTarget,
1575
+ options: {
1576
+ viewportBlockRangesOverride?: readonly { start: number; end: number }[] | null;
1577
+ enrichCulledPlaceholders?: boolean;
1578
+ } = {},
1570
1579
  ): RuntimeRenderSnapshot["surface"] {
1571
1580
  const activeStoryKey = storyTargetKey(nextActiveStory);
1581
+ const surfaceViewportRanges =
1582
+ "viewportBlockRangesOverride" in options
1583
+ ? options.viewportBlockRangesOverride ?? null
1584
+ : viewportBlockRanges;
1585
+ const surfaceViewportRangesKey =
1586
+ "viewportBlockRangesOverride" in options
1587
+ ? serializeViewportRanges(surfaceViewportRanges)
1588
+ : viewportRangesKey;
1589
+ const surfaceCacheKey =
1590
+ options.enrichCulledPlaceholders === false
1591
+ ? `${surfaceViewportRangesKey}|raw-placeholders`
1592
+ : surfaceViewportRangesKey;
1572
1593
  if (
1573
1594
  cachedSurface &&
1574
1595
  cachedSurface.revisionToken === state.revisionToken &&
1575
1596
  cachedSurface.activeStoryKey === activeStoryKey &&
1576
- cachedSurface.viewportRangesKey === viewportRangesKey
1597
+ cachedSurface.viewportRangesKey === surfaceCacheKey
1577
1598
  ) {
1578
1599
  return cachedSurface.snapshot;
1579
1600
  }
1580
1601
 
1581
1602
  const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory, {
1582
- viewportBlockRanges,
1603
+ viewportBlockRanges: surfaceViewportRanges,
1583
1604
  ...(effectiveMarkupModeProvider
1584
1605
  ? { getEffectiveMarkupMode: effectiveMarkupModeProvider }
1585
1606
  : {}),
@@ -1605,20 +1626,57 @@ export function createDocumentRuntime(
1605
1626
  //
1606
1627
  // No-op on pre-pagination calls (engine not yet ready → empty map)
1607
1628
  // and on the inert facet.
1608
- const enrichedSnapshot = enrichCulledPlaceholdersWithHeights(snapshot);
1629
+ const enrichedSnapshot =
1630
+ options.enrichCulledPlaceholders === false
1631
+ ? snapshot
1632
+ : enrichCulledPlaceholdersWithHeights(snapshot);
1609
1633
  cachedSurface = {
1610
1634
  revisionToken: state.revisionToken,
1611
1635
  activeStoryKey,
1612
- viewportRangesKey,
1636
+ viewportRangesKey: surfaceCacheKey,
1613
1637
  snapshot: enrichedSnapshot,
1614
1638
  };
1615
1639
  // Keep the scroll-path fingerprint in lockstep so a subsequent
1616
1640
  // `maybeRefreshSurfaceForViewport` sees the freshly-built snapshot
1617
1641
  // and short-circuits instead of paying a redundant projection.
1618
- cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
1642
+ cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${surfaceCacheKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
1619
1643
  return enrichedSnapshot;
1620
1644
  }
1621
1645
 
1646
+ function getLocalTextCommitViewportRanges(
1647
+ previousSurface: RuntimeRenderSnapshot["surface"] | undefined,
1648
+ ): readonly { start: number; end: number }[] | null {
1649
+ if (!previousSurface || previousSurface.blocks.length < EDITING_CORRIDOR_MIN_BLOCKS) {
1650
+ return viewportBlockRanges;
1651
+ }
1652
+ const caretBlockIndex = findSurfaceBlockIndexForSelection(previousSurface, state.selection);
1653
+ if (caretBlockIndex === -1) {
1654
+ return viewportBlockRanges;
1655
+ }
1656
+ const corridor = {
1657
+ start: Math.max(0, caretBlockIndex - EDITING_CORRIDOR_BLOCK_RADIUS),
1658
+ end: Math.min(previousSurface.blocks.length, caretBlockIndex + EDITING_CORRIDOR_BLOCK_RADIUS + 1),
1659
+ };
1660
+ return normalizeViewportRanges([...(viewportBlockRanges ?? []), corridor]);
1661
+ }
1662
+
1663
+ function findSurfaceBlockIndexForSelection(
1664
+ surface: RuntimeRenderSnapshot["surface"],
1665
+ selection: EditorState["selection"],
1666
+ ): number {
1667
+ if (!surface) return -1;
1668
+ const from = Math.min(selection.anchor, selection.head);
1669
+ const to = Math.max(selection.anchor, selection.head);
1670
+ for (let index = 0; index < surface.blocks.length; index += 1) {
1671
+ const block = surface.blocks[index];
1672
+ if (!block) continue;
1673
+ if (to >= block.from && from <= block.to) {
1674
+ return index;
1675
+ }
1676
+ }
1677
+ return -1;
1678
+ }
1679
+
1622
1680
  function enrichCulledPlaceholdersWithHeights(
1623
1681
  snapshot: EditorSurfaceSnapshot,
1624
1682
  ): EditorSurfaceSnapshot {
@@ -2469,6 +2527,57 @@ export function createDocumentRuntime(
2469
2527
  return snapshot;
2470
2528
  }
2471
2529
 
2530
+ function refreshLocalTextCommitSnapshot(
2531
+ surface: RuntimeRenderSnapshot["surface"],
2532
+ mapping: TransactionMapping,
2533
+ ): RuntimeRenderSnapshot {
2534
+ perfCounters.increment("refresh.localTextEquivalent");
2535
+ return {
2536
+ ...cachedRenderSnapshot,
2537
+ documentId: state.documentId,
2538
+ sessionId: state.sessionId,
2539
+ sourceLabel: state.sourceLabel,
2540
+ revisionToken: state.revisionToken,
2541
+ isReady: state.phase === "ready",
2542
+ isDirty: state.isDirty,
2543
+ readOnly: state.readOnly,
2544
+ documentMode: viewState.documentMode,
2545
+ selection: toPublicSelectionSnapshot(state.selection, activeStory),
2546
+ activeStory,
2547
+ documentStats: updateDocumentStatsForLocalTextCommit(
2548
+ cachedRenderSnapshot.documentStats,
2549
+ mapping,
2550
+ ),
2551
+ warnings: cachedRenderSnapshot.warnings,
2552
+ fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
2553
+ commandState: {
2554
+ canUndo: history.past.length > 0,
2555
+ canRedo: history.future.length > 0,
2556
+ readOnly: state.readOnly,
2557
+ },
2558
+ surface,
2559
+ protectionSnapshot,
2560
+ grabbedObjectId: grabState.objectId,
2561
+ };
2562
+ }
2563
+
2564
+ function updateDocumentStatsForLocalTextCommit(
2565
+ previousStats: RuntimeRenderSnapshot["documentStats"],
2566
+ mapping: TransactionMapping,
2567
+ ): RuntimeRenderSnapshot["documentStats"] {
2568
+ if (!previousStats) {
2569
+ return toPublicDocumentStats(state);
2570
+ }
2571
+ let delta = 0;
2572
+ for (const step of mapping.steps) {
2573
+ delta += step.insertSize - Math.max(0, step.to - step.from);
2574
+ }
2575
+ return {
2576
+ ...previousStats,
2577
+ storyLength: Math.max(0, previousStats.storyLength + delta),
2578
+ };
2579
+ }
2580
+
2472
2581
  /**
2473
2582
  * Fingerprint over the inputs that the viewport-refresh fast-path knows
2474
2583
  * about. Used to short-circuit {@link maybeRefreshSurfaceForViewport}
@@ -4892,14 +5001,52 @@ export function createDocumentRuntime(
4892
5001
  history.future = [];
4893
5002
  }
4894
5003
 
4895
- applyTransactionToState(transaction);
5004
+ applyTransactionToState(transaction, { allowLocalTextFastPath: true });
4896
5005
  }
4897
5006
 
4898
5007
  function commitRemote(transaction: EditorTransaction): void {
4899
5008
  applyTransactionToState(transaction);
4900
5009
  }
4901
5010
 
4902
- function applyTransactionToState(transaction: EditorTransaction): void {
5011
+ function shouldUseLocalTextCommitSnapshot(
5012
+ previous: EditorState,
5013
+ next: EditorState,
5014
+ transaction: EditorTransaction,
5015
+ effects: EditorTransaction["effects"],
5016
+ ): boolean {
5017
+ if (activeStory.kind !== "main") return false;
5018
+ if (!transaction.markDirty || transaction.mapping.steps.length === 0) {
5019
+ return false;
5020
+ }
5021
+ if (transaction.mapping.metadata?.invalidatesStructures) {
5022
+ return false;
5023
+ }
5024
+ if (previous.document.subParts !== next.document.subParts) {
5025
+ return false;
5026
+ }
5027
+ if (effects.warningsAdded.length > 0 || effects.warningsCleared.length > 0) {
5028
+ return false;
5029
+ }
5030
+ if ((effects.transientWarnings?.length ?? 0) > 0) {
5031
+ return false;
5032
+ }
5033
+ return (
5034
+ !effects.commentAdded &&
5035
+ !effects.commentResolved &&
5036
+ !effects.commentReopened &&
5037
+ !effects.commentReplyAdded &&
5038
+ !effects.commentBodyEdited &&
5039
+ !effects.changeAccepted &&
5040
+ !effects.changeRejected &&
5041
+ !effects.revisionAuthored &&
5042
+ !effects.commandBlocked
5043
+ );
5044
+ }
5045
+
5046
+ function applyTransactionToState(
5047
+ transaction: EditorTransaction,
5048
+ options: { allowLocalTextFastPath?: boolean } = {},
5049
+ ): void {
4903
5050
  // Pure-no-op short-circuit: when a reducer skipped without emitting any
4904
5051
  // observable effect AND the selection is identical, skip finalizeState
4905
5052
  // / invalidate / refresh / notify entirely. This keeps hot paths (e.g.
@@ -4941,7 +5088,9 @@ export function createDocumentRuntime(
4941
5088
  const previous = state;
4942
5089
 
4943
5090
  const tApply0 = performance.now();
5091
+ const tProtection0 = performance.now();
4944
5092
  protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
5093
+ perfCounters.increment("commit.protectionRemap.us", Math.round((performance.now() - tProtection0) * 1000));
4945
5094
  const tFinalize0 = performance.now();
4946
5095
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
4947
5096
  perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
@@ -4949,8 +5098,10 @@ export function createDocumentRuntime(
4949
5098
  // Re-sync marker-backed scope IDs against the new document; the
4950
5099
  // store handles set computation + telemetry. `replaceOverlay(current, doc)`
4951
5100
  // is idempotent on state and re-derives the marker-backed set.
5101
+ const tOverlay0 = performance.now();
4952
5102
  overlayStore.replaceOverlay(overlayStore.getOverlay(), state.document);
4953
5103
  const detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
5104
+ perfCounters.increment("commit.overlaySync.us", Math.round((performance.now() - tOverlay0) * 1000));
4954
5105
 
4955
5106
  const tInvalidate0 = performance.now();
4956
5107
  if (transaction.markDirty && transaction.mapping.steps.length > 0) {
@@ -4974,12 +5125,37 @@ export function createDocumentRuntime(
4974
5125
  }
4975
5126
  perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
4976
5127
 
5128
+ const notifyEffects: EditorTransaction["effects"] = {
5129
+ ...transaction.effects,
5130
+ warningsAdded: [
5131
+ ...transaction.effects.warningsAdded,
5132
+ ...detachedWorkflowScopeWarnings.added,
5133
+ ],
5134
+ warningsCleared: [
5135
+ ...transaction.effects.warningsCleared,
5136
+ ...detachedWorkflowScopeWarnings.cleared,
5137
+ ],
5138
+ };
5139
+
5140
+ const tClassify0 = performance.now();
5141
+ const useLocalTextCommitSnapshot =
5142
+ options.allowLocalTextFastPath === true &&
5143
+ shouldUseLocalTextCommitSnapshot(
5144
+ previous,
5145
+ state,
5146
+ transaction,
5147
+ notifyEffects,
5148
+ );
5149
+ perfCounters.increment("commit.refreshClassify.us", Math.round((performance.now() - tClassify0) * 1000));
5150
+
4977
5151
  // I5: validate post-mutation selection against the new document bound.
4978
5152
  // First call to getCachedSurface() here computes the post-mutation surface
4979
5153
  // snapshot (cache miss — revisionToken just changed in finalizeState).
4980
- // refreshRenderSnapshot's internal call below hits the cache and reuses it.
4981
- // Net cost: identical to pre-I5 one surface walk per commit, just shifted
4982
- // a few lines earlier so the validator can read storySize.
5154
+ // For local text commits, this is an editing-corridor surface (current
5155
+ // block plus a small halo) so the immediate typing path keeps visible
5156
+ // truth hot without rebuilding offscreen table/comment markup. Structural,
5157
+ // review, and warning commits still use the full current viewport surface
5158
+ // and fall back to refreshRenderSnapshot().
4983
5159
  //
4984
5160
  // Wired ONLY at this chokepoint. The other 12 `cachedRenderSnapshot =
4985
5161
  // refreshRenderSnapshot()` sites in this file are intentionally skipped:
@@ -4999,7 +5175,13 @@ export function createDocumentRuntime(
4999
5175
  // The return type of getCachedSurface is `RuntimeRenderSnapshot["surface"]`
5000
5176
  // which is optional in the public API for shape-only reasons — the helper
5001
5177
  // itself always returns a defined snapshot.
5002
- const surfaceForValidation = getCachedSurface(state.document, activeStory)!;
5178
+ const tValidation0 = performance.now();
5179
+ const surfaceForValidation = useLocalTextCommitSnapshot
5180
+ ? getCachedSurface(state.document, activeStory, {
5181
+ viewportBlockRangesOverride: getLocalTextCommitViewportRanges(cachedRenderSnapshot.surface),
5182
+ enrichCulledPlaceholders: false,
5183
+ })!
5184
+ : getCachedSurface(state.document, activeStory)!;
5003
5185
  const validationOptions = state.selection.activeRange.kind === "node"
5004
5186
  ? {
5005
5187
  isValidNodeTarget: createSurfaceNodeSelectionProbe(surfaceForValidation),
@@ -5015,26 +5197,24 @@ export function createDocumentRuntime(
5015
5197
  state = { ...state, selection: validatedSelection };
5016
5198
  storySelections.set(storyTargetKey(activeStory), state.selection);
5017
5199
  }
5200
+ perfCounters.increment("commit.selectionValidation.us", Math.round((performance.now() - tValidation0) * 1000));
5018
5201
 
5019
5202
  const tRefresh0 = performance.now();
5020
- cachedRenderSnapshot = refreshRenderSnapshot();
5203
+ cachedRenderSnapshot = useLocalTextCommitSnapshot
5204
+ ? refreshLocalTextCommitSnapshot(surfaceForValidation, transaction.mapping)
5205
+ : refreshRenderSnapshot();
5021
5206
  perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
5022
5207
 
5023
5208
  const tNotify0 = performance.now();
5024
- notify(previous, state, {
5025
- ...transaction,
5026
- effects: {
5027
- ...transaction.effects,
5028
- warningsAdded: [
5029
- ...transaction.effects.warningsAdded,
5030
- ...detachedWorkflowScopeWarnings.added,
5031
- ],
5032
- warningsCleared: [
5033
- ...transaction.effects.warningsCleared,
5034
- ...detachedWorkflowScopeWarnings.cleared,
5035
- ],
5036
- },
5037
- });
5209
+ deferNextContextAnalyticsEmit = useLocalTextCommitSnapshot;
5210
+ try {
5211
+ notify(previous, state, {
5212
+ ...transaction,
5213
+ effects: notifyEffects,
5214
+ });
5215
+ } finally {
5216
+ deferNextContextAnalyticsEmit = false;
5217
+ }
5038
5218
  perfCounters.increment("commit.notify.us", Math.round((performance.now() - tNotify0) * 1000));
5039
5219
  perfCounters.increment("commit.total.us", Math.round((performance.now() - tApply0) * 1000));
5040
5220
  emitStageToken(telemetryBus, "commit", "commit.apply.complete", {
@@ -5608,23 +5788,58 @@ export function createDocumentRuntime(
5608
5788
  // synchronous call stack — one per commit. (Flag declared above near
5609
5789
  // perfCounters so it is initialized before construction-time emits run.)
5610
5790
  function scheduleContextAnalyticsEmit(): void {
5791
+ const shouldDefer = deferNextContextAnalyticsEmit;
5792
+ deferNextContextAnalyticsEmit = false;
5611
5793
  if (analyticsEmitScheduled) {
5794
+ if (shouldDefer && analyticsEmitScheduleMode === "microtask") {
5795
+ analyticsEmitScheduleMode = "idle";
5796
+ perfCounters.increment("emit.contextAnalytics.deferred");
5797
+ return;
5798
+ }
5612
5799
  perfCounters.increment("emit.contextAnalytics.coalesced");
5613
5800
  return;
5614
5801
  }
5615
5802
  analyticsEmitScheduled = true;
5616
- queueMicrotask(() => {
5803
+ analyticsEmitScheduleMode = shouldDefer ? "idle" : "microtask";
5804
+ const run = () => {
5617
5805
  // Reset BEFORE the emit so any synchronous re-entrant emits triggered
5618
5806
  // by listener callbacks schedule a fresh second microtask (no lost
5619
5807
  // updates). Reversing this order would drop bursts originating in
5620
5808
  // listeners.
5621
5809
  analyticsEmitScheduled = false;
5810
+ analyticsEmitScheduleMode = "none";
5622
5811
  const t = performance.now();
5623
5812
  emitContextAnalyticsChanged();
5624
5813
  perfCounters.increment("emit.contextAnalytics.us", Math.round((performance.now() - t) * 1000));
5814
+ };
5815
+ if (shouldDefer) {
5816
+ perfCounters.increment("emit.contextAnalytics.deferred");
5817
+ scheduleIdleContextAnalytics(run);
5818
+ return;
5819
+ }
5820
+ queueMicrotask(() => {
5821
+ if (analyticsEmitScheduleMode === "idle") {
5822
+ scheduleIdleContextAnalytics(run);
5823
+ return;
5824
+ }
5825
+ run();
5625
5826
  });
5626
5827
  }
5627
5828
 
5829
+ function scheduleIdleContextAnalytics(callback: () => void): void {
5830
+ const requestIdle = (globalThis as {
5831
+ requestIdleCallback?: (
5832
+ cb: () => void,
5833
+ options?: { timeout?: number },
5834
+ ) => number;
5835
+ }).requestIdleCallback;
5836
+ if (typeof requestIdle === "function") {
5837
+ requestIdle(callback, { timeout: 250 });
5838
+ return;
5839
+ }
5840
+ setTimeout(callback, 0);
5841
+ }
5842
+
5628
5843
  // V6c — TOC auto-refresh scheduler. Mirrors scheduleContextAnalyticsEmit's
5629
5844
  // microtask-coalesce shape. Bursts of heading edits within one synchronous
5630
5845
  // call stack collapse to a single rebuild + a single toc_auto_refreshed
@@ -29,7 +29,7 @@ import type { EditorView } from "prosemirror-view";
29
29
  import type { GeometryFacet } from "../../api/public-types.ts";
30
30
  import {
31
31
  findScrollAnchor,
32
- restoreScrollAnchor,
32
+ resolveScrollTopForAnchor,
33
33
  type ScrollAnchor,
34
34
  } from "./scroll-anchor.ts";
35
35
 
@@ -56,6 +56,13 @@ export interface PreservePositionOptions {
56
56
  * element directly to avoid the DOM lookup.
57
57
  */
58
58
  scrollRoot?: HTMLElement | null;
59
+ /**
60
+ * Optional safety bound for edit-path restores. When set, restores whose
61
+ * target differs from the captured `scrollTop` by more than this many CSS
62
+ * pixels are refused. This keeps scroll preservation narrow for typing
63
+ * rebuilds while still allowing small same-story geometry shifts.
64
+ */
65
+ maxScrollDeltaPx?: number;
59
66
  }
60
67
 
61
68
  export interface PreservedPosition {
@@ -102,11 +109,29 @@ export function capturePosition(
102
109
  export function restorePosition(
103
110
  captured: PreservedPosition,
104
111
  options: PreservePositionOptions,
105
- ): void {
106
- if (!captured.scrollRoot || !captured.anchor) return;
107
- restoreScrollAnchor(captured.scrollRoot, captured.anchor, {
108
- geometryFacet: options.geometryFacet,
109
- });
112
+ ): boolean {
113
+ if (!captured.scrollRoot || !captured.anchor) return false;
114
+ const targetScrollTop = resolveScrollTopForAnchor(
115
+ captured.scrollRoot,
116
+ captured.anchor,
117
+ {
118
+ geometryFacet: options.geometryFacet,
119
+ },
120
+ );
121
+ if (targetScrollTop === null || !Number.isFinite(targetScrollTop)) {
122
+ return false;
123
+ }
124
+ if (targetScrollTop < 0) {
125
+ return false;
126
+ }
127
+ if (
128
+ options.maxScrollDeltaPx !== undefined &&
129
+ Math.abs(targetScrollTop - captured.scrollTop) > options.maxScrollDeltaPx
130
+ ) {
131
+ return false;
132
+ }
133
+ captured.scrollRoot.scrollTop = targetScrollTop;
134
+ return true;
110
135
  }
111
136
 
112
137
  /**
@@ -137,7 +137,23 @@ export function restoreScrollAnchor(
137
137
  anchor: ScrollAnchor | null,
138
138
  options?: FindScrollAnchorOptions,
139
139
  ): void {
140
- if (!root || !anchor) return;
140
+ const targetScrollTop = resolveScrollTopForAnchor(root, anchor, options);
141
+ if (root && targetScrollTop !== null) {
142
+ root.scrollTop = targetScrollTop;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Resolve the `scrollTop` that would restore `anchor` without mutating the
148
+ * scroll container. Used by the edit-path preservation guard so it can reject
149
+ * unsafe restores before writing scroll state.
150
+ */
151
+ export function resolveScrollTopForAnchor(
152
+ root: HTMLElement | null,
153
+ anchor: ScrollAnchor | null,
154
+ options?: FindScrollAnchorOptions,
155
+ ): number | null {
156
+ if (!root || !anchor) return null;
141
157
 
142
158
  if (options?.geometryFacet && !options.prepaintFallback) {
143
159
  const geometry = options.geometryFacet.getBlock(anchor.blockId);
@@ -150,15 +166,14 @@ export function restoreScrollAnchor(
150
166
  // newScrollTop = newBlockTop + offsetWithinBlock.
151
167
  // Matches the DOM-path formula below (round-trip verified by
152
168
  // `test/ui/mode-toggle-scroll-anchor.test.ts`).
153
- root.scrollTop = rect.topPx + anchor.offsetWithinBlock;
154
- return;
169
+ return rect.topPx + anchor.offsetWithinBlock;
155
170
  }
156
171
  // No block match through facet; fall through to DOM path.
157
172
  }
158
173
 
159
174
  const selector = `[data-block-id="${cssEscape(anchor.blockId)}"]`;
160
175
  const block = root.querySelector<HTMLElement>(selector);
161
- if (!block) return;
176
+ if (!block) return null;
162
177
  // Cold-open / pre-paint DOM fallback — same rationale as
163
178
  // findScrollAnchor's fallback above.
164
179
  // geometry:allow-dom-fallback
@@ -171,7 +186,7 @@ export function restoreScrollAnchor(
171
186
  // capture time). Scrolling by `delta` shifts rects by `-delta`, so
172
187
  // solve for delta: delta = blockRect.top - rootRect.top + offsetWithinBlock.
173
188
  const delta = blockRect.top - rootRect.top + anchor.offsetWithinBlock;
174
- root.scrollTop = root.scrollTop + delta;
189
+ return root.scrollTop + delta;
175
190
  }
176
191
 
177
192
  /**
@@ -72,6 +72,7 @@ import { createPredictedTxGate } from "./predicted-tx-gate";
72
72
  import { replaceStatePreservingPosition } from "./preserve-position";
73
73
  import {
74
74
  createScopeTagRegistry,
75
+ storyTargetsEqual,
75
76
  type ScopeTagRegistry,
76
77
  } from "../../api/public-types";
77
78
  import { hasBailIfCrossedTagInRange } from "./predicted-tag-preflight";
@@ -92,6 +93,24 @@ import { chartNodeViews } from "./chart-node-view.tsx";
92
93
  import type { SelectionToolbarAnchor } from "../../ui/headless/selection-toolbar-model";
93
94
  import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
94
95
 
96
+ type RebuildScrollAnchorPolicy = "bounded-same-story";
97
+
98
+ const BOUNDED_TYPING_SCROLL_RESTORE_MAX_DELTA_PX = 256;
99
+
100
+ export function shouldPreserveScrollAnchorForRebuild(options: {
101
+ policy: RebuildScrollAnchorPolicy | null;
102
+ view: Pick<EditorView, "hasFocus"> | null;
103
+ geometryFacet?: import("../../runtime/geometry/index.ts").GeometryFacet;
104
+ previousStory: EditorStoryTarget | null;
105
+ nextStory: EditorStoryTarget;
106
+ }): boolean {
107
+ if (options.policy !== "bounded-same-story") return false;
108
+ if (!options.view?.hasFocus()) return false;
109
+ if (!options.geometryFacet) return false;
110
+ if (!options.previousStory) return false;
111
+ return storyTargetsEqual(options.previousStory, options.nextStory);
112
+ }
113
+
95
114
  /**
96
115
  * Build page-break widget decorations from the layout facet's current page
97
116
  * graph. Returns `[]` when the facet is unavailable, the active story is
@@ -435,6 +454,8 @@ export const TwProseMirrorSurface = forwardRef<
435
454
  const sessionRef = useRef<import("./local-edit-session-state").LocalEditSessionState | null>(null);
436
455
  const laneRef = useRef<import("./fast-text-edit-lane").FastTextEditLane | null>(null);
437
456
  const equivalentAckLedgerRef = useRef<Map<string, string>>(new Map());
457
+ const pendingRebuildScrollAnchorRef = useRef<RebuildScrollAnchorPolicy | null>(null);
458
+ const lastBuiltStoryRef = useRef<EditorStoryTarget | null>(null);
438
459
  const selectionToolbarFrameRef = useRef<number | null>(null);
439
460
  const lastSelectionToolbarMeasurementRef = useRef<{
440
461
  key: string | null;
@@ -766,6 +787,7 @@ export const TwProseMirrorSurface = forwardRef<
766
787
  if (!props.dispatchRuntimeCommand || !sessionRef.current) {
767
788
  laneRef.current = null;
768
789
  equivalentAckLedgerRef.current.clear();
790
+ pendingRebuildScrollAnchorRef.current = null;
769
791
  return;
770
792
  }
771
793
  // Wave 1 Slice E1/E2 — lane observability.
@@ -811,6 +833,7 @@ export const TwProseMirrorSurface = forwardRef<
811
833
  );
812
834
  },
813
835
  onEquivalentAck: (ack) => {
836
+ pendingRebuildScrollAnchorRef.current = null;
814
837
  if (
815
838
  ack.opId &&
816
839
  ack.newRevisionToken &&
@@ -821,16 +844,22 @@ export const TwProseMirrorSurface = forwardRef<
821
844
  }
822
845
  equivalentAckLedgerRef.current.clear();
823
846
  },
824
- onAdjustedAck: () => {
847
+ onAdjustedAck: (ack) => {
825
848
  // Adjusted path: allow the rebuild effect to run (it will call
826
849
  // view.updateState with the canonical snapshot).
827
850
  equivalentAckLedgerRef.current.clear();
851
+ pendingRebuildScrollAnchorRef.current =
852
+ getTextCommandRefreshClass(ack) === "surface-only"
853
+ ? "bounded-same-story"
854
+ : null;
828
855
  },
829
856
  onRejectedAck: () => {
830
857
  equivalentAckLedgerRef.current.clear();
858
+ pendingRebuildScrollAnchorRef.current = null;
831
859
  },
832
860
  onStructuralDivergence: () => {
833
861
  equivalentAckLedgerRef.current.clear();
862
+ pendingRebuildScrollAnchorRef.current = null;
834
863
  },
835
864
  });
836
865
  }, [props.dispatchRuntimeCommand, scopeTagRegistry]);
@@ -839,6 +868,7 @@ export const TwProseMirrorSurface = forwardRef<
839
868
  if (!mountRef.current || !surface) return;
840
869
 
841
870
  if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
871
+ pendingRebuildScrollAnchorRef.current = null;
842
872
  return;
843
873
  }
844
874
 
@@ -866,6 +896,8 @@ export const TwProseMirrorSurface = forwardRef<
866
896
  documentBuildKeyRef.current = documentBuildKey;
867
897
  applyDecorationProps(viewRef.current, positionMapRef.current);
868
898
  equivalentAckLedgerRef.current.delete(snapshot.revisionToken);
899
+ pendingRebuildScrollAnchorRef.current = null;
900
+ lastBuiltStoryRef.current = snapshot.activeStory;
869
901
  if (pendingTypingProbeRef.current) {
870
902
  finishPerfProbe(pendingTypingProbeRef.current);
871
903
  pendingTypingProbeRef.current = null;
@@ -921,24 +953,37 @@ export const TwProseMirrorSurface = forwardRef<
921
953
  // AFTER, so PM's internal selection-change events during the
922
954
  // swap are swallowed by the selection-sync plugin.
923
955
  //
924
- // Scroll-anchor preservation (`preserveScrollAnchor: true`) is
925
- // currently OFF by default after the 2026-04-24 jump-to-top
926
- // regression report (see hotfix commit). Re-enable under a
927
- // diagnosed-safe codepath only; the capture/restore helpers
928
- // remain tested and ready.
956
+ // Scroll-anchor preservation is narrowly re-enabled only when a
957
+ // predicted edit receives a `surface-only` adjusted ack and the
958
+ // replacement stays in the same focused story with a live geometry
959
+ // facet. `maxScrollDeltaPx` is the guardrail: if the anchor target
960
+ // would move by more than the small local-edit budget, the helper
961
+ // refuses the restore and leaves the browser/runtime position alone.
929
962
  //
930
963
  // Ordering invariant is regression-guarded by
931
964
  // `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
965
+ const scrollAnchorPolicy = pendingRebuildScrollAnchorRef.current;
966
+ pendingRebuildScrollAnchorRef.current = null;
967
+ const preserveScrollAnchor = shouldPreserveScrollAnchorForRebuild({
968
+ policy: scrollAnchorPolicy,
969
+ view: viewRef.current,
970
+ geometryFacet: props.geometryFacet,
971
+ previousStory: lastBuiltStoryRef.current,
972
+ nextStory: snapshot.activeStory,
973
+ });
932
974
  replaceStatePreservingPosition(
933
975
  {
934
976
  view: viewRef.current,
935
977
  geometryFacet: props.geometryFacet,
936
978
  suppressionRef: suppressSelectionEchoRef,
979
+ preserveScrollAnchor,
980
+ maxScrollDeltaPx: BOUNDED_TYPING_SCROLL_RESTORE_MAX_DELTA_PX,
937
981
  },
938
982
  state,
939
983
  );
940
984
  }
941
985
  documentBuildKeyRef.current = documentBuildKey;
986
+ lastBuiltStoryRef.current = snapshot.activeStory;
942
987
  applyDecorationProps(viewRef.current, positionMap);
943
988
 
944
989
  if (activeSearchRef.current) {