Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ WP_HOST=yoursite.local
# so PHP enqueues the client script from the correct port.
BS_PORT=3000

# Block HMR dev-server port (used by `npm run start:blocks`).
# Default: 8887. Change if port 8887 is already in use.
BLOCKS_DEV_SERVER_PORT=8887

# SSL cert paths — only needed when your local site runs on HTTPS.
# LocalWP stores certs at:
# macOS: ~/Library/Application Support/Local/run/router/nginx/certs/<domain>.key / .crt
Expand Down
28 changes: 23 additions & 5 deletions docs/hmr.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ This document explains how live reload and hot module replacement work in the th

## Overview

Running `npm start` enables two complementary tools:
Running `npm start` runs two scripts in parallel, each with a complementary tool:

- **BrowserSync** (port 3000) — live reload for the frontend via snippet mode. Your site URL stays unchanged.
- **webpack-dev-server / Fast Refresh** (port 8887) — hot module replacement for block editor React components. Block state is preserved across updates; no full page reload needed.
- **`start:theme` → BrowserSync** (port 3000) — live reload for the frontend via snippet mode. Your site URL stays unchanged.
- **`start:blocks` → webpack-dev-server / Fast Refresh** (port 8887 by default, configurable via `BLOCKS_DEV_SERVER_PORT`) — hot module replacement for block editor React components. Block state is preserved across updates; no full page reload needed.

For BrowserSync:

Expand Down Expand Up @@ -48,11 +48,19 @@ Without `SCRIPT_DEBUG`, WordPress does not support Fast Refresh.

## How It Works

1. `npm start` runs webpack in watch mode.
**Theme assets (`start:theme` + BrowserSync):**

1. `start:theme` runs `wp-scripts start` in watch mode (no `--hot`) using `webpack.config.js`.
2. When a file changes, webpack rebuilds the affected assets in `assets/build/`.
3. BrowserSync detects the change and notifies the browser via the client script.
4. CSS changes are injected in-place. Everything else triggers a full reload.
5. Block changes are detected in editor by the webpack-dev-server run by the `--hot` option

**Blocks (`start:blocks` + Fast Refresh):**

5. `start:blocks` runs `wp-scripts start --hot`, which starts webpack-dev-server, using `webpack.blocks.config.js`.
6. JS/JSX changes to block components hot-swap in the editor without a full reload; block state is preserved.

`webpack.blocks.config.js` is a thin wrapper over `@wordpress/scripts`' default config. It exists only to strip the `devServer.proxy` option: webpack-dev-server v5 (pinned via the `overrides` block in `package.json`) requires `proxy` to be an array, while wp-scripts still emits the v4 object form, which v5 rejects with `options.proxy should be an array`. The wrapper also sets the dev-server port from `BLOCKS_DEV_SERVER_PORT`.

BrowserSync watches the following:

Expand Down Expand Up @@ -94,6 +102,16 @@ define( 'ELEMENTARY_THEME_BROWSER_SYNC_URL', 'https://yoursite.local:3001/browse

`ELEMENTARY_THEME_BROWSER_SYNC_URL` overrides the auto-detected URL entirely, so it also works for remote setups (ddev, reverse proxy) where the BrowserSync server is on a different host or IP.

### Block dev server port

The block Fast Refresh dev server runs on port 8887 by default. If that port is already in use (e.g. two local sites running `start:blocks` at once), set a different port in `.env.local`:

```
BLOCKS_DEV_SERVER_PORT=8889
```

`webpack.blocks.config.js` reads this value and applies it to the dev server. No matching `wp-config.php` constant is needed — the editor loads block scripts from disk, and the HMR client connects to the dev server directly.

### HTTPS

If your local site runs on HTTPS, also add the SSL cert paths:
Expand Down
40 changes: 35 additions & 5 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import wordpressPlugin from '@wordpress/eslint-plugin';
import comments from '@eslint-community/eslint-plugin-eslint-comments/configs';
import jestPlugin from 'eslint-plugin-jest';
import globals from 'globals';
import tseslint from 'typescript-eslint';

const TEST_FILES = [
'**/__tests__/**/*.js',
'**/test/*.js',
'**/?(*.)test.js',
'tests/js/**/*.js',
'**/__tests__/**/*.{js,ts,tsx}',
'**/test/*.{js,ts,tsx}',
'**/?(*.)test.{js,ts,tsx}',
'tests/js/**/*.{js,ts,tsx}',
];

export default [
Expand All @@ -28,6 +29,30 @@ export default [

...wordpressPlugin.configs[ 'recommended-with-formatting' ],

// `recommended-with-formatting` (unlike `recommended`) doesn't register a
// TypeScript config, so `.ts`/`.tsx` would be unmatched and skipped. Add the
// TypeScript parser + the unused-vars handoff, mirroring the TS block in
// @wordpress/eslint-plugin's `recommended` preset.
{
files: [ '**/*.ts', '**/*.tsx' ],
languageOptions: {
parser: tseslint.parser,
},
plugins: {
'@typescript-eslint': tseslint.plugin,
},
rules: {
'no-duplicate-imports': 'off',
'jsdoc/require-param-type': 'off',
'jsdoc/require-returns-type': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ ignoreRestSiblings: true },
],
},
},

// import plugin is already registered by the WordPress config above;
// add the remaining rules from plugin:import/recommended without re-registering.
{
Expand Down Expand Up @@ -65,7 +90,12 @@ export default [

{
settings: {
'import/resolver': { node: true },
'import/resolver': {
typescript: {
extensions: [ '.js', '.jsx', '.ts', '.tsx' ],
},
node: true,
},
},
},
];
35 changes: 34 additions & 1 deletion inc/Core/Assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,42 @@ public function enqueue_assets(): void {
$scheme = is_ssl() ? 'https' : 'http';
$host = wp_parse_url( home_url(), PHP_URL_HOST );
$host = $host ? $host : 'localhost';
$bs_url = "{$scheme}://{$host}:3000/browser-sync/browser-sync-client.js";
$port = $this->get_browser_sync_port();
$bs_url = "{$scheme}://{$host}:{$port}/browser-sync/browser-sync-client.js";
}
wp_enqueue_script( 'elementary-browser-sync', $bs_url, [], ELEMENTARY_THEME_VERSION, true );
}
}

/**
* Read the BrowserSync port from .env.local (BS_PORT), defaulting to 3000.
*
* Keeps the enqueued client URL in sync with the port webpack/BrowserSync
* actually bind to, which is read from the same .env.local on the build side.
*
* @since 1.0.0
*
* @return int BrowserSync port.
*/
private function get_browser_sync_port(): int {
$default = 3000;
$env_file = $this->base_dir . '.env.local';

if ( ! is_readable( $env_file ) ) {
return $default;
}

// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local dev only; reading a small project file, not remote.
$contents = file_get_contents( $env_file );

if ( false === $contents ) {
return $default;
}

if ( preg_match( '/^\s*BS_PORT\s*=\s*(\d+)/m', $contents, $matches ) ) {
return (int) $matches[1];
}

return $default;
}
}
Loading