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 = ` - +