Skip to content

[6.x] CP publish-form performance: Bard, Replicator, and related wins - #15158

Open
jackmcdade wants to merge 32 commits into
6.xfrom
bard-replicator-performance
Open

[6.x] CP publish-form performance: Bard, Replicator, and related wins#15158
jackmcdade wants to merge 32 commits into
6.xfrom
bard-replicator-performance

Conversation

@jackmcdade

Copy link
Copy Markdown
Member

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:

  1. Instrumentation — opt-in Statamic.$perf + Vitest browser benches so regressions are measurable (see CONTRIBUTING.md and benchmarks/README.md).
  2. Phase 1–2 quick wins — JS request/CPU hot paths + PHP preload/render waste that multiply with set count.
  3. Phase 3 structural wins — value-based collapsed previews, deferred mounting of collapsed set bodies, lazy TipTap init, targeted condition watching, and visibleValues emission restructuring.

Expected impact

Area Rough gain
Collapsed heavy pages (headline) Order-of-magnitude load improvement when sets start collapsed (#14512 measured ~1min → ~5s on a real site)
Duplicate relationship/asset GETs Often removes N−1 identical requests per shared selection
Typing / condition-heavy blueprints Meaningful scripting reduction via clone removal, cheap visibleValues, targeted watchers
Server render (Bard/Replicator preload) Tens–hundreds of ms on Bard-heavy pages; Fields::newInstance may cut a large share of row resolution waste

Auto-collapse (≥10 existing sets) is included so deferred mounting engages even when authors leave collapse: true off — without that, Phase 3 is a no-op on many “monster” entries.


What’s included

Instrumentation (foundation)

  • Statamic.$perf / localStorage.statamic.perf User Timing helpers (report, copy, snapshot, diff, etc.)
  • Vitest bench project + npm run bench / bench:compare / bench:update-baseline
  • Contributor docs for attaching perf reports to issues

Phase 1 — Frontend quick wins

Item Change Est. gain Trade-offs
P1.1 Drop redundant full-tree clones in Values.js High / every keystroke Callers must not mutate shared trees unsafely (existing contract)
P1.2 + P3.5 visibleValues hot path (return live tree when nothing omittable); emit update:visibleValues from values/hiddenFields watchers instead of deep-watching the computed High typing scripting; a few ms more from emission fix Emission timing/frequency shifts — verify Live Preview / dirty / parent forms
P1.3 SelectField options cache + cacheKey watcher + in-flight share; clear on Inertia nav High — kills duplicate relationship.index GETs Mild staleness within a page view
P1.4 Cheaper conditions: merge values once, memoize getConditions, hoist regexes, reuse ShowField in Sections/Tabs, skip 2nd revealer pass when none Medium on condition-heavy forms Memo assumes static field config per eval cycle
P1.5 Shared dedupeInFlight for assets-fieldtype (Assets, Markdown, Bard Image); clone shared responses Situational; up to ~0.5–1s when many sets share assets Must clone; don’t abort shared in-flight from one unmount
P1.6 RelationshipInput settled-response cache (extends #14590) Modest first load; snappier late sets / selector trips Deliberate semantic change (test flipped); mid-edit renames need invalidation
P1.7 Template fieldtype list cache per page view Low Mild staleness within page view

Phase 2 — PHP / server quick wins

Item Change Est. gain Trade-offs
P2.1 Bard preload() calls asset field meta() once Tens–hundreds of ms on Bard-heavy pages None
P2.2 mb_check_encoding fast path before mb_convert_encoding(..., mb_list_encodings()) Few ms × N relationship requests Keep slow path for #566
P2.3 Fields::newInstance() assigns $items directly (skip wasted setItems resolve) Speculative 10–30% server render on replicator-heavy entries Addons overriding setItems for side effects on newInstance won’t see them (none in core)
P2.4 Blink-cache linkTypesForToolbar() Hundreds of ms when many Bard blocks share link types Request-scoped only
P2.5 linkDataForType uses getItemData() (+ preload fallback for custom link types) Medium; scales with statamic:// link count Custom link types need the fallback
P2.6 Per-instance item find memo on Entries/Terms/Users Halves lookups; bigger on eloquent Per fieldtype instance, not request-wide
P2.7 + auto-collapse Memoize flattenedSetsConfig Blink key by spl_object_id($field) (fixes singleton reuse bug); auto-collapse when ≥10 existing sets Medium CPU + unlocks Phase 3 on non-collapse blueprints UX: large fields start collapsed by default

Phase 3 — Structural (largest wins)

Item Change Est. gain Trade-offs
P3.1 Value-based collapsed preview text (buildPreviewText, formatPreviewValue, extractBardText, use-preview-text) Enabler for P3.2; also fixes preview bugs Formatter drift vs mounted fieldtypes; addon fallbacks
P3.2 Defer collapsed set bodies (v-if + hasBeenExpanded/fieldsReady); createMountScheduler; headless ShowField for omitValue; auto-expand sets with errors Headline ~5–10× collapsed load; expand-all progressive Highest risk — save payload / conditions / validation / addon mount side effects
P3.3 Lazy TipTap init (ensureEditor on visibility/focus/fullscreen); null-editor toolbar guards; skip init getHTML ~20–100ms × N hidden Bards; helps tabs/offscreen even after P3.2 Addon buttons assuming editor exists after mount; late-init edge cases
P3.4 Watch only condition-referenced handles in Publish Field.vue ~10–30% typing scripting on condition-heavy docs Missed handle extraction → stale visibility / wrong omitValue

Intentionally deferred / out of scope

  • Caching HasFieldActions (rejected in #14512)
  • CSS *-bulk-op work from #14512
  • Narrowing Fieldtype.vue’s publishContainer fan-out (follow-up after P3.4)
  • Bard per-keystroke getJSON / getHTML / double-JSON.stringify costs (instrumented; not rewritten here)
  • Fullscreen still rebuilds the TipTap editor (existing behavior; lazy path covers first init)
  • Eager Grid::preload() new meta (#13427-adjacent; needs FE lazy-fetch counterpart)

Test plan

Automated

  • PHPUnit: BardTest, ReplicatorTest, FieldsTest, EntriesTest, RelationshipFieldtypeTest
  • Vitest: relationship SelectField / RelationshipInput, field conditions, createMountScheduler, PublishValues, Assets fieldtype (as exercised during development)
  • vendor/bin/pint --dirty

Manual — heavy entry (monster / page-builder Bard)

  • Cold load with many collapsed sets: TTI / main-thread time improved; few TipTap editors until expand
  • Network: duplicate fieldtypes/relationship and assets-fieldtype calls largely gone for shared selections
  • Expand one set → fields mount; Expand All → progressive mount, no multi-second freeze
  • Nested Bard inside sets + nested Replicator inside Bard sets still edit/save correctly
  • Conditionals inside collapsed sets: hidden fields omitted from save payload (headless evaluation)
  • Validation errors inside collapsed sets auto-expand and surface
  • Live Preview updates for typing, add/remove/reorder sets, condition toggles, Bard edits
  • Dirty state / revisions still track correctly after visibleValues emission change
  • Fullscreen Bard: editor focuses and edits after portal remount
  • Auto-collapse: field with ≥10 sets starts collapsed even without blueprint collapse: true
  • Addon Bard buttons / mount-time fieldtype side effects smoke-tested if available
  • Optional: localStorage.statamic.perf=1Statamic.$perf.report() / copy('md') before/after

Perf reporting for reviewers

localStorage.setItem('statamic.perf', '1')
// reload, reproduce, then:
Statamic.$perf.report()
Statamic.$perf.copy('md')

Related

  • Closes / progresses #13385
  • Supersedes structural approach from #14512 (re-landed selectively)
  • Builds on #14590 (relationship in-flight map → settled cache + assets dedupe pattern)
  • Encoding fast path preserves #566 fallback

Made with Cursor

jackmcdade and others added 15 commits August 11, 2026 09:26
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>
@jackmcdade

Copy link
Copy Markdown
Member Author

Trying this as an end user / on a real site

This 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 branch

In 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

--prefer-source matters so you get a git checkout under vendor/statamic/cms you can build from.

2. Build Control Panel assets from the branch

Compiled resources/dist is only committed on release tags — this PR’s JS is not in the published dist yet. From your site root:

cd vendor/statamic/cms
npm ci
npm run build
cd ../../..
php please cache:clear

Hard-refresh the Control Panel (or disable cache) so you’re not staring at an old JS bundle.

3. What to open

Use the worst entry you already have:

  • Large Bard page-builder / lots of sets
  • Nested Replicators, nested Bard, relationships, assets inside sets
  • Ideally 10+ sets on a single Bard or Replicator field (this branch auto-collapses those on load so deferred mounting actually kicks in, even if the blueprint doesn’t set collapse: true)

Open that entry in the CP and compare feel to 6.x / your previous version.

4. What you should notice

  • Faster first paint / less freeze on heavy entries when sets start collapsed
  • Fewer duplicate network calls in DevTools (same relationship options / assets-fieldtype payloads shared instead of N identical requests)
  • Expand All should mount progressively instead of locking the tab for ages
  • Collapsed set headers still show preview text (from values, not from mounted fields)
  • Bard editors inside off-screen / collapsed sets shouldn’t all boot TipTap until you expand or scroll to them

5. Optional: send us a perf report

If 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/PR

Turn off afterward with Statamic.$perf.disable() or localStorage.removeItem('statamic.perf').

More detail: CONTRIBUTING.md → Control Panel Performance Reports.

6. Things to poke for correctness (not just speed)

  • Expand a collapsed set → fields appear and are editable
  • Save with conditionals inside never-expanded sets → hidden values still omitted correctly
  • Validation errors inside collapsed sets → set should auto-expand
  • Live Preview still updates while typing / reordering sets
  • Fullscreen Bard still works after enter/exit

7. Rollback

composer require "statamic/cms:^6.0"   # or pin your previous version
php please cache:clear

Remove the VCS repository from composer.json if you added one only for this test.


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 $perf.copy('md') dump if you have one.

- 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>
@SteJW

SteJW commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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 :)
artisan vendor:publish --tag=statamic-cp --force

Load Speed

Loading 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 Speed

This is where the major upgrade is and makes an unworkable situation workable.

  • Before when I added text to the title field, it didn't type through. It took about 500 ms to show a character i typed. Imagine creating a page like that. Now, it just appears right away!
  • The same goes for checkboxes. Before it took quite some time for it to get activated, now it's pretty much right away.
  • The CPU load was excessive on every change for about 500ms-1000ms. Thats gone.
  • The preview is much faster in response as well.
  • The publish and save button before would enable and quickly disable again, which caused not being able to save/publish ([6.x] Improve Bard/Replicator performance on large documents #15057). This is fixed as well.

Bugs

Found a couple of relatively small bugs.
Bug preview
The preview is sometimes refreshing on scroll or expanding. My guess would be the progressively mounting.

Escape non string error
Got an error in console that a non string was being escaped

resources/js/bootstrap/globals.js

export function escapeHtml(string) {
    if (typeof string !== 'string') return string;

     return string
         .replaceAll('&', '&amp;')
         .replaceAll('<', '&lt;')

preview text of widget
Got an error in console that the preview text couldn't be written in the widget tab. Also a string issue.

Git patch:
/resources/js/util/buildPreviewText.js
line 49

                 if (Array.isArray(value)) return escapeHtml(value.join(', '));
                 return escapeHtml(String(value));
             })
            .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 || {});

line 64

             })
             .map((field) => formatPreviewValue(values?.[field.handle], field, { escape: true }))
             .filter((value) => typeof value === 'string' && value.trim() !== '');
     }
 
     return previewValues.join(separator);

mounting error in reveal
The reveal has a bug when an "el" is still null. I fixed it like this, but it could also have to do with the progressively loading widgets?
resources/js/components/Reveal.js
line 8

     }
 
     mount(el, callback) {
         if (!el) return;

         registry.set(el, callback);
 
         onBeforeUnmount(() => registry.delete(el));

Perf

I have an error on copy to md, but Claude transformed the console log to md.

Statamic $perf Report

Generated by: Statamic.$perf.report()
Phases: mount → save → interact
(≥1000ms shown as s)

Headline metrics: phase.mount (initial render) · phase.save (full save pipeline)

Heat legend:

Heat Threshold
● critical mean ≥ 16ms or p95 ≥ 32ms
● hot mean ≥ 8ms or p95 ≥ 16ms
● warm mean ≥ 2ms or total ≥ 100ms
● ok below warm thresholds
● count tally only, no timing

MOUNT

Heat Metric Count Total Mean p95 Max
hot phase.mount 1 375.7ms 375.7ms 375.7ms 375.7ms
critical mount.browser.longtask 2 290ms 145ms 158ms 158ms
ok mount.publish.container.watch.visibleValues 2 1.1ms 0.55ms 1ms 1ms

INTERACT

Heat Metric Count Total Mean p95 Max
critical interact.browser.longtask 3 263ms 87.667ms 125ms 125ms
critical interact.bard.mount 2 176.2ms 88.1ms 116.4ms 116.4ms
critical interact.replicator.mount 2 142.4ms 71.2ms 72.5ms 72.5ms
ok interact.publish.container.watch.values 2 2.5ms 1.25ms 1.3ms 1.3ms
ok interact.bard.set.syncAttributes 2 1.3ms 0.65ms 1.2ms 1.2ms
ok interact.bard.set.stringify 2 1.2ms 0.6ms 1.2ms 1.2ms
ok interact.publish.container.watch.visibleValues 1 1ms 1ms 1ms 1ms
ok interact.bard.value.sync 2 0.8ms 0.4ms 0.8ms 0.8ms
ok interact.bard.json.stringify 2 0.7ms 0.35ms 0.7ms 0.7ms
ok interact.replicator.previewText 4 0.4ms 0.1ms 0.2ms 0.2ms
count interact.bard.json.updateDebounced 2
count interact.fieldtype.update.bard 2
count interact.livePreview.watch 2
count interact.publish.container.watch.localizedFields 1

OTHER

Heat Metric Count
count phase.enter.interact 1
count phase.enter.mount 1

Notes

  • No save phase data was captured in this run (only mount and interact).
  • Four metrics are flagged critical: mount.browser.longtask, interact.browser.longtask, interact.bard.mount, and interact.replicator.mount — these are the main contributors to perceived slowness.
  • phase.mount itself (375.7ms) is flagged hot, largely explained by the 290ms of browser long tasks during mount.
  • Export helpers available in console: Statamic.$perf.copy() (tsv), .copy("md"), .copy("json"), .download().
  • Snapshot/diff helper: const before = Statamic.$perf.snapshot("before"); /* later */ Statamic.$perf.diff(before).

@jackmcdade

Copy link
Copy Markdown
Member Author

Fantastic feedback, thank you! Working on revisions 👍

jackmcdade and others added 5 commits August 12, 2026 09:14
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>
Comment thread resources/js/util/perf.js Fixed
jackmcdade and others added 2 commits August 12, 2026 09:16
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>
@jackmcdade

Copy link
Copy Markdown
Member Author

@SteJW All of these should be fixed on the branch now:

  • Live Preview refreshing on scroll/expand — lazy TipTap was seeding slightly-normalized JSON into values; deferred mounted again + skip identical LP watch payloads
  • escapeHtml non-string — early return for non-strings
    Preview text / widget trim error — coerce/filter non-string preview HTML before .trim()
  • Reveal.mount null el — guard when the ref isn’t ready yet during progressive mount
  • $perf.copy('md') — clipboard often denies writes from the console; falls back to dumping the markdown, plus safer table escaping

Grab the latest branch commits, rebuild CP assets + vendor:publish --tag=statamic-cp --force, and give it another spin if you can. I appreciated the patches!

@SteJW

SteJW commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thanks, this is working great!

This is the actual copy from perf:

phase name heat count total (ms) mean (ms) p95 (ms) max (ms)
mount phase.mount hot 1 368.8 368.8 368.8 368.8
mount mount.browser.longtask critical 2 291 145.5 164 164
mount mount.publish.container.watch.visibleValues warm 2 4.1 2.05 4 4
interact interact.browser.event.pointerenter critical 26 4784 184 184 184
interact interact.browser.longtask critical 2 217 108.5 142 142
interact interact.replicator.mount critical 3 212.4 70.8 75.1 75.1
interact interact.browser.event.mouseover critical 1 184 184 184 184
interact interact.browser.event.pointerover critical 1 184 184 184 184
interact interact.bard.mount critical 2 146.6 73.3 93.1 93.1
interact interact.publish.container.watch.visibleValues warm 1 3.1 3.1 3.1 3.1
interact interact.replicator.previewText ok 6 0.7 0.117 0.3 0.3
other phase.enter.interact count 1 0 0 0 0
other phase.enter.mount count 1 0 0 0 0

@jacksleight

jacksleight commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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 Sets

Depending 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.mp4

Dragging Sets

This 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.mp4

Perf Report

phase name heat count total (ms) mean (ms) p95 (ms) max (ms)
mount phase.mount hot 1 847.9 847.9 847.9 847.9
mount mount.replicator.mount critical 7 1195 170.714 242.4 242.4
mount mount.browser.longtask critical 2 499 249.5 386 386
mount mount.publish.container.watch.visibleValues ok 3 1.8 0.6 0.9 0.9
interact interact.browser.event.pointerenter critical 41 13104 319.61 712 712
interact interact.browser.event.pointerleave critical 45 11488 255.289 680 680
interact interact.browser.event.mouseout critical 21 6416 305.524 680 712
interact interact.browser.event.mouseover critical 21 6416 305.524 680 712
interact interact.browser.event.pointerout critical 21 6416 305.524 680 712
interact interact.browser.event.pointerover critical 21 6416 305.524 680 712
interact interact.browser.longtask critical 10 980 98 343 343
interact interact.browser.event.mousedown critical 1 168 168 168 168
interact interact.browser.event.pointerdown critical 1 168 168 168 168
interact interact.replicator.sorted warm 1 3.4 3.4 3.4 3.4
interact interact.publish.container.watch.values ok 1 1 1 1 1
interact interact.fieldtype.update.replicator count 1 0 0 0 0
interact interact.livePreview.watch count 1 0 0 0 0
other phase.enter.interact count 1 0 0 0 0
other phase.enter.mount count 1 0 0 0 0

jackmcdade and others added 4 commits August 13, 2026 08:20
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>
@jackmcdade

Copy link
Copy Markdown
Member Author

Thank you for testing @jacksleight! I've pushed some fixes for those items — let me know what you think!

jackmcdade and others added 5 commits August 13, 2026 11:18
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>
@jacksleight

Copy link
Copy Markdown
Contributor

Thank you for testing @jacksleight! I've pushed some fixes for those items — let me know what you think!

Works great!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance for large Replicators, Relationships and Conditional fields

5 participants