Skip to content

Commit 539b58f

Browse files
committed
Improve CP performance: staggered set mounting, fallback previews, targeted condition watching
- Add createMountScheduler() to mount replicator/bard sets progressively using requestIdleCallback, eliminating as much freezes as possible on "Expand All" - Add buildPreviewText/formatPreviewValue/extractBardText utilities for value-based fallback previews when fields aren't mounted yet - Optimize Field.vue to only watch specific field handles in conditions instead of entire values object, reducing re-renders in large replicators - Optimize Container.vue visibleValues to return by reference when possible, reducing GC pressure; emit only when hiddenFields change - Add CSS *-bulk-op classes to disable transitions during expand/collapse all - Deduplicate RelationshipInput requests via inFlightRequests Map - Fix LivePreview memory leak by properly removing event listener on unmount - Wrap FieldAction instances with markRaw() to prevent reactivity proxy errors
1 parent 8a48cc5 commit 539b58f

26 files changed

Lines changed: 1246 additions & 175 deletions

resources/css/components/fieldtypes/bard.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
/* BARD
22
=================================================== */
3+
4+
.bard-bulk-op [data-type] header,
5+
.bard-bulk-op [data-type] header * {
6+
transition: none !important;
7+
}
8+
39
@layer ui {
410
/* .grid-cell, [data-ui-input-group] are exceptions for Bard fieldtypes inside a Grid fieldtype, since these cause double borders */
511
.bard-fieldtype:not(.form-group, .grid-cell, [data-ui-input-group]) {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
/* ==========================================================================
22
REPLICATOR FIELDTYPE
33
========================================================================== */
4+
5+
.replicator-bulk-op [data-replicator-set] header,
6+
.replicator-bulk-op [data-replicator-set] header * {
7+
transition: none !important;
8+
}

resources/js/bootstrap/statamic.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,11 @@ export default {
198198
const bladeContent = el?.innerHTML || '';
199199
const _this = this;
200200

201+
const corePages = import.meta.glob('../pages/**/*.vue');
202+
201203
await createInertiaApp({
202204
id: 'statamic',
203-
resolve: name => {
205+
resolve: async name => {
204206
if (name === 'NonInertiaPage') {
205207
return {
206208
default: {
@@ -211,8 +213,8 @@ export default {
211213
}
212214

213215
// Resolve core pages
214-
const pages = import.meta.glob('../pages/**/*.vue', { eager: true });
215-
let page = pages[`../pages/${name}.vue`];
216+
const pageImport = corePages[`../pages/${name}.vue`];
217+
let page = pageImport ? await pageImport() : null;
216218

217219
// Resolve addon pages
218220
if (!page) {

resources/js/components/field-actions/HasFieldActions.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import toFieldActions from './toFieldActions.js';
22

33
export default {
4+
data() {
5+
return {
6+
_cachedFieldActions: null,
7+
};
8+
},
9+
410
computed: {
511
fieldActions() {
6-
return toFieldActions(
12+
if (this._cachedFieldActions) return this._cachedFieldActions;
13+
this._cachedFieldActions = toFieldActions(
714
this.fieldActionBinding,
815
this.fieldActionPayload,
916
this.internalFieldActions,
1017
);
18+
return this._cachedFieldActions;
1119
},
1220

1321
internalFieldActions() {
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { markRaw } from 'vue';
12
import FieldAction from './FieldAction.js';
23

34
export default function toFieldActions(binding, payload, extraActions = []) {
45
return [...Statamic.$fieldActions.get(binding), ...extraActions]
5-
.map((action) => new FieldAction(action, payload))
6+
.map((action) => markRaw(new FieldAction(action, payload)))
67
.filter((action) => action.visible);
78
}

resources/js/components/fieldtypes/Fieldtype.vue

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,31 @@ export default {
4242
// When using the Options API, this feels more natural. However since this is a
4343
// computed, it won't be avaialble within data(). In those cases you will
4444
// need to use this.injectedPublishContainer.someValue.value directly.
45-
return Object.fromEntries(
46-
Object.entries(this.injectedPublishContainer).map(([key, value]) => [
47-
key,
48-
isRef(value) ? value.value : value,
49-
])
50-
);
45+
//
46+
// We build the cache once with lazy getters, so the computed has zero reactive deps
47+
// and is never invalidated. Dep tracking happens in the consumer's reactive scope.
48+
if (this._publishContainerCache) return this._publishContainerCache;
49+
const cache = {};
50+
const src = this.injectedPublishContainer;
51+
for (const key in src) {
52+
const val = src[key];
53+
if (isRef(val)) {
54+
Object.defineProperty(cache, key, {
55+
enumerable: true,
56+
configurable: true,
57+
get: () => val.value,
58+
});
59+
} else {
60+
Object.defineProperty(cache, key, {
61+
enumerable: true,
62+
configurable: true,
63+
writable: false,
64+
value: val,
65+
});
66+
}
67+
}
68+
this._publishContainerCache = cache;
69+
return cache;
5170
},
5271
5372
name() {

resources/js/components/fieldtypes/LinkFieldtype.vue

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
<script>
4141
import Fieldtype from './Fieldtype.vue';
4242
import { Input, Select } from '@/components/ui';
43+
import debounce from '@/util/debounce.js';
44+
import { markRaw } from 'vue';
4345
4446
export default {
4547
components: { Input, Text, Select },
@@ -60,6 +62,13 @@ export default {
6062
};
6163
},
6264
65+
created() {
66+
this.syncUrlDebounced = markRaw(debounce((url) => {
67+
this.update(url);
68+
this.updateMeta({ ...this.meta, initialUrl: url });
69+
}, 150));
70+
},
71+
6372
computed: {
6473
entryValue() {
6574
return this.selectedEntries.length ? `entry::${this.selectedEntries[0]}` : null;
@@ -116,13 +125,17 @@ export default {
116125
117126
urlValue(url) {
118127
if (this.metaChanging) return;
119-
120-
this.update(url);
121-
this.updateMeta({ ...this.meta, initialUrl: url });
128+
this.syncUrlDebounced(url);
122129
},
123130
124131
meta(meta, oldMeta) {
125-
if (JSON.stringify(meta) === JSON.stringify(oldMeta)) return;
132+
if (meta === oldMeta) return;
133+
if (
134+
meta.initialUrl === oldMeta.initialUrl &&
135+
meta.initialOption === oldMeta.initialOption &&
136+
this.shallowArrayEqual(meta.initialSelectedEntries, oldMeta.initialSelectedEntries) &&
137+
this.shallowArrayEqual(meta.initialSelectedAssets, oldMeta.initialSelectedAssets)
138+
) return;
126139
127140
this.metaChanging = true;
128141
this.urlValue = meta.initialUrl;
@@ -134,6 +147,10 @@ export default {
134147
},
135148
136149
methods: {
150+
shallowArrayEqual(a, b) {
151+
return a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => x === b[i]));
152+
},
153+
137154
initialOptions() {
138155
return [
139156
this.config.required ? null : { label: __('None'), value: null },

resources/js/components/fieldtypes/TextFieldtype.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,15 @@ const shouldFocus = computed(() => {
4040
if (props.config.focus === false || props.config.focus === true) {
4141
return props.config.focus;
4242
}
43-
43+
4444
const isRootField = !props.fieldPathPrefix;
4545
const isImplicitAutofocusField = name.value === 'title' || name.value === 'alt';
4646
4747
return isRootField && isImplicitAutofocusField;
4848
});
4949
5050
function inputUpdated(value) {
51-
return !props.config.debounce ? update(value) : updateDebounced(value);
51+
return props.config.debounce === false ? update(value) : updateDebounced(value);
5252
}
5353
5454
defineExpose(expose);

resources/js/components/fieldtypes/bard/BardFieldtype.vue

Lines changed: 26 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<div :class="fullScreenMode && wrapperClasses">
88
<div
99
class="bard-fieldtype antialiased with-contrast:border-gray-500 shadow-ui-sm"
10-
:class="{ 'bard-fullscreen': fullScreenMode }"
10+
:class="{ 'bard-fullscreen': fullScreenMode, 'bard-bulk-op': suppressTransitions }"
1111
ref="container"
1212
@dragstart.stop="ignorePageHeader(true)"
1313
@dragend="ignorePageHeader(false)"
@@ -177,6 +177,8 @@ import 'highlight.js/styles/github.css';
177177
import importTiptap from '@/util/tiptap.js';
178178
import { computed } from 'vue';
179179
import { data_get } from "@/bootstrap/globals.js";
180+
import extractBardText from "@/util/extractBardText";
181+
import { createMountScheduler } from "@/util/createMountScheduler.js";
180182
181183
const lowlight = createLowlight(common);
182184
let tiptap = null;
@@ -213,10 +215,12 @@ export default {
213215
escBinding: null,
214216
showAddSetButton: false,
215217
hasBeenFocused: false,
218+
suppressTransitions: false,
216219
provide: {
217220
bard: this.makeBardProvide(),
218221
bardSets: this.config.sets,
219222
showReplicatorFieldPreviews: this.config.previews,
223+
mountScheduler: createMountScheduler(),
220224
},
221225
errorsById: {},
222226
debounceNextUpdate: true,
@@ -294,25 +298,7 @@ export default {
294298
295299
replicatorPreview() {
296300
if (!this.showFieldPreviews) return;
297-
const stack = [...this.value];
298-
let text = '';
299-
while (stack.length) {
300-
const node = stack.shift();
301-
if (node.type === 'text') {
302-
text += ` ${node.text || ''}`;
303-
} else if (node.type === 'set') {
304-
const handle = node.attrs.values.type;
305-
const set = this.setConfigs.find((set) => set.handle === handle);
306-
text += ` [${__(set ? set.display : handle)}]`;
307-
}
308-
if (text.length > 150) {
309-
break;
310-
}
311-
if (node.content) {
312-
stack.unshift(...node.content);
313-
}
314-
}
315-
return text;
301+
return extractBardText(this.value, 150, this.setConfigs);
316302
},
317303
318304
inputIsInline() {
@@ -391,6 +377,7 @@ export default {
391377
392378
this.json = this.editor.getJSON().content;
393379
this.html = this.editor.getHTML();
380+
this._lastDocSize = this.editor.state.doc.content.size;
394381
395382
this.$nextTick(() => this.mounted = true);
396383
@@ -643,11 +630,19 @@ export default {
643630
},
644631
645632
collapseAll() {
633+
this.suppressTransitions = true;
646634
this.collapsed = Object.keys(this.meta.existing);
635+
this.$nextTick(() => requestAnimationFrame(() => {
636+
this.suppressTransitions = false;
637+
}));
647638
},
648639
649640
expandAll() {
641+
this.suppressTransitions = true;
650642
this.collapsed = [];
643+
this.$nextTick(() => requestAnimationFrame(() => {
644+
this.suppressTransitions = false;
645+
}));
651646
},
652647
653648
toggleCollapseSets() {
@@ -868,24 +863,19 @@ export default {
868863
}
869864
}, 1);
870865
},
871-
onUpdate: () => {
872-
const oldJson = this.json;
873-
const newJson = clone(this.editor.getJSON().content);
874-
875-
const countNodes = (nodes) => {
876-
if (!nodes || !Array.isArray(nodes)) return 0;
877-
let count = nodes.length;
878-
nodes.forEach(node => {
879-
if (node.content) {
880-
count += countNodes(node.content);
881-
}
882-
});
883-
return count;
884-
};
866+
onUpdate: ({ transaction }) => {
867+
// Filter out non-doc transactions (selection, metadata, etc.).
868+
if (!transaction.docChanged) return;
869+
870+
const newDocSize = this.editor.state.doc.content.size;
871+
const oldDocSize = this._lastDocSize ?? newDocSize;
885872
886-
if (countNodes(oldJson) !== countNodes(newJson)) this.debounceNextUpdate = false;
873+
// Structural size change -> sync immediately, skip the usual debounce window.
874+
if (oldDocSize !== newDocSize) this.debounceNextUpdate = false;
875+
this._lastDocSize = newDocSize;
887876
888-
this.json = newJson;
877+
// getJSON() already returns a fresh plain object — no clone() needed.
878+
this.json = this.editor.getJSON().content;
889879
this.html = this.editor.getHTML();
890880
},
891881
onCreate: ({ editor }) => {

0 commit comments

Comments
 (0)