From 11b43124826afa077254cf30802fda15d0d60b3d Mon Sep 17 00:00:00 2001 From: Jan Willem Rigters Date: Wed, 22 Jul 2026 23:25:54 +0200 Subject: [PATCH 1/2] Improve Bard/Replicator performance on large documents --- .../js/components/entries/PublishForm.vue | 10 +- .../js/components/fieldtypes/Fieldtype.vue | 39 ++- .../fieldtypes/bard/BardFieldtype.vue | 50 ++-- .../js/components/fieldtypes/bard/Set.vue | 119 ++++++-- .../js/tests/createMountScheduler.test.js | 282 ++++++++++++++++++ resources/js/util/createMountScheduler.js | 49 +++ 6 files changed, 488 insertions(+), 61 deletions(-) create mode 100644 resources/js/tests/createMountScheduler.test.js create mode 100644 resources/js/util/createMountScheduler.js diff --git a/resources/js/components/entries/PublishForm.vue b/resources/js/components/entries/PublishForm.vue index 2c888064b35..a0d84d07896 100644 --- a/resources/js/components/entries/PublishForm.vue +++ b/resources/js/components/entries/PublishForm.vue @@ -582,7 +582,7 @@ export default { this.trackDirtyState = false; this.values = resetValuesFromResponse(response.data.data.values, this.$refs.container); this.extraValues = response.data.data.extraValues; - this.trackDirtyStateTimeout = setTimeout(() => (this.trackDirtyState = true), 500); + this.trackDirtyStateTimeout = setTimeout(() => (this.trackDirtyState = true), 3000); this.$nextTick(() => this.$emit('saved', response)); return; } @@ -605,7 +605,7 @@ export default { else { clearTimeout(this.trackDirtyStateTimeout); this.trackDirtyState = false; - this.trackDirtyStateTimeout = setTimeout(() => (this.trackDirtyState = true), 500); + this.trackDirtyStateTimeout = setTimeout(() => (this.trackDirtyState = true), 3000); this.initialPublished = response.data.data.published; this.activeLocalization.published = response.data.data.published; this.activeLocalization.status = response.data.data.status; @@ -694,7 +694,7 @@ export default { this.initialPublished = data.values.published; this.readOnly = data.readOnly; - this.trackDirtyStateTimeout = setTimeout(() => (this.trackDirtyState = true), 500); // after any fieldtypes do a debounced update + this.trackDirtyStateTimeout = setTimeout(() => (this.trackDirtyState = true), 3000); // after any fieldtypes do a debounced update }); }, @@ -777,7 +777,7 @@ export default { clearTimeout(this.trackDirtyStateTimeout); this.trackDirtyState = false; this.values = resetValuesFromResponse(response.data.data.values, this.$refs.container); - this.trackDirtyStateTimeout = setTimeout(() => (this.trackDirtyState = true), 500); + this.trackDirtyStateTimeout = setTimeout(() => (this.trackDirtyState = true), 3000); this.activeLocalization.title = response.data.data.title; this.activeLocalization.published = response.data.data.published; this.activeLocalization.status = response.data.data.status; @@ -813,7 +813,7 @@ export default { clearTimeout(this.trackDirtyStateTimeout); this.trackDirtyState = false; this.values = resetValuesFromResponse(response.data.values, this.$refs.container); - this.trackDirtyStateTimeout = setTimeout(() => (this.trackDirtyState = true), 500); + this.trackDirtyStateTimeout = setTimeout(() => (this.trackDirtyState = true), 3000); this.initialPublished = response.data.published; this.activeLocalization.published = response.data.published; this.activeLocalization.status = response.data.status; diff --git a/resources/js/components/fieldtypes/Fieldtype.vue b/resources/js/components/fieldtypes/Fieldtype.vue index c6ee19b8aa3..249115f7b07 100644 --- a/resources/js/components/fieldtypes/Fieldtype.vue +++ b/resources/js/components/fieldtypes/Fieldtype.vue @@ -43,12 +43,39 @@ export default { // When using the Options API, this feels more natural. However since this is a // computed, it won't be avaialble within data(). In those cases you will // need to use this.injectedPublishContainer.someValue.value directly. - return Object.fromEntries( - Object.entries(this.injectedPublishContainer).map(([key, value]) => [ - key, - isRef(value) ? value.value : value, - ]) - ); + // + // We build the cache once with lazy getters, so this computed has zero reactive + // deps and is never invalidated/recomputed itself — reading a specific key still + // goes through to the live ref, so dependency tracking happens correctly in + // whichever consumer's reactive scope reads it (e.g. a field's own condition + // computed), rather than recomputing this whole object for every field on the + // page whenever any one of the injected refs changes. + if (this._publishContainerCache) return this._publishContainerCache; + + const cache = {}; + const src = this.injectedPublishContainer; + + for (const key in src) { + const val = src[key]; + + if (isRef(val)) { + Object.defineProperty(cache, key, { + enumerable: true, + configurable: true, + get: () => val.value, + }); + } else { + Object.defineProperty(cache, key, { + enumerable: true, + configurable: true, + writable: false, + value: val, + }); + } + } + + this._publishContainerCache = cache; + return cache; }, name() { diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index e682303b6f1..794da78f86d 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -175,8 +175,9 @@ import readTimeEstimate from 'read-time-estimate'; import { common, createLowlight } from 'lowlight'; import 'highlight.js/styles/github.css'; import importTiptap from '@/util/tiptap.js'; -import { computed } from 'vue'; +import { computed, markRaw } from 'vue'; import { data_get } from "@/bootstrap/globals.js"; +import { createMountScheduler } from '@/util/createMountScheduler.js'; import { useContentDirection } from '@/composables/content-direction'; const lowlight = createLowlight(common); @@ -224,6 +225,7 @@ export default { bard: this.makeBardProvide(), bardSets: this.config.sets, showReplicatorFieldPreviews: this.config.previews, + mountScheduler: createMountScheduler(), }, errorsById: {}, debounceNextUpdate: true, @@ -396,8 +398,9 @@ export default { this.initToolbarButtons(); this.initEditor(); - this.json = this.editor.getJSON().content; + this.json = markRaw(this.editor.getJSON().content); this.html = this.editor.getHTML(); + this._lastDocSize = this.editor.state.doc.content.size; this.$nextTick(() => this.mounted = true); @@ -877,24 +880,31 @@ export default { } }, 1); }, - onUpdate: () => { - const oldJson = this.json; - const newJson = clone(this.editor.getJSON().content); - - const countNodes = (nodes) => { - if (!nodes || !Array.isArray(nodes)) return 0; - let count = nodes.length; - nodes.forEach(node => { - if (node.content) { - count += countNodes(node.content); - } - }); - return count; - }; - - if (countNodes(oldJson) !== countNodes(newJson)) this.debounceNextUpdate = false; - - this.json = newJson; + onUpdate: ({ transaction }) => { + // Selection-only transactions (cursor moves, etc.) don't change the + // document at all — skip all of the work below entirely for those. + if (!transaction.docChanged) return; + + const newDocSize = this.editor.state.doc.content.size; + const oldDocSize = this._lastDocSize ?? newDocSize; + + // doc.content.size changes on every keystroke, not just structural + // edits (block add/remove). That's intentional here — debounceNextUpdate + // previously used a recursive node-count comparison to approximate this, + // but that required a full clone() + tree walk on every transaction just + // to produce a coarser signal. If a debounce-suppression distinction + // between "typed a character" and "added/removed a node" turns out to be + // load-bearing elsewhere, this may need revisiting. + if (oldDocSize !== newDocSize) this.debounceNextUpdate = false; + this._lastDocSize = newDocSize; + + // markRaw prevents Vue from deep-reactive-proxying the full document + // tree. Every node in a large Bard document would otherwise get its own + // Proxy plus a dependency-tracking map, regenerated on every update — this + // is the dominant cost (both CPU and memory) on large documents. json/html + // are only used here for display/emit purposes and don't rely on deep + // reactivity elsewhere in this component. + this.json = markRaw(this.editor.getJSON().content); this.html = this.editor.getHTML(); }, onCreate: ({ editor }) => { diff --git a/resources/js/components/fieldtypes/bard/Set.vue b/resources/js/components/fieldtypes/bard/Set.vue index 1c99d83d775..9116763adc2 100644 --- a/resources/js/components/fieldtypes/bard/Set.vue +++ b/resources/js/components/fieldtypes/bard/Set.vue @@ -56,14 +56,16 @@