Get Tailwind HMR working (first cut) (#1736)

* Get Tailwind HMR working

* PR feedback

* perf: improve HMR `head` performance

Co-authored-by: Nate Moore <nate@skypack.dev>
This commit is contained in:
Drew Powers 2021-11-04 14:01:28 -06:00 committed by GitHub
parent 2c5e67bb44
commit 2e1bded735
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 175 additions and 84 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
fix: Improve Tailwind HMR

View file

@ -235,6 +235,14 @@ Now you're ready to write Tailwind! Our recommended approach is to create a `src
@tailwind utilities;
```
Lastly, add it to your Astro page (or layout template):
```astro
<head>
<link rel="stylesheet" href={Astro.resolve('../styles/global.css')}>
</head>
```
As an alternative to `src/styles/global.css`, You may also add Tailwind utilities to individual `pages/*.astro` components in `<style>` tags, but be mindful of duplication! If you end up creating multiple Tailwind-managed stylesheets for your site, make sure you're not sending the same CSS to users over and over again in separate CSS files.
#### Migrating from v0.19

View file

@ -10,7 +10,7 @@
},
"devDependencies": {
"astro": "^0.21.0-next.1",
"autoprefixer": "^10.3.7",
"tailwindcss": "^2.2.17"
"autoprefixer": "^10.4.0",
"tailwindcss": "^2.2.19"
}
}

View file

@ -1,6 +1,10 @@
const path = require('path');
module.exports = {
plugins: {
tailwindcss: {},
tailwindcss: {
config: path.join(__dirname, 'tailwind.config.js'), // update this if your path differs!
},
autoprefixer: {},
},
};

View file

@ -1,3 +1,6 @@
<button class="py-2 px-4 bg-green-500 text-white font-semibold rounded-lg shadow-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-opacity-75">
---
let { type = 'button' } = Astro.props;
---
<button class="py-2 px-4 bg-purple-500 text-white font-semibold rounded-lg shadow-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-400 focus:ring-opacity-75" type={type}>
<slot />
</button>

View file

@ -8,9 +8,7 @@ import Button from '../components/Button.astro';
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Astro + TailwindCSS</title>
<link rel="stylesheet" type="text/css" href={Astro.resolve("../styles/global.css")}>
</head>

View file

@ -139,8 +139,11 @@ export interface CollectionRSS {
/** Generic interface for a component (Astro, Svelte, React, etc.) */
export interface ComponentInstance {
$$metadata: {
modules: { module: Record<string, unknown>; specifier: string }[];
fileURL: URL;
};
default: AstroComponentFactory;
css?: string[];
getStaticPaths?: (options: GetStaticPathsOptions) => GetStaticPathsResult;
}
@ -263,6 +266,9 @@ export interface Renderer {
knownEntrypoints?: string[];
}
/** <link> tags with attributes represented by an object */
export type Resource = Record<string, string>;
export interface RouteData {
component: string;
generate: (data?: any) => string;

View file

@ -4,6 +4,7 @@ import type { AstroConfig, ManifestData, RouteCache, RouteData } from '../../@ty
import type { LogOptions } from '../logger';
import type { HmrContext, ModuleNode } from '../vite';
import path from 'path';
import { fileURLToPath } from 'url';
import { promisify } from 'util';
import connect from 'connect';
@ -13,6 +14,8 @@ import stripAnsi from 'strip-ansi';
import vite from '../vite.js';
import { defaultLogOptions, error, info } from '../logger.js';
import { ssr } from '../ssr/index.js';
import { STYLE_EXTENSIONS } from '../ssr/css.js';
import { collectResources } from '../ssr/html.js';
import { createRouteManifest, matchRoute } from '../ssr/routing.js';
import { createVite } from '../create-vite.js';
import * as msg from './messages.js';
@ -51,7 +54,6 @@ export class AstroDevServer {
httpServer: http.Server | undefined;
hostname: string;
port: number;
private config: AstroConfig;
private logging: LogOptions;
private manifest: ManifestData;
@ -92,49 +94,94 @@ export class AstroDevServer {
}
public async handleHotUpdate({ file, modules }: HmrContext): Promise<void | ModuleNode[]> {
if (!this.viteServer) throw new Error(`AstroDevServer.start() not called`);
const { viteServer } = this;
if (!viteServer) throw new Error(`AstroDevServer.start() not called`);
for (const module of modules) {
this.viteServer.moduleGraph.invalidateModule(module);
viteServer.moduleGraph.invalidateModule(module);
}
const route = this.mostRecentRoute;
const pathname = route?.pathname ?? '/';
if (!route) {
this.viteServer.ws.send({
viteServer.ws.send({
type: 'full-reload',
});
return [];
}
try {
const filePath = new URL(`./${route.component}`, this.config.projectRoot);
// try to update the most recent route
const html = await ssr({
astroConfig: this.config,
filePath: new URL(`./${route.component}`, this.config.projectRoot),
filePath,
logging: this.logging,
mode: 'development',
origin: this.origin,
pathname,
route,
routeCache: this.routeCache,
viteServer: this.viteServer,
viteServer,
});
// collect style tags to be reloaded (needed for Tailwind HMR, etc.)
let invalidatedModules: vite.ModuleNode[] = [];
await Promise.all(
collectResources(html)
.filter(({ href }) => {
if (!href) return false;
const ext = path.extname(href);
return STYLE_EXTENSIONS.has(ext);
})
.map(async ({ href }) => {
const viteModule =
viteServer.moduleGraph.getModuleById(`${href}?direct`) ||
(await viteServer.moduleGraph.getModuleByUrl(`${href}?direct`)) ||
viteServer.moduleGraph.getModuleById(href) ||
(await viteServer.moduleGraph.getModuleByUrl(href));
if (viteModule) {
invalidatedModules.push(viteModule);
viteServer.moduleGraph.invalidateModule(viteModule);
}
})
);
// TODO: log update
this.viteServer.ws.send({
viteServer.ws.send({
type: 'custom',
event: 'astro:reload',
data: { html },
});
for (const viteModule of invalidatedModules) {
// Note: from the time viteServer.moduleGraph.invalidateModule() is called above until now, CSS
// is building in the background. For things like Tailwind, this can take some time. If the
// CSS is still processing by the time HMR fires, well end up with stale styles on the page.
// TODO: fix this hack by figuring out how to add these styles to the { modules } above
setTimeout(() => {
viteServer.ws.send({
type: 'update',
updates: [
{
type: viteModule.type === 'js' ? 'js-update' : 'css-update',
path: viteModule.id || viteModule.file || viteModule.url,
acceptedPath: viteModule.url,
timestamp: Date.now(),
},
],
});
}, 150);
}
return [];
} catch (e) {
const err = e as Error;
this.viteServer.ssrFixStacktrace(err);
viteServer.ssrFixStacktrace(err);
// eslint-disable-next-line
console.error(err.stack);
this.viteServer.ws.send({
viteServer.ws.send({
type: 'full-reload',
});
return [];

View file

@ -1,7 +1,6 @@
import type vite from '../../../vendor/vite';
import path from 'path';
import htmlparser2 from 'htmlparser2';
// https://vitejs.dev/guide/features.html#css-pre-processors
export const STYLE_EXTENSIONS = new Set(['.css', '.pcss', '.scss', '.sass', '.styl', '.stylus', '.less']);

View file

@ -4,53 +4,68 @@ import htmlparser2 from 'htmlparser2';
/** Inject tags into HTML (note: for best performance, group as many tags as possible into as few calls as you can) */
export function injectTags(html: string, tags: vite.HtmlTagDescriptor[]): string {
// TODO: this usually takes 5ms or less, but if it becomes a bottleneck we can create a WeakMap cache
let output = html;
if (!tags.length) return output;
const pos = { 'head-prepend': -1, head: -1, 'body-prepend': -1, body: -1 };
try {
// parse html
const parser = new htmlparser2.Parser({
onopentag(tagname) {
if (tagname === 'head') pos['head-prepend'] = parser.endIndex + 1;
if (tagname === 'body') pos['body-prepend'] = parser.endIndex + 1;
},
onclosetag(tagname) {
if (tagname === 'head') pos['head'] = parser.startIndex;
if (tagname === 'body') pos['body'] = parser.startIndex;
},
});
parser.write(html);
parser.end();
// parse html
const parser = new htmlparser2.Parser({
onopentag(tagname) {
if (tagname === 'head') pos['head-prepend'] = parser.endIndex + 1;
if (tagname === 'body') pos['body-prepend'] = parser.endIndex + 1;
},
onclosetag(tagname) {
if (tagname === 'head') pos['head'] = parser.startIndex;
if (tagname === 'body') pos['body'] = parser.startIndex;
},
});
parser.write(html);
parser.end();
// inject
const lastToFirst = Object.entries(pos).sort((a, b) => b[1] - a[1]);
lastToFirst.forEach(([name, i]) => {
if (i === -1) {
// TODO: warn on missing tag? Is this an HTML partial?
return;
// inject
const lastToFirst = Object.entries(pos).sort((a, b) => b[1] - a[1]);
lastToFirst.forEach(([name, i]) => {
if (i === -1) {
// TODO: warn on missing tag? Is this an HTML partial?
return;
}
let selected = tags.filter(({ injectTo }) => {
if (name === 'head-prepend' && !injectTo) {
return true; // "head-prepend" is the default
} else {
return injectTo === name;
}
let selected = tags.filter(({ injectTo }) => {
if (name === 'head-prepend' && !injectTo) {
return true; // "head-prepend" is the default
} else {
return injectTo === name;
}
});
if (!selected.length) return;
output = output.substring(0, i) + serializeTags(selected) + html.substring(i);
});
} catch (err) {
// on invalid HTML, do nothing
}
if (!selected.length) return;
output = output.substring(0, i) + serializeTags(selected) + html.substring(i);
});
return output;
}
// Everything below © Vite
type Resource = Record<string, string>;
/** Collect resources (scans final, rendered HTML so expressions have been applied) */
export function collectResources(html: string): Resource[] {
let resources: Resource[] = [];
const parser = new htmlparser2.Parser({
// <link> tags are self-closing, so only use onopentag (avoid onattribute or onclosetag)
onopentag(tagname, attrs) {
if (tagname === 'link') resources.push(attrs);
},
});
parser.write(html);
parser.end();
return resources;
}
// -------------------------------------------------------------------------------
// Everything below © Vite. Rather than invent our own tag creating API, we borrow
// Vites `transformIndexHtml()` API for ease-of-use and consistency. But we need
// to borrow a few private methods in Vite to make that available here.
// https://github.com/vitejs/vite/blob/main/packages/vite/src/node/plugins/html.ts
// -------------------------------------------------------------------------------
// Vite is released under the MIT license:

View file

@ -4,7 +4,6 @@ import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Prop
import type { AstroGlobal, TopLevelAstro, SSRResult, SSRElement } from '../../@types/astro-runtime';
import type { LogOptions } from '../logger';
import { fileURLToPath } from 'url';
import fs from 'fs';
import path from 'path';
import { renderPage, renderSlot } from '../../runtime/server/index.js';
@ -164,7 +163,7 @@ export async function ssr({ astroConfig, filePath, logging, mode, origin, pathna
// inject tags
const tags: vite.HtmlTagDescriptor[] = [];
// inject Astro HMR client (dev only)
// dev only: inject Astro HMR client
if (mode === 'development') {
tags.push({
tag: 'script',

View file

@ -6,10 +6,17 @@ if (import.meta.hot) {
morphdom(document.head, doc.head, {
onBeforeElUpdated(fromEl, toEl) {
// Do not update identical tags
if (fromEl.isEqualNode(toEl)) {
return false;
}
// Do not update <link> or <script> tags
// to avoid re-fetching their contents
if (fromEl.tagName === toEl.tagName && (toEl.tagName === 'LINK' || toEl.tagName === 'SCRIPT')) {
return false;
}
return true;
},
});

View file

@ -2549,16 +2549,16 @@ at-least-node@^1.0.0:
resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2"
integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==
autoprefixer@^10.3.7:
version "10.3.7"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.3.7.tgz#cef2562058406bd378c94aacda36bb46a97b3186"
integrity sha512-EmGpu0nnQVmMhX8ROoJ7Mx8mKYPlcUHuxkwrRYEYMz85lu7H09v8w6R1P0JPdn/hKU32GjpLBFEOuIlDWCRWvg==
autoprefixer@^10.4.0:
version "10.4.0"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.0.tgz#c3577eb32a1079a440ec253e404eaf1eb21388c8"
integrity sha512-7FdJ1ONtwzV1G43GDD0kpVMn/qbiNqyOPMFTX5nRffI+7vgWoFEc6DcXOxHJxrWNDXrZh18eDsZjvZGUljSRGA==
dependencies:
browserslist "^4.17.3"
caniuse-lite "^1.0.30001264"
browserslist "^4.17.5"
caniuse-lite "^1.0.30001272"
fraction.js "^4.1.1"
normalize-range "^0.1.2"
picocolors "^0.2.1"
picocolors "^1.0.0"
postcss-value-parser "^4.1.0"
available-typed-arrays@^1.0.5:
@ -2778,15 +2778,15 @@ browserslist@^4.16.6:
escalade "^3.1.1"
node-releases "^1.1.75"
browserslist@^4.17.3:
version "4.17.4"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.17.4.tgz#72e2508af2a403aec0a49847ef31bd823c57ead4"
integrity sha512-Zg7RpbZpIJRW3am9Lyckue7PLytvVxxhJj1CaJVlCWENsGEAOlnlt8X0ZxGRPp7Bt9o8tIRM5SEXy4BCPMJjLQ==
browserslist@^4.17.5:
version "4.17.5"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.17.5.tgz#c827bbe172a4c22b123f5e337533ceebadfdd559"
integrity sha512-I3ekeB92mmpctWBoLXe0d5wPS2cBuRvvW0JyyJHMrk9/HmP2ZjrTboNAZ8iuGqaEIlKguljbQY32OkOJIRrgoA==
dependencies:
caniuse-lite "^1.0.30001265"
electron-to-chromium "^1.3.867"
caniuse-lite "^1.0.30001271"
electron-to-chromium "^1.3.878"
escalade "^3.1.1"
node-releases "^2.0.0"
node-releases "^2.0.1"
picocolors "^1.0.0"
buffer-crc32@~0.2.3:
@ -2933,10 +2933,10 @@ caniuse-lite@^1.0.30001254:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001258.tgz#b604eed80cc54a578e4bf5a02ae3ed49f869d252"
integrity sha512-RBByOG6xWXUp0CR2/WU2amXz3stjKpSl5J1xU49F1n2OxD//uBZO4wCKUiG+QMGf7CHGfDDcqoKriomoGVxTeA==
caniuse-lite@^1.0.30001264, caniuse-lite@^1.0.30001265:
version "1.0.30001267"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001267.tgz#b1cf2937175afc0570e4615fc2d2f9069fa0ed30"
integrity sha512-r1mjTzAuJ9W8cPBGbbus8E0SKcUP7gn03R14Wk8FlAlqhH9hroy9nLqmpuXlfKEw/oILW+FGz47ipXV2O7x8lg==
caniuse-lite@^1.0.30001271, caniuse-lite@^1.0.30001272:
version "1.0.30001274"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001274.tgz#26ca36204d15b17601ba6fc35dbdad950a647cc7"
integrity sha512-+Nkvv0fHyhISkiMIjnyjmf5YJcQ1IQHZN6U9TLUMroWR38FNwpsC51Gb68yueafX1V6ifOisInSgP9WJFS13ew==
caseless@~0.12.0:
version "0.12.0"
@ -3952,10 +3952,10 @@ electron-to-chromium@^1.3.830:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.843.tgz#671489bd2f59fd49b76adddc1aa02c88cd38a5c0"
integrity sha512-OWEwAbzaVd1Lk9MohVw8LxMXFlnYd9oYTYxfX8KS++kLLjDfbovLOcEEXwRhG612dqGQ6+44SZvim0GXuBRiKg==
electron-to-chromium@^1.3.867:
version "1.3.871"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.871.tgz#6e87365fd72037a6c898fb46050ad4be3ac9ef62"
integrity sha512-qcLvDUPf8DSIMWarHT2ptgcqrYg62n3vPA7vhrOF24d8UNzbUBaHu2CySiENR3nEDzYgaN60071t0F6KLYMQ7Q==
electron-to-chromium@^1.3.878:
version "1.3.886"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.886.tgz#ac039c4001b665b1dd0f0ed9c2e4da90ff3c9267"
integrity sha512-+vYdeBosI63VkCtNWnEVFjgNd/IZwvnsWkKyPtWAvrhA+XfByKoBJcbsMgudVU/bUcGAF9Xp3aXn96voWlc3oQ==
emmet@^2.1.5:
version "2.3.4"
@ -7729,10 +7729,10 @@ node-releases@^1.1.75:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.75.tgz#6dd8c876b9897a1b8e5a02de26afa79bb54ebbfe"
integrity sha512-Qe5OUajvqrqDSy6wrWFmMwfJ0jVgwiw4T3KqmbTcZ62qW0gQkheXYhcFM1+lOVcGUoRxcEcfyvFMAnDgaF1VWw==
node-releases@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.0.tgz#67dc74903100a7deb044037b8a2e5f453bb05400"
integrity sha512-aA87l0flFYMzCHpTM3DERFSYxc6lv/BltdbRTOMZuxZ0cwZCD3mejE5n9vLhSJCN++/eOqr77G1IO5uXxlQYWA==
node-releases@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.1.tgz#3d1d395f204f1f2f29a54358b9fb678765ad2fc5"
integrity sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==
node.extend@~2.0.2:
version "2.0.2"
@ -10224,10 +10224,10 @@ svelte@^3.44.0:
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.44.0.tgz#e6176cb3ad93846ddb4140e93f43098136b23f3b"
integrity sha512-zWACSJBSncGiDvFfYOMFGNV5zDLOlyhftmO5yOZ0lEtQMptpElaRtl39MWz1+lYCpwUq4F3Q2lTzI9TrTL+eMA==
tailwindcss@^2.2.17:
version "2.2.17"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-2.2.17.tgz#c6332731f9ff1b6628ff589c95c38685347775e3"
integrity sha512-WgRpn+Pxn7eWqlruxnxEbL9ByVRWi3iC10z4b6dW0zSdnkPVC4hPMSWLQkkW8GCyBIv/vbJ0bxIi9dVrl4CfoA==
tailwindcss@^2.2.19:
version "2.2.19"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-2.2.19.tgz#540e464832cd462bb9649c1484b0a38315c2653c"
integrity sha512-6Ui7JSVtXadtTUo2NtkBBacobzWiQYVjYW0ZnKaP9S1ZCKQ0w7KVNz+YSDI/j7O7KCMHbOkz94ZMQhbT9pOqjw==
dependencies:
arg "^5.0.1"
bytes "^3.0.0"