From 7d1e20b18eff865bf6fe429cdc62646d37834afb Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 24 Aug 2026 16:41:59 -0400 Subject: [PATCH 1/2] Avoid cloning publish values when no fields are omitted The visibleValues computed cloned the entire values tree on every keystroke, even when field conditions had nothing to omit, which is the common case. Return the live tree by reference instead, and only clone when there is actually something to strip out. --- .../js/components/ui/Publish/Container.vue | 5 ++ .../ui/Publish/VisibleValues.test.js | 70 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 resources/js/tests/components/ui/Publish/VisibleValues.test.js diff --git a/resources/js/components/ui/Publish/Container.vue b/resources/js/components/ui/Publish/Container.vue index 3c39c75f2da..a8a2f4e4364 100644 --- a/resources/js/components/ui/Publish/Container.vue +++ b/resources/js/components/ui/Publish/Container.vue @@ -112,6 +112,11 @@ const visibleValues = computed(() => { const omittable = Object.keys(hiddenFields.value).filter( (field) => hiddenFields.value[field].omitValue, ); + + // When nothing is omitted, hand back the live tree rather than cloning it on every + // keystroke. Consumers must treat visibleValues as read-only. + if (omittable.length === 0) return values.value; + return new Values(values.value).except(omittable); }); diff --git a/resources/js/tests/components/ui/Publish/VisibleValues.test.js b/resources/js/tests/components/ui/Publish/VisibleValues.test.js new file mode 100644 index 00000000000..5ca946d2615 --- /dev/null +++ b/resources/js/tests/components/ui/Publish/VisibleValues.test.js @@ -0,0 +1,70 @@ +import { mount } from '@vue/test-utils'; +import { expect, test } from 'vitest'; +import { h, nextTick } from 'vue'; +import * as Globals from '@/bootstrap/globals'; +import Container from '@/components/ui/Publish/Container.vue'; +import Fields from '@/components/ui/Publish/Fields.vue'; +import FieldsProvider from '@/components/ui/Publish/FieldsProvider.vue'; +import Values from '@/components/publish/Values.js'; + +Object.keys(Globals).forEach((fn) => (window[fn] = Globals[fn])); +window.__ = (key) => key; + +window.Statamic = { + $app: { component: () => undefined }, + $config: { + get: (key) => (key === 'sites' ? [{ handle: 'default', direction: 'ltr' }] : undefined), + }, + $dirty: { has: () => false, add: () => {}, remove: () => {} }, + $events: { $emit: () => {} }, +}; + +const unconditionalFields = [{ handle: 'title', type: 'text' }]; + +const conditionalFields = [ + { handle: 'toggle', type: 'toggle' }, + { handle: 'secret', type: 'text', if: { toggle: 'equals true' } }, +]; + +// Field conditions commit their omit bookkeeping on a later tick, so let the queue drain. +async function settle() { + for (let i = 0; i < 5; i++) await nextTick(); +} + +async function mountContainer(modelValue, fields = unconditionalFields) { + const wrapper = mount(Container, { + props: { blueprint: { tabs: [] }, modelValue, site: 'default' }, + slots: { default: () => [h(FieldsProvider, { fields }, () => h(Fields))] }, + }); + + await settle(); + + return wrapper; +} + +test('visible values are the live values tree when nothing is omitted', async () => { + const wrapper = await mountContainer({ title: 'Hello' }); + + expect(wrapper.vm.visibleValues).toBe(wrapper.vm.values); +}); + +test('visible values are a copy when a field is omitted', async () => { + const wrapper = await mountContainer({ toggle: false, secret: 'shh' }, conditionalFields); + + expect(wrapper.vm.visibleValues).not.toBe(wrapper.vm.values); + expect(wrapper.vm.visibleValues).toEqual({ toggle: false }); + expect(wrapper.vm.values).toEqual({ toggle: false, secret: 'shh' }); +}); + +test('the uncloned tree has the same content as a clone would', async () => { + const wrapper = await mountContainer({ title: 'Hello', nested: { deep: ['a', 'b'] }, empty: null }); + + expect(wrapper.vm.visibleValues).toEqual(new Values(wrapper.vm.values).except([])); +}); + +test('the uncloned tree serializes identically to a clone', async () => { + const values = { title: 'Hello', nested: { deep: ['a', 'b'] }, empty: null, missing: undefined }; + const wrapper = await mountContainer(values); + + expect(JSON.stringify(wrapper.vm.visibleValues)).toBe(JSON.stringify(new Values(wrapper.vm.values).except([]))); +}); From 73995ae50caef1d1383bed590899aed1d06bd132 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 24 Aug 2026 16:42:12 -0400 Subject: [PATCH 2/2] Emit visibleValues without deep watching the derived tree Deep watching the visibleValues computed traversed the entire derived tree on every keystroke, on top of building it. visibleValues is derived from values and hiddenFields, so emit it alongside update:modelValue from the values watcher, and watch the much smaller hiddenFields object for the rest. --- .../js/components/ui/Publish/Container.vue | 8 +- .../ui/Publish/VisibleValues.test.js | 78 +++++++++++++++++-- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/resources/js/components/ui/Publish/Container.vue b/resources/js/components/ui/Publish/Container.vue index a8a2f4e4364..9b794cca288 100644 --- a/resources/js/components/ui/Publish/Container.vue +++ b/resources/js/components/ui/Publish/Container.vue @@ -175,13 +175,17 @@ watch( (values) => { dirty(); emit('update:modelValue', values); + emit('update:visibleValues', visibleValues.value); }, { deep: true }, ); +// visibleValues is derived from values and hiddenFields, and the values watcher above +// already covers the former. Watching hiddenFields here avoids a second deep traversal +// of the whole values tree on every keystroke. watch( - visibleValues, - (values) => emit('update:visibleValues', values), + hiddenFields, + () => emit('update:visibleValues', visibleValues.value), { deep: true }, ); diff --git a/resources/js/tests/components/ui/Publish/VisibleValues.test.js b/resources/js/tests/components/ui/Publish/VisibleValues.test.js index 5ca946d2615..5455ffafc1c 100644 --- a/resources/js/tests/components/ui/Publish/VisibleValues.test.js +++ b/resources/js/tests/components/ui/Publish/VisibleValues.test.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import { expect, test } from 'vitest'; -import { h, nextTick } from 'vue'; +import { h, nextTick, toRaw } from 'vue'; import * as Globals from '@/bootstrap/globals'; import Container from '@/components/ui/Publish/Container.vue'; import Fields from '@/components/ui/Publish/Fields.vue'; @@ -32,24 +32,33 @@ async function settle() { } async function mountContainer(modelValue, fields = unconditionalFields) { + const emitted = []; + const wrapper = mount(Container, { - props: { blueprint: { tabs: [] }, modelValue, site: 'default' }, + props: { + blueprint: { tabs: [] }, + modelValue, + site: 'default', + // Snapshot on receipt. On the uncloned path the payload is the live tree, so + // holding the reference would let later edits rewrite earlier emissions. + 'onUpdate:visibleValues': (values) => emitted.push(structuredClone(toRaw(values))), + }, slots: { default: () => [h(FieldsProvider, { fields }, () => h(Fields))] }, }); await settle(); - return wrapper; + return { wrapper, emitted }; } test('visible values are the live values tree when nothing is omitted', async () => { - const wrapper = await mountContainer({ title: 'Hello' }); + const { wrapper } = await mountContainer({ title: 'Hello' }); expect(wrapper.vm.visibleValues).toBe(wrapper.vm.values); }); test('visible values are a copy when a field is omitted', async () => { - const wrapper = await mountContainer({ toggle: false, secret: 'shh' }, conditionalFields); + const { wrapper } = await mountContainer({ toggle: false, secret: 'shh' }, conditionalFields); expect(wrapper.vm.visibleValues).not.toBe(wrapper.vm.values); expect(wrapper.vm.visibleValues).toEqual({ toggle: false }); @@ -57,14 +66,69 @@ test('visible values are a copy when a field is omitted', async () => { }); test('the uncloned tree has the same content as a clone would', async () => { - const wrapper = await mountContainer({ title: 'Hello', nested: { deep: ['a', 'b'] }, empty: null }); + const { wrapper } = await mountContainer({ title: 'Hello', nested: { deep: ['a', 'b'] }, empty: null }); expect(wrapper.vm.visibleValues).toEqual(new Values(wrapper.vm.values).except([])); }); test('the uncloned tree serializes identically to a clone', async () => { const values = { title: 'Hello', nested: { deep: ['a', 'b'] }, empty: null, missing: undefined }; - const wrapper = await mountContainer(values); + const { wrapper } = await mountContainer(values); expect(JSON.stringify(wrapper.vm.visibleValues)).toBe(JSON.stringify(new Values(wrapper.vm.values).except([]))); }); + +test('visible values are emitted when a value changes', async () => { + const { wrapper, emitted } = await mountContainer({ title: 'Hello' }); + const before = emitted.length; + + wrapper.vm.setFieldValue('title', 'Goodbye'); + await settle(); + + expect(emitted.length).toBe(before + 1); + expect(emitted.at(-1)).toEqual({ title: 'Goodbye' }); +}); + +test('visible values are emitted when a nested value changes', async () => { + const { wrapper, emitted } = await mountContainer({ nested: { deep: 'a' } }); + const before = emitted.length; + + wrapper.vm.setFieldValue('nested.deep', 'b'); + await settle(); + + expect(emitted.length).toBe(before + 1); + expect(emitted.at(-1)).toEqual({ nested: { deep: 'b' } }); +}); + +test('visible values are emitted when the values are replaced wholesale', async () => { + const { wrapper, emitted } = await mountContainer({ title: 'Hello' }); + const before = emitted.length; + + wrapper.vm.setValues({ title: 'Replaced' }); + await settle(); + + expect(emitted.length).toBe(before + 1); + expect(emitted.at(-1)).toEqual({ title: 'Replaced' }); +}); + +test('visible values are emitted when a field becomes omitted', async () => { + const { wrapper, emitted } = await mountContainer({ toggle: true, secret: 'shh' }, conditionalFields); + + wrapper.vm.setFieldValue('toggle', false); + await settle(); + + expect(emitted.at(-1)).toEqual({ toggle: false }); + expect(wrapper.vm.visibleValues).toEqual({ toggle: false }); +}); + +test('visible values are emitted when a field stops being omitted', async () => { + const { wrapper, emitted } = await mountContainer({ toggle: false, secret: 'shh' }, conditionalFields); + + expect(wrapper.vm.visibleValues).toEqual({ toggle: false }); + + wrapper.vm.setFieldValue('toggle', true); + await settle(); + + expect(emitted.at(-1)).toEqual({ toggle: true, secret: 'shh' }); + expect(wrapper.vm.visibleValues).toEqual({ toggle: true, secret: 'shh' }); +});