Skip to content

Commit 80e3d4d

Browse files
lilnasysarah11918
andauthored
feature: configuration for css inlining behavior (#6659)
* feature(inline stylesheets): implement as experimental * test: rename css-inline -> css-import-as-inline * test(content collections): add de-duplication of css * test: add new suite for inlineStylesheets configuration * fix(inline stylesheets): did not act on propagated styles * hack(inline stylesheets testing): duplicate fixtures Content collections reuses build data across multiple fixture.builds, even though a configuration change may have changed it. Duplicating fixtures avoids usage of the stale cache. https://cdn.discordapp.com/attachments/1039830843440504872/1097795182340092024/Screenshot_87_colored.png * refactor(css plugin): reduce nesting * optimization(css rendering): merge <style> tags Chrome, but not Safari or Firefox, is slower to match rules when they are split across multiple files or style tags. https://nolanlawson.com/2022/06/22/style-scoping-versus-shadow-dom-which-is-fastest/ Having the abiility to inline stylesheets opens us up to this optimization. Ideally, it would extend to propagated styles, but that ended up being a rabbit hole. * typedocs(inlineStylesheets config): ensure consistency Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * chore(build internals): update comment * correct minor mistake in test * test(inline stylesheets): unique package names for duplicate fixtures * refactor(css build plugin): maps -> records * refactor(css build plugin): remove use of spread operator --------- Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
1 parent 8d75340 commit 80e3d4d

49 files changed

Lines changed: 1355 additions & 310 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/friendly-fishes-sing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Implement Inline Stylesheets RFC as experimental

packages/astro/src/@types/astro.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,26 @@ export interface AstroUserConfig {
10361036
*/
10371037
assets?: boolean;
10381038

1039+
/**
1040+
* @docs
1041+
* @name experimental.inlineStylesheets
1042+
* @type {('always' | 'auto' | 'never')}
1043+
* @default `never`
1044+
* @description
1045+
* Control whether styles are sent to the browser in a separate css file or inlined into <style> tags. Choose from the following options:
1046+
* - `'always'` - all styles are inlined into <style> tags
1047+
* - `'auto'` - only stylesheets smaller than `ViteConfig.build.assetsInlineLimit` (default: 4kb) are inlined. Otherwise, styles are sent in external stylesheets.
1048+
* - `'never'` - all styles are sent in external stylesheets
1049+
*
1050+
* ```js
1051+
* {
1052+
* experimental: {
1053+
* inlineStylesheets: `auto`,
1054+
* },
1055+
* }
1056+
*/
1057+
inlineStylesheets?: 'always' | 'auto' | 'never';
1058+
10391059
/**
10401060
* @docs
10411061
* @name experimental.middleware

packages/astro/src/content/runtime.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
createHeadAndContent,
77
renderComponent,
88
renderScriptElement,
9-
renderStyleElement,
109
renderTemplate,
1110
renderUniqueStylesheet,
1211
unescapeHTML,
@@ -152,13 +151,21 @@ async function render({
152151
links = '',
153152
scripts = '';
154153
if (Array.isArray(collectedStyles)) {
155-
styles = collectedStyles.map((style: any) => renderStyleElement(style)).join('');
154+
styles = collectedStyles
155+
.map((style: any) => {
156+
return renderUniqueStylesheet(result, {
157+
type: 'inline',
158+
content: style,
159+
});
160+
})
161+
.join('');
156162
}
157163
if (Array.isArray(collectedLinks)) {
158164
links = collectedLinks
159165
.map((link: any) => {
160166
return renderUniqueStylesheet(result, {
161-
href: prependForwardSlash(link),
167+
type: 'external',
168+
src: prependForwardSlash(link),
162169
});
163170
})
164171
.join('');

packages/astro/src/content/vite-plugin-content-assets.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ export function astroConfigBuildPlugin(
123123
chunk.type === 'chunk' &&
124124
(chunk.code.includes(LINKS_PLACEHOLDER) || chunk.code.includes(SCRIPTS_PLACEHOLDER))
125125
) {
126-
let entryCSS = new Set<string>();
126+
let entryStyles = new Set<string>();
127+
let entryLinks = new Set<string>();
127128
let entryScripts = new Set<string>();
128129

129130
for (const id of Object.keys(chunk.modules)) {
@@ -137,7 +138,8 @@ export function astroConfigBuildPlugin(
137138
const _entryScripts = pageData.propagatedScripts?.get(id);
138139
if (_entryCss) {
139140
for (const value of _entryCss) {
140-
entryCSS.add(value);
141+
if (value.type === 'inline') entryStyles.add(value.content);
142+
if (value.type === 'external') entryLinks.add(value.src);
141143
}
142144
}
143145
if (_entryScripts) {
@@ -150,10 +152,16 @@ export function astroConfigBuildPlugin(
150152
}
151153

152154
let newCode = chunk.code;
153-
if (entryCSS.size) {
155+
if (entryStyles.size) {
156+
newCode = newCode.replace(
157+
JSON.stringify(STYLES_PLACEHOLDER),
158+
JSON.stringify(Array.from(entryStyles))
159+
);
160+
}
161+
if (entryLinks.size) {
154162
newCode = newCode.replace(
155163
JSON.stringify(LINKS_PLACEHOLDER),
156-
JSON.stringify(Array.from(entryCSS).map(prependBase))
164+
JSON.stringify(Array.from(entryLinks).map(prependBase))
157165
);
158166
}
159167
if (entryScripts.size) {

packages/astro/src/core/app/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
import { RouteCache } from '../render/route-cache.js';
2525
import {
2626
createAssetLink,
27-
createLinkStylesheetElementSet,
27+
createStylesheetElementSet,
2828
createModuleScriptElement,
2929
} from '../render/ssr-element.js';
3030
import { matchRoute } from '../routing/match.js';
@@ -180,7 +180,9 @@ export class App {
180180
const url = new URL(request.url);
181181
const pathname = '/' + this.removeBase(url.pathname);
182182
const info = this.#routeDataToRouteInfo.get(routeData!)!;
183-
const links = createLinkStylesheetElementSet(info.links);
183+
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
184+
const links = new Set<never>();
185+
const styles = createStylesheetElementSet(info.styles);
184186

185187
let scripts = new Set<SSRElement>();
186188
for (const script of info.scripts) {
@@ -203,6 +205,7 @@ export class App {
203205
pathname,
204206
componentMetadata: this.#manifest.componentMetadata,
205207
scripts,
208+
styles,
206209
links,
207210
route: routeData,
208211
status,

packages/astro/src/core/app/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import type {
1111

1212
export type ComponentPath = string;
1313

14+
export type StylesheetAsset =
15+
| { type: 'inline'; content: string }
16+
| { type: 'external'; src: string };
17+
1418
export interface RouteInfo {
1519
routeData: RouteData;
1620
file: string;
@@ -21,6 +25,7 @@ export interface RouteInfo {
2125
// Hoisted
2226
| { type: 'inline' | 'external'; value: string }
2327
)[];
28+
styles: StylesheetAsset[];
2429
}
2530

2631
export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & {

packages/astro/src/core/build/generate.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,25 @@ import { createEnvironment, createRenderContext, renderPage } from '../render/in
4040
import { callGetStaticPaths } from '../render/route-cache.js';
4141
import {
4242
createAssetLink,
43-
createLinkStylesheetElementSet,
43+
createStylesheetElementSet,
4444
createModuleScriptsSet,
4545
} from '../render/ssr-element.js';
4646
import { createRequest } from '../request.js';
4747
import { matchRoute } from '../routing/match.js';
4848
import { getOutputFilename } from '../util.js';
4949
import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js';
50-
import { eachPageData, getPageDataByComponent, sortedCSS } from './internal.js';
51-
import type { PageBuildData, SingleFileBuiltModule, StaticBuildOptions } from './types';
50+
import {
51+
eachPageData,
52+
getPageDataByComponent,
53+
cssOrder,
54+
mergeInlineCss,
55+
} from './internal.js';
56+
import type {
57+
PageBuildData,
58+
SingleFileBuiltModule,
59+
StaticBuildOptions,
60+
StylesheetAsset,
61+
} from './types';
5262
import { getTimeStat } from './util.js';
5363

5464
function shouldSkipDraft(pageModule: ComponentInstance, settings: AstroSettings): boolean {
@@ -161,8 +171,14 @@ async function generatePage(
161171
const renderers = ssrEntry.renderers;
162172

163173
const pageInfo = getPageDataByComponent(internals, pageData.route.component);
164-
const linkIds: string[] = sortedCSS(pageData);
174+
175+
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
176+
const linkIds: [] = [];
165177
const scripts = pageInfo?.hoistedScript ?? null;
178+
const styles = pageData.styles
179+
.sort(cssOrder)
180+
.map(({ sheet }) => sheet)
181+
.reduce(mergeInlineCss, []);
166182

167183
const pageModule = ssrEntry.pageMap?.get(pageData.component);
168184
const middleware = ssrEntry.middleware;
@@ -183,6 +199,7 @@ async function generatePage(
183199
internals,
184200
linkIds,
185201
scripts,
202+
styles,
186203
mod: pageModule,
187204
renderers,
188205
};
@@ -273,6 +290,7 @@ interface GeneratePathOptions {
273290
internals: BuildInternals;
274291
linkIds: string[];
275292
scripts: { type: 'inline' | 'external'; value: string } | null;
293+
styles: StylesheetAsset[];
276294
mod: ComponentInstance;
277295
renderers: SSRLoadedRenderer[];
278296
}
@@ -341,7 +359,15 @@ async function generatePath(
341359
middleware?: AstroMiddlewareInstance<unknown>
342360
) {
343361
const { settings, logging, origin, routeCache } = opts;
344-
const { mod, internals, linkIds, scripts: hoistedScripts, pageData, renderers } = gopts;
362+
const {
363+
mod,
364+
internals,
365+
linkIds,
366+
scripts: hoistedScripts,
367+
styles: _styles,
368+
pageData,
369+
renderers,
370+
} = gopts;
345371

346372
// This adds the page name to the array so it can be shown as part of stats.
347373
if (pageData.route.type === 'page') {
@@ -350,13 +376,15 @@ async function generatePath(
350376

351377
debug('build', `Generating: ${pathname}`);
352378

353-
const links = createLinkStylesheetElementSet(
354-
linkIds,
379+
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
380+
const links = new Set<never>();
381+
const scripts = createModuleScriptsSet(
382+
hoistedScripts ? [hoistedScripts] : [],
355383
settings.config.base,
356384
settings.config.build.assetsPrefix
357385
);
358-
const scripts = createModuleScriptsSet(
359-
hoistedScripts ? [hoistedScripts] : [],
386+
const styles = createStylesheetElementSet(
387+
_styles,
360388
settings.config.base,
361389
settings.config.build.assetsPrefix
362390
);
@@ -431,6 +459,7 @@ async function generatePath(
431459
request: createRequest({ url, headers: new Headers(), logging, ssr }),
432460
componentMetadata: internals.componentMetadata,
433461
scripts,
462+
styles,
434463
links,
435464
route: pageData.route,
436465
env,

packages/astro/src/core/build/internal.ts

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Rollup } from 'vite';
2-
import type { PageBuildData, ViteID } from './types';
2+
import type { PageBuildData, StylesheetAsset, ViteID } from './types';
33

44
import type { SSRResult } from '../../@types/astro';
55
import type { PageOptions } from '../../vite-plugin-astro/types';
@@ -224,39 +224,56 @@ export function hasPrerenderedPages(internals: BuildInternals) {
224224
return false;
225225
}
226226

227+
interface OrderInfo {
228+
depth: number;
229+
order: number;
230+
}
231+
227232
/**
228233
* Sort a page's CSS by depth. A higher depth means that the CSS comes from shared subcomponents.
229234
* A lower depth means it comes directly from the top-level page.
230-
* The return of this function is an array of CSS paths, with shared CSS on top
231-
* and page-level CSS on bottom.
235+
* Can be used to sort stylesheets so that shared rules come first
236+
* and page-specific rules come after.
232237
*/
233-
export function sortedCSS(pageData: PageBuildData) {
234-
return Array.from(pageData.css)
235-
.sort((a, b) => {
236-
let depthA = a[1].depth,
237-
depthB = b[1].depth,
238-
orderA = a[1].order,
239-
orderB = b[1].order;
240-
241-
if (orderA === -1 && orderB >= 0) {
242-
return 1;
243-
} else if (orderB === -1 && orderA >= 0) {
244-
return -1;
245-
} else if (orderA > orderB) {
246-
return 1;
247-
} else if (orderA < orderB) {
248-
return -1;
249-
} else {
250-
if (depthA === -1) {
251-
return -1;
252-
} else if (depthB === -1) {
253-
return 1;
254-
} else {
255-
return depthA > depthB ? -1 : 1;
256-
}
257-
}
258-
})
259-
.map(([id]) => id);
238+
export function cssOrder(a: OrderInfo, b: OrderInfo) {
239+
let depthA = a.depth,
240+
depthB = b.depth,
241+
orderA = a.order,
242+
orderB = b.order;
243+
244+
if (orderA === -1 && orderB >= 0) {
245+
return 1;
246+
} else if (orderB === -1 && orderA >= 0) {
247+
return -1;
248+
} else if (orderA > orderB) {
249+
return 1;
250+
} else if (orderA < orderB) {
251+
return -1;
252+
} else {
253+
if (depthA === -1) {
254+
return -1;
255+
} else if (depthB === -1) {
256+
return 1;
257+
} else {
258+
return depthA > depthB ? -1 : 1;
259+
}
260+
}
261+
}
262+
263+
export function mergeInlineCss(
264+
acc: Array<StylesheetAsset>,
265+
current: StylesheetAsset
266+
): Array<StylesheetAsset> {
267+
const lastAdded = acc.at(acc.length - 1);
268+
const lastWasInline = lastAdded?.type === 'inline';
269+
const currentIsInline = current?.type === 'inline';
270+
if (lastWasInline && currentIsInline) {
271+
const merged = { type: 'inline' as const, content: lastAdded.content + current.content };
272+
acc[acc.length - 1] = merged;
273+
return acc;
274+
}
275+
acc.push(current)
276+
return acc;
260277
}
261278

262279
export function isHoistedScript(internals: BuildInternals, id: string): boolean {

packages/astro/src/core/build/page-data.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export async function collectPagesData(
5353
component: route.component,
5454
route,
5555
moduleSpecifier: '',
56-
css: new Map(),
56+
styles: [],
5757
propagatedStyles: new Map(),
5858
propagatedScripts: new Map(),
5959
hoistedScript: undefined,
@@ -76,7 +76,7 @@ export async function collectPagesData(
7676
component: route.component,
7777
route,
7878
moduleSpecifier: '',
79-
css: new Map(),
79+
styles: [],
8080
propagatedStyles: new Map(),
8181
propagatedScripts: new Map(),
8282
hoistedScript: undefined,

0 commit comments

Comments
 (0)