@beyondwork/docx-react-component 1.0.42 → 1.0.45

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 (82) hide show
  1. package/README.md +17 -0
  2. package/package.json +5 -4
  3. package/src/api/editor-state-types.ts +110 -0
  4. package/src/api/public-types.ts +333 -4
  5. package/src/core/commands/formatting-commands.ts +7 -1
  6. package/src/core/commands/index.ts +60 -10
  7. package/src/core/commands/text-commands.ts +59 -0
  8. package/src/core/search/search-text.ts +15 -2
  9. package/src/core/selection/review-anchors.ts +131 -21
  10. package/src/index.ts +29 -1
  11. package/src/io/chart-preview-resolver.ts +281 -0
  12. package/src/io/docx-session.ts +692 -2
  13. package/src/io/export/build-app-properties-xml.ts +1 -1
  14. package/src/io/export/serialize-comments.ts +38 -9
  15. package/src/io/export/twip.ts +1 -1
  16. package/src/io/load-scheduler.ts +230 -0
  17. package/src/io/normalize/normalize-text.ts +116 -0
  18. package/src/io/ooxml/parse-comments.ts +0 -33
  19. package/src/io/ooxml/parse-complex-content.ts +14 -0
  20. package/src/io/ooxml/parse-main-document.ts +4 -0
  21. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  22. package/src/io/ooxml/workflow-payload.ts +172 -1
  23. package/src/preservation/opaque-region.ts +5 -0
  24. package/src/review/store/comment-remapping.ts +2 -2
  25. package/src/runtime/collab-session.ts +1 -1
  26. package/src/runtime/document-runtime.ts +661 -42
  27. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  28. package/src/runtime/edit-dispatch/index.ts +2 -0
  29. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  30. package/src/runtime/editor-state-channel.ts +544 -0
  31. package/src/runtime/editor-state-integration.ts +217 -0
  32. package/src/runtime/editor-surface/capabilities.ts +411 -0
  33. package/src/runtime/layout/index.ts +2 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +63 -2
  36. package/src/runtime/layout/layout-engine-version.ts +41 -0
  37. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  38. package/src/runtime/layout/public-facet.ts +430 -1
  39. package/src/runtime/perf-counters.ts +28 -0
  40. package/src/runtime/prerender/cache-envelope.ts +29 -0
  41. package/src/runtime/prerender/cache-key.ts +66 -0
  42. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  43. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  44. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  45. package/src/runtime/prerender/prerender-document.ts +145 -0
  46. package/src/runtime/render/block-fragment-projection.ts +2 -0
  47. package/src/runtime/render/render-frame-types.ts +17 -0
  48. package/src/runtime/render/render-kernel.ts +172 -29
  49. package/src/runtime/selection/post-edit-validator.ts +77 -0
  50. package/src/runtime/surface-projection.ts +45 -7
  51. package/src/runtime/workflow-markup.ts +71 -16
  52. package/src/ui/WordReviewEditor.tsx +142 -237
  53. package/src/ui/editor-command-bag.ts +14 -0
  54. package/src/ui/editor-runtime-boundary.ts +115 -12
  55. package/src/ui/editor-shell-view.tsx +10 -0
  56. package/src/ui/editor-surface-controller.tsx +5 -0
  57. package/src/ui/headless/selection-helpers.ts +10 -0
  58. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  59. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  60. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  62. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  63. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  64. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  65. package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
  66. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
  67. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  68. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  69. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  70. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
  71. package/src/ui-tailwind/index.ts +5 -1
  72. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  73. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  74. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  75. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  76. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  77. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  78. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  79. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  80. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  81. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  82. package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
@@ -14,6 +14,8 @@ import {
14
14
  } from "../core/state/editor-state.ts";
15
15
  import type {
16
16
  AddCommentParams,
17
+ AddCommentReplyResult,
18
+ AddCommentResult,
17
19
  CommentSidebarSnapshot,
18
20
  CommentSidebarThreadSnapshot,
19
21
  CompatibilityReport,
@@ -113,6 +115,7 @@ import {
113
115
  type RevisionStore,
114
116
  } from "../review/store/revision-store.ts";
115
117
  import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
118
+ import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
116
119
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
117
120
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
118
121
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
@@ -204,6 +207,30 @@ import {
204
207
  incrementInvalidationCounter,
205
208
  recordPerfSample,
206
209
  } from "../ui-tailwind/editor-surface/perf-probe.ts";
210
+ import {
211
+ createLoadScheduler,
212
+ type LoadScheduler,
213
+ } from "../io/load-scheduler.ts";
214
+ import {
215
+ createEditorStateChannel,
216
+ type EditorStateChannel,
217
+ } from "./editor-state-channel.ts";
218
+ import { PerfCounters } from "./perf-counters.ts";
219
+ import type {
220
+ EditorStateNamespace,
221
+ EditorStatePolicy,
222
+ EditorStateResolver,
223
+ EditorStatePersister,
224
+ } from "../api/editor-state-types.ts";
225
+ import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
226
+ import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
227
+
228
+ /** Internal extension of ExportDocxOptions that threads the collected
229
+ * editorState payload from the runtime to the docx serializer. */
230
+ interface InternalExportDocxOptions extends ExportDocxOptions {
231
+ /** @internal Schema 1.2 — collected by runtime before serialize. */
232
+ _editorState?: EditorStatePayload;
233
+ }
207
234
 
208
235
  export type Unsubscribe = () => void;
209
236
 
@@ -220,6 +247,7 @@ export type ActiveStoryTextCommand =
220
247
  | Extract<EditorCommand, { type: "text.delete-backward" }>
221
248
  | Extract<EditorCommand, { type: "text.delete-forward" }>
222
249
  | Extract<EditorCommand, { type: "text.insert-tab" }>
250
+ | Extract<EditorCommand, { type: "text.outdent-tab" }>
223
251
  | Extract<EditorCommand, { type: "text.insert-hard-break" }>
224
252
  | Extract<EditorCommand, { type: "paragraph.split" }>;
225
253
 
@@ -257,11 +285,11 @@ export interface DocumentRuntime {
257
285
  blur(): void;
258
286
  setDefaultAuthorId?(authorId?: string): void;
259
287
  getDefaultAuthorId?(): string | undefined;
260
- addComment(params: AddCommentParams): string;
288
+ addComment(params: AddCommentParams): AddCommentResult;
261
289
  openComment(commentId: string): void;
262
290
  resolveComment(commentId: string): void;
263
291
  reopenComment(commentId: string): void;
264
- addCommentReply(commentId: string, body: string, authorId?: string): void;
292
+ addCommentReply(commentId: string, body: string, authorId?: string): AddCommentReplyResult;
265
293
  editCommentBody(commentId: string, body: string): void;
266
294
  acceptChange(changeId: string): void;
267
295
  rejectChange(changeId: string): void;
@@ -341,6 +369,38 @@ export interface DocumentRuntime {
341
369
  getRuntimeContextAnalytics(
342
370
  query?: RuntimeContextAnalyticsQuery,
343
371
  ): RuntimeContextAnalyticsSnapshot | null;
372
+ // Schema 1.2 — EditorStateChannel delegation
373
+ configureEditorStatePolicy(policy: EditorStatePolicy): void;
374
+ registerEditorStateResolver(resolver: EditorStateResolver | null): void;
375
+ registerEditorStatePersister(persister: EditorStatePersister | null): void;
376
+ getEditorStateKey(namespace: EditorStateNamespace): string | undefined;
377
+ retryPendingPersist(namespace?: EditorStateNamespace): Promise<void>;
378
+ /** Internal: exposes the channel for load-path hydration in editor-runtime-boundary. */
379
+ readonly editorStateChannel: EditorStateChannel;
380
+ /**
381
+ * L7 render-perf instrumentation. Returns a string→number map of internal
382
+ * perf counters incremented during runtime activity. Used by perf benches
383
+ * and tests to assert that a given facet rebuild ran (or did not run) for
384
+ * a given operation. Counter names are not part of the public API contract
385
+ * — they may be added or renamed across L7 phases.
386
+ */
387
+ getPerfCountersSnapshot(): Record<string, number>;
388
+ resetPerfCounters(): void;
389
+ /**
390
+ * Notifies the runtime that the UI's visible block range changed.
391
+ * The next `refreshRenderSnapshot()` (triggered by any mutation) uses this
392
+ * range to cull off-screen blocks in the surface projection. Calling this
393
+ * method alone does NOT trigger a snapshot rebuild — a subsequent mutation
394
+ * or an explicit `requestViewportRefresh()` is required.
395
+ *
396
+ * Safe to call at any frequency; identical ranges are a no-op.
397
+ */
398
+ setVisibleBlockRange(range: { start: number; end: number }): void;
399
+ /**
400
+ * Triggers a surface-only refresh that applies the latest visible block range
401
+ * without running the full commit pipeline. Used by scroll handlers.
402
+ */
403
+ requestViewportRefresh(): void;
344
404
  }
345
405
 
346
406
  export interface CommandAppliedMeta {
@@ -386,6 +446,27 @@ export interface CreateDocumentRuntimeOptions {
386
446
  ) => void;
387
447
  initialViewState?: Partial<ViewState>;
388
448
  protectionSnapshot?: ProtectionSnapshot;
449
+ /**
450
+ * Optional main-thread time-slicing scheduler. When provided, the runtime
451
+ * schedules post-`ready` idle work (e.g., sub-part hydration, compatibility
452
+ * report — reserved for future fastload passes) through it. When omitted,
453
+ * the runtime falls back to the sync scheduler — behavior is byte-identical
454
+ * to the pre-fastload runtime.
455
+ *
456
+ * See `docs/plans/fastload.md` and `src/io/load-scheduler.ts`.
457
+ */
458
+ loadScheduler?: LoadScheduler;
459
+ /**
460
+ * L7 Phase 2.5 — prerender cache seed. When set, the internal
461
+ * LayoutEngineInstance is seeded with this graph so the first
462
+ * `getPageGraph()` call returns it directly, skipping `fullRebuild`.
463
+ * The seeded graph must have been produced with the current
464
+ * `LAYOUT_ENGINE_VERSION` (callers rely on the cache-key scheme in
465
+ * `src/runtime/prerender/cache-key.ts` to guarantee this).
466
+ *
467
+ * See docs/plans/lane-2-render-performance.md §Phase 2.5 §3.7.
468
+ */
469
+ seedLayoutCache?: import("./layout/page-graph.ts").RuntimePageGraph;
389
470
  }
390
471
 
391
472
  interface HistoryState {
@@ -393,6 +474,84 @@ interface HistoryState {
393
474
  future: EditorState[];
394
475
  }
395
476
 
477
+ /**
478
+ * L7 Phase 1.7.4 — structural-counts hash for the reviewWork cache key.
479
+ *
480
+ * `getCachedWorkflowMarkupSnapshot()` returns a NEW object on every
481
+ * `revisionToken` bump, so the previous reference-equality check
482
+ * (`cachedReviewWork.workflowMarkup === wfMarkup`) always failed on pure
483
+ * text edits — forcing `createReviewWorkSnapshot` to re-walk every comment,
484
+ * revision, protected range, and opaque fragment on every keystroke
485
+ * (94 % of typing latency on large-tables, 97 % on extra-large).
486
+ *
487
+ * The hash is a colon-joined string of per-category item counts. Pure text
488
+ * edits within unchanged blocks do not change any count, so the hash
489
+ * compares equal and the cached snapshot is reused. Structural changes
490
+ * (comment added, revision authored, block inserted/deleted) alter at
491
+ * least one count, so the hash diverges and the cache misses correctly.
492
+ *
493
+ * Correctness: the cached `items` include absolute-position `anchor`s that
494
+ * get stale under a text edit. That is safe here because this cache lives
495
+ * ONLY inside `getCachedRuntimeContextAnalytics`, whose consumers
496
+ * (`buildDocumentAnalytics`, `buildSelectionAnalytics`, etc.) read only the
497
+ * numeric `openCommentCount` and `actionableRevisionCount` fields — the
498
+ * stale `items[].anchor` never reaches a consumer. The direct
499
+ * `runtime.getReviewWorkSnapshot()` API does not use this cache.
500
+ */
501
+ function computeWorkflowMarkupStructuralHash(
502
+ wfMarkup: WorkflowMarkupSnapshot,
503
+ ): string {
504
+ return [
505
+ wfMarkup.totalCount,
506
+ wfMarkup.highlights.length,
507
+ wfMarkup.metadata.length,
508
+ wfMarkup.comments.length,
509
+ wfMarkup.revisions.length,
510
+ wfMarkup.fields.length,
511
+ wfMarkup.protectedRanges.length,
512
+ wfMarkup.opaqueFragments.length,
513
+ ].join(":");
514
+ }
515
+
516
+ /**
517
+ * L7 Phase 1.7.4 — review-status hash for the reviewWork cache key.
518
+ *
519
+ * `state.document.review.comments` and `.revisions` are rebuilt with new
520
+ * object identities on every text command (via `remapReviewStateAfterContent
521
+ * Change` → `remapCommentThreads` → `Object.fromEntries`), so a reference
522
+ * check on those records always fails on pure text edits. But the
523
+ * `reviewWork` snapshot's observable output depends only on:
524
+ * - which comments are OPEN (`openCommentCount = openCommentIds.length`)
525
+ * - which revisions are ACTIONABLE
526
+ * (`actionableRevisionCount = actionableChangeIds.length`)
527
+ * plus the per-category item counts already captured by
528
+ * `computeWorkflowMarkupStructuralHash`.
529
+ *
530
+ * `comments` / `revisions` keys and statuses are stable across text edits
531
+ * (remap only rewrites anchors and wraps in `{...thread, anchor}` — status
532
+ * is carried through). Hashing "id+status" for each record is O(N) over
533
+ * the small review set and captures the semantic identity we need.
534
+ *
535
+ * We intentionally do NOT include anchor positions in the hash: anchor
536
+ * shifts are exactly the staleness the cache is allowed to tolerate (see
537
+ * `cachedReviewWork` correctness note above).
538
+ */
539
+ function computeReviewStateHash(
540
+ review: CanonicalDocumentEnvelope["review"],
541
+ ): string {
542
+ const commentParts: string[] = [];
543
+ for (const commentId of Object.keys(review.comments).sort()) {
544
+ const thread = review.comments[commentId];
545
+ commentParts.push(`${commentId}=${thread?.status ?? ""}`);
546
+ }
547
+ const revisionParts: string[] = [];
548
+ for (const revisionId of Object.keys(review.revisions).sort()) {
549
+ const revision = review.revisions[revisionId];
550
+ revisionParts.push(`${revisionId}=${revision?.status ?? ""}`);
551
+ }
552
+ return `${commentParts.join(",")}|${revisionParts.join(",")}`;
553
+ }
554
+
396
555
  export function createDocumentRuntime(
397
556
  options: CreateDocumentRuntimeOptions,
398
557
  ): DocumentRuntime {
@@ -402,6 +561,29 @@ export function createDocumentRuntime(
402
561
  const sessionId = createSessionId(options.documentId, clock());
403
562
  const listeners = new Set<() => void>();
404
563
  const eventListeners = new Set<(event: DocumentRuntimeEvent) => void>();
564
+ // Fastload P2: optional time-slicing scheduler. When caller does not pass
565
+ // one, use the sync backend so the runtime keeps its pre-fastload behavior
566
+ // end-to-end (no deferred work, idle tasks run inline). Future fastload
567
+ // passes (P6 async loader) will consume `scheduler.scheduleIdle` for
568
+ // post-ready work.
569
+ const loadScheduler: LoadScheduler =
570
+ options.loadScheduler ?? createLoadScheduler({ backendOverride: "sync" });
571
+
572
+ // L7 Phase 0 — perf counters. Each `refreshRenderSnapshot` call increments
573
+ // `refresh.all`. Phase 1 will add per-facet `facet.<name>.build` counters
574
+ // wired to the per-facet cached builders. Cost is one Map.set per call.
575
+ const perfCounters = new PerfCounters();
576
+
577
+ // L7 Phase 1 — context-analytics emit coalescing flag. Multiple events that
578
+ // each trigger context-analytics within one synchronous call stack collapse
579
+ // to a single microtask-deferred emit. Declared early because emit() (which
580
+ // checks this flag) runs during construction.
581
+ let analyticsEmitScheduled = false;
582
+
583
+ // Schema 1.2 — editor-state channel (policy, resolver/persister, debounce queues).
584
+ // Instantiated once per runtime; forwarded to the public interface.
585
+ const editorStateChannel = createEditorStateChannel();
586
+
405
587
  const history: HistoryState = {
406
588
  past: [],
407
589
  future: [],
@@ -463,6 +645,16 @@ export function createDocumentRuntime(
463
645
  // upgrades to the canvas backend once fonts resolve. `swapMeasurementProvider`
464
646
  // emits `measurement_backend_ready` so chrome consumers can re-read metrics.
465
647
  const layoutEngine: LayoutEngineInstance = createLayoutEngine();
648
+ if (options.seedLayoutCache) {
649
+ // L7 Phase 2.5 — seed the cached graph from the prerender envelope so
650
+ // the first getPageGraph call skips fullRebuild. Seed is keyed on the
651
+ // runtime's current document identity so `keyEqual` passes without
652
+ // triggering a rebuild. The cache-key scheme in
653
+ // src/runtime/prerender/cache-key.ts guarantees the seed is produced
654
+ // by the same LAYOUT_ENGINE_VERSION.
655
+ layoutEngine.seedCachedGraph(options.seedLayoutCache, state.document);
656
+ perfCounters.increment("loadSession.layoutCache.seeded");
657
+ }
466
658
  const fontLoader: DocxFontLoader = createDocxFontLoader(
467
659
  collectFontLoaderInput(state.document),
468
660
  );
@@ -480,6 +672,7 @@ export function createDocumentRuntime(
480
672
  zoomLevel: viewState.zoomLevel,
481
673
  },
482
674
  }),
675
+ canonicalDocument: () => state.document,
483
676
  renderKernel: () => renderKernelRef,
484
677
  getWorkflowRailInput: () => {
485
678
  if (!workflowOverlay) return null;
@@ -504,11 +697,40 @@ export function createDocumentRuntime(
504
697
  // runtime already builds for comment/revision/search consumers.
505
698
  getWorkflowMarkupMetadata: () =>
506
699
  getCachedWorkflowMarkupSnapshot().metadata,
700
+ // L7 Phase 2 — delegate viewport culling through the facet so the
701
+ // workspace (which only holds a `layoutFacet` ref) can drive culling
702
+ // without a separate `DocumentRuntime` prop. The lambdas forward to
703
+ // the runtime's own `setVisibleBlockRange` / `requestViewportRefresh`
704
+ // implementations which are defined below. Capturing by closure works
705
+ // because the lambdas are only invoked after the full runtime object is
706
+ // built and the methods below have been assigned.
707
+ // TODO(L7 Phase 2 post-merge): this lambda duplicates the setVisibleBlockRange
708
+ // logic implemented on `runtime` at line ~2874. Consolidate after Phase 2 ships
709
+ // so the facet forwards to the runtime method. Forward-reference pattern matches
710
+ // `renderKernelRef` — see that example. See spec-review notes on commit 0b03bfa7.
711
+ setVisibleBlockRange: (range) => {
712
+ if (
713
+ viewportBlockRange &&
714
+ viewportBlockRange.start === range.start &&
715
+ viewportBlockRange.end === range.end
716
+ ) {
717
+ return;
718
+ }
719
+ viewportBlockRange = { start: range.start, end: range.end };
720
+ perfCounters.increment("runtime.viewport.updates");
721
+ },
722
+ requestViewportRefresh: () => {
723
+ perfCounters.increment("runtime.viewport.refreshes");
724
+ refreshSurfaceOnly();
725
+ },
507
726
  });
508
727
  renderKernelRef = createRenderKernel({
509
728
  facet: layoutFacet,
510
729
  getActiveStory: () => activeStory,
511
730
  });
731
+ // L7 Phase 2 — viewport block range for surface culling.
732
+ let viewportBlockRange: { start: number; end: number } | null = null;
733
+
512
734
  let cachedSurface:
513
735
  | {
514
736
  revisionToken: string;
@@ -544,6 +766,66 @@ export function createDocumentRuntime(
544
766
  snapshot: SuggestionsSnapshot;
545
767
  }
546
768
  | undefined;
769
+ // L7 Phase 1 — review-work snapshot cache. Independent of selection.
770
+ //
771
+ // What we cache. `createReviewWorkSnapshot`'s observable output is:
772
+ // - `openCommentCount` = `comments.openCommentIds.length`
773
+ // - `actionableRevisionCount` = `trackedChanges.actionableChangeIds.length`
774
+ // - `items[]` (each with stale-tolerable `anchor`/`sectionIndex`/
775
+ // `pageIndex` — see "Correctness" below)
776
+ //
777
+ // Cache-busting inputs (L7 Phase 1.7.4 — two complementary hashes):
778
+ // - `workflowMarkupHash` — structural-counts hash over wfMarkup.
779
+ // Replaces the old `workflowMarkup === wfMarkup` reference check,
780
+ // which always failed on pure text edits because
781
+ // `getCachedWorkflowMarkupSnapshot()` is keyed on revisionToken.
782
+ // Pure text edits keep all per-category item counts stable → hash
783
+ // equal. A comment ADDITION, revision AUTHORED, protected-range added,
784
+ // opaque-fragment added → at least one count changes → hash diverges.
785
+ // - `reviewStateHash` — "id+status" hash over `state.document.review`.
786
+ // Captures the open/resolved/detached distribution that item COUNTS
787
+ // alone can't see (a comment RESOLVED leaves the count stable but
788
+ // shifts openCommentCount). Stable across text edits because
789
+ // `remapCommentThreads` preserves status on every record.
790
+ //
791
+ // Note we intentionally DO NOT key on `cachedRenderSnapshot.comments` or
792
+ // `.trackedChanges` (the snapshot references) because those projections
793
+ // are rebuilt on every revisionToken bump — their text previews depend on
794
+ // live document text. And we DO NOT key on `state.document.review.comments`
795
+ // or `.revisions` directly: every text command rebuilds those records via
796
+ // `remapReviewStateAfterContentChange`, producing new object identities
797
+ // even when no anchor or status changed. The two hashes above capture the
798
+ // semantic identity that actually matters for ReviewWorkSnapshot output
799
+ // without churning on each keystroke.
800
+ //
801
+ // We also DO NOT store a `document` or `navigation` reference: both churn
802
+ // on every revisionToken bump, so those checks forced a 100 % miss rate
803
+ // on text edits with no compensating correctness gain.
804
+ //
805
+ // Correctness. Any change that affects the cached snapshot's observable
806
+ // output (`openCommentCount`, `actionableRevisionCount`, or the set of
807
+ // workflow items) shifts at least one of (workflowMarkupHash,
808
+ // reviewStateHash). The per-item `anchor` positions stored on stale cached
809
+ // items can be out of date, but this cache is consumed only inside
810
+ // `getCachedRuntimeContextAnalytics` where downstream
811
+ // `buildDocumentAnalytics` / `buildSelectionAnalytics` /
812
+ // `buildWorkflowScopeAnalytics` / `buildWorkItemAnalytics` read ONLY
813
+ // `reviewWorkSnapshot.openCommentCount` and `.actionableRevisionCount`
814
+ // (numbers). The stale `items[].anchor` never reaches a consumer. The
815
+ // public `runtime.getReviewWorkSnapshot()` API calls
816
+ // `createReviewWorkSnapshot` fresh and does not use this cache.
817
+ //
818
+ // Per-keystroke profiling on CCEP fixtures showed createReviewWorkSnapshot
819
+ // at 188 ms/call on large-tables (40 pp) and ~5 s/call on extra-large
820
+ // (100 pp) — dominated by per-item createDocumentLocationSnapshot. Phase
821
+ // 1.7.4 takes the 20/20 miss rate on pure text edits to ≤2/20.
822
+ let cachedReviewWork:
823
+ | {
824
+ workflowMarkupHash: string;
825
+ reviewStateHash: string;
826
+ snapshot: ReviewWorkSnapshot;
827
+ }
828
+ | undefined;
547
829
  let cachedPageLayout:
548
830
  | {
549
831
  revisionToken: string;
@@ -606,7 +888,9 @@ export function createDocumentRuntime(
606
888
  {
607
889
  revisionToken: string;
608
890
  activeStoryKey: string;
609
- selection: EditorState["selection"];
891
+ // L7 Phase 1.6: null for non-selection queries (doc/scope/work_item)
892
+ // so selection movement doesn't invalidate these scope-stable analytics.
893
+ selection: EditorState["selection"] | null;
610
894
  readOnly: boolean;
611
895
  documentMode: DocumentMode;
612
896
  workflowOverlay: WorkflowOverlay | null;
@@ -633,7 +917,7 @@ export function createDocumentRuntime(
633
917
  return cachedSurface.snapshot;
634
918
  }
635
919
 
636
- const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory);
920
+ const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory, { viewportBlockRange });
637
921
  recordPerfSample("snapshot.surface");
638
922
  incrementInvalidationCounter("runtime.snapshot.surfaceMisses");
639
923
  cachedSurface = {
@@ -1366,6 +1650,10 @@ export function createDocumentRuntime(
1366
1650
  protectionSnapshot,
1367
1651
  preservation: state.document.preservation,
1368
1652
  workflowMetadataSnapshot: deriveWorkflowMetadataSnapshot(),
1653
+ perfStage: (name, durationMs) => {
1654
+ perfCounters.increment(`wfMarkup.${name}.us`, Math.round(durationMs * 1000));
1655
+ perfCounters.increment(`wfMarkup.${name}.calls`);
1656
+ },
1369
1657
  });
1370
1658
  cachedWorkflowMarkupSnapshot = {
1371
1659
  revisionToken: state.revisionToken,
@@ -1386,11 +1674,21 @@ export function createDocumentRuntime(
1386
1674
  const activeStoryKey = storyTargetKey(activeStory);
1387
1675
  const queryKey = getRuntimeContextAnalyticsQueryKey(query);
1388
1676
  const cachedEntry = cachedContextAnalyticsSnapshots.get(queryKey);
1677
+ // L7 Phase 1.6 — only `scopeKind: "selection"` queries actually read
1678
+ // selection-anchored data (buildSelectionAnalytics filters markup items
1679
+ // that intersect the selection anchor). For document / workflow_scope /
1680
+ // work_item queries the selection is not an input, so changing it
1681
+ // shouldn't invalidate the cache. When selection moves within the same
1682
+ // workflow scope without text editing, document/scope/work_item analytics
1683
+ // hit; cursor-navigation bench drops accordingly.
1684
+ const effectiveScopeKind = query?.scopeKind ?? "selection";
1685
+ const selectionCacheKey =
1686
+ effectiveScopeKind === "selection" ? state.selection : null;
1389
1687
  if (
1390
1688
  cachedEntry &&
1391
1689
  cachedEntry.revisionToken === state.revisionToken &&
1392
1690
  cachedEntry.activeStoryKey === activeStoryKey &&
1393
- cachedEntry.selection === state.selection &&
1691
+ cachedEntry.selection === selectionCacheKey &&
1394
1692
  cachedEntry.readOnly === state.readOnly &&
1395
1693
  cachedEntry.documentMode === viewState.documentMode &&
1396
1694
  cachedEntry.workflowOverlay === workflowOverlay &&
@@ -1401,28 +1699,85 @@ export function createDocumentRuntime(
1401
1699
  return cachedEntry.snapshot;
1402
1700
  }
1403
1701
 
1702
+ const tWf = performance.now();
1703
+ const wfScope = getCachedWorkflowScopeSnapshot();
1704
+ const wfGuard = getCachedInteractionGuardSnapshot();
1705
+ const wfMarkup = getCachedWorkflowMarkupSnapshot();
1706
+ perfCounters.increment("ctxa.workflow.us", Math.round((performance.now() - tWf) * 1000));
1707
+
1708
+ const tSugg = performance.now();
1709
+ const suggestions = getCachedSuggestionsSnapshot(state);
1710
+ perfCounters.increment("ctxa.suggestions.us", Math.round((performance.now() - tSugg) * 1000));
1711
+
1712
+ const tNav = performance.now();
1713
+ const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
1714
+ perfCounters.increment("ctxa.navigation.us", Math.round((performance.now() - tNav) * 1000));
1715
+
1716
+ const tReview = performance.now();
1717
+ let reviewWork: ReviewWorkSnapshot;
1718
+ // L7 Phase 1.7.4 — two structural hashes replace the old reference
1719
+ // checks. `workflowMarkupHash` captures per-category wfMarkup item
1720
+ // counts (see computeWorkflowMarkupStructuralHash); `reviewStateHash`
1721
+ // captures id+status over state.document.review (see
1722
+ // computeReviewStateHash). Together they bust on any mutation that can
1723
+ // change ReviewWorkSnapshot's observable output
1724
+ // (openCommentCount, actionableRevisionCount, or the item set), while
1725
+ // staying stable under pure text edits so the expensive
1726
+ // createReviewWorkSnapshot walk is skipped. The prior `comments` /
1727
+ // `trackedChanges` / `workflowMarkup` / `document` / `navigation`
1728
+ // reference checks are dropped: all of them were churned on every
1729
+ // revisionToken bump and forced a 20/20 miss rate. See the
1730
+ // `cachedReviewWork` declaration above for the full rationale.
1731
+ const wfMarkupHash = computeWorkflowMarkupStructuralHash(wfMarkup);
1732
+ const reviewStateHash = computeReviewStateHash(state.document.review);
1733
+ if (
1734
+ cachedReviewWork &&
1735
+ cachedReviewWork.workflowMarkupHash === wfMarkupHash &&
1736
+ cachedReviewWork.reviewStateHash === reviewStateHash
1737
+ ) {
1738
+ reviewWork = cachedReviewWork.snapshot;
1739
+ perfCounters.increment("ctxa.reviewWork.cacheHit");
1740
+ } else {
1741
+ reviewWork = createReviewWorkSnapshot({
1742
+ comments: cachedRenderSnapshot.comments,
1743
+ trackedChanges: cachedRenderSnapshot.trackedChanges,
1744
+ workflowMarkup: wfMarkup,
1745
+ document: state.document,
1746
+ navigation,
1747
+ });
1748
+ cachedReviewWork = {
1749
+ workflowMarkupHash: wfMarkupHash,
1750
+ reviewStateHash,
1751
+ snapshot: reviewWork,
1752
+ };
1753
+ perfCounters.increment("ctxa.reviewWork.cacheMiss");
1754
+ }
1755
+ perfCounters.increment("ctxa.reviewWork.us", Math.round((performance.now() - tReview) * 1000));
1756
+
1757
+ const tCompat = performance.now();
1758
+ const compat = toPublicCompatibilityReport(createDerivedCompatibility(state));
1759
+ perfCounters.increment("ctxa.compat.us", Math.round((performance.now() - tCompat) * 1000));
1760
+
1761
+ const tBuild = performance.now();
1404
1762
  const snapshot = createRuntimeContextAnalyticsSnapshot({
1405
1763
  query,
1406
1764
  renderSnapshot: cachedRenderSnapshot,
1407
1765
  workflowOverlay,
1408
- workflowScopeSnapshot: getCachedWorkflowScopeSnapshot(),
1409
- interactionGuardSnapshot: getCachedInteractionGuardSnapshot(),
1410
- workflowMarkupSnapshot: getCachedWorkflowMarkupSnapshot(),
1411
- suggestionsSnapshot: getCachedSuggestionsSnapshot(state),
1412
- reviewWorkSnapshot: createReviewWorkSnapshot({
1413
- comments: cachedRenderSnapshot.comments,
1414
- trackedChanges: cachedRenderSnapshot.trackedChanges,
1415
- workflowMarkup: getCachedWorkflowMarkupSnapshot(),
1416
- document: state.document,
1417
- navigation: getCachedDocumentNavigationSnapshot(state, activeStory),
1418
- }),
1766
+ workflowScopeSnapshot: wfScope,
1767
+ interactionGuardSnapshot: wfGuard,
1768
+ workflowMarkupSnapshot: wfMarkup,
1769
+ suggestionsSnapshot: suggestions,
1770
+ reviewWorkSnapshot: reviewWork,
1419
1771
  warnings: state.warnings.map(toPublicWarning),
1420
- compatibility: toPublicCompatibilityReport(createDerivedCompatibility(state)),
1772
+ compatibility: compat,
1421
1773
  });
1774
+ perfCounters.increment("ctxa.build.us", Math.round((performance.now() - tBuild) * 1000));
1422
1775
  cachedContextAnalyticsSnapshots.set(queryKey, {
1423
1776
  revisionToken: state.revisionToken,
1424
1777
  activeStoryKey,
1425
- selection: state.selection,
1778
+ // L7 Phase 1.6: store the selectionCacheKey we tested against, so the
1779
+ // same scopeKind invariant applies to future reads.
1780
+ selection: selectionCacheKey,
1426
1781
  readOnly: state.readOnly,
1427
1782
  documentMode: viewState.documentMode,
1428
1783
  workflowOverlay,
@@ -1509,8 +1864,17 @@ export function createDocumentRuntime(
1509
1864
  return true;
1510
1865
  }
1511
1866
 
1867
+ function timeFacet<T>(name: string, fn: () => T): T {
1868
+ const t0 = performance.now();
1869
+ const result = fn();
1870
+ perfCounters.increment(`facet.${name}.us`, Math.round((performance.now() - t0) * 1000));
1871
+ perfCounters.increment(`facet.${name}.calls`);
1872
+ return result;
1873
+ }
1874
+
1512
1875
  function refreshRenderSnapshot(): RuntimeRenderSnapshot {
1513
- const surface = getCachedSurface(state.document, activeStory);
1876
+ perfCounters.increment("refresh.all");
1877
+ const surface = timeFacet("surface", () => getCachedSurface(state.document, activeStory));
1514
1878
  return {
1515
1879
  documentId: state.documentId,
1516
1880
  sessionId: state.sessionId,
@@ -1522,11 +1886,11 @@ export function createDocumentRuntime(
1522
1886
  documentMode: viewState.documentMode,
1523
1887
  selection: toPublicSelectionSnapshot(state.selection, activeStory),
1524
1888
  activeStory,
1525
- pageLayout: getCachedPageLayoutSnapshot(state, activeStory) ?? undefined,
1889
+ pageLayout: timeFacet("layout", () => getCachedPageLayoutSnapshot(state, activeStory)) ?? undefined,
1526
1890
  documentStats: toPublicDocumentStats(state),
1527
- comments: getCachedCommentSidebarSnapshot(state),
1528
- trackedChanges: getCachedTrackedChangesSnapshot(state, surface),
1529
- compatibility: getCachedCompatibilityReport(state),
1891
+ comments: timeFacet("comments", () => getCachedCommentSidebarSnapshot(state)),
1892
+ trackedChanges: timeFacet("trackedChanges", () => getCachedTrackedChangesSnapshot(state, surface)),
1893
+ compatibility: timeFacet("compatibility", () => getCachedCompatibilityReport(state)),
1530
1894
  warnings: state.warnings.map((warning) => toPublicWarning(warning)),
1531
1895
  fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
1532
1896
  commandState: {
@@ -1539,6 +1903,38 @@ export function createDocumentRuntime(
1539
1903
  };
1540
1904
  }
1541
1905
 
1906
+ /**
1907
+ * L7 Phase 2 — surface-only refresh triggered by viewport scroll.
1908
+ * Rebuilds only the surface facet (with the current viewportBlockRange) and
1909
+ * splices it into cachedRenderSnapshot. Does NOT rebuild layout, comments,
1910
+ * trackedChanges, or compatibility. Does NOT increment "refresh.all".
1911
+ */
1912
+ function refreshSurfaceOnly(): void {
1913
+ const newSurface = createEditorSurfaceSnapshot(
1914
+ state.document,
1915
+ state.selection,
1916
+ activeStory,
1917
+ { viewportBlockRange },
1918
+ );
1919
+ // Refresh the cache with the just-built snapshot so a subsequent
1920
+ // refreshRenderSnapshot (same revisionToken + activeStoryKey) hits cache.
1921
+ // Without this repopulate, the next non-mutation refresh wastes a second
1922
+ // createEditorSurfaceSnapshot call on an identical rebuild.
1923
+ cachedSurface = {
1924
+ revisionToken: state.revisionToken,
1925
+ activeStoryKey: storyTargetKey(activeStory),
1926
+ snapshot: newSurface,
1927
+ };
1928
+ cachedRenderSnapshot = {
1929
+ ...cachedRenderSnapshot,
1930
+ surface: newSurface,
1931
+ };
1932
+ // Notify listeners using the same pattern as other setX methods.
1933
+ for (const listener of listeners) {
1934
+ listener();
1935
+ }
1936
+ }
1937
+
1542
1938
  function getCachedViewStateSnapshot(): EditorViewStateSnapshot {
1543
1939
  const activeStoryKey = storyTargetKey(activeStory);
1544
1940
  const pageLayout = cachedRenderSnapshot.pageLayout;
@@ -1604,6 +2000,40 @@ export function createDocumentRuntime(
1604
2000
  });
1605
2001
  }
1606
2002
 
2003
+ // Schema 1.2: forward channel events to the runtime event bus.
2004
+ editorStateChannel.addListener((event) => {
2005
+ switch (event.kind) {
2006
+ case "load_failed":
2007
+ emit({
2008
+ type: "editor_state_part_load_failed",
2009
+ documentId: state.documentId,
2010
+ failure: event.failure,
2011
+ });
2012
+ break;
2013
+ case "persist_failed":
2014
+ emit({
2015
+ type: "editor_state_part_persist_failed",
2016
+ documentId: state.documentId,
2017
+ failure: event.failure,
2018
+ });
2019
+ break;
2020
+ case "policy_migrated":
2021
+ emit({
2022
+ type: "editor_state_policy_migrated",
2023
+ documentId: state.documentId,
2024
+ migration: event.migration,
2025
+ });
2026
+ break;
2027
+ case "unknown_namespace":
2028
+ emit({
2029
+ type: "editor_state_unknown_namespace",
2030
+ documentId: state.documentId,
2031
+ namespace: event.namespace,
2032
+ });
2033
+ break;
2034
+ }
2035
+ });
2036
+
1607
2037
  return {
1608
2038
  subscribe(listener) {
1609
2039
  listeners.add(listener);
@@ -1832,13 +2262,13 @@ export function createDocumentRuntime(
1832
2262
  const selection = params.anchor
1833
2263
  ? createSelectionFromPublicAnchor(params.anchor)
1834
2264
  : state.selection;
1835
- if (!canCreateDocxCommentAnchor(state.document.content, anchor)) {
2265
+ if (!canCreateDocxCommentAnchor(cachedRenderSnapshot.surface, anchor)) {
1836
2266
  const error: InternalEditorError = {
1837
2267
  errorId: createSessionId("comment-anchor", clock()),
1838
2268
  code: "validation_failed",
1839
2269
  isFatal: false,
1840
2270
  message:
1841
- "DOCX comments must use a non-empty range that stays within a single paragraph.",
2271
+ "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.",
1842
2272
  source: "runtime",
1843
2273
  details: {
1844
2274
  reason: "invalid_comment_anchor",
@@ -1880,7 +2310,10 @@ export function createDocumentRuntime(
1880
2310
  origin: createOrigin("api", clock()),
1881
2311
  });
1882
2312
 
1883
- return commentId;
2313
+ return {
2314
+ commentId,
2315
+ anchor: toPublicAnchorProjection(anchor),
2316
+ };
1884
2317
  },
1885
2318
  openComment(commentId) {
1886
2319
  this.dispatch({
@@ -1905,6 +2338,8 @@ export function createDocumentRuntime(
1905
2338
  });
1906
2339
  },
1907
2340
  addCommentReply(commentId, body, authorId) {
2341
+ const priorEntryCount =
2342
+ state.document.review.comments[commentId]?.entries?.length ?? 0;
1908
2343
  this.dispatch({
1909
2344
  type: "comment.add-reply",
1910
2345
  commentId,
@@ -1912,6 +2347,8 @@ export function createDocumentRuntime(
1912
2347
  authorId: authorId ?? defaultAuthorId,
1913
2348
  origin: createOrigin("api", clock()),
1914
2349
  });
2350
+ const entryId = `${commentId}-entry-${priorEntryCount + 1}`;
2351
+ return { commentId, entryId };
1915
2352
  },
1916
2353
  editCommentBody(commentId, body) {
1917
2354
  this.dispatch({
@@ -2254,7 +2691,37 @@ export function createDocumentRuntime(
2254
2691
  throw toStructuredRuntimeException(error);
2255
2692
  }
2256
2693
 
2257
- const result = await options.exportDocx(this.getSessionState(), exportOptions);
2694
+ // Schema 1.2: collect editor-state payload (flushes pending persists).
2695
+ const collectedEditorState = await collectEditorStateForSerialize({
2696
+ channel: editorStateChannel,
2697
+ getNamespaceData: (ns) => {
2698
+ switch (ns) {
2699
+ case "hostAnnotations": {
2700
+ const snap = deriveHostAnnotationSnapshot();
2701
+ if (!snap || snap.annotations.length === 0) return null;
2702
+ return { schemaVersion: "host-annotation-overlay/1", data: snap };
2703
+ }
2704
+ case "workflowOverlay": {
2705
+ const ov = workflowOverlay;
2706
+ if (!ov) return null;
2707
+ return { schemaVersion: "workflow-overlay/1", data: ov };
2708
+ }
2709
+ case "workflowMetadata": {
2710
+ const meta = deriveWorkflowMetadataSnapshot();
2711
+ if (!meta || (meta.definitions.length === 0 && meta.entries.length === 0)) return null;
2712
+ return { schemaVersion: "workflow-metadata/1", data: meta };
2713
+ }
2714
+ case "workItems":
2715
+ return null;
2716
+ }
2717
+ },
2718
+ });
2719
+ const internalOptions: InternalExportDocxOptions = {
2720
+ ...exportOptions,
2721
+ _editorState: collectedEditorState,
2722
+ };
2723
+
2724
+ const result = await options.exportDocx(this.getSessionState(), internalOptions);
2258
2725
 
2259
2726
  emit({
2260
2727
  type: "export_completed",
@@ -2270,6 +2737,11 @@ export function createDocumentRuntime(
2270
2737
  overlay,
2271
2738
  origin: createOrigin("api", clock()),
2272
2739
  });
2740
+ editorStateChannel.recordMutation("workflowOverlay", {
2741
+ namespace: "workflowOverlay",
2742
+ schemaVersion: "workflow-overlay/1",
2743
+ data: overlay,
2744
+ });
2273
2745
  },
2274
2746
  clearWorkflowOverlay() {
2275
2747
  this.dispatch({
@@ -2308,6 +2780,11 @@ export function createDocumentRuntime(
2308
2780
  entries,
2309
2781
  origin: createOrigin("api", clock()),
2310
2782
  });
2783
+ editorStateChannel.recordMutation("workflowMetadata", {
2784
+ namespace: "workflowMetadata",
2785
+ schemaVersion: "workflow-metadata/1",
2786
+ data: entries,
2787
+ });
2311
2788
  },
2312
2789
  clearWorkflowMetadataEntries() {
2313
2790
  this.dispatch({
@@ -2324,6 +2801,11 @@ export function createDocumentRuntime(
2324
2801
  overlay,
2325
2802
  origin: createOrigin("api", clock()),
2326
2803
  });
2804
+ editorStateChannel.recordMutation("hostAnnotations", {
2805
+ namespace: "hostAnnotations",
2806
+ schemaVersion: "host-annotation-overlay/1",
2807
+ data: overlay,
2808
+ });
2327
2809
  },
2328
2810
  clearHostAnnotationOverlay() {
2329
2811
  this.dispatch({
@@ -2391,6 +2873,46 @@ export function createDocumentRuntime(
2391
2873
  getRuntimeContextAnalytics(query) {
2392
2874
  return getCachedRuntimeContextAnalytics(query);
2393
2875
  },
2876
+ // Schema 1.2 — EditorStateChannel delegation
2877
+ configureEditorStatePolicy(policy) {
2878
+ editorStateChannel.setPolicy(policy);
2879
+ },
2880
+ registerEditorStateResolver(resolver) {
2881
+ editorStateChannel.setResolver(resolver);
2882
+ },
2883
+ registerEditorStatePersister(persister) {
2884
+ editorStateChannel.setPersister(persister);
2885
+ },
2886
+ getEditorStateKey(namespace) {
2887
+ return editorStateChannel.getKey(namespace);
2888
+ },
2889
+ async retryPendingPersist(namespace) {
2890
+ await editorStateChannel.retry(namespace);
2891
+ },
2892
+ get editorStateChannel() {
2893
+ return editorStateChannel;
2894
+ },
2895
+ getPerfCountersSnapshot() {
2896
+ return perfCounters.snapshot();
2897
+ },
2898
+ resetPerfCounters() {
2899
+ perfCounters.reset();
2900
+ },
2901
+ setVisibleBlockRange(range) {
2902
+ if (
2903
+ viewportBlockRange &&
2904
+ viewportBlockRange.start === range.start &&
2905
+ viewportBlockRange.end === range.end
2906
+ ) {
2907
+ return;
2908
+ }
2909
+ viewportBlockRange = { start: range.start, end: range.end };
2910
+ perfCounters.increment("runtime.viewport.updates");
2911
+ },
2912
+ requestViewportRefresh() {
2913
+ perfCounters.increment("runtime.viewport.refreshes");
2914
+ refreshSurfaceOnly();
2915
+ },
2394
2916
  };
2395
2917
 
2396
2918
  function applyHistory(direction: "undo" | "redo"): void {
@@ -2438,15 +2960,14 @@ export function createDocumentRuntime(
2438
2960
  function applyTransactionToState(transaction: EditorTransaction): void {
2439
2961
  const previous = state;
2440
2962
 
2963
+ const tApply0 = performance.now();
2441
2964
  protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
2965
+ const tFinalize0 = performance.now();
2442
2966
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
2967
+ perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
2443
2968
  storySelections.set(storyTargetKey(activeStory), state.selection);
2444
2969
 
2445
- // Signal a bounded content-edit invalidation to the layout engine so the
2446
- // next layout query can splice rather than rebuild the full graph. The
2447
- // engine analyzes the reason against its cached graph and falls back to
2448
- // a full rebuild when the edit crosses section boundaries or reaches a
2449
- // page the engine cannot safely resume from.
2970
+ const tInvalidate0 = performance.now();
2450
2971
  if (transaction.markDirty && transaction.mapping.steps.length > 0) {
2451
2972
  let minFrom = Infinity;
2452
2973
  let maxTo = -Infinity;
@@ -2459,22 +2980,56 @@ export function createDocumentRuntime(
2459
2980
  layoutEngine.invalidate({ kind: "content-edit", from: minFrom, to: maxTo });
2460
2981
  }
2461
2982
  }
2462
-
2463
- // Font-loader refresh on subParts identity change — this is the
2464
- // lightweight proxy for "a change that could affect which fonts the
2465
- // canvas backend measures against". Typing edits don't rebuild
2466
- // subParts; style + font + numbering imports do. Hardening: also
2467
- // invalidate the measurement provider's glyph cache AND the engine's
2468
- // cached graph so the next pagination run re-measures with the
2469
- // newly-registered FontFaces (pre-hardening the canvas backend kept
2470
- // returning pre-refresh widths from its cache).
2471
2983
  if (previous.document.subParts !== state.document.subParts) {
2472
2984
  fontLoader.refresh(collectFontLoaderInput(state.document));
2473
2985
  layoutEngine.invalidateMeasurementCache();
2474
2986
  }
2987
+ perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
2988
+
2989
+ // I5: validate post-mutation selection against the new document bound.
2990
+ // First call to getCachedSurface() here computes the post-mutation surface
2991
+ // snapshot (cache miss — revisionToken just changed in finalizeState).
2992
+ // refreshRenderSnapshot's internal call below hits the cache and reuses it.
2993
+ // Net cost: identical to pre-I5 — one surface walk per commit, just shifted
2994
+ // a few lines earlier so the validator can read storySize.
2995
+ //
2996
+ // Wired ONLY at this chokepoint. The other 12 `cachedRenderSnapshot =
2997
+ // refreshRenderSnapshot()` sites in this file are intentionally skipped:
2998
+ // - Initial setup (line ~1878): no prior selection to validate.
2999
+ // - View-mode setters (lines ~2313–2351): zoom/mode/preview changes don't
3000
+ // change state.document; validator pass would always be identity.
3001
+ // - applyHistory / undo-redo (line ~2813): restores a {document, selection}
3002
+ // snapshot that was already validated at the commit that produced it.
3003
+ // - Workflow overlay / error / story switch (lines ~3398, 3416, 3582, 3608):
3004
+ // substitute a stored selection that was validated at the commit that
3005
+ // stored it. If a future structural command can mutate state.document
3006
+ // without going through applyTransactionToState, validation must be added
3007
+ // at that site.
3008
+ //
3009
+ // Using the post-mutation storySize (not cachedRenderSnapshot's pre-mutation
3010
+ // storySize) avoids clamping the caret behind every keystroke at end-of-doc.
3011
+ // The return type of getCachedSurface is `RuntimeRenderSnapshot["surface"]`
3012
+ // which is optional in the public API for shape-only reasons — the helper
3013
+ // itself always returns a defined snapshot.
3014
+ const surfaceForValidation = getCachedSurface(state.document, activeStory)!;
3015
+ const validatedSelection = validateSelectionAgainstDocument(
3016
+ state.document,
3017
+ state.selection,
3018
+ surfaceForValidation.storySize,
3019
+ );
3020
+ if (validatedSelection !== state.selection) {
3021
+ state = { ...state, selection: validatedSelection };
3022
+ storySelections.set(storyTargetKey(activeStory), state.selection);
3023
+ }
2475
3024
 
3025
+ const tRefresh0 = performance.now();
2476
3026
  cachedRenderSnapshot = refreshRenderSnapshot();
3027
+ perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
3028
+
3029
+ const tNotify0 = performance.now();
2477
3030
  notify(previous, state, transaction);
3031
+ perfCounters.increment("commit.notify.us", Math.round((performance.now() - tNotify0) * 1000));
3032
+ perfCounters.increment("commit.total.us", Math.round((performance.now() - tApply0) * 1000));
2478
3033
  }
2479
3034
 
2480
3035
  function notify(
@@ -2935,10 +3490,39 @@ export function createDocumentRuntime(
2935
3490
  return nextReview;
2936
3491
  }
2937
3492
 
3493
+ // L7 Phase 1 — context-analytics emissions are coalesced per microtask.
3494
+ // A single keystroke commit fires multiple events that each independently
3495
+ // trigger `emitContextAnalyticsChanged` (selection_changed, change_authored,
3496
+ // …). Each emission re-walks every workflow markup item (~211 ms on a
3497
+ // 40-page CCEP fixture) so unbatched, an N-event commit costs N × 211 ms.
3498
+ // Coalescing collapses the burst to a single emit at the tail of the
3499
+ // synchronous call stack — one per commit. (Flag declared above near
3500
+ // perfCounters so it is initialized before construction-time emits run.)
3501
+ function scheduleContextAnalyticsEmit(): void {
3502
+ if (analyticsEmitScheduled) {
3503
+ perfCounters.increment("emit.contextAnalytics.coalesced");
3504
+ return;
3505
+ }
3506
+ analyticsEmitScheduled = true;
3507
+ queueMicrotask(() => {
3508
+ // Reset BEFORE the emit so any synchronous re-entrant emits triggered
3509
+ // by listener callbacks schedule a fresh second microtask (no lost
3510
+ // updates). Reversing this order would drop bursts originating in
3511
+ // listeners.
3512
+ analyticsEmitScheduled = false;
3513
+ const t = performance.now();
3514
+ emitContextAnalyticsChanged();
3515
+ perfCounters.increment("emit.contextAnalytics.us", Math.round((performance.now() - t) * 1000));
3516
+ });
3517
+ }
3518
+
2938
3519
  function emit(event: DocumentRuntimeEvent): void {
3520
+ perfCounters.increment(`emit.${event.type}.calls`);
3521
+ const t0 = performance.now();
2939
3522
  emitInternal(event);
3523
+ perfCounters.increment(`emit.${event.type}.internal.us`, Math.round((performance.now() - t0) * 1000));
2940
3524
  if (shouldEmitContextAnalyticsChanged(event)) {
2941
- emitContextAnalyticsChanged();
3525
+ scheduleContextAnalyticsEmit();
2942
3526
  }
2943
3527
  }
2944
3528
 
@@ -3085,7 +3669,23 @@ export function createDocumentRuntime(
3085
3669
  }
3086
3670
 
3087
3671
  function emitContextAnalyticsChanged(): void {
3672
+ // L7 Phase 1 — short-circuit when no consumer can observe the event.
3673
+ // The downstream cost is dominated by createRuntimeContextAnalyticsSnapshot,
3674
+ // which assembles ~8 facets (suggestions, navigation, review-work,
3675
+ // workflow-markup, etc.) and runs > 300 ms on a 40-page CCEP fixture
3676
+ // because its cache key includes `selection` and selection moves on
3677
+ // every keystroke. Skipping here when nobody is subscribed eliminates
3678
+ // the cost in headless/SSR/test usage. Production keeps its `onEvent`
3679
+ // handler, so the production typing-latency win lives in Phase 1.5
3680
+ // (microtask coalescing) and Phase 1.6 (per-input-axis caching of the
3681
+ // analytics inputs).
3682
+ if (options.onEvent === undefined && eventListeners.size === 0) {
3683
+ perfCounters.increment("emit.contextAnalytics.skippedNoListeners");
3684
+ return;
3685
+ }
3686
+ const t0 = performance.now();
3088
3687
  const trackedSnapshots = collectTrackedContextAnalyticsSnapshots();
3688
+ perfCounters.increment("emit.contextAnalytics.collect.us", Math.round((performance.now() - t0) * 1000));
3089
3689
  if (
3090
3690
  lastEmittedContextAnalyticsSnapshots !== undefined &&
3091
3691
  trackedContextAnalyticsSnapshotsEqual(lastEmittedContextAnalyticsSnapshots, trackedSnapshots)
@@ -4438,6 +5038,25 @@ function resolveSupportedFieldDisplay(
4438
5038
  if (field.fieldFamily === "TOC") {
4439
5039
  return undefined;
4440
5040
  }
5041
+ if (field.fieldFamily === "PAGE") {
5042
+ const page = resolveRepresentativePageForStory(navigation, storyTarget);
5043
+ if (!page) {
5044
+ return { displayText: "", refreshStatus: "unresolvable" };
5045
+ }
5046
+ return {
5047
+ displayText: String(resolveDisplayedPageNumber(page)),
5048
+ refreshStatus: "current",
5049
+ };
5050
+ }
5051
+ if (field.fieldFamily === "NUMPAGES") {
5052
+ if (navigation.pageCount === 0) {
5053
+ return { displayText: "", refreshStatus: "unresolvable" };
5054
+ }
5055
+ return {
5056
+ displayText: String(navigation.pageCount),
5057
+ refreshStatus: "current",
5058
+ };
5059
+ }
4441
5060
  if (!field.fieldTarget) {
4442
5061
  return { displayText: "", refreshStatus: "unresolvable" };
4443
5062
  }