diff --git a/.changeset/few-panthers-admire.md b/.changeset/few-panthers-admire.md
deleted file mode 100644
index c3b75ad7d..000000000
--- a/.changeset/few-panthers-admire.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'preact-cli': major
----
-
-Removes Critters which facilitates automatic CSS inlining in prod.
-
-Unfortunately Critters has not been updated for Webpack v5, resulting in a precarious dependency situation that causes issues for NPM users. As such, Critters will be removed for the time being.
-
-It may be updated or we may switch to a fork, but for now, it's causing issues and will require some work to correct.
diff --git a/packages/cli/package.json b/packages/cli/package.json
index a9bd2f1b3..74469cdbe 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -48,6 +48,7 @@
"browserslist": "^4.20.3",
"console-clear": "^1.0.0",
"copy-webpack-plugin": "^9.1.0",
+ "critters": "^0.0.16",
"css-loader": "^6.6.0",
"css-minimizer-webpack-plugin": "3.4.1",
"dotenv": "^16.0.0",
diff --git a/packages/cli/src/index.js b/packages/cli/src/index.js
index 1564a78e8..b64617532 100755
--- a/packages/cli/src/index.js
+++ b/packages/cli/src/index.js
@@ -42,6 +42,7 @@ prog
'Path to prerendered routes config',
'prerender-urls.json'
)
+ .option('--inlineCss', 'Adds critical CSS to the prerendered HTML', true)
.option('-c, --config', 'Path to custom CLI config', 'preact.config.js')
.option('-v, --verbose', 'Verbose output', false)
.action(argv => exec(build(argv)));
@@ -80,6 +81,9 @@ prog
.action(() => exec(info()));
prog.parse(process.argv, {
+ alias: {
+ inlineCss: ['inline-css'],
+ },
unknown: arg => {
const cmd = process.argv[2];
error(
diff --git a/packages/cli/src/lib/webpack/critters-plugin.js b/packages/cli/src/lib/webpack/critters-plugin.js
new file mode 100644
index 000000000..224c11a01
--- /dev/null
+++ b/packages/cli/src/lib/webpack/critters-plugin.js
@@ -0,0 +1,214 @@
+/**
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ *
+ * https://github.com/GoogleChromeLabs/critters/blob/main/packages/critters-webpack-plugin/src/index.js
+ */
+
+/**
+ * Critters does not (yet) support `html-webpack-plugin` v5, so we vendor it.
+ */
+
+const path = require('path');
+const minimatch = require('minimatch');
+const { sources } = require('webpack');
+const Critters = require('critters');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+
+function tap(inst, hook, pluginName, async, callback) {
+ if (inst.hooks) {
+ const camel = hook.replace(/-([a-z])/g, (_s, i) => i.toUpperCase());
+ inst.hooks[camel][async ? 'tapAsync' : 'tap'](pluginName, callback);
+ } else {
+ inst.plugin(hook, callback);
+ }
+}
+
+// Used to annotate this plugin's hooks in Tappable invocations
+const PLUGIN_NAME = 'critters-webpack-plugin';
+
+/**
+ * Create a Critters plugin instance with the given options.
+ * @public
+ * @param {import('critters').Options} options Options to control how Critters inlines CSS. See https://github.com/GoogleChromeLabs/critters#usage
+ * @example
+ * // webpack.config.js
+ * module.exports = {
+ * plugins: [
+ * new Critters({
+ * // Outputs:
+ * preload: 'swap',
+ *
+ * // Don't inline critical font-face rules, but preload the font URLs:
+ * preloadFonts: true
+ * })
+ * ]
+ * }
+ */
+module.exports = class CrittersWebpackPlugin extends Critters {
+ /**
+ * @param {import('critters').Options} options
+ */
+ constructor(options) {
+ super(options);
+ }
+
+ /**
+ * Invoked by Webpack during plugin initialization
+ */
+ apply(compiler) {
+ // hook into the compiler to get a Compilation instance...
+ tap(compiler, 'compilation', PLUGIN_NAME, false, compilation => {
+ this.options.path = compiler.options.output.path;
+ this.options.publicPath = compiler.options.output.publicPath;
+
+ const handleHtmlPluginData = (htmlPluginData, callback) => {
+ this.fs = compilation.outputFileSystem;
+ this.compilation = compilation;
+ this.process(htmlPluginData.html)
+ .then(html => {
+ callback(null, { html });
+ })
+ .catch(callback);
+ };
+
+ HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
+ PLUGIN_NAME,
+ handleHtmlPluginData
+ );
+ });
+ }
+
+ /**
+ * Given href, find the corresponding CSS asset
+ */
+ async getCssAsset(href, style) {
+ const outputPath = this.options.path;
+ const publicPath = this.options.publicPath;
+
+ // CHECK - the output path
+ // path on disk (with output.publicPath removed)
+ let normalizedPath = href.replace(/^\//, '');
+ const pathPrefix = (publicPath || '').replace(/(^\/|\/$)/g, '') + '/';
+ if (normalizedPath.indexOf(pathPrefix) === 0) {
+ normalizedPath = normalizedPath
+ .substring(pathPrefix.length)
+ .replace(/^\//, '');
+ }
+ const filename = path.resolve(outputPath, normalizedPath);
+
+ // try to find a matching asset by filename in webpack's output (not yet written to disk)
+ const relativePath = path
+ .relative(outputPath, filename)
+ .replace(/^\.\//, '');
+ const asset = this.compilation.assets[relativePath]; // compilation.assets[relativePath];
+
+ // Attempt to read from assets, falling back to a disk read
+ let sheet = asset && asset.source();
+
+ if (!sheet) {
+ try {
+ sheet = await this.readFile(this.compilation, filename);
+ this.logger.warn(
+ `Stylesheet "${relativePath}" not found in assets, but a file was located on disk.${
+ this.options.pruneSource
+ ? ' This means pruneSource will not be applied.'
+ : ''
+ }`
+ );
+ } catch (e) {
+ this.logger.warn(`Unable to locate stylesheet: ${relativePath}`);
+ return;
+ }
+ }
+
+ style.$$asset = asset;
+ style.$$assetName = relativePath;
+ // style.$$assets = this.compilation.assets;
+
+ return sheet;
+ }
+
+ checkInlineThreshold(link, style, sheet) {
+ const inlined = super.checkInlineThreshold(link, style, sheet);
+
+ if (inlined) {
+ const asset = style.$$asset;
+ if (asset) {
+ delete this.compilation.assets[style.$$assetName];
+ } else {
+ this.logger.warn(
+ ` > ${style.$$name} was not found in assets. the resource may still be emitted but will be unreferenced.`
+ );
+ }
+ }
+
+ return inlined;
+ }
+
+ /**
+ * Inline the stylesheets from options.additionalStylesheets (assuming it passes `options.filter`)
+ */
+ async embedAdditionalStylesheet(document) {
+ const styleSheetsIncluded = [];
+ (this.options.additionalStylesheets || []).forEach(cssFile => {
+ if (styleSheetsIncluded.includes(cssFile)) {
+ return;
+ }
+ styleSheetsIncluded.push(cssFile);
+ const webpackCssAssets = Object.keys(this.compilation.assets).filter(
+ file => minimatch(file, cssFile)
+ );
+ webpackCssAssets.map(asset => {
+ const style = document.createElement('style');
+ style.$$external = true;
+ style.textContent = this.compilation.assets[asset].source();
+ document.head.appendChild(style);
+ });
+ });
+ }
+
+ /**
+ * Prune the source CSS files
+ */
+ pruneSource(style, before, sheetInverse) {
+ const isStyleInlined = super.pruneSource(style, before, sheetInverse);
+ const asset = style.$$asset;
+ const name = style.$$name;
+
+ if (asset) {
+ // if external stylesheet would be below minimum size, just inline everything
+ const minSize = this.options.minimumExternalSize;
+ if (minSize && sheetInverse.length < minSize) {
+ // delete the webpack asset:
+ delete this.compilation.assets[style.$$assetName];
+ return true;
+ }
+ this.compilation.assets[style.$$assetName] =
+ new sources.LineToLineMappedSource(
+ sheetInverse,
+ style.$$assetName,
+ before
+ );
+ } else {
+ this.logger.warn(
+ 'pruneSource is enabled, but a style (' +
+ name +
+ ') has no corresponding Webpack asset.'
+ );
+ }
+
+ return isStyleInlined;
+ }
+};
diff --git a/packages/cli/src/lib/webpack/webpack-client-config.js b/packages/cli/src/lib/webpack/webpack-client-config.js
index 94e0e5186..d823a0d32 100644
--- a/packages/cli/src/lib/webpack/webpack-client-config.js
+++ b/packages/cli/src/lib/webpack/webpack-client-config.js
@@ -8,6 +8,7 @@ const CopyWebpackPlugin = require('copy-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
+const CrittersPlugin = require('./critters-plugin.js');
const renderHTMLPlugin = require('./render-html-plugin');
const baseConfig = require('./webpack-base-config');
const { InjectManifest } = require('workbox-webpack-plugin');
@@ -189,6 +190,17 @@ function prodBuild(config) {
},
};
+ if (config.inlineCss) {
+ prodConfig.plugins.push(
+ new CrittersPlugin({
+ preload: 'media',
+ pruneSource: false,
+ logLevel: 'silent',
+ additionalStylesheets: ['route-*.css'],
+ })
+ );
+ }
+
if (config.analyze) {
prodConfig.plugins.push(new BundleAnalyzerPlugin());
}
diff --git a/packages/cli/tests/build.test.js b/packages/cli/tests/build.test.js
index b7510d009..3fd72fdef 100644
--- a/packages/cli/tests/build.test.js
+++ b/packages/cli/tests/build.test.js
@@ -231,6 +231,18 @@ describe('preact build', () => {
).toBeUndefined();
});
+ it('--inlineCss', async () => {
+ let dir = await subject('minimal');
+
+ await buildFast(dir, { inlineCss: true });
+ let head = await getHead(dir);
+ expect(head).toMatch('');
+
+ await buildFast(dir, { inlineCss: false });
+ head = await getOutputFile(dir, 'index.html');
+ expect(head).not.toMatch(/');
+ });
+
// Issue #1411
it('should preserve side-effectful CSS imports even if package.json claims no side effects', async () => {
let dir = await subject('css-side-effect');
diff --git a/packages/cli/tests/images/build.js b/packages/cli/tests/images/build.js
index 956bca6fe..7231bb1cb 100644
--- a/packages/cli/tests/images/build.js
+++ b/packages/cli/tests/images/build.js
@@ -26,7 +26,7 @@ exports.default = {
'es-polyfills.js': 42690,
'favicon.ico': 15086,
- 'index.html': 1972,
+ 'index.html': 3998,
'manifest.json': 455,
'preact_prerender_data.json': 11,
@@ -55,7 +55,11 @@ exports.prerender.heads.home = `
-
+