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:
@@ -6,6 +6,8 @@
|
|||||||
// Stylesheet links belong to one controller: the router disables them on
|
// Stylesheet links belong to one controller: the router disables them on
|
||||||
// every navigation to another controller or to a screen that is not a
|
// 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.
|
// 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.
|
// Files load only through script and link elements; nothing here fetches.
|
||||||
import type { ControllerAssets } from '../api/types'
|
import type { ControllerAssets } from '../api/types'
|
||||||
import { runtime } from './runtime'
|
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
|
// 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.
|
// file, and each keeps its own link so disabling one never hides the other.
|
||||||
const styles = new Map<string, { owner: string; link: HTMLLinkElement }>()
|
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. */
|
/** The URL prefix every plugin asset must start with. */
|
||||||
export function assetPrefix(): string {
|
export function assetPrefix(): string {
|
||||||
@@ -86,21 +90,29 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void
|
|||||||
const link = document.createElement('link')
|
const link = document.createElement('link')
|
||||||
link.rel = 'stylesheet'
|
link.rel = 'stylesheet'
|
||||||
link.setAttribute(OWNER_ATTRIBUTE, controllerId)
|
link.setAttribute(OWNER_ATTRIBUTE, controllerId)
|
||||||
|
link.disabled = controllerId !== active
|
||||||
link.href = url
|
link.href = url
|
||||||
styles.set(key, { owner: controllerId, link })
|
styles.set(key, { owner: controllerId, link })
|
||||||
document.head.appendChild(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 {
|
export function activateStyles(controllerId: string): void {
|
||||||
|
active = controllerId
|
||||||
for (const { owner, link } of styles.values()) {
|
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
|
* resolves once every script has loaded or failed, with the URLs that
|
||||||
* failed; it never rejects. Views start this without awaiting it.
|
* failed; it never rejects. Views start this without awaiting it.
|
||||||
*/
|
*/
|
||||||
@@ -109,7 +121,6 @@ export async function loadControllerAssets(
|
|||||||
assets: ControllerAssets | null | undefined,
|
assets: ControllerAssets | null | undefined,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
loadStyles(controllerId, assets?.styles ?? [])
|
loadStyles(controllerId, assets?.styles ?? [])
|
||||||
activateStyles(controllerId)
|
|
||||||
const urls = assets?.scripts ?? []
|
const urls = assets?.scripts ?? []
|
||||||
const results = await Promise.allSettled(urls.map((url) => loadScript(url)))
|
const results = await Promise.allSettled(urls.map((url) => loadScript(url)))
|
||||||
return urls.filter((_, index) => results[index]?.status === 'rejected')
|
return urls.filter((_, index) => results[index]?.status === 'rejected')
|
||||||
|
|||||||
@@ -165,8 +165,9 @@ describe('controller stylesheets', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('loadControllerAssets', () => {
|
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')])
|
loadStyles('acme.demo.other', [asset('css/other.css')])
|
||||||
|
activateStyles('acme.demo.c1')
|
||||||
const js = asset('js/c1.js')
|
const js = asset('js/c1.js')
|
||||||
let settled = false
|
let settled = false
|
||||||
const done = loadControllerAssets('acme.demo.c1', { scripts: [js], styles: [asset('css/c1.css')] }).then((failed) => {
|
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)
|
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 () => {
|
it('resolves with the scripts that failed or were refused and never rejects', async () => {
|
||||||
const ok = asset('js/c2-ok.js')
|
const ok = asset('js/c2-ok.js')
|
||||||
const bad = asset('js/c2-bad.js')
|
const bad = asset('js/c2-bad.js')
|
||||||
|
|||||||
@@ -636,4 +636,37 @@ describe('controller stylesheets (D-14, UI-SPEC S6)', () => {
|
|||||||
expect(link(gadgetsCss)!.disabled).toBe(true)
|
expect(link(gadgetsCss)!.disabled).toBe(true)
|
||||||
expect(seen.links.filter((item) => item.getAttribute('href') === widgetsCss)).toHaveLength(1)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -6,7 +6,7 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-C3qtJyIU.js"></script>
|
<script type="module" crossorigin src="./assets/index-J-FCndLr.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-CfeX_snf.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-CfeX_snf.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user