[6.x] CP publish-form performance: Bard, Replicator, and related wins - #15158
[6.x] CP publish-form performance: Bard, Replicator, and related wins#15158jackmcdade wants to merge 32 commits into
Conversation
P1.1 — Values.js was cloning the entire values tree on missingValue, encode/decode, and forgetValue paths. Drop the wasteful copies so keystroke/update work stays on the live structure. Estimated gain: High relative to effort — every keystroke on large documents; often one of the cheapest ~5-15% scripting wins. Pros: Tiny, output-identical, no API change. Cons: None meaningful; relies on callers not mutating shared trees unsafely (existing contract). Refs: #13385; plan Phase 1.1; complements #14512 visibleValues work. Co-authored-by: Cursor <cursoragent@cursor.com>
P1.3 — Watch cacheKey instead of deep config churn, keep settled options for the page view, share in-flight GETs, and clear on Inertia navigation. Fixes the common “same relationship field N times = N identical requests” symptom from #13385. Estimated gain: High — often kills most duplicate relationship.index GETs on heavy Replicator/Bard pages (seconds of network on monster entries). Pros: Proven module-level cache pattern; tests cover settle + nav clear. Cons: Mild staleness within a page view (titles edited elsewhere won’t refresh until next navigation). Abort ownership must not cancel a shared in-flight request for sibling fields. Refs: #13385; related #14590 (RelationshipInput in-flight map). Co-authored-by: Cursor <cursoragent@cursor.com>
P1.2 + P3.5 — Return the live values tree when nothing is omittable (hot path), and emit update:visibleValues from the values/hiddenFields watchers instead of deep-watching the computed tree. Estimated gain: High for typing on large docs after clone removal (~10-20% scripting); emission restructure adds a few ms per keystroke (~5-10% more after the hot path). Pros: Makes the shared dependency cheap; explicit emission intent; pairs cleanly with targeted Field conditions (P3.4). Cons: Emission timing/frequency shifts — verify Live Preview, dirty state, and parent publish forms still refresh for every mutation type. Refs: #13385; #14512 (emission restructure + visibleValues work). Co-authored-by: Cursor <cursoragent@cursor.com>
P1.4 — Merge values once per ShowField instance, memoize Validator::getConditions(), hoist regexes, reuse ShowField across Sections/Tabs filter loops, and skip the second non-revealer pass when no revealers are registered. Estimated gain: Medium — ~1-3% load scripting, ~5-15% interaction scripting on condition-heavy blueprints (halves-to-thirds eval work per cycle at N-fields scale). Pros: Pure CPU win; no behavior change when revealers exist. Cons: Memoization assumes field config is static for the evaluation cycle (true today). Revealer short-circuit must stay correct when revealers appear later in the tick. Refs: #13385; plan Phase 1.4. Co-authored-by: Cursor <cursoragent@cursor.com>
P1.5 — Extract a shared dedupeInFlight helper and use it from Assets, Markdown, and Bard Image so identical asset ID lists share one POST. Clone the response before assigning so sibling fields don’t mutate a shared rows array. Estimated gain: Situational — often zero; up to ~0.5-1s when many sets reuse the same assets (second half of the “20 calls” math in #13385). Pros: Same proven pattern as RelationshipInput; one helper, three call sites; in-flight only (no settled staleness). Cons: Low reward when selections differ per field. Shared-promise subscribers must clone; don’t let one unmount abort a shared request. Refs: #13385; related #14590 pattern. Co-authored-by: Cursor <cursoragent@cursor.com>
P1.6 — Extends the #14590 in-flight map so late-mounting fields and selector round-trips reuse just-fetched item data instead of POSTing again. Invalidates on Inertia navigation and selectionsUpdated. Estimated gain: Modest on first load (preload usually covers it); saves a POST per late-added set / selector trip — snappier editing. Pros: Reuses existing cache key + invalidation hooks. Cons: Deliberate semantic change (flips the “fresh after settle” test). Mid-edit server-side renames won’t appear until invalidation. Land after SelectField caching (P1.3). Refs: #13385; #14590. Co-authored-by: Cursor <cursoragent@cursor.com>
P1.7 — Module-level templates cache cleared on Inertia navigation so multiple Template fields don’t each GET api/templates. Estimated gain: Low — one duplicate request per extra template field. Pros: Trivial; same pattern as FieldtypeSelector. Cons: Mild staleness within a page view. Refs: #13385; plan Phase 1.7. Co-authored-by: Cursor <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
Trying this as an end user / on a real siteThis PR mixes PHP and Control Panel JS changes. Composer alone is enough for the server-side wins; the big client-side wins (deferred set mounting, lazy Bard TipTap, request caching) only show up after the CP assets from this branch are built. 1. Point your site at this branchIn a Statamic 6 site: composer config repositories.cms vcs https://github.com/statamic/cms.git
composer require "statamic/cms:dev-bard-replicator-performance as 6.x-dev" --prefer-source
2. Build Control Panel assets from the branchCompiled cd vendor/statamic/cms
npm ci
npm run build
cd ../../..
php please cache:clearHard-refresh the Control Panel (or disable cache) so you’re not staring at an old JS bundle. 3. What to openUse the worst entry you already have:
Open that entry in the CP and compare feel to 4. What you should notice
5. Optional: send us a perf reportIf something is still slow (or to show a before/after), in the CP console: localStorage.setItem('statamic.perf', '1')Reload, open the entry, wait until idle, then: Statamic.$perf.report()
Statamic.$perf.copy('md') // paste into the issue/PRTurn off afterward with More detail: 6. Things to poke for correctness (not just speed)
7. Rollbackcomposer require "statamic/cms:^6.0" # or pin your previous version
php please cache:clearRemove the VCS repository from If you try this on a production-sized entry, please comment with: Statamic version you came from, rough set counts, whether load felt better, and a |
- 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 <cursoragent@cursor.com>
|
This pr is a difference between night and day. Thanks for that. When you install, don't forget to publish. Had me worried for a bit that the changes didn't improve the situation :) Load SpeedLoading the page feels a lot faster as well. Before, fully loaded took about 7.5 seconds, now within 2 seconds. Before I saw a lot of pending files for the Bard field, waiting on the javascript to finish loading. Now that's a lot better. UI SpeedThis is where the major upgrade is and makes an unworkable situation workable.
BugsFound a couple of relatively small bugs. Escape non string error resources/js/bootstrap/globals.js preview text of widget Git patch: line 64 mounting error in reveal PerfI have an error on copy to md, but Claude transformed the console log to md. Statamic $perf ReportGenerated by: Headline metrics: Heat legend:
MOUNT
INTERACT
OTHER
Notes
|
|
Fantastic feedback, thank you! Working on revisions 👍 |
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 <cursoragent@cursor.com>
Preview formatters can pass numbers/objects; early-return instead of calling replaceAll on them. Co-authored-by: Cursor <cursoragent@cursor.com>
Filter/coerce PreviewHtml and formatter output before trim so widget/set previews don't throw. Co-authored-by: Cursor <cursoragent@cursor.com>
Progressive set mounting can race the template ref; skip registration until el exists. Co-authored-by: Cursor <cursoragent@cursor.com>
Console invocations often hit NotAllowedError; fall back to dumping the export and escape markdown table cells. Co-authored-by: Cursor <cursoragent@cursor.com>
CodeQL flagged incomplete escaping — a leading backslash could neutralize the pipe escape. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
@SteJW All of these should be fixed on the branch now:
Grab the latest branch commits, rebuild CP assets + |
|
Thanks, this is working great! This is the actual copy from perf:
|
|
Tested this on a page with 60+ sets and multiple levels of nesting. Huge improvement and haven't run into any errors. I only have the top-level replicator expanded on load, everything else is collapsed by default. Initial server response time is down from ~1100ms to ~770ms. The UI initialises significantly faster, there is almost zero noticeable hydration or reflow as the sets/previews load. 🚀 Two things I did spot, one new and one pre-existing: Expanding SetsDepending on what's in it expanding a set that's collapsed on load now has a small delay, which I assume is expected. This is fine, but the UI has this brief glitch where a bottom border appears before the rest of the fields, causing things below to move twice. Only a very subtle minor thing I know but if would feel a little slicker if it was one step. CleanShot.2026-08-13.at.09.38.55.mp4Dragging SetsThis is pre-existing but related so thought I'd mention it. Dragging sets gets increasingly laggy the more sets there are in the form: CleanShot.2026-08-13.at.09.37.49.mp4Perf Report
|
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
|
Thank you for testing @jacksleight! I've pushed some fixes for those items — let me know what you think! |
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Collapse-on-drag was shrinking sets above the pointer after dragstart, so the ghost and drop indicator floated in empty space. Co-authored-by: Cursor <cursoragent@cursor.com>
Works great! |
Summary
Addresses severe Control Panel publish-form slowness on large Bard/Replicator entries (#13385), building on ideas and measurements from the closed structural PR #14512 and the relationship in-flight caching in #14590.
This PR ships three layers:
Statamic.$perf+ Vitest browser benches so regressions are measurable (seeCONTRIBUTING.mdandbenchmarks/README.md).visibleValuesemission restructuring.Expected impact
visibleValues, targeted watchersFields::newInstancemay cut a large share of row resolution wasteAuto-collapse (≥10 existing sets) is included so deferred mounting engages even when authors leave
collapse: trueoff — without that, Phase 3 is a no-op on many “monster” entries.What’s included
Instrumentation (foundation)
Statamic.$perf/localStorage.statamic.perfUser Timing helpers (report,copy,snapshot,diff, etc.)npm run bench/bench:compare/bench:update-baselinePhase 1 — Frontend quick wins
Values.jsvisibleValueshot path (return live tree when nothing omittable); emitupdate:visibleValuesfromvalues/hiddenFieldswatchers instead of deep-watching the computedSelectFieldoptions cache +cacheKeywatcher + in-flight share; clear on Inertia navrelationship.indexGETsgetConditions, hoist regexes, reuseShowFieldin Sections/Tabs, skip 2nd revealer pass when nonededupeInFlightforassets-fieldtype(Assets, Markdown, Bard Image); clone shared responsesRelationshipInputsettled-response cache (extends #14590)Phase 2 — PHP / server quick wins
preload()calls asset fieldmeta()oncemb_check_encodingfast path beforemb_convert_encoding(..., mb_list_encodings())Fields::newInstance()assigns$itemsdirectly (skip wastedsetItemsresolve)setItemsfor side effects onnewInstancewon’t see them (none in core)linkTypesForToolbar()linkDataForTypeusesgetItemData()(+ preload fallback for custom link types)statamic://link countflattenedSetsConfigBlink key byspl_object_id($field)(fixes singleton reuse bug); auto-collapse when ≥10 existing setscollapseblueprintsPhase 3 — Structural (largest wins)
buildPreviewText,formatPreviewValue,extractBardText,use-preview-text)v-if+hasBeenExpanded/fieldsReady);createMountScheduler; headlessShowFieldfor omitValue; auto-expand sets with errorsensureEditoron visibility/focus/fullscreen); null-editor toolbar guards; skip initgetHTMLField.vueIntentionally deferred / out of scope
HasFieldActions(rejected in #14512)*-bulk-opwork from #14512Fieldtype.vue’spublishContainerfan-out (follow-up after P3.4)getJSON/getHTML/ double-JSON.stringifycosts (instrumented; not rewritten here)Grid::preload()newmeta (#13427-adjacent; needs FE lazy-fetch counterpart)Test plan
Automated
BardTest,ReplicatorTest,FieldsTest,EntriesTest,RelationshipFieldtypeTestSelectField/RelationshipInput, field conditions,createMountScheduler, PublishValues, Assets fieldtype (as exercised during development)vendor/bin/pint --dirtyManual — heavy entry (monster / page-builder Bard)
fieldtypes/relationshipandassets-fieldtypecalls largely gone for shared selectionsvisibleValuesemission changecollapse: truelocalStorage.statamic.perf=1→Statamic.$perf.report()/copy('md')before/afterPerf reporting for reviewers
Related
Made with Cursor