Skip to content

Commit 9ad3798

Browse files
imslepovdanielroe
andauthored
fix: preserve order of external stylesheets (#246)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 966a2d8 commit 9ad3798

6 files changed

Lines changed: 444 additions & 8 deletions

File tree

packages/beasties/src/index.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ const implicitUniversalPattern = /([>+~])\s*(?!\1)([>+~])/g
3131
const emptyCombinatorPattern = /([>+~])\s*(?=\1|$)/g
3232
const removeTrailingCommasPattern = /\(\s*,|,\s*\)/g
3333

34+
interface PreFetchedStylesheet {
35+
link: ChildNode
36+
href: string
37+
sheet: string
38+
style: Node
39+
}
40+
3441
export default class Beasties {
3542
#selectorCache = new Map<string, string>()
3643
options: Options & Required<Pick<Options, 'logLevel' | 'path' | 'publicPath' | 'reduceInlineStyles' | 'pruneSource' | 'additionalStylesheets'>> & { allowRules: Array<string | RegExp> }
@@ -109,9 +116,26 @@ export default class Beasties {
109116
if (this.options.external !== false) {
110117
const externalSheets = [...document.querySelectorAll('link[rel="stylesheet"]')] as ChildNode[]
111118

112-
await Promise.all(
113-
externalSheets.map(link => this.embedLinkedStylesheet(link, document)),
114-
)
119+
const hasCustomEmbed = this.embedLinkedStylesheet !== Beasties.prototype.embedLinkedStylesheet
120+
121+
if (hasCustomEmbed) {
122+
// fall back to sequential processing if embedLinkedStylesheet is overridden
123+
for (const link of externalSheets) {
124+
await this.embedLinkedStylesheet(link, document)
125+
}
126+
}
127+
else {
128+
const sheets = await Promise.all(
129+
externalSheets.map(link => this.fetchStylesheet(link, document)),
130+
)
131+
132+
// embed stylesheets sequentially to preserve order
133+
for (const sheet of sheets) {
134+
if (sheet) {
135+
this.embedFetchedStylesheet(sheet, document)
136+
}
137+
}
138+
}
115139
}
116140

117141
// go through all the style tags in the document and reduce them to only critical CSS
@@ -245,29 +269,39 @@ export default class Beasties {
245269
}
246270

247271
/**
248-
* Inline the target stylesheet referred to by a <link rel="stylesheet"> (assuming it passes `options.filter`)
272+
* Fetch CSS content for a linked stylesheet
249273
*/
250-
async embedLinkedStylesheet(link: ChildNode, document: HTMLDocument) {
274+
private async fetchStylesheet(link: ChildNode, document: HTMLDocument): Promise<PreFetchedStylesheet | undefined> {
251275
const href = link.getAttribute('href')
252276

253277
// skip filtered resources, or network resources if no filter is provided
254278
if (!href?.endsWith('.css')) {
255279
return undefined
256280
}
257281

258-
// the reduced critical CSS gets injected into a new <style> tag
282+
// dreate style element early so subclasses can use it in getCssAsset
259283
const style = document.createElement('style')
260284
style.$$external = true
285+
261286
const sheet = await this.getCssAsset(href, style)
262287

263288
if (!sheet) {
264-
return
289+
return undefined
265290
}
266291

292+
return { link, href, sheet, style }
293+
}
294+
295+
/**
296+
* Embed a fetched stylesheet into the document
297+
*/
298+
private embedFetchedStylesheet(data: PreFetchedStylesheet, document: HTMLDocument) {
299+
const { link, href, sheet, style } = data
300+
267301
style.textContent = sheet
268302
style.$$name = href
269303
style.$$links = [link]
270-
link.parentNode?.insertBefore(style, link)
304+
link.parentNode?.insertBefore(style as ChildNode, link)
271305

272306
if (this.checkInlineThreshold(link, style, sheet)) {
273307
return
@@ -373,6 +407,16 @@ export default class Beasties {
373407
}
374408
}
375409

410+
/**
411+
* Inline the target stylesheet referred to by a <link rel="stylesheet"> (assuming it passes `options.filter`)
412+
*/
413+
async embedLinkedStylesheet(link: ChildNode, document: HTMLDocument) {
414+
const sheet = await this.fetchStylesheet(link, document)
415+
if (sheet) {
416+
this.embedFetchedStylesheet(sheet, document)
417+
}
418+
}
419+
376420
/**
377421
* Prune the source CSS files
378422
*/

packages/beasties/test/__snapshots__/beasties.test.ts.snap

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,34 @@ exports[`beasties > should keep existing link tag attributes in the noscript lin
118118
</html>"
119119
`;
120120
121+
exports[`beasties > should preserve order of external stylesheets 1`] = `
122+
"<html>
123+
<head>
124+
<title>Testing</title>
125+
<link rel="preload" href="large.css" as="style">
126+
<style>h1{color:blue}p{color:purple}.contents{padding:50px;text-align:center}.input-field{padding:10px}.custom-element::part(tab){color:#0c0dcc;border-bottom:transparent solid 2px}.custom-element::part(tab):hover:active{background-color:#0c0d33;color:#ffffff}.custom-element::part(tab):focus{box-shadow:0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff,
127+
0 0 0 4px rgba(10, 132, 255, 0.3)}div:is(:hover, .active){color:#000}div:is(.selected, :hover){color:#fff}</style><link rel="preload" href="styles.css" as="style">
128+
<link rel="preload" href="colors.css" as="style">
129+
</head>
130+
<body>
131+
<div class="container">
132+
<header>
133+
<div class="banner">Lorem ipsum dolor sit amet</div>
134+
</header>
135+
</div>
136+
<div class data-beasties-container>
137+
<h1>Hello World!</h1>
138+
<p>This is a paragraph</p>
139+
<input class="input-field">
140+
<div class="contents"></div>
141+
<div class="selected active"></div>
142+
</div>
143+
<footer class></footer>
144+
<link rel="stylesheet" href="large.css"><link rel="stylesheet" href="styles.css"><link rel="stylesheet" href="colors.css"></body>
145+
</html>
146+
"
147+
`;
148+
121149
exports[`beasties > skip invalid path 1`] = `
122150
"<html>
123151
<link>

packages/beasties/test/beasties.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,120 @@ describe('beasties', () => {
8989
expect(result).toMatchSnapshot()
9090
})
9191

92+
it('should preserve order of external stylesheets', async () => {
93+
const beasties = new Beasties({
94+
reduceInlineStyles: false,
95+
path: fixtureDir,
96+
})
97+
98+
const html = fs.readFileSync(path.join(fixtureDir, 'multiple-stylesheets.html'), 'utf-8')
99+
100+
const result = await beasties.process(html)
101+
expect(result).toMatchSnapshot()
102+
})
103+
104+
it('should preserve order of external stylesheets with variable load times', async () => {
105+
vi.useFakeTimers()
106+
107+
const beasties = new Beasties({
108+
reduceInlineStyles: false,
109+
path: '/',
110+
mergeStylesheets: false, // Keep separate to verify order
111+
})
112+
113+
// Simulate variable latency - second file loads fastest
114+
const assets: Record<string, { content: string, delay: number }> = {
115+
'/first.css': { content: 'h1 { color: red; }', delay: 50 },
116+
'/second.css': { content: 'h2 { color: blue; }', delay: 10 },
117+
'/third.css': { content: 'h3 { color: green; }', delay: 30 },
118+
}
119+
120+
beasties.readFile = (filename) => {
121+
const key = filename.replace(/^\w:/, '').replace(/\\/g, '/')
122+
const asset = assets[key]
123+
if (!asset)
124+
return Promise.resolve('')
125+
return new Promise((resolve) => {
126+
setTimeout(() => resolve(asset.content), asset.delay)
127+
})
128+
}
129+
130+
const processPromise = beasties.process(trim`
131+
<html>
132+
<head>
133+
<link rel="stylesheet" href="/first.css">
134+
<link rel="stylesheet" href="/second.css">
135+
<link rel="stylesheet" href="/third.css">
136+
</head>
137+
<body>
138+
<h1>First</h1>
139+
<h2>Second</h2>
140+
<h3>Third</h3>
141+
</body>
142+
</html>
143+
`)
144+
145+
// Advance all timers
146+
await vi.runAllTimersAsync()
147+
const result = await processPromise
148+
149+
vi.useRealTimers()
150+
151+
// Verify style tags are in correct order (first, second, third)
152+
const styleOrder = [...result.matchAll(/<style>([^<]+)<\/style>/g)].map(m => m[1])
153+
expect(styleOrder).toEqual([
154+
'h1{color:red}',
155+
'h2{color:blue}',
156+
'h3{color:green}',
157+
])
158+
159+
// Verify body links are in correct order
160+
const bodyLinkMatches = result.match(/<body>[\s\S]*<\/body>/)?.[0] || ''
161+
const linkOrder = [...bodyLinkMatches.matchAll(/href="\/([^"]+)\.css"/g)].map(m => m[1])
162+
expect(linkOrder).toEqual(['first', 'second', 'third'])
163+
})
164+
165+
it('should use custom embedLinkedStylesheet when overridden', async () => {
166+
const embeddedLinks: string[] = []
167+
168+
class CustomBeasties extends Beasties {
169+
override async embedLinkedStylesheet(link: Parameters<Beasties['embedLinkedStylesheet']>[0], document: Parameters<Beasties['embedLinkedStylesheet']>[1]) {
170+
const href = link.getAttribute('href')
171+
if (href) {
172+
embeddedLinks.push(href)
173+
}
174+
return super.embedLinkedStylesheet(link, document)
175+
}
176+
}
177+
178+
const beasties = new CustomBeasties({
179+
reduceInlineStyles: false,
180+
path: '/',
181+
})
182+
183+
const assets: Record<string, string> = {
184+
'/a.css': 'h1 { color: red; }',
185+
'/b.css': 'h2 { color: blue; }',
186+
}
187+
beasties.readFile = filename => assets[filename.replace(/^\w:/, '').replace(/\\/g, '/')]!
188+
189+
await beasties.process(trim`
190+
<html>
191+
<head>
192+
<link rel="stylesheet" href="/a.css">
193+
<link rel="stylesheet" href="/b.css">
194+
</head>
195+
<body>
196+
<h1>Hello</h1>
197+
<h2>World</h2>
198+
</body>
199+
</html>
200+
`)
201+
202+
// Verify that our custom embedLinkedStylesheet was called for each stylesheet
203+
expect(embeddedLinks).toEqual(['/a.css', '/b.css'])
204+
})
205+
92206
it('does not encode HTML', async () => {
93207
const beasties = new Beasties({
94208
reduceInlineStyles: false,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.red { color: red; }
2+
.green { color: green; }
3+
.blue { color: blue; }

0 commit comments

Comments
 (0)