Skip to content

Review-geometry publisher sweeps the whole document on every frame of a scroll, in a host that renders no review sidebar #4010

Description

@ZacharyHampton

Package: superdoc@2.13.0 / @superdoc/docx-engine@0.12.0
Browser: Chrome 141, macOS 15
Severity: main thread blocked for seconds on a document whose only unusual property is that it has many tracked insertions.

Summary

A host that sets ui: { comments: false } and never mounts a review sidebar still pays
for the v2 review-geometry publisher. On every animation frame of a scroll, the publisher
re-measures every tracked-change carrier painted anywhere in the document with
Element.getClientRects(), and publishes positions nothing reads.

The engine already has the gate that would stop this — shouldRenderReviewInViewing, which
the publisher consults as isCommentsEnabled() — but it returns true unconditionally
outside documentMode: 'viewing':

// superdoc.es.js — the comments store
const isViewingMode = computed(() => viewingVisibility.documentMode === 'viewing')
const shouldRenderReviewInViewing = computed(() => {
  if (!isViewingMode.value) return true            // <-- editing / suggesting: always on
  return viewingVisibility.commentsVisible || viewingVisibility.trackChangesVisible
})

So an editing host cannot opt out, however it is configured. The repro below shows the same
document, the same scroll, measured in both modes.

Minimal reproduction

Three files. make-docx.mjs writes the input, so no binary is attached and the OOXML is
readable.

make-docx.mjs

Builds a DOCX with ROWS × 5 table cells where every cell's text is its own w:ins,
preceded by filler paragraphs so the table starts several pages below the fold. Neutral
content (a greenhouse log).

// bun make-docx.mjs [rows=40] [fillerParagraphs=120]
import JSZip from 'jszip'
import { writeFileSync } from 'node:fs'

const ROWS = Number(process.argv[2] ?? 40)
const FILLER = Number(process.argv[3] ?? 120)

const DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
const PKG = 'http://schemas.openxmlformats.org/package/2006/relationships'
const OFF = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
const AUTHOR = 'Reviewer One'
const DATE = '2026-01-01T00:00:00Z'

let revision = 1000
const insCell = (text) =>
  `<w:tc><w:tcPr><w:tcW w:w="1800" w:type="dxa"/></w:tcPr><w:p>` +
  `<w:ins w:id="${revision++}" w:author="${AUTHOR}" w:date="${DATE}">` +
  `<w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:ins></w:p></w:tc>`

const headerRow =
  '<w:tr>' +
  ['Tray', 'Variety', 'Greenhouse', 'Seedlings', 'Germination']
    .map((t) =>
      `<w:tc><w:tcPr><w:tcW w:w="1800" w:type="dxa"/></w:tcPr><w:p><w:r><w:rPr><w:b/></w:rPr>` +
      `<w:t xml:space="preserve">${t}</w:t></w:r></w:p></w:tc>`)
    .join('') +
  '</w:tr>'

const bodyRows = Array.from({ length: ROWS }, (_, i) => {
  const n = i + 1
  return '<w:tr>' +
    insCell(`Seedling tray ${n}`) +
    insCell(n % 3 === 0 ? 'Perennial' : 'Annual') +
    insCell(`Greenhouse ${String.fromCharCode(65 + (n % 6))}`) +
    insCell(String(120 + n * 7)) +
    insCell(`${(70 + (n % 25)).toFixed(0)}%`) +
    '</w:tr>'
}).join('')

const table =
  `<w:tbl><w:tblPr><w:tblW w:w="9000" w:type="dxa"/><w:tblBorders>` +
  ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
    .map((s) => `<w:${s} w:val="single" w:sz="4" w:color="000000"/>`).join('') +
  `</w:tblBorders></w:tblPr>` +
  `<w:tblGrid>${'<w:gridCol w:w="1800"/>'.repeat(5)}</w:tblGrid>` +
  headerRow + bodyRows + `</w:tbl>`

const filler = Array.from({ length: FILLER }, (_, i) =>
  `<w:p><w:r><w:t xml:space="preserve">Note ${i + 1}. Watering and light hours for the week, ` +
  `kept so the tray counts below can be checked against them.</w:t></w:r></w:p>`).join('')

const documentXml =
  `${DECL}\n<w:document xmlns:w="${W}" xmlns:r="${OFF}"><w:body>` +
  `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Greenhouse Log</w:t></w:r></w:p>` + filler +
  `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Weekly seedling counts</w:t></w:r></w:p>` + table +
  `<w:p><w:pPr><w:sectPr><w:pgSz w:w="12240" w:h="15840"/>` +
  `<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>` +
  `</w:sectPr></w:pPr></w:p></w:body></w:document>`

const contentTypes = `${DECL}
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
</Types>`
const pkgRels = `${DECL}\n<Relationships xmlns="${PKG}"><Relationship Id="rId1" Type="${OFF}/officeDocument" Target="word/document.xml"/></Relationships>`
const docRels = `${DECL}\n<Relationships xmlns="${PKG}">
<Relationship Id="rId1" Type="${OFF}/styles" Target="styles.xml"/>
<Relationship Id="rId2" Type="${OFF}/settings" Target="settings.xml"/></Relationships>`
const styles = `${DECL}\n<w:styles xmlns:w="${W}"><w:docDefaults><w:rPrDefault><w:rPr>
<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman"/><w:sz w:val="22"/></w:rPr></w:rPrDefault>
<w:pPrDefault/></w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style></w:styles>`
const settings = `${DECL}\n<w:settings xmlns:w="${W}"><w:trackChanges/></w:settings>`

const zip = new JSZip()
zip.file('[Content_Types].xml', contentTypes)
zip.folder('_rels').file('.rels', pkgRels)
const word = zip.folder('word')
word.file('document.xml', documentXml)
word.file('styles.xml', styles)
word.file('settings.xml', settings)
word.folder('_rels').file('document.xml.rels', docRels)
writeFileSync(new URL('./tracked-table.docx', import.meta.url),
  await zip.generateAsync({ type: 'nodebuffer' }))
console.log(`${ROWS} rows x 5 cells = ${ROWS * 5} tracked inserts`)

index.html

<!doctype html>
<html><head><meta charset="utf-8" />
<link rel="stylesheet" href="/node_modules/superdoc/dist/style.css" />
<style>
  body { margin: 0; font: 13px system-ui, sans-serif; }
  #out { white-space: pre; padding: 8px 12px; font-family: ui-monospace, monospace; }
  #doc { height: 70vh; overflow: auto; border-top: 1px solid #ccc; }
</style></head>
<body>
<div><button id="run" disabled>measure a scroll into the table</button> <span id="status">loading…</span></div>
<div id="out"></div>
<div id="doc"></div>
<script type="module" src="/main.js"></script>
</body></html>

main.js

import { SuperDoc } from 'superdoc'

const out = document.getElementById('out')
const status = document.getElementById('status')
const log = (s) => { out.textContent += s + '\n' }

let rects = 0
const origRects = Element.prototype.getClientRects
Element.prototype.getClientRects = function () { rects++; return origRects.apply(this, arguments) }

let longMs = 0
const tasks = []
new PerformanceObserver((l) => {
  for (const e of l.getEntries()) { longMs += e.duration; tasks.push(Math.round(e.duration)) }
}).observe({ type: 'longtask', buffered: false })

const bytes = await fetch('/tracked-table.docx').then((r) => r.arrayBuffer())
const file = new File([bytes], 'tracked-table.docx', {
  type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})

// `?mode=viewing` is the control arm.
const MODE = new URLSearchParams(location.search).get('mode') === 'viewing' ? 'viewing' : 'editing'

new SuperDoc({
  selector: '#doc',
  document: file,
  documentMode: MODE,
  ui: { comments: false },       // no review sidebar is mounted by this page
  viewing: { comments: false },
  modules: { trackChanges: { mode: 'review' } },
  telemetry: { enabled: false },
  onReady: () => { status.textContent = 'ready'; document.getElementById('run').disabled = false },
})

document.getElementById('run').onclick = async () => {
  const doc = document.getElementById('doc')
  const carriers = () => document.querySelectorAll('[data-track-change-id]').length
  doc.scrollTop = 0
  await new Promise((r) => setTimeout(r, 1500))
  rects = 0; longMs = 0; tasks.length = 0
  const step = Math.max(200, Math.round(doc.clientHeight * 0.6))
  while (doc.scrollTop + doc.clientHeight < doc.scrollHeight - 1) {
    doc.scrollTop += step
    await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
  }
  await new Promise((r) => setTimeout(r, 3000))
  const N = carriers()
  log(`documentMode                        : ${MODE}`)
  log(`tracked-insert carriers painted (N) : ${N}`)
  log(`Element.getClientRects calls        : ${rects}`)
  log(`  => calls / N                      : ${(rects / Math.max(N, 1)).toFixed(0)} whole-document sweeps`)
  log(`long tasks (>50ms)                  : ${tasks.length}, total ${Math.round(longMs)} ms, worst ${Math.max(0, ...tasks)} ms`)
}

Running it

npm i superdoc jszip vite
node make-docx.mjs 40 120
npx vite            # open http://localhost:5173/ and press the button

Measured

One scroll from the top of the document to the bottom, pressing the button once.

document mode carriers painted (N) getClientRects calls calls / N long tasks
40 rows (200 w:ins cells) editing 200 5,480 27 3, 344 ms total, worst 129 ms
40 rows (200 w:ins cells) viewing, viewing.comments:false 200 0 0 3, 325 ms total, worst 116 ms
80 rows (400 w:ins cells) editing 360 12,115 34 4, 1,020 ms total, worst 340 ms

Two things the control arm settles:

  1. The redlines are still painted in the viewing arm — document.querySelectorAll('.track-insert-dec').length === 200 — so the geometry publisher is not what draws them.
  2. Every one of those getClientRects calls is the publisher's. Turning it off takes the count to zero and changes nothing on screen.

Where the work happens

Stack samples land entirely in the geometry collector inside
@superdoc/docx-engine/dist/docx-engine.es.js: the publisher factory (minified
{publish, recollect, reset, getLastPayload, getLastEpoch}) collects the carrier elements
under the mount container and measures each one with getClientRects. It is re-entered
from two places:

  • superdoc.es.js binds capture-phase scroll and resize listeners on window to
    recollectV2GeometryIfActive() — coalesced to one call per animation frame, but each
    call is a whole-document sweep rather than a sweep of what moved;
  • the publisher's own MutationObserver (attributeFilter: ['data-v2-paint-route-id'],
    subtree: true) calls recollect() directly — not frame-coalesced — whenever a page's
    paint-route id changes, which a virtualized page does on every mount.

So the cost is O(frames of scroll × carriers painted in the whole document), when the
information that actually changed is one page's worth.

What would fix it

Any one of these; the first is the smallest.

  1. Honour the host's opt-out in every document mode. shouldRenderReviewInViewing
    already encodes "nobody is looking at review geometry"; it just short-circuits to true
    outside viewing. Letting an explicit ui.comments === false (or a new
    review: { publishGeometry: false }) reach it in editing and suggesting would let a
    host that mounts no sidebar pay nothing.
  2. Scope the recollect to what changed. The paint-route observer knows which element's
    route id changed; the sweep does not use it.
  3. Frame-coalesce the observer path the way the scroll path already is, and skip the
    sweep entirely when the published positions have no subscriber.

Happy to test a patch against the repro above.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

status: in-progressEngineering work, review, or testing is in progress.

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions