fix(10.1): WR-01 keep a late schema response from restyling the current view

loadControllerAssets no longer activates stylesheets. activateStyles,
called only by the router, records the controller on screen, and new
links are created disabled unless they belong to it, so a list or form
schema that resolves after its view was left adds its links disabled
instead of switching plugin CSS to the wrong controller. Rebuilt the
embedded admin dist.
This commit is contained in:
Jakub Zych
2026-09-29 09:50:05 +02:00
parent 849a9ffe3f
commit 5bbb0ada05
5 changed files with 73 additions and 9 deletions

View File

@@ -6,6 +6,8 @@
// Stylesheet links belong to one controller: the router disables them on
// every navigation to another controller or to a screen that is not a
// controller view, so plugin CSS never styles a view it was not written for.
// Only the router picks the enabled controller; a schema response that
// arrives after its view was left adds its links disabled.
// Files load only through script and link elements; nothing here fetches.
import type { ControllerAssets } from '../api/types'
import { runtime } from './runtime'
@@ -17,6 +19,8 @@ const scripts = new Map<string, Promise<void>>()
// Links by controller and URL: two controllers of one plugin may share a
// file, and each keeps its own link so disabling one never hides the other.
const styles = new Map<string, { owner: string; link: HTMLLinkElement }>()
// The controller whose links are enabled; '' enables none.
let active = ''
/** The URL prefix every plugin asset must start with. */
export function assetPrefix(): string {
@@ -86,21 +90,29 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void
const link = document.createElement('link')
link.rel = 'stylesheet'
link.setAttribute(OWNER_ATTRIBUTE, controllerId)
link.disabled = controllerId !== active
link.href = url
styles.set(key, { owner: controllerId, link })
document.head.appendChild(link)
}
}
/** Enables this controller's stylesheet links and disables every other one. */
/**
* Enables this controller's stylesheet links, now and as they are added,
* and disables every other one; '' disables them all. The router calls it
* on every navigation.
*/
export function activateStyles(controllerId: string): void {
active = controllerId
for (const { owner, link } of styles.values()) {
link.disabled = owner !== controllerId
link.disabled = owner !== active
}
}
/**
* Activates the controller's styles and loads its files. The promise
* Adds the controller's stylesheet links and loads its scripts. The links
* are enabled only while the router shows that controller, so a response
* for a view already left never restyles the current one. The promise
* resolves once every script has loaded or failed, with the URLs that
* failed; it never rejects. Views start this without awaiting it.
*/
@@ -109,7 +121,6 @@ export async function loadControllerAssets(
assets: ControllerAssets | null | undefined,
): Promise<string[]> {
loadStyles(controllerId, assets?.styles ?? [])
activateStyles(controllerId)
const urls = assets?.scripts ?? []
const results = await Promise.allSettled(urls.map((url) => loadScript(url)))
return urls.filter((_, index) => results[index]?.status === 'rejected')

View File

@@ -165,8 +165,9 @@ describe('controller stylesheets', () => {
})
describe('loadControllerAssets', () => {
it('creates and activates the styles, then resolves once the scripts have loaded', async () => {
it('creates the styles of the active controller enabled, then resolves once the scripts have loaded', async () => {
loadStyles('acme.demo.other', [asset('css/other.css')])
activateStyles('acme.demo.c1')
const js = asset('js/c1.js')
let settled = false
const done = loadControllerAssets('acme.demo.c1', { scripts: [js], styles: [asset('css/c1.css')] }).then((failed) => {
@@ -184,6 +185,25 @@ describe('loadControllerAssets', () => {
expect(settled).toBe(true)
})
it('never enables a controller the router has left (a late schema response)', async () => {
activateStyles('acme.demo.late-b')
await loadControllerAssets('acme.demo.late-b', { scripts: [], styles: [asset('css/late-b.css')] })
expect(links('acme.demo.late-b').map((link) => link.disabled)).toEqual([false])
// A's schema resolves after the router moved on to B.
await loadControllerAssets('acme.demo.late-a', { scripts: [], styles: [asset('css/late-a.css')] })
expect(links('acme.demo.late-a').map((link) => link.disabled)).toEqual([true])
expect(links('acme.demo.late-b').map((link) => link.disabled)).toEqual([false])
// Links added while no controller is active stay disabled until one is.
activateStyles('')
loadStyles('acme.demo.late-c', [asset('css/late-c.css')])
expect(links('acme.demo.late-c').map((link) => link.disabled)).toEqual([true])
activateStyles('acme.demo.late-c')
expect(links('acme.demo.late-c').map((link) => link.disabled)).toEqual([false])
expect([...links('acme.demo.late-a'), ...links('acme.demo.late-b')].every((link) => link.disabled)).toBe(true)
})
it('resolves with the scripts that failed or were refused and never rejects', async () => {
const ok = asset('js/c2-ok.js')
const bad = asset('js/c2-bad.js')

View File

@@ -636,4 +636,37 @@ describe('controller stylesheets (D-14, UI-SPEC S6)', () => {
expect(link(gadgetsCss)!.disabled).toBe(true)
expect(seen.links.filter((item) => item.getAttribute('href') === widgetsCss)).toHaveLength(1)
})
it('keeps a late schema response of a view already left from restyling the current one (WR-01)', async () => {
const seen = stubAssets()
const gadgets = `${API}/acme/demo/gadgets`
const lateCss = '/admin-test/assets/acme/demo/css/late-widgets.css?v=1'
const currentCss = '/admin-test/assets/acme/demo/css/current-gadgets.css?v=1'
const widgetsSchema = clone(extensionListSchemaFixture)
widgetsSchema.data.assets.styles = [lateCss]
const gadgetsSchema = clone(extensionListSchemaFixture)
gadgetsSchema.data.assets.styles = [currentCss]
delete gadgetsSchema.data.headerPartial
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
const { router } = await mountApp(
'/acme/demo/widgets',
listRoutes({
[`GET ${BASE}/schema/list`]: () => pending,
[`GET ${gadgets}/schema/list`]: { body: gadgetsSchema },
[`GET ${gadgets}`]: { body: listRows },
}),
)
await router.push('/acme/demo/gadgets')
await flushPromises()
const link = (url: string) => seen.links.find((item) => item.getAttribute('href') === url)
expect(link(currentCss)!.disabled).toBe(false)
release({ body: widgetsSchema })
await flushPromises()
expect(link(currentCss)!.disabled).toBe(false)
expect(link(lateCss)!.disabled).toBe(true)
})
})