diff --git a/resources/css/components/fieldtypes/bard.css b/resources/css/components/fieldtypes/bard.css
index 1477f7ddead..26f4faf292f 100644
--- a/resources/css/components/fieldtypes/bard.css
+++ b/resources/css/components/fieldtypes/bard.css
@@ -1,5 +1,11 @@
/* BARD
=================================================== */
+
+.bard-bulk-op [data-type] header,
+.bard-bulk-op [data-type] header * {
+ transition: none !important;
+}
+
@layer ui {
/* .grid-cell, [data-ui-input-group] are exceptions for Bard fieldtypes inside a Grid fieldtype, since these cause double borders */
.bard-fieldtype:not(.form-group, .grid-cell, [data-ui-input-group]) {
diff --git a/resources/css/components/fieldtypes/replicator.css b/resources/css/components/fieldtypes/replicator.css
index cbf0f8dc3bf..ecad8e9eea8 100644
--- a/resources/css/components/fieldtypes/replicator.css
+++ b/resources/css/components/fieldtypes/replicator.css
@@ -1,3 +1,8 @@
/* ==========================================================================
REPLICATOR FIELDTYPE
========================================================================== */
+
+.replicator-bulk-op [data-replicator-set] header,
+.replicator-bulk-op [data-replicator-set] header * {
+ transition: none !important;
+}
diff --git a/resources/js/bootstrap/statamic.js b/resources/js/bootstrap/statamic.js
index ba48aaa3a17..3a5eda975b2 100644
--- a/resources/js/bootstrap/statamic.js
+++ b/resources/js/bootstrap/statamic.js
@@ -198,9 +198,11 @@ export default {
const bladeContent = el?.innerHTML || '';
const _this = this;
+ const corePages = import.meta.glob('../pages/**/*.vue');
+
await createInertiaApp({
id: 'statamic',
- resolve: name => {
+ resolve: async name => {
if (name === 'NonInertiaPage') {
return {
default: {
@@ -211,8 +213,8 @@ export default {
}
// Resolve core pages
- const pages = import.meta.glob('../pages/**/*.vue', { eager: true });
- let page = pages[`../pages/${name}.vue`];
+ const pageImport = corePages[`../pages/${name}.vue`];
+ let page = pageImport ? await pageImport() : null;
// Resolve addon pages
if (!page) {
diff --git a/resources/js/components/field-actions/HasFieldActions.js b/resources/js/components/field-actions/HasFieldActions.js
index a73637e3ecc..f5546349896 100644
--- a/resources/js/components/field-actions/HasFieldActions.js
+++ b/resources/js/components/field-actions/HasFieldActions.js
@@ -1,13 +1,21 @@
import toFieldActions from './toFieldActions.js';
export default {
+ data() {
+ return {
+ _cachedFieldActions: null,
+ };
+ },
+
computed: {
fieldActions() {
- return toFieldActions(
+ if (this._cachedFieldActions) return this._cachedFieldActions;
+ this._cachedFieldActions = toFieldActions(
this.fieldActionBinding,
this.fieldActionPayload,
this.internalFieldActions,
);
+ return this._cachedFieldActions;
},
internalFieldActions() {
diff --git a/resources/js/components/field-actions/toFieldActions.js b/resources/js/components/field-actions/toFieldActions.js
index b9dcefb6dc9..d94c9a8677f 100644
--- a/resources/js/components/field-actions/toFieldActions.js
+++ b/resources/js/components/field-actions/toFieldActions.js
@@ -1,7 +1,8 @@
+import { markRaw } from 'vue';
import FieldAction from './FieldAction.js';
export default function toFieldActions(binding, payload, extraActions = []) {
return [...Statamic.$fieldActions.get(binding), ...extraActions]
- .map((action) => new FieldAction(action, payload))
+ .map((action) => markRaw(new FieldAction(action, payload)))
.filter((action) => action.visible);
}
diff --git a/resources/js/components/fieldtypes/Fieldtype.vue b/resources/js/components/fieldtypes/Fieldtype.vue
index 2004e662b6e..cfc6ce5aef3 100644
--- a/resources/js/components/fieldtypes/Fieldtype.vue
+++ b/resources/js/components/fieldtypes/Fieldtype.vue
@@ -42,12 +42,31 @@ 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 the computed has zero reactive deps
+ // and is never invalidated. Dep tracking happens in the consumer's reactive scope.
+ 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/LinkFieldtype.vue b/resources/js/components/fieldtypes/LinkFieldtype.vue
index 6ac64115f14..d11af2e7310 100644
--- a/resources/js/components/fieldtypes/LinkFieldtype.vue
+++ b/resources/js/components/fieldtypes/LinkFieldtype.vue
@@ -40,6 +40,8 @@
diff --git a/resources/js/components/fieldtypes/grid/Row.vue b/resources/js/components/fieldtypes/grid/Row.vue
index 1dfd4c61c9e..b28b4f6f4c6 100644
--- a/resources/js/components/fieldtypes/grid/Row.vue
+++ b/resources/js/components/fieldtypes/grid/Row.vue
@@ -98,15 +98,11 @@ export default {
methods: {
updated(handle, value) {
- let row = JSON.parse(JSON.stringify(this.values));
- row[handle] = value;
- this.$emit('updated', this.index, row);
+ this.$emit('updated', this.index, { ...this.values, [handle]: value });
},
metaUpdated(handle, value) {
- let meta = clone(this.meta);
- meta[handle] = value;
- this.$emit('meta-updated', meta);
+ this.$emit('meta-updated', { ...this.meta, [handle]: value });
},
fieldPath(handle) {
diff --git a/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js b/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js
index 44149c7d579..b9b5f7ec479 100644
--- a/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js
+++ b/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js
@@ -1,30 +1,22 @@
-import PreviewHtml from './PreviewHtml';
+import { buildPreviewText } from '@/util/buildPreviewText';
+import formatPreviewValueUtil from '@/util/formatPreviewValue';
export default {
computed: {
previewText() {
- return Object.entries(this.previews)
- .filter(([handle, value]) => {
- if (!handle.endsWith('_')) return false;
- handle = handle.substr(0, handle.length - 1); // Remove the trailing underscore.
- const config = this.config.fields.find((f) => f.handle === handle);
- if (!config) return false;
- return config.replicator_preview === undefined ? this.showFieldPreviews : config.replicator_preview;
- })
- .map(([handle, value]) => value)
- .filter((value) => (['null', '[]', '{}', '', undefined].includes(JSON.stringify(value)) ? null : value))
- .map((value) => {
- if (value instanceof PreviewHtml) return value.html;
-
- if (typeof value === 'string') return escapeHtml(value);
-
- if (Array.isArray(value) && typeof value[0] === 'string') {
- return escapeHtml(value.join(', '));
- }
+ return buildPreviewText({
+ previews: this.previews,
+ config: this.config,
+ values: this.values,
+ showFieldPreviews: this.showFieldPreviews,
+ separator: ' / ',
+ });
+ },
+ },
- return escapeHtml(JSON.stringify(value));
- })
- .join(' / ');
+ methods: {
+ formatPreviewValue(value, fieldConfig) {
+ return formatPreviewValueUtil(value, fieldConfig, { escape: false });
},
},
};
diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue
index 085eafa56f7..eb013e202df 100644
--- a/resources/js/components/fieldtypes/replicator/Replicator.vue
+++ b/resources/js/components/fieldtypes/replicator/Replicator.vue
@@ -7,7 +7,7 @@
v._id);
+ this.$nextTick(() => requestAnimationFrame(() => {
+ this.suppressTransitions = false;
+ }));
},
expandAll() {
+ this.suppressTransitions = true;
this.collapsed = [];
+ this.$nextTick(() => requestAnimationFrame(() => {
+ this.suppressTransitions = false;
+ }));
},
toggleFullscreen() {
diff --git a/resources/js/components/fieldtypes/replicator/Set.vue b/resources/js/components/fieldtypes/replicator/Set.vue
index 3e48b0fb8f6..ebfd875d135 100644
--- a/resources/js/components/fieldtypes/replicator/Set.vue
+++ b/resources/js/components/fieldtypes/replicator/Set.vue
@@ -1,5 +1,6 @@
@@ -203,20 +214,23 @@ reveal.use(rootEl, () => emit('expanded'));
diff --git a/resources/js/components/inputs/relationship/RelationshipInput.vue b/resources/js/components/inputs/relationship/RelationshipInput.vue
index cdcf943082f..a5df7e3d729 100644
--- a/resources/js/components/inputs/relationship/RelationshipInput.vue
+++ b/resources/js/components/inputs/relationship/RelationshipInput.vue
@@ -108,6 +108,8 @@ import { Button, Icon, Stack } from '@/components/ui';
import { router } from '@inertiajs/vue3';
import axios from 'axios';
+const inFlightRequests = new Map();
+
export default {
props: {
canCreate: { type: Boolean },
@@ -246,7 +248,7 @@ export default {
created() {
this.removeNavigationListener = router.on('before', () => {
- if (this.abortController) this.abortController.abort();
+ if (this.abortController && this._ownsRequest) this.abortController.abort();
});
},
@@ -260,7 +262,7 @@ export default {
},
beforeUnmount() {
- if (this.abortController) this.abortController.abort();
+ if (this.abortController && this._ownsRequest) this.abortController.abort();
if (this.removeNavigationListener) this.removeNavigationListener();
if (this.sortable) {
this.sortable.destroy();
@@ -331,8 +333,18 @@ export default {
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
- return this.$axios
+ const cacheKey = this.itemDataUrl + '|' + (this.site || '') + '|' + JSON.stringify(selections?.slice().sort());
+ const existing = inFlightRequests.get(cacheKey);
+
+ this._ownsRequest = !existing;
+
+ const request = existing ?? this.$axios
.post(this.itemDataUrl, { site: this.site, selections }, { signal: this.abortController.signal })
+ .finally(() => inFlightRequests.delete(cacheKey));
+
+ if (!existing) inFlightRequests.set(cacheKey, request);
+
+ return request
.then((response) => {
this.$emit('item-data-updated', response.data.data);
})
diff --git a/resources/js/components/ui/LivePreview/LivePreview.vue b/resources/js/components/ui/LivePreview/LivePreview.vue
index 52de68f3ef6..b95535e9ec0 100644
--- a/resources/js/components/ui/LivePreview/LivePreview.vue
+++ b/resources/js/components/ui/LivePreview/LivePreview.vue
@@ -283,10 +283,14 @@ const keybinding = ref(
}),
);
-onUnmounted(() => keybinding.value.destroy());
+const refreshHandler = () => { if (props.enabled) update(); };
+const refreshEvent = `live-preview.${name.value}.refresh`;
-Statamic.$events.$on(`live-preview.${name.value}.refresh`, () => {
- if (props.enabled) update();
+Statamic.$events.$on(refreshEvent, refreshHandler);
+
+onUnmounted(() => {
+ keybinding.value.destroy();
+ Statamic.$events.$off(refreshEvent, refreshHandler);
});
diff --git a/resources/js/components/ui/Publish/Container.vue b/resources/js/components/ui/Publish/Container.vue
index 11834fe0e08..34c67903602 100644
--- a/resources/js/components/ui/Publish/Container.vue
+++ b/resources/js/components/ui/Publish/Container.vue
@@ -9,8 +9,7 @@ import { nanoid as uniqid } from 'nanoid';
import { onMounted, onUnmounted, watch, ref, computed, toRef, nextTick } from 'vue';
import Component from '@/components/Component.js';
import Tabs from './Tabs.vue';
-import Values from '@/components/publish/Values.js';
-import { data_get } from '@/bootstrap/globals.js';
+import { clone, data_get } from '@/bootstrap/globals.js';
const emit = defineEmits(['update:modelValue', 'update:visibleValues', 'update:modifiedFields', 'update:meta']);
@@ -106,11 +105,32 @@ const localizedFields = ref(props.modifiedFields || []);
const components = ref([]);
const direction = computed(() => Statamic.$config.get('sites').find(s => s.handle === props.site)?.direction ?? document.documentElement.dir ?? 'ltr');
+// File-scoped helper: walk the dotted path and delete the leaf if present.
+function forgetAtPath(obj, path) {
+ const parts = path.split('.');
+ while (parts.length > 1) {
+ const key = parts.shift();
+ if (!obj || typeof obj !== 'object' || !(key in obj)) return;
+ obj = obj[key];
+ }
+ if (obj && typeof obj === 'object') delete obj[parts[0]];
+}
+
const visibleValues = computed(() => {
- const omittable = Object.keys(hiddenFields.value).filter(
- (field) => hiddenFields.value[field].omitValue,
- );
- return new Values(values.value).except(omittable);
+ const hf = hiddenFields.value;
+ let omittable = null;
+ for (const key in hf) {
+ if (hf[key].omitValue) {
+ if (omittable === null) omittable = [];
+ omittable.push(key);
+ }
+ }
+
+ if (omittable === null) return values.value;
+
+ const out = clone(values.value);
+ for (const key of omittable) forgetAtPath(out, key);
+ return out;
});
const revealerValues = computed(() => {
@@ -160,16 +180,19 @@ watch(
watch(
values,
- (values) => {
+ (v) => {
dirty();
- emit('update:modelValue', values);
+ emit('update:modelValue', v);
+ emit('update:visibleValues', visibleValues.value);
},
{ deep: true },
);
watch(
- visibleValues,
- (values) => emit('update:visibleValues', values),
+ hiddenFields,
+ () => {
+ emit('update:visibleValues', visibleValues.value);
+ },
{ deep: true },
);
diff --git a/resources/js/components/ui/Publish/Field.vue b/resources/js/components/ui/Publish/Field.vue
index d366abfcac4..42d44a8c76b 100644
--- a/resources/js/components/ui/Publish/Field.vue
+++ b/resources/js/components/ui/Publish/Field.vue
@@ -9,6 +9,7 @@ import {
} from '@ui';
import FieldActions from '@/components/field-actions/FieldActions.vue';
import ShowField from '@/components/field-conditions/ShowField.js';
+import { KEYS } from '@/components/field-conditions/Constants.js';
const props = defineProps({
config: {
@@ -137,7 +138,15 @@ const extraValues = computed(() => {
return fieldPathPrefix.value ? data_get(containerExtraValues.value, fieldPathPrefix.value) : containerExtraValues.value;
});
-const shouldShowField = computed(() => {
+const conditionHandles = computed(() => {
+ const conditionKey = KEYS.find((k) => props.config[k]);
+ if (!conditionKey) return null;
+ const conditions = props.config[conditionKey];
+ if (typeof conditions === 'string') return null;
+ return Object.keys(conditions);
+});
+
+function evaluateShowField() {
return new ShowField(
values.value,
extraValues.value,
@@ -147,8 +156,46 @@ const shouldShowField = computed(() => {
setHiddenField,
{ container },
).showField(props.config, fullPath.value);
+}
+
+const hasConditions = computed(() => {
+ if (props.config.visibility === 'hidden') return false;
+ return KEYS.some((k) => props.config[k]);
});
+const shouldShowField = ref(props.config.visibility !== 'hidden');
+
+const isCustomCondition = computed(() => {
+ const conditionKey = KEYS.find((k) => props.config[k]);
+ return conditionKey ? typeof props.config[conditionKey] === 'string' : false;
+});
+
+if (hasConditions.value) {
+ shouldShowField.value = evaluateShowField();
+
+ watch(
+ () => {
+ if (isCustomCondition.value) return values.value;
+ const handles = conditionHandles.value;
+ if (!handles) return null;
+ const src = values.value ?? {};
+ const rootSrc = containerValues.value ?? {};
+ return handles.map((handle) => {
+ if (handle.startsWith('$root.') || handle.startsWith('root.')) {
+ return data_get(rootSrc, handle.replace(/^\$?root\./, ''));
+ }
+ return data_get(src, handle);
+ });
+ },
+ () => { shouldShowField.value = evaluateShowField(); },
+ { deep: isCustomCondition.value },
+ );
+
+ watch(hiddenFields, () => {
+ shouldShowField.value = evaluateShowField();
+ });
+}
+
const shouldShowLabelText = computed(() => !props.config.hide_display);
const shouldShowLabel = computed(
diff --git a/resources/js/composables/use-preview-text.js b/resources/js/composables/use-preview-text.js
new file mode 100644
index 00000000000..a8323a99983
--- /dev/null
+++ b/resources/js/composables/use-preview-text.js
@@ -0,0 +1,40 @@
+import { computed } from 'vue';
+import { buildPreviewText } from '@/util/buildPreviewText';
+import { data_get } from '@/bootstrap/globals.js';
+
+/**
+ * Composable for generating preview text in replicator sets.
+ *
+ * @param {Object} options - Configuration options
+ * @param {Object} options.config - The set configuration with fields array
+ * @param {Object} options.values - The current field values
+ * @param {Object} options.previews - The preview data from mounted fieldtype components
+ * @param {string} options.fieldPathPrefix - The field path prefix for looking up previews
+ * @param {boolean} options.showFieldPreviews - Whether to show field previews by default
+ * @returns {Object} - Object containing the previewText computed property
+ */
+export default function usePreviewText(options) {
+ const {
+ config,
+ values,
+ previews,
+ fieldPathPrefix,
+ showFieldPreviews,
+ } = options;
+
+ const previewText = computed(() => {
+ const previewData = data_get(previews.value, fieldPathPrefix.value) || {};
+
+ return buildPreviewText({
+ previews: previewData,
+ config: config.value,
+ values: values.value,
+ showFieldPreviews: showFieldPreviews.value,
+ separator: '
/ ',
+ });
+ });
+
+ return {
+ previewText,
+ };
+}
diff --git a/resources/js/tests/PublishContainerVisibleValues.test.js b/resources/js/tests/PublishContainerVisibleValues.test.js
new file mode 100644
index 00000000000..c8499d6ebf6
--- /dev/null
+++ b/resources/js/tests/PublishContainerVisibleValues.test.js
@@ -0,0 +1,94 @@
+import { test, expect } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import PublishContainer from '@ui/Publish/Container.vue';
+
+window.Statamic = {
+ $dirty: {
+ add: () => {},
+ remove: () => {},
+ has: () => false,
+ },
+ $events: {
+ $emit: () => {},
+ },
+ $config: {
+ get: (key) => (key === 'sites' ? [{ handle: 'default', direction: 'ltr' }] : undefined),
+ },
+};
+window.__ = (msg) => msg;
+
+const TestComponent = { template: '
' };
+
+const createContainer = (props = {}) => mount(PublishContainer, {
+ props: {
+ blueprint: { publish: [], tabs: { main: { fields: [] } } },
+ modelValue: {},
+ trackDirtyState: false,
+ ...props,
+ },
+ slots: { default: TestComponent },
+});
+
+test('it returns values by reference when no hidden fields', () => {
+ const values = { title: 'Hello', body: 'World' };
+ const wrapper = createContainer({ modelValue: values });
+
+ expect(wrapper.vm.visibleValues).toBe(wrapper.vm.values);
+});
+
+test('it omits top-level keys with omitValue true', async () => {
+ const values = { title: 'Hello', secret: 'hidden', body: 'World' };
+ const wrapper = createContainer({ modelValue: values });
+
+ wrapper.vm.setHiddenField({ dottedKey: 'secret', hidden: true, omitValue: true });
+ await nextTick();
+
+ expect(wrapper.vm.visibleValues).not.toHaveProperty('secret');
+ expect(wrapper.vm.visibleValues).toHaveProperty('title', 'Hello');
+ expect(wrapper.vm.visibleValues).toHaveProperty('body', 'World');
+ expect(wrapper.vm.values).toHaveProperty('secret', 'hidden');
+});
+
+test('it omits nested paths correctly', async () => {
+ const values = {
+ features: [
+ { textcontent: 'Feature 1', price: 100 },
+ { textcontent: 'Feature 2', price: 200 },
+ ],
+ };
+ const wrapper = createContainer({ modelValue: values });
+
+ wrapper.vm.setHiddenField({ dottedKey: 'features.0.textcontent', hidden: true, omitValue: true });
+ await nextTick();
+
+ expect(wrapper.vm.visibleValues.features[0]).not.toHaveProperty('textcontent');
+ expect(wrapper.vm.visibleValues.features[0]).toHaveProperty('price', 100);
+ expect(wrapper.vm.visibleValues.features[1]).toHaveProperty('textcontent', 'Feature 2');
+});
+
+test('it returns values by reference when hidden field changes to not omit', async () => {
+ const values = { title: 'Hello', secret: 'hidden' };
+ const wrapper = createContainer({ modelValue: values });
+
+ wrapper.vm.setHiddenField({ dottedKey: 'secret', hidden: true, omitValue: true });
+ await nextTick();
+ expect(wrapper.vm.visibleValues).not.toBe(wrapper.vm.values);
+
+ wrapper.vm.setHiddenField({ dottedKey: 'secret', hidden: true, omitValue: false });
+ await nextTick();
+ expect(wrapper.vm.visibleValues).toBe(wrapper.vm.values);
+});
+
+test('it emits update:visibleValues on deep changes', async () => {
+ const values = { title: 'Hello', nested: { key: 'value' } };
+ const wrapper = createContainer({ modelValue: values });
+
+ wrapper.emitted()['update:visibleValues'] = [];
+
+ wrapper.vm.values.nested.key = 'updated';
+ await nextTick();
+
+ expect(wrapper.emitted()['update:visibleValues']).toBeTruthy();
+ expect(wrapper.emitted()['update:visibleValues'].length).toBeGreaterThan(0);
+});
diff --git a/resources/js/tests/createMountScheduler.test.js b/resources/js/tests/createMountScheduler.test.js
new file mode 100644
index 00000000000..380df6c2dec
--- /dev/null
+++ b/resources/js/tests/createMountScheduler.test.js
@@ -0,0 +1,282 @@
+import { test, expect, vi, beforeEach, afterEach } from 'vitest';
+import { createMountScheduler } from '../util/createMountScheduler.js';
+import { mount } from '@vue/test-utils';
+import { nextTick, ref, h } from 'vue';
+
+beforeEach(() => {
+ vi.useFakeTimers({
+ toFake: [
+ 'setTimeout', 'clearTimeout',
+ 'setInterval', 'clearInterval',
+ 'setImmediate', 'clearImmediate',
+ 'queueMicrotask',
+ 'requestAnimationFrame', 'cancelAnimationFrame',
+ 'requestIdleCallback', 'cancelIdleCallback',
+ 'Date',
+ ],
+ });
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+test('zero-arg factory still works (backward compat)', () => {
+ const scheduler = createMountScheduler();
+ expect(scheduler).toHaveProperty('schedule');
+ expect(typeof scheduler.schedule).toBe('function');
+});
+
+// Helper to flush requestAnimationFrame / requestIdleCallback
+const flushTick = async () => {
+ await vi.advanceTimersToNextTimerAsync();
+};
+
+test('processes multiple cheap callbacks within budget', async () => {
+ const scheduler = createMountScheduler({ budgetMs: 8 });
+ const results = [];
+
+ // Schedule 5 cheap callbacks
+ for (let i = 0; i < 5; i++) {
+ scheduler.schedule(() => results.push(i));
+ }
+
+ await flushTick();
+
+ // All 5 should complete in one tick since they're cheap
+ expect(results).toEqual([0, 1, 2, 3, 4]);
+});
+
+test('resumes remaining callbacks on next tick when budget exceeded', async () => {
+ let processedCount = 0;
+ const scheduler = createMountScheduler({ budgetMs: 5 });
+
+ // Schedule callbacks - first one is slow, others are fast
+ scheduler.schedule(() => {
+ const start = performance.now();
+ while (performance.now() - start < 10) {} // 10ms sync delay
+ processedCount++;
+ });
+
+ for (let i = 0; i < 4; i++) {
+ scheduler.schedule(() => processedCount++);
+ }
+
+ await flushTick();
+ // Budget was blown by first callback, so only 1 processed
+ expect(processedCount).toBe(1);
+
+ await flushTick();
+ // Next tick should process the rest
+ expect(processedCount).toBe(5);
+});
+
+test('error in one callback does not prevent others from running', async () => {
+ const scheduler = createMountScheduler({ budgetMs: 8 });
+ const results = [];
+
+ scheduler.schedule(() => results.push(1));
+ scheduler.schedule(() => { throw new Error('Intentional error'); });
+ scheduler.schedule(() => results.push(3));
+
+ // Spy on console.error
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ await flushTick();
+
+ expect(results).toEqual([1, 3]);
+ expect(consoleSpy).toHaveBeenCalledOnce();
+
+ consoleSpy.mockRestore();
+});
+
+test('many cheap callbacks finish within reasonable iterations', async () => {
+ const scheduler = createMountScheduler({ budgetMs: 8 });
+ const results = [];
+
+ // 20 cheap callbacks
+ for (let i = 0; i < 20; i++) {
+ scheduler.schedule(() => results.push(i));
+ }
+
+ let iterations = 0;
+ while (results.length < 20 && iterations < 10) {
+ await flushTick();
+ iterations++;
+ }
+
+ expect(results.length).toBe(20);
+ expect(iterations).toBeLessThanOrEqual(3);
+});
+
+test('vue render time from a callback counts against the budget', async () => {
+ // Component that takes measurable time to render when it mounts.
+ const HeavyChild = {
+ setup() {
+ // Synthetic cost at setup time.
+ const start = performance.now();
+ while (performance.now() - start < 6) {}
+ return () => h('div');
+ },
+ };
+ const Parent = {
+ setup() {
+ const show = ref(false);
+ return { show };
+ },
+ render() {
+ return this.show ? h(HeavyChild) : h('div');
+ },
+ };
+
+ const scheduler = createMountScheduler({ budgetMs: 5 });
+ const wrappers = [mount(Parent), mount(Parent), mount(Parent)];
+ const mounted = [];
+
+ wrappers.forEach((w, i) => {
+ scheduler.schedule(() => {
+ w.vm.show = true;
+ mounted.push(i);
+ });
+ });
+
+ await flushTick();
+ // First flip triggers HeavyChild mount (~6ms real time via nextTick);
+ // budget is 5ms, so only one should process this tick.
+ expect(mounted.length).toBe(1);
+
+ await flushTick();
+ await flushTick();
+ expect(mounted.length).toBe(3);
+
+ wrappers.forEach(w => w.unmount());
+});
+
+test('scheduler recovers after a nextTick rejection', async () => {
+ const scheduler = createMountScheduler({ budgetMs: 8 });
+ const results = [];
+
+ // Mock nextTick to reject on the first invocation only.
+ const realNextTick = nextTick;
+ let first = true;
+ const spy = vi.spyOn(await import('vue'), 'nextTick').mockImplementation((...args) => {
+ if (first) { first = false; return Promise.reject(new Error('boom')); }
+ return realNextTick(...args);
+ });
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ scheduler.schedule(() => results.push('a'));
+ scheduler.schedule(() => results.push('b'));
+
+ await flushTick();
+ await flushTick();
+
+ expect(results).toEqual(['a', 'b']);
+ expect(consoleSpy).toHaveBeenCalled();
+
+ spy.mockRestore();
+ consoleSpy.mockRestore();
+});
+
+test('a callback that schedules more work runs on a later tick', async () => {
+ const scheduler = createMountScheduler({ budgetMs: 8 });
+ const order = [];
+
+ scheduler.schedule(() => {
+ order.push('a');
+ scheduler.schedule(() => order.push('b'));
+ });
+
+ await flushTick();
+ expect(order).toEqual(['a', 'b']);
+});
+
+test('respects budgetMs when requestIdleCallback fires with didTimeout', async () => {
+ const originalRIC = globalThis.requestIdleCallback;
+ let ricCallCount = 0;
+ globalThis.requestIdleCallback = (cb) => {
+ ricCallCount++;
+ setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), 0);
+ return 0;
+ };
+
+ try {
+ const scheduler = createMountScheduler({ budgetMs: 5 });
+ const results = [];
+
+ // First callback exceeds the 5ms budget.
+ scheduler.schedule(() => {
+ const start = performance.now();
+ while (performance.now() - start < 10) {}
+ results.push('heavy');
+ });
+ for (let i = 0; i < 4; i++) scheduler.schedule(() => results.push(i));
+
+ // Drain until all five have run; cap iterations to avoid infinite loops on regression.
+ for (let i = 0; i < 10 && results.length < 5; i++) {
+ await flushTick();
+ }
+
+ expect(results).toEqual(['heavy', 0, 1, 2, 3]);
+
+ // With the fix the busy callback forces a yield, so rIC is entered at
+ // least twice. With the old `return false` bypass, all 5 drain in one
+ // batch and ricCallCount stays at 1.
+ expect(ricCallCount).toBeGreaterThan(1);
+ } finally {
+ if (originalRIC) globalThis.requestIdleCallback = originalRIC;
+ else delete globalThis.requestIdleCallback;
+ }
+});
+
+test('yields based on IdleDeadline.timeRemaining when idle is granted', async () => {
+ const originalRIC = globalThis.requestIdleCallback;
+ let ricCallCount = 0;
+ globalThis.requestIdleCallback = (cb) => {
+ ricCallCount++;
+ const grantTime = performance.now();
+ const deadline = {
+ didTimeout: false,
+ timeRemaining: () => Math.max(0, 10 - (performance.now() - grantTime)),
+ };
+ setTimeout(() => cb(deadline), 0);
+ return 0;
+ };
+
+ try {
+ const scheduler = createMountScheduler({ budgetMs: 100 });
+ const results = [];
+
+ for (let i = 0; i < 3; i++) {
+ scheduler.schedule(() => {
+ const start = performance.now();
+ while (performance.now() - start < 6) {}
+ results.push(i);
+ });
+ }
+
+ for (let i = 0; i < 10 && results.length < 3; i++) {
+ await flushTick();
+ }
+
+ expect(results).toEqual([0, 1, 2]);
+ expect(ricCallCount).toBeGreaterThan(1);
+ } finally {
+ if (originalRIC) globalThis.requestIdleCallback = originalRIC;
+ else delete globalThis.requestIdleCallback;
+ }
+});
+
+test('scheduler resumes cleanly after an earlier flush completes', async () => {
+ const scheduler = createMountScheduler({ budgetMs: 8 });
+ const results = [];
+
+ scheduler.schedule(() => results.push('a'));
+ await flushTick();
+ expect(results).toEqual(['a']);
+
+ scheduler.schedule(() => results.push('b'));
+ scheduler.schedule(() => results.push('c'));
+ await flushTick();
+ expect(results).toEqual(['a', 'b', 'c']);
+});
diff --git a/resources/js/tests/toFieldActions.test.js b/resources/js/tests/toFieldActions.test.js
new file mode 100644
index 00000000000..9a5f24894a9
--- /dev/null
+++ b/resources/js/tests/toFieldActions.test.js
@@ -0,0 +1,46 @@
+import { test, expect, beforeEach, afterEach } from 'vitest';
+import { ref, reactive } from 'vue';
+import toFieldActions from '../components/field-actions/toFieldActions.js';
+
+let originalStatamic;
+
+beforeEach(() => {
+ originalStatamic = globalThis.Statamic;
+ globalThis.Statamic = {
+ $fieldActions: {
+ get: () => [
+ { title: 'Quick', quick: true, run: () => {} },
+ { title: 'Slow', quick: false, run: () => {} },
+ { title: 'Hidden', quick: false, visible: false, run: () => {} },
+ ],
+ },
+ };
+});
+
+afterEach(() => {
+ globalThis.Statamic = originalStatamic;
+});
+
+test('returns FieldAction instances safe for reactive containers (ref)', () => {
+ const actions = toFieldActions('some-binding', {});
+ const r = ref(actions);
+
+ // Without markRaw, accessing a getter that reads a private field
+ // throws "can't access private field or method: object is not the right class".
+ expect(() => r.value[0].quick).not.toThrow();
+ expect(r.value[0].quick).toBe(true);
+ expect(r.value[1].quick).toBe(false);
+});
+
+test('returns FieldAction instances safe for reactive containers (reactive)', () => {
+ const actions = toFieldActions('some-binding', {});
+ const state = reactive({ list: actions });
+
+ expect(() => state.list[0].quick).not.toThrow();
+ expect(state.list.filter((a) => !a.quick).map((a) => a.title)).toEqual(['Slow']);
+});
+
+test('filters out non-visible actions', () => {
+ const actions = toFieldActions('some-binding', {});
+ expect(actions.map((a) => a.title)).toEqual(['Quick', 'Slow']);
+});
diff --git a/resources/js/util/buildPreviewText.js b/resources/js/util/buildPreviewText.js
new file mode 100644
index 00000000000..cadbb4cb8c2
--- /dev/null
+++ b/resources/js/util/buildPreviewText.js
@@ -0,0 +1,72 @@
+import PreviewHtml from '@/components/fieldtypes/replicator/PreviewHtml.js';
+import formatPreviewValue from '@/util/formatPreviewValue';
+import { escapeHtml } from '@/bootstrap/globals.js';
+
+/**
+ * Build preview text from field values and mounted component previews.
+ *
+ * @param {Object} params - Parameters
+ * @param {Object} params.previews - Preview data from mounted fieldtype components
+ * @param {Object} params.config - Field configuration with fields array
+ * @param {Object} params.values - Current field values
+ * @param {boolean} params.showFieldPreviews - Whether to show field previews by default
+ * @param {string} params.separator - Separator string to use between preview values
+ * @returns {string} - The formatted preview text
+ */
+export function buildPreviewText({
+ previews,
+ config,
+ values,
+ showFieldPreviews,
+ separator,
+}) {
+ const hasMountedPreviews = Object.keys(previews).length > 0;
+
+ let previewValues;
+
+ if (hasMountedPreviews) {
+ // Use previews from mounted fieldtype components
+ previewValues = Object.entries(previews)
+ .filter(([handle, value]) => {
+ if (!handle.endsWith('_')) return false;
+ handle = handle.slice(0, -1); // Remove the trailing underscore
+ const fieldConfig = config.fields?.find((f) => f.handle === handle);
+ if (!fieldConfig) return false;
+ return fieldConfig.replicator_preview === undefined ? showFieldPreviews : fieldConfig.replicator_preview;
+ })
+ .map(([handle, value]) => value)
+ .filter((value) => {
+ if (value == null || value === '') return false;
+ if (typeof value === 'object' && !(value instanceof PreviewHtml) && !Array.isArray(value)) {
+ return false;
+ }
+ return true;
+ })
+ .map((value) => {
+ if (value instanceof PreviewHtml) return value.html;
+ if (typeof value === 'string') return escapeHtml(value);
+ if (Array.isArray(value)) return escapeHtml(value.join(', '));
+ return escapeHtml(String(value));
+ })
+ .filter((html) => html && html.trim() !== '');
+ } else {
+ // Fallback: extract values directly from values
+ const fields = config.fields || [];
+ previewValues = fields
+ .filter((field) => {
+ const shouldShow = field.replicator_preview === undefined ? showFieldPreviews : field.replicator_preview;
+ if (!shouldShow) return false;
+ const value = values?.[field.handle];
+ if (value == null || value === '') return false;
+ if (Array.isArray(value)) return value.length > 0;
+ if (typeof value === 'object') return Object.keys(value).length > 0;
+ return true;
+ })
+ .map((field) => formatPreviewValue(values?.[field.handle], field, { escape: true }))
+ .filter((value) => value && value.trim() !== '');
+ }
+
+ return previewValues.join(separator);
+}
+
+export default buildPreviewText;
diff --git a/resources/js/util/createMountScheduler.js b/resources/js/util/createMountScheduler.js
new file mode 100644
index 00000000000..2554fcf0937
--- /dev/null
+++ b/resources/js/util/createMountScheduler.js
@@ -0,0 +1,49 @@
+import { nextTick } from 'vue';
+
+const DEFAULT_BUDGET_MS = 8;
+
+export function createMountScheduler({ budgetMs = DEFAULT_BUDGET_MS } = {}) {
+ const queue = [];
+ let flushing = false;
+
+ const waitForIdle = () => new Promise((resolve) => {
+ if (typeof requestIdleCallback === 'function') {
+ requestIdleCallback(resolve, { timeout: 50 });
+ } else {
+ requestAnimationFrame(() => resolve());
+ }
+ });
+
+ function schedule(callback) {
+ queue.push(callback);
+ if (!flushing) flush();
+ }
+
+ async function flush() {
+ flushing = true;
+ try {
+ while (queue.length) {
+ let deadline;
+ deadline = await waitForIdle();
+
+ const frameStart = performance.now();
+ const shouldYield = () => {
+ if (deadline && typeof deadline.timeRemaining === 'function' && !deadline.didTimeout) {
+ return deadline.timeRemaining() < 1;
+ }
+ return performance.now() - frameStart >= budgetMs;
+ };
+
+ while (queue.length && !shouldYield()) {
+ const cb = queue.shift();
+ try { cb?.(); } catch (e) { console.error(e); }
+ try { await nextTick(); } catch (e) { console.error(e); }
+ }
+ }
+ } finally {
+ flushing = false;
+ }
+ }
+
+ return { schedule };
+}
diff --git a/resources/js/util/extractBardText.js b/resources/js/util/extractBardText.js
new file mode 100644
index 00000000000..3a3f0e6b29d
--- /dev/null
+++ b/resources/js/util/extractBardText.js
@@ -0,0 +1,23 @@
+export default function extractBardText(
+ prosemirrorNodes,
+ limit = 150,
+ setConfigs = null,
+) {
+ if (!Array.isArray(prosemirrorNodes)) return "";
+
+ const stack = [...prosemirrorNodes];
+ let text = "";
+ while (stack.length && text.length < limit) {
+ const node = stack.shift();
+ if (node.type === 'text') {
+ text += ` ${node.text || ''}`;
+ } else if (node.type === 'set' && setConfigs) {
+ const handle = node.attrs?.values?.type;
+ const set = setConfigs.find((s) => s.handle === handle);
+ text += ` [${__(set ? set.display : handle)}]`;
+ } else {
+ if (node.content) stack.unshift(...node.content);
+ }
+ }
+ return text.trim();
+}
diff --git a/resources/js/util/formatPreviewValue.js b/resources/js/util/formatPreviewValue.js
new file mode 100644
index 00000000000..ee23331f3a8
--- /dev/null
+++ b/resources/js/util/formatPreviewValue.js
@@ -0,0 +1,247 @@
+import PreviewHtml from '@/components/fieldtypes/replicator/PreviewHtml.js';
+import extractBardText from '@/util/extractBardText';
+import { escapeHtml } from '@/bootstrap/globals.js';
+
+/**
+ * Normalize field options to a consistent format for lookup.
+ * Handles array-of-strings, array-of-objects {value, label} or {key, value}, and plain objects.
+ *
+ * @param {Array|Object} options - The options configuration
+ * @returns {Array} - Array of {value, label} objects
+ */
+function resolveOptions(options) {
+ if (!options) return [];
+
+ // Plain object: {key: value, key2: value2}
+ if (!Array.isArray(options)) {
+ return Object.entries(options).map(([key, val]) => ({
+ value: key,
+ label: val,
+ }));
+ }
+
+ // Array of strings: ['option1', 'option2']
+ if (options.length > 0 && typeof options[0] === 'string') {
+ return options.map((opt) => ({
+ value: opt,
+ label: opt,
+ }));
+ }
+
+ // Array of objects - normalize key/value to value/label
+ return options.map((opt) => {
+ if (typeof opt === 'object' && opt !== null) {
+ return {
+ value: opt.value !== undefined ? opt.value : opt.key,
+ label: opt.label !== undefined ? opt.label : opt.value,
+ };
+ }
+ return { value: opt, label: opt };
+ });
+}
+
+/**
+ * Resolve option label(s) for a given value.
+ *
+ * @param {*} value - The selected value(s)
+ * @param {Object} fieldConfig - The field configuration
+ * @returns {string|null} - The resolved label(s) or null
+ */
+function resolveOptionLabel(value, fieldConfig) {
+ const options = resolveOptions(fieldConfig.options);
+ if (options.length === 0) return null;
+
+ const findLabel = (val) => {
+ const option = options.find((opt) => opt.value === val);
+ return option ? option.label : val;
+ };
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) return null;
+ return value.map(findLabel).join(', ');
+ }
+
+ return findLabel(value);
+}
+
+/**
+ * Truncate a string to a maximum length.
+ *
+ * @param {string} str - The string to truncate
+ * @param {number} maxLength - Maximum length
+ * @returns {string} - Truncated string
+ */
+function truncate(str, maxLength) {
+ if (!str || str.length <= maxLength) return str;
+ return str.slice(0, maxLength) + '...';
+}
+
+/**
+ * Format a preview value for display in replicator sets.
+ *
+ * @param {*} value - The value to format
+ * @param {Object} fieldConfig - The field configuration
+ * @param {Object} options - Options for formatting
+ * @param {boolean} options.escape - Whether to escape HTML in the output (default: false)
+ * @returns {string|null} - The formatted preview value, or null if the value should be skipped
+ */
+export default function formatPreviewValue(value, fieldConfig, options = {}) {
+ const { escape = false } = options;
+
+ if (value == null || value === '') return null;
+
+ // Handle PreviewHtml instances
+ if (value instanceof PreviewHtml) {
+ return value.html;
+ }
+
+ const type = fieldConfig?.type;
+
+ // Type-specific handling (ordered before generic fallbacks)
+
+ // Toggle: ✓ Field Label / ✗ Field Label
+ if (type === 'toggle') {
+ const display = fieldConfig.display || 'Toggle';
+ const prefix = value ? '✓' : '✗';
+ const result = display ? `${prefix} ${display}` : prefix;
+ return escape ? escapeHtml(result) : result;
+ }
+
+ // Select, Radio, Button Group: resolved option label
+ if (type === 'select' || type === 'radio' || type === 'button_group') {
+ const label = resolveOptionLabel(value, fieldConfig);
+ if (!label) return null;
+ return escape ? escapeHtml(label) : label;
+ }
+
+ // Checkboxes: labels joined by ', '
+ if (type === 'checkboxes') {
+ const label = resolveOptionLabel(value, fieldConfig);
+ if (!label) return null;
+ return escape ? escapeHtml(label) : label;
+ }
+
+ // Dictionary: same as select (uses config.options with loaded meta)
+ if (type === 'dictionary') {
+ const label = resolveOptionLabel(value, fieldConfig);
+ if (!label) return null;
+ return escape ? escapeHtml(label) : label;
+ }
+
+ // Replicator: Display: N set(s)
+ if (type === 'replicator') {
+ const display = fieldConfig.display || 'Replicator';
+ const count = Array.isArray(value) ? value.length : 0;
+ const result = `${display}: ${count} ${count === 1 ? 'Set' : 'Sets'}`;
+ return escape ? escapeHtml(result) : result;
+ }
+
+ // Grid: Display: N row(s)
+ if (type === 'grid') {
+ const display = fieldConfig.display || 'Grid';
+ const count = Array.isArray(value) ? value.length : 0;
+ const result = `${display}: ${count} ${count === 1 ? 'Row' : 'Rows'}`;
+ return escape ? escapeHtml(result) : result;
+ }
+
+ // Assets: simplified checkmark
+ if (type === 'assets') {
+ const hasAssets = Array.isArray(value) && value.length > 0;
+ const result = hasAssets ? '✓' : '✗';
+ return escape ? escapeHtml(result) : result;
+ }
+
+ // Color: show hex string
+ if (type === 'color') {
+ const colorValue = typeof value === 'string' ? value : value?.hex || value?.color;
+ if (!colorValue) return null;
+ return escape ? escapeHtml(String(colorValue)) : String(colorValue);
+ }
+
+ // Code: truncate code content
+ if (type === 'code') {
+ const codeValue = typeof value === 'string' ? value : value?.code;
+ if (!codeValue) return null;
+ const truncated = truncate(codeValue, 60);
+ return escape ? escapeHtml(truncated) : truncated;
+ }
+
+ // Table: joined cell values
+ if (type === 'table') {
+ if (!Array.isArray(value)) return null;
+ const rows = value
+ .map((row) => {
+ if (!row || !Array.isArray(row.cells)) return '';
+ return row.cells.filter(Boolean).join(', ');
+ })
+ .filter(Boolean);
+ if (rows.length === 0) return null;
+ const result = rows.join(', ');
+ return escape ? escapeHtml(result) : result;
+ }
+
+ // Array: key: value pairs joined
+ if (type === 'array') {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
+ const entries = Object.entries(value)
+ .map(([k, v]) => `${k}: ${v}`)
+ .join(', ');
+ if (!entries) return null;
+ return escape ? escapeHtml(entries) : entries;
+ }
+
+ // Entries / Terms / Users: count only
+ if (type === 'entries' || type === 'terms' || type === 'users') {
+ const count = Array.isArray(value) ? value.length : 0;
+ const result = `${count} ${count === 1 ? 'Item' : 'Items'}`;
+ return escape ? escapeHtml(result) : result;
+ }
+
+ // Link: show raw string value (usually a URL)
+ if (type === 'link') {
+ const linkValue = typeof value === 'string' ? value : value?.url || value?.permalink;
+ if (!linkValue) return null;
+ return escape ? escapeHtml(String(linkValue)) : String(linkValue);
+ }
+
+ // Revealer: always hidden
+ if (type === 'revealer') {
+ return null;
+ }
+
+ // Bard: Display: N block(s)
+ if (type === 'bard' && Array.isArray(value)) {
+ const display = fieldConfig.display || 'Content';
+ const count = value.length;
+ const result = `${display}: ${count} ${count === 1 ? 'Block' : 'Blocks'}`;
+ return escape ? escapeHtml(result) : result;
+ }
+
+ // Markdown: pass through as-is (markdown is human-readable)
+ if (type === 'markdown') {
+ const mdValue = typeof value === 'string' ? value : null;
+ if (!mdValue) return null;
+ return escape ? escapeHtml(mdValue) : mdValue;
+ }
+
+ // Handle array of strings (e.g., select, tags) - fallback for non-typed arrays
+ if (
+ Array.isArray(value) &&
+ value.length > 0 &&
+ typeof value[0] === 'string'
+ ) {
+ const joined = value.join(', ');
+ return escape ? escapeHtml(joined) : joined;
+ }
+
+ // Skip complex objects/arrays that would show as [object Object] or JSON
+ if (
+ Array.isArray(value) ||
+ (typeof value === 'object' && !(value instanceof PreviewHtml))
+ ) {
+ return null;
+ }
+
+ const stringValue = String(value);
+ return escape ? escapeHtml(stringValue) : stringValue;
+}