Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Hot Module Replacement (HMR) for CSS #64444

Draft
wants to merge 3 commits into
base: trunk
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions lib/client-assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
die( 'Silence is golden.' );
}

/**
* Checks whether HMR is enabled.
* Since it depends on the webpack build, the most reliable way is to check the file existence.
*
* @return bool True if HMR is enabled, false otherwise.
*/
function gutenberg_is_hmr_enabled() {
return SCRIPT_DEBUG && file_exists( gutenberg_dir_path() . 'build/🔥hot.js' );
}

/**
* Retrieves the root plugin path.
*
Expand All @@ -31,6 +41,10 @@ function gutenberg_dir_path() {
* @return string Fully qualified URL pointing to the desired file.
*/
function gutenberg_url( $path ) {
if ( gutenberg_is_hmr_enabled() ) {
return 'http://localhost:8887/' . $path;
}

return plugins_url( $path, __DIR__ );
}

Expand Down Expand Up @@ -187,6 +201,19 @@ function gutenberg_register_packages_scripts( $scripts ) {
// else (for development or test) default to use the current time.
$default_version = defined( 'GUTENBERG_VERSION' ) && ! SCRIPT_DEBUG ? GUTENBERG_VERSION : time();

// Clear the file cache for HMR.
if ( SCRIPT_DEBUG ) {
clearstatcache( true, gutenberg_dir_path() . 'build/🔥hot.js' );
}

// Register the runtime script.
$scripts->add( 'wp-runtime', gutenberg_url( 'build/runtime.min.js' ) );

// Register development dependencies for Hot Module Replacement.
if ( gutenberg_is_hmr_enabled() ) {
$scripts->add( '🔥hot', gutenberg_url( 'build/🔥hot.js' ), array( 'wp-runtime' ) );
}

foreach ( glob( gutenberg_dir_path() . 'build/*/index.min.js' ) as $path ) {
// Prefix `wp-` to package directory to get script handle.
// For example, `…/build/a11y/index.min.js` becomes `wp-a11y`.
Expand All @@ -200,6 +227,14 @@ function gutenberg_register_packages_scripts( $scripts ) {
$dependencies = isset( $asset['dependencies'] ) ? $asset['dependencies'] : array();
$version = isset( $asset['version'] ) ? $asset['version'] : $default_version;

// Register the runtime dependency for each entry point.
$dependencies[] = 'wp-runtime';

// Register HMR dependencies for each entry point in development mode.
if ( gutenberg_is_hmr_enabled() ) {
$dependencies[] = '🔥hot';
}

// Add dependencies that cannot be detected and generated by build tools.
switch ( $handle ) {
case 'wp-block-library':
Expand Down
91 changes: 91 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
"eslint-plugin-testing-library": "6.0.2",
"execa": "4.0.2",
"fast-glob": "3.2.7",
"file-loader": "6.2.0",
"filenamify": "4.2.0",
"glob": "7.1.2",
"husky": "7.0.0",
Expand Down Expand Up @@ -263,6 +264,7 @@
"webdriverio": "8.16.20",
"webpack": "5.88.2",
"webpack-bundle-analyzer": "4.9.1",
"webpack-dev-server": "4.15.1",
"worker-farm": "1.7.0"
},
"overrides": {
Expand All @@ -277,7 +279,7 @@
"build:plugin-zip": "bash ./bin/build-plugin-zip.sh",
"clean:package-types": "tsc --build --clean && rimraf \"./packages/*/build-types\"",
"clean:packages": "rimraf \"./packages/*/@(build|build-module|build-style)\"",
"dev": "cross-env NODE_ENV=development npm run build:packages && concurrently \"wp-scripts start\" \"npm run dev:packages\"",
"dev": "cross-env NODE_ENV=development npm run build:packages && concurrently \"wp-scripts serve\" \"npm run dev:packages\"",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the limitation of wp-scripts start --hot that force introducing a new script? I would be more in favor of always using the dev server for start by default even when not using hot module replacement.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to use our own manual HMR setup so we don't want to pass --hot to webpack. wp-scripts start --hot seems to always pass that to webpack which then override the hot: false in the config.

I agree using start will be ideal but serve seems reasonable too, given that the official webpack command uses serve to start a dev server too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, webpack serve is a thing which is reflected in wp-scripts start implementation. The same applies to webpack watch but it doesn’t need to be exposed to the devs.

"dev:packages": "cross-env NODE_ENV=development concurrently \"node ./bin/packages/watch.js\" \"tsc --build --watch\"",
"distclean": "git clean --force -d -X",
"docs:api-ref": "node ./bin/api-docs/update-api-docs.js",
Expand Down
7 changes: 7 additions & 0 deletions packages/css-hmr-loader/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# CSS HMR Loader

A webpack loader to enable CSS Hot Module Replacement in Gutenberg.

**This package is still experimental and breaking changes could be introduced in future minor versions (`v0.x`). Use it at your own risks.**

<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p>
34 changes: 34 additions & 0 deletions packages/css-hmr-loader/lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* External dependencies
*/
const path = require( 'path' );

module.exports = () => {};
module.exports.pitch = function pitch( remainingRequest ) {
const modulePath = path.join( __dirname, 'reload-css.js' );
const moduleLoader = JSON.stringify( `!${ modulePath }` );
const request = JSON.stringify(
this.utils.contextify(
this.context || this.rootContext,
`!!${ remainingRequest }`
)
);

if ( this.cacheable ) {
this.cacheable();
}

return `
if (module.hot) {
const reloadCSS = require(${ moduleLoader })();
require(${ request });

module.hot.accept(${ request }, function () {
reloadCSS(require(${ request }).default);
});
module.hot.dispose(function () {
reloadCSS(require(${ request }).default);
});
}
`;
};
92 changes: 92 additions & 0 deletions packages/css-hmr-loader/lib/reload-css.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* External dependencies
*/
const log = require( 'webpack/hot/log' );

const visitedStylesheets = new WeakSet();

function debounce( fn, time ) {
let timeout;
return function ( ...args ) {
clearTimeout( timeout );
timeout = setTimeout( () => {
return fn.apply( this, args );
}, time );
};
}

function updateCSS( stylesheet, url ) {
const href = stylesheet.href;

if ( ! href || ( url && ! href.includes( url ) ) ) {
return;
}

if ( visitedStylesheets.has( stylesheet ) ) {
return;
}

visitedStylesheets.add( stylesheet );
const element = stylesheet.cloneNode();
visitedStylesheets.add( element );

element.href = href.split( '?' )[ 0 ] + `?${ Date.now() }`;

element.addEventListener( 'load', () => {
if ( visitedStylesheets.has( element ) ) {
visitedStylesheets.delete( element );
stylesheet.remove();
}
} );

element.addEventListener( 'error', () => {
if ( visitedStylesheets.has( element ) ) {
visitedStylesheets.delete( element );
stylesheet.remove();
}
} );

stylesheet.after( element );
}

function getAllStylesheets( win ) {
const links = [
...win.document.querySelectorAll( "link[rel='stylesheet']" ),
];

// Recursively loop through all frames with the same origin.
for ( let i = 0; i < win.frames.length; i++ ) {
try {
links.push( ...getAllStylesheets( win.frames[ i ] ) );
} catch ( err ) {
// Ignore same origin policy errors.
}
}

return links;
}

module.exports = function reloadCSS() {
function update( url ) {
const normalizedUrl = url.split( '?' )[ 0 ];
const stylesheets = getAllStylesheets( window );
let loaded = false;

stylesheets.forEach( ( stylesheet ) => {
updateCSS( stylesheet, normalizedUrl );
loaded = true;
} );

if ( loaded ) {
log( 'info', `[HMR] css reload ${ normalizedUrl }` );
} else {
log( 'info', '[HMR] Reload all css' );

stylesheets.forEach( ( stylesheet ) => {
updateCSS( stylesheet );
} );
}
}

return debounce( update, 50 );
};
34 changes: 34 additions & 0 deletions packages/css-hmr-loader/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@wordpress/css-hmr-loader",
"version": "0.0.0",
"private": true,
"description": "A webpack loader to enable CSS Hot Module Replacement in Gutenberg.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
"keywords": [
"CSS",
"HMR",
"webpack",
"loader"
],
"homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/css-hmr-loader/README.md",
"repository": {
"type": "git",
"url": "https://github.com/WordPress/gutenberg.git",
"directory": "packages/css-hmr-loader"
},
"bugs": {
"url": "https://github.com/WordPress/gutenberg/issues"
},
"engines": {
"node": ">=18.12.0",
"npm": ">=8.19.2"
},
"main": "lib/index.js",
"peerDependencies": {
"webpack": "*"
},
"publishConfig": {
"access": "public"
}
}
Loading
Loading