Perf: load window, wallpaper and widget bundles on demand - #613
Conversation
Three PHP functions called wp_enqueue_script() on every registered
native window, wallpaper and widget, on every admin page the shell
rendered. On a stock install that is 1.5 MB of JavaScript (423 KB
gzipped) downloaded and parsed before the user has clicked anything —
for windows most sessions never open, wallpapers most users are not
wearing, and widgets most desktops do not show.
None of it had to happen at boot. The shell reads a window's render
callback off window.openStationNativeWindows[ id ] at OPEN time; a
widget's picker row is entirely PHP metadata; a wallpaper's swatch is
a CSS string already in the payload.
- Window bundles load on first open. The declared <template> paints
immediately and hydrateNative's existing promise handling holds
the window's loading spinner over the fetch.
- Wallpaper bundles load when the wallpaper is applied or the picker
opens. src/wallpapers/lazy.ts registers a metadata stub whose
mount hydrates-and-delegates, so a selection made any other way
still works.
- Widget bundles load when the widget mounts. Never, for a widget
the user has not enabled.
- my-wordpress-woocommerce rides WP Explorer as a companion script
rather than being enqueued on every admin page of a store.
Three new public surfaces, all documented: the `scripts` companion
list, `preload_script` (opt-out for a bundle with a genuine boot-time
job), and wp.os.loadWindowScript( id ). WP Explorer's early stub now
forwards openDetail / openMedia / openUserFootprint / trashEntity
through that loader, so the recycle-bin drag-to-trash path keeps
working with the bundle unloaded.
Fixes #606.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@claude can you review this? is it a hazard for the stability of the plugin? |
|
Claude finished @AllTerrainDeveloper's task in 2m 37s —— View job Review complete
Short answer: no, this doesn't look like a stability hazard. It's a well-executed, mechanical deferral of three symptom-identical registries, and the tricky parts (config/l10n/translation delivery on the lazy path, race-safety, backward compat) all got explicit handling with tests. CI is green (both What I checked
Minor, non-blocking observations
Nothing else stood out — the companion-script ordering, dedup-by-URL, and stub/hydrate patterns are the kind of thing that's easy to get subtly wrong (double loads, stale globals, teardown races) and this PR's tests ( |
`src/shell-overlays/loader.ts` decided whether its lazy bundle was
already in the tab by asking `customElements.get( 'os-confirm-dialog' )`
— a tag that bundle registers. Any bundle can register the same tag,
and one did: `dock-constellation` and `dock-peek` import a one-line
event-name constant from `item-visibility-menu.ts`, a lazy bundle's
ENTRY, which dragged its whole tree — including the dialog component
— into `desktop.min.js`. Three `desktop-files` modules imported
`osConfirm` from the component module rather than the `../os-confirm`
wrapper and did the same.
So the tag was registered at boot, the loader read "already loaded"
before fetching anything, and `shell-overlays[.min].js` was never
requested on any page. Nothing failed. `<os-context-menu>` — which
that bundle registers and nothing else in the shell does — simply
stopped upgrading, and a right-click appended an inert element and
opened no menu.
It went unnoticed because `my-wordpress.min.js` was enqueued on every
admin page and imports the same component. Deferring that bundle took
the menus with it, which is how this surfaced as a regression in the
lazy-loading work rather than as the standing bug it was.
- Readiness is now `window.openStationShellOverlays`, set by the
bundle's entry and by nothing else. Mirrors
`window.openStationWindowSystem`.
- The four leaks are closed: `ITEM_MENU_OPENING_EVENT` moves to a
leaf module (re-exported, so the old import path still works),
the two menu-item modules import `osConfirm` from `../os-confirm`,
and `share-settings-modal` drops an import for a dialog it never
renders. `desktop.min.js` no longer registers `os-confirm-dialog`,
`os-context-menu` or `os-menu`, and drops 7.0 KB raw / 2.1 KB
gzipped.
Tests for both halves. `loader.test.ts` runs in the poisoned state the
setup file creates — every component pre-registered — and asserts the
loader still fetches; `shell-overlays-boundary.test.ts` pins the
readiness flag, forbids `customElements` in the loader, and walks the
import graph from `src/desktop.ts` to hold the main bundle's reach
into the overlay kit to a named allowlist. Reintroducing the tag sniff
fails four of them; reintroducing the entry import fails one more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@claude you were not accurate, I was able to spot by myself regressions. The first I found is the Context Menu Options in the wallapper and icons, those were missing now. Check the last commit, and be more accurate |
|
Claude finished @AllTerrainDeveloper's task in 3m 21s —— View job Re-review of the fix commit — doneYou were right, and thanks for pushing back — my first pass said "no stability hazard" without actually exercising the one place where the lazy-load work broke something real. Here's the accurate version.
What broke, confirmed
Why the fix holds up
I wasn't able to re-run One thing worth a look, not blocking
Bottom lineThe last commit fixes the exact regression you found — the context-menu-goes-inert bug — at its actual root cause (a false-positive readiness check), not just for the item-visibility menu but for every menu gated behind |
Components register per bundle, at import time. After boot the page
has 26 of the 64 tags the plugin ships — whichever ones
`desktop.min.js`, `shell-overlays` and `window-system` imported for
their own UI. `<os-switch>`, `<os-number-field>`, `<os-table>` and 35
others are not among them.
Inside this repo that is fixed with an import. Outside it there was
no fix. `use-from-a-plugin.md` offers `"openstation": "file:../openstation"`,
which is right for a plugin developed beside the shell in one
checkout and unavailable to one that ships: a zip installs onto a
site that has OpenStation somewhere in `wp-content/plugins/`, and
`file:` resolves at install time against a path a contributor's
machine doesn't have. The doc's other two routes are bundling a
second copy of components the page already has, and externalizing
against `window.openStation` — a module namespace that does not
exist. So the honest options were the 26 registered tags, or
hand-rolling.
await wp.os.loadComponents( [ 'os-switch', 'os-number-field' ] );
is the third route. `os-components[.min].js` (309 KB raw / 77 KB
gzip) holds the whole kit and loads on demand; passing tags lets the
call skip the fetch when they are already registered, so calling it
before each render costs a registry lookup. Names that aren't
components are reported and don't stop the rest. The shell never
loads this bundle itself — it is for callers who cannot import.
The overlap with the eagerly-registered subset is deliberate: a lazy
bundle cannot import from `desktop.min.js`, and `defineComponent()`
no-ops on a tag that already exists. It is also unpaid by every page
that never asks.
Readiness is `window.openStationComponents`, a flag the entry sets —
not a tag sniff. A tag answers "will this upgrade?", which is why
the loader uses it to skip fetches; it cannot answer "did my bundle
load?", which is what left the context menus inert in cca0e9a.
Tests: `loader.test.ts` (8) covers no-fetch / fetch / single-flight /
retry-after-failure / unknown names / no-URL / missing flag.
`component-kit-bundle.test.ts` (4) keeps the route wired end to end —
entry pulls the barrel, the target is in `vite.config.js` AND in
`npm run build`, PHP emits the URL, and no component registers a tag
that `OS_COMPONENT_TAGS` is missing.
Docs: `javascript-reference.md` (full entry + index row),
`api-index.md`, `components-reference.md` (the two routes),
`use-from-a-plugin.md` (leads with this for zip-distributed plugins,
and stops recommending an externalize shim against a global that
isn't there), `examples/load-components.md`, `DEVELOPMENT.md`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From an AllTerrain Forms integration report. Each was verified against
the code before being acted on.
`wp.os.registerNativeUrlRemap()` — the remap registry's own docblock
said plugins could hook it "(added later)". They could not: it was
never put on `wp.os`. Without it, a plugin whose native window
duplicates one of its own admin pages had to render a pointer page,
open the native window from inside the iframe, and then close the
window it was itself inside — a visible flash of a window that exists
only to dismiss itself, plus a 4-second retry loop, because the shell
wires a window's iframe to its Window object after the iframe's own
scripts run. The registry lives in a shared store, so the function is
handed out unchanged.
`RelatedEntityItem.windowId` + `params` — related items could only
name a URL, and a native window has no URL. Expressing "open that
window, scoped to this form" meant registering an admin URL for it,
remapping that URL back, and encoding the scoping into a query string
on the way through. `windowId` wins over `url` when both are set and
falls back to it when nothing is registered under the id, so an item
that carries both still opens its page if the window's plugin is
gone. The validator now takes either destination and rejects an item
with neither.
`wp.os.getWindowParams( id )` — `ctx.params` is the right way to read
them and needs a render callback to exist. A window whose body is a
declarative PHP template has none; nor does a module that mounts
after the callback ran, or code reacting to a retarget from outside a
WINDOW_REOPENED subscriber. Their workaround was sessionStorage
written by the handoff. The manager already holds the live copy —
a reopen writes it before the reopen event fires — so this returns a
copy of it and cannot disagree with `ctx.params`.
Also three palette gaps from the same report, all of which produced a
real visual bug:
- `--os-ui-field-bg` / `-border` / `-fg` now exist. There was no
field surface in the palette, so light-DOM markup reached for
`--os-ui-modal-field-bg` — right value, modal scope — and every
input in their builder rendered as a bright rectangle on a dark
theme. The kit's own controls don't read these; they resolve
`--os-window-bg` inside their shadow roots.
- `--os-ui-accent-text` does not exist and the name is
`--os-ui-fg-on-accent`. An undefined custom property yields to the
var() fallback, so the wrong name fails silently. Named in the
token table where a reader will hit it.
- Palette vs component-local is now a documented grep rather than a
guess. Both families resolve at runtime, which is why reading
`--os-ui-badge-warning` in devtools looks like proof and isn't:
it is declared inside <os-badge>, so a theme re-pointing
`--os-ui-warning-fg` leaves those badges behind. The
`--os-ui-badge-*` family straddles the line, which is the example
the docs now use.
Tests: two on the related-item validator (a native destination
resolves; an item naming neither is dropped), three pinning the new
palette values in brand-palette.test.ts.
Docs: javascript-reference.md (two new entries, index rows, the
RelatedEntityItem table, and the Related-menu paragraph that claimed
remaps are deliberately not consulted), api-index.md,
desktop-themes.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #606.
The issue named three bundles. They turned out to be symptoms of one shape repeated three times:
openstation_enqueue_native_window_scripts(),openstation_enqueue_desktop_wallpaper_scripts()andopenstation_enqueue_desktop_widget_scripts()each looped their registry and calledwp_enqueue_script()unconditionally, on every admin page the shell rendered.None of it had to happen at boot. The shell reads a window's render callback off
window.openStationNativeWindows[ id ]at open time; a widget's picker row is built entirely from PHP metadata; a wallpaper's swatch is a CSS string already in the payload. The bundles were being paid for and not used.Bytes transferred — one admin page load, stock install, admin user
Measured on the production build (
npm run build), gzip at level 9. Only OpenStation's own JS; Core's admin scripts are the same either way.What stopped loading at boot
my-wordpress.min.jsposts-window.min.jsplugins-window.min.jsgames.min.jscomments-window.min.jsrecycle-bin.min.jsagent-run-window.min.jscontent-graph.min.jsliving-tree-wallpaper.min.jssnow-wallpaper.min.jsanimated-logo-wallpaper.min.jswidget-drafts.min.jswidget-focus-timer.min.jswidget-notes.min.jswidget-heartbeat.min.jswidget-jazz-quote.min.jswidget-site-views.min.jswidget-post-stats.min.jswidget-recent-comments.min.jswidget-starter.min.jsWhat starts loading at boot
shell-overlays.min.jsTotals
On a store with WooCommerce active,
my-wordpress-woocommerce.min.js(46.3 KB raw / 13.2 KB gzip) also stops loading at boot and rides WP Explorer instead: −1462.8 KB raw (−65.5 %).What still loads at boot is
desktop.min.js,admin-bar.jsandwindow-system.min.js, unchanged in role — plusshell-overlays.min.js, which is new here only in the sense that it now happens at all.The overlays correction
An earlier revision of this description claimed
shell-overlayswas "already injected on demand and not affected". That was true ofwindow-systemand wrong aboutshell-overlays: its loader testedcustomElements.get( 'os-confirm-dialog' )to decide whether the bundle was in the tab, that tag also reacheddesktop.min.jsthrough a leaked import, and so the check answered "already loaded" at boot and the bundle was never fetched on any page — on this branch or on trunk.<os-context-menu>is registered by that bundle and by nothing else in the shell, so right-clicking the wallpaper or a desktop icon appended an inert element and opened no menu. It went unnoticed on trunk becausemy-wordpress.min.jswas enqueued on every admin page and imports the same component. Deferring that bundle took the menus with it, which is how a standing bug surfaced as a regression here.Fixed in
cca0e9a3: readiness is nowwindow.openStationShellOverlays, a flag the bundle's entry sets and nothing else can, mirroringwindow.openStationWindowSystem. So the 86.1 KB above is not a new cost so much as one that was being paid dishonestly — trunk was spending 332.9 KB ofmy-wordpress.min.jsto get a context menu.Cost
desktop.min.jsshrinks: 689 188 → 683 919 bytes raw (−5 269), 203 226 → 201 631 gzipped (−1 595).The stub and forwarding logic this PR adds cost +1 771 raw / +494 gzipped. Closing the four leaks that hid the overlays bug more than paid that back:
dock-constellationanddock-peekwanted one string,ITEM_MENU_OPENING_EVENT, and imported it from a lazy bundle's entry, pulling that entry's tree into main; threedesktop-filesmodules importedosConfirmfrom the component module rather than the../os-confirmwrapper that exists to keep the class out. That is −7 040 bytes of weight trunk was carrying too — a pre-existing bloat fix riding along, not an efficiency this PR earned.Deferred bytes are not saved bytes — a user who opens WP Explorer still downloads it, once, on click, with the window's own spinner covering the fetch. The saving is on everything they don't open, on every page load.
New public surface
All documented, all with tests.
'scripts' => [ $handle, … ]onopenstation_register_window()— companion bundles loaded in order immediately before the window's own script. For code that extends a window from outside it and must be subscribed before its render callback paints.my-wordpress-woocommerceis the in-tree user.'preload_script' => true— opt-out, for a bundle with a genuine boot-time job (a badge poller, awp.osAPI surface). Nothing in-tree needs it.wp.os.loadWindowScript( id )— load a window's bundle without opening the window, for reaching an API the bundle publishes. Deduped by URL; concurrent callers share one<script>.wp.os.myWordpress's early stub now forwardsopenDetail/openMedia/openUserFootprint/trashEntitythrough that loader, so the recycle-bin drag-to-trash path keeps working with the bundle unloaded.registerEntityKindstill queues and drains as before — it is synchronous by contract.Compatibility
Delivery is otherwise unchanged:
wp_localize_script/wp_add_inline_script/wp_set_script_translationsdata is harvested off the registered handle into the boot payload and replayed as inline tags around the injected<script src>, inwp_print_scriptsorder. The'config'arg works on both paths. Thewindow.openStationNativeWindows/openStationWallpapers/openStationWidgetscontracts are untouched.A third-party window bundle that does something at load time other than publish its render callback now does it later.
docs/migration-lazy-window-scripts.mdcovers that case and the two ways out (split the bundle, orpreload_script). Per-tab scripts registered viaopenstation_register_window_tab()stay eager — the shell has no lazy path for them.The native-window enqueue hook moves from
admin_enqueue_scripts:20to:5, becauseopenstation_enqueue_assets()builds the payload at 10 and data attached after that would ship a bundle with no config. Same reason for the WooCommerce config attach.Tests
25 for the deferral:
nativeWindowLazyScript.php(11),native-windows-lazy-script.test.ts(10 — including "reads the render callback after the load" and companion ordering),wallpapers-lazy.test.ts(9),widgets-lazy.test.ts(5), plus two additions tomyWordpressWoocommerce.php.10 more for the overlays bug, one file per half of it:
src/shell-overlays/loader.test.ts(5) — behavioural.tests/vitest/setup.tspre-registers the whole component kit, so these run in exactly the poisoned state that broke production: a registeredos-confirm-dialogmust not stop the loader fetching.tests/vitest/shell-overlays-boundary.test.ts(5) — structural, so the cause can't return either. Pins the readiness flag, forbidscustomElementsanywhere in the loader (no tag can prove which bundle loaded), and walks the import graph fromsrc/desktop.tsto hold the main bundle's reach into the overlay kit to a named allowlist.Reintroducing the tag sniff fails four of them; reintroducing the entry import fails one more, naming the offending file.
Green:
typecheck,lint,lint:php,test:js(4345),test:php(2209).Manual QA
/wp-admin/— none of the bundles in the table above should appear in the network log.shell-overlays.min.jsappears in the network log shortly after first paint. Toasts, confirm dialogs and window-chrome buttons were riding on the same accident, so they're worth a glance too.Follow-ups, not in this PR
registerPalettestill has no server-side registration, so a plugin's palette needs an F5. Same fix shape as commands if it comes up.os-toast,os-button,os-window-button,os-save-status) still reachdesktop.min.jsfrom real render sites in the notes layer, the share-settings modal and the wallpaper settings section. Harmless now that readiness is a flag, but it is weight on every page, and the boundary test's allowlist is where they are named.🤖 Generated with Claude Code