From b912f7b5e0461ce5f3662553983748bfbbf7c2ed Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 11 Aug 2026 09:26:10 -0400 Subject: [PATCH 01/31] performance testing instrumentation --- .gitignore | 1 + CONTRIBUTING.md | 51 + package.json | 3 + packages/cms/src/api.js | 1 + resources/js/api.js | 2 + resources/js/bootstrap/statamic.js | 7 + .../js/components/fieldtypes/Fieldtype.vue | 3 + .../fieldtypes/bard/BardFieldtype.vue | 85 +- .../js/components/fieldtypes/bard/Set.vue | 16 +- .../replicator/ManagesPreviewText.js | 41 +- .../fieldtypes/replicator/Replicator.vue | 104 +- .../components/ui/LivePreview/LivePreview.vue | 8 +- .../js/components/ui/Publish/Container.vue | 29 +- .../js/components/ui/Publish/SavePipeline.js | 89 +- resources/js/tests/Package.test.js | 1 + .../js/tests/browser/bench/bard.bench.js | 64 ++ .../js/tests/browser/bench/fixtures.bench.js | 77 ++ .../js/tests/browser/bench/mount.bench.js | 56 ++ .../tests/browser/bench/replicator.bench.js | 107 +++ resources/js/tests/browser/fixtures/bard.js | 176 ++++ resources/js/tests/browser/fixtures/index.js | 15 + .../js/tests/browser/fixtures/replicator.js | 146 +++ resources/js/tests/browser/fixtures/seeded.js | 41 + resources/js/tests/browser/helpers/mount.js | 252 +++++ resources/js/tests/browser/setup.js | 94 ++ resources/js/tests/util/perf.test.js | 250 +++++ resources/js/util/perf.js | 909 ++++++++++++++++++ scripts/bench-diff.mjs | 151 +++ vite.config.js | 16 + 29 files changed, 2677 insertions(+), 118 deletions(-) create mode 100644 resources/js/tests/browser/bench/bard.bench.js create mode 100644 resources/js/tests/browser/bench/fixtures.bench.js create mode 100644 resources/js/tests/browser/bench/mount.bench.js create mode 100644 resources/js/tests/browser/bench/replicator.bench.js create mode 100644 resources/js/tests/browser/fixtures/bard.js create mode 100644 resources/js/tests/browser/fixtures/index.js create mode 100644 resources/js/tests/browser/fixtures/replicator.js create mode 100644 resources/js/tests/browser/fixtures/seeded.js create mode 100644 resources/js/tests/browser/helpers/mount.js create mode 100644 resources/js/tests/browser/setup.js create mode 100644 resources/js/tests/util/perf.test.js create mode 100644 resources/js/util/perf.js create mode 100644 scripts/bench-diff.mjs diff --git a/.gitignore b/.gitignore index 4026fb22467..963bd0fdbb8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ resources/dist-dev resources/dist-frontend resources/dist-package resources/js/tests/browser/__screenshots__ +benchmarks/results.json packages/cms/src/ui.css composer.lock .env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4b018ba314..a255b73730e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,7 @@ This is a guideline for contributing to Statamic, its documentation, and addons. - [How You Can Contribute](#how-you-can-contribute) - [Which Repo?](#which-repo) - [Bug Reports](#bug-reports) +- [Control Panel Performance Reports](#control-panel-performance-reports) - [Feature Requests](#feature-requests) - [Security Disclosures](#security-disclosures) - [Core Enhancements](#core-enhancements) @@ -55,6 +56,56 @@ If you _do_ find a similar issue, upvote it by adding a :thumbsup: [reaction](ht If no one has filed the issue yet, feel free to [submit a new one](https://github.com/statamic/cms/issues/new?template=bug_report.yml). Please include a clear description of the issue, follow along with the issue template, and provide and as much relevant information as possible. Code examples demonstrating the issue are the best way to ensure a timely solution to the issue. +### Control Panel Performance Reports + +If you're reporting sluggish Bard, Replicator, or publish-form behavior, please include a structured perf report when you can: + +1. Open the Control Panel and run this in the browser console: + +```js +localStorage.setItem('statamic.perf', '1') +``` + +2. Reload the page (required — instrumentation starts in the **mount** phase on boot). +3. Reproduce the issue. Useful recipes: + - **Slow initial render:** open the entry, wait until idle, then `Statamic.$perf.report()` — look at `phase.mount` and the `mount.*` rows. + - **Slow save:** click Save, wait for it to finish, then report — look at `phase.save` and `save.publish.save.*`. + - **Slow typing / editing:** after the form is idle, run `Statamic.$perf.reset()` to clear mount noise, reproduce the interaction, then report — look at `interact.*`. +4. Run: + +```js +Statamic.$perf.report() +``` + +You'll get a color-coded list grouped by phase (`mount` → `save` → `interact`). Times are **milliseconds**. Headline wall clocks: `phase.mount` (initial render) and `phase.save` (full save pipeline). Heat levels: `critical`, `hot`, `warm`, `ok`, or `count` (tally only). + +5. Export / paste into the GitHub issue (DevTools `console.table` is preview-only — use these): + +```js +Statamic.$perf.copy('md') // markdown table → clipboard (best for GitHub) +Statamic.$perf.copy() // TSV → clipboard (spreadsheets) +Statamic.$perf.copy('json') // versioned snapshot → clipboard +Statamic.$perf.download() // download snapshot JSON +``` + +To compare two runs over time: + +```js +const before = Statamic.$perf.snapshot('before-fix') +// …change code / reload / reproduce… +Statamic.$perf.diff(before) // console delta table +Statamic.$perf.copyDiff(before) // TSV deltas → clipboard +``` + +To turn instrumentation off afterward: + +```js +Statamic.$perf.disable() +// or: localStorage.removeItem('statamic.perf') +``` + +This uses the browser User Timing API under the hood (marks also show up in the Chrome DevTools Performance panel). Core maintainers compare changes against the Vitest browser benchmark suite — see [`benchmarks/README.md`](benchmarks/README.md). + ### Feature Requests Feature requests should be created in the [statamic/ideas](https://github.com/statamic/ideas) repository. diff --git a/package.json b/package.json index 28d8599ab14..0909848a45f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "svgo": "svgo -f ./resources/svg/ -r", "test": "vitest run", "test-watch": "npm run test -- --watch --notify", + "bench": "vitest bench --project bench --run --outputJson benchmarks/results.json", + "bench:compare": "npm run bench && node scripts/bench-diff.mjs benchmarks/baseline.json benchmarks/results.json", + "bench:update-baseline": "vitest bench --project bench --run --outputJson benchmarks/baseline.json", "frontend-dev": "vite -c vite-frontend.config.js", "frontend-build": "vite build -c vite-frontend.config.js", "knip": "knip", diff --git a/packages/cms/src/api.js b/packages/cms/src/api.js index 40e8654a1ed..e28b1e1fbc1 100644 --- a/packages/cms/src/api.js +++ b/packages/cms/src/api.js @@ -15,6 +15,7 @@ export const { inertia, keys, numberFormatter, + perf, permissions, portals, preferences, diff --git a/resources/js/api.js b/resources/js/api.js index 8e2ad4c0c9d..fd79665667c 100644 --- a/resources/js/api.js +++ b/resources/js/api.js @@ -24,6 +24,7 @@ import Toasts from './components/Toasts.js'; import Portals from './components/portals/Portals.js'; import Stacks from './components/ui/Stack/Stacks.js'; import Inertia from './components/Inertia'; +import perf from './util/perf.js'; export const keys = new Keys(); export const components = new Components; @@ -50,3 +51,4 @@ export const toast = new Toasts(); export const portals = markRaw(new Portals()); export const stacks = new Stacks(portals); export const inertia = new Inertia(); +export { perf }; diff --git a/resources/js/bootstrap/statamic.js b/resources/js/bootstrap/statamic.js index 3a5eda975b2..67345ac03a6 100644 --- a/resources/js/bootstrap/statamic.js +++ b/resources/js/bootstrap/statamic.js @@ -46,6 +46,7 @@ import { portals, stacks, inertia, + perf, } from '@api'; let bootingCallbacks = []; @@ -149,6 +150,10 @@ export default { return dirty; }, + get $perf() { + return perf; + }, + get $events() { return events; }, @@ -267,6 +272,7 @@ export default { this.$app.directive('tooltip', tooltipDirective); this.$app.use(VueComponentDebug, { enabled: import.meta.env.VITE_VUE_COMPONENT_DEBUG === 'true' }); toast.initialize(this.$app); + perf.attachVueApp(this.$app); Object.assign(this.$app.config.globalProperties, { $config: config, @@ -279,6 +285,7 @@ export default { $conditions: conditions, $callbacks: callbacks, $dirty: dirty, + $perf: perf, $slug: slug, $portals: portals, $stacks: stacks, diff --git a/resources/js/components/fieldtypes/Fieldtype.vue b/resources/js/components/fieldtypes/Fieldtype.vue index c6ee19b8aa3..b09d2d72c91 100644 --- a/resources/js/components/fieldtypes/Fieldtype.vue +++ b/resources/js/components/fieldtypes/Fieldtype.vue @@ -6,6 +6,7 @@ import emits from './emits.js'; import { UPDATE_DEBOUNCE_MS } from './constants'; import { publishContextKey } from '@/components/ui'; import { isRef, markRaw } from 'vue'; +import { perf } from '@api'; export default { emits, @@ -22,10 +23,12 @@ export default { methods: { update(value) { + perf.count(`fieldtype.update.${this.config?.type || 'unknown'}`); this.$emit('update:value', value); }, updateMeta(value) { + perf.count(`fieldtype.updateMeta.${this.config?.type || 'unknown'}`); this.$emit('update:meta', value); }, }, diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index e682303b6f1..c7e9dd72648 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -134,6 +134,7 @@ From f83cc23be0083250afb532377498def055d2d5c4 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 11 Aug 2026 10:07:42 -0400 Subject: [PATCH 09/31] Fast-path UTF-8 check in relationship fieldtype controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2.2 — Skip mb_convert_encoding(..., mb_list_encodings()) when the decoded config JSON is already valid UTF-8. The conversion remains for the #566 unicode edge case. Estimated gain: A few ms CPU per relationship request × N requests per page load — adds up on monster entries. Pros: Trivial, output-identical for the common case. Cons: Must keep the slow path for non-UTF-8 configs (#566). Refs: #13385; #566. Co-authored-by: Cursor --- .../RelationshipFieldtypeController.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Http/Controllers/CP/Fieldtypes/RelationshipFieldtypeController.php b/src/Http/Controllers/CP/Fieldtypes/RelationshipFieldtypeController.php index 899feef822b..cf2db999ecf 100644 --- a/src/Http/Controllers/CP/Fieldtypes/RelationshipFieldtypeController.php +++ b/src/Http/Controllers/CP/Fieldtypes/RelationshipFieldtypeController.php @@ -56,12 +56,15 @@ private function getConfig($request) // The json may include unicode characters, so we'll try to convert it to UTF-8. // See https://github.com/statamic/cms/issues/566 - $utf8 = mb_convert_encoding($json, 'UTF-8', mb_list_encodings()); - - // In PHP 8.1 there's a bug where encoding will return null. It's fixed in 8.1.2. - // In this case, we'll fall back to the original JSON, but without the encoding. - // Issue #566 may still occur, but it's better than failing completely. - $json = empty($utf8) ? $json : $utf8; + // Fast path: skip encoding detection (~80 encodings) when already valid UTF-8. + if (! mb_check_encoding($json, 'UTF-8')) { + $utf8 = mb_convert_encoding($json, 'UTF-8', mb_list_encodings()); + + // In PHP 8.1 there's a bug where encoding will return null. It's fixed in 8.1.2. + // In this case, we'll fall back to the original JSON, but without the encoding. + // Issue #566 may still occur, but it's better than failing completely. + $json = empty($utf8) ? $json : $utf8; + } return json_decode($json, true); } From e29ddb8da9beb38e3d6d21c2e37986bd8498e513 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 11 Aug 2026 10:07:42 -0400 Subject: [PATCH 10/31] Avoid wasted field resolution in Fields::newInstance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2.3 — newInstance() was calling setItems() (full resolveFields) then immediately replacing via setFields(). Assign items directly on the clone so addValues/preProcess/process/augment don’t thrash. Estimated gain: Possibly best effort-to-impact in Phase 2 — speculative 10-30% of server render on replicator-heavy entries (every row, render/save/augment). Pros: No public API change; both properties are protected on the same class. Cons: Subtle — subclasses/addons that override setItems for side effects won’t see them on newInstance (none in core). Refs: #13385; plan Phase 2.3. Co-authored-by: Cursor --- src/Fields/Fields.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Fields/Fields.php b/src/Fields/Fields.php index f6707b50866..aab43494431 100644 --- a/src/Fields/Fields.php +++ b/src/Fields/Fields.php @@ -118,10 +118,14 @@ public function only(...$keys): self public function newInstance() { - return (new static) + // Assign items directly — setItems() would re-resolve every field, then + // setFields() would immediately discard that work. + $instance = new static; + $instance->items = $this->items; + + return $instance ->setParent($this->parent) ->setParentField($this->parentField, $this->parentIndex) - ->setItems($this->items) ->setFields($this->fields) ->setFilled($this->filled); } From 616a2e84b5bdcb449b3a13f1128b1c6cf7cbc915 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 11 Aug 2026 10:07:42 -0400 Subject: [PATCH 11/31] Memoize relationship item finds per fieldtype instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2.6 — authorizeItemData + toItemArray each called Entry/Term/User find for the same ID. Cache on the fieldtype instance so preload and relationship.data pay once per ID. Estimated gain: Medium — halves item lookups; larger on eloquent (half the queries). Pros: Leaves protected method signatures intact for subclasses. Cons: Per-instance only (not request-wide); stale if the same instance is reused across unrelated requests (not the CP fieldtype lifecycle). Refs: #13385; plan Phase 2.6. Co-authored-by: Cursor --- src/Fieldtypes/Entries.php | 10 ++++++++-- src/Fieldtypes/Terms.php | 29 ++++++++++++++++++++--------- src/Fieldtypes/Users.php | 10 ++++++++-- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 60cc7cd31b4..a339848ba88 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -44,6 +44,7 @@ class Entries extends Relationship protected $statusIcons = true; protected $formComponent = 'entry-publish-form'; protected $activeFilterBadges; + protected array $itemCache = []; protected $formComponentProps = [ 'initialActions' => 'actions', @@ -379,18 +380,23 @@ protected function getCreatables() protected function authorizeItemData($id): bool { - return $this->authorizeViewable(Entry::find($id)); + return $this->authorizeViewable($this->findEntry($id)); } protected function toItemArray($id) { - if (! $entry = Entry::find($id)) { + if (! $entry = $this->findEntry($id)) { return $this->invalidItemArray($id); } return (new EntryResource($entry, $this))->resolve()['data']; } + protected function findEntry($id) + { + return $this->itemCache[$id] ??= Entry::find($id); + } + protected function collect($value) { return new \Statamic\Entries\EntryCollection($value); diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index 1501d4c29c2..1122115495b 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -38,6 +38,7 @@ class Terms extends Relationship protected $canCreate = true; protected $canSearch = true; protected $statusIcons = false; + protected array $itemCache = []; protected $taggable = true; protected $icon = 'fieldtype-taxonomy'; protected $formComponent = 'term-publish-form'; @@ -418,20 +419,14 @@ protected function getCreatables() protected function authorizeItemData($id): bool { - if ($this->usingSingleTaxonomy() && ! Str::contains($id, '::')) { - $id = "{$this->taxonomies()[0]}::{$id}"; - } - - return $this->authorizeViewable(Term::find($id)); + return $this->authorizeViewable($this->findTerm($id)); } protected function toItemArray($id) { - if ($this->usingSingleTaxonomy() && ! Str::contains($id, '::')) { - $id = "{$this->taxonomies()[0]}::{$id}"; - } + $id = $this->normalizeTermId($id); - if (! $term = Term::find($id)) { + if (! $term = $this->findTerm($id)) { return $this->invalidItemArray($id); } @@ -457,6 +452,22 @@ protected function toItemArray($id) ]; } + protected function normalizeTermId($id): string + { + if ($this->usingSingleTaxonomy() && ! Str::contains($id, '::')) { + return "{$this->taxonomies()[0]}::{$id}"; + } + + return $id; + } + + protected function findTerm($id) + { + $id = $this->normalizeTermId($id); + + return $this->itemCache[$id] ??= Term::find($id); + } + protected function getColumns() { $columns = [Column::make('title')]; diff --git a/src/Fieldtypes/Users.php b/src/Fieldtypes/Users.php index 298771dc765..a1e09d57070 100644 --- a/src/Fieldtypes/Users.php +++ b/src/Fieldtypes/Users.php @@ -24,6 +24,7 @@ class Users extends Relationship protected $statusIcons = false; protected $formComponent = 'user-publish-form'; protected $canEdit = true; + protected array $itemCache = []; protected $formComponentProps = [ 'initialTitle' => 'title', @@ -104,12 +105,12 @@ public function preProcess($data) protected function authorizeItemData($id): bool { - return $this->authorizeViewable(User::find($id)); + return $this->authorizeViewable($this->findUser($id)); } protected function toItemArray($id, $site = null) { - if ($user = User::find($id)) { + if ($user = $this->findUser($id)) { $canViewUsers = $this->canViewUser($user); return [ @@ -123,6 +124,11 @@ protected function toItemArray($id, $site = null) return $this->invalidItemArray($id); } + protected function findUser($id) + { + return $this->itemCache[$id] ??= User::find($id); + } + public function getIndexItems($request) { // Don't reveal existence to a user who can't view the listing; return an empty From 7ae4c59433d01c8621b3c7e570ea3fc261bc68e8 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 11 Aug 2026 10:07:42 -0400 Subject: [PATCH 12/31] Speed up Bard/Replicator PHP preload and auto-collapse large fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2.1 — Call Assets meta() once in Bard::preload() instead of twice. P2.4 — Blink-cache linkTypesForToolbar() per link-type config. P2.5 — Resolve link item data via getItemData() (with preload fallback for custom link types) instead of full relationship preload per link. P2.7 — Memoize flattenedSetsConfig Blink key by spl_object_id(field) so singleton fieldtype reuse can’t stick the wrong config, and we stop re-json_encoding huge configs per row. Follow-up — Auto-collapse existing sets when count ≥ 10 even if the blueprint omits collapse: true, so deferred mounting (P3.2) actually engages on monster pages. Estimated gain: Tens–hundreds of ms server render on Bard-heavy pages from 2.1/2.4/2.5/2.7; auto-collapse is what unlocks the ~5-10x client win from #14512-style deferred mounting when authors leave collapse off. Pros: Output-identical preload data; Blink is request-scoped; tests cover collapse preload + link data. Cons: Auto-collapse changes default UX for large fields (headers only until expand). getItemData path must keep custom link-type fallback. flattenedSetsConfig memo must track the Field object identity, not the fieldtype singleton. Refs: #13385; #14512; plan Phase 2.1–2.5, 2.7. Co-authored-by: Cursor --- src/Fieldtypes/Bard.php | 72 +++++++++++++---------- src/Fieldtypes/Replicator.php | 43 +++++++++++++- tests/Fieldtypes/BardTest.php | 89 +++++++++++++++++++++++++++++ tests/Fieldtypes/ReplicatorTest.php | 74 ++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 33 deletions(-) diff --git a/src/Fieldtypes/Bard.php b/src/Fieldtypes/Bard.php index 4292a933050..58452220af4 100644 --- a/src/Fieldtypes/Bard.php +++ b/src/Fieldtypes/Bard.php @@ -655,7 +655,7 @@ public function preload() 'existing' => $existing, 'new' => $new ?? null, 'defaults' => $defaults ?? null, - 'collapsed' => $this->config('collapse') ? array_keys($existing) : [], + 'collapsed' => $this->initialCollapsedSetIds($existing), 'previews' => $previews, '__collaboration' => ['existing'], 'linkCollections' => $linkCollections, @@ -673,9 +673,11 @@ public function preload() 'folder' => $this->config('folder'), ])); + $assetMeta = $assetField->meta(); + $data['assets'] = [ - 'container' => $assetField->meta()['container'], - 'columns' => $assetField->meta()['columns'], + 'container' => $assetMeta['container'], + 'columns' => $assetMeta['columns'], ]; } @@ -809,8 +811,15 @@ private function linkDataForType(string $handle, string $id): ?array $nestedField = new Field($handle, $config); $nestedField->setValue([$id]); + $fieldtype = $nestedField->fieldtype(); + + // Relationship fieldtypes expose getItemData(); custom link types may + // only implement preload() with a data payload. + if (method_exists($fieldtype, 'getItemData')) { + return $fieldtype->getItemData([$id])->first(); + } - return $nestedField->fieldtype()->preload()['data'][0] ?? null; + return $fieldtype->preload()['data'][0] ?? null; } private function linkTypeField(): Field @@ -825,32 +834,35 @@ private function linkTypeField(): Field private function linkTypesForToolbar(): array { $field = $this->linkTypeField(); - - return collect(Link::types()) - ->filter(fn (LinkType $type): bool => $type->visible($field)) - ->map(function (LinkType $type, string $handle) use ($field): ?array { - if (! $config = $type->fieldtype($field)) { - return null; - } - - $nestedField = new Field($handle, $config); - $nestedFieldtype = $nestedField->fieldtype(); - - try { - $meta = $nestedFieldtype->preload(); - } catch (CollectionNotFoundException) { - $meta = []; - } - - return [ - 'title' => $type->title(), - 'component' => $nestedFieldtype->component(), - 'config' => $nestedFieldtype->config(), - 'meta' => $meta, - ]; - }) - ->filter() - ->all(); + $config = $field->config(); + + return Blink::once('bard-link-types-'.md5(json_encode($config)), function () use ($field) { + return collect(Link::types()) + ->filter(fn (LinkType $type): bool => $type->visible($field)) + ->map(function (LinkType $type, string $handle) use ($field): ?array { + if (! $config = $type->fieldtype($field)) { + return null; + } + + $nestedField = new Field($handle, $config); + $nestedFieldtype = $nestedField->fieldtype(); + + try { + $meta = $nestedFieldtype->preload(); + } catch (CollectionNotFoundException) { + $meta = []; + } + + return [ + 'title' => $type->title(), + 'component' => $nestedFieldtype->component(), + 'config' => $nestedFieldtype->config(), + 'meta' => $meta, + ]; + }) + ->filter() + ->all(); + }); } private function wrapInlineValue($value) diff --git a/src/Fieldtypes/Replicator.php b/src/Fieldtypes/Replicator.php index 816098c086f..4e23a8659ec 100644 --- a/src/Fieldtypes/Replicator.php +++ b/src/Fieldtypes/Replicator.php @@ -25,6 +25,15 @@ class Replicator extends Fieldtype protected $categories = ['structured']; protected $keywords = ['builder', 'page builder', 'content']; protected $rules = ['array']; + protected ?string $flattenedSetsConfigBlinkKey = null; + + protected ?int $flattenedSetsConfigFieldId = null; + + /** + * When a field has this many existing sets (or more), start them collapsed + * even if the collapse config is off — unlocks deferred field-body mounting. + */ + private const AUTO_COLLAPSE_SET_THRESHOLD = 10; protected function configFieldItems(): array { @@ -259,10 +268,27 @@ public function preload() 'existing' => $existing, 'new' => $new ?? null, 'defaults' => $defaults ?? null, - 'collapsed' => $this->config('collapse') ? array_keys($existing) : [], + 'collapsed' => $this->initialCollapsedSetIds($existing), ]; } + /** + * @param array $existing + * @return list + */ + protected function initialCollapsedSetIds(array $existing): array + { + if ($this->config('collapse')) { + return array_keys($existing); + } + + if (count($existing) >= self::AUTO_COLLAPSE_SET_THRESHOLD) { + return array_keys($existing); + } + + return []; + } + private function shouldProcessNewValues(): bool { $parent = $this->field()->parent(); @@ -276,9 +302,20 @@ private function shouldProcessNewValues(): bool public function flattenedSetsConfig() { - $blink = md5($this->field?->handle().json_encode($this->field?->config())); + // Fieldtype instances are shared across fields of the same type, so the + // Blink key must be invalidated whenever $this->field changes. Memoizing + // against spl_object_id avoids re-serializing config on repeated calls + // for the same Field object (fields()/preload()/augment() hot path). + $fieldId = $this->field ? spl_object_id($this->field) : null; + + if ($this->flattenedSetsConfigFieldId !== $fieldId) { + $this->flattenedSetsConfigFieldId = $fieldId; + $this->flattenedSetsConfigBlinkKey = md5( + $this->field?->handle().json_encode($this->field?->config()) + ); + } - return Blink::once($blink, function () { + return Blink::once($this->flattenedSetsConfigBlinkKey, function () { $sets = collect($this->config('sets')); // If the first set doesn't have a nested "set" key, it would be the legacy format. diff --git a/tests/Fieldtypes/BardTest.php b/tests/Fieldtypes/BardTest.php index 100eb745c25..d1e0e1d6cbd 100644 --- a/tests/Fieldtypes/BardTest.php +++ b/tests/Fieldtypes/BardTest.php @@ -1577,6 +1577,95 @@ private function bard($config = []) return (new Bard)->setField(new Field('test', array_merge(['type' => 'bard', 'sets' => ['one' => []]], $config))); } + #[Test] + public function it_preloads_collapsed_when_collapse_is_enabled() + { + $this->partialMock(RowId::class, function (MockInterface $mock) { + $mock->shouldReceive('generate')->andReturn('set-1', 'set-2'); + }); + + $field = (new Field('test', [ + 'type' => 'bard', + 'collapse' => true, + 'sets' => [ + 'main' => [ + 'fields' => [ + ['handle' => 'words', 'field' => ['type' => 'text']], + ], + ], + ], + ]))->setValue([ + [ + 'type' => 'set', + 'attrs' => ['values' => ['type' => 'main', 'words' => 'one']], + ], + [ + 'type' => 'set', + 'attrs' => ['values' => ['type' => 'main', 'words' => 'two']], + ], + ])->preProcess(); + + $this->assertSame(['set-1', 'set-2'], $field->fieldtype()->preload()['collapsed']); + } + + #[Test] + public function it_preloads_collapsed_empty_when_collapse_is_off_and_under_threshold() + { + $this->partialMock(RowId::class, function (MockInterface $mock) { + $mock->shouldReceive('generate')->andReturn('set-1', 'set-2'); + }); + + $field = (new Field('test', [ + 'type' => 'bard', + 'sets' => [ + 'main' => [ + 'fields' => [ + ['handle' => 'words', 'field' => ['type' => 'text']], + ], + ], + ], + ]))->setValue([ + [ + 'type' => 'set', + 'attrs' => ['values' => ['type' => 'main', 'words' => 'one']], + ], + [ + 'type' => 'set', + 'attrs' => ['values' => ['type' => 'main', 'words' => 'two']], + ], + ])->preProcess(); + + $this->assertSame([], $field->fieldtype()->preload()['collapsed']); + } + + #[Test] + public function it_auto_collapses_on_preload_when_set_count_meets_threshold() + { + $ids = collect(range(1, 10))->map(fn ($i) => "set-{$i}")->all(); + + $this->partialMock(RowId::class, function (MockInterface $mock) use ($ids) { + $mock->shouldReceive('generate')->andReturn(...$ids); + }); + + $field = (new Field('test', [ + 'type' => 'bard', + 'sets' => [ + 'main' => [ + 'fields' => [ + ['handle' => 'words', 'field' => ['type' => 'text']], + ], + ], + ], + ]))->setValue( + collect($ids)->map(fn () => [ + 'type' => 'set', + 'attrs' => ['values' => ['type' => 'main', 'words' => 'x']], + ])->all() + )->preProcess(); + + $this->assertSame($ids, $field->fieldtype()->preload()['collapsed']); + } + public static function groupedSetsProvider() { return [ diff --git a/tests/Fieldtypes/ReplicatorTest.php b/tests/Fieldtypes/ReplicatorTest.php index ea15c10c54c..98a260810a2 100644 --- a/tests/Fieldtypes/ReplicatorTest.php +++ b/tests/Fieldtypes/ReplicatorTest.php @@ -1232,6 +1232,80 @@ public function it_has_button_label_config() $this->assertSame('Add Set', $configFields['button_label']['placeholder']); } + #[Test] + public function it_preloads_collapsed_when_collapse_is_enabled() + { + $this->partialMock(RowId::class, function (MockInterface $mock) { + $mock->shouldReceive('generate')->andReturn('set-1', 'set-2'); + }); + + $field = (new Field('test', [ + 'type' => 'replicator', + 'collapse' => true, + 'sets' => [ + 'main' => [ + 'fields' => [ + ['handle' => 'words', 'field' => ['type' => 'text']], + ], + ], + ], + ]))->setValue([ + ['type' => 'main', 'words' => 'one'], + ['type' => 'main', 'words' => 'two'], + ])->preProcess(); + + $this->assertSame(['set-1', 'set-2'], $field->fieldtype()->preload()['collapsed']); + } + + #[Test] + public function it_preloads_collapsed_empty_when_collapse_is_off_and_under_threshold() + { + $this->partialMock(RowId::class, function (MockInterface $mock) { + $mock->shouldReceive('generate')->andReturn('set-1', 'set-2'); + }); + + $field = (new Field('test', [ + 'type' => 'replicator', + 'sets' => [ + 'main' => [ + 'fields' => [ + ['handle' => 'words', 'field' => ['type' => 'text']], + ], + ], + ], + ]))->setValue([ + ['type' => 'main', 'words' => 'one'], + ['type' => 'main', 'words' => 'two'], + ])->preProcess(); + + $this->assertSame([], $field->fieldtype()->preload()['collapsed']); + } + + #[Test] + public function it_auto_collapses_on_preload_when_set_count_meets_threshold() + { + $ids = collect(range(1, 10))->map(fn ($i) => "set-{$i}")->all(); + + $this->partialMock(RowId::class, function (MockInterface $mock) use ($ids) { + $mock->shouldReceive('generate')->andReturn(...$ids); + }); + + $field = (new Field('test', [ + 'type' => 'replicator', + 'sets' => [ + 'main' => [ + 'fields' => [ + ['handle' => 'words', 'field' => ['type' => 'text']], + ], + ], + ], + ]))->setValue( + collect($ids)->map(fn () => ['type' => 'main', 'words' => 'x'])->all() + )->preProcess(); + + $this->assertSame($ids, $field->fieldtype()->preload()['collapsed']); + } + public static function groupedSetsProvider() { return [ From be478ce3dc89b4d52e03fe518ab921619753db1a Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 11 Aug 2026 10:07:51 -0400 Subject: [PATCH 13/31] Compute collapsed-set preview text from raw values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3.1 — Port #14512’s value-based preview pipeline: buildPreviewText, formatPreviewValue (per-type formatters), extractBardText, and the use-preview-text composable. ManagesPreviewText now uses the shared builder so headers don’t require mounted fieldtypes to emit previews. Estimated gain: Near zero alone — enabler for deferred set mounting (P3.2), plus fixes replaceAll TypeError / double-escape / disabled preview bugs. Pros: Unblocks v-if deferred bodies; instant headers on load; dedupes Bard JSON walking. Cons: Formatters can drift from live fieldtype display; addon types need generic fallback; subtle differences vs mounted previews. Refs: #13385; #14512. Co-authored-by: Cursor --- .../replicator/ManagesPreviewText.js | 38 ++- resources/js/composables/use-preview-text.js | 40 +++ resources/js/util/buildPreviewText.js | 73 ++++++ resources/js/util/extractBardText.js | 23 ++ resources/js/util/formatPreviewValue.js | 247 ++++++++++++++++++ 5 files changed, 398 insertions(+), 23 deletions(-) create mode 100644 resources/js/composables/use-preview-text.js create mode 100644 resources/js/util/buildPreviewText.js create mode 100644 resources/js/util/extractBardText.js create mode 100644 resources/js/util/formatPreviewValue.js diff --git a/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js b/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js index 5fb329c7a96..8df5b81506b 100644 --- a/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js +++ b/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js @@ -1,33 +1,25 @@ -import PreviewHtml from './PreviewHtml'; +import { buildPreviewText } from '@/util/buildPreviewText'; +import formatPreviewValueUtil from '@/util/formatPreviewValue'; import { perf } from '@api'; export default { computed: { previewText() { return perf.measure('replicator.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 escapeHtml(JSON.stringify(value)); - }) - .join(' / '); + return buildPreviewText({ + previews: this.previews, + config: this.config, + values: this.values, + showFieldPreviews: this.showFieldPreviews, + separator: ' / ', + }); }); }, }, + + methods: { + formatPreviewValue(value, fieldConfig) { + return formatPreviewValueUtil(value, fieldConfig, { escape: false }); + }, + }, }; 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/util/buildPreviewText.js b/resources/js/util/buildPreviewText.js new file mode 100644 index 00000000000..adf513591b5 --- /dev/null +++ b/resources/js/util/buildPreviewText.js @@ -0,0 +1,73 @@ +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 fields = Array.isArray(config.fields) ? config.fields : Object.values(config.fields || {}); + const fieldConfig = 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 = Array.isArray(config.fields) ? config.fields : Object.values(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/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; +} From 3ac919fbac980b74b83dd75654eb59991b856a0e Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 11 Aug 2026 10:07:51 -0400 Subject: [PATCH 14/31] Defer collapsed set bodies and lazy-init Bard TipTap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3.2 — Collapsed Bard/Replicator sets use v-if + hasBeenExpanded / fieldsReady instead of mounting every field under v-show. Expand (and expand-all) mounts via createMountScheduler (idle callback, ~8ms budget). Headless ShowField keeps omitValue bookkeeping for never-mounted sets; sets with errors auto-expand. P3.3 — Bard shells mount without TipTap; ensureEditor() runs on visibility (IntersectionObserver), focus, or fullscreen. Toolbar and computed paths guard null editor; skip eager getHTML() on init. Estimated gain: Headline win from #14512 measurements — ~1min → ~5s collapsed load on heavy sites; expand-all freeze → progressive ~300ms. Lazy TipTap alone: ~20-100ms × N hidden Bards (~1-2s+) off load, plus memory; still helps tabbed/hidden Bards after 3.2. Pros: Biggest #13385 lever; unmounted fields don’t fetch; complementary (3.3 covers cases 3.2 doesn’t). Cons: Highest risk — conditions/save payload need the headless path; validation must surface in collapsed sets; addon mount-time side effects differ; async mount needs unmount guards; toolbar/focus must tolerate late editor init. Refs: #13385; #14512. Co-authored-by: Cursor --- .../fieldtypes/bard/BardFieldtype.vue | 172 +++++++---- .../js/components/fieldtypes/bard/Set.vue | 86 +++++- .../fieldtypes/replicator/Replicator.vue | 2 + .../components/fieldtypes/replicator/Set.vue | 130 +++++--- .../js/tests/createMountScheduler.test.js | 282 ++++++++++++++++++ resources/js/util/createMountScheduler.js | 49 +++ 6 files changed, 607 insertions(+), 114 deletions(-) create mode 100644 resources/js/tests/createMountScheduler.test.js create mode 100644 resources/js/util/createMountScheduler.js diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index c7e9dd72648..3eccf77fefd 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -58,6 +58,8 @@ 'focus-within:focus-outline': !fullScreenMode, }" tabindex="0" + @focusin="ensureEditor" + @pointerdown="ensureEditor" > set.handle === handle); - text += ` [${__(set ? set.display : handle)}]`; - } - if (text.length > 150) { - break; - } - if (node.content) { - stack.unshift(...node.content); - } - } - return text; + return extractBardText(this.value, 150, this.setConfigs); }, inputIsInline() { @@ -377,6 +366,7 @@ export default { }, shouldShowAddSetHelperText() { + if (!this.editor) return false; return !this.$refs.setPicker?.isOpen && this.suitableToShowSetButton(this.editor); }, }, @@ -391,30 +381,10 @@ export default { } }, - async mounted() { - perf.start('bard.mount'); - perf.notifyMountActivity(); - - try { - tiptap = await importTiptap(); - - this.initToolbarButtons(); - this.initEditor(); - - this.json = this.editor.getJSON().content; - this.html = this.editor.getHTML(); - - this.$nextTick(() => { - this.mounted = true; - perf.stop('bard.mount'); - perf.notifyMountActivity(); - }); - } catch (error) { - perf.stop('bard.mount'); - perf.notifyMountActivity(); - throw error; - } - + mounted() { + // Preview text and value watchers work without TipTap. Defer the expensive + // Editor construction until this field is (nearly) visible or focused. + this.initToolbarButtons(); this.pageHeader = document.querySelector('.global-header'); if (!commandPaletteCallbackRegistered) { @@ -429,16 +399,19 @@ export default { } this.$nextTick(() => { + this.setupLazyEditor(); + let el = document.querySelector(`label[for="${this.fieldId}"]`); if (el) { el.addEventListener('click', () => { - this.editor.commands.focus(); + this.ensureEditor().then(() => this.editor?.commands.focus()); }); } }); }, beforeUnmount() { + this._intersectionObserver?.disconnect(); this.editor?.destroy(); this.escBinding?.destroy(); }, @@ -483,7 +456,7 @@ export default { }, readOnly(readOnly) { - this.editor.setEditable(!this.readOnly); + this.editor?.setEditable(!this.readOnly); }, collapsed(value) { @@ -493,19 +466,22 @@ export default { }, fullScreenMode(fullScreenMode) { - this.initEditor(); - - if (fullScreenMode) { - this.escBinding = this.$keys.bindGlobal('esc', this.closeFullscreen); - // Focus the editor content when entering fullscreen mode - this.$nextTick(() => { - if (this.editor) { - this.editor.commands.focus(); - } - }); - } else { - this.escBinding?.destroy(); - } + // Portal remount needs a fresh TipTap instance bound to the new DOM. + // ensureEditor() covers the lazy-init case; initEditor() recreates when + // an editor already existed outside the portal. + const hadEditor = !!this.editor; + this.ensureEditor().then(() => { + if (hadEditor) this.initEditor(); + + if (fullScreenMode) { + this.escBinding = this.$keys.bindGlobal('esc', this.closeFullscreen); + this.$nextTick(() => { + this.editor?.commands.focus(); + }); + } else { + this.escBinding?.destroy(); + } + }); }, loadingSet(loading) { @@ -535,6 +511,67 @@ export default { }, methods: { + setupLazyEditor() { + const el = this.$refs.container; + if (!el || typeof IntersectionObserver === 'undefined') { + this.ensureEditor(); + return; + } + + // Already in view (e.g. first expanded set) — init immediately. + const rect = el.getBoundingClientRect(); + const inView = rect.top < window.innerHeight + 100 && rect.bottom > -100; + if (inView) { + this.ensureEditor(); + return; + } + + this._intersectionObserver = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + this._intersectionObserver?.disconnect(); + this._intersectionObserver = null; + this.ensureEditor(); + } + }, + { rootMargin: '100px' }, + ); + this._intersectionObserver.observe(el); + }, + + async ensureEditor() { + if (this.editor) return this.editor; + if (this._editorInitPromise) return this._editorInitPromise; + + this._editorInitPromise = (async () => { + perf.start('bard.mount'); + perf.notifyMountActivity(); + + try { + tiptap = await importTiptap(); + this.initEditor(); + // Seed json from the editor once. Skip getHTML() here — it's a full-doc + // serialize and only needed when reading-time/footer config is enabled + // (computed lazily via onUpdate / readingTime). + this.json = this.editor.getJSON().content; + this.mounted = true; + return this.editor; + } catch (error) { + this.initError = error.message || String(error); + throw error; + } finally { + perf.stop('bard.mount'); + perf.notifyMountActivity(); + } + })(); + + try { + return await this._editorInitPromise; + } finally { + this._editorInitPromise = null; + } + }, + addSet(handle) { this.loadingSet = handle; @@ -847,6 +884,8 @@ export default { }, buttonIsActive(button) { + // Toolbar can render before lazy TipTap init finishes. + if (!this.editor) return false; if (button.hasOwnProperty('active')) { return button.active(this.editor, button.args); } @@ -856,6 +895,7 @@ export default { }, buttonIsVisible(button) { + if (!this.editor) return !button.hasOwnProperty('visibleWhenActive'); if (button.hasOwnProperty('visible')) { return button.visible(this.editor, button.args); } @@ -974,7 +1014,7 @@ export default { }, valueToContent(value) { - return value.length ? { type: 'doc', content: value } : null; + return value?.length ? { type: 'doc', content: value } : null; }, getExtensions() { diff --git a/resources/js/components/fieldtypes/bard/Set.vue b/resources/js/components/fieldtypes/bard/Set.vue index 78b9495181c..e23d6de6ce5 100644 --- a/resources/js/components/fieldtypes/bard/Set.vue +++ b/resources/js/components/fieldtypes/bard/Set.vue @@ -80,20 +80,22 @@
- - - +
@@ -117,9 +119,11 @@ import { PublishFields as Fields } from '@ui'; import { containerContextKey } from '@/components/ui/Publish/Container.vue'; -import { watch } from 'vue'; +import { watch, inject } from 'vue'; import { reveal, perf } from '@api'; import { useUiDirection } from '@/composables/ui-direction'; +import { createMountScheduler } from '@/util/createMountScheduler.js'; +import ShowField from '@/components/field-conditions/ShowField.js'; export default { props: nodeViewProps, @@ -127,6 +131,17 @@ export default { setup() { return { uiDirection: useUiDirection().direction, + mountScheduler: inject('mountScheduler', createMountScheduler()), + }; + }, + + data() { + const collapsedIds = this.extension.options.bard.collapsed || []; + const initiallyCollapsed = collapsedIds.includes(this.node.attrs.id); + + return { + hasBeenExpanded: !initiallyCollapsed, + fieldsReady: !initiallyCollapsed, }; }, @@ -387,6 +402,54 @@ export default { { deep: true } ); + watch( + () => this.collapsed, + (collapsed) => { + if (!collapsed && !this.hasBeenExpanded) { + this.hasBeenExpanded = true; + this.mountScheduler.schedule(() => { + if (!this._setUnmounted) { + this.fieldsReady = true; + } + }); + } else if (!collapsed) { + this.fieldsReady = true; + } + }, + ); + + // Headlessly evaluate conditions for never-mounted sets so omitValue + // bookkeeping stays correct for the save payload. + watch( + [() => this.values, () => this.fieldsReady], + () => { + if (this.fieldsReady || !this.hasFields) return; + + const fields = Array.isArray(this.fields) + ? this.fields + : Object.values(this.fields || {}); + + const showField = new ShowField( + this.values || {}, + {}, + this.publishContainer.visibleValues.value, + this.publishContainer.revealerValues.value, + this.publishContainer.hiddenFields.value, + this.publishContainer.setHiddenField, + { container: this.publishContainer.container }, + ); + + fields.forEach((field) => { + showField.showField(field, `${this.fieldPathPrefix}.${field.handle}`); + }); + }, + { deep: true, immediate: true }, + ); + + if (this.hasError && this.collapsed) { + this.expand(); + } + reveal.mount(this.$refs.container, this.expand); // Firefox bug 739071: text selection doesn't work inside elements with a @@ -406,6 +469,7 @@ export default { }, beforeUnmount() { + this._setUnmounted = true; this._draggableObserver?.disconnect(); }, }; diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue index 6db57428bc3..0d1f98d4ee9 100644 --- a/resources/js/components/fieldtypes/replicator/Replicator.vue +++ b/resources/js/components/fieldtypes/replicator/Replicator.vue @@ -96,6 +96,7 @@ import ManagesSetMeta from './ManagesSetMeta'; import { SortableList } from '../../sortable/Sortable'; import { data_get } from "@/bootstrap/globals.js"; import { perf } from '@api'; +import { createMountScheduler } from '@/util/createMountScheduler.js'; export default { mixins: [Fieldtype, ManagesSetMeta], @@ -115,6 +116,7 @@ export default { provide: { replicatorSets: this.config.sets, showReplicatorFieldPreviews: this.config.previews, + mountScheduler: createMountScheduler(), }, errorsById: {}, setsCache: {}, diff --git a/resources/js/components/fieldtypes/replicator/Set.vue b/resources/js/components/fieldtypes/replicator/Set.vue index 3b7ef4c3a72..79b255754ed 100644 --- a/resources/js/components/fieldtypes/replicator/Set.vue +++ b/resources/js/components/fieldtypes/replicator/Set.vue @@ -1,5 +1,5 @@ @@ -203,20 +256,23 @@ reveal.use(rootEl, () => emit('expanded'));
- - - +
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/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 }; +} From 148ce1c709b646afd36e861ace97f7ff07ea9912 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 11 Aug 2026 10:07:51 -0400 Subject: [PATCH 15/31] Watch only condition-referenced handles in Publish Field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3.4 — Replace shouldShowField’s full visibleValues dependency with a watcher over handles extracted from the field’s conditions, falling back to full-tree watching for custom/string conditions. Estimated gain: After visibleValues hot path (P1.2), cuts N condition evals per keystroke to ~1-5 — another ~10-30% off typing scripting on condition-heavy docs; little effect on initial load. Pros: Fixes fan-out at the root; complements cheap visibleValues. Cons: Correctness hinges on complete handle extraction ($root/$parent, nested paths, always_save). Missed deps = stale visibility / wrong omitValue. Custom JS conditions must hit the full-tree fallback. Refs: #13385; #14512. Co-authored-by: Cursor --- resources/js/components/ui/Publish/Field.vue | 64 +++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/resources/js/components/ui/Publish/Field.vue b/resources/js/components/ui/Publish/Field.vue index aa918a8d86b..fd6f81b8446 100644 --- a/resources/js/components/ui/Publish/Field.vue +++ b/resources/js/components/ui/Publish/Field.vue @@ -10,6 +10,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: { @@ -144,7 +145,26 @@ 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; + // Blueprint conditions are `{ field: 'operator value' }` objects. + return Object.keys(conditions); +}); + +const hasConditions = computed(() => { + if (props.config.visibility === 'hidden') return false; + return KEYS.some((k) => props.config[k]); +}); + +const isCustomCondition = computed(() => { + const conditionKey = KEYS.find((k) => props.config[k]); + return conditionKey ? typeof props.config[conditionKey] === 'string' : false; +}); + +function evaluateShowField() { return new ShowField( values.value, extraValues.value, @@ -154,7 +174,47 @@ const shouldShowField = computed(() => { setHiddenField, { container }, ).showField(props.config, fullPath.value); -}); +} + +// Targeted watching: only re-evaluate when referenced condition handles change, +// instead of depending on the entire values tree via a computed. +const shouldShowField = ref(props.config.visibility !== 'hidden'); + +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(); + }); + + // Revealers / $parent paths / nested values may not be listed as handles — + // also re-evaluate when revealer values change. + watch(revealerValues, () => { + shouldShowField.value = evaluateShowField(); + }, { deep: true }); +} else if (props.config.visibility === 'hidden') { + shouldShowField.value = evaluateShowField(); +} // Hidden fieldtypes are mounted like any other field so they take part in field // conditions, but they only become visible on a form submission. From a95eb9b1998b9ece2fc029b06b0190efa5634b44 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 11 Aug 2026 12:17:54 -0400 Subject: [PATCH 16/31] Fix CI failures from perf branch side effects - Replicator flattenedSetsConfig memo never initialized its Blink key when $this->field was null (null === initial fieldId), which broke MarkTest and any fieldtype use before a Field was assigned. - Return $this from Bard containerRequiredRule::setData and drop the now-unnecessary phpstan baseline ignore (line numbers had also drifted). - Keep the vitest bench project out of `npm test` via include: [] so browser mode stops re-running the whole unit suite. - Normalize commonmark permalink tabindex across versions in MarkdownTest. Co-authored-by: Cursor --- .phpstan/baseline.neon | 6 ------ src/Fieldtypes/Bard.php | 2 ++ src/Fieldtypes/Replicator.php | 6 ++++-- tests/Markdown/MarkdownTest.php | 7 ++++--- vite.config.js | 3 +++ 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.phpstan/baseline.neon b/.phpstan/baseline.neon index 5e43a294010..865f5ca2805 100644 --- a/.phpstan/baseline.neon +++ b/.phpstan/baseline.neon @@ -66,12 +66,6 @@ parameters: count: 1 path: ../src/Facades/Endpoint/Parse.php - - - message: '#^Method Illuminate\\Contracts\\Validation\\DataAwareRule@anonymous/Fieldtypes/Bard\.php\:934\:\:setData\(\) should return \$this\(Illuminate\\Contracts\\Validation\\DataAwareRule@anonymous/Fieldtypes/Bard\.php\:934\) but return statement is missing\.$#' - identifier: return.missing - count: 1 - path: ../src/Fieldtypes/Bard.php - - message: '#^Access to an undefined property Statamic\\Filesystem\\AbstractAdapter\:\:\$filesystem\.$#' identifier: property.notFound diff --git a/src/Fieldtypes/Bard.php b/src/Fieldtypes/Bard.php index 58452220af4..ec0ba078fab 100644 --- a/src/Fieldtypes/Bard.php +++ b/src/Fieldtypes/Bard.php @@ -950,6 +950,8 @@ private function containerRequiredRule(): ValidationRule public function setData(array $data) { $this->data = $data; + + return $this; } public function validate(string $attribute, mixed $value, Closure $fail): void diff --git a/src/Fieldtypes/Replicator.php b/src/Fieldtypes/Replicator.php index 4e23a8659ec..cab5849ec46 100644 --- a/src/Fieldtypes/Replicator.php +++ b/src/Fieldtypes/Replicator.php @@ -308,10 +308,12 @@ public function flattenedSetsConfig() // for the same Field object (fields()/preload()/augment() hot path). $fieldId = $this->field ? spl_object_id($this->field) : null; - if ($this->flattenedSetsConfigFieldId !== $fieldId) { + // When $this->field is null, $fieldId is null — same as the property's + // initial value — so also regenerate when the Blink key was never set. + if ($this->flattenedSetsConfigBlinkKey === null || $this->flattenedSetsConfigFieldId !== $fieldId) { $this->flattenedSetsConfigFieldId = $fieldId; $this->flattenedSetsConfigBlinkKey = md5( - $this->field?->handle().json_encode($this->field?->config()) + ($this->field?->handle() ?? '').json_encode($this->field?->config()) ); } diff --git a/tests/Markdown/MarkdownTest.php b/tests/Markdown/MarkdownTest.php index b7304d26b95..44511ce5a9f 100644 --- a/tests/Markdown/MarkdownTest.php +++ b/tests/Markdown/MarkdownTest.php @@ -249,11 +249,12 @@ public function it_uses_heading_permalinks_on_demand()

Charlie Delta

EOT, $markdown); + // Newer commonmark versions may add tabindex="-1" on permalink anchors. $this->assertEquals(<<<'EOT'

Alfa Bravo

Charlie Delta

EOT, - rtrim(Markdown::withHeadingPermalinks()->parse($markdown)) + str(Markdown::withHeadingPermalinks()->parse($markdown))->replace(' tabindex="-1"', '')->trim()->toString() ); } @@ -289,10 +290,10 @@ public function it_uses_table_of_contents_on_demand()

Baz qux.

EOT; - // Make assertion without newlines because they differ between versions of commonmark. + // Normalize newlines / tabindex — both differ across commonmark versions. $this->assertEquals( str($expected)->replace("\n", ''), - str(Markdown::withTableOfContents()->parse($markdown))->trim()->replace("\n", '') + str(Markdown::withTableOfContents()->parse($markdown))->trim()->replace(' tabindex="-1"', '')->replace("\n", '') ); } } diff --git a/vite.config.js b/vite.config.js index b8ea4a266bc..a6a08b5c526 100644 --- a/vite.config.js +++ b/vite.config.js @@ -71,6 +71,9 @@ export default defineConfig(({ mode, command }) => { extends: true, test: { name: 'bench', + // Benchmarks only — keep out of `vitest run` / `npm test`. + // Invoked via `vitest bench --project bench`. + include: [], browser: { enabled: true, headless: true, From 3c9e3de758cb2469efd7862d20664a8d83800b01 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Wed, 12 Aug 2026 09:14:02 -0400 Subject: [PATCH 17/31] Prevent Live Preview refresh on progressive Bard mount Lazy TipTap seed was syncing normalized JSON into values before mounted flipped, which deep-watched Live Preview into a refresh on scroll/expand. Defer mounted and skip identical watch payloads. Co-authored-by: Cursor --- .../fieldtypes/bard/BardFieldtype.vue | 4 +++ .../components/ui/LivePreview/LivePreview.vue | 30 +++++++++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index 3eccf77fefd..98ce5de407e 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -553,7 +553,11 @@ export default { // Seed json from the editor once. Skip getHTML() here — it's a full-doc // serialize and only needed when reading-time/footer config is enabled // (computed lazily via onUpdate / readingTime). + // Defer `mounted` so the json watcher skips this seed — TipTap often + // normalizes content slightly, and pushing that into values was + // refreshing Live Preview on scroll/expand (lazy init). this.json = this.editor.getJSON().content; + await this.$nextTick(); this.mounted = true; return this.editor; } catch (error) { diff --git a/resources/js/components/ui/LivePreview/LivePreview.vue b/resources/js/components/ui/LivePreview/LivePreview.vue index c253ddd179c..ae7d27fe816 100644 --- a/resources/js/components/ui/LivePreview/LivePreview.vue +++ b/resources/js/components/ui/LivePreview/LivePreview.vue @@ -99,35 +99,47 @@ const payload = computed(() => ({ extras: extras.value, })); +// Progressive mounting / TipTap seed can deep-watch-churn `values` without a real +// content change. Skip identical payloads from the watch so the iframe doesn't +// refresh on scroll/expand. Explicit update() callers (open / popout / refresh) +// still always POST. +let lastPostedPayloadKey = null; + watch( [payload, target], - (payload) => { + () => { perf.measure('livePreview.watch', () => { - if (props.enabled) { - perf.count('livePreview.update'); - update(); - } + if (!props.enabled) return; + + const key = JSON.stringify([payload.value, target.value]); + if (key === lastPostedPayloadKey) return; + + perf.count('livePreview.update'); + update(); }); }, { deep: true }, ); const update = debounce(() => { + const body = payload.value; + lastPostedPayloadKey = JSON.stringify([body, target.value]); + if (source) source.abort(); source = new AbortController(); loading.value = true; axios - .post(tokenizedUrl.value, payload.value, { signal: source.signal }) + .post(tokenizedUrl.value, body, { signal: source.signal }) .then((response) => { token.value = response.data.token; const url = response.data.url; const tgt = toRaw(props.targets[target.value]); - const payload = { token: token.value, reference: props.reference }; + const messagePayload = { token: token.value, reference: props.reference }; poppedOut.value - ? channel.value.postMessage({ event: 'updated', url, target: tgt, payload }) - : updateIframeContents(url, tgt, payload, setIframeAttributes); + ? channel.value.postMessage({ event: 'updated', url, target: tgt, payload: messagePayload }) + : updateIframeContents(url, tgt, messagePayload, setIframeAttributes); loading.value = false; }) .catch((e) => { From 79dfbc63cadc1af4262755c5afac1b38ff52381a Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Wed, 12 Aug 2026 09:14:02 -0400 Subject: [PATCH 18/31] Guard escapeHtml against non-string values Preview formatters can pass numbers/objects; early-return instead of calling replaceAll on them. Co-authored-by: Cursor --- resources/js/bootstrap/globals.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/js/bootstrap/globals.js b/resources/js/bootstrap/globals.js index da068ea8924..fefbdb7b0ff 100644 --- a/resources/js/bootstrap/globals.js +++ b/resources/js/bootstrap/globals.js @@ -125,6 +125,8 @@ export function truncate(string, length, ending = '...') { } export function escapeHtml(string) { + if (typeof string !== 'string') return string; + return string .replaceAll('&', '&') .replaceAll('<', '<') From 6bc1c0c283b41504863925db0c4ba39b681f26da Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Wed, 12 Aug 2026 09:14:02 -0400 Subject: [PATCH 19/31] Harden collapsed preview text against non-string HTML Filter/coerce PreviewHtml and formatter output before trim so widget/set previews don't throw. Co-authored-by: Cursor --- resources/js/util/buildPreviewText.js | 8 +++++--- resources/js/util/formatPreviewValue.js | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/resources/js/util/buildPreviewText.js b/resources/js/util/buildPreviewText.js index adf513591b5..5b17189abbb 100644 --- a/resources/js/util/buildPreviewText.js +++ b/resources/js/util/buildPreviewText.js @@ -44,12 +44,14 @@ export function buildPreviewText({ return true; }) .map((value) => { - if (value instanceof PreviewHtml) return value.html; + if (value instanceof PreviewHtml) { + return typeof value.html === 'string' ? 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() !== ''); + .filter((html) => typeof html === 'string' && html.trim() !== ''); } else { // Fallback: extract values directly from values const fields = Array.isArray(config.fields) ? config.fields : Object.values(config.fields || {}); @@ -64,7 +66,7 @@ export function buildPreviewText({ return true; }) .map((field) => formatPreviewValue(values?.[field.handle], field, { escape: true })) - .filter((value) => value && value.trim() !== ''); + .filter((value) => typeof value === 'string' && value.trim() !== ''); } return previewValues.join(separator); diff --git a/resources/js/util/formatPreviewValue.js b/resources/js/util/formatPreviewValue.js index ee23331f3a8..d56e6f3e70d 100644 --- a/resources/js/util/formatPreviewValue.js +++ b/resources/js/util/formatPreviewValue.js @@ -92,7 +92,7 @@ export default function formatPreviewValue(value, fieldConfig, options = {}) { // Handle PreviewHtml instances if (value instanceof PreviewHtml) { - return value.html; + return typeof value.html === 'string' ? value.html : null; } const type = fieldConfig?.type; From 55842aa09c0396f64a28b4b00eb20f51961ac931 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Wed, 12 Aug 2026 09:14:02 -0400 Subject: [PATCH 20/31] Ignore Reveal.mount when the element ref is null Progressive set mounting can race the template ref; skip registration until el exists. Co-authored-by: Cursor --- resources/js/components/Reveal.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/js/components/Reveal.js b/resources/js/components/Reveal.js index 83a903246bf..bf25d6255d3 100644 --- a/resources/js/components/Reveal.js +++ b/resources/js/components/Reveal.js @@ -8,6 +8,9 @@ class Reveal { } mount(el, callback) { + // Progressive set mounting can call this before the template ref exists. + if (!el) return; + registry.set(el, callback); onBeforeUnmount(() => registry.delete(el)); From 638f658393fdcaf8e9f3a351a3332a7e5d8a84c8 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Wed, 12 Aug 2026 09:14:02 -0400 Subject: [PATCH 21/31] Make $perf.copy resilient when clipboard write is denied Console invocations often hit NotAllowedError; fall back to dumping the export and escape markdown table cells. Co-authored-by: Cursor --- resources/js/tests/util/perf.test.js | 20 ++++++++++ resources/js/util/perf.js | 60 ++++++++++++++++++---------- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/resources/js/tests/util/perf.test.js b/resources/js/tests/util/perf.test.js index 69c0bf27b09..66b02041116 100644 --- a/resources/js/tests/util/perf.test.js +++ b/resources/js/tests/util/perf.test.js @@ -204,6 +204,26 @@ test('copy writes export text to the clipboard', async () => { vi.unstubAllGlobals(); }); +test('copy falls back to console when clipboard write is denied', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('NotAllowedError')); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + perf.enable(); + perf.count('demo.copy.fallback'); + + const text = await perf.copy('md'); + expect(writeText).toHaveBeenCalledOnce(); + expect(text).toContain('`interact.demo.copy.fallback`'); + expect(warn).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(text); + + log.mockRestore(); + warn.mockRestore(); + vi.unstubAllGlobals(); +}); + test('formatDuration scales ms to s (and m) for readability', () => { expect(perf.formatDuration(0)).toBe('0ms'); expect(perf.formatDuration(16)).toBe('16ms'); diff --git a/resources/js/util/perf.js b/resources/js/util/perf.js index c9bc4d23488..882a26364d4 100644 --- a/resources/js/util/perf.js +++ b/resources/js/util/perf.js @@ -633,14 +633,23 @@ function toCsv(input) { return lines.join('\n'); } +function escapeMarkdownCell(value) { + return String(value ?? '') + .replace(/\|/g, '\\|') + .replace(/`/g, "'"); +} + function toMarkdown(input) { const rows = Array.isArray(input) ? input : (input?.rows ?? reportJson()); const header = '| phase | name | heat | count | total (ms) | mean (ms) | p95 (ms) | max (ms) |'; const sep = '| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |'; - const body = rows.map( - (row) => - `| ${row.phase} | \`${row.name}\` | ${row.heat} | ${row.count} | ${row.total} | ${row.mean} | ${row.p95} | ${row.max} |`, - ); + const body = rows.map((row) => { + const name = escapeMarkdownCell(row.name); + const phase = escapeMarkdownCell(row.phase); + const heat = escapeMarkdownCell(row.heat); + + return `| ${phase} | \`${name}\` | ${heat} | ${row.count ?? 0} | ${row.total ?? 0} | ${row.mean ?? 0} | ${row.p95 ?? 0} | ${row.max ?? 0} |`; + }); return [header, sep, ...body].join('\n'); } @@ -672,15 +681,21 @@ async function copy(format = 'tsv') { const text = serialize(format); const rows = reportJson().length; - if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - console.log(`%cCopied%c $perf ${format} (${rows} rows) to clipboard`, 'font-weight:700;color:#22c55e', ''); - } else { - // Fallback: dump a selectable string when Clipboard API is unavailable. - console.log(`%cClipboard unavailable%c — copy the string below:`, 'font-weight:700;color:#f97316', ''); - console.log(text); + // Clipboard API often rejects when invoked from the console without a user + // gesture (NotAllowedError) — fall back to dumping the string. + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + console.log(`%cCopied%c $perf ${format} (${rows} rows) to clipboard`, 'font-weight:700;color:#22c55e', ''); + return text; + } + } catch (error) { + console.warn(`$perf.copy(${JSON.stringify(format)}) clipboard write failed:`, error); } + console.log(`%cClipboard unavailable%c — copy the string below:`, 'font-weight:700;color:#f97316', ''); + console.log(text); + return text; } @@ -843,17 +858,22 @@ async function copyDiff(baseline, current) { ]; const text = lines.join('\n'); - if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - console.log( - `%cCopied%c $perf diff TSV (${result.rows.length} rows) to clipboard`, - 'font-weight:700;color:#22c55e', - '', - ); - } else { - console.log(text); + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + console.log( + `%cCopied%c $perf diff TSV (${result.rows.length} rows) to clipboard`, + 'font-weight:700;color:#22c55e', + '', + ); + return text; + } + } catch (error) { + console.warn('$perf.copyDiff() clipboard write failed:', error); } + console.log(text); + return text; } From a7e889bcbe1457a4c65a789e9420546ce9c95d49 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Wed, 12 Aug 2026 09:16:26 -0400 Subject: [PATCH 22/31] Escape backslashes before pipes in $perf markdown cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged incomplete escaping — a leading backslash could neutralize the pipe escape. Co-authored-by: Cursor --- resources/js/tests/util/perf.test.js | 8 ++++++++ resources/js/util/perf.js | 1 + 2 files changed, 9 insertions(+) diff --git a/resources/js/tests/util/perf.test.js b/resources/js/tests/util/perf.test.js index 66b02041116..a288aa8f87c 100644 --- a/resources/js/tests/util/perf.test.js +++ b/resources/js/tests/util/perf.test.js @@ -189,6 +189,14 @@ test('snapshot and markdown/tsv exports are pasteable', () => { expect(tsv).toContain('interact.demo.export'); }); +test('markdown export escapes backslashes before pipes in metric names', () => { + perf.enable(); + perf.count('demo\\pipe|name'); + + const md = perf.toMarkdown(); + expect(md).toContain('`interact.demo\\\\pipe\\|name`'); +}); + test('copy writes export text to the clipboard', async () => { const writeText = vi.fn().mockResolvedValue(undefined); vi.stubGlobal('navigator', { clipboard: { writeText } }); diff --git a/resources/js/util/perf.js b/resources/js/util/perf.js index 882a26364d4..72bde990835 100644 --- a/resources/js/util/perf.js +++ b/resources/js/util/perf.js @@ -635,6 +635,7 @@ function toCsv(input) { function escapeMarkdownCell(value) { return String(value ?? '') + .replace(/\\/g, '\\\\') .replace(/\|/g, '\\|') .replace(/`/g, "'"); } From 43f8a6731c62249f87e8995df641d1861f70e5de Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Thu, 13 Aug 2026 08:20:26 -0400 Subject: [PATCH 23/31] Render lazily-mounted set bodies in a single expand step The bordered wrapper was appearing before fieldsReady, so expanding a collapsed set jumped twice. Gate the body and header corners on fieldsReady, and pre-warm on header hover so the click is usually instant. Co-authored-by: Cursor --- .../js/components/fieldtypes/bard/Set.vue | 36 ++++++++++++------- .../components/fieldtypes/replicator/Set.vue | 36 ++++++++++++------- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/resources/js/components/fieldtypes/bard/Set.vue b/resources/js/components/fieldtypes/bard/Set.vue index e23d6de6ce5..8aa4664d9a9 100644 --- a/resources/js/components/fieldtypes/bard/Set.vue +++ b/resources/js/components/fieldtypes/bard/Set.vue @@ -22,8 +22,9 @@
@@ -80,22 +81,21 @@
- + + +
@@ -336,6 +336,16 @@ export default { } }, + prewarmFields() { + if (this.hasBeenExpanded || !this.hasFields) return; + this.hasBeenExpanded = true; + this.mountScheduler.schedule(() => { + if (!this._setUnmounted) { + this.fieldsReady = true; + } + }); + }, + collapse() { // this.$events.$emit('collapsed', this.node.attrs.id); this.extension.options.bard.collapseSet(this.node.attrs.id); diff --git a/resources/js/components/fieldtypes/replicator/Set.vue b/resources/js/components/fieldtypes/replicator/Set.vue index 79b255754ed..cd4fc672433 100644 --- a/resources/js/components/fieldtypes/replicator/Set.vue +++ b/resources/js/components/fieldtypes/replicator/Set.vue @@ -104,6 +104,16 @@ function toggleCollapsedState() { props.collapsed ? emit('expanded') : emit('collapsed'); } +function prewarmFields() { + if (hasBeenExpanded.value || !hasFields.value) return; + hasBeenExpanded.value = true; + mountScheduler.schedule(() => { + if (!isUnmounted) { + fieldsReady.value = true; + } + }); +} + const deletingSet = ref(false); function destroy() { @@ -196,8 +206,9 @@ reveal.use(rootEl, () => emit('expanded'));
emit('expanded'));
- + + +
From a3d331e38ebc95c2541dae55ae1f856e02e8f661 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Thu, 13 Aug 2026 08:20:26 -0400 Subject: [PATCH 24/31] Collapse replicator set bodies while dragging Hide set bodies during drag (and in the mirror clone) so swaps only relayout header bars. Skip swap animation on lists with more than 20 sets. Co-authored-by: Cursor --- .../css/components/fieldtypes/replicator.css | 6 ++++++ resources/css/core/layout.css | 5 +++++ resources/css/cp.css | 1 + .../fieldtypes/replicator/Replicator.vue | 18 +++++++++++++++--- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/resources/css/components/fieldtypes/replicator.css b/resources/css/components/fieldtypes/replicator.css index cbf0f8dc3bf..b65819e04a6 100644 --- a/resources/css/components/fieldtypes/replicator.css +++ b/resources/css/components/fieldtypes/replicator.css @@ -1,3 +1,9 @@ /* ========================================================================== REPLICATOR FIELDTYPE ========================================================================== */ + +/* Collapse set bodies to header bars while dragging so swaps only relayout + header-height rows instead of full expanded field trees. */ +.replicator-dragging [data-replicator-set] > [data-set-body] { + display: none; +} diff --git a/resources/css/core/layout.css b/resources/css/core/layout.css index f258de42bc9..f9bc73f0507 100644 --- a/resources/css/core/layout.css +++ b/resources/css/core/layout.css @@ -163,3 +163,8 @@ main.nav-closed { body > .draggable-mirror { z-index: var(--z-index-draggable); } + +/* Don't clone expanded set bodies into the drag mirror. */ +body > .draggable-mirror [data-set-body] { + display: none; +} diff --git a/resources/css/cp.css b/resources/css/cp.css index dd8ab0f1172..7d532f95c00 100644 --- a/resources/css/cp.css +++ b/resources/css/cp.css @@ -35,6 +35,7 @@ @import './components/fieldtypes/markdown.css'; @import './components/fieldtypes/partial.css'; @import './components/fieldtypes/relationship.css'; +@import './components/fieldtypes/replicator.css'; @import './components/fieldtypes/section.css'; @import './components/fieldtypes/table.css'; @import './components/fieldtypes/width.css'; diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue index 0d1f98d4ee9..11565170b39 100644 --- a/resources/js/components/fieldtypes/replicator/Replicator.vue +++ b/resources/js/components/fieldtypes/replicator/Replicator.vue @@ -16,17 +16,18 @@ @close="toggleFullscreen" /> -
+
@@ -121,6 +122,7 @@ export default { errorsById: {}, setsCache: {}, loadingSet: null, + dragging: false, }; }, @@ -228,6 +230,16 @@ export default { }); }, + dragStarted() { + this.dragging = true; + this.$emit('focus'); + }, + + dragEnded() { + this.dragging = false; + this.$emit('blur'); + }, + addSet(handle, index) { this.loadingSet = handle; From e35b9f3d5b0d29deeab11659cff9164d9d5fa519 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Thu, 13 Aug 2026 09:25:33 -0400 Subject: [PATCH 25/31] Auto-collapse sets on drag. Better performance, better UX, Papa Johns. --- resources/js/components/fieldtypes/bard/Set.vue | 7 +++++++ .../js/components/fieldtypes/replicator/Replicator.vue | 1 + 2 files changed, 8 insertions(+) diff --git a/resources/js/components/fieldtypes/bard/Set.vue b/resources/js/components/fieldtypes/bard/Set.vue index 8aa4664d9a9..b03e9cfa736 100644 --- a/resources/js/components/fieldtypes/bard/Set.vue +++ b/resources/js/components/fieldtypes/bard/Set.vue @@ -369,11 +369,18 @@ export default { this._draggableObserver?.disconnect(); this.$el.setAttribute('draggable', true); + // dragstart fires on this.$el (the draggable wrapper), not the inner container. + this.$el.addEventListener('dragstart', this.collapseSiblingsForDrag, { once: true }); document.addEventListener('mouseup', this.disableDragging, { once: true }); document.addEventListener('dragend', this.disableDragging, { once: true }); }, + collapseSiblingsForDrag() { + this.extension.options.bard.collapseAll(); + }, + disableDragging() { + this.$el.removeEventListener('dragstart', this.collapseSiblingsForDrag); this.$el.setAttribute('draggable', false); this._draggableObserver?.observe(this.$el, { attributes: true, attributeFilter: ['draggable'] }); }, diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue index 11565170b39..055946df274 100644 --- a/resources/js/components/fieldtypes/replicator/Replicator.vue +++ b/resources/js/components/fieldtypes/replicator/Replicator.vue @@ -232,6 +232,7 @@ export default { dragStarted() { this.dragging = true; + this.collapseAll(); this.$emit('focus'); }, From 3b38b5faee433bacce488d8b4c04565f53d433a4 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Thu, 13 Aug 2026 09:47:10 -0400 Subject: [PATCH 26/31] Better drop state indicator --- resources/css/components/fieldtypes/bard.css | 34 +++++++++++++++++++ .../fieldtypes/bard/BardFieldtype.vue | 6 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/resources/css/components/fieldtypes/bard.css b/resources/css/components/fieldtypes/bard.css index 1477f7ddead..74b302d48a5 100644 --- a/resources/css/components/fieldtypes/bard.css +++ b/resources/css/components/fieldtypes/bard.css @@ -257,6 +257,40 @@ } } } + +/* BARD / DROP + GAP CURSORS +=================================================== */ +/* Unlayered so we beat ProseMirror's injected 1px black defaults. */ +.bard-dropcursor { + border-radius: 999px; + background-color: var(--focus-outline-color, var(--color-blue-400)); +} + +/* Leading disc on block (horizontal) drops — Notion-style slot marker. + Inline (vertical) carets stay a rounded pill so they don't grow a blob. */ +.bard-dropcursor.prosemirror-dropcursor-block::before { + content: ''; + position: absolute; + inset-inline-start: 0; + top: 50%; + width: 6px; + height: 6px; + border-radius: 999px; + background-color: inherit; + translate: -30% -50%; +} + +[dir='rtl'] .bard-dropcursor.prosemirror-dropcursor-block::before { + translate: 30% -50%; +} + +/* Gapcursor: click-between-blocks caret. Same token as dropcursor. */ +.ProseMirror-gapcursor:after { + border-top: 2px solid var(--focus-outline-color, var(--color-blue-400)); + border-radius: 999px; + width: 1.5rem; +} + /* BARD / FULL SCREEN =================================================== */ @layer ui-states { diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index 98ce5de407e..2914875293b 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -1086,7 +1086,11 @@ export default { setConfigs: this.setConfigs, addSet: this.addSet, }), - Dropcursor, + Dropcursor.configure({ + color: false, + width: 2, + class: 'bard-dropcursor', + }), Gapcursor, History, Paragraph, From 6092e39a7103ea410bc29db52a040ce04aa9bd79 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Thu, 13 Aug 2026 11:18:34 -0400 Subject: [PATCH 27/31] Skip Bard getHTML serialization unless reading time is enabled. Co-authored-by: Cursor --- resources/js/components/fieldtypes/bard/BardFieldtype.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index 2914875293b..fdc28ceb95b 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -967,7 +967,10 @@ export default { if (nodeCountChanged) this.debounceNextUpdate = false; this.json = newJson; - this.html = perf.measure('bard.onUpdate.getHTML', () => this.editor.getHTML()); + + if (this.config.reading_time) { + this.html = perf.measure('bard.onUpdate.getHTML', () => this.editor.getHTML()); + } }); }, onCreate: ({ editor }) => { From f30cdb3c5417374ab06d9e23ed31a08124887132 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Thu, 13 Aug 2026 11:25:16 -0400 Subject: [PATCH 28/31] Only deep-watch Live Preview payloads while preview is open. Co-authored-by: Cursor --- .../components/ui/LivePreview/LivePreview.vue | 75 +++++++++++-------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/resources/js/components/ui/LivePreview/LivePreview.vue b/resources/js/components/ui/LivePreview/LivePreview.vue index ae7d27fe816..f7582c30a38 100644 --- a/resources/js/components/ui/LivePreview/LivePreview.vue +++ b/resources/js/components/ui/LivePreview/LivePreview.vue @@ -21,7 +21,6 @@ import axios from 'axios'; import wait from '@/util/wait.js'; import { mapValues } from 'lodash-es'; import { useIframeManager } from './ManagesIframes.js'; -import { perf } from '@api'; const props = defineProps({ enabled: { @@ -67,22 +66,6 @@ const livePreviewFieldsPortal = computed(() => { return `live-preview-fields-${name.value}`; }); -watch( - () => props.enabled, - (enabled, wasEnabled) => { - if (wasEnabled && !enabled) { - nextTick(() => (portalEnabled.value = false)); - } else { - portalEnabled.value = enabled; - } - - if (!enabled) return; - - update(); - animateIn(); - }, -); - const tokenizedUrl = computed(() => { let url = props.url; @@ -104,22 +87,7 @@ const payload = computed(() => ({ // refresh on scroll/expand. Explicit update() callers (open / popout / refresh) // still always POST. let lastPostedPayloadKey = null; - -watch( - [payload, target], - () => { - perf.measure('livePreview.watch', () => { - if (!props.enabled) return; - - const key = JSON.stringify([payload.value, target.value]); - if (key === lastPostedPayloadKey) return; - - perf.count('livePreview.update'); - update(); - }); - }, - { deep: true }, -); +let stopPayloadWatch = null; const update = debounce(() => { const body = payload.value; @@ -185,6 +153,46 @@ function animateOut() { return wait(300); } +function startPayloadWatch() { + if (stopPayloadWatch) return; + + stopPayloadWatch = watch( + [payload, target], + () => { + const key = JSON.stringify([payload.value, target.value]); + if (key === lastPostedPayloadKey) return; + + update(); + }, + { deep: true }, + ); +} + +function teardownPayloadWatch() { + stopPayloadWatch?.(); + stopPayloadWatch = null; + update.cancel(); + source?.abort(); +} + +watch( + () => props.enabled, + (enabled, wasEnabled) => { + if (wasEnabled && !enabled) { + teardownPayloadWatch(); + nextTick(() => (portalEnabled.value = false)); + } else { + portalEnabled.value = enabled; + } + + if (!enabled) return; + + startPayloadWatch(); + update(); + animateIn(); + }, +); + const canPopOut = computed(() => typeof BroadcastChannel === 'function'); function popout() { @@ -319,6 +327,7 @@ const refreshEvent = `live-preview.${name.value}.refresh`; Statamic.$events.$on(refreshEvent, refreshHandler); onUnmounted(() => { + teardownPayloadWatch(); keybinding.value.destroy(); Statamic.$events.$off(refreshEvent, refreshHandler); }); From f6a4411f41f7b9399abfb4212b321215ad9b3087 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 13 Aug 2026 11:02:37 -0400 Subject: [PATCH 29/31] Remove the Vitest browser benchmark suite Drops the browser bench project, its fixtures and mount helpers, the bench-diff comparison script, and the npm scripts and gitignore entry that supported them. This tooling was only ever a development aid for measuring the optimisations in this PR; it isn't something we want to carry in core. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 - package.json | 3 - .../js/tests/browser/bench/bard.bench.js | 64 ----- .../js/tests/browser/bench/fixtures.bench.js | 77 ------ .../js/tests/browser/bench/mount.bench.js | 56 ---- .../tests/browser/bench/replicator.bench.js | 107 -------- resources/js/tests/browser/fixtures/bard.js | 176 ------------ resources/js/tests/browser/fixtures/index.js | 15 -- .../js/tests/browser/fixtures/replicator.js | 146 ---------- resources/js/tests/browser/fixtures/seeded.js | 41 --- resources/js/tests/browser/helpers/mount.js | 252 ------------------ resources/js/tests/browser/setup.js | 94 ------- scripts/bench-diff.mjs | 151 ----------- vite.config.js | 19 -- 14 files changed, 1202 deletions(-) delete mode 100644 resources/js/tests/browser/bench/bard.bench.js delete mode 100644 resources/js/tests/browser/bench/fixtures.bench.js delete mode 100644 resources/js/tests/browser/bench/mount.bench.js delete mode 100644 resources/js/tests/browser/bench/replicator.bench.js delete mode 100644 resources/js/tests/browser/fixtures/bard.js delete mode 100644 resources/js/tests/browser/fixtures/index.js delete mode 100644 resources/js/tests/browser/fixtures/replicator.js delete mode 100644 resources/js/tests/browser/fixtures/seeded.js delete mode 100644 resources/js/tests/browser/helpers/mount.js delete mode 100644 resources/js/tests/browser/setup.js delete mode 100644 scripts/bench-diff.mjs diff --git a/.gitignore b/.gitignore index 963bd0fdbb8..4026fb22467 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ resources/dist-dev resources/dist-frontend resources/dist-package resources/js/tests/browser/__screenshots__ -benchmarks/results.json packages/cms/src/ui.css composer.lock .env diff --git a/package.json b/package.json index 0909848a45f..28d8599ab14 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,6 @@ "svgo": "svgo -f ./resources/svg/ -r", "test": "vitest run", "test-watch": "npm run test -- --watch --notify", - "bench": "vitest bench --project bench --run --outputJson benchmarks/results.json", - "bench:compare": "npm run bench && node scripts/bench-diff.mjs benchmarks/baseline.json benchmarks/results.json", - "bench:update-baseline": "vitest bench --project bench --run --outputJson benchmarks/baseline.json", "frontend-dev": "vite -c vite-frontend.config.js", "frontend-build": "vite build -c vite-frontend.config.js", "knip": "knip", diff --git a/resources/js/tests/browser/bench/bard.bench.js b/resources/js/tests/browser/bench/bard.bench.js deleted file mode 100644 index 4a992e113e1..00000000000 --- a/resources/js/tests/browser/bench/bard.bench.js +++ /dev/null @@ -1,64 +0,0 @@ -import { bench, describe } from 'vitest'; -import { mountBard } from '../helpers/mount.js'; -import perf from '@/util/perf.js'; - -function createBardContext(profile, overrides) { - let wrapper; - let editor; - let ready; - - return { - async ensure() { - if (!ready) { - ready = (async () => { - perf.enable(); - ({ wrapper } = await mountBard({ - profile, - withSets: false, - overrides, - })); - editor = wrapper.vm.editor; - editor.commands.focus('end'); - perf.reset(); - })(); - } - - await ready; - - return { wrapper, editor }; - }, - cleanup() { - wrapper?.unmount(); - wrapper = null; - editor = null; - ready = null; - }, - }; -} - -describe('bard keystroke latency', () => { - const medium = createBardContext('medium', { paragraphs: 25, sets: 0, nestingDepth: 0 }); - const pathological = createBardContext('pathological', { paragraphs: 80, sets: 0, nestingDepth: 0 }); - - bench( - 'insertContent character (medium)', - async () => { - const { editor } = await medium.ensure(); - perf.start('bench.bard.keystroke'); - editor.commands.insertContent('x'); - perf.stop('bench.bard.keystroke'); - }, - { iterations: 25, warmupIterations: 5 }, - ); - - bench( - 'insertContent character (pathological)', - async () => { - const { editor } = await pathological.ensure(); - perf.start('bench.bard.keystroke.pathological'); - editor.commands.insertContent('x'); - perf.stop('bench.bard.keystroke.pathological'); - }, - { iterations: 15, warmupIterations: 3 }, - ); -}); diff --git a/resources/js/tests/browser/bench/fixtures.bench.js b/resources/js/tests/browser/bench/fixtures.bench.js deleted file mode 100644 index 7945581cfc2..00000000000 --- a/resources/js/tests/browser/bench/fixtures.bench.js +++ /dev/null @@ -1,77 +0,0 @@ -import { bench, describe } from 'vitest'; -import { - makeBardValueFromProfile, - makeReplicatorValueFromProfile, -} from '../fixtures/index.js'; - -function countNodes(nodes) { - if (!nodes || !Array.isArray(nodes)) return 0; - let count = nodes.length; - nodes.forEach((node) => { - if (node.content) { - count += countNodes(node.content); - } - if (node.attrs?.values?.body) { - count += countNodes(node.attrs.values.body); - } - }); - return count; -} - -describe('fixture generation', () => { - bench('bard small', () => { - makeBardValueFromProfile('small'); - }); - - bench('bard medium', () => { - makeBardValueFromProfile('medium'); - }); - - bench('bard pathological', () => { - makeBardValueFromProfile('pathological'); - }); - - bench('replicator small', () => { - makeReplicatorValueFromProfile('small'); - }); - - bench('replicator medium', () => { - makeReplicatorValueFromProfile('medium'); - }); - - bench('replicator pathological', () => { - makeReplicatorValueFromProfile('pathological'); - }); -}); - -describe('serialization hot paths (bard medium)', () => { - const value = makeBardValueFromProfile('medium'); - - bench('JSON.stringify bard value', () => { - JSON.stringify(value); - }); - - bench('clone via JSON.parse(JSON.stringify)', () => { - JSON.parse(JSON.stringify(value)); - }); - - bench('countNodes', () => { - countNodes(value); - }); -}); - -describe('serialization hot paths (bard pathological)', () => { - const value = makeBardValueFromProfile('pathological'); - - bench('JSON.stringify bard value', () => { - JSON.stringify(value); - }); - - bench('clone via JSON.parse(JSON.stringify)', () => { - JSON.parse(JSON.stringify(value)); - }); - - bench('countNodes', () => { - countNodes(value); - }); -}); diff --git a/resources/js/tests/browser/bench/mount.bench.js b/resources/js/tests/browser/bench/mount.bench.js deleted file mode 100644 index bf486a47c60..00000000000 --- a/resources/js/tests/browser/bench/mount.bench.js +++ /dev/null @@ -1,56 +0,0 @@ -import { bench, describe } from 'vitest'; -import { - mountBard, - mountReplicator, - mountPublishWithBard, -} from '../helpers/mount.js'; - -describe('mount time', () => { - bench( - 'bard small', - async () => { - const { wrapper } = await mountBard({ profile: 'small', withSets: false }); - wrapper.unmount(); - }, - { iterations: 5, warmupIterations: 1 }, - ); - - bench( - 'bard medium', - async () => { - const { wrapper } = await mountBard({ profile: 'medium', withSets: false }); - wrapper.unmount(); - }, - { iterations: 3, warmupIterations: 1 }, - ); - - bench( - 'replicator small', - async () => { - const { wrapper } = await mountReplicator({ profile: 'small', shallow: true }); - wrapper.unmount(); - }, - { iterations: 5, warmupIterations: 1 }, - ); - - bench( - 'replicator medium', - async () => { - const { wrapper } = await mountReplicator({ profile: 'medium', shallow: true }); - wrapper.unmount(); - }, - { iterations: 3, warmupIterations: 1 }, - ); - - bench( - 'publish form + bard medium', - async () => { - const wrapper = await mountPublishWithBard('medium'); - wrapper.unmount(); - }, - { iterations: 3, warmupIterations: 1 }, - ); - - // Full publish-form + replicator mounts are covered by fixture/serialization benches - // plus shallow replicator mounts above until set child wiring is hardened for browser mode. -}); diff --git a/resources/js/tests/browser/bench/replicator.bench.js b/resources/js/tests/browser/bench/replicator.bench.js deleted file mode 100644 index 9de365e6704..00000000000 --- a/resources/js/tests/browser/bench/replicator.bench.js +++ /dev/null @@ -1,107 +0,0 @@ -import { bench, describe } from 'vitest'; -import { nextTick } from 'vue'; -import { mountReplicator } from '../helpers/mount.js'; -import perf from '@/util/perf.js'; - -function createReplicatorContext(profile = 'medium') { - let wrapper; - let vm; - let ready; - - return { - async ensure() { - if (!ready) { - ready = (async () => { - perf.enable(); - // Shallow keeps focus on parent structural ops (sorted/collapse/duplicate) - // without requiring the full Sortable + set child tree. - ({ wrapper } = await mountReplicator({ profile, shallow: true })); - vm = wrapper.vm; - perf.reset(); - })(); - } - - await ready; - - return { wrapper, vm }; - }, - cleanup() { - wrapper?.unmount(); - wrapper = null; - vm = null; - ready = null; - }, - }; -} - -describe('replicator structural ops', () => { - const ctx = createReplicatorContext('medium'); - - bench( - 'collapseAll', - async () => { - const { vm } = await ctx.ensure(); - perf.start('bench.replicator.collapseAll'); - vm.collapseAll(); - await nextTick(); - perf.stop('bench.replicator.collapseAll'); - vm.expandAll(); - await nextTick(); - }, - { iterations: 20, warmupIterations: 3 }, - ); - - bench( - 'expandAll', - async () => { - const { vm } = await ctx.ensure(); - vm.collapseAll(); - await nextTick(); - perf.start('bench.replicator.expandAll'); - vm.expandAll(); - await nextTick(); - perf.stop('bench.replicator.expandAll'); - }, - { iterations: 20, warmupIterations: 3 }, - ); - - bench( - 'sorted (reorder)', - async () => { - const { vm } = await ctx.ensure(); - const value = [...vm.value]; - if (value.length < 2) return; - - const reordered = [value[1], value[0], ...value.slice(2)]; - - perf.start('bench.replicator.sorted'); - vm.sorted(reordered); - await nextTick(); - perf.stop('bench.replicator.sorted'); - - vm.sorted(value); - await nextTick(); - }, - { iterations: 20, warmupIterations: 3 }, - ); - - bench( - 'duplicateSet', - async () => { - const { vm } = await ctx.ensure(); - const originalLength = vm.value.length; - const id = vm.value[0]._id; - - perf.start('bench.replicator.duplicateSet'); - vm.duplicateSet(id); - await nextTick(); - perf.stop('bench.replicator.duplicateSet'); - - if (vm.value.length > originalLength) { - vm.removed(vm.value[1], 1); - await nextTick(); - } - }, - { iterations: 10, warmupIterations: 2 }, - ); -}); diff --git a/resources/js/tests/browser/fixtures/bard.js b/resources/js/tests/browser/fixtures/bard.js deleted file mode 100644 index bbfaffca255..00000000000 --- a/resources/js/tests/browser/fixtures/bard.js +++ /dev/null @@ -1,176 +0,0 @@ -import { createSeededRandom, seededId, seededText } from './seeded.js'; - -export const BARD_PROFILES = { - small: { paragraphs: 5, sets: 2, nestingDepth: 0, wordsPerParagraph: 20 }, - medium: { paragraphs: 25, sets: 10, nestingDepth: 1, wordsPerParagraph: 40 }, - pathological: { paragraphs: 80, sets: 40, nestingDepth: 2, wordsPerParagraph: 60 }, -}; - -function makeParagraph(random, words) { - return { - type: 'paragraph', - content: [{ type: 'text', text: seededText(random, words) }], - }; -} - -function makeNestedBardValue(random, { paragraphs, wordsPerParagraph, nestingDepth }) { - const content = []; - - for (let i = 0; i < paragraphs; i++) { - content.push(makeParagraph(random, wordsPerParagraph)); - } - - if (nestingDepth > 0) { - content.push( - makeSetNode(random, { - index: 0, - nestingDepth: nestingDepth - 1, - wordsPerParagraph, - }), - ); - } - - return content; -} - -function makeSetNode(random, { index, nestingDepth, wordsPerParagraph }) { - const id = seededId('set', index); - const nestedParagraphs = Math.max(2, Math.floor(wordsPerParagraph / 10)); - - return { - type: 'set', - attrs: { - id, - enabled: true, - values: { - type: 'page_builder', - title: seededText(random, 4), - body: makeNestedBardValue(random, { - paragraphs: nestedParagraphs, - wordsPerParagraph: Math.max(8, Math.floor(wordsPerParagraph / 2)), - nestingDepth, - }), - }, - }, - }; -} - -/** - * Build a Bard ProseMirror JSON content array. - */ -export function makeBardValue(options = {}) { - const { - paragraphs = 5, - sets = 0, - nestingDepth = 0, - wordsPerParagraph = 20, - seed = 1, - } = options; - - const random = createSeededRandom(seed); - const content = []; - let setIndex = 0; - - for (let i = 0; i < paragraphs; i++) { - content.push(makeParagraph(random, wordsPerParagraph)); - - // Interleave sets through the document. - if (sets > 0 && (i + 1) % Math.max(1, Math.floor(paragraphs / sets)) === 0 && setIndex < sets) { - content.push( - makeSetNode(random, { - index: setIndex++, - nestingDepth, - wordsPerParagraph, - }), - ); - } - } - - while (setIndex < sets) { - content.push( - makeSetNode(random, { - index: setIndex++, - nestingDepth, - wordsPerParagraph, - }), - ); - } - - return content; -} - -export function makeBardValueFromProfile(profile = 'small', overrides = {}) { - const preset = BARD_PROFILES[profile] || BARD_PROFILES.small; - - return makeBardValue({ ...preset, ...overrides }); -} - -export function makeBardConfig({ withSets = true } = {}) { - const sets = withSets - ? [ - { - handle: 'main', - display: 'Main', - sets: [ - { - handle: 'page_builder', - display: 'Page Builder', - fields: [ - { handle: 'title', type: 'text', display: 'Title' }, - { - handle: 'body', - type: 'bard', - display: 'Body', - buttons: ['bold', 'italic'], - sets: [], - }, - ], - }, - ], - }, - ] - : []; - - return { - display: 'Content', - type: 'bard', - buttons: ['bold', 'italic', 'h2', 'h3', 'unorderedlist', 'orderedlist'], - toolbar_mode: 'fixed', - container: 'assets', - save_html: false, - inline: false, - enable_input_rules: true, - enable_paste_rules: true, - remove_empty_nodes: false, - previews: true, - sets, - }; -} - -export function makeBardMeta(value = []) { - const existing = {}; - - for (const node of value) { - if (node.type !== 'set') continue; - - existing[node.attrs.id] = { - title: {}, - body: { existing: [], defaults: {}, new: {}, collapsed: [], flatten: false }, - }; - } - - return { - existing, - defaults: { - page_builder: { title: null, body: [] }, - }, - new: { - page_builder: { - title: {}, - body: { existing: [], defaults: {}, new: {}, collapsed: [], flatten: false }, - }, - }, - collapsed: [], - flatten: false, - }; -} diff --git a/resources/js/tests/browser/fixtures/index.js b/resources/js/tests/browser/fixtures/index.js deleted file mode 100644 index 60571fcae6f..00000000000 --- a/resources/js/tests/browser/fixtures/index.js +++ /dev/null @@ -1,15 +0,0 @@ -export { - BARD_PROFILES, - makeBardValue, - makeBardValueFromProfile, - makeBardConfig, - makeBardMeta, -} from './bard.js'; - -export { - REPLICATOR_PROFILES, - makeReplicatorValue, - makeReplicatorValueFromProfile, -} from './replicator.js'; - -export { createSeededRandom, seededId, seededText } from './seeded.js'; diff --git a/resources/js/tests/browser/fixtures/replicator.js b/resources/js/tests/browser/fixtures/replicator.js deleted file mode 100644 index 2f514504af6..00000000000 --- a/resources/js/tests/browser/fixtures/replicator.js +++ /dev/null @@ -1,146 +0,0 @@ -import { createSeededRandom, seededId, seededText } from './seeded.js'; - -export const REPLICATOR_PROFILES = { - small: { sets: 3, fieldsPerSet: 2, nested: false }, - medium: { sets: 15, fieldsPerSet: 4, nested: false }, - pathological: { sets: 40, fieldsPerSet: 6, nested: true }, -}; - -function makeSetFields(fieldsPerSet) { - return Array.from({ length: fieldsPerSet }, (_, index) => ({ - handle: `field_${index}`, - type: 'text', - display: `Field ${index}`, - replicator_preview: index === 0, - })); -} - -/** - * Build a Replicator value array plus matching config/meta. - */ -export function makeReplicatorValue(options = {}) { - const { - sets = 3, - fieldsPerSet = 2, - nested = false, - seed = 1, - } = options; - - const random = createSeededRandom(seed); - const fields = makeSetFields(fieldsPerSet); - - const value = Array.from({ length: sets }, (_, index) => { - const set = { - _id: seededId('rep', index), - type: 'block', - enabled: true, - }; - - for (const field of fields) { - set[field.handle] = seededText(random, 8); - } - - if (nested) { - set.nested = Array.from({ length: 2 }, (_, nestedIndex) => ({ - _id: seededId(`rep-${index}-nested`, nestedIndex), - type: 'block', - enabled: true, - field_0: seededText(random, 6), - })); - } - - return set; - }); - - const nestedField = nested - ? [ - { - handle: 'nested', - type: 'replicator', - display: 'Nested', - sets: [ - { - handle: 'main', - sets: [ - { - handle: 'block', - display: 'Block', - fields: [{ handle: 'field_0', type: 'text', display: 'Field 0' }], - }, - ], - }, - ], - }, - ] - : []; - - const config = { - display: 'Blocks', - type: 'replicator', - collapse: false, - previews: true, - sets: [ - { - handle: 'main', - display: 'Main', - sets: [ - { - handle: 'block', - display: 'Block', - fields: [...fields, ...nestedField], - }, - ], - }, - ], - }; - - const existing = {}; - - for (const set of value) { - existing[set._id] = Object.fromEntries(fields.map((field) => [field.handle, {}])); - - if (nested && set.nested) { - existing[set._id].nested = { - existing: Object.fromEntries(set.nested.map((child) => [child._id, { field_0: {} }])), - defaults: { block: { field_0: null } }, - new: { block: { field_0: {} } }, - collapsed: [], - }; - } - } - - const meta = { - existing, - defaults: { - block: Object.fromEntries([ - ...fields.map((field) => [field.handle, null]), - ...(nested ? [['nested', []]] : []), - ]), - }, - new: { - block: Object.fromEntries([ - ...fields.map((field) => [field.handle, {}]), - ...(nested - ? [[ - 'nested', - { - existing: {}, - defaults: { block: { field_0: null } }, - new: { block: { field_0: {} } }, - collapsed: [], - }, - ]] - : []), - ]), - }, - collapsed: [], - }; - - return { value, config, meta, fields }; -} - -export function makeReplicatorValueFromProfile(profile = 'small', overrides = {}) { - const preset = REPLICATOR_PROFILES[profile] || REPLICATOR_PROFILES.small; - - return makeReplicatorValue({ ...preset, ...overrides }); -} diff --git a/resources/js/tests/browser/fixtures/seeded.js b/resources/js/tests/browser/fixtures/seeded.js deleted file mode 100644 index 57f883b2f2f..00000000000 --- a/resources/js/tests/browser/fixtures/seeded.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Tiny seeded PRNG so fixture generation is deterministic across runs. - */ -export function createSeededRandom(seed = 1) { - let state = seed >>> 0; - - return function random() { - state = (1664525 * state + 1013904223) >>> 0; - return state / 0x100000000; - }; -} - -export function seededId(prefix, index) { - return `${prefix}-${String(index).padStart(4, '0')}`; -} - -export function seededText(random, words = 12) { - const lexicon = [ - 'lorem', - 'ipsum', - 'dolor', - 'sit', - 'amet', - 'consectetur', - 'adipiscing', - 'elit', - 'sed', - 'do', - 'eiusmod', - 'tempor', - 'incididunt', - 'ut', - 'labore', - 'et', - 'dolore', - 'magna', - 'aliqua', - ]; - - return Array.from({ length: words }, () => lexicon[Math.floor(random() * lexicon.length)]).join(' '); -} diff --git a/resources/js/tests/browser/helpers/mount.js b/resources/js/tests/browser/helpers/mount.js deleted file mode 100644 index bc5d3556abb..00000000000 --- a/resources/js/tests/browser/helpers/mount.js +++ /dev/null @@ -1,252 +0,0 @@ -import { mount, flushPromises } from '@vue/test-utils'; -import { h, nextTick, defineComponent, ref } from 'vue'; -import wait from '@/util/wait.js'; -import Container, { containerContextKey } from '@/components/ui/Publish/Container.vue'; -import Fields from '@/components/ui/Publish/Fields.vue'; -import FieldsProvider from '@/components/ui/Publish/FieldsProvider.vue'; -import BardFieldtype from '@/components/fieldtypes/bard/BardFieldtype.vue'; -import ReplicatorFieldtype from '@/components/fieldtypes/replicator/Replicator.vue'; -import TextFieldtype from '@/components/fieldtypes/TextFieldtype.vue'; -import { Input } from '@/components/ui'; -import { - makeBardConfig, - makeBardMeta, - makeBardValueFromProfile, - makeReplicatorValueFromProfile, -} from '../fixtures/index.js'; -import perf from '@/util/perf.js'; - -function registerFieldtypes() { - Statamic.$components.register('text-fieldtype', TextFieldtype); - Statamic.$components.register('bard-fieldtype', BardFieldtype); - Statamic.$components.register('replicator-fieldtype', ReplicatorFieldtype); -} - -function makePublishContainerStub({ values = {}, meta = {} } = {}) { - const valuesRef = ref(values); - const metaRef = ref(meta); - - return { - name: 'bench', - reference: null, - blueprint: { handle: 'bench', token: 'bench-token' }, - values: valuesRef, - meta: metaRef, - site: 'default', - errors: {}, - readOnly: false, - previews: ref({}), - setFieldValue: (path, value) => data_set(valuesRef.value, path, value), - setFieldMeta: (path, value) => data_set(metaRef.value, path, value), - setFieldPreviewValue: () => {}, - syncField: () => {}, - desyncField: () => {}, - }; -} - -export async function mountPublishForm({ fields, values, meta = {}, blueprint } = {}) { - registerFieldtypes(); - - const wrapper = mount(Container, { - props: { - blueprint: blueprint || { handle: 'bench', tabs: [{ handle: 'main', sections: [{ fields }] }] }, - modelValue: values, - meta, - site: 'default', - trackDirtyState: true, - }, - global: { - components: { - 'text-fieldtype': TextFieldtype, - 'bard-fieldtype': BardFieldtype, - 'replicator-fieldtype': ReplicatorFieldtype, - Input, - }, - stubs: { - portal: { - template: '
', - }, - }, - }, - slots: { - default: () => - h(FieldsProvider, { fields }, () => h(Fields)), - }, - }); - - await flushPromises(); - await nextTick(); - - return wrapper; -} - -export async function mountBard({ profile = 'small', withSets = false, overrides = {} } = {}) { - registerFieldtypes(); - - // TipTap set node-views need a fully wired Vue app provide tree. - // Default benches use paragraphs-only docs; pass withSets/overrides explicitly for set scenarios. - const profileOverrides = withSets - ? overrides - : { ...overrides, sets: 0, nestingDepth: 0 }; - - const value = makeBardValueFromProfile(profile, profileOverrides); - const config = makeBardConfig({ withSets }); - const meta = makeBardMeta(value); - const container = makePublishContainerStub({ - values: { content: value }, - meta: { content: meta }, - }); - - const wrapper = mount(BardFieldtype, { - props: { - handle: 'content', - value, - config, - meta, - }, - global: { - components: { - 'text-fieldtype': TextFieldtype, - 'bard-fieldtype': BardFieldtype, - Input, - }, - stubs: { - portal: { - template: '
', - }, - 'publish-field-fullscreen-header': true, - 'ui-button': true, - 'ui-description': true, - }, - provide: { - [containerContextKey]: container, - }, - }, - }); - - // Bard's mounted() is async (dynamic tipTap import). - await waitFor(() => wrapper.vm.editor, { - timeout: 15000, - message: 'Bard editor failed to initialize', - }); - - return { wrapper, value, config, meta, container }; -} - -async function waitFor(getter, { timeout = 5000, message = 'Timed out waiting for condition' } = {}) { - const started = Date.now(); - - while (Date.now() - started < timeout) { - await flushPromises(); - await nextTick(); - - const value = getter(); - if (value) return value; - - await wait(25); - } - - throw new Error(message); -} - -export async function mountReplicator({ profile = 'small', overrides = {}, shallow = false } = {}) { - registerFieldtypes(); - - const { value, config, meta } = makeReplicatorValueFromProfile(profile, overrides); - const container = makePublishContainerStub({ - values: { blocks: value }, - meta: { blocks: meta }, - }); - - const wrapper = mount(ReplicatorFieldtype, { - shallow, - props: { - handle: 'blocks', - value, - config, - meta, - id: 'blocks-field', - }, - global: { - components: { - 'text-fieldtype': TextFieldtype, - 'replicator-fieldtype': ReplicatorFieldtype, - Input, - }, - stubs: { - portal: { - template: '
', - }, - 'publish-field-fullscreen-header': true, - ReplicatorSet: true, - 'sortable-list': true, - 'add-set-button': true, - }, - provide: { - [containerContextKey]: container, - }, - }, - }); - - await flushPromises(); - await nextTick(); - - if (!wrapper.vm?.value) { - throw new Error('Replicator failed to mount'); - } - - return { wrapper, value, config, meta, container }; -} - -export async function mountPublishWithBard(profile = 'medium') { - // Paragraphs-only for stable publish-form mounts; set node-views need fuller app wiring. - const value = makeBardValueFromProfile(profile, { sets: 0, nestingDepth: 0 }); - const config = { handle: 'content', ...makeBardConfig({ withSets: false }) }; - const meta = { content: makeBardMeta(value) }; - - return mountPublishForm({ - fields: [config], - values: { content: value }, - meta, - }); -} - -export async function mountPublishWithReplicator(profile = 'medium') { - const { value, config, meta } = makeReplicatorValueFromProfile(profile); - - return mountPublishForm({ - fields: [{ handle: 'blocks', ...config }], - values: { blocks: value }, - meta: { blocks: meta }, - }); -} - -/** - * Time an async/sync operation once using the shared perf module. - * Useful inside benches that care about a single interaction latency - * rather than tinybench's ops/sec loop. - */ -export async function timeOnce(name, fn) { - perf.reset(); - perf.enable(); - perf.start(name); - - const result = await fn(); - - perf.stop(name); - - return { - result, - report: perf.reportJson(), - duration: perf.reportJson().find((row) => row.name === name)?.mean ?? 0, - }; -} - -export function Probe(callback) { - return defineComponent({ - setup() { - callback(); - return () => h('div'); - }, - }); -} diff --git a/resources/js/tests/browser/setup.js b/resources/js/tests/browser/setup.js deleted file mode 100644 index 2f6e447007c..00000000000 --- a/resources/js/tests/browser/setup.js +++ /dev/null @@ -1,94 +0,0 @@ -import { config } from '@vue/test-utils'; -import * as Globals from '@/bootstrap/globals.js'; -import { bard, dirty, events, keys, toast, progress, fieldActions, conditions, preferences } from '@api'; -import perf from '@/util/perf.js'; - -Object.keys(Globals).forEach((fn) => { - window[fn] = Globals[fn]; -}); - -window.__ = (key) => key; -window.__n = (key) => key; -window.cp_url = (url) => `/cp/${url}`; -window.docs_url = (url) => `https://statamic.dev/${url}`; - -const components = new Map(); - -const $components = { - has: (name) => components.has(name), - register: (name, component) => components.set(name, component), - get: (name) => components.get(name), -}; - -const $config = { - get: (key) => { - if (key === 'sites') return [{ handle: 'default', direction: 'ltr' }]; - if (key === 'locale') return 'en'; - return undefined; - }, -}; - -const $commandPalette = { preventIf: () => {} }; -const $axios = { - post: async () => ({ data: { new: {}, defaults: {} } }), - get: async () => ({ data: {} }), -}; - -window.Statamic = { - $app: { - component: (name) => components.get(name), - config: { performance: false }, - }, - $components, - $config, - $dirty: dirty, - $events: events, - $toast: toast, - $keys: keys, - $commandPalette, - $fieldActions: fieldActions, - $permissions: { has: () => true }, - $preferences: preferences, - $hooks: { run: async (_name, payload) => payload }, - $callbacks: { call: () => {} }, - $slug: { create: () => ({ create: () => {}, destroy: () => {} }) }, - $conditions: conditions, - $progress: progress, - $perf: perf, - $bard: bard, - $axios, - user: { id: 'bench-user' }, -}; - -perf.attachVueApp(window.Statamic.$app); -perf.enable(); -perf.reset(); - -config.global.directives = { - tooltip: () => {}, - elastic: () => {}, -}; - -// Keep Vue-reserved `$…` keys off `mocks` — VTU assigns mocks onto the instance -// and reserved names (e.g. `$components`) throw in browser mode. -config.global.mocks = { - __: (key) => key, - __n: (key) => key, - $markdown: (value) => value, - can: () => true, - cp_url: (url) => `/cp/${url}`, - docs_url: (url) => `https://statamic.dev/${url}`, - $bard: bard, - $keys: keys, - $toast: toast, - $events: events, - $dirty: dirty, - $config, - $commandPalette, - $fieldActions: fieldActions, - $conditions: conditions, - $progress: progress, - $preferences: preferences, - $perf: perf, - $axios, -}; diff --git a/scripts/bench-diff.mjs b/scripts/bench-diff.mjs deleted file mode 100644 index d0637e2b0f6..00000000000 --- a/scripts/bench-diff.mjs +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env node - -/** - * Diff two Vitest bench --outputJson snapshots. - * - * Usage: - * node scripts/bench-diff.mjs benchmarks/baseline.json benchmarks/results.json - * node scripts/bench-diff.mjs benchmarks/baseline.json # reads benchmarks/results.json - */ - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -const baselinePath = resolve(process.argv[2] || 'benchmarks/baseline.json'); -const currentPath = resolve(process.argv[3] || 'benchmarks/results.json'); - -function load(path) { - return JSON.parse(readFileSync(path, 'utf8')); -} - -function flatten(report) { - const rows = new Map(); - - for (const file of report.files || []) { - for (const group of file.groups || []) { - for (const bench of group.benchmarks || []) { - const key = `${group.fullName} › ${bench.name}`; - rows.set(key, { - key, - name: bench.name, - group: group.fullName.replace(/^resources\/js\/tests\/browser\/bench\//, ''), - mean: bench.mean, - hz: bench.hz, - rme: bench.rme, - sampleCount: bench.sampleCount, - }); - } - } - } - - return rows; -} - -function fmtMs(value) { - if (value >= 10) return value.toFixed(2); - if (value >= 1) return value.toFixed(3); - return value.toFixed(4); -} - -function fmtPct(value) { - const sign = value > 0 ? '+' : ''; - return `${sign}${value.toFixed(1)}%`; -} - -function pad(value, width, right = false) { - const str = String(value); - return right ? str.padStart(width) : str.padEnd(width); -} - -const baseline = flatten(load(baselinePath)); -const current = flatten(load(currentPath)); - -const keys = [...new Set([...baseline.keys(), ...current.keys()])].sort(); - -if (keys.length === 0) { - console.error('No benchmarks found in either report.'); - process.exit(1); -} - -const rows = []; - -for (const key of keys) { - const before = baseline.get(key); - const after = current.get(key); - - if (!before || !after) { - rows.push({ - group: (after || before).group, - name: (after || before).name, - before: before?.mean ?? null, - after: after?.mean ?? null, - deltaPct: null, - status: before ? 'removed' : 'added', - }); - continue; - } - - // Lower mean = faster. Positive deltaPct means slower than baseline. - const deltaPct = ((after.mean - before.mean) / before.mean) * 100; - let status = 'same'; - if (deltaPct <= -3) status = 'faster'; - else if (deltaPct >= 3) status = 'slower'; - - rows.push({ - group: after.group, - name: after.name, - before: before.mean, - after: after.mean, - deltaPct, - status, - }); -} - -console.log(`Baseline: ${baselinePath}`); -console.log(`Current: ${currentPath}`); -console.log('Delta = change in mean ms. Negative = faster than baseline.\n'); - -console.log( - `${pad('Scenario', 52)} ${pad('baseline', 10, true)} ${pad('current', 10, true)} ${pad('Δ mean', 10, true)} ${pad('', 8)}`, -); -console.log('-'.repeat(94)); - -let group = null; -for (const row of rows) { - if (row.group !== group) { - if (group !== null) console.log(''); - group = row.group; - console.log(group); - } - - const label = ` ${row.name}`.slice(0, 52); - if (row.status === 'added') { - console.log(`${pad(label, 52)} ${pad('—', 10, true)} ${pad(fmtMs(row.after), 10, true)} ${pad('added', 10, true)}`); - continue; - } - if (row.status === 'removed') { - console.log(`${pad(label, 52)} ${pad(fmtMs(row.before), 10, true)} ${pad('—', 10, true)} ${pad('removed', 10, true)}`); - continue; - } - - const arrow = row.status === 'faster' ? 'faster' : row.status === 'slower' ? 'SLOWER' : ''; - console.log( - `${pad(label, 52)} ${pad(fmtMs(row.before), 10, true)} ${pad(fmtMs(row.after), 10, true)} ${pad(fmtPct(row.deltaPct), 10, true)} ${arrow}`, - ); -} - -const slower = rows.filter((row) => row.status === 'slower'); -const faster = rows.filter((row) => row.status === 'faster'); - -console.log('\nSummary'); -console.log(` ${faster.length} faster (≥3%)`); -console.log(` ${slower.length} slower (≥3%)`); -console.log(` ${rows.length - faster.length - slower.length} within noise`); - -if (slower.length) { - console.log('\nRegressions:'); - for (const row of slower) { - console.log(` - ${row.name}: ${fmtPct(row.deltaPct)} (mean ${fmtMs(row.before)} → ${fmtMs(row.after)} ms)`); - } - process.exitCode = 1; -} diff --git a/vite.config.js b/vite.config.js index a6a08b5c526..4238d85af62 100644 --- a/vite.config.js +++ b/vite.config.js @@ -67,25 +67,6 @@ export default defineConfig(({ mode, command }) => { exclude: ['resources/js/tests/browser/**'], }, }, - { - extends: true, - test: { - name: 'bench', - // Benchmarks only — keep out of `vitest run` / `npm test`. - // Invoked via `vitest bench --project bench`. - include: [], - browser: { - enabled: true, - headless: true, - provider: playwright(), - instances: [{ browser: 'chromium' }], - }, - setupFiles: 'resources/js/tests/browser/setup.js', - benchmark: { - include: ['resources/js/tests/browser/bench/**/*.bench.js'], - }, - }, - }, { extends: true, plugins: [ From d7af31978ca2014552aba0f9438063123490ba17 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 13 Aug 2026 11:02:46 -0400 Subject: [PATCH 30/31] Remove the $perf instrumentation Drops the perf utility, its tests, the Statamic.$perf global and api export, and every perf.measure/start/stop/count call site across the Bard, Replicator, publish and Live Preview components, along with the CONTRIBUTING section documenting how to file a perf report. The instrumentation existed to measure the optimisations in this PR; the optimisations themselves are unchanged. Where a measure merely wrapped existing code, that code is restored to its 6.x form verbatim. Co-Authored-By: Claude Opus 5 (1M context) --- CONTRIBUTING.md | 51 - packages/cms/src/api.js | 1 - resources/js/api.js | 2 - resources/js/bootstrap/statamic.js | 7 - .../js/components/fieldtypes/Fieldtype.vue | 3 - .../fieldtypes/bard/BardFieldtype.vue | 70 +- .../js/components/fieldtypes/bard/Set.vue | 16 +- .../replicator/ManagesPreviewText.js | 15 +- .../fieldtypes/replicator/Replicator.vue | 104 +- .../js/components/ui/Publish/Container.vue | 35 +- .../js/components/ui/Publish/SavePipeline.js | 89 +- resources/js/tests/Package.test.js | 1 - resources/js/tests/util/perf.test.js | 278 ------ resources/js/util/perf.js | 930 ------------------ 14 files changed, 103 insertions(+), 1499 deletions(-) delete mode 100644 resources/js/tests/util/perf.test.js delete mode 100644 resources/js/util/perf.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a255b73730e..e4b018ba314 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,6 @@ This is a guideline for contributing to Statamic, its documentation, and addons. - [How You Can Contribute](#how-you-can-contribute) - [Which Repo?](#which-repo) - [Bug Reports](#bug-reports) -- [Control Panel Performance Reports](#control-panel-performance-reports) - [Feature Requests](#feature-requests) - [Security Disclosures](#security-disclosures) - [Core Enhancements](#core-enhancements) @@ -56,56 +55,6 @@ If you _do_ find a similar issue, upvote it by adding a :thumbsup: [reaction](ht If no one has filed the issue yet, feel free to [submit a new one](https://github.com/statamic/cms/issues/new?template=bug_report.yml). Please include a clear description of the issue, follow along with the issue template, and provide and as much relevant information as possible. Code examples demonstrating the issue are the best way to ensure a timely solution to the issue. -### Control Panel Performance Reports - -If you're reporting sluggish Bard, Replicator, or publish-form behavior, please include a structured perf report when you can: - -1. Open the Control Panel and run this in the browser console: - -```js -localStorage.setItem('statamic.perf', '1') -``` - -2. Reload the page (required — instrumentation starts in the **mount** phase on boot). -3. Reproduce the issue. Useful recipes: - - **Slow initial render:** open the entry, wait until idle, then `Statamic.$perf.report()` — look at `phase.mount` and the `mount.*` rows. - - **Slow save:** click Save, wait for it to finish, then report — look at `phase.save` and `save.publish.save.*`. - - **Slow typing / editing:** after the form is idle, run `Statamic.$perf.reset()` to clear mount noise, reproduce the interaction, then report — look at `interact.*`. -4. Run: - -```js -Statamic.$perf.report() -``` - -You'll get a color-coded list grouped by phase (`mount` → `save` → `interact`). Times are **milliseconds**. Headline wall clocks: `phase.mount` (initial render) and `phase.save` (full save pipeline). Heat levels: `critical`, `hot`, `warm`, `ok`, or `count` (tally only). - -5. Export / paste into the GitHub issue (DevTools `console.table` is preview-only — use these): - -```js -Statamic.$perf.copy('md') // markdown table → clipboard (best for GitHub) -Statamic.$perf.copy() // TSV → clipboard (spreadsheets) -Statamic.$perf.copy('json') // versioned snapshot → clipboard -Statamic.$perf.download() // download snapshot JSON -``` - -To compare two runs over time: - -```js -const before = Statamic.$perf.snapshot('before-fix') -// …change code / reload / reproduce… -Statamic.$perf.diff(before) // console delta table -Statamic.$perf.copyDiff(before) // TSV deltas → clipboard -``` - -To turn instrumentation off afterward: - -```js -Statamic.$perf.disable() -// or: localStorage.removeItem('statamic.perf') -``` - -This uses the browser User Timing API under the hood (marks also show up in the Chrome DevTools Performance panel). Core maintainers compare changes against the Vitest browser benchmark suite — see [`benchmarks/README.md`](benchmarks/README.md). - ### Feature Requests Feature requests should be created in the [statamic/ideas](https://github.com/statamic/ideas) repository. diff --git a/packages/cms/src/api.js b/packages/cms/src/api.js index e28b1e1fbc1..40e8654a1ed 100644 --- a/packages/cms/src/api.js +++ b/packages/cms/src/api.js @@ -15,7 +15,6 @@ export const { inertia, keys, numberFormatter, - perf, permissions, portals, preferences, diff --git a/resources/js/api.js b/resources/js/api.js index fd79665667c..8e2ad4c0c9d 100644 --- a/resources/js/api.js +++ b/resources/js/api.js @@ -24,7 +24,6 @@ import Toasts from './components/Toasts.js'; import Portals from './components/portals/Portals.js'; import Stacks from './components/ui/Stack/Stacks.js'; import Inertia from './components/Inertia'; -import perf from './util/perf.js'; export const keys = new Keys(); export const components = new Components; @@ -51,4 +50,3 @@ export const toast = new Toasts(); export const portals = markRaw(new Portals()); export const stacks = new Stacks(portals); export const inertia = new Inertia(); -export { perf }; diff --git a/resources/js/bootstrap/statamic.js b/resources/js/bootstrap/statamic.js index 67345ac03a6..3a5eda975b2 100644 --- a/resources/js/bootstrap/statamic.js +++ b/resources/js/bootstrap/statamic.js @@ -46,7 +46,6 @@ import { portals, stacks, inertia, - perf, } from '@api'; let bootingCallbacks = []; @@ -150,10 +149,6 @@ export default { return dirty; }, - get $perf() { - return perf; - }, - get $events() { return events; }, @@ -272,7 +267,6 @@ export default { this.$app.directive('tooltip', tooltipDirective); this.$app.use(VueComponentDebug, { enabled: import.meta.env.VITE_VUE_COMPONENT_DEBUG === 'true' }); toast.initialize(this.$app); - perf.attachVueApp(this.$app); Object.assign(this.$app.config.globalProperties, { $config: config, @@ -285,7 +279,6 @@ export default { $conditions: conditions, $callbacks: callbacks, $dirty: dirty, - $perf: perf, $slug: slug, $portals: portals, $stacks: stacks, diff --git a/resources/js/components/fieldtypes/Fieldtype.vue b/resources/js/components/fieldtypes/Fieldtype.vue index b09d2d72c91..c6ee19b8aa3 100644 --- a/resources/js/components/fieldtypes/Fieldtype.vue +++ b/resources/js/components/fieldtypes/Fieldtype.vue @@ -6,7 +6,6 @@ import emits from './emits.js'; import { UPDATE_DEBOUNCE_MS } from './constants'; import { publishContextKey } from '@/components/ui'; import { isRef, markRaw } from 'vue'; -import { perf } from '@api'; export default { emits, @@ -23,12 +22,10 @@ export default { methods: { update(value) { - perf.count(`fieldtype.update.${this.config?.type || 'unknown'}`); this.$emit('update:value', value); }, updateMeta(value) { - perf.count(`fieldtype.updateMeta.${this.config?.type || 'unknown'}`); this.$emit('update:meta', value); }, }, diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index fdc28ceb95b..a8c4bc9e5be 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -136,7 +136,6 @@