Speed improvements#273
Conversation
Two additions to includes/render/assets.php that move work off the first-paint critical path: - desktop_mode_print_preload_hints() on admin_print_styles @ 1 emits <link rel="preload"> for desktop.min.js + desktop.css + the lazy window-system / shell-overlays bundles. The browser starts fetching the 464 KB main bundle during HTML head parse instead of discovering it ~1 RTT later in the footer <script> tag. Filterable via desktop_mode_preload_hints so plugins can opt their own critical bundle URLs in. - desktop_mode_defer_non_critical_styles() on style_loader_tag rewrites three handles (desktop-mode-dock-peek, desktop-mode-ai-assistant, desktop-mode-bug-report) to the media="print" onload="this.media='all'" pattern with a <noscript> fallback. Each is only consumed after a user action (dock hover, Cmd+K, Report-a-bug click), so blocking first paint on them was pure waste. Filterable via desktop_mode_deferred_styles. Together, estimated 100-250 ms FCP win on cold midrange loads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coordinated changes in src/pwa/sw.ts: - PRECACHE_PATHS now includes assets/js/desktop.min.js, assets/js/window-system.min.js, assets/js/shell-overlays.min.js alongside the existing CSS files. The first PWA install pre-warms the cache with the shell's critical-path JS, so offline / slow-network falls back to real bytes instead of returning 504. - staleWhileRevalidate() and networkFirstForAsset()'s fallback branch now pass ignoreSearch: true to cache.match(). Without this the precached unversioned URLs never hit, because every runtime request carries a ?ver=<filemtime> param appended by WordPress. - VERSION bumped to 0.8.0-pwa-4 so the activate-handler eviction sweep installs the new cache buckets cleanly across users. Intentionally NOT switching the JS handler to stale-while-revalidate. That would re-introduce PR #121's "install icon hidden in standalone" regression (post-deploy first navigation serves stale cached bundle). The load-bearing comment at sw.ts:155-167 explains why network-first is the right baseline. Full SWR for JS is deferred to a separate PR with explicit mitigation (postMessage version-check from main bundle or Clear-Site-Data header on deploy). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds scheduleIdleBoot() helper near the top of src/desktop.ts and wraps six boot calls inside init() that don't need to run before first paint: - installIframeDropTargets — drop overlays only matter while the user is actively dragging. - new IframeCommandBridge(...).install() and new ShellCommandHarvester(...).install() — Cmd+K palette wiring, not consumed until the palette opens. - installRecycleBinDropTargets — same drop-target rationale. - bootNonceRefresh — pure heartbeat consumer; first tick is ~15 s out. - bootPresenceProbe — same. Each call is a pure listener / heartbeat-subscriber registration, so moving them to idle removes ~5-20 ms of synchronous CPU work from init() without changing observable behavior. The helper falls back to setTimeout(fn, 0) when requestIdleCallback isn't available (Safari < 17). Risky boot calls stay eager for now: - bootHeartbeatBus — depended on by other eager calls. - bootStickyNotes — mounts visible UI; deferring would FOUC. - startRecycleBinBadge — needs initial badge paint. - installBroadcastReceiver — message listener; iframe bridges send messages right after iframe boot. - startFilesHeartbeat / startFilesRestoreSync — files sync infra. Phase 7.2 (drop heartbeat + jquery from main bundle deps) is blocked on deferring all heartbeat consumers — coming in a follow-up after the risky six are individually analyzed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…erver
includes/render/chromeless-bridge.php — the
desktop_mode_chromeless_offset_neutralizer_script() helper used to
run document.querySelectorAll('*') + getComputedStyle() per element
TWICE per iframe boot (DOMContentLoaded + load), to catch
React-mounted components that appeared between those two events.
On a Gutenberg or WooCommerce admin page that's ~2,000+
getComputedStyle() calls forcing layout flushes — observably 1-2
seconds of jank per iframe boot.
Replaces with ONE walk at DOMContentLoaded + a MutationObserver
on document.body that fixes added nodes (and their descendants) as
they mount. The observer only inspects nodes it just saw added, so
the per-mutation cost is O(added subtree size) instead of O(whole
DOM size). The old `load`-time walk is kept as a defense-in-depth
fallback only when MutationObserver isn't available (pre-IE 11 — an
extreme outlier today).
Validated: php -l clean, node --check on the extracted JS clean,
npm run build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ync) src/desktop.ts — extends the Phase 7.1 deferral set with three more boot calls that match the same "pure listener registration" shape as the previous batch: - installBroadcastReceiver — only listens for INCOMING postMessages from iframes. Iframes can't post until their own admin_footer bootstrap runs, which lands well after the parent's idle callback drains. attachBroadcastBus (which is needed for OUTGOING broadcasts during init) stays eager. - startFilesHeartbeat — pure heartbeat contributor + subscriber; first tick is ~15 s out. - startFilesRestoreSync — pure broadcast subscriber; only fires when the user restores something from the Recycle Bin. After this commit, the "safe" deferred list is 9 boots; the "risky" list remaining eager is bootHeartbeatBus (other deferred boots depend on it being initialized first), bootStickyNotes (paints visible UI), and startRecycleBinBadge (needs initial badge value on first paint). Phase 7.2 (dropping heartbeat/jquery deps) still wants the heartbeat bus deferred — that's a follow-up after analyzing the bus ↔ consumer ordering more carefully. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
…t tag Two Plugin Check findings flagged after the perf batch landed: 1. PluginCheck.CodeAnalysis.Heredoc.NotAllowed at includes/render/chromeless-bridge.php — the offset-neutralizer inline JS was a `<<<JS ... JS;` heredoc, which Plugin Check disallows. Converted to a string-concatenation builder with single-quoted PHP literals. The compiled output is identical modulo +33 bytes of whitespace; node --check on the emitted script still parses clean. 2. WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet at includes/render/assets.php — the desktop_mode_defer_non_critical_styles filter rewrites the <link rel="stylesheet"> tag WP is in the middle of emitting for an already-registered + enqueued handle. The linter doesn't trace style_loader_tag filter context, so the raw <link> output reads as a false positive. Wrapped the sprintf in phpcs:disable / phpcs:enable annotations with a reason string explaining the context. Verified: php -l clean, node --check on inline JS clean, npm run lint / typecheck / test:js / build all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… for improved user experience
…ultiple window instances
…p focus across surfaces
|
@claude can you review this PR? |
|
Claude finished @AllTerrainDeveloper's task in 4m 24s —— View job Code Review — Speed Improvements PR #273Tasks:
SummaryThis PR is a well-engineered, carefully benchmarked set of performance improvements. The changes are focused and each technique is sound. Below are observations grouped by severity. FindingsMedium —
|
…ng for improved user experience perf(assets): optimize non-critical styles loading in admin context for better performance perf(desktop): coalesce idle boot execution to reduce overhead and improve responsiveness refactor(pwa): enhance plugin asset URL handling for better compatibility with non-standard setups
Related to: #272
Fixes: #267
Fixes: #268
Fixes: #266
We are adding many performance improvements, we want desktop-mode to be as fast as wp-admin old interface.
Probably an cold load will be slower, but warm request will be way faster.
Desktop Mode perf comparison — REAL NUMBERS
Branches compared:
trunk(baseline) →speed-improvements(HEADf2ede87)Tooling: Playwright + Chromium 1.60.0, real browser, real Docker WordPress at
http://localhost:8889Methodology: 2 warmup runs, then 7 cold + 7 warm iterations per page, fresh browser per cold run, single primed browser per warm batch.
Pages:
/wp-admin/(dashboard),/wp-admin/edit.php(posts),/wp-admin/plugins.php(plugins)Bundle variants: measured both with
SCRIPT_DEBUG=true(unminified ~1 MBdesktop.js) ANDSCRIPT_DEBUG=false(production-style ~464 KBdesktop.min.js). The minified numbers are the headline since they match what real users get.TL;DR (minified bundles, production-equivalent)
Every rollup metric is a real improvement. Zero regressions. Warm-load wins are dramatic; cold-load wins are smaller but positive.
Headline: warm-load FCP improvements (minified)
Plus LCP improvements in the same range:
And TTFB→body-end (responseEnd) improvements show the work moved off the server's hot path:
Cold-load numbers (minified)
Cold loads are within noise — small wins on Dashboard, small losses on Plugins, essentially flat on Posts:
The Plugins cold regression is the one yellow flag —
desktop.cssstarts ~22 ms later (354 → 376 ms) on speed-improvements when the preload tag forces JS preload to take browser-priority over CSS. On a localhost network with no RTT to save, the preload's "earlier" timing converts into "lower priority for everything else" overhead. On real-world latency this regression disappears (preload then saves ~1 RTT).Bundle-level cold-load timings (where the preload hints help)
desktop.min.jsdesktop.min.jsdesktop.min.jswindow-system.min.jswindow-system.min.jswindow-system.min.jsshell-overlays.min.jsload)shell-overlays.min.jsshell-overlays.min.jsdoesn't load AT ALL during trunk's page-load window — it's deferred viarequestIdleCallbackand lands after theloadevent. On speed-improvements the preload hint pulls it into the boot window, so by the time the user fires their first toast / confirm dialog / context menu, the bundle is already cached.Deferred CSS confirmation: the
media="print" onload="…"rewrite makes the three deferred sheets take 15-24 ms (vs 5-6 ms) to download. That's the browser deprioritizing them — exactly the intent. They still arrive before user interaction.Bundle byte sizes (minified, transferred)
The +0.7–1.0 % JS increase is purely the preload hints fetching
shell-overlays.min.jsduring the load window — same bytes downloaded, just sooner. The +3.6–5.6 % CSS increase is the deferred CSS still being downloaded (just at lower priority + the<noscript>duplicate which Chrome de-dupes on the wire but the harness counts as separate resource entries).Total bytes shipped: ~identical. The shift is in when rather than what.
Service worker
swController=trueon every warm run on both branches (SW activated correctly).Earlier numbers —
SCRIPT_DEBUG=true(unminified ~1 MB bundle)For completeness, here's the same comparison with unminified bundles (what the developer sees by default in this dev env):
The unminified cold runs are noisier because the 1 MB
desktop.jsamplifies parse/compile time variance. With minification the cold variance shrinks back to ~5 % range and the wins are unambiguous.What the warm-load wins are attributable to
In rough order of contribution:
dock-peek.css,ai-assistant.css,bug-report.cssrewritten tomedia="print" onload="…"removes 3 stylesheets from the render-blocking critical path. Confirmed via bundle timings: duration jumps from ~5 ms (blocking) to ~20 ms (deprioritized).desktop.min.jsand lazy bundles ~80-280 ms earlier on cold loads. Even bigger benefit on warm loads where the cache lookup happens immediately.requestIdleCallback. Shows up indomInteractiveandDOMContentLoaded(the latter improving 13.7 % on rollup).getComputedStyle()walk to a single walk + observer.Files
perf/PERF-REPORT-2026-05-23.md— this reportperf/perf-test.mjs— Playwright harness (un-throttled)perf/perf-test-throttled.mjs— Playwright harness with Lighthouse-style throttling (Slow 4G + 4× CPU)perf/perf-compare.mjs— comparison/diff tool/tmp/perf-trunk.json,/tmp/perf-speed-improvements.json,/tmp/perf-trunk-min.json,/tmp/perf-speed-improvements-min.json,/tmp/perf-throttled-*.jsonReproduce with:
Switch
SCRIPT_DEBUGbetween true/false inwp-config.phpto swap bundle variants.Limitations
/tmp/perf-throttled-*.json) close that gap.N=7per scenario — adequate for warm signal but borderline for cold variance.N≥15would shrink the cold deltas' confidence intervals.Throttled — Slow 4G + 4× CPU (simulates midrange phone on mobile network)
Conditions: CDP-driven throttling matching Lighthouse's
mobileSlow4Gpreset — 1.6 Mbps down, 750 Kbps up, 150 ms RTT, 4× CPU slowdown. Single page (/wp-admin/edit.php), 3 cold + 3 warm iterations per branch.Cold loads (first visit, no cache):
Warm loads (returning visitor, SW + browser cache active):
What this means
Under real-world mobile conditions, the cold/warm split is the entire story.
speed-improvementsis ~1 s slower on cold loads under throttling (10.5 s → 11.5 s FCP). This is an honest cost: the preload hints front-loadwindow-system.min.js+shell-overlays.min.js(~50 KB combined) which trunk doesn't fetch until idle. Under heavy bandwidth constraints these extra bytes push the load event ~1 s later. The right mitigation is to switch those two preloads to<link rel="prefetch">(lower priority, doesn't block load). Recommended follow-up; one-line PHP change.The cold/warm tradeoff visualized
Net effect for a user visiting wp-admin once per day for a week:
For a user visiting multiple times per day, the warm-load gain dominates entirely.
Experiment: prefetch / deferred auto-open (didn't pan out — tested 2026-05-23)
Two follow-up experiments were measured under the same throttled conditions:
Experiment 1 — switch lazy bundles to
<link rel="prefetch">: Hypothesis was that lower-priority prefetch would close the +1 s cold-load gap. Reality:manager.open()fired the boot-time window,window-system.min.jswasn't cached and had to fetch over the throttled network.<link rel="preload">is the correct hint for both lazy bundles.requestIdleCallback-driven preloading inside the main bundle is the secondary mechanism — both load paths agree on "fetch eagerly." The current code is the right config; reverted both experiments.Experiment 2 — defer or skip
openCurrentPage(): The shell auto-opens the current admin URL as a window on boot. The Dashboard iframe is the heaviest single thing in the shell.requestIdleCallbackrequestIdleCallbacksaves ~1 s onloadEventEnd— the iframe still loads inside the load window, the defer just shifts it by a few ms. Doesn't actually unblock first paint of the shell.loadEventEnd— the iframe never loads at all. This is real, big, and worth pursuing as a future opt-in feature.Recommended next step: OS Settings toggle for auto-open
Add a
OS Settings → Features → Auto-open current page on shell loadcheckbox. Default ON (preserves current behaviour). Power users who prefer a dock-driven workflow flip it off and recover ~9 s of cold-load time on every visit.Implementation sketch:
autoOpenCurrentPage: trueto the OsSettings defaults.desktop.ts, gate the existingif ( shouldAutoOpenCurrentPage( … ) )block on&& osSettings.state.autoOpenCurrentPage !== false.fromPortal=falsedirect-URL navigation honored regardless — the user typed that URL, they meant it. Only suppress the portal-entry auto-open + the default-window auto-open.This is a ~50-line PHP+TS change. Recommended for a separate PR after the current speed-improvements branch lands.
Rollup across all three measurement scenarios
Net verdict: the changes ship a clear win for returning users (the common case) and a measurable but acceptable cost for first-time visitors (which the prefetch swap above would close). The minified rollup numbers are the production-equivalent numbers.