@beyondwork/docx-react-component 1.0.52 → 1.0.53

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 (29) hide show
  1. package/package.json +31 -40
  2. package/src/api/public-types.ts +32 -0
  3. package/src/io/chart-preview-resolver.ts +41 -0
  4. package/src/io/docx-session.ts +187 -17
  5. package/src/runtime/collab/runtime-collab-sync.ts +87 -6
  6. package/src/runtime/document-runtime.ts +159 -0
  7. package/src/runtime/layout/layout-engine-version.ts +40 -2
  8. package/src/runtime/layout/public-facet.ts +43 -1
  9. package/src/runtime/prerender/cache-envelope.ts +30 -0
  10. package/src/runtime/prerender/customxml-cache.ts +17 -3
  11. package/src/runtime/prerender/prerender-document.ts +17 -1
  12. package/src/runtime/render/render-kernel.ts +67 -19
  13. package/src/runtime/surface-projection.ts +28 -0
  14. package/src/runtime/table-schema.ts +27 -0
  15. package/src/runtime/table-style-resolver.ts +51 -0
  16. package/src/ui/editor-runtime-boundary.ts +39 -2
  17. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  18. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
  19. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
  20. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
  21. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +224 -0
  22. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +2 -2
  23. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +2 -2
  24. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +11 -147
  25. package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
  26. package/src/ui-tailwind/theme/editor-theme.css +26 -24
  27. package/src/ui-tailwind/theme/tokens.css +345 -0
  28. package/src/ui-tailwind/theme/tokens.ts +313 -0
  29. package/src/ui-tailwind/theme/use-density.ts +60 -0
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.52",
4
+ "version": "1.0.53",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
- "packageManager": "pnpm@10.30.3",
7
6
  "type": "module",
8
7
  "sideEffects": [
9
8
  "**/*.css"
@@ -93,35 +92,6 @@
93
92
  "./ui-tailwind/theme/editor-theme.css": "./src/ui-tailwind/theme/editor-theme.css"
94
93
  },
95
94
  "types": "./src/index.ts",
96
- "scripts": {
97
- "build": "tsup",
98
- "test": "bash scripts/run-workspace-tests.sh",
99
- "test:repo": "node scripts/ci-check-layout-engine-version.mjs && node scripts/run-repo-tests.mjs core",
100
- "test:repo:all": "node scripts/run-repo-tests.mjs all",
101
- "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
102
- "test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
103
- "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
104
- "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
105
- "test:visual": "VISUAL_SMOKE_PROFILE=bare pnpm exec playwright test --project=chromium",
106
- "test:visual:chrome": "VISUAL_SMOKE_PROFILE=chrome-cycle pnpm exec playwright test --project=chromium",
107
- "visual:list-runs": "node scripts/visual-smoke-list-runs.mjs",
108
- "mcp:visual-smoke": "node scripts/visual-smoke-mcp.mjs",
109
- "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
110
- "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
111
- "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
112
- "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
113
- "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
114
- "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
115
- "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
116
- "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
117
- "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
118
- "wave:launch:managed": "bash scripts/wave-launch.sh",
119
- "wave:status": "bash scripts/wave-status.sh",
120
- "wave:watch": "bash scripts/wave-watch.sh --follow",
121
- "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
122
- "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
123
- "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
124
- },
125
95
  "keywords": [
126
96
  "docx",
127
97
  "word",
@@ -205,14 +175,35 @@
205
175
  "y-protocols": "^1.0.7",
206
176
  "yjs": "^13.6.30"
207
177
  },
208
- "pnpm": {
209
- "onlyBuiltDependencies": [
210
- "esbuild",
211
- "sharp"
212
- ],
213
- "overrides": {
214
- "react": "19.2.4",
215
- "react-dom": "19.2.4"
216
- }
178
+ "scripts": {
179
+ "build": "tsup",
180
+ "test": "bash scripts/run-workspace-tests.sh",
181
+ "test:repo": "node scripts/ci-check-layout-engine-version.mjs && node scripts/run-repo-tests.mjs core",
182
+ "test:repo:all": "node scripts/run-repo-tests.mjs all",
183
+ "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
184
+ "test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
185
+ "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
186
+ "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
187
+ "test:visual": "VISUAL_SMOKE_PROFILE=bare pnpm exec playwright test --project=chromium",
188
+ "test:visual:chrome": "VISUAL_SMOKE_PROFILE=chrome-cycle pnpm exec playwright test --project=chromium",
189
+ "visual:list-runs": "node scripts/visual-smoke-list-runs.mjs",
190
+ "mcp:visual-smoke": "node scripts/visual-smoke-mcp.mjs",
191
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
192
+ "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
193
+ "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
194
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
195
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
196
+ "generate:token-reference": "node scripts/generate-token-reference.mjs",
197
+ "check:token-reference": "node scripts/generate-token-reference.mjs --check",
198
+ "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
199
+ "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
200
+ "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
201
+ "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
202
+ "wave:launch:managed": "bash scripts/wave-launch.sh",
203
+ "wave:status": "bash scripts/wave-status.sh",
204
+ "wave:watch": "bash scripts/wave-watch.sh --follow",
205
+ "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
206
+ "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
207
+ "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
217
208
  }
218
209
  }
@@ -892,6 +892,13 @@ export interface SurfaceTableCellSnapshot {
892
892
  borderRight?: string | null;
893
893
  borderBottom?: string | null;
894
894
  borderLeft?: string | null;
895
+ /**
896
+ * R3.a Phase 2: per-cell text-flow direction copied from
897
+ * `TableCellNode.textDirection`. The node-view maps these to CSS
898
+ * `writing-mode` (`tbRl` → `vertical-rl`, `btLr` → `vertical-lr`, `lrTb` →
899
+ * unset / horizontal default). Cells with no direction stay horizontal.
900
+ */
901
+ textDirection?: "lrTb" | "tbRl" | "btLr" | null;
895
902
  /**
896
903
  * R2a: space-joined CSS class names from the resolved table-style conditional
897
904
  * regions (e.g. "band-firstRow band-band1Horz"). Consumers apply these to the
@@ -985,6 +992,31 @@ export type SurfaceBlockSnapshot =
985
992
  noHBand?: boolean;
986
993
  noVBand?: boolean;
987
994
  };
995
+ /**
996
+ * R3.a Phase 2: resolved table-level properties (width / layoutMode /
997
+ * cellSpacing / borders) projected from the canonical TableNode + its
998
+ * resolved table-style cascade. Borders carry the typed `BorderSpec`
999
+ * per side; the node-view converts top/right/bottom/left to CSS
1000
+ * shorthand. insideH / insideV are stamped here for round-trip and
1001
+ * future renderer use, but at render time their visual effect is
1002
+ * realized through per-cell borders on the cell snapshot (CSS has no
1003
+ * direct "table-inside-border" property).
1004
+ */
1005
+ tableResolved?: {
1006
+ width?: number | null;
1007
+ widthType?: "auto" | "dxa" | "pct" | "nil" | null;
1008
+ layoutMode?: "fixed" | "autofit" | null;
1009
+ cellSpacing?: number | null;
1010
+ cellSpacingType?: "auto" | "dxa" | "pct" | "nil" | null;
1011
+ borders?: {
1012
+ top?: { value?: string; size?: number; space?: number; color?: string } | null;
1013
+ right?: { value?: string; size?: number; space?: number; color?: string } | null;
1014
+ bottom?: { value?: string; size?: number; space?: number; color?: string } | null;
1015
+ left?: { value?: string; size?: number; space?: number; color?: string } | null;
1016
+ insideH?: { value?: string; size?: number; space?: number; color?: string } | null;
1017
+ insideV?: { value?: string; size?: number; space?: number; color?: string } | null;
1018
+ } | null;
1019
+ };
988
1020
  rows: SurfaceTableRowSnapshot[];
989
1021
  }
990
1022
  | {
@@ -138,6 +138,47 @@ export async function resolveChartPreviewsForDocument(
138
138
  return applyResolutions(doc, successful);
139
139
  }
140
140
 
141
+ /**
142
+ * C3b — Phase A/B deferred chart preview resolution.
143
+ *
144
+ * Phase A (cheap, synchronous): check whether there are any unresolved
145
+ * chart_preview nodes. If not, return immediately without scheduling
146
+ * anything. The collect walk is O(paragraphs) and takes ~0 ms.
147
+ *
148
+ * Phase B (deferred): schedule the actual `renderChartPreview` calls
149
+ * outside the critical load path via requestIdleCallback (browser) or
150
+ * setTimeout(0) (Node / SSR). Fires `onReady` with the resolved
151
+ * CanonicalDocument when all charts complete.
152
+ *
153
+ * The caller (loadDocxEditorSessionAsync) returns the session without
154
+ * chart previews. When `onReady` fires, the consumer can update the
155
+ * runtime's media catalog (e.g. via a follow-up `hydrateChartPreviews`
156
+ * call, or by accepting the updated CanonicalDocument into a new
157
+ * progressive render). Expected win: 30–80 ms removed from the warm
158
+ * cold-open path on extra-large docs with ~12 charts.
159
+ */
160
+ export function scheduleChartPreviewResolution(
161
+ doc: CanonicalDocument,
162
+ pkg: OpcPackage,
163
+ adapter: EditorHostAdapter | undefined,
164
+ onReady: (resolved: CanonicalDocument) => void,
165
+ ): void {
166
+ if (!adapter?.renderChartPreview) return;
167
+ // Phase A: fast collect — avoids scheduling idle work when there are no
168
+ // unresolved charts (the common case for most non-chart documents).
169
+ const pending = collectUnresolvedChartPreviews(doc, pkg);
170
+ if (pending.length === 0) return;
171
+
172
+ const schedule =
173
+ typeof requestIdleCallback !== "undefined"
174
+ ? (fn: () => void) => { requestIdleCallback(fn); }
175
+ : (fn: () => void) => { setTimeout(fn, 0); };
176
+
177
+ schedule(() => {
178
+ void resolveChartPreviewsForDocument(doc, pkg, adapter).then(onReady);
179
+ });
180
+ }
181
+
141
182
  /**
142
183
  * Walk the whole content tree and yield one PendingResolution per
143
184
  * chart_preview node that (a) has no previewMediaId yet and (b) can
@@ -3,6 +3,7 @@ import type {
3
3
  EditorError,
4
4
  EditorHostAdapter,
5
5
  EditorSessionState,
6
+ EditorSurfaceSnapshot,
6
7
  EditorWarning as PublicEditorWarning,
7
8
  EditorAnchorProjection as PublicEditorAnchorProjection,
8
9
  ExportDocxOptions,
@@ -26,7 +27,13 @@ import type {
26
27
  RevisionRecord as RuntimeRevisionRecord,
27
28
  EditorWarning as InternalEditorWarning,
28
29
  } from "../core/state/editor-state.ts";
29
- import { createCanonicalDocumentId } from "../core/state/editor-state.ts";
30
+ import {
31
+ createCanonicalDocumentId,
32
+ createDefaultCanonicalDocument,
33
+ createSelectionSnapshot,
34
+ } from "../core/state/editor-state.ts";
35
+ import { createEditorSurfaceSnapshot } from "../runtime/surface-projection.ts";
36
+ import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
30
37
  import {
31
38
  createDetachedAnchor,
32
39
  storyTargetsEqual,
@@ -44,7 +51,7 @@ import {
44
51
  normalizeParsedTextDocument,
45
52
  normalizeParsedTextDocumentAsync,
46
53
  } from "./normalize/normalize-text.ts";
47
- import { createChartPartLookup, resolveChartPreviewsForDocument } from "./chart-preview-resolver.ts";
54
+ import { createChartPartLookup, resolveChartPreviewsForDocument, scheduleChartPreviewResolution } from "./chart-preview-resolver.ts";
48
55
  import { type LoadScheduler } from "./load-scheduler.ts";
49
56
  import type { CacheEnvelope } from "../runtime/prerender/cache-envelope.ts";
50
57
  import {
@@ -122,6 +129,7 @@ import type {
122
129
  SubPartsCatalog,
123
130
  } from "../model/canonical-document.ts";
124
131
  import { createCanonicalDocumentSignature } from "../model/canonical-document.ts";
132
+ import type { CanonicalDocument } from "../model/canonical-document.ts";
125
133
  import type {
126
134
  CommentImportDiagnostic,
127
135
  ImportedCommentDefinition,
@@ -959,6 +967,53 @@ export interface LoadDocxEditorSessionAsyncOptions extends LoadDocxEditorSession
959
967
  * path because their outputs are required by downstream consumers.
960
968
  */
961
969
  laycacheEnvelope?: CacheEnvelope;
970
+ /**
971
+ * L7 Phase 2 Finale C2 — progressive initial mount. When supplied, the
972
+ * async loader fires this callback exactly once, after the body stage
973
+ * completes but before styles / sub-parts / compatibility / snapshot
974
+ * assembly, with a viewport-windowed `EditorSurfaceSnapshot`. The
975
+ * callback lets UI consumers show the first page's text before the
976
+ * rest of the load finishes, measurably shrinking perceived cold-open.
977
+ *
978
+ * The progressive surface is synthesized from a provisional
979
+ * `CanonicalDocumentEnvelope`: `content` is the normalized body; all
980
+ * other catalogs (styles, numbering, media, review, preservation) are
981
+ * empty. Viewport blocks render live; blocks beyond the viewport
982
+ * render as placeholders via the existing `cullBuild` flag. Consumers
983
+ * must treat the progressive surface as provisional — the final
984
+ * `LoadedDocxEditorSession` (returned from the same `await`) carries
985
+ * the real styled envelope.
986
+ *
987
+ * The callback receives `blocksRealized` (the viewport window size)
988
+ * and `blocksTotal` (total block count at body-stage time) so the
989
+ * consumer can size its viewport-commit telemetry.
990
+ *
991
+ * Optional. When absent, the async loader does not perform the
992
+ * provisional-envelope synthesis — no cold-path regression for
993
+ * consumers that do not opt in.
994
+ *
995
+ * Omitted on the Plan B short-circuit path (laycacheEnvelope !== undefined):
996
+ * the short-circuit is already fast enough that a progressive pre-commit
997
+ * adds more overhead than it saves.
998
+ */
999
+ onProgressiveSnapshot?: (partial: {
1000
+ surface: EditorSurfaceSnapshot;
1001
+ phase: "viewport";
1002
+ blocksRealized: number;
1003
+ blocksTotal: number;
1004
+ }) => void;
1005
+ /**
1006
+ * C3b — deferred chart preview resolution. When supplied, chart preview
1007
+ * rendering (`renderChartPreview()` calls) is removed from the critical
1008
+ * load path: the session returns without chart previews, and this
1009
+ * callback fires later (via requestIdleCallback / setTimeout) with the
1010
+ * CanonicalDocument that has all chart previews resolved. Expected win:
1011
+ * 30–80 ms on extra-large warm-path docs with ~12 charts.
1012
+ *
1013
+ * Consumers that need chart previews on first render should not supply
1014
+ * this callback — the blocking path remains available by omitting it.
1015
+ */
1016
+ onChartPreviewsReady?: (resolvedDoc: CanonicalDocument) => void;
962
1017
  }
963
1018
 
964
1019
  /**
@@ -979,6 +1034,23 @@ export interface LoadDocxEditorSessionAsyncOptions extends LoadDocxEditorSession
979
1034
  * and SSR. The DOM boundary in `editor-runtime-boundary.ts` calls this
980
1035
  * async path so the browser can paint the skeleton mid-parse.
981
1036
  */
1037
+
1038
+ /**
1039
+ * L7 Phase 2 Finale C2 — progressive initial mount viewport window size.
1040
+ *
1041
+ * Sized to cover the first page of a typical paginated document (~20
1042
+ * body blocks = ~1 page on CCEP-scale templates at default margins). The
1043
+ * window is intentionally small so the provisional-envelope synthesis +
1044
+ * surface projection stays under ~30 ms — making `firstViewportCommit`
1045
+ * significantly faster than the full load.
1046
+ *
1047
+ * Blocks beyond this window render as `placeholder-culled` entries via
1048
+ * Phase 2.9's cullBuild flag (auto-derived from `!isInViewport`) — they
1049
+ * preserve `from`/`to` offsets for selection stability while costing ~0
1050
+ * style-cascade work.
1051
+ */
1052
+ const PROGRESSIVE_VIEWPORT_BLOCKS = 20;
1053
+
982
1054
  export async function loadDocxEditorSessionAsync(
983
1055
  options: LoadDocxEditorSessionAsyncOptions,
984
1056
  ): Promise<LoadedDocxEditorSession> {
@@ -1090,18 +1162,45 @@ export async function loadDocxEditorSessionAsync(
1090
1162
  const protectionSnapshot = buildProtectionSnapshot(documentProtection, []);
1091
1163
 
1092
1164
  // Chart previews (`previewMediaId` is host-dependent) aren't cached
1093
- // in the envelope, so we still resolve them on the short-circuit.
1094
- const documentWithChartPreviews = (await resolveChartPreviewsForDocument(
1095
- canonicalDocument,
1096
- sourcePackage,
1097
- options.hostAdapter,
1098
- )) as CanonicalDocumentEnvelope;
1165
+ // in the envelope. C3b: when onChartPreviewsReady is provided, defer
1166
+ // resolution out of the critical path. Otherwise block (legacy behavior).
1167
+ let documentWithChartPreviews: CanonicalDocumentEnvelope;
1168
+ if (options.onChartPreviewsReady) {
1169
+ scheduleChartPreviewResolution(
1170
+ canonicalDocument,
1171
+ sourcePackage,
1172
+ options.hostAdapter,
1173
+ options.onChartPreviewsReady,
1174
+ );
1175
+ documentWithChartPreviews = canonicalDocument as CanonicalDocumentEnvelope;
1176
+ } else {
1177
+ documentWithChartPreviews = (await resolveChartPreviewsForDocument(
1178
+ canonicalDocument,
1179
+ sourcePackage,
1180
+ options.hostAdapter,
1181
+ )) as CanonicalDocumentEnvelope;
1182
+ }
1099
1183
 
1100
1184
  const timestamp = new Date().toISOString();
1101
- const compatibility = buildCompatibilityReport({
1102
- document: documentWithChartPreviews,
1103
- generatedAt: timestamp,
1104
- });
1185
+ // Phase 2 Finale C3: skip `buildCompatibilityReport` (60–100 ms on
1186
+ // extra-large) when the envelope carries a pre-computed report.
1187
+ // Pure-function determinism of the report is enforced by
1188
+ // `canonicalDocumentHash` (5th input to `deriveCacheKey`): any
1189
+ // change to the canonical doc flips the hash and rejects the
1190
+ // envelope on load.
1191
+ //
1192
+ // The cached report's `generatedAt` is a fixed sentinel
1193
+ // (`CACHE_NORMALIZED_GENERATED_AT`) for envelope byte-identity.
1194
+ // Swap it for the live ISO8601 timestamp here because downstream
1195
+ // `validatePersistedEditorSnapshot` requires
1196
+ // `$.compatibility.generatedAt` to be ISO 8601.
1197
+ const cachedReport = options.laycacheEnvelope?.compatibilityReport;
1198
+ const compatibility = cachedReport
1199
+ ? { ...cachedReport, generatedAt: timestamp }
1200
+ : buildCompatibilityReport({
1201
+ document: documentWithChartPreviews,
1202
+ generatedAt: timestamp,
1203
+ });
1105
1204
  await scheduler.yield();
1106
1205
 
1107
1206
  const snapshot = createImportedSnapshot({
@@ -1225,6 +1324,64 @@ export async function loadDocxEditorSessionAsync(
1225
1324
  );
1226
1325
  stages.emit("body");
1227
1326
  await scheduler.yield();
1327
+
1328
+ // L7 Phase 2 Finale C2 — progressive initial mount.
1329
+ //
1330
+ // Fire `onProgressiveSnapshot` exactly once, after the body stage and
1331
+ // its post-yield. At this point `normalizedDocument.content` carries
1332
+ // the full block tree with per-block runProperties already resolved
1333
+ // during `normalizeParsedTextDocumentAsync`. We synthesize a
1334
+ // throw-away `CanonicalDocumentEnvelope` using the normalized content
1335
+ // + empty style/review/preservation catalogs, then project a
1336
+ // viewport-windowed `EditorSurfaceSnapshot` (first `PROGRESSIVE_VIEWPORT_BLOCKS`
1337
+ // blocks real, rest as culled placeholders via the Phase 2.9 flag).
1338
+ //
1339
+ // The bench's measured signal: time from `loadDocxEditorSessionAsync`
1340
+ // entry to this callback's fire is `firstViewportCommitMs` — the
1341
+ // metric C2 gates on.
1342
+ //
1343
+ // Skipped on the Plan B short-circuit: `laycacheEnvelope !== undefined`
1344
+ // already completes ~376 ms faster than cold — adding a progressive
1345
+ // synthesis on top costs more than it saves. The short-circuit path
1346
+ // returns the real snapshot fast enough.
1347
+ if (
1348
+ options.onProgressiveSnapshot !== undefined &&
1349
+ options.laycacheEnvelope === undefined
1350
+ ) {
1351
+ const provisionalDoc: CanonicalDocumentEnvelope = {
1352
+ ...createDefaultCanonicalDocument(
1353
+ options.documentId,
1354
+ new Date().toISOString(),
1355
+ ),
1356
+ content: normalizedDocument.content,
1357
+ };
1358
+ const blocksTotal = normalizedDocument.content.children.length;
1359
+ const blocksRealized = Math.min(
1360
+ PROGRESSIVE_VIEWPORT_BLOCKS,
1361
+ blocksTotal,
1362
+ );
1363
+ const progressiveSurface = createEditorSurfaceSnapshot(
1364
+ provisionalDoc,
1365
+ createSelectionSnapshot(0, 0),
1366
+ MAIN_STORY_TARGET,
1367
+ blocksRealized < blocksTotal
1368
+ ? { viewportBlockRange: { start: 0, end: blocksRealized } }
1369
+ : undefined,
1370
+ );
1371
+ try {
1372
+ options.onProgressiveSnapshot({
1373
+ surface: progressiveSurface,
1374
+ phase: "viewport",
1375
+ blocksRealized,
1376
+ blocksTotal,
1377
+ });
1378
+ } catch {
1379
+ // A throwing consumer must not abort the load. Progressive is
1380
+ // a best-effort optimization; errors on the callback side
1381
+ // silently fall through to the normal full-commit path.
1382
+ }
1383
+ }
1384
+
1228
1385
  const commentsPartPath = resolveCommentsPartPath(
1229
1386
  sourcePackage,
1230
1387
  mainDocumentPath,
@@ -1579,11 +1736,24 @@ export async function loadDocxEditorSessionAsync(
1579
1736
  // chart_preview nodes inline so the first snapshot already carries the
1580
1737
  // synthesized `previewMediaId`. Fallback-safe: returning null or throwing
1581
1738
  // is per-chart — the typed badge renders as if the adapter weren't set.
1582
- const document = (await resolveChartPreviewsForDocument(
1583
- importedDocument,
1584
- sourcePackage,
1585
- options.hostAdapter,
1586
- )) as CanonicalDocumentEnvelope;
1739
+ // C3b: when onChartPreviewsReady is provided, defer resolution out of
1740
+ // the critical path (same pattern as the short-circuit branch above).
1741
+ let document: CanonicalDocumentEnvelope;
1742
+ if (options.onChartPreviewsReady) {
1743
+ scheduleChartPreviewResolution(
1744
+ importedDocument,
1745
+ sourcePackage,
1746
+ options.hostAdapter,
1747
+ options.onChartPreviewsReady,
1748
+ );
1749
+ document = importedDocument as CanonicalDocumentEnvelope;
1750
+ } else {
1751
+ document = (await resolveChartPreviewsForDocument(
1752
+ importedDocument,
1753
+ sourcePackage,
1754
+ options.hostAdapter,
1755
+ )) as CanonicalDocumentEnvelope;
1756
+ }
1587
1757
  const compatibility = buildCompatibilityReport({
1588
1758
  document,
1589
1759
  generatedAt: timestamp,
@@ -11,6 +11,7 @@ import type { BlockNode } from "../../model/canonical-document.ts";
11
11
  import type {
12
12
  CommandAppliedMeta,
13
13
  DocumentRuntime,
14
+ RemoteCommandEnvelope,
14
15
  Unsubscribe,
15
16
  } from "../document-runtime.ts";
16
17
  import {
@@ -200,6 +201,30 @@ export interface RuntimeCollabSyncOptions {
200
201
  * - `"observer"`: all writes refused with `collab_observer_readonly`.
201
202
  */
202
203
  role?: "author" | "reviewer" | "observer";
204
+ /**
205
+ * R1 — coalesce inbound remote-replay events into one commit per
206
+ * animation frame. When `true` (default), N events arriving within a
207
+ * single rAF tick (or `setTimeout(0)` in non-DOM contexts) drain via
208
+ * one `runtime.applyRemoteCommandBatch(...)` call → one
209
+ * `refreshRenderSnapshot` / one `notify`. Observer semantics change:
210
+ * subscribers see one `DocumentRuntimeEvent` per burst rather than
211
+ * one per event. Hosts that depend on per-event semantics (rare —
212
+ * counted-event tests, granular per-comment-add observers) opt out
213
+ * with `false`. The whole batch is atomic: any per-command effect
214
+ * collision falls back to per-event replay automatically.
215
+ *
216
+ * Defaults to `true` in both DOM and Node contexts so tests exercise
217
+ * the coalesced path without ceremony.
218
+ */
219
+ coalesceRemoteReplay?: boolean;
220
+ /**
221
+ * R1 hook point for R3 idle-priority plumbing. Invoked once per
222
+ * scheduled drain (NOT per queued event). R3's future implementation
223
+ * will use this to abort any in-flight idle prerender task on
224
+ * `src/io/load-scheduler.ts` so the upcoming commit doesn't compete
225
+ * with idle work. Safe to omit; R1 functions identically without it.
226
+ */
227
+ onRemoteReplayScheduled?: () => void;
203
228
  }
204
229
 
205
230
  export interface RuntimeCollabSyncHandle {
@@ -268,6 +293,48 @@ export function createRuntimeCollabSync(
268
293
  let readOnly = false;
269
294
  let baseDocFingerprint: string | null = null;
270
295
 
296
+ // R1 — coalesced remote-replay scheduler. Default: ON in both DOM and
297
+ // Node contexts. Falls back to setTimeout(0) when requestAnimationFrame
298
+ // is missing so tests and non-DOM hosts exercise the same drain path.
299
+ const useCoalescing = options.coalesceRemoteReplay !== false;
300
+ const replayQueue: CommandEvent[] = [];
301
+ let rafHandle: number | null = null;
302
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
303
+
304
+ function scheduleDrain(): void {
305
+ if (rafHandle !== null || timeoutHandle !== null) return;
306
+ options.onRemoteReplayScheduled?.();
307
+ if (typeof requestAnimationFrame === "function") {
308
+ rafHandle = requestAnimationFrame(() => {
309
+ rafHandle = null;
310
+ drainReplayQueue();
311
+ });
312
+ } else {
313
+ timeoutHandle = setTimeout(() => {
314
+ timeoutHandle = null;
315
+ drainReplayQueue();
316
+ }, 0);
317
+ }
318
+ }
319
+
320
+ function drainReplayQueue(): void {
321
+ if (replayQueue.length === 0) return;
322
+ const batch = replayQueue.splice(0, replayQueue.length);
323
+ const envelopes = batch.map(eventToEnvelope);
324
+ runtime.applyRemoteCommandBatch(envelopes);
325
+ }
326
+
327
+ function cancelPendingDrain(): void {
328
+ if (rafHandle !== null) {
329
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(rafHandle);
330
+ rafHandle = null;
331
+ }
332
+ if (timeoutHandle !== null) {
333
+ clearTimeout(timeoutHandle);
334
+ timeoutHandle = null;
335
+ }
336
+ }
337
+
271
338
  // Events emitted before any subscriber exists are buffered and flushed
272
339
  // to the first subscriber. This lets hosts react to the attach-path
273
340
  // base-doc mismatch and schema-version mismatch for pre-existing
@@ -494,6 +561,10 @@ export function createRuntimeCollabSync(
494
561
 
495
562
  return {
496
563
  destroy() {
564
+ // R1 — drain any queued replay events synchronously before tearing
565
+ // down so callers don't lose remote events on an early unmount.
566
+ cancelPendingDrain();
567
+ drainReplayQueue();
497
568
  unsubscribeCommandApplied();
498
569
  yEvents.unobserve(onYEventsChange);
499
570
  yMeta.unobserve(checkFingerprintAgainstMeta);
@@ -607,19 +678,29 @@ export function createRuntimeCollabSync(
607
678
  return true;
608
679
  }
609
680
 
610
- function applyEventToRuntime(event: CommandEvent): void {
611
- runtime.applyRemoteCommand(
612
- event.command,
613
- {
681
+ function eventToEnvelope(event: CommandEvent): RemoteCommandEnvelope {
682
+ return {
683
+ command: event.command,
684
+ context: {
614
685
  timestamp: event.timestamp,
615
686
  documentMode: event.context.documentMode,
616
687
  defaultAuthorId: event.context.defaultAuthorId ?? event.authorId,
617
688
  },
618
- {
689
+ meta: {
619
690
  preSelection: event.context.preSelection,
620
691
  activeStory: event.context.activeStory,
621
692
  },
622
- );
693
+ };
694
+ }
695
+
696
+ function applyEventToRuntime(event: CommandEvent): void {
697
+ if (useCoalescing) {
698
+ replayQueue.push(event);
699
+ scheduleDrain();
700
+ return;
701
+ }
702
+ const env = eventToEnvelope(event);
703
+ runtime.applyRemoteCommand(env.command, env.context, env.meta);
623
704
  }
624
705
  }
625
706