@beyondwork/docx-react-component 1.0.75 → 1.0.76

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.75",
4
+ "version": "1.0.76",
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": [
@@ -123,7 +123,7 @@ export function createFastTextEditLane(
123
123
  const fromRuntime = positionMap.pmToRuntime(fromPm);
124
124
  const toRuntime = positionMap.pmToRuntime(toPm);
125
125
 
126
- pushLaneDebug({
126
+ const debugEntry = pushLaneDebug({
127
127
  opId,
128
128
  intent: intent.kind,
129
129
  pmFrom: fromPm,
@@ -137,6 +137,7 @@ export function createFastTextEditLane(
137
137
  if (options.shouldBailBeforePredict?.(intent, fromRuntime, toRuntime)) {
138
138
  const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
139
139
  incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.bailBeforePredict);
140
+ markLaneDebugReconciled(debugEntry, ack.kind, true);
140
141
  options.probe?.markReconciled(opId, ack.kind);
141
142
  switch (ack.kind) {
142
143
  case "equivalent":
@@ -183,6 +184,7 @@ export function createFastTextEditLane(
183
184
  op.predictedSelectionHead = view.state.selection.head;
184
185
 
185
186
  const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
187
+ markLaneDebugReconciled(debugEntry, ack.kind, false);
186
188
  options.probe?.markReconciled(opId, ack.kind);
187
189
 
188
190
  switch (ack.kind) {
@@ -280,6 +282,14 @@ interface LaneDebugEntry {
280
282
  runtimeStorySize: number;
281
283
  fromRuntime: number;
282
284
  toRuntime: number;
285
+ /** Dispatch → reconcile observation. Filled by `markLaneDebugReconciled`. */
286
+ ackKind?: TextCommandAck["kind"];
287
+ /** Wall-clock ms between `pushLaneDebug` and `markLaneDebugReconciled`. */
288
+ reconcileMs?: number;
289
+ /** Whether the lane short-circuited to dispatch-only (no predicted TX). */
290
+ bailed?: boolean;
291
+ /** Wall-clock timestamp at push time — used to compute reconcileMs. */
292
+ startedAtMs: number;
283
293
  }
284
294
 
285
295
  declare global {
@@ -294,19 +304,48 @@ declare global {
294
304
  * buffer is capped at 200 entries; consumers can read it from the browser
295
305
  * console to diagnose cursor position mismatches between PM and the runtime.
296
306
  *
307
+ * Returns the pushed entry (or `null` when the buffer isn't enabled) so
308
+ * the caller can mutate it in place with ack-kind + reconcile timing
309
+ * via `markLaneDebugReconciled` once the runtime dispatch returns.
310
+ *
297
311
  * To enable in the browser console:
298
312
  * window.__DOCX_LANE_DEBUG__ = [];
299
313
  * Then type, then:
300
314
  * JSON.stringify(window.__DOCX_LANE_DEBUG__, null, 2)
301
315
  */
302
- function pushLaneDebug(entry: LaneDebugEntry): void {
303
- if (typeof window === "undefined") return;
316
+ function pushLaneDebug(
317
+ entry: Omit<LaneDebugEntry, "startedAtMs">,
318
+ ): LaneDebugEntry | null {
319
+ if (typeof window === "undefined") return null;
304
320
  const buffer = window.__DOCX_LANE_DEBUG__;
305
- if (!Array.isArray(buffer)) return;
306
- buffer.push(entry);
321
+ if (!Array.isArray(buffer)) return null;
322
+ const full: LaneDebugEntry = {
323
+ ...entry,
324
+ startedAtMs:
325
+ typeof performance !== "undefined" && typeof performance.now === "function"
326
+ ? performance.now()
327
+ : Date.now(),
328
+ };
329
+ buffer.push(full);
307
330
  if (buffer.length > 200) {
308
331
  buffer.splice(0, buffer.length - 200);
309
332
  }
333
+ return full;
334
+ }
335
+
336
+ function markLaneDebugReconciled(
337
+ entry: LaneDebugEntry | null,
338
+ ackKind: TextCommandAck["kind"],
339
+ bailed: boolean,
340
+ ): void {
341
+ if (!entry) return;
342
+ entry.ackKind = ackKind;
343
+ entry.bailed = bailed;
344
+ const now =
345
+ typeof performance !== "undefined" && typeof performance.now === "function"
346
+ ? performance.now()
347
+ : Date.now();
348
+ entry.reconcileMs = now - entry.startedAtMs;
310
349
  }
311
350
 
312
351
  function buildTxCompat(
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Snapshot-replacement position-preservation funnel.
3
+ *
4
+ * `view.updateState()` replaces the PM document wholesale after an
5
+ * `adjusted` / `rejected` / `structural-divergence` ack, after a
6
+ * progressive-surface swap, or after any structural edit that falls off
7
+ * the predicted-lane short-circuit. Between the capture and restore
8
+ * points, the scroll container's `scrollTop` may end up pointing at a
9
+ * different document position because blocks above the viewport changed
10
+ * height. This helper wraps a replacement `fn` so the anchor block
11
+ * stays at the same viewport-Y before and after.
12
+ *
13
+ * The helper builds on `findScrollAnchor` / `restoreScrollAnchor`
14
+ * (scroll-anchor.ts) — those already honor the
15
+ * `geometry:allow-dom-fallback` discipline: geometry facet first, DOM
16
+ * `getBoundingClientRect` only when the facet can't resolve. The helper
17
+ * inherits that discipline so the per-keystroke path never measures DOM
18
+ * (performance invariant 7); DOM reads fire only on the cold-open
19
+ * branch before the render kernel's first frame.
20
+ *
21
+ * Focus preservation: `EditorView.updateState` already preserves DOM
22
+ * focus on the view's root when the prior state was focused. This helper
23
+ * does not force focus back — it only captures `hadFocus` for
24
+ * observability so tests can assert focus invariants externally.
25
+ */
26
+
27
+ import type { EditorView } from "prosemirror-view";
28
+
29
+ import type { GeometryFacet } from "../../api/public-types.ts";
30
+ import {
31
+ findScrollAnchor,
32
+ restoreScrollAnchor,
33
+ type ScrollAnchor,
34
+ } from "./scroll-anchor.ts";
35
+
36
+ /**
37
+ * Scroll-container discovery selector — matches the shell's scroll root
38
+ * marker. Kept in sync with the selector used by
39
+ * `tw-prosemirror-surface.tsx` selection-toolbar anchoring and
40
+ * `tw-review-workspace.tsx` mode-toggle anchoring.
41
+ */
42
+ const SCROLL_ROOT_SELECTOR = "[data-wre-scroll-root='true']";
43
+
44
+ export interface PreservePositionOptions {
45
+ /** The PM view whose state is about to be replaced. */
46
+ view: EditorView;
47
+ /**
48
+ * Geometry facet from the runtime. When supplied, capture + restore
49
+ * read block rects from the render kernel instead of the DOM (warm
50
+ * path — no layout thrashing).
51
+ */
52
+ geometryFacet?: GeometryFacet;
53
+ /**
54
+ * Explicit scroll-container override. Defaults to
55
+ * `view.dom.closest("[data-wre-scroll-root='true']")`. Tests pass an
56
+ * element directly to avoid the DOM lookup.
57
+ */
58
+ scrollRoot?: HTMLElement | null;
59
+ }
60
+
61
+ export interface PreservedPosition {
62
+ scrollRoot: HTMLElement | null;
63
+ anchor: ScrollAnchor | null;
64
+ /** `scrollRoot.scrollTop` at capture time. Retained for regression-test assertions. */
65
+ scrollTop: number;
66
+ /** Whether the view held DOM focus at capture time. Observability only. */
67
+ hadFocus: boolean;
68
+ }
69
+
70
+ /**
71
+ * Capture the scroll-anchor state of the view's scroll container.
72
+ *
73
+ * Returns a record with `anchor: null` when no `[data-block-id]`
74
+ * descendants exist (typical for empty docs or pre-mount), which makes
75
+ * the paired `restorePosition` call a graceful no-op.
76
+ */
77
+ export function capturePosition(
78
+ options: PreservePositionOptions,
79
+ ): PreservedPosition {
80
+ const scrollRoot = resolveScrollRoot(options);
81
+ const anchor = findScrollAnchor(scrollRoot, {
82
+ geometryFacet: options.geometryFacet,
83
+ });
84
+ return {
85
+ scrollRoot,
86
+ anchor,
87
+ scrollTop: scrollRoot ? scrollRoot.scrollTop : 0,
88
+ hadFocus: options.view.hasFocus(),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Restore the scroll container's position so the anchor block sits at
94
+ * the same viewport-Y it had at capture time.
95
+ *
96
+ * Graceful no-op when the scroll root is gone, when no anchor was
97
+ * captured, or when the anchor's block no longer exists in the new
98
+ * document (e.g. the block was deleted by the replacement). The
99
+ * scroll-anchor helpers in scroll-anchor.ts handle each of these cases
100
+ * internally.
101
+ */
102
+ export function restorePosition(
103
+ captured: PreservedPosition,
104
+ options: PreservePositionOptions,
105
+ ): void {
106
+ if (!captured.scrollRoot || !captured.anchor) return;
107
+ restoreScrollAnchor(captured.scrollRoot, captured.anchor, {
108
+ geometryFacet: options.geometryFacet,
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Execute `fn` with position preservation. Captures the scroll-anchor
114
+ * before `fn` runs, invokes `fn` synchronously, then restores the
115
+ * anchor so the user's viewport lands on the same block.
116
+ *
117
+ * Returns `fn`'s return value so callers can thread state through
118
+ * without a wrapping closure.
119
+ *
120
+ * Intentionally synchronous — async variants would require a
121
+ * two-step microtask dance we don't need today. When a caller needs a
122
+ * deferred restore (e.g. to wait for a second paint), use
123
+ * `capturePosition` / `restorePosition` directly and schedule the
124
+ * restore via `requestAnimationFrame` like `tw-review-workspace.tsx`
125
+ * does for the mode toggle.
126
+ */
127
+ export function preservePosition<T>(
128
+ options: PreservePositionOptions,
129
+ fn: () => T,
130
+ ): T {
131
+ const captured = capturePosition(options);
132
+ const result = fn();
133
+ restorePosition(captured, options);
134
+ return result;
135
+ }
136
+
137
+ function resolveScrollRoot(
138
+ options: PreservePositionOptions,
139
+ ): HTMLElement | null {
140
+ if (options.scrollRoot !== undefined) return options.scrollRoot;
141
+ const dom = options.view.dom;
142
+ if (!(dom instanceof HTMLElement)) return null;
143
+ return dom.closest<HTMLElement>(SCROLL_ROOT_SELECTOR);
144
+ }
145
+
146
+ /**
147
+ * Mutable ref shape for the echo-suppression flag. Kept minimal so
148
+ * callers can pass a `useRef` object or a stub in tests without
149
+ * importing React types here.
150
+ */
151
+ export interface EchoSuppressionRef {
152
+ current: boolean;
153
+ }
154
+
155
+ export interface ReplaceStateOptions extends PreservePositionOptions {
156
+ /**
157
+ * Ref whose `.current` is read by the selection-sync plugin. The
158
+ * helper sets it to `true` before the replacement and releases it to
159
+ * `false` inside a microtask that runs AFTER the state swap — the
160
+ * ordering invariant tested by
161
+ * `preserve-position-ordering.test.ts`.
162
+ */
163
+ suppressionRef: EchoSuppressionRef;
164
+ /**
165
+ * Microtask scheduler. Defaults to the global `queueMicrotask`.
166
+ * Tests override it to capture the release callback.
167
+ */
168
+ scheduleMicrotask?: (callback: () => void) => void;
169
+ }
170
+
171
+ /**
172
+ * Replace the view's state with `newState` while preserving the user's
173
+ * scroll position AND suppressing the selection-sync echo that would
174
+ * otherwise fire when PM dispatches its internal selection-change
175
+ * notifications during the swap.
176
+ *
177
+ * Ordering invariant (regression-guarded):
178
+ *
179
+ * 1. capture scroll anchor
180
+ * 2. suppressionRef.current = true
181
+ * 3. view.updateState(newState) ← PM may fire selection events here
182
+ * 4. restore scroll anchor
183
+ * 5. queueMicrotask(() => suppressionRef.current = false)
184
+ *
185
+ * The microtask release guarantees the flag is still `true` for any
186
+ * synchronous selection-change handler that fires during (3), and
187
+ * false by the time any subsequent user-initiated selection change
188
+ * reaches the selection-sync plugin.
189
+ *
190
+ * Using `queueMicrotask` rather than `setTimeout(..., 0)` /
191
+ * `requestAnimationFrame` is deliberate — a later scheduler would
192
+ * leave the flag stuck true across a macrotask boundary, causing
193
+ * legitimate post-swap selection changes to be swallowed.
194
+ */
195
+ export function replaceStatePreservingPosition(
196
+ options: ReplaceStateOptions,
197
+ newState: import("prosemirror-state").EditorState,
198
+ ): void {
199
+ const preserved = capturePosition(options);
200
+ options.suppressionRef.current = true;
201
+ options.view.updateState(newState);
202
+ restorePosition(preserved, options);
203
+ const release = () => {
204
+ options.suppressionRef.current = false;
205
+ };
206
+ if (options.scheduleMicrotask) {
207
+ options.scheduleMicrotask(release);
208
+ } else {
209
+ queueMicrotask(release);
210
+ }
211
+ }
@@ -143,11 +143,14 @@ export function restoreScrollAnchor(
143
143
  const geometry = options.geometryFacet.getBlock(anchor.blockId);
144
144
  if (geometry && geometry.rects.length > 0) {
145
145
  const rect = geometry.rects[0]!;
146
- // We want, post-restore, the frame-local y of the block's top to
147
- // land at `scrollTop + offsetWithinBlock`-below-viewport-top.
148
- // Equivalently, set scrollTop so that the block's top is
149
- // `offsetWithinBlock` above it.
150
- root.scrollTop = rect.topPx - anchor.offsetWithinBlock;
146
+ // `offsetWithinBlock = viewportTopFramePx - blockTop` at capture
147
+ // time (see `findScrollAnchor` above), i.e. how far INTO the
148
+ // block the viewport top sat. To land the viewport at the same
149
+ // relative point inside the block in the new frame:
150
+ // newScrollTop = newBlockTop + offsetWithinBlock.
151
+ // Matches the DOM-path formula below (round-trip verified by
152
+ // `test/ui/mode-toggle-scroll-anchor.test.ts`).
153
+ root.scrollTop = rect.topPx + anchor.offsetWithinBlock;
151
154
  return;
152
155
  }
153
156
  // No block match through facet; fall through to DOM path.
@@ -65,6 +65,7 @@ import { buildPositionMap, type PositionMap } from "./pm-position-map";
65
65
  import { createLocalEditSessionState } from "./local-edit-session-state";
66
66
  import { createFastTextEditLane } from "./fast-text-edit-lane";
67
67
  import { createPredictedTxGate } from "./predicted-tx-gate";
68
+ import { replaceStatePreservingPosition } from "./preserve-position";
68
69
  import {
69
70
  createScopeTagRegistry,
70
71
  type ScopeTagRegistry,
@@ -772,11 +773,35 @@ export const TwProseMirrorSurface = forwardRef<
772
773
  laneRef.current = null;
773
774
  return;
774
775
  }
776
+ // Wave 1 Slice E1/E2 — lane observability.
777
+ //
778
+ // `typing.reconcile` measures the dispatch → ack window per keystroke
779
+ // (predicted path). `typing.divergence` fires on the
780
+ // structural-divergence ack kind (the rollback-all path). Both probe
781
+ // kinds are declared in `PerfProbeKind` and were previously
782
+ // unemitted — wiring them here closes the instrumentation gap so
783
+ // lane quality regressions show up in the standard perf summary.
784
+ const pendingReconcileTokens = new Map<string, string | null>();
775
785
  laneRef.current = createFastTextEditLane({
776
786
  session: sessionRef.current,
777
787
  getView: () => viewRef.current,
778
788
  getPositionMap: () => positionMapRef.current,
779
789
  dispatchRuntimeCommand: props.dispatchRuntimeCommand,
790
+ probe: {
791
+ markPredicted(opId: string) {
792
+ pendingReconcileTokens.set(opId, startPerfProbe("typing.reconcile"));
793
+ },
794
+ markReconciled(opId: string, kind) {
795
+ const token = pendingReconcileTokens.get(opId);
796
+ if (token !== undefined) {
797
+ finishPerfProbe(token);
798
+ pendingReconcileTokens.delete(opId);
799
+ }
800
+ if (kind === "structural-divergence") {
801
+ recordPerfSample("typing.divergence");
802
+ }
803
+ },
804
+ },
780
805
  suppressSelectionSync: (suppressed) => {
781
806
  suppressSelectionEchoRef.current = suppressed;
782
807
  },
@@ -891,11 +916,30 @@ export const TwProseMirrorSurface = forwardRef<
891
916
  viewRef.current = view;
892
917
  recordPerfSample("pm.mount");
893
918
  } else {
894
- suppressSelectionEchoRef.current = true;
895
- viewRef.current.updateState(state);
896
- queueMicrotask(() => {
897
- suppressSelectionEchoRef.current = false;
898
- });
919
+ // Wave 1 Slice C · the single funnel for snapshot replacement.
920
+ //
921
+ // `replaceStatePreservingPosition` encapsulates two invariants:
922
+ // 1. Scroll position preservation — capture the anchor block
923
+ // before `view.updateState`, restore scroll after, so the
924
+ // user's viewport doesn't jump when blocks above change
925
+ // height (invariant 7: geometry-facet warm path, no DOM
926
+ // measurement on the hot path).
927
+ // 2. Echo-suppression ordering — `suppressSelectionEchoRef` is
928
+ // set to `true` BEFORE the state swap and released in a
929
+ // microtask AFTER, so PM's internal selection-change events
930
+ // during the swap are swallowed by the selection-sync
931
+ // plugin.
932
+ //
933
+ // Ordering is regression-guarded by
934
+ // `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
935
+ replaceStatePreservingPosition(
936
+ {
937
+ view: viewRef.current,
938
+ geometryFacet: props.geometryFacet,
939
+ suppressionRef: suppressSelectionEchoRef,
940
+ },
941
+ state,
942
+ );
899
943
  }
900
944
  documentBuildKeyRef.current = documentBuildKey;
901
945
  applyDecorationProps(viewRef.current, positionMap);