Refactor CSS preprocess and HMR (#4422)

This commit is contained in:
Bjorn Lu 2022-08-24 21:06:48 +08:00 committed by GitHub
parent ea4d68c0b0
commit 85646918ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 118 additions and 142 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Refactor CSS preprocess and deps HMR

View file

@ -1,40 +1,21 @@
import type { TransformResult } from '@astrojs/compiler'; import type { TransformResult } from '@astrojs/compiler';
import type { PluginContext, SourceMapInput } from 'rollup'; import type { PluginContext, SourceMapInput } from 'rollup';
import type { ViteDevServer } from 'vite';
import type { AstroConfig } from '../@types/astro'; import type { AstroConfig } from '../@types/astro';
import type { TransformHook } from './styles'; import type { TransformStyleWithVite } from './styles';
import { transform } from '@astrojs/compiler'; import { transform } from '@astrojs/compiler';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { AstroErrorCodes } from '../core/errors.js'; import { AstroErrorCodes } from '../core/errors.js';
import { prependForwardSlash } from '../core/path.js'; import { prependForwardSlash } from '../core/path.js';
import { viteID } from '../core/util.js'; import { viteID } from '../core/util.js';
import { transformWithVite } from './styles.js';
type CompilationCache = Map<string, CompileResult>; type CompilationCache = Map<string, CompileResult>;
type CompileResult = TransformResult & { type CompileResult = TransformResult & {
rawCSSDeps: Set<string>; cssDeps: Set<string>;
source: string; source: string;
}; };
/**
* Note: this is currently needed because Astro is directly using a Vite internal CSS transform. This gives us
* some nice features out of the box, but at the expense of also running Vite's CSS postprocessing build step,
* which does some things that we don't like, like resolving/handling `@import` too early. This function pulls
* out the `@import` tags to be added back later, and then finally handled correctly by Vite.
*
* In the future, we should remove this workaround and most likely implement our own Astro style handling without
* having to hook into Vite's internals.
*/
function createImportPlaceholder(spec: string) {
// Note: We keep this small so that we can attempt to exactly match the # of characters in the original @import.
// This keeps sourcemaps accurate (to the best of our ability) at the intermediate step where this appears.
// -> `@import '${spec}';`;
return `/*IMPORT:${spec}*/`;
}
function safelyReplaceImportPlaceholder(code: string) {
return code.replace(/\/\*IMPORT\:(.*?)\*\//g, `@import '$1';`);
}
const configCache = new WeakMap<AstroConfig, CompilationCache>(); const configCache = new WeakMap<AstroConfig, CompilationCache>();
export interface CompileProps { export interface CompileProps {
@ -43,7 +24,8 @@ export interface CompileProps {
moduleId: string; moduleId: string;
source: string; source: string;
ssr: boolean; ssr: boolean;
viteTransform: TransformHook; transformStyleWithVite: TransformStyleWithVite;
viteDevServer?: ViteDevServer;
pluginContext: PluginContext; pluginContext: PluginContext;
} }
@ -63,13 +45,19 @@ async function compile({
moduleId, moduleId,
source, source,
ssr, ssr,
viteTransform, transformStyleWithVite,
viteDevServer,
pluginContext, pluginContext,
}: CompileProps): Promise<CompileResult> { }: CompileProps): Promise<CompileResult> {
const normalizedID = getNormalizedID(filename); const normalizedID = getNormalizedID(filename);
let rawCSSDeps = new Set<string>(); let cssDeps = new Set<string>();
let cssTransformError: Error | undefined; let cssTransformError: Error | undefined;
// handleHotUpdate doesn't have `addWatchFile` used by transformStyleWithVite.
if (!pluginContext.addWatchFile) {
pluginContext.addWatchFile = () => {};
}
// Transform from `.astro` to valid `.ts` // Transform from `.astro` to valid `.ts`
// use `sourcemap: "both"` so that sourcemap is included in the code // use `sourcemap: "both"` so that sourcemap is included in the code
// result passed to esbuild, but also available in the catch handler. // result passed to esbuild, but also available in the catch handler.
@ -89,32 +77,21 @@ async function compile({
const lang = `.${attrs?.lang || 'css'}`.toLowerCase(); const lang = `.${attrs?.lang || 'css'}`.toLowerCase();
try { try {
// In the static build, grab any @import as CSS dependencies for HMR. const result = await transformStyleWithVite.call(pluginContext, {
value.replace(
/(?:@import)\s(?:url\()?\s?["\'](.*?)["\']\s?\)?(?:[^;]*);?/gi,
(match, spec) => {
rawCSSDeps.add(spec);
// If the language is CSS: prevent `@import` inlining to prevent scoping of imports.
// Otherwise: Sass, etc. need to see imports for variables, so leave in for their compiler to handle.
if (lang === '.css') {
return createImportPlaceholder(spec);
} else {
return match;
}
}
);
const result = await transformWithVite({
value,
lang,
id: normalizedID, id: normalizedID,
transformHook: viteTransform, source: value,
lang,
ssr, ssr,
pluginContext, viteDevServer,
}); });
let map: SourceMapInput | undefined;
if (!result) return null as any; // TODO: add type in compiler to fix "any" if (!result) return null as any; // TODO: add type in compiler to fix "any"
for (const dep of result.deps) {
cssDeps.add(dep);
}
let map: SourceMapInput | undefined;
if (result.map) { if (result.map) {
if (typeof result.map === 'string') { if (typeof result.map === 'string') {
map = result.map; map = result.map;
@ -122,8 +99,8 @@ async function compile({
map = result.map.toString(); map = result.map.toString();
} }
} }
const code = safelyReplaceImportPlaceholder(result.code);
return { code, map }; return { code: result.code, map };
} catch (err) { } catch (err) {
// save error to throw in plugin context // save error to throw in plugin context
cssTransformError = err as any; cssTransformError = err as any;
@ -147,8 +124,8 @@ async function compile({
}); });
const compileResult: CompileResult = Object.create(transformResult, { const compileResult: CompileResult = Object.create(transformResult, {
rawCSSDeps: { cssDeps: {
value: rawCSSDeps, value: cssDeps,
}, },
source: { source: {
value: source, value: source,

View file

@ -1,6 +1,5 @@
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import type { PluginContext as RollupPluginContext, ResolvedId } from 'rollup'; import type { HmrContext, ModuleNode } from 'vite';
import type { HmrContext, ModuleNode, ViteDevServer } from 'vite';
import type { AstroConfig } from '../@types/astro'; import type { AstroConfig } from '../@types/astro';
import type { LogOptions } from '../core/logger/core.js'; import type { LogOptions } from '../core/logger/core.js';
import { info } from '../core/logger/core.js'; import { info } from '../core/logger/core.js';
@ -8,49 +7,6 @@ import * as msg from '../core/messages.js';
import { cachedCompilation, invalidateCompilation, isCached } from './compile.js'; import { cachedCompilation, invalidateCompilation, isCached } from './compile.js';
import { isAstroScript } from './query.js'; import { isAstroScript } from './query.js';
interface TrackCSSDependenciesOptions {
viteDevServer: ViteDevServer | null;
filename: string;
id: string;
deps: Set<string>;
}
export async function trackCSSDependencies(
this: RollupPluginContext,
opts: TrackCSSDependenciesOptions
): Promise<void> {
const { viteDevServer, filename, deps, id } = opts;
// Dev, register CSS dependencies for HMR.
if (viteDevServer) {
const mod = viteDevServer.moduleGraph.getModuleById(id);
if (mod) {
const cssDeps = (
await Promise.all(
Array.from(deps).map((spec) => {
return this.resolve(spec, id);
})
)
)
.filter(Boolean)
.map((dep) => (dep as ResolvedId).id);
const { moduleGraph } = viteDevServer;
// record deps in the module graph so edits to @import css can trigger
// main import to hot update
const depModules = new Set(mod.importedModules);
for (const dep of cssDeps) {
depModules.add(moduleGraph.createFileOnlyEntry(dep));
}
// Update the module graph, telling it about our CSS deps.
moduleGraph.updateModuleInfo(mod, depModules, new Map(), new Set(), new Set(), true);
for (const dep of cssDeps) {
this.addWatchFile(dep);
}
}
}
}
const PKG_PREFIX = new URL('../../', import.meta.url); const PKG_PREFIX = new URL('../../', import.meta.url);
const isPkgFile = (id: string | null) => { const isPkgFile = (id: string | null) => {
return id?.startsWith(fileURLToPath(PKG_PREFIX)) || id?.startsWith(PKG_PREFIX.pathname); return id?.startsWith(fileURLToPath(PKG_PREFIX)) || id?.startsWith(PKG_PREFIX.pathname);
@ -125,6 +81,13 @@ export async function handleHotUpdate(
for (const file of files) { for (const file of files) {
if (isStyleOnlyChange && file === ctx.file) continue; if (isStyleOnlyChange && file === ctx.file) continue;
invalidateCompilation(config, file); invalidateCompilation(config, file);
// If `ctx.file` is depended by an .astro file, e.g via `this.addWatchFile`,
// Vite doesn't trigger updating that .astro file by default. See:
// https://github.com/vitejs/vite/issues/3216
// For now, we trigger the change manually here.
if (file.endsWith('.astro')) {
ctx.server.moduleGraph.onFileChange(file);
}
} }
// Bugfix: sometimes style URLs get normalized and end with `lang.css=` // Bugfix: sometimes style URLs get normalized and end with `lang.css=`

View file

@ -11,9 +11,9 @@ import { fileURLToPath } from 'url';
import { isRelativePath, startsWithForwardSlash } from '../core/path.js'; import { isRelativePath, startsWithForwardSlash } from '../core/path.js';
import { getFileInfo } from '../vite-plugin-utils/index.js'; import { getFileInfo } from '../vite-plugin-utils/index.js';
import { cachedCompilation, CompileProps, getCachedSource } from './compile.js'; import { cachedCompilation, CompileProps, getCachedSource } from './compile.js';
import { handleHotUpdate, trackCSSDependencies } from './hmr.js'; import { handleHotUpdate } from './hmr.js';
import { parseAstroRequest, ParsedRequestResult } from './query.js'; import { parseAstroRequest, ParsedRequestResult } from './query.js';
import { getViteTransform, TransformHook } from './styles.js'; import { createTransformStyleWithViteFn, TransformStyleWithVite } from './styles.js';
const FRONTMATTER_PARSE_REGEXP = /^\-\-\-(.*)^\-\-\-/ms; const FRONTMATTER_PARSE_REGEXP = /^\-\-\-(.*)^\-\-\-/ms;
interface AstroPluginOptions { interface AstroPluginOptions {
@ -38,8 +38,8 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
} }
let resolvedConfig: vite.ResolvedConfig; let resolvedConfig: vite.ResolvedConfig;
let viteTransform: TransformHook; let transformStyleWithVite: TransformStyleWithVite;
let viteDevServer: vite.ViteDevServer | null = null; let viteDevServer: vite.ViteDevServer | undefined;
// Variables for determing if an id starts with /src... // Variables for determing if an id starts with /src...
const srcRootWeb = config.srcDir.pathname.slice(config.root.pathname.length - 1); const srcRootWeb = config.srcDir.pathname.slice(config.root.pathname.length - 1);
@ -60,7 +60,7 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
enforce: 'pre', // run transforms before other plugins can enforce: 'pre', // run transforms before other plugins can
configResolved(_resolvedConfig) { configResolved(_resolvedConfig) {
resolvedConfig = _resolvedConfig; resolvedConfig = _resolvedConfig;
viteTransform = getViteTransform(resolvedConfig); transformStyleWithVite = createTransformStyleWithViteFn(_resolvedConfig);
}, },
configureServer(server) { configureServer(server) {
viteDevServer = server; viteDevServer = server;
@ -118,7 +118,8 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
moduleId: id, moduleId: id,
source, source,
ssr: Boolean(opts?.ssr), ssr: Boolean(opts?.ssr),
viteTransform, transformStyleWithVite,
viteDevServer,
pluginContext: this, pluginContext: this,
}; };
@ -129,14 +130,6 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
} }
const transformResult = await cachedCompilation(compileProps); const transformResult = await cachedCompilation(compileProps);
// Track any CSS dependencies so that HMR is triggered when they change.
await trackCSSDependencies.call(this, {
viteDevServer,
id,
filename,
deps: transformResult.rawCSSDeps,
});
const csses = transformResult.css; const csses = transformResult.css;
const code = csses[query.index]; const code = csses[query.index];
@ -224,7 +217,8 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
moduleId: id, moduleId: id,
source, source,
ssr: Boolean(opts?.ssr), ssr: Boolean(opts?.ssr),
viteTransform, transformStyleWithVite,
viteDevServer,
pluginContext: this, pluginContext: this,
}; };
@ -232,6 +226,10 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
const transformResult = await cachedCompilation(compileProps); const transformResult = await cachedCompilation(compileProps);
const { fileId: file, fileUrl: url } = getFileInfo(id, config); const { fileId: file, fileUrl: url } = getFileInfo(id, config);
for (const dep of transformResult.cssDeps) {
this.addWatchFile(dep);
}
// Compile all TypeScript to JavaScript. // Compile all TypeScript to JavaScript.
// Also, catches invalid JS/TS in the compiled output before returning. // Also, catches invalid JS/TS in the compiled output before returning.
const { code, map } = await esbuild.transform(transformResult.code, { const { code, map } = await esbuild.transform(transformResult.code, {
@ -355,7 +353,8 @@ ${source}
moduleId: context.file, moduleId: context.file,
source: await context.read(), source: await context.read(),
ssr: true, ssr: true,
viteTransform, transformStyleWithVite,
viteDevServer,
pluginContext: this, pluginContext: this,
}; };
const compile = () => cachedCompilation(compileProps); const compile = () => cachedCompilation(compileProps);

View file

@ -9,34 +9,61 @@ export type TransformHook = (
ssr?: boolean ssr?: boolean
) => Promise<vite.TransformResult>; ) => Promise<vite.TransformResult>;
/** Load vite:css transform() hook */ interface TransformStyleWithViteOptions {
export function getViteTransform(viteConfig: vite.ResolvedConfig): TransformHook {
const viteCSSPlugin = viteConfig.plugins.find(({ name }) => name === 'vite:css');
if (!viteCSSPlugin) throw new Error(`vite:css plugin couldnt be found`);
if (!viteCSSPlugin.transform) throw new Error(`vite:css has no transform() hook`);
return viteCSSPlugin.transform as any;
}
interface TransformWithViteOptions {
value: string;
lang: string;
id: string; id: string;
transformHook: TransformHook; source: string;
pluginContext: PluginContext; lang: string;
ssr?: boolean; ssr?: boolean;
viteDevServer?: vite.ViteDevServer;
} }
/** Transform style using Vite hook */ export interface TransformStyleWithVite {
export async function transformWithVite({ (options: TransformStyleWithViteOptions): Promise<{
value, code: string;
lang, map: vite.TransformResult['map'];
transformHook, deps: Set<string>;
id, } | null>;
ssr, }
pluginContext,
}: TransformWithViteOptions): Promise<vite.TransformResult | null> { export function createTransformStyleWithViteFn(
if (!STYLE_EXTENSIONS.has(lang)) { viteConfig: vite.ResolvedConfig
return null; // only preprocess langs supported by Vite ): TransformStyleWithVite {
} const viteCSSPlugin = viteConfig.plugins.find(({ name }) => name === 'vite:css');
return transformHook.call(pluginContext, value, id + `?astro&type=style&lang${lang}`, ssr); if (!viteCSSPlugin) throw new Error(`vite:css plugin couldn't be found`);
if (!viteCSSPlugin.transform) throw new Error(`vite:css has no transform() hook`);
const transformCss = viteCSSPlugin.transform as TransformHook;
return async function (
this: PluginContext,
{ id, source, lang, ssr, viteDevServer }: TransformStyleWithViteOptions
) {
if (!STYLE_EXTENSIONS.has(lang)) {
return null; // only preprocess langs supported by Vite
}
// Id must end with valid CSS extension for vite:css to process
const styleId = `${id}?astro&type=style&lang${lang}`;
viteDevServer?.moduleGraph.ensureEntryFromUrl(styleId, ssr, false);
const transformResult = await transformCss.call(this, source, styleId, ssr);
// NOTE: only `code` and `map` are returned by vite:css
const { code, map } = transformResult;
const deps = new Set<string>();
// Get deps from module created while transforming the styleId by Vite.
// In build, it's fine that we skip this as it's used by HMR only.
const mod = viteDevServer?.moduleGraph.getModuleById(styleId);
if (mod) {
// Get all @import references
for (const imported of mod.importedModules) {
if (imported.file) {
deps.add(imported.file);
}
}
}
return { code, map, deps };
};
} }

View file

@ -4,13 +4,16 @@ import esbuild from 'esbuild';
import fs from 'fs'; import fs from 'fs';
import matter from 'gray-matter'; import matter from 'gray-matter';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import type { Plugin } from 'vite'; import type { Plugin, ViteDevServer } from 'vite';
import type { AstroConfig } from '../@types/astro'; import type { AstroConfig } from '../@types/astro';
import { pagesVirtualModuleId } from '../core/app/index.js'; import { pagesVirtualModuleId } from '../core/app/index.js';
import { collectErrorMetadata } from '../core/errors.js'; import { collectErrorMetadata } from '../core/errors.js';
import type { LogOptions } from '../core/logger/core.js'; import type { LogOptions } from '../core/logger/core.js';
import { cachedCompilation, CompileProps } from '../vite-plugin-astro/compile.js'; import { cachedCompilation, CompileProps } from '../vite-plugin-astro/compile.js';
import { getViteTransform, TransformHook } from '../vite-plugin-astro/styles.js'; import {
createTransformStyleWithViteFn,
TransformStyleWithVite,
} from '../vite-plugin-astro/styles.js';
import type { PluginMetadata as AstroPluginMetadata } from '../vite-plugin-astro/types'; import type { PluginMetadata as AstroPluginMetadata } from '../vite-plugin-astro/types';
import { getFileInfo } from '../vite-plugin-utils/index.js'; import { getFileInfo } from '../vite-plugin-utils/index.js';
@ -61,13 +64,14 @@ export default function markdown({ config, logging }: AstroPluginOptions): Plugi
return false; return false;
} }
let viteTransform: TransformHook; let transformStyleWithVite: TransformStyleWithVite;
let viteDevServer: ViteDevServer | undefined;
return { return {
name: 'astro:markdown', name: 'astro:markdown',
enforce: 'pre', enforce: 'pre',
configResolved(_resolvedConfig) { configResolved(_resolvedConfig) {
viteTransform = getViteTransform(_resolvedConfig); transformStyleWithVite = createTransformStyleWithViteFn(_resolvedConfig);
}, },
async resolveId(id, importer, options) { async resolveId(id, importer, options) {
// Resolve any .md files with the `?content` cache buster. This should only come from // Resolve any .md files with the `?content` cache buster. This should only come from
@ -205,7 +209,8 @@ ${setup}`.trim();
moduleId: id, moduleId: id,
source: astroResult, source: astroResult,
ssr: Boolean(opts?.ssr), ssr: Boolean(opts?.ssr),
viteTransform, transformStyleWithVite,
viteDevServer,
pluginContext: this, pluginContext: this,
}; };