Skip to content
Merged
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
19 changes: 14 additions & 5 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
# Local site hostname (without protocol or trailing slash).
# LocalWP: elementary.local, Lando: yoursite.lndo.site, wp-env: localhost, WP Studio: localhost
# LocalWP: yoursite.local, Lando: yoursite.lndo.site, wp-env: localhost, WP Studio: localhost
WP_HOST=yoursite.local

# BrowserSync port. Default: 3000. Change if port 3000 is already in use.
# If you change this, also define ELEMENTARY_THEME_BROWSER_SYNC_URL in wp-config.php
# so PHP enqueues the client script from the correct port.
BS_PORT=3000
# BrowserSync port. Default: 3001. Change if port 3001 is already in use.
# Both the build and PHP read this value directly, so changing it here is
# enough. Define ELEMENTARY_THEME_BROWSER_SYNC_URL only to override the full
# client URL (e.g. remote/proxy setups), not just the port.
BS_PORT=3001

# 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

# Disable BrowserSync client enqueue. Truthy: 1, true, yes, on (default off).
# The BrowserSync server still runs; only the frontend client is skipped.
# DISABLE_BS=true

# SSL cert paths — only needed when your local site runs on HTTPS.
# LocalWP stores certs at:
Expand Down
52 changes: 35 additions & 17 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:assets` → BrowserSync** (port 3001) — 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 All @@ -24,7 +24,7 @@ For block editor HMR, JS/JSX changes to block components hot-swap in the editor
npm start
```

Webpack starts watching for file changes and BrowserSync starts on port 3000. Open your local site and edits will reflect automatically.
Webpack starts watching for file changes and BrowserSync starts on port 3001. Open your local site and edits will reflect automatically.

---

Expand All @@ -48,19 +48,27 @@ Without `SCRIPT_DEBUG`, WordPress does not support Fast Refresh.

## How It Works

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

1. `start:assets` 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:

- `assets/build/**/*`
- `**/*.php` (excluding `vendor/`)
- `**/*.html`

The client script is enqueued by PHP from `{scheme}://{host}:3000/browser-sync/browser-sync-client.js`. The scheme (`http` or `https`) and host are derived automatically from the WordPress site URL using `is_ssl()` and `home_url()`.
The client script is enqueued by PHP from `{scheme}://{host}:3001/browser-sync/browser-sync-client.js`. The scheme (`http` or `https`) and host are derived automatically from the WordPress site URL using `is_ssl()` and `home_url()`.

BrowserSync is only added to the `scripts` webpack config. Adding it to all three configs (`scripts`, `styles`, `moduleScripts`) would start three BrowserSync instances on the same port.

Expand All @@ -80,20 +88,30 @@ WP_HOST=yoursite.local

### Multiple sites / custom URL

If port 3000 is already taken (e.g. two local sites running at once), set a different port in `.env.local`:
If port 3001 is already taken (e.g. two local sites running at once), set a different port in `.env.local`:

```
BS_PORT=3001
BS_PORT=3002
```

Then define the matching constant in `wp-config.php` so PHP enqueues the client from the right URL:

```php
define( 'ELEMENTARY_THEME_BROWSER_SYNC_URL', 'https://yoursite.local:3001/browser-sync/browser-sync-client.js' );
define( 'ELEMENTARY_THEME_BROWSER_SYNC_URL', 'https://yoursite.local:3002/browser-sync/browser-sync-client.js' );
```

`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 All @@ -103,7 +121,7 @@ WP_SSL_KEY=/path/to/yoursite.local.key
WP_SSL_CERT=/path/to/yoursite.local.crt
```

This is required to avoid mixed content errors — the BrowserSync client script on port 3000 must also be served over HTTPS. Since SSL certs are domain-based, the same cert your local site uses also covers port 3000.
This is required to avoid mixed content errors — the BrowserSync client script on port 3001 must also be served over HTTPS. Since SSL certs are domain-based, the same cert your local site uses also covers port 3001.

**Finding cert paths in LocalWP (macOS):**

Expand All @@ -118,10 +136,10 @@ This is required to avoid mixed content errors — the BrowserSync client script

### Disabling BrowserSync

To disable BrowserSync without removing it from the webpack config, define this constant in `wp-config.php`:
To disable BrowserSync without removing it from the webpack config, set this in `.env.local`:

```php
define( 'ELEMENTARY_THEME_DISABLE_BROWSER_SYNC', true );
```
DISABLE_BS=true
```

This prevents PHP from enqueuing the BrowserSync client script. The BrowserSync server still starts (webpack still runs it), but the browser won't connect to it. Useful when working purely in the block editor and you don't want the BrowserSync client loading on the frontend.
Expand All @@ -131,13 +149,13 @@ This prevents PHP from enqueuing the BrowserSync client script. The BrowserSync
By default, PHP constructs the client URL from the site's scheme and host:

```
{scheme}://{host}:3000/browser-sync/browser-sync-client.js
{scheme}://{host}:3001/browser-sync/browser-sync-client.js
```

To override it entirely — for a non-standard port, a remote dev server, or a reverse proxy setup — define this constant in `wp-config.php`:

```php
define( 'ELEMENTARY_THEME_BROWSER_SYNC_URL', 'https://yoursite.local:3001/browser-sync/browser-sync-client.js' );
define( 'ELEMENTARY_THEME_BROWSER_SYNC_URL', 'https://yoursite.local:3002/browser-sync/browser-sync-client.js' );
```

This takes precedence over the auto-detected URL.
Expand All @@ -146,6 +164,6 @@ This takes precedence over the auto-detected URL.

## Known Limitations

**BrowserSync port**: BrowserSync requires its own port (3000) separate from your local site. Snippet mode keeps the site URL unchanged — proxy mode would change the URL and break WordPress redirects and cookie domains.
**BrowserSync port**: BrowserSync requires its own port (3001) separate from your local site. Snippet mode keeps the site URL unchanged — proxy mode would change the URL and break WordPress redirects and cookie domains.

**WDS host validation**: WDS runs on `localhost:8887`. For custom local hostnames (e.g. `yoursite.local`), `allowedHosts: 'all'` is set in the webpack devServer config so the HMR WebSocket connection is accepted.
4 changes: 0 additions & 4 deletions functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,6 @@ function constants(): void {
if ( ! defined( 'ELEMENTARY_THEME_ENABLE_TAILWIND' ) ) {
define( 'ELEMENTARY_THEME_ENABLE_TAILWIND', file_exists( get_template_directory() . '/src/css/frontend/tailwind.css' ) );
}

if ( ! defined( 'ELEMENTARY_THEME_DISABLE_BROWSER_SYNC' ) ) {
define( 'ELEMENTARY_THEME_DISABLE_BROWSER_SYNC', false );
}
}

constants();
Expand Down
120 changes: 111 additions & 9 deletions inc/Core/Assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public function __construct() {
public function register_hooks(): void {
add_action( 'wp_enqueue_scripts', [ $this, 'register_assets' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_assets' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_browser_sync' ] );
add_filter( 'render_block', [ $this, 'enqueue_block_specific_assets' ], 10, 2 );
}

Expand Down Expand Up @@ -123,17 +124,118 @@ public function enqueue_assets(): void {
if ( $this->tailwind_enabled ) {
wp_enqueue_style( 'elementary-theme-tailwind' );
}
}

/**
* Enqueue the BrowserSync client script for local live reload.
*
* Only runs in the `local` environment and when not disabled via DISABLE_BS
* in .env.local. The client URL is derived from the site URL and the
* BrowserSync port (BS_PORT in .env.local, default 3001), or taken verbatim
* from the ELEMENTARY_THEME_BROWSER_SYNC_URL constant when defined (for
* custom ports or remote/proxied setups).
*
* @since 1.0.0
*
* @action wp_enqueue_scripts
*/
public function enqueue_browser_sync(): void {
if ( 'local' !== wp_get_environment_type() || $this->is_browser_sync_disabled() ) {
return;
}

if ( defined( 'ELEMENTARY_THEME_BROWSER_SYNC_URL' ) ) {
$bs_url = ELEMENTARY_THEME_BROWSER_SYNC_URL;
} else {
$scheme = is_ssl() ? 'https' : 'http';
$host = wp_parse_url( home_url(), PHP_URL_HOST );
$host = $host ? $host : 'localhost';
$port = $this->get_browser_sync_port();
$bs_url = "{$scheme}://{$host}:{$port}/browser-sync/browser-sync-client.js";
}

if ( 'local' === wp_get_environment_type() && ! ELEMENTARY_THEME_DISABLE_BROWSER_SYNC ) {
if ( defined( 'ELEMENTARY_THEME_BROWSER_SYNC_URL' ) ) {
$bs_url = ELEMENTARY_THEME_BROWSER_SYNC_URL;
} else {
$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";
wp_enqueue_script( 'elementary-browser-sync', $bs_url, [], ELEMENTARY_THEME_VERSION, true );
}

/**
* Read the BrowserSync port from .env.local (BS_PORT), defaulting to 3001.
*
* 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.
* Falls back to the default when BS_PORT is absent or not a valid TCP port
* (1–65535).
*
* THIS METHOD IS INTENDED FOR LOCAL DEVELOPMENT ENVIRONMENTS ONLY.
*
* @return int BrowserSync port.
*/
private function get_browser_sync_port(): int {
$default = 3001;
$value = $this->get_env_value( 'BS_PORT' );

if ( null !== $value && preg_match( '/^\d+$/', $value ) ) {
$port = (int) $value;

if ( $port >= 1 && $port <= 65535 ) {
return $port;
}
wp_enqueue_script( 'elementary-browser-sync', $bs_url, [], ELEMENTARY_THEME_VERSION, true );
}

return $default;
}

/**
* Whether BrowserSync is disabled via DISABLE_BS in .env.local.
*
* Disabling prevents PHP from enqueuing the BrowserSync client script. The
* BrowserSync server still starts (webpack still runs it), but the browser
* won't connect to it. Truthy values are `1`, `true`, `yes`, and `on`
* (case-insensitive); anything else (or an absent key) keeps it enabled.
*
* THIS METHOD IS INTENDED FOR LOCAL DEVELOPMENT ENVIRONMENTS ONLY.
*
* @return bool True when BrowserSync should be disabled.
*/
private function is_browser_sync_disabled(): bool {
$value = $this->get_env_value( 'DISABLE_BS' );

if ( null === $value ) {
return false;
}

return in_array( strtolower( $value ), [ '1', 'true', 'yes', 'on' ], true );
}

/**
* Read a single key's value from .env.local.
*
* Returns the trimmed value (without surrounding single/double quotes) for
* the given key, or null when the file is unreadable or the key is absent.
*
* THIS METHOD IS INTENDED FOR LOCAL DEVELOPMENT ENVIRONMENTS ONLY.
*
* @param string $key Environment variable name to read.
*
* @return string|null The value, or null when not found.
*/
private function get_env_value( string $key ): ?string {
$env_file = $this->base_dir . '.env.local';

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

// phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- Local dev only; reading a small project file, not remote.
$contents = file_get_contents( $env_file );

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

if ( preg_match( '/^\s*' . preg_quote( $key, '/' ) . '\s*=\s*(.*)$/m', $contents, $matches ) ) {
return trim( $matches[1], " \t\"'" );
}

return null;
}
}
Loading