Skip to content

Commit dec47ed

Browse files
committed
Fix(Runtime): CSS rules are not inserted at runtime, fixed #382
1 parent 79dbfc7 commit dec47ed

File tree

5 files changed

+244
-82
lines changed

5 files changed

+244
-82
lines changed
+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// https://github.com/master-co/css/issues/382
2+
3+
import { test, expect } from '@playwright/test'
4+
import init from '../init'
5+
6+
test('O to O', async ({ page }) => {
7+
await page.evaluate(() => {
8+
document.body.innerHTML = `
9+
<div id="p1" class="p1">
10+
<div id="p1c2" class="p1c2"></div>
11+
<div id="p1c3" class="p1c3"></div>
12+
</div>
13+
`
14+
})
15+
await init(page, '@layer base, theme, preset, components, general;')
16+
await page.evaluate(() => {
17+
const createElement = (name: string) => {
18+
const el = document.createElement('div')
19+
el.id = name
20+
el.className = name
21+
return el
22+
}
23+
const p1 = document.getElementById('p1') as HTMLHeadElement
24+
const p1c1 = createElement('p1c1')
25+
const p1c2 = document.getElementById('p1c2') as HTMLHeadElement
26+
const p1c3 = document.getElementById('p1c3') as HTMLHeadElement
27+
p1.remove()
28+
p1.appendChild(p1c1)
29+
p1c2.remove()
30+
p1.appendChild(p1c2)
31+
p1c2.classList.add('p1c2-1')
32+
p1c3.remove()
33+
p1c3.classList.add('p1c3-1')
34+
document.body.appendChild(p1)
35+
})
36+
expect(await page.evaluate(() => Object.fromEntries(globalThis.runtimeCSS.classUsages))).toStrictEqual({
37+
'p1': 1,
38+
'p1c1': 1,
39+
'p1c2': 1,
40+
'p1c2-1': 1,
41+
})
42+
})
43+
44+
test('O to X', async ({ page }) => {
45+
await page.evaluate(() => {
46+
document.body.innerHTML = `
47+
<div id="p1" class="p1">
48+
<div id="p1c2" class="p1c2"></div>
49+
<div id="p1c3" class="p1c3"></div>
50+
</div>
51+
`
52+
})
53+
await init(page, '@layer base, theme, preset, components, general;')
54+
await page.evaluate(() => {
55+
const createElement = (name: string) => {
56+
const el = document.createElement('div')
57+
el.id = name
58+
el.className = name
59+
return el
60+
}
61+
const p1 = document.getElementById('p1') as HTMLHeadElement
62+
const p1c1 = createElement('p1c1')
63+
const p1c2 = document.getElementById('p1c2') as HTMLHeadElement
64+
const p1c3 = document.getElementById('p1c3') as HTMLHeadElement
65+
p1.remove()
66+
p1.appendChild(p1c1)
67+
p1c2.remove()
68+
p1.appendChild(p1c2)
69+
p1c2.classList.add('p1c2-1')
70+
p1c3.remove()
71+
p1c3.classList.add('p1c3-1')
72+
})
73+
expect(await page.evaluate(() => Object.fromEntries(globalThis.runtimeCSS.classUsages))).toStrictEqual({})
74+
})
75+
76+
test('X to O', async ({ page }) => {
77+
await init(page, '@layer base, theme, preset, components, general;')
78+
await page.evaluate(() => {
79+
const createElement = (name: string) => {
80+
const el = document.createElement('div')
81+
el.id = name
82+
el.className = name
83+
return el
84+
}
85+
const p1 = createElement('p1')
86+
p1.innerHTML = `
87+
<div id="p1c2" class="p1c2"></div>
88+
<div id="p1c3" class="p1c3"></div>
89+
`
90+
const p1c1 = createElement('p1c1')
91+
const p1c2 = p1.querySelector('#p1c2') as HTMLHeadElement
92+
const p1c3 = p1.querySelector('#p1c3') as HTMLHeadElement
93+
document.body.appendChild(p1)
94+
p1.appendChild(p1c1)
95+
p1c2.remove()
96+
p1.appendChild(p1c2)
97+
p1c2.classList.add('p1c2-1')
98+
p1c3.remove()
99+
p1c3.classList.add('p1c3-1')
100+
})
101+
expect(await page.evaluate(() => Object.fromEntries(globalThis.runtimeCSS.classUsages))).toStrictEqual({
102+
'p1': 1,
103+
'p1c1': 1,
104+
'p1c2': 1,
105+
'p1c2-1': 1,
106+
})
107+
})
108+
109+
110+
test('X to X', async ({ page }) => {
111+
await init(page, '@layer base, theme, preset, components, general;')
112+
await page.evaluate(() => {
113+
const createElement = (name: string) => {
114+
const el = document.createElement('div')
115+
el.id = name
116+
el.className = name
117+
return el
118+
}
119+
const p1 = createElement('p1')
120+
p1.innerHTML = `
121+
<div id="p1c2" class="p1c2"></div>
122+
<div id="p1c3" class="p1c3"></div>
123+
`
124+
const p1c1 = createElement('p1c1')
125+
const p1c2 = p1.querySelector('#p1c2') as HTMLHeadElement
126+
const p1c3 = p1.querySelector('#p1c3') as HTMLHeadElement
127+
document.body.appendChild(p1)
128+
p1.appendChild(p1c1)
129+
p1c2.remove()
130+
p1.appendChild(p1c2)
131+
p1c2.classList.add('p1c2-1')
132+
p1c3.remove()
133+
p1c3.classList.add('p1c3-1')
134+
p1.remove()
135+
})
136+
expect(await page.evaluate(() => Object.fromEntries(globalThis.runtimeCSS.classUsages))).toStrictEqual({})
137+
})

packages/runtime/playground/index.html

+4-11
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,10 @@
88
</head>
99

1010
<body>
11-
<ul class="flex">
12-
<li class="1 flex">1
13-
<ul>
14-
<li class="1-1">1-1</li>
15-
<li class="1-2 flex">1-2</li>
16-
<li class="1-3">1-3</li>
17-
</ul>
18-
</li>
19-
<li class="2">2</li>
20-
<li class="3">3</li>
21-
</ul>
11+
<div id="p1" class="p1">
12+
<div id="p1c2" class="p1c2"></div>
13+
<div id="p1c3" class="p1c3"></div>
14+
</div>
2215
</body>
2316

2417
</html>
+21-7
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import '../../src/global.min'
22

3-
// const element = document.querySelector('h1') as HTMLHeadElement
4-
// document.body.appendChild(element) // 觸發 addNodes
5-
// element.remove() // 觸發 removeNodes
6-
// element.classList.add('A') // 觸發 old new
7-
// element.classList.remove('A') // 觸發 old new
8-
// element.classList.add('B') // 觸發 old new
9-
// element.classList.add('C') // 觸發 old new
3+
const p1 = document.getElementById('p1') as HTMLHeadElement
4+
5+
const create = (name: string) => {
6+
const el = document.createElement('div')
7+
el.id = name
8+
el.className = name
9+
return el
10+
}
11+
12+
const p1c1 = create('p1c1')
13+
const p1c2 = document.getElementById('p1c2') as HTMLHeadElement
14+
const p1c3 = document.getElementById('p1c3') as HTMLHeadElement
15+
16+
p1.remove()
17+
p1.appendChild(p1c1)
18+
p1c2.remove()
19+
p1.appendChild(p1c2)
20+
p1c2.classList.add('p1c2-1')
21+
p1c3.remove()
22+
p1c3.classList.add('p1c3-1')
23+
document.body.appendChild(p1)

packages/runtime/src/core.ts

+81-63
Original file line numberDiff line numberDiff line change
@@ -99,89 +99,102 @@ export class RuntimeCSS extends MasterCSS {
9999
// @ts-expect-error readonly
100100
this.observer = new MutationObserver((mutationRecords) => {
101101
// console.clear()
102-
// console.log('-----------')
103-
// const test = 'swiper-slide-next'
104-
// console.log(`${test}: ${this.classUsages.get(test)}`)
102+
// const test = ''
103+
// if (test) {
104+
// console.log('')
105+
// console.log(`${test}: ${this.classUsages.get(test)}`)
106+
// }
105107
const eachClassUsages = new Map()
106-
const updatedAttrElements = new Set<Element>()
107-
const updatedConnectedElements = new Set<Element>()
108-
const updatedElements = new Set<Element>()
108+
const targetFirstAttrMutationRecord = new Map<Element, MutationRecord>()
109109

110110
const updateClassUsage = (classes: Set<string> | string[] | DOMTokenList, isAdding = false) => {
111111
const usage = isAdding ? 1 : -1
112112
classes.forEach((className) => {
113-
// if (className === test) console.log(` ${isAdding ? '+' : '-'} ${className} ${(eachClassUsages.get(className) || 0) + usage}`, target)
114113
eachClassUsages.set(className, (eachClassUsages.get(className) || 0) + usage)
115114
})
116115
}
117116

118-
const updateElementTree = (element: Element, adding: boolean) => {
119-
if (element.isConnected) {
120-
updatedConnectedElements.add(element)
121-
}
122-
updatedElements.add(element)
123-
updateClassUsage(element.classList, adding)
124-
for (const child of element.children) {
125-
updateElementTree(child as Element, adding)
117+
const connectedStatusMap = new Map<Element, { change: number, mutationRecord: MutationRecord }>()
118+
const disconnectedStatusMap = new Map<Element, { change: number, mutationRecord: MutationRecord }>()
119+
const recordStatus = (target: Element, mutationRecord: MutationRecord, map: Map<Element, { change: number, mutationRecord: MutationRecord }>, adding: boolean) => {
120+
const status = map.get(target)
121+
if (status) {
122+
status.change += adding ? 1 : -1
123+
status.mutationRecord = mutationRecord
124+
} else {
125+
map.set(target, { change: adding ? 1 : -1, mutationRecord })
126126
}
127127
}
128128

129-
// console.log('///')
130-
131-
mutationRecords.forEach((mutation) => {
132-
const target = mutation.target as Element
133-
switch (mutation.type) {
129+
mutationRecords.forEach((mutationRecord) => {
130+
const target = mutationRecord.target as Element
131+
switch (mutationRecord.type) {
134132
case 'attributes':
135-
const oldClassList = mutation.oldValue ? mutation.oldValue.split(/\s+/) : []
136-
const newClassList = target.classList
137-
const addedClasses: string[] = []
138-
newClassList.forEach(c => {
139-
if (!oldClassList.includes(c)) addedClasses.push(c)
140-
})
141-
// const removedClasses = oldClassList.filter(c => !newClassList.contains(c))
142-
// if (addedClasses.length) console.log('[attribute]', '[add]', addedClasses, target)
143-
// if (removedClasses.length) console.log('[attribute]', '[remove]', removedClasses, target)
133+
if (!targetFirstAttrMutationRecord.has(target)) {
134+
targetFirstAttrMutationRecord.set(target, mutationRecord)
135+
}
144136
break
145137
case 'childList':
146-
// if (mutation.addedNodes.length) console.log('[childList]', '[add]', mutation.addedNodes)
147-
// if (mutation.removedNodes.length) console.log('[childList]', '[remove]', mutation.removedNodes)
138+
const targetStatusMap = target.isConnected ? connectedStatusMap : disconnectedStatusMap
139+
mutationRecord.addedNodes.forEach((node) =>
140+
'classList' in node && recordStatus(node as Element, mutationRecord, targetStatusMap, true)
141+
)
142+
mutationRecord.removedNodes.forEach((node) =>
143+
'classList' in node && recordStatus(node as Element, mutationRecord, targetStatusMap, false)
144+
)
148145
break
149146
}
150147
})
151148

152-
// console.log('///')
149+
const updatedTargetChangeMap = new Map<Element, number>()
153150

154-
mutationRecords.forEach((mutation) => {
155-
const target = mutation.target as Element
156-
switch (mutation.type) {
157-
case 'attributes':
158-
/**
159-
* We only need to determine the first attribute record of the same target,
160-
* because the first record has the original old class name.
161-
*
162-
* The target is skipped if it is contained in the unhandled childList.
163-
*/
164-
if (updatedAttrElements.has(target) || updatedElements.has(target)) return
165-
updatedAttrElements.add(target)
166-
const oldClassList = mutation.oldValue ? mutation.oldValue.split(/\s+/) : []
167-
const newClassList = target.classList
168-
const addedClasses: string[] = []
169-
newClassList.forEach(c => {
170-
if (!oldClassList.includes(c)) addedClasses.push(c)
151+
const updateTarget = (target: Element, adding: boolean) => {
152+
const change = updatedTargetChangeMap.get(target) || 0
153+
const newChange = change + (adding ? 1 : -1)
154+
if (newChange >= -1 && newChange <= 1) {
155+
updatedTargetChangeMap.set(target, newChange)
156+
const firstAttrMutationRecord = targetFirstAttrMutationRecord.get(target)
157+
if (firstAttrMutationRecord) {
158+
targetFirstAttrMutationRecord.delete(target)
159+
}
160+
if (adding) {
161+
updateClassUsage(target.classList, adding)
162+
} else {
163+
if (firstAttrMutationRecord) {
164+
updateClassUsage(firstAttrMutationRecord.oldValue ? firstAttrMutationRecord.oldValue.split(/\s+/) : [], adding)
165+
} else {
166+
updateClassUsage(target.classList, adding)
167+
}
168+
disconnectedStatusMap.forEach((disconnectedTargetStatus, disconnectedTarget) => {
169+
if (disconnectedTargetStatus.mutationRecord.target === target && disconnectedTargetStatus.change !== 0) {
170+
updateTarget(disconnectedTarget, disconnectedTargetStatus.change > 0)
171+
}
171172
})
172-
const removedClasses = oldClassList.filter(c => !newClassList.contains(c))
173-
// if (addedClasses.length) console.log('[attribute]', '[add]', addedClasses, target)
174-
if (addedClasses.length) updateClassUsage(addedClasses, true)
175-
// if (removedClasses.length) console.log('[attribute]', '[remove]', removedClasses, target)
176-
if (removedClasses.length) updateClassUsage(removedClasses, false)
177-
break
178-
case 'childList':
179-
if (updatedConnectedElements.has(target)) return
180-
// if (mutation.addedNodes.length) console.log('[childList]', '[add]', mutation.addedNodes)
181-
mutation.addedNodes.forEach((node) => 'classList' in node && updateElementTree(node as Element, true))
182-
// if (mutation.removedNodes.length) console.log('[childList]', '[remove]', mutation.removedNodes)
183-
mutation.removedNodes.forEach((node) => 'classList' in node && updateElementTree(node as Element, false))
184-
break
173+
}
174+
for (const child of target.children) {
175+
updateTarget(child as Element, adding)
176+
}
177+
}
178+
}
179+
180+
connectedStatusMap.forEach(({ change }, target) => change !== 0 && updateTarget(target, change > 0))
181+
182+
targetFirstAttrMutationRecord.forEach((mutation, target) => {
183+
if (!target.isConnected) return
184+
const oldClassList = mutation.oldValue ? mutation.oldValue.split(/\s+/) : []
185+
const newClassList = target.classList
186+
const addedClasses: string[] = []
187+
newClassList.forEach(c => {
188+
if (!oldClassList.includes(c)) addedClasses.push(c)
189+
})
190+
const removedClasses = oldClassList.filter(c => !newClassList.contains(c))
191+
if (addedClasses.length) {
192+
// console.log('[attribute]', '[add] ', addedClasses, target)
193+
updateClassUsage(addedClasses, true)
194+
}
195+
if (removedClasses.length) {
196+
// console.log('[attribute]', '[remove]', removedClasses, target)
197+
updateClassUsage(removedClasses, false)
185198
}
186199
})
187200

@@ -224,12 +237,17 @@ export class RuntimeCSS extends MasterCSS {
224237
// }
225238
// })
226239

227-
// // 與 this.classUsages 比較並且打印不同的部分
228240
// for (const className in safeClassUsages) {
229241
// if (this.classUsages.get(className) !== safeClassUsages[className]) {
230242
// throw new Error(`[css] ${className} ${this.classUsages.get(className)} (correct: ${safeClassUsages[className]})`)
231243
// }
232244
// }
245+
246+
// this.classUsages.forEach((count, className) => {
247+
// if (!Object.prototype.hasOwnProperty.call(safeClassUsages, className)) {
248+
// throw new Error(`[css] ${className} ${count} (correct: 0)`)
249+
// }
250+
// })
233251
// end: debug
234252
})
235253

site/.env

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
NEXT_PUBLIC_PROJECT=Master CSS
2-
NEXT_PUBLIC_VERSION=2.0.0-rc.50
2+
NEXT_PUBLIC_VERSION=2.0.0-rc.51

0 commit comments

Comments
 (0)