Merge pull request #5534 from deepseek-harness/turtle/plugin-toggle-css-c341cac49368

fix(client-modules): keep other owners' styles when a plugin toggles
This commit is contained in:
Turtle authored and GitHub committed 2026-09-30 16:15:49 +08:00
commit 95a7a0cae3
2 files changed
+43 -5

No files matched your search

+12 -5
View File
@@ -63,15 +63,20 @@ function chunkUrl(row: BootModuleRow, fileName: string, rev: string): string {
return `${url.slice(0, resourceStart)}/${row.id}/${fileName}?${url.slice(revisionStart + 1)}`
}
/** Untagged <style> elements present before a factory runs; the factory must not claim them. */
const untaggedStyles = (): ReadonlySet<Element> =>
typeof document === 'undefined' ? new Set() : new Set(document.querySelectorAll('style:not([data-plugin])'))
/**
* Claim and inventory the <style> tags a factory injected during
* materialization: preset-emitted tags arrive pre-tagged with data-plugin;
* any untagged tag is claimed for the materializing plugin (HMR bookkeeping).
* an untagged tag that was absent from `before` is claimed for the
* materializing plugin (HMR bookkeeping).
*/
const claimStyles = (id: string): string[] => {
const claimStyles = (id: string, before: ReadonlySet<Element>): string[] => {
if (typeof document === 'undefined') return []
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
el.setAttribute('data-plugin', id)
for (const el of untaggedStyles()) {
if (!before.has(el)) el.setAttribute('data-plugin', id)
}
const owned: string[] = []
for (const el of document.querySelectorAll(`style[data-plugin=${JSON.stringify(id)}]`)) {
@@ -300,13 +305,15 @@ export class ClientModuleSystem implements ClientModuleLoader {
throw new Error(`client-modules: require cycle through "${id}" (factory-form CJS cannot deliver partial exports)`)
}
this.materializing.add(id)
const before = untaggedStyles()
try {
const edges = new Set<string>()
const exports = registered.factory(this.makeRequire(ownerId, edges))
const record: ClientModuleRecord = { id, exports, styles: claimStyles(ownerId), edges }
const record: ClientModuleRecord = { id, exports, styles: claimStyles(ownerId, before), edges }
this.loadCache.set(id, record)
return record
} catch (error) {
claimStyles(ownerId, before)
removeOwnedStyles(ownerId)
throw error
} finally {
@@ -832,6 +832,37 @@ describe('style claiming', () => {
expect(foreign.getAttribute('data-plugin')).toBe('other')
})
it('leaves untagged style tags inserted before materialization unclaimed', async () => {
const earlier = document.createElement('style')
document.head.appendChild(earlier)
const b = bench([row('a')], { a: () => ({}) })
await b.loader.import('a', '', {})
removeOwnedStyles('a')
expect(earlier.isConnected).toBe(true)
expect(earlier.hasAttribute('data-plugin')).toBe(false)
})
it('assigns an untagged style to the factory that inserted it before a nested require', async () => {
const outer = document.createElement('style')
const b = bench([row('b'), row('a', { external: ['b'] })], {
a: (require) => {
document.head.appendChild(outer)
require('b')
return {}
},
b: () => ({}),
})
await b.loader.import('a', '', {})
expect(outer.getAttribute('data-plugin')).toBe('a')
})
it('removes untagged styles inserted by a factory that throws', async () => {
const leaked = document.createElement('style')
const b = bench([row('a')], { a: () => { document.head.appendChild(leaked); throw new Error('factory exploded') } })
await expect(b.loader.import('a', '', {})).rejects.toThrow('factory exploded')
expect(leaked.isConnected).toBe(false)
})
it('materialization without a document skips the style inventory', async () => {
const b = bench([row('a')], { a: () => ({}) })
vi.stubGlobal('document', undefined)