Skip to content

[6.x] Improve Bard/Replicator performance on large documents#15057

Open
SteJW wants to merge 2 commits into
statamic:6.xfrom
SteJW:fix/bard-large-document-performance
Open

[6.x] Improve Bard/Replicator performance on large documents#15057
SteJW wants to merge 2 commits into
statamic:6.xfrom
SteJW:fix/bard-large-document-performance

Conversation

@SteJW

@SteJW SteJW commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Performance fixes for the CP with large Bard/Replicator page-builder content
(see #13385). Builds on the unmerged parts of #14512 — combining its ideas
with an additional Vue-reactivity fix that PR was missing — plus one
unrelated bug found while investigating: the Publish button can get stuck
disabled after Save on large entries.

Scoped deliberately narrow, per @jasonvarga's note on #14512 ("way too much
to be in one PR"). This covers Bard-specific performance only. It does not
include #14512's Replicator/Grid-level changes (replicator/Set.vue,
Replicator.vue) — those are independent and better suited to their own PR.

1. BardFieldtype.vueonUpdate cost on every transaction

onUpdate previously did, on every Tiptap transaction (including plain
cursor moves before this change):

const oldJson = this.json;
const newJson = clone(this.editor.getJSON().content);       // JSON.parse(JSON.stringify())
const countNodes = (nodes) => { /* recursive walk, run on BOTH trees */ };
if (countNodes(oldJson) !== countNodes(newJson)) this.debounceNextUpdate = false;
this.json = newJson;

For a large document this clones and recursively walks the whole tree twice
per keystroke. Separately, this.json is a Vue data() property, so Vue
deep-reactive-proxies the entire tree — every nested node gets its own Proxy
plus a dependency-tracking map, regenerated on every update. Traced with
Chrome DevTools: a single RunMicrotasks task blocked the main thread for
~7s on one large real-world document, dominated by Vue's internal
track/trigger machinery walking that Proxy tree. Independently
corroborated on #13385 by @jasonbaciulis, who profiled 8,295 microtasks and
~3.7s on a single Enter keypress with matching root cause.

This PR combines two complementary fixes:

One behavior change worth discussing directly, not silently deciding:
doc.content.size changes on every keystroke, not just structural
edits — broader than the original countNodes()'s intent (distinguishing
"typed a character" from "added/removed a block"). If debounceNextUpdate
being reset more often than before turns out to matter, the safer fallback
is comparing this.json.length (top-level node count) instead — cheaper
than the original recursive walk while preserving the original's coarser
granularity. Flagging this rather than picking one silently.

2. Fieldtype.vue — targeted condition watching (from #14512, unmerged)

publishContainer unwrapped every injected ref via
Object.fromEntries(Object.entries(...).map(...)) — reading every ref's
.value up front. Since this is a Vue computed, it has a reactive
dependency on all of them, so it recomputes for every field on the page
whenever any one of them changes, cascading condition re-evaluation
broadly instead of scoping to what each field's own condition actually
reads.

Replaced with a cache built once, using lazy per-key getters
(Object.defineProperty with get: () => val.value for ref-backed keys).
The outer object itself never invalidates; reading a specific key still
goes through to the live ref, so dependency tracking happens correctly in
whichever consumer's own reactive scope reads it.

Adjusted from #14512's original patch to preserve this branch's
UPDATE_DEBOUNCE_MS constant (extracted since #14512 was forked) rather
than reverting to a hardcoded 150.

3. Set.vue (Bard) — staggered field mounting (from #14512, unmerged, reconciled)

New resources/js/util/createMountScheduler.js (included as-is): batches
"mount" callbacks and drains them incrementally via requestIdleCallback
(falling back to requestAnimationFrame), yielding to the browser between
each and awaiting nextTick() so Vue can flush DOM updates incrementally
instead of all at once. This directly targets the root cause behind the
initial-render freeze: a document with many Sets currently mounts every
Set's full field UI synchronously in one block.

BardFieldtype.vue now provides a mountScheduler instance; each Set.vue
defers rendering its own fields (fieldsReady) until the scheduler gets to
it, rather than all Sets doing so in the same tick on initial expand.

Reconciliation note: #14512's fork was based on an older snapshot of
the 6.x branch and had drifted from current 6.x — its version of
Set.vue was missing RTL direction support (:dir="uiDirection") and a
drag-selection fix (preventNodeSelectionDrag) that have since landed
upstream. This PR's Set.vue is hand-reconciled against the current
branch tip with both of those preserved; it is not a copy of #14512's file.
Also folded in from the same diff: a small MutationObserver debounce via
requestAnimationFrame (avoids redundant setAttribute calls firing
synchronously on every mutation), and a v-if+v-for cleanup on the same
element in the field-actions dropdown (Vue anti-pattern, was also missing
a :key).

4. PublishForm.vue — Publish button stuck disabled after Save

Unrelated to #14512/#13385's thread but found while investigating the same
class of large-document performance problem. canPublish returns false
whenever isDirty is true. After Save, trackDirtyState is set false,
values is reset from the response, and after a fixed 500ms timeout,
dirty-tracking re-enables (comment in source: "after any fieldtypes do a
debounced update"). On a large Bard document, the fieldtype's own
post-reset update cycle can exceed 500ms even after fix #1 above, so the
entry gets marked dirty again right after the grace window closes, and the
Publish button stays disabled until another edit or a full reload.

Bumped the fixed delay from 500ms to 3000ms across all 5 call sites in this
file (save, quick-save, editLocalization, createLocalization branches).
This is a mitigation, not a structural fix — a large enough document could
still exceed a fixed timeout. A more robust approach would track pending
fieldtype updates directly and wait for those to settle rather than
guessing a duration; happy to explore that instead if maintainers prefer.

Testing

  • Verified via Chrome performance trace that the multi-second
    RunMicrotasks block on onUpdate disappears after fix First #1.
  • Verified directly against Vue 3.4's reactive()/markRaw() that a
    frozen-object approach (tried and rejected during development) throws
    under nested reactive access, while markRaw() does not — see prior
    discussion, not repeated here.
  • createMountScheduler.js includes its original unit tests from [6.x] Improve CP performance: staggered set mounting, fallback previews, targeted condition watching #14512
    (resources/js/tests/createMountScheduler.test.js), included unchanged.
  • Manually tested Set collapse/expand, drag reordering, RTL layout, and the
    Save → Publish flow on a large entry.

Files changed

  • resources/js/components/fieldtypes/bard/BardFieldtype.vue
  • resources/js/components/fieldtypes/Fieldtype.vue
  • resources/js/components/fieldtypes/bard/Set.vue
  • resources/js/util/createMountScheduler.js (new)
  • resources/js/tests/createMountScheduler.test.js (new)
  • resources/js/components/entries/PublishForm.vue

Credits

Fixes #1 and #3 build directly on work originally proposed by @waldemar-p
in #14512. Fix #2 is also from #14512. Fix #4 and the markRaw() addition
to fix #1 are new here.

@SteJW SteJW changed the title Improve Bard/Replicator performance on large documents [6.x] Improve Bard/Replicator performance on large documents Jul 22, 2026
@joshuablum

Copy link
Copy Markdown
Member

Hey @SteJW,
Thank you for the PR and the write up!

Could you please elaborate on the following?

  • Does markRaw() drop field edits made inside a set? The emitted JSON lands in the store raw, so values.value[bard] reads back non reactive. Set.vue's { deep: true } sync watcher then establishes no deps over that raw subtree, so a nested field edit never fires it and updateAttributes never runs. An immediate save persists the edit but the next onUpdate reserializes from the stale doc and silently overwrites it. Could you try to expand a set, edit a field, type in the Bard body, save, and confirm the edit survives?

@SteJW

SteJW commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

It turns out there was still a bug while adding new items to the page builder. I reverted the changes to work on it some more.
The only change that remains is simple, adding more time to the trackDirtyState. That prevents the publish button to stay disabled while making changes to an entry.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Asset Container URLs Pagination First

2 participants