Use CSS depth to sort CSS in production (#4446)

* Use CSS depth to sort CSS in production

* Adds a changeset
This commit is contained in:
Matthew Phillips 2022-08-23 13:26:49 -04:00 committed by GitHub
parent adb2079796
commit 27ac6a03a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 146 additions and 62 deletions

View file

@ -0,0 +1,9 @@
---
'astro': patch
---
Deterministic CSS ordering
This makes our CSS link order deterministic. It uses CSS depth; that is how deeply a module import the CSS comes from, in order to determine which CSS is page-level vs. component-level CSS.
This is intended to match dev ordering where, because we do not bundle, the page-level CSS always comes after component-level.

View file

@ -28,7 +28,7 @@ import { createRequest } from '../request.js';
import { matchRoute } from '../routing/match.js'; import { matchRoute } from '../routing/match.js';
import { getOutputFilename } from '../util.js'; import { getOutputFilename } from '../util.js';
import { getOutFile, getOutFolder } from './common.js'; import { getOutFile, getOutFolder } from './common.js';
import { eachPageData, getPageDataByComponent } from './internal.js'; import { eachPageData, getPageDataByComponent, sortedCSS } from './internal.js';
import type { PageBuildData, SingleFileBuiltModule, StaticBuildOptions } from './types'; import type { PageBuildData, SingleFileBuiltModule, StaticBuildOptions } from './types';
import { getTimeStat } from './util.js'; import { getTimeStat } from './util.js';
@ -124,7 +124,7 @@ async function generatePage(
const renderers = ssrEntry.renderers; const renderers = ssrEntry.renderers;
const pageInfo = getPageDataByComponent(internals, pageData.route.component); const pageInfo = getPageDataByComponent(internals, pageData.route.component);
const linkIds: string[] = Array.from(pageInfo?.css ?? []); const linkIds: string[] = sortedCSS(pageData);
const scripts = pageInfo?.hoistedScript ?? null; const scripts = pageInfo?.hoistedScript ?? null;
const pageModule = ssrEntry.pageMap.get(pageData.component); const pageModule = ssrEntry.pageMap.get(pageData.component);
@ -264,7 +264,7 @@ async function generatePath(
astroConfig.base !== '/' astroConfig.base !== '/'
? joinPaths(astroConfig.site?.toString() || 'http://localhost/', astroConfig.base) ? joinPaths(astroConfig.site?.toString() || 'http://localhost/', astroConfig.base)
: astroConfig.site; : astroConfig.site;
const links = createLinkStylesheetElementSet(linkIds.reverse(), site); const links = createLinkStylesheetElementSet(linkIds, site);
const scripts = createModuleScriptsSet(hoistedScripts ? [hoistedScripts] : [], site); const scripts = createModuleScriptsSet(hoistedScripts ? [hoistedScripts] : [], site);
if (astroConfig._ctx.scripts.some((script) => script.stage === 'page')) { if (astroConfig._ctx.scripts.some((script) => script.stage === 'page')) {

View file

@ -5,19 +5,20 @@ import { resolvedPagesVirtualModuleId } from '../app/index.js';
export function* walkParentInfos( export function* walkParentInfos(
id: string, id: string,
ctx: { getModuleInfo: GetModuleInfo }, ctx: { getModuleInfo: GetModuleInfo },
depth = 0,
seen = new Set<string>() seen = new Set<string>()
): Generator<ModuleInfo, void, unknown> { ): Generator<[ModuleInfo, number], void, unknown> {
seen.add(id); seen.add(id);
const info = ctx.getModuleInfo(id); const info = ctx.getModuleInfo(id);
if (info) { if (info) {
yield info; yield [info, depth];
} }
const importers = (info?.importers || []).concat(info?.dynamicImporters || []); const importers = (info?.importers || []).concat(info?.dynamicImporters || []);
for (const imp of importers) { for (const imp of importers) {
if (seen.has(imp)) { if (seen.has(imp)) {
continue; continue;
} }
yield* walkParentInfos(imp, ctx, seen); yield* walkParentInfos(imp, ctx, ++depth, seen);
} }
} }
@ -26,10 +27,10 @@ export function* walkParentInfos(
export function* getTopLevelPages( export function* getTopLevelPages(
id: string, id: string,
ctx: { getModuleInfo: GetModuleInfo } ctx: { getModuleInfo: GetModuleInfo }
): Generator<ModuleInfo, void, unknown> { ): Generator<[ModuleInfo, number], void, unknown> {
for (const info of walkParentInfos(id, ctx)) { for (const res of walkParentInfos(id, ctx)) {
if (info?.importers[0] === resolvedPagesVirtualModuleId) { if (res[0]?.importers[0] === resolvedPagesVirtualModuleId) {
yield info; yield res;
} }
} }
} }

View file

@ -181,3 +181,24 @@ export function hasPageDataByViteID(internals: BuildInternals, viteid: ViteID):
export function* eachPageData(internals: BuildInternals) { export function* eachPageData(internals: BuildInternals) {
yield* internals.pagesByComponent.values(); yield* internals.pagesByComponent.values();
} }
/**
* Sort a page's CSS by depth. A higher depth means that the CSS comes from shared subcomponents.
* A lower depth means it comes directly from the top-level page.
* The return of this function is an array of CSS paths, with shared CSS on top
* and page-level CSS on bottom.
*/
export function sortedCSS(pageData: PageBuildData) {
return Array.from(pageData.css).sort((a, b) => {
let depthA = a[1].depth,
depthB = b[1].depth;
if(depthA === -1) {
return -1;
} else if(depthB === -1) {
return 1;
} else {
return depthA > depthB ? -1 : 1;
}
}).map(([id]) => id);
}

View file

@ -53,7 +53,7 @@ export async function collectPagesData(
component: route.component, component: route.component,
route, route,
moduleSpecifier: '', moduleSpecifier: '',
css: new Set(), css: new Map(),
hoistedScript: undefined, hoistedScript: undefined,
}; };
@ -74,7 +74,7 @@ export async function collectPagesData(
component: route.component, component: route.component,
route, route,
moduleSpecifier: '', moduleSpecifier: '',
css: new Set(), css: new Map(),
hoistedScript: undefined, hoistedScript: undefined,
}; };
} }

View file

@ -18,7 +18,7 @@ export interface PageBuildData {
component: ComponentPath; component: ComponentPath;
route: RouteData; route: RouteData;
moduleSpecifier: string; moduleSpecifier: string;
css: Set<string>; css: Map<string, { depth: number }>;
hoistedScript: { type: 'inline' | 'external'; value: string } | undefined; hoistedScript: { type: 'inline' | 'external'; value: string } | undefined;
} }
export type AllPagesData = Record<ComponentPath, PageBuildData>; export type AllPagesData = Record<ComponentPath, PageBuildData>;

View file

@ -22,7 +22,7 @@ export function vitePluginAnalyzer(internals: BuildInternals): VitePlugin {
} }
if (hoistedScripts.size) { if (hoistedScripts.size) {
for (const pageInfo of getTopLevelPages(from, this)) { for (const [pageInfo] of getTopLevelPages(from, this)) {
const pageId = pageInfo.id; const pageId = pageInfo.id;
for (const hid of hoistedScripts) { for (const hid of hoistedScripts) {
if (pageScripts.has(pageId)) { if (pageScripts.has(pageId)) {
@ -98,7 +98,7 @@ export function vitePluginAnalyzer(internals: BuildInternals): VitePlugin {
clientOnlys.push(cid); clientOnlys.push(cid);
} }
for (const pageInfo of getTopLevelPages(id, this)) { for (const [pageInfo] of getTopLevelPages(id, this)) {
const pageData = getPageDataByViteID(internals, pageInfo.id); const pageData = getPageDataByViteID(internals, pageInfo.id);
if (!pageData) continue; if (!pageData) continue;

View file

@ -42,8 +42,8 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
} }
function createNameForParentPages(id: string, ctx: { getModuleInfo: GetModuleInfo }): string { function createNameForParentPages(id: string, ctx: { getModuleInfo: GetModuleInfo }): string {
const parents = Array.from(getTopLevelPages(id, ctx)).sort(); const parents = Array.from(getTopLevelPages(id, ctx));
const proposedName = parents.map((page) => nameifyPage(page.id)).join('-'); const proposedName = parents.map(([page]) => nameifyPage(page.id)).sort().join('-');
// We don't want absurdedly long chunk names, so if this is too long create a hashed version instead. // We don't want absurdedly long chunk names, so if this is too long create a hashed version instead.
if (proposedName.length <= MAX_NAME_LENGTH) { if (proposedName.length <= MAX_NAME_LENGTH) {
@ -51,7 +51,7 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
} }
const hash = crypto.createHash('sha256'); const hash = crypto.createHash('sha256');
for (const page of parents) { for (const [page] of parents) {
hash.update(page.id, 'utf-8'); hash.update(page.id, 'utf-8');
} }
return hash.digest('hex').slice(0, 8); return hash.digest('hex').slice(0, 8);
@ -61,7 +61,7 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
id: string, id: string,
ctx: { getModuleInfo: GetModuleInfo } ctx: { getModuleInfo: GetModuleInfo }
): Generator<PageBuildData, void, unknown> { ): Generator<PageBuildData, void, unknown> {
for (const info of walkParentInfos(id, ctx)) { for (const [info] of walkParentInfos(id, ctx)) {
yield* getPageDatasByClientOnlyID(internals, info.id); yield* getPageDatasByClientOnlyID(internals, info.id);
} }
} }
@ -107,6 +107,10 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
// Chunks that have the viteMetadata.importedCss are CSS chunks // Chunks that have the viteMetadata.importedCss are CSS chunks
if (meta.importedCss.size) { if (meta.importedCss.size) {
//console.log(meta.importedCss, c.fileName);
debugger;
// For the client build, client:only styles need to be mapped // For the client build, client:only styles need to be mapped
// over to their page. For this chunk, determine if it's a child of a // over to their page. For this chunk, determine if it's a child of a
// client:only component and if so, add its CSS to the page it belongs to. // client:only component and if so, add its CSS to the page it belongs to.
@ -114,7 +118,7 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
for (const [id] of Object.entries(c.modules)) { for (const [id] of Object.entries(c.modules)) {
for (const pageData of getParentClientOnlys(id, this)) { for (const pageData of getParentClientOnlys(id, this)) {
for (const importedCssImport of meta.importedCss) { for (const importedCssImport of meta.importedCss) {
pageData.css.add(importedCssImport); pageData.css.set(importedCssImport, { depth: -1 });
} }
} }
} }
@ -122,11 +126,23 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
// For this CSS chunk, walk parents until you find a page. Add the CSS to that page. // For this CSS chunk, walk parents until you find a page. Add the CSS to that page.
for (const [id] of Object.entries(c.modules)) { for (const [id] of Object.entries(c.modules)) {
for (const pageInfo of getTopLevelPages(id, this)) { debugger;
for (const [pageInfo, depth] of getTopLevelPages(id, this)) {
const pageViteID = pageInfo.id; const pageViteID = pageInfo.id;
const pageData = getPageDataByViteID(internals, pageViteID); const pageData = getPageDataByViteID(internals, pageViteID);
for (const importedCssImport of meta.importedCss) { for (const importedCssImport of meta.importedCss) {
pageData?.css.add(importedCssImport); // CSS is prioritized based on depth. Shared CSS has a higher depth due to being imported by multiple pages.
// Depth info is used when sorting the links on the page.
if(pageData?.css.has(importedCssImport)) {
// eslint-disable-next-line
const cssInfo = pageData?.css.get(importedCssImport)!;
if(depth < cssInfo.depth) {
cssInfo.depth = depth;
}
} else {
pageData?.css.set(importedCssImport, { depth });
}
} }
} }
} }

View file

@ -12,7 +12,7 @@ import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-sc
import { pagesVirtualModuleId } from '../app/index.js'; import { pagesVirtualModuleId } from '../app/index.js';
import { serializeRouteData } from '../routing/index.js'; import { serializeRouteData } from '../routing/index.js';
import { addRollupInput } from './add-rollup-input.js'; import { addRollupInput } from './add-rollup-input.js';
import { eachPageData } from './internal.js'; import { eachPageData, sortedCSS } from './internal.js';
export const virtualModuleId = '@astrojs-ssr-virtual-entry'; export const virtualModuleId = '@astrojs-ssr-virtual-entry';
const resolvedVirtualModuleId = '\0' + virtualModuleId; const resolvedVirtualModuleId = '\0' + virtualModuleId;
@ -139,7 +139,7 @@ function buildManifest(
routes.push({ routes.push({
file: '', file: '',
links: Array.from(pageData.css).reverse(), links: sortedCSS(pageData),
scripts: [ scripts: [
...scripts, ...scripts,
...astroConfig._ctx.scripts ...astroConfig._ctx.scripts

View file

@ -4,13 +4,6 @@ import { loadFixture } from './test-utils.js';
import testAdapter from './test-adapter.js'; import testAdapter from './test-adapter.js';
describe('CSS production ordering', () => { describe('CSS production ordering', () => {
let staticHTML, serverHTML;
let staticCSS, serverCSS;
const commonConfig = Object.freeze({
root: './fixtures/css-order/',
});
function getLinks(html) { function getLinks(html) {
let $ = cheerio.load(html); let $ = cheerio.load(html);
let out = []; let out = [];
@ -20,42 +13,85 @@ describe('CSS production ordering', () => {
return out; return out;
} }
before(async () => { /**
let fixture = await loadFixture({ ...commonConfig }); *
await fixture.build(); * @param {import('./test-utils').Fixture} fixture
staticHTML = await fixture.readFile('/one/index.html'); * @param {string} href
staticCSS = await Promise.all( * @returns {Promise<{ href: string; css: string; }>}
getLinks(staticHTML).map(async (href) => { */
const css = await fixture.readFile(href); async function getLinkContent(fixture, href) {
return { href, css }; const css = await fixture.readFile(href);
}) return { href, css };
); }
});
before(async () => { describe('SSG and SSR parity', () => {
let fixture = await loadFixture({ let staticHTML, serverHTML;
...commonConfig, let staticCSS, serverCSS;
adapter: testAdapter(),
output: 'server', const commonConfig = Object.freeze({
root: './fixtures/css-order/',
}); });
await fixture.build();
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/one'); before(async () => {
const response = await app.render(request); let fixture = await loadFixture({ ...commonConfig });
serverHTML = await response.text(); await fixture.build();
serverCSS = await Promise.all( staticHTML = await fixture.readFile('/one/index.html');
getLinks(serverHTML).map(async (href) => { staticCSS = await Promise.all(
const css = await fixture.readFile(`/client${href}`); getLinks(staticHTML).map(href => getLinkContent(fixture, href))
return { href, css }; );
}) });
);
before(async () => {
let fixture = await loadFixture({
...commonConfig,
adapter: testAdapter(),
output: 'server',
});
await fixture.build();
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/one');
const response = await app.render(request);
serverHTML = await response.text();
serverCSS = await Promise.all(
getLinks(serverHTML).map(async (href) => {
const css = await fixture.readFile(`/client${href}`);
return { href, css };
})
);
});
it('is in the same order for output: server and static', async () => {
const staticContent = staticCSS.map((o) => o.css);
const serverContent = serverCSS.map((o) => o.css);
expect(staticContent).to.deep.equal(serverContent);
});
}); });
it('is in the same order for output: server and static', async () => { describe('Page vs. Shared CSS', () => {
const staticContent = staticCSS.map((o) => o.css); /** @type {import('./test-utils').Fixture} */
const serverContent = serverCSS.map((o) => o.css); let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/css-order/',
});
await fixture.build();
});
expect(staticContent).to.deep.equal(serverContent); it('Page level CSS is defined lower in the page', async () => {
let html = await fixture.readFile('/two/index.html');
const content = await Promise.all(
getLinks(html).map(href => getLinkContent(fixture, href))
);
expect(content).to.have.a.lengthOf(2, 'there are 2 stylesheets');
const [,last] = content;
expect(last.css).to.match(/#00f/);
});
}); });
}); });

View file

@ -8,6 +8,7 @@ import CommonHead from '../components/CommonHead.astro';
<style> <style>
body { body {
margin: 2px; margin: 2px;
color: blue;
} }
</style> </style>
</head> </head>