@beyondwork/docx-react-component 1.0.74 → 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.74",
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": [
@@ -5625,6 +5625,13 @@ export interface ShortcutDelegationContext {
5625
5625
  selectionRange: SelectionSnapshot;
5626
5626
  }
5627
5627
 
5628
+ /**
5629
+ * Resolver contract (`resolveChromeVisibilityForPreset`): every field on
5630
+ * this interface is REQUIRED — no `| undefined`, no optional sugar. Each
5631
+ * preset's default-visibility block (`src/api/v3/ui/chrome-preset-model.ts`)
5632
+ * MUST set every field. If you add a new surface visibility flag, add it
5633
+ * here AND to all preset default blocks in the same commit.
5634
+ */
5628
5635
  export interface WordReviewEditorChromeVisibility {
5629
5636
  toolbar: boolean;
5630
5637
  alerts: boolean;
@@ -11,12 +11,16 @@
11
11
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
12
12
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
13
13
  import type {
14
+ EditorStoryTarget,
14
15
  OverlayKind,
15
16
  OverlayVisibilityPolicy,
16
17
  WorkflowMarkupModePolicy,
17
18
  } from "../../public-types.ts";
18
19
  import { emitUxResponse } from "../_ux-response.ts";
19
- import { createScopeFromBlockId } from "../../../runtime/workflow/scope-writer.ts";
20
+ import {
21
+ createScopeFromAnchor,
22
+ createScopeFromBlockId,
23
+ } from "../../../runtime/workflow/scope-writer.ts";
20
24
  import { attachScopeMetadata } from "../../../runtime/workflow/metadata-writer.ts";
21
25
  import {
22
26
  DEFAULT_REGISTRY_ENTRIES,
@@ -113,6 +117,59 @@ export interface CreateScopeResult {
113
117
  readonly status: "created" | "block-not-found";
114
118
  }
115
119
 
120
+ /**
121
+ * Input for `createScopeFromAnchor` — sub-block marker-backed scope.
122
+ *
123
+ * Use this variant when the scope must bracket a range *within* a block
124
+ * (a phrase inside a paragraph) or across blocks (a selection that
125
+ * spans paragraph boundaries). For whole-block scopes, prefer
126
+ * `createScope({blockId})` — it stays block-aligned after edits at the
127
+ * block boundary.
128
+ *
129
+ * The `from`/`to` positions are consumed **once** at creation. After
130
+ * this call returns, use the returned `scopeId` as the reference. Do
131
+ * not cache, store, or reuse the positions — they are positional
132
+ * queries (KI-P9), valid only in the document state they were captured
133
+ * in. The scope itself is marker-backed and travels with its bracketed
134
+ * content through any number of subsequent edits.
135
+ */
136
+ export interface CreateScopeFromAnchorInput {
137
+ readonly anchor: {
138
+ /** Zero-based surface offset, inclusive. Must be `>= 0`. */
139
+ readonly from: number;
140
+ /** Zero-based surface offset, exclusive of the marker slot. Must be `>= from` and `<= storyLength`. */
141
+ readonly to: number;
142
+ /** Non-main-body story (footnote / header / endnote). Defaults to `{kind:"main"}`. */
143
+ readonly storyTarget?: EditorStoryTarget;
144
+ };
145
+ readonly mode?: "edit" | "suggest" | "comment" | "view";
146
+ readonly label?: string;
147
+ readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
148
+ readonly stableRefHint?:
149
+ | "scope-id"
150
+ | "bookmark"
151
+ | "semantic-path"
152
+ | "runtime-handle";
153
+ }
154
+
155
+ export type CreateScopeFromAnchorResult =
156
+ | { readonly scopeId: string; readonly status: "created" }
157
+ | {
158
+ readonly scopeId: "";
159
+ readonly status: "range-invalid";
160
+ readonly reason:
161
+ | "from-negative"
162
+ | "to-less-than-from"
163
+ | "range-exceeds-story-length";
164
+ readonly from: number;
165
+ readonly to: number;
166
+ readonly storyLength: number;
167
+ /** Agent-actionable single-sentence explanation. Safe to surface to LLM tool replies as-is. */
168
+ readonly message: string;
169
+ /** Machine-routable next-step hint (`"clamp-from-to-zero"` / `"swap-from-and-to"` / `"clamp-to-to-storyLength-or-pick-a-different-range"`). */
170
+ readonly nextStep: string;
171
+ };
172
+
116
173
  export const createScopeMetadata: ApiV3FnMetadata = {
117
174
  name: "runtime.workflow.createScope",
118
175
  status: "live-with-adapter",
@@ -129,6 +186,32 @@ export const createScopeMetadata: ApiV3FnMetadata = {
129
186
  rwdReference: "§Runtime API § runtime.workflow.createScope",
130
187
  };
131
188
 
189
+ export const createScopeFromAnchorMetadata: ApiV3FnMetadata = {
190
+ name: "runtime.workflow.createScopeFromAnchor",
191
+ status: "live-with-adapter",
192
+ sourceLayer: "workflow-review",
193
+ liveEvidence: {
194
+ runnerTest: "test/api/v3/workflow-create-scope-from-anchor-live.test.ts",
195
+ commit: "pending",
196
+ },
197
+ uxIntent: {
198
+ uiVisible: true,
199
+ expectsUxResponse: "scope-created",
200
+ expectedDelta:
201
+ "rail shows new scope chip for the sub-block range; editor surface refreshes with inline markers planted at from/to",
202
+ },
203
+ agentMetadata: {
204
+ readOrMutate: "mutate",
205
+ boundedScope: "selection",
206
+ auditCategory: "scope-creation",
207
+ },
208
+ stateClass: "A-canonical",
209
+ persistsTo: "customXml",
210
+ broadcastsVia: "crdt",
211
+ rwdReference:
212
+ "§Runtime API § runtime.workflow.createScopeFromAnchor. Companion to createScope({blockId}) for sub-block ranges. Consumes from/to once to plant inline scope_marker_start/end; the returned scopeId is the durable reference (KI-P9).",
213
+ };
214
+
132
215
  export interface AttachMetadataInput {
133
216
  readonly scopeId: string;
134
217
  readonly metadataId: string;
@@ -411,6 +494,52 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
411
494
  return { scopeId: adapterResult.scopeId, status: "created" };
412
495
  },
413
496
 
497
+ createScopeFromAnchor(
498
+ input: CreateScopeFromAnchorInput,
499
+ ): CreateScopeFromAnchorResult {
500
+ // @endStateApi — live-with-adapter. Delegates to the layer-06
501
+ // scope-writer with the anchor range passed through directly.
502
+ // Bounds are validated against the story length; out-of-bounds
503
+ // ranges return a typed `range-invalid` discriminator rather than
504
+ // inventing a scopeId.
505
+ const adapterResult = createScopeFromAnchor(runtime, {
506
+ anchor: input.anchor,
507
+ mode: input.mode,
508
+ label: input.label,
509
+ ...(input.assoc ? { assoc: input.assoc } : {}),
510
+ ...(input.stableRefHint
511
+ ? { stableRefHint: input.stableRefHint }
512
+ : {}),
513
+ });
514
+ emitUxResponse(runtime, {
515
+ apiFn: createScopeFromAnchorMetadata.name,
516
+ intent: createScopeFromAnchorMetadata.uxIntent.expectedDelta ?? "",
517
+ mockOrLive: "live",
518
+ uiVisible: true,
519
+ expectedDelta: createScopeFromAnchorMetadata.uxIntent.expectedDelta,
520
+ actualDelta:
521
+ adapterResult.status === "created"
522
+ ? {
523
+ kind: "inline-change",
524
+ payload: { scopeId: adapterResult.scopeId },
525
+ }
526
+ : undefined,
527
+ });
528
+ if (adapterResult.status !== "created") {
529
+ return {
530
+ scopeId: "",
531
+ status: "range-invalid",
532
+ reason: adapterResult.reason,
533
+ from: adapterResult.from,
534
+ to: adapterResult.to,
535
+ storyLength: adapterResult.storyLength,
536
+ message: adapterResult.message,
537
+ nextStep: adapterResult.nextStep,
538
+ };
539
+ }
540
+ return { scopeId: adapterResult.scopeId, status: "created" };
541
+ },
542
+
414
543
  getVisibilityPolicy(kind: OverlayKind): OverlayVisibilityPolicy | null {
415
544
  // @endStateApi — live. Class-A policy read; composition with
416
545
  // class-C local preference lives in L10 (`ui.overlays.getVisibility`).
@@ -17,6 +17,7 @@ import { resolveHighlightColor } from "./highlight-colors.ts";
17
17
  import type { ParseDrawingOpts } from "./parse-drawing.ts";
18
18
  import { parseFFDataFromFldChar } from "./parse-ffdata.ts";
19
19
  import { classifyFieldInstruction } from "./parse-fields.ts";
20
+ import { isSafeTableFieldInstruction } from "./table-opaque-preservation.ts";
20
21
  import { parseXmlWithOffsets as parseXml } from "./xml-parser.ts";
21
22
  import { localName, readStringAttr } from "./xml-attr-helpers.ts";
22
23
  import {
@@ -1018,8 +1019,12 @@ function containsRiskyElement(element: XmlElementNode): boolean {
1018
1019
  const instruction =
1019
1020
  readStringAttr(child, "w:instr") ??
1020
1021
  extractTextContent(child);
1021
- const classification = classifyFieldInstruction(instruction);
1022
- if (!isSafeSecondaryStoryFieldFamily(classification.family)) {
1022
+ // Coord-01 §11 unification (2026-04-24): the field-safety check
1023
+ // is delegated to the shared `isSafeTableFieldInstruction` helper
1024
+ // so body-direct + secondary-story tables apply the same
1025
+ // allowlist. Legacy form fields (FORMTEXT/FORMCHECKBOX/
1026
+ // FORMDROPDOWN) now pass here just like in main-story tables.
1027
+ if (!isSafeTableFieldInstruction(instruction)) {
1023
1028
  return true;
1024
1029
  }
1025
1030
  continue;
@@ -1041,17 +1046,6 @@ function containsRiskyElement(element: XmlElementNode): boolean {
1041
1046
  return false;
1042
1047
  }
1043
1048
 
1044
- function isSafeSecondaryStoryFieldFamily(family: string): boolean {
1045
- return (
1046
- family === "REF" ||
1047
- family === "PAGEREF" ||
1048
- family === "NOTEREF" ||
1049
- family === "TOC" ||
1050
- family === "PAGE" ||
1051
- family === "NUMPAGES"
1052
- );
1053
- }
1054
-
1055
1049
  function parseSimpleTableElement(
1056
1050
  tblElement: XmlElementNode,
1057
1051
  sourceXml: string,
@@ -41,6 +41,7 @@ import { parseShapeXml, parseVmlXml } from "./parse-shapes.ts";
41
41
  import { parseObject } from "./parse-object.ts";
42
42
  import { parseDrawingFrame } from "./parse-drawing.ts";
43
43
  import { readFrameProperties } from "./parse-paragraph-formatting.ts";
44
+ import { tableRequiresOpaquePreservation } from "./table-opaque-preservation.ts";
44
45
  import { classifyFieldInstruction } from "./parse-fields.ts";
45
46
  import { parseFFDataFromFldChar } from "./parse-ffdata.ts";
46
47
  import { resolveHighlightColor } from "./highlight-colors.ts";
@@ -2020,37 +2021,12 @@ function readCellCnfStyle(node: XmlElementNode): string | undefined {
2020
2021
  * Tables matching this check stay opaque until the respective features
2021
2022
  * are implemented in the table editing path.
2022
2023
  */
2023
- function tableRequiresOpaquePreservation(rawXml: string): boolean {
2024
- // Safe table-local content now includes hyperlinks, bookmarks, comments,
2025
- // nested tables, floating images, VML preview atoms, and bounded field
2026
- // families already owned by the current field slice. Risky table-local
2027
- // semantics still fail closed to preserve-only.
2028
- if (/<w:(ins|del|rPrChange|pPrChange|tblPrChange|trPrChange|tcPrChange|sectPrChange|cellIns|cellDel|cellMerge|smartTag)\b/.test(rawXml)) {
2029
- return true;
2030
- }
2031
-
2032
- const simpleInstructions = [...rawXml.matchAll(/\bw:instr="([^"]*)"/g)].map((match) => match[1] ?? "");
2033
- const complexInstructions = extractComplexFieldInstructions(rawXml);
2034
- for (const instruction of [...simpleInstructions, ...complexInstructions]) {
2035
- const classification = classifyFieldInstruction(instruction);
2036
- if (!isSafeMainStoryTableFieldFamily(classification.family)) {
2037
- return true;
2038
- }
2039
- }
2040
-
2041
- return false;
2042
- }
2043
-
2044
- function isSafeMainStoryTableFieldFamily(family: string): boolean {
2045
- return (
2046
- family === "REF" ||
2047
- family === "PAGEREF" ||
2048
- family === "NOTEREF" ||
2049
- family === "TOC" ||
2050
- family === "PAGE" ||
2051
- family === "NUMPAGES"
2052
- );
2053
- }
2024
+ // `tableRequiresOpaquePreservation` now lives in
2025
+ // `src/io/ooxml/table-opaque-preservation.ts` as a shared helper used by
2026
+ // `parse-main-document.ts`, `parse-headers-footers.ts`, and
2027
+ // `parse-footnotes.ts`. Unified 2026-04-24 per coord-01 §11 to stop the
2028
+ // three parsers from drifting out of alignment. The top-of-file import
2029
+ // `tableRequiresOpaquePreservation` points at that module.
2054
2030
 
2055
2031
  function extractComplexFieldInstructions(rawXml: string): string[] {
2056
2032
  const tokenPattern =
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Unified table-opaque-preservation predicate — shared across
3
+ * `parse-main-document.ts`, `parse-headers-footers.ts`, and
4
+ * `parse-footnotes.ts` so the three parsers agree on which tables can
5
+ * be promoted to the typed `TableNode` shape and which must fall back
6
+ * to `opaque_block` for lossless rawXml round-trip.
7
+ *
8
+ * ## History
9
+ *
10
+ * Before this module existed, each of the three parsers maintained its
11
+ * own divergent allowlist. That divergence drove the coord-01 §11
12
+ * finding (2026-04-24): legacy form fields (`FORMTEXT`,
13
+ * `FORMCHECKBOX`, `FORMDROPDOWN`) classify as `UNKNOWN` under
14
+ * `FIELD_FAMILY_PATTERN` (which targets data-field families), so both
15
+ * the main-story and secondary-story table predicates rejected them
16
+ * even though the body-direct paragraph parser path (via
17
+ * `parseFFDataFromFldChar`) handles them correctly. The result was
18
+ * 58 cell paragraphs lost on APS Short Form because 2 body-direct
19
+ * tables flattened to opaque_block; 5 additional footer tables on
20
+ * the same doc also flatten (those carry `DOCPROPERTY` — a real data
21
+ * field, distinct from the legacy form-field case).
22
+ *
23
+ * ## Contract
24
+ *
25
+ * A `<w:tbl>` is "safe to parse as structured" iff NONE of the
26
+ * following are true:
27
+ *
28
+ * 1. **Revision markup.** `<w:ins>`, `<w:del>`, `<w:moveFrom>`,
29
+ * `<w:moveTo>`, `<w:rPrChange>`, `<w:pPrChange>`,
30
+ * `<w:tblPrChange>`, `<w:trPrChange>`, `<w:tcPrChange>`,
31
+ * `<w:sectPrChange>`, `<w:cellIns>`, `<w:cellDel>`,
32
+ * `<w:cellMerge>`, `<w:smartTag>`.
33
+ * Rationale: tracked-change-aware table editing isn't implemented;
34
+ * flattening preserves fidelity until it is.
35
+ *
36
+ * 2. **Field instruction outside the safe set.** Scan every
37
+ * `w:instr` simple-field attribute + every `<w:instrText>` inside
38
+ * a complex field (`<w:fldChar>begin</w:fldChar>...end`) and
39
+ * classify via `classifyFieldInstruction`. If the classified
40
+ * family isn't in `SAFE_TABLE_FIELD_FAMILIES` AND the instruction
41
+ * isn't a legacy form field (`isLegacyFormFieldInstruction`), the
42
+ * table flattens. The legacy-form-field short-circuit is the
43
+ * coord-01 §11 fix — those tokens are fully supported by the cell
44
+ * paragraph parser but classify as `UNKNOWN`.
45
+ *
46
+ * Extending this contract is a cross-layer architectural decision:
47
+ * widening `SAFE_TABLE_FIELD_FAMILIES` lets more tables parse as
48
+ * structured but commits the edit-command layer (L06/L08) to handle
49
+ * each field family's cell-level edit semantics. Keep this list
50
+ * aligned with the field families supported by the runtime
51
+ * formatting + scope-command pipeline.
52
+ */
53
+
54
+ import { classifyFieldInstruction } from "./parse-fields.ts";
55
+
56
+ /**
57
+ * Field families safe enough to leave a `<w:tbl>` in structured
58
+ * canonical form. Widening this set commits L06 / L08 to cell-level
59
+ * edit semantics for that family — don't expand opportunistically.
60
+ */
61
+ export const SAFE_TABLE_FIELD_FAMILIES: ReadonlySet<string> = new Set([
62
+ "REF",
63
+ "PAGEREF",
64
+ "NOTEREF",
65
+ "TOC",
66
+ "PAGE",
67
+ "NUMPAGES",
68
+ ]);
69
+
70
+ /**
71
+ * Revision + structural markup that forces table flattening.
72
+ */
73
+ const RISKY_TABLE_MARKUP_RE =
74
+ /<w:(ins|del|moveFrom|moveTo|rPrChange|pPrChange|tblPrChange|trPrChange|tcPrChange|sectPrChange|cellIns|cellDel|cellMerge|smartTag)\b/;
75
+
76
+ const SIMPLE_FIELD_INSTR_RE = /\bw:instr="([^"]*)"/g;
77
+
78
+ const COMPLEX_FIELD_TOKEN_RE =
79
+ /<(?:\w+:)?fldChar\b[^>]*?(?:\w+:)?fldCharType="(begin|separate|end)"[^>]*?(?:\/>|>[\s\S]*?<\/(?:\w+:)?fldChar>)|<(?:\w+:)?instrText\b[^>]*>([\s\S]*?)<\/(?:\w+:)?instrText>/gu;
80
+
81
+ /**
82
+ * Decode the minimal set of XML entities that can appear inside an
83
+ * `instrText` payload. `classifyFieldInstruction` consumes the raw
84
+ * instruction text, so we normalize `&amp;` / `&quot;` / `&lt;` /
85
+ * `&gt;` / `&apos;` before handing it off. Matches the pre-existing
86
+ * behavior in `parse-main-document.ts::extractComplexFieldInstructions`.
87
+ */
88
+ function decodeXmlEntities(text: string): string {
89
+ return text
90
+ .replace(/&lt;/g, "<")
91
+ .replace(/&gt;/g, ">")
92
+ .replace(/&quot;/g, '"')
93
+ .replace(/&apos;/g, "'")
94
+ .replace(/&amp;/g, "&");
95
+ }
96
+
97
+ /**
98
+ * Extract every complex-field instruction (`<w:fldChar
99
+ * fldCharType="begin">...<w:instrText>...</w:instrText>...</w:fldChar
100
+ * fldCharType="end">`) as a single concatenated instruction string.
101
+ * Mirrors `parse-main-document.ts::extractComplexFieldInstructions`.
102
+ */
103
+ export function extractComplexFieldInstructionsFromRaw(rawXml: string): string[] {
104
+ const instructions: string[] = [];
105
+ let active = "";
106
+ let capturing = false;
107
+ for (const match of rawXml.matchAll(COMPLEX_FIELD_TOKEN_RE)) {
108
+ const [, fldCharType, instrText] = match;
109
+ if (fldCharType === "begin") {
110
+ active = "";
111
+ capturing = true;
112
+ continue;
113
+ }
114
+ if (fldCharType === "separate" || fldCharType === "end") {
115
+ if (capturing && active.trim().length > 0) instructions.push(active);
116
+ active = "";
117
+ capturing = false;
118
+ continue;
119
+ }
120
+ if (capturing && instrText !== undefined) active += decodeXmlEntities(instrText);
121
+ }
122
+ return instructions;
123
+ }
124
+
125
+ /**
126
+ * Legacy form fields (ECMA-376 §17.16.21) — `FORMTEXT`, `FORMCHECKBOX`,
127
+ * `FORMDROPDOWN`. These are fully supported by the body-direct
128
+ * paragraph parser via `parseFFDataFromFldChar` but classify as
129
+ * `UNKNOWN` under `FIELD_FAMILY_PATTERN` (which targets data-field
130
+ * families like REF / TOC / MERGEFIELD). Short-circuiting them lets
131
+ * form-field cells stay in structured canonical tables instead of
132
+ * flattening the entire table to `opaque_block`. Coord-01 §11, 2026-04-24.
133
+ */
134
+ export function isLegacyFormFieldInstruction(instruction: string): boolean {
135
+ return /^\s*(FORMTEXT|FORMCHECKBOX|FORMDROPDOWN)\b/i.test(instruction);
136
+ }
137
+
138
+ /**
139
+ * Decides whether a single field instruction (either `w:instr`
140
+ * attribute value or concatenated `instrText` run) is safe for
141
+ * structured-table parsing. Used by the shared predicate below;
142
+ * exposed for direct callers (the debug diagnostics script runs
143
+ * this to classify source instructions alongside the canonical).
144
+ */
145
+ export function isSafeTableFieldInstruction(instruction: string): boolean {
146
+ if (isLegacyFormFieldInstruction(instruction)) return true;
147
+ const family = classifyFieldInstruction(instruction).family;
148
+ return SAFE_TABLE_FIELD_FAMILIES.has(family);
149
+ }
150
+
151
+ /**
152
+ * Shared `<w:tbl>` opaque-preservation predicate. Returns `true`
153
+ * when the table must fall back to `opaque_block` for lossless
154
+ * round-trip, `false` when `parseTable` / `parseSimpleTableElement`
155
+ * can promote it to a structured `TableNode`.
156
+ *
157
+ * Callers (`parse-main-document.ts`, `parse-headers-footers.ts`,
158
+ * `parse-footnotes.ts`) pass the raw XML of the `<w:tbl>` element.
159
+ */
160
+ export function tableRequiresOpaquePreservation(rawXml: string): boolean {
161
+ if (RISKY_TABLE_MARKUP_RE.test(rawXml)) return true;
162
+
163
+ const simpleInstructions = [...rawXml.matchAll(SIMPLE_FIELD_INSTR_RE)].map(
164
+ (match) => match[1] ?? "",
165
+ );
166
+ const complexInstructions = extractComplexFieldInstructionsFromRaw(rawXml);
167
+ for (const instruction of [...simpleInstructions, ...complexInstructions]) {
168
+ if (!isSafeTableFieldInstruction(instruction)) return true;
169
+ }
170
+ return false;
171
+ }
@@ -1036,6 +1036,9 @@ function normalizeViewportRanges(
1036
1036
  for (let i = 1; i < cleaned.length; i += 1) {
1037
1037
  const next = cleaned[i]!;
1038
1038
  const last = merged[merged.length - 1]!;
1039
+ // Mutation is safe: `last` is a fresh object from the `.map` step above,
1040
+ // not aliased with any caller-provided input. The `Object.freeze` at the
1041
+ // return site only runs after the merge loop finishes.
1039
1042
  if (next.start <= last.end) {
1040
1043
  if (next.end > last.end) last.end = next.end;
1041
1044
  } else {
@@ -1372,6 +1375,17 @@ export function createDocumentRuntime(
1372
1375
  | {
1373
1376
  revisionToken: string;
1374
1377
  activeStoryKey: string;
1378
+ /**
1379
+ * Serialized viewport ranges at build time. A surface snapshot is
1380
+ * only reusable for a subsequent `getCachedSurface` call if the
1381
+ * runtime's current `viewportRangesKey` matches — otherwise the
1382
+ * cached snapshot realizes a different set of blocks as real vs
1383
+ * placeholder-culled than the current runtime state demands.
1384
+ * Pre-refactor/11b this was a (revisionToken, activeStoryKey) key
1385
+ * only, which was silently stale-prone when callers applied new
1386
+ * viewport ranges without also calling `requestViewportRefresh()`.
1387
+ */
1388
+ viewportRangesKey: string;
1375
1389
  snapshot: RuntimeRenderSnapshot["surface"];
1376
1390
  }
1377
1391
  | undefined;
@@ -1533,7 +1547,8 @@ export function createDocumentRuntime(
1533
1547
  if (
1534
1548
  cachedSurface &&
1535
1549
  cachedSurface.revisionToken === state.revisionToken &&
1536
- cachedSurface.activeStoryKey === activeStoryKey
1550
+ cachedSurface.activeStoryKey === activeStoryKey &&
1551
+ cachedSurface.viewportRangesKey === viewportRangesKey
1537
1552
  ) {
1538
1553
  return cachedSurface.snapshot;
1539
1554
  }
@@ -1549,8 +1564,13 @@ export function createDocumentRuntime(
1549
1564
  cachedSurface = {
1550
1565
  revisionToken: state.revisionToken,
1551
1566
  activeStoryKey,
1567
+ viewportRangesKey,
1552
1568
  snapshot,
1553
1569
  };
1570
+ // Keep the scroll-path fingerprint in lockstep so a subsequent
1571
+ // `maybeRefreshSurfaceForViewport` sees the freshly-built snapshot
1572
+ // and short-circuits instead of paying a redundant projection.
1573
+ cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
1554
1574
  return snapshot;
1555
1575
  }
1556
1576
 
@@ -2394,6 +2414,7 @@ export function createDocumentRuntime(
2394
2414
  cachedSurface = {
2395
2415
  revisionToken: state.revisionToken,
2396
2416
  activeStoryKey: storyTargetKey(activeStory),
2417
+ viewportRangesKey,
2397
2418
  snapshot: newSurface,
2398
2419
  };
2399
2420
  cachedRenderSnapshot = {
@@ -2427,6 +2448,12 @@ export function createDocumentRuntime(
2427
2448
 
2428
2449
  function invalidateDerivedRuntimeCaches(): void {
2429
2450
  cachedSurface = undefined;
2451
+ // Keep the scroll-path fingerprint in lockstep with `cachedSurface`.
2452
+ // Otherwise a post-commit `requestViewportRefresh` would compute a new
2453
+ // fingerprint, see it differs from the stale pre-commit value, and
2454
+ // rebuild + broadcast even when the freshly-hydrated `cachedSurface`
2455
+ // already matches — wasted projection + listener fan-out.
2456
+ cachedSurfaceFingerprint = null;
2430
2457
  cachedCompatibility = undefined;
2431
2458
  cachedComments = undefined;
2432
2459
  cachedTrackedChanges = undefined;
@@ -106,6 +106,15 @@ export interface SurfaceProjectionOptions {
106
106
  * block stays real without realizing the document-scale gap between the
107
107
  * two). Callers still supplying the legacy scalar `viewportBlockRange` get
108
108
  * wrapped into a single-element array internally.
109
+ *
110
+ * **Invariant (caller responsibility):** intervals must be sorted ascending
111
+ * by `start` and non-overlapping. The projection function itself only
112
+ * checks membership — it does not sort or merge — so an unsorted input
113
+ * produces a correct `isInViewport` answer but the snapshot's
114
+ * `viewportBlockRanges` field carries the unnormalized shape to downstream
115
+ * consumers (e.g. the surface-build-key serializer). Runtime callers
116
+ * route through `DocumentRuntime.applyViewportRanges` which normalizes;
117
+ * if you call `createEditorSurfaceSnapshot` directly, normalize first.
109
118
  */
110
119
  viewportBlockRanges?: readonly { start: number; end: number }[] | null;
111
120
  /** @deprecated use `viewportBlockRanges`. Kept for back-compat; wrapped into a 1-element array when supplied alone. */