From 46dc57bbda117690ab8178d987874b47e9b18a0c Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 24 Aug 2026 21:31:10 -0400 Subject: [PATCH 1/3] Skip Bard's HTML serialization when reading time is disabled The onUpdate handler serialized the whole document to HTML on every update, but the result is only consumed by the readingTime computed, which only renders when the reading_time config is enabled. The seed in mounted() is left alone so readingTime still has a value on first render. Co-Authored-By: Claude Opus 5 (1M context) --- .../fieldtypes/bard/BardFieldtype.vue | 6 +- .../fieldtypes/bard/BardFieldtype.test.js | 103 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index acfacc43a41..35493f47fc0 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -893,7 +893,11 @@ export default { if (countNodes(oldJson) !== countNodes(newJson)) this.debounceNextUpdate = false; this.json = newJson; - this.html = this.editor.getHTML(); + + // Serializing the whole document is expensive, and reading time is the only thing that needs it. + if (this.config.reading_time) { + this.html = this.editor.getHTML(); + } }, onCreate: ({ editor }) => { const state = editor.view.state; diff --git a/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js new file mode 100644 index 00000000000..a657f16802d --- /dev/null +++ b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js @@ -0,0 +1,103 @@ +import { mount } from '@vue/test-utils'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import * as Globals from '@/bootstrap/globals'; +import BardFieldtype from '@/components/fieldtypes/bard/BardFieldtype.vue'; +import { containerContextKey } from '@/components/ui/Publish/Container.vue'; + +Object.keys(Globals).forEach((fn) => (window[fn] = Globals[fn])); +window.cp_url = (url) => url; +window.__ = (key, replacements = {}) => + Object.entries(replacements).reduce((string, [key, value]) => string.replace(`:${key}`, value), key); +window.__n = (key, count) => key.split('|')[count === 1 ? 0 : 1].replace(/:count/g, count); + +// Five words, twenty four characters. +const value = [{ type: 'paragraph', content: [{ type: 'text', text: 'One two three four five.' }] }]; + +async function mountField(config = {}) { + const wrapper = mount(BardFieldtype, { + props: { + value, + handle: 'content', + config: { sets: [], buttons: ['bold'], ...config }, + meta: { existing: {}, collapsed: [], defaults: {}, new: {} }, + }, + global: { + stubs: { + 'publish-field-fullscreen-header': true, + 'ui-icon': true, + 'set-picker': true, + }, + mocks: { + $bard: { extensionCallbacks: [], extensionReplacementCallbacks: [], buttonCallbacks: [] }, + $events: { $on: () => {}, $off: () => {} }, + }, + provide: { + [containerContextKey]: { + values: { value: {} }, + previews: { value: {} }, + errors: { value: {} }, + setFieldValue: vi.fn(), + setFieldMeta: vi.fn(), + }, + }, + }, + }); + + await vi.waitUntil(() => wrapper.vm.editor); + await wrapper.vm.$nextTick(); + + return wrapper; +} + +async function type(wrapper, text) { + wrapper.vm.editor.commands.insertContent(text); + await wrapper.vm.$nextTick(); +} + +beforeEach(() => { + window.Statamic = { + $components: { has: () => true, register: () => {} }, + $fieldActions: { get: () => [] }, + $commandPalette: { preventIf: () => {}, add: () => {} }, + $config: { get: () => null }, + }; +}); + +afterEach(() => vi.restoreAllMocks()); + +// Serializing the whole document is expensive, and the reading time is the only thing that needs it. +test('the document is not serialized to html on update when reading time is disabled', async () => { + const wrapper = await mountField({ reading_time: false }); + const getHTML = vi.spyOn(wrapper.vm.editor, 'getHTML'); + + await type(wrapper, ' six seven eight.'); + + expect(getHTML).not.toHaveBeenCalled(); +}); + +test('the document is serialized to html on update when reading time is enabled', async () => { + const wrapper = await mountField({ reading_time: true }); + const getHTML = vi.spyOn(wrapper.vm.editor, 'getHTML'); + + await type(wrapper, ' six seven eight.'); + + expect(getHTML).toHaveBeenCalled(); + expect(wrapper.vm.html).toContain('six seven eight.'); +}); + +test('reading time is rendered when enabled', async () => { + const wrapper = await mountField({ reading_time: true }); + + await type(wrapper, ' six seven eight.'); + + expect(wrapper.vm.readingTime).toMatch(/^\d{2}:\d{2}$/); + expect(wrapper.find('.bard-footer-toolbar').text()).toContain(wrapper.vm.readingTime); +}); + +// The counts come from the character count extension's storage rather than the serialized +// html, so they are rendered whether or not the html is being kept up to date. +test('word and character counts are rendered when reading time is disabled', async () => { + const wrapper = await mountField({ reading_time: false, word_count: true, character_limit: 100 }); + + expect(wrapper.find('.bard-footer-toolbar').text()).toBe('5 words, 24/100 characters'); +}); From 098f8214a91e92d8bf990e86a77411a705d0f248 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 25 Aug 2026 00:02:12 -0400 Subject: [PATCH 2/3] Gate the initial HTML serialization behind reading time too The seed in mounted() was doing a full document serialize per Bard field at page load. It's only needed when reading time is enabled, and it still runs in that case, so readingTime has a value on first render as before. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/fieldtypes/bard/BardFieldtype.vue | 5 ++++- .../fieldtypes/bard/BardFieldtype.test.js | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index 35493f47fc0..1f6af2ff5ec 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -397,7 +397,10 @@ export default { this.initEditor(); this.json = this.editor.getJSON().content; - this.html = this.editor.getHTML(); + + if (this.config.reading_time) { + this.html = this.editor.getHTML(); + } this.$nextTick(() => this.mounted = true); diff --git a/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js index a657f16802d..6d44c4c6183 100644 --- a/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { Editor } from '@tiptap/vue-3'; import * as Globals from '@/bootstrap/globals'; import BardFieldtype from '@/components/fieldtypes/bard/BardFieldtype.vue'; import { containerContextKey } from '@/components/ui/Publish/Container.vue'; @@ -66,6 +67,21 @@ beforeEach(() => { afterEach(() => vi.restoreAllMocks()); // Serializing the whole document is expensive, and the reading time is the only thing that needs it. +test('the document is not serialized to html on mount when reading time is disabled', async () => { + const getHTML = vi.spyOn(Editor.prototype, 'getHTML'); + + const wrapper = await mountField({ reading_time: false }); + + expect(getHTML).not.toHaveBeenCalled(); + expect(wrapper.vm.html).toBe(null); +}); + +test('the document is serialized to html on mount when reading time is enabled', async () => { + const wrapper = await mountField({ reading_time: true }); + + expect(wrapper.vm.html).toContain('One two three four five.'); +}); + test('the document is not serialized to html on update when reading time is disabled', async () => { const wrapper = await mountField({ reading_time: false }); const getHTML = vi.spyOn(wrapper.vm.editor, 'getHTML'); From e06bc06fcb7b26873ec23fcf5cf8f11ad1565adb Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 25 Aug 2026 00:06:45 -0400 Subject: [PATCH 3/3] Remove unnecessary comment Co-Authored-By: Claude Opus 5 (1M context) --- resources/js/components/fieldtypes/bard/BardFieldtype.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index 1f6af2ff5ec..5dbac920e7d 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -897,7 +897,6 @@ export default { this.json = newJson; - // Serializing the whole document is expensive, and reading time is the only thing that needs it. if (this.config.reading_time) { this.html = this.editor.getHTML(); }