feat: restart dev server when tsconfig and tailwind config changes (#4947)

* First run

* Works with tailwind!

* Added TSConfig to watchlist

* Changeset

* Fix eslint

* Renamed `isConfigReload` --> `isRestart` and `injectWatchTarget` --> `addWatchFile`

* Refactored watchTargets to watchFiles

* Refactor createSettings

* addWatchFile now accepts URL

* Fix getViteConfig

* Expanded description of the change

Co-authored-by: Matthew Phillips <matthew@skypack.dev>
This commit is contained in:
Juan Martín Seery 2022-10-12 12:36:33 -03:00 committed by GitHub
parent 5412c0c114
commit a5e3ecc803
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 129 additions and 74 deletions

View file

@ -0,0 +1,6 @@
---
'astro': minor
---
- Added `isRestart` and `addWatchFile` to integration step `isRestart`.
- Restart dev server automatically when tsconfig changes.

View file

@ -0,0 +1,7 @@
---
'@astrojs/tailwind': minor
---
## HMR on config file changes
New in this release is the ability for config changes to automatically reflect via HMR. Now when you edit your `tsconfig.json` or `tailwind.config.js` configs, the changes will reload automatically without the need to restart your dev server.

View file

@ -891,6 +891,7 @@ export interface AstroSettings {
}[]; }[];
tsConfig: TsConfigJson | undefined; tsConfig: TsConfigJson | undefined;
tsConfigPath: string | undefined; tsConfigPath: string | undefined;
watchFiles: string[];
} }
export type AsyncRendererComponentFn<U> = ( export type AsyncRendererComponentFn<U> = (
@ -1142,8 +1143,10 @@ export interface AstroIntegration {
'astro:config:setup'?: (options: { 'astro:config:setup'?: (options: {
config: AstroConfig; config: AstroConfig;
command: 'dev' | 'build'; command: 'dev' | 'build';
isRestart: boolean;
updateConfig: (newConfig: Record<string, any>) => void; updateConfig: (newConfig: Record<string, any>) => void;
addRenderer: (renderer: AstroRenderer) => void; addRenderer: (renderer: AstroRenderer) => void;
addWatchFile: (path: URL | string) => void;
injectScript: (stage: InjectedScriptStage, content: string) => void; injectScript: (stage: InjectedScriptStage, content: string) => void;
injectRoute: (injectRoute: InjectedRoute) => void; injectRoute: (injectRoute: InjectedRoute) => void;
// TODO: Add support for `injectElement()` for full HTML element injection, not just scripts. // TODO: Add support for `injectElement()` for full HTML element injection, not just scripts.

View file

@ -9,7 +9,6 @@ import add from '../core/add/index.js';
import build from '../core/build/index.js'; import build from '../core/build/index.js';
import { import {
createSettings, createSettings,
loadTSConfig,
openConfig, openConfig,
resolveConfigPath, resolveConfigPath,
resolveFlags, resolveFlags,
@ -168,12 +167,7 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {
}); });
if (!initialAstroConfig) return; if (!initialAstroConfig) return;
telemetry.record(event.eventCliSession(cmd, initialUserConfig, flags)); telemetry.record(event.eventCliSession(cmd, initialUserConfig, flags));
let initialTsConfig = loadTSConfig(root); let settings = createSettings(initialAstroConfig, root);
let settings = createSettings({
config: initialAstroConfig,
tsConfig: initialTsConfig?.config,
tsConfigPath: initialTsConfig?.path,
});
// Common CLI Commands: // Common CLI Commands:
// These commands run normally. All commands are assumed to have been handled // These commands run normally. All commands are assumed to have been handled
@ -191,42 +185,48 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {
const handleServerRestart = (logMsg: string) => const handleServerRestart = (logMsg: string) =>
async function (changedFile: string) { async function (changedFile: string) {
if ( if (restartInFlight) return;
!restartInFlight &&
(configFlag let shouldRestart = false;
? // If --config is specified, only watch changes for this file
configFlagPath && normalizePath(configFlagPath) === normalizePath(changedFile) // If the config file changed, reload the config and restart the server.
: // Otherwise, watch for any astro.config.* file changes in project root shouldRestart = configFlag
new RegExp( ? // If --config is specified, only watch changes for this file
`${normalizePath(resolvedRoot)}.*astro\.config\.((mjs)|(cjs)|(js)|(ts))$` !!configFlagPath && normalizePath(configFlagPath) === normalizePath(changedFile)
).test(normalizePath(changedFile))) : // Otherwise, watch for any astro.config.* file changes in project root
) { new RegExp(
restartInFlight = true; `${normalizePath(resolvedRoot)}.*astro\.config\.((mjs)|(cjs)|(js)|(ts))$`
console.clear(); ).test(normalizePath(changedFile));
try {
const newConfig = await openConfig({ if (!shouldRestart && settings.watchFiles.length > 0) {
cwd: root, // If the config file didn't change, check if any of the watched files changed.
flags, shouldRestart = settings.watchFiles.some(
cmd, (path) => normalizePath(path) === normalizePath(changedFile)
logging, );
isConfigReload: true, }
});
info(logging, 'astro', logMsg + '\n'); if (!shouldRestart) return;
let astroConfig = newConfig.astroConfig;
let tsconfig = loadTSConfig(root); restartInFlight = true;
settings = createSettings({ console.clear();
config: astroConfig, try {
tsConfig: tsconfig?.config, const newConfig = await openConfig({
tsConfigPath: tsconfig?.path, cwd: root,
}); flags,
await stop(); cmd,
await startDevServer({ isRestart: true }); logging,
} catch (e) { isRestart: true,
await handleConfigError(e, { cwd: root, flags, logging }); });
await stop(); info(logging, 'astro', logMsg + '\n');
info(logging, 'astro', 'Continuing with previous valid configuration\n'); let astroConfig = newConfig.astroConfig;
await startDevServer({ isRestart: true }); settings = createSettings(astroConfig, root);
} await stop();
await startDevServer({ isRestart: true });
} catch (e) {
await handleConfigError(e, { cwd: root, flags, logging });
await stop();
info(logging, 'astro', 'Continuing with previous valid configuration\n');
await startDevServer({ isRestart: true });
} }
}; };

View file

@ -16,7 +16,7 @@ export function getViteConfig(inlineConfig: UserConfig) {
const [ const [
{ mergeConfig }, { mergeConfig },
{ nodeLogDestination }, { nodeLogDestination },
{ openConfig, createSettings, loadTSConfig }, { openConfig, createSettings },
{ createVite }, { createVite },
{ runHookConfigSetup, runHookConfigDone }, { runHookConfigSetup, runHookConfigDone },
] = await Promise.all([ ] = await Promise.all([
@ -34,12 +34,7 @@ export function getViteConfig(inlineConfig: UserConfig) {
cmd, cmd,
logging, logging,
}); });
const initialTsConfig = loadTSConfig(inlineConfig.root); const settings = createSettings(config, inlineConfig.root);
const settings = createSettings({
config,
tsConfig: initialTsConfig?.config,
tsConfigPath: initialTsConfig?.path,
});
await runHookConfigSetup({ settings, command: cmd, logging }); await runHookConfigSetup({ settings, command: cmd, logging });
const viteConfig = await createVite( const viteConfig = await createVite(
{ {

View file

@ -135,7 +135,7 @@ interface LoadConfigOptions {
validate?: boolean; validate?: boolean;
logging: LogOptions; logging: LogOptions;
/** Invalidate when reloading a previously loaded config */ /** Invalidate when reloading a previously loaded config */
isConfigReload?: boolean; isRestart?: boolean;
} }
/** /**
@ -222,7 +222,7 @@ async function tryLoadConfig(
flags: configOptions.flags, flags: configOptions.flags,
}); });
if (!configPath) return undefined; if (!configPath) return undefined;
if (configOptions.isConfigReload) { if (configOptions.isRestart) {
// Hack: Write config to temporary file at project root // Hack: Write config to temporary file at project root
// This invalidates and reloads file contents when using ESM imports or "resolve" // This invalidates and reloads file contents when using ESM imports or "resolve"
const tempConfigPath = path.join( const tempConfigPath = path.join(

View file

@ -1,24 +1,21 @@
import type { TsConfigJson } from 'tsconfig-resolver';
import type { AstroConfig, AstroSettings } from '../../@types/astro'; import type { AstroConfig, AstroSettings } from '../../@types/astro';
import jsxRenderer from '../../jsx/renderer.js'; import jsxRenderer from '../../jsx/renderer.js';
import { loadTSConfig } from './tsconfig.js';
export interface CreateSettings { export function createSettings(config: AstroConfig, cwd?: string): AstroSettings {
config: AstroConfig; const tsconfig = loadTSConfig(cwd);
tsConfig?: TsConfigJson;
tsConfigPath?: string;
}
export function createSettings({ config, tsConfig, tsConfigPath }: CreateSettings): AstroSettings {
return { return {
config, config,
tsConfig, tsConfig: tsconfig?.config,
tsConfigPath, tsConfigPath: tsconfig?.path,
adapter: undefined, adapter: undefined,
injectedRoutes: [], injectedRoutes: [],
pageExtensions: ['.astro', '.md', '.html'], pageExtensions: ['.astro', '.md', '.html'],
renderers: [jsxRenderer], renderers: [jsxRenderer],
scripts: [], scripts: [],
watchFiles: tsconfig?.exists ? [tsconfig.path, ...tsconfig.extendedPaths] : [],
}; };
} }

View file

@ -35,7 +35,12 @@ export default async function dev(
const devStart = performance.now(); const devStart = performance.now();
applyPolyfill(); applyPolyfill();
await options.telemetry.record([]); await options.telemetry.record([]);
settings = await runHookConfigSetup({ settings, command: 'dev', logging: options.logging }); settings = await runHookConfigSetup({
settings,
command: 'dev',
logging: options.logging,
isRestart: options.isRestart,
});
const { host, port } = settings.config.server; const { host, port } = settings.config.server;
const { isRestart = false } = options; const { isRestart = false } = options;

View file

@ -1,5 +1,6 @@
import { bold } from 'kleur/colors'; import { bold } from 'kleur/colors';
import type { AddressInfo } from 'net'; import type { AddressInfo } from 'net';
import { fileURLToPath } from 'node:url';
import type { InlineConfig, ViteDevServer } from 'vite'; import type { InlineConfig, ViteDevServer } from 'vite';
import { import {
AstroConfig, AstroConfig,
@ -37,10 +38,12 @@ export async function runHookConfigSetup({
settings, settings,
command, command,
logging, logging,
isRestart = false,
}: { }: {
settings: AstroSettings; settings: AstroSettings;
command: 'dev' | 'build'; command: 'dev' | 'build';
logging: LogOptions; logging: LogOptions;
isRestart?: boolean;
}): Promise<AstroSettings> { }): Promise<AstroSettings> {
// An adapter is an integration, so if one is provided push it. // An adapter is an integration, so if one is provided push it.
if (settings.config.adapter) { if (settings.config.adapter) {
@ -66,6 +69,7 @@ export async function runHookConfigSetup({
const hooks: HookParameters<'astro:config:setup'> = { const hooks: HookParameters<'astro:config:setup'> = {
config: updatedConfig, config: updatedConfig,
command, command,
isRestart,
addRenderer(renderer: AstroRenderer) { addRenderer(renderer: AstroRenderer) {
if (!renderer.name) { if (!renderer.name) {
throw new Error(`Integration ${bold(integration.name)} has an unnamed renderer.`); throw new Error(`Integration ${bold(integration.name)} has an unnamed renderer.`);
@ -86,6 +90,9 @@ export async function runHookConfigSetup({
injectRoute: (injectRoute) => { injectRoute: (injectRoute) => {
updatedSettings.injectedRoutes.push(injectRoute); updatedSettings.injectedRoutes.push(injectRoute);
}, },
addWatchFile: (path) => {
updatedSettings.watchFiles.push(path instanceof URL ? fileURLToPath(path) : path);
},
}; };
// Semi-private `addPageExtension` hook // Semi-private `addPageExtension` hook
function addPageExtension(...input: (string | string[])[]) { function addPageExtension(...input: (string | string[])[]) {

View file

@ -3,7 +3,7 @@ import { polyfill } from '@astrojs/webapi';
import fs from 'fs'; import fs from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { loadConfig } from '../dist/core/config/config.js'; import { loadConfig } from '../dist/core/config/config.js';
import { createSettings, loadTSConfig } from '../dist/core/config/index.js'; import { createSettings } from '../dist/core/config/index.js';
import dev from '../dist/core/dev/index.js'; import dev from '../dist/core/dev/index.js';
import build from '../dist/core/build/index.js'; import build from '../dist/core/build/index.js';
import preview from '../dist/core/preview/index.js'; import preview from '../dist/core/preview/index.js';
@ -95,12 +95,7 @@ export async function loadFixture(inlineConfig) {
if (inlineConfig.base && !inlineConfig.base.endsWith('/')) { if (inlineConfig.base && !inlineConfig.base.endsWith('/')) {
config.base = inlineConfig.base + '/'; config.base = inlineConfig.base + '/';
} }
let tsconfig = loadTSConfig(fileURLToPath(cwd)); let settings = createSettings(config, fileURLToPath(cwd));
let settings = createSettings({
config,
tsConfig: tsconfig?.config,
tsConfigPath: tsconfig?.path,
});
if (config.integrations.find((integration) => integration.name === '@astrojs/mdx')) { if (config.integrations.find((integration) => integration.name === '@astrojs/mdx')) {
// Enable default JSX integration. It needs to come first, so unshift rather than push! // Enable default JSX integration. It needs to come first, so unshift rather than push!
const { default: jsxRenderer } = await import('astro/jsx/renderer.js'); const { default: jsxRenderer } = await import('astro/jsx/renderer.js');

View file

@ -1,6 +1,7 @@
import load from '@proload/core'; import load, { resolve } from '@proload/core';
import type { AstroIntegration } from 'astro'; import type { AstroIntegration } from 'astro';
import autoprefixerPlugin from 'autoprefixer'; import autoprefixerPlugin from 'autoprefixer';
import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import tailwindPlugin, { Config as TailwindConfig } from 'tailwindcss'; import tailwindPlugin, { Config as TailwindConfig } from 'tailwindcss';
import resolveConfig from 'tailwindcss/resolveConfig.js'; import resolveConfig from 'tailwindcss/resolveConfig.js';
@ -17,7 +18,7 @@ function getDefaultTailwindConfig(srcUrl: URL): TailwindConfig {
}) as TailwindConfig; }) as TailwindConfig;
} }
async function getUserConfig(root: URL, configPath?: string) { async function getUserConfig(root: URL, configPath?: string, isRestart = false) {
const resolvedRoot = fileURLToPath(root); const resolvedRoot = fileURLToPath(root);
let userConfigPath: string | undefined; let userConfigPath: string | undefined;
@ -26,7 +27,42 @@ async function getUserConfig(root: URL, configPath?: string) {
userConfigPath = fileURLToPath(new URL(configPathWithLeadingSlash, root)); userConfigPath = fileURLToPath(new URL(configPathWithLeadingSlash, root));
} }
return await load('tailwind', { mustExist: false, cwd: resolvedRoot, filePath: userConfigPath }); if (isRestart) {
// Hack: Write config to temporary file at project root
// This invalidates and reloads file contents when using ESM imports or "resolve"
const resolvedConfigPath = (await resolve('tailwind', {
mustExist: false,
cwd: resolvedRoot,
filePath: userConfigPath,
})) as string;
const { dir, base } = path.parse(resolvedConfigPath);
const tempConfigPath = path.join(dir, `.temp.${Date.now()}.${base}`);
await fs.copyFile(resolvedConfigPath, tempConfigPath);
const result = await load('tailwind', {
mustExist: false,
cwd: resolvedRoot,
filePath: tempConfigPath,
});
try {
await fs.unlink(tempConfigPath);
} catch {
/** file already removed */
}
return {
...result,
filePath: resolvedConfigPath,
};
} else {
return await load('tailwind', {
mustExist: false,
cwd: resolvedRoot,
filePath: userConfigPath,
});
}
} }
type TailwindOptions = type TailwindOptions =
@ -55,9 +91,9 @@ export default function tailwindIntegration(options?: TailwindOptions): AstroInt
return { return {
name: '@astrojs/tailwind', name: '@astrojs/tailwind',
hooks: { hooks: {
'astro:config:setup': async ({ config, injectScript }) => { 'astro:config:setup': async ({ config, injectScript, addWatchFile, isRestart }) => {
// Inject the Tailwind postcss plugin // Inject the Tailwind postcss plugin
const userConfig = await getUserConfig(config.root, customConfigPath); const userConfig = await getUserConfig(config.root, customConfigPath, isRestart);
if (customConfigPath && !userConfig?.value) { if (customConfigPath && !userConfig?.value) {
throw new Error( throw new Error(
@ -67,6 +103,10 @@ export default function tailwindIntegration(options?: TailwindOptions): AstroInt
); );
} }
if (userConfig?.filePath) {
addWatchFile(userConfig.filePath);
}
const tailwindConfig: TailwindConfig = const tailwindConfig: TailwindConfig =
(userConfig?.value as TailwindConfig) ?? getDefaultTailwindConfig(config.srcDir); (userConfig?.value as TailwindConfig) ?? getDefaultTailwindConfig(config.srcDir);
config.style.postcss.plugins.push(tailwindPlugin(tailwindConfig)); config.style.postcss.plugins.push(tailwindPlugin(tailwindConfig));