Skip to content

[6.x] Fix Utility pages not rendering - #19340

Open
brianjhanson wants to merge 5 commits into
6.xfrom
worktree-utility-pages-fix
Open

[6.x] Fix Utility pages not rendering#19340
brianjhanson wants to merge 5 commits into
6.xfrom
worktree-utility-pages-fix

Conversation

@brianjhanson

@brianjhanson brianjhanson commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Description

Utility pages rendered blank, with $ is not defined, window.Cp.config is not a function, and a second $ is not defined from the page's ready-JS in the console. Two independent defects, plus a jQuery-removal cleanup they opened up.

resources/js/modules/slideout/slideout.ts built its shared live region with $('<span …>') at module scope. cp.ts imports the module on every Inertia CP page, so wherever jQuery isn't already on the page the import throws, aborting the CP bootstrap before window.Cp = Cp runs — which is why window.Cp.config was missing and the Inertia app never mounted. The region is now built with document.createElement, the same way the ported Garnish Modal builds its own.

UtilitiesController::show() called headHtml()/bodyHtml(), which clear the registry as they render. That drained not just the utility's own registrations but the global CP asset bundle — jQuery, Garnish, legacy cp.js, the window.Craft config — that HandleInertiaRequests registers for every CP request, into page props that utilities/Show.vue doesn't read. app.blade.php was left with an effectively empty <head> (571 bytes on /utilities/* vs ~22KB on /dashboard), so the legacy stack never loaded and the ready-JS wrapper hit an undefined $. It now uses HtmlStack::capture() so only the utility's own assets reach the props, where AppLayout's existing useAppendHtml() applies them — on the initial render and on subsequent Inertia visits, which never re-render the root template.

Craft.cp.announce() reached into $activeLiveRegion with [0], .empty() and .text(), which forced every component feeding the announcer to keep handing it jQuery — Slideout.$liveRegion included. It now resolves the reference through a getLiveRegionElement() helper and assigns textContent, accepting either a jQuery collection (the jQuery-era modals and $globalLiveRegion) or a plain element. That lets Slideout.$liveRegion become the shared element itself, appended with appendChild, which still moves rather than clones it.

Verification

Reverted both fixes and reproduced all three console errors verbatim against a local build, then confirmed they're gone and the page renders fully. All 9 utility pages carry the complete asset stack.

For the announcer, exercised both live-region shapes against both implementations:

jQuery collection plain element
old announce() announces warns, announces nothing
new announce() announces announces

A real Craft.Slideout now exposes $liveRegion as an HTMLElement, still renders as <span class="visually-hidden" role="status"></span>, still moves as a single shared node between containers, and announces correctly.

tests/Feature/Integration/UtilityPageAssetsTest.php pins the controller regression — it fails 3/4 without that change. Garnish (351) and root (41) Vitest suites pass; vue-tsc is clean.

Note: the legacy CP bundle could not be rebuilt cleanly in my environment — npm run build:bundles fails loading inputmask/webpack.config.js (pkgDir.sync is not a function), and building the cp bundle alone fails on ./js/FieldToggle.js importing tailwindcss/src/value-parser. Both are pre-existing and unrelated to this diff, but they mean the CP.js change was verified by running its exact logic in the browser rather than through a fully-built bundle.

🤖 Generated with Claude Code

brianjhanson and others added 2 commits July 29, 2026 10:55
Two independent defects left every Utility page blank.

`slideout.ts` built its shared live region with `$(...)` at module scope.
`cp.ts` imports the module on every Inertia CP page, so on any page where
jQuery isn't already loaded the import throws, aborting the CP bootstrap
before `window.Cp = Cp` runs — hence `window.Cp.config is not a function`
and an Inertia app that never mounts. The region is now built on first use.

`UtilitiesController::show()` called `headHtml()`/`bodyHtml()`, which clear
the registry as they render. That drained not just the utility's own
registrations but the global CP asset bundle (jQuery, Garnish, legacy
cp.js, the `window.Craft` config) that `HandleInertiaRequests` registers
for every CP request, into page props. `app.blade.php` was left with an
empty `<head>`, so the legacy stack never loaded. It now uses
`HtmlStack::capture()` so only the utility's own assets go to the props,
where `AppLayout`'s `useAppendHtml()` already applies them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/uiopen Storybook

No changed components detected in this Storybook.

resources/jsopen Storybook

No changed components detected in this Storybook.

brianjhanson and others added 3 commits July 29, 2026 11:05
Replaces the lazy `$('<span …>')` getter with a plain `document.createElement`
at module scope, matching how the modern Garnish `Modal` port builds its own
live region. This removes jQuery from module evaluation outright rather than
deferring the call, which is what actually kept `cp.ts` from booting on pages
without the legacy bundle.

`init` still wraps the shared node with `$()` before assigning `$liveRegion`:
`CP.js`'s announcer reads that property off the instance and calls `.empty()`,
`.text()` and `[0]` on it, so the exposed value has to stay a jQuery
collection. Wrapping the same node also preserves the moves-not-clones
semantic — `appendTo` still relocates the single shared element.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes the last thing forcing `Slideout.$liveRegion` to be a jQuery
collection, so it can be a plain element like the ported Garnish `Modal`'s.

`announce()` and `handleLayerUpdates()` reached into `$activeLiveRegion` with
`[0]`, `.empty()` and `.text()`, which meant every component feeding the
announcer had to keep handing it jQuery. Both now resolve the reference
through `getLiveRegionElement()` and set `textContent`, accepting either a
jQuery collection (the jQuery-era modals and `$globalLiveRegion`) or a plain
element. `textContent = message` replaces `.empty().text(message)` — it clears
and sets in one step.

`Slideout.$liveRegion` is now the shared element itself, appended with
`appendChild`, which still moves rather than clones it.

Verified in the browser against both shapes: the old announcer silently warns
and announces nothing when given an element, while the new one announces
through both it and a jQuery collection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brianjhanson
brianjhanson marked this pull request as ready for review July 29, 2026 18:46
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.

1 participant