Skip to content

Build/Test Tools: Make the file watcher pick up changes reliably - #15863

Closed
westonruter wants to merge 9 commits into
WordPress:trunkfrom
westonruter:fix/grunt-watch-reliability
Closed

westonruter wants to merge 9 commits into
WordPress:trunkfrom
westonruter:fix/grunt-watch-reliability

Conversation

@westonruter

@westonruter westonruter commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

✅ Committed in r65396 (6a8e0f8).


This makes npm run dev (and grunt watch) pick up changes reliably. Before, an edit to src/js/_enqueues/lib/emoji-loader.js produced no output at all; the causes are described in Core-66638.

  • grunt-contrib-watch is replaced by a watch task built on chokidar, in tools/watch/task.js. It uses each platform's native file system events instead of polling every file, skips ignored directories instead of finding every file in them first, and keeps one watcher for the whole session instead of recreating it after each run. Changes made while tasks run are run as soon as they finish, a failing task is logged without stopping the watcher, and a change to the Gruntfile restarts it once the changes made before and during that run have been built. CHOKIDAR_USEPOLLING=1 falls back to polling, for network drives and Windows drives accessed from WSL. chokidar is already relied on across the project. In Gutenberg, npm run dev runs wp-build --watch, from the @wordpress/build package, which watches with chokidar@^4.0.0; chokidar@4.0.3 is added here as a devDependency to match. In core, chokidar was already installed: sass, a devDependency used to build the admin color schemes, depends on chokidar 5, and webpack-dev-server, a dependency of @wordpress/scripts, on chokidar 3. Version 4 is used rather than 5 because chokidar 5 is ESM-only, while the Gruntfile is CommonJS. Patterns are matched with the minimatch that grunt provides as grunt.file.minimatch, so no other dependency is added.
  • Only the files the build uses are watched. Plugins, third-party themes, uploads, the rest of wp-content other than the default themes, and node_modules directories are ignored. The all target watches the files of copy:files instead of all of src/, including hello.php, Akismet, and wp-config-sample.php in the root. It copies _index.php to index.php as the build does, instead of copying src/index.php over the built one, and deletes the built index.php when _index.php is deleted. It is skipped with --dev, where it has nothing to copy. watch:phpunit watches src/ and tests/phpunit/ instead of every PHP file in the repository.
  • The :dynamic tasks are configured for all the files changed together, by a watch-dynamic task each target runs first, instead of each change overwriting the configuration so that only the last file was rebuilt. The configuration is reset on each run, so a deletion's clean:dynamic list is no longer reused to delete uploads (Core-63605).
  • Changed scripts are built as the build builds them. A script in src/js/_enqueues is minified by the uglify target that minifies it in the build, instead of always by uglify:dynamic: wp-emoji-loader.js as a module, the jQuery UI scripts with their license comments, and farbtastic.js and iris.min.js not at all. A deleted script's .min.js is deleted along with it. Vendor scripts in src/js/_enqueues/vendor are matched to their copy:vendor-js entry again: the arguments to minimatch.match() were reversed, so every vendor script was copied by the first entry's mapping.
  • npm run build:dev -- --symlink (or npm run dev -- --symlink) symlinks the scripts copied unchanged from src/js/_enqueues into src/wp-admin/js and src/wp-includes/js, so that edits to them take effect without the watcher. Later builds with the option keep the links in place instead of cleaning and re-creating them. It is opt-in, because Docker Desktop's file sharing can fail to read a path that switches between a file and a symlink until npm run env:restart.

Testing

  1. Run npm run dev and wait for Waiting....
  2. Edit src/js/_enqueues/lib/emoji-loader.js: it is copied to src/wp-includes/js/wp-emoji-loader.js and minified within about a second.
  3. Edit several files at once (for example, a script in src/js/_enqueues and a stylesheet in src/wp-includes/css): each is rebuilt.
  4. Upload an image in the Media Library: the watcher reports nothing, and the upload is kept.
  5. Edit src/js/_enqueues/vendor/jquery/ui/core.js: it is copied to src/wp-includes/js/jquery/ui/, and uglify:jquery-ui minifies it.
  6. Stop it with ctrl+c, then run npm run watch (building into build/): an edit to src/wp-includes/formatting.php is copied to build/, an edit to src/_index.php updates build/index.php, an edit to wp-config-sample.php updates build/wp-config-sample.php, and an edit to src/index.php is not copied.
  7. Move src/js/_enqueues/wp/util.js out of the way: build/wp-includes/js/wp-util.js and wp-util.min.js are both deleted, and both are rebuilt when it is moved back.

Trac ticket: https://core.trac.wordpress.org/ticket/66638

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5 (medium effort)
Used for: Investigating the causes, writing the code and documentation, and testing the watcher against changes in both modes; reviewed by me.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

westonruter and others added 3 commits November 9, 2026 18:59
`npm run dev` often missed changes entirely, such as edits to
`src/js/_enqueues/lib/emoji-loader.js`. grunt-contrib-watch is replaced
by a watch task built on chokidar, in `tools/watch/task.js`, because
of several problems that the Gruntfile could not work around:

- gaze, which it uses, finds every file below a pattern before
  filtering out the excluded ones. For `src/**`, that includes every
  plugin in `src/wp-content/plugins` and every `node_modules` directory
  of a bundled theme: 1.8 million files in a development checkout,
  read synchronously, blocking the watcher for minutes.
- It then polls each watched file every two seconds.
- With `spawn: false`, it closes and recreates every watcher after each
  run, missing the changes made in the meantime.
- A change for one target seen while another target's tasks run is
  left queued until the next change.

The new task watches with the native file system events of each
platform, skipping the ignored directories, and keeps one watcher for
the whole session. Changes made while tasks run are run as soon as
they finish. A failing task is logged without stopping the watcher,
and a change to the Gruntfile restarts it. Polling remains available
through `CHOKIDAR_USEPOLLING=1`, for network drives and Windows drives
accessed from WSL.

The `all` target, which copies changed files to `build/`, is no longer
watched with `--dev`, as there is nothing for it to copy. The
`phpunit` target watches `src/` and `tests/phpunit/` instead of every
PHP file in the repository, which included caches and worktrees.

The changes of a target are now collected for all the files changed
together, instead of each overwriting the configuration of the
`:dynamic` tasks, so that only the last file was rebuilt. The
configuration is also reset on each run: the `clean:dynamic` file list
was kept from the last deletion, so that a later run could delete that
path again. `wp-emoji-loader.js` is minified as a module, as in the
build, and a vendor script is mapped to the copy pattern it matches
instead of always the first.

`npm run build:dev -- --symlink` symlinks the scripts that are copied
unchanged from `src/js/_enqueues` into `src/wp-admin/js` and
`src/wp-includes/js`, so that edits to them take effect without the
watcher. It is opt-in because Docker Desktop's file sharing can fail
to read a path that is replaced by a symlink until the containers are
restarted. Where symlinks cannot be created, as on Windows without
Developer Mode, the files are copied.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN
Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1
Third-party themes, uploads, translations, and the rest of
`wp-content` other than the default themes are no longer watched, as
they are not part of the build. Ignoring them also keeps the watcher
from searching their directories.

When building into `build/`, the `all` target now watches the files
that the `copy:files` task copies, after any `--minimal-copy`
exclusions, instead of everything in `src/`. Changes were otherwise
copied that the build does not copy: those to third-party themes and
uploads, and those to `src/index.php`, `src/wp-admin/index.php`, and
`wp-includes/version.php`, whose versions in `build/` the build
creates from `_index.php` or processes.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN
Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1
…ilds

Each build cleaned the built scripts first, so `--symlink` deleted
every link and created it again, even when it already pointed to its
source. Docker Desktop's file sharing can fail to read a path that is
replaced this way until the containers are restarted, so the scripts
would fail to load after every build, including the one that
`npm run dev -- --symlink` runs when it starts.

With `--symlink`, the `clean:js` and `clean:webpack-assets` tasks now
keep the links that point to their sources, and the `link` task leaves
them as they are. The other built files in `src/wp-admin/js` are
cleaned one by one instead of with the directory. Without the option,
the links are cleaned and replaced with copies, as before.

The README now mentions `npm run dev -- --symlink` and that switching
between links and copies can require `npm run env:restart`.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN
Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1
@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

- With `--dev`, the `colors` and `rtl` targets ran again for the files
  their own tasks wrote into `src/`, without end, and `rtlcss` turned
  `common-rtl.css` into `common-rtl-rtl.css` and so on. The `colors`
  target now watches only the `.scss` sources, and the `rtl` target
  excludes the built `-rtl` and `.min` stylesheets.
- Editing a vendor script in `src/js/_enqueues/vendor` threw: the
  arguments to `minimatch.match()` were reversed, so no glob matched
  and the lodash entry, which has no `cwd`, was reached. Each entry is
  now matched with `grunt.file.isMatch()`, skipping those without a
  `cwd`.
- A changed script is minified by the uglify target that minifies it in
  the build, instead of always by `uglify:dynamic`: the jQuery UI
  scripts with the comments kept, `wp-emoji-loader.js` as a module,
  and `farbtastic.js` and `iris.min.js` not at all. This replaces the
  `uglifyModules` list and the `uglify:dynamic-module` target.
- A reload for a change to the Gruntfile now happens after the tasks
  for the other changes made with it, which the restarted watcher would
  not see, and the `phpunit` target is defined when the Gruntfile
  loads, so that the restarted watch task still has it.
- When a task fails, only the rest of its own target's tasks are
  skipped, instead of those of every other target in the same run.
- The `all` target watches `index.php`, `hello.php`, and Akismet in
  `wp-content/plugins`, which the build copies, and copies `_index.php`
  to `index.php` as the build does.
- The ignored patterns are compiled once instead of for every path
  found, and the clean tasks collect the scripts' links once instead of
  for every path they check.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN
Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Three active moderate issues can leave generated files stale or orphaned and can drop edits during watch-task restarts, so the change is not yet safe to approve.

3 open issues
What changed in this PR

This pull request replaces the legacy Grunt file watcher with a persistent chokidar-based watcher to improve change detection, task batching, failure recovery, and development-build behavior. It also adds optional JavaScript symlinking and documents the new watcher options.

Changes:

  • Adds a chokidar watcher with native events, debouncing, dynamic task configuration, reloads, and failure recovery.
  • Updates Grunt watch and build handling for scoped file watching, incremental rebuilds, JavaScript minification, and symlinks.
  • Replaces the legacy watcher dependency and documents polling and symlink usage.
File Description
tools/​watch/​task.js Implements the persistent chokidar-based watcher and task queue management.
README.md Documents watcher polling and optional JavaScript symlink usage.
package.json Updates development dependencies by replacing the legacy watcher with chokidar.
package-lock.json Updates the lockfile dependency tree for the watcher changes.
Gruntfile.js Integrates the watcher and configures dynamic builds, minification, watched paths, and symlink support.

Review effort: Lite

Comment thread Gruntfile.js Resolved
Comment thread Gruntfile.js Resolved
Comment thread tools/watch/task.js Resolved
- The `all` target now watches `wp-config-sample.php` in the root,
  which the build copies too, and copies it to the build.
- When `_index.php` or `wp-admin/_index.php` is deleted, the
  `index.php` copied from it is deleted from the build, instead of
  being left behind.
- When the Gruntfile changes, the watch task now runs the changes made
  while its tasks ran before restarting, as the new process would not
  see them.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN
Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Approval is blocked by two active moderate issues: deleted enqueues can leave stale minified artifacts, and the watcher imports undeclared minimatch, risking startup failure.

No open issues 🎉

2 previously missed issues

In code that hasn't changed since last review

Medium severity Deleted scripts leave stale minified outputs

Gruntfile.js:2819

For a deleted enqueue, this branch removes only the unminified destination (or its symlink). Files handled by uglify:core or targets such as emoji-loader and jquery-ui still have their generated .min.js left behind, so a deleted source can continue to be served from a stale build artifact. Include the corresponding minified output in deletion cleanup, including when symlink mode is enabled.

Medium severity Declare minimatch as a direct devDependency

tools/​watch/​task.js:37

This file now imports minimatch directly, but package.json does not declare it as a dependency; the lockfile only provides it transitively through Grunt. A future dependency-tree/lockfile change can therefore make the watcher fail at startup with MODULE_NOT_FOUND. Add minimatch as a direct devDependency and regenerate the lockfile.

3 resolved since last review

Review effort: Lite

- When a script is deleted, its minified copy is deleted from the build
  too, instead of being left behind to be served.
- The watch task uses the minimatch that grunt provides as
  `grunt.file.minimatch`, rather than requiring `minimatch`, which is
  not a direct dependency and is found only because grunt's copy is
  hoisted.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN
Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1
@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5 (medium effort)

The two "previously missed issues" from #15863 (review) are fixed in f6c733d.

Details
  • Deleted scripts leave stale minified outputs: when a script with an uglify target is deleted, its .min.js (which every uglify target writes next to the script) is now added to the clean:dynamic list as well. Verified by deleting src/js/_enqueues/wp/util.js under grunt _watch: both build/wp-includes/js/wp-util.js and wp-util.min.js were removed, and both were rebuilt when the file was restored.
  • Declare minimatch as a direct devDependency: rather than adding a dependency, tools/watch/task.js now uses grunt.file.minimatch, the copy that grunt itself requires and exposes, so it no longer depends on the hoisted copy.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

A critical active finding remains because configuration changes restart the wrong Grunt task, causing the watcher to exit instead of resuming correctly.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

High severity Pass the current watch task when restarting Grunt

tools/​watch/​task.js:240

The restarted process is launched without the watch task name: for a normal watch run nameArgs is empty, so this executes Grunt's default task (build) and exits instead of resuming the watcher; for watch:phpunit it passes only :phpunit, which is not a valid task name. Include the current watch task name (for example, taskName + nameArgs) in the spawned arguments so config changes actually restart the watcher.

Review effort: Lite

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5 (medium effort)

The "Pass the current watch task when restarting Grunt" finding from #15863 (review) is a false positive: grunt's this.nameArgs is the full task name with its arguments (_watch or _watch:phpunit), not only the arguments, so the restarted process runs the same watch task.

Details
  • Grunt sets it in node_modules/grunt/lib/util/task.js:149 as nameArgs: name, where name is the whole task:arg string that was run.
  • The watch task in the Gruntfile already relies on this with grunt.task.run( '_' + this.nameArgs ), which yields _watch or _watch:phpunit.
  • In the reload probes, a change to Gruntfile.js logs Reloading watch config..., then Running "_watch" task and Waiting... from the new process; build is not run.

@westonruter
westonruter marked this pull request as ready for review November 10, 2026 07:44
@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter, lancewillett.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

@lancewillett lancewillett left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks, @westonruter. Approving the watcher change at f6c733d.

I ran trunk's watcher and this one side by side, in copies of trunk and of this PR with no plugins installed.

  • Trunk, --dev: an edit to src/js/_enqueues/lib/emoji-loader.js produced no output after 20 seconds. When wp/util.js and admin/post.js were saved together, only wp-util.js was copied.

  • This PR, --dev: each of these was built in under a second:

    • the emoji-loader edit
    • two scripts and a stylesheet saved together, with their .min.js and -rtl.css files
    • jQuery UI core.js, by uglify:jquery-ui
    • a script deleted and restored, with its .min.js deleted and rebuilt

    A new file in uploads/ logged nothing and was kept. A syntax error logged a warning, the watcher kept running, and the next save rebuilt the file.

  • This PR, build/ mode: these were copied:

    • formatting.php
    • _index.php, to build/index.php
    • wp-config-sample.php
    • hello.php

    These were left alone: src/index.php, a third-party plugin, and a .po file in languages/. Deleting _index.php deleted build/index.php, and restoring it rebuilt the file. A file in build/wp-content/uploads survived later runs.

  • --symlink: the first build linked 94 scripts, and a second build kept them. A build without the option replaced them with copies, and a build into build/ ignored the option.

  • CI is green.

Suggestion: consider moving --symlink to its own ticket and PR. The ticket describes it as separate from the watcher fix. It also adds the link task and the clean:js and clean:webpack-assets filters, which every build loads. On its own, the watcher fix would be smaller to review and to revert. Not a blocker.

Two minor findings, neither blocking:

  1. An edit made during a reload is lost. restart() closes this process's watcher before the new process is ready. I saved Gruntfile.js, then edited emoji-loader.js while the new process was still starting. That edit was never built. On a cold start, the initial scan took 13 seconds here, so the gap can be that long. Gruntfile edits are rare, so a note in the "Reloading watch config..." message may be enough.
  2. A failed uglify prints a second warning. After "Uglification failed.", the log also shows Warning: Cannot read properties of undefined (reading 'min'). The replaced grunt.warn returns instead of stopping the task, so grunt-contrib-uglify keeps running after its own failure. This is harmless, since the run still recovers, but the second line reads like a separate bug.

AI review · claude-opus-5-5

@westonruter

Copy link
Copy Markdown
Member Author

Suggestion: consider moving --symlink to its own ticket and PR. The ticket describes it as separate from the watcher fix. It also adds the link task and the clean:js and clean:webpack-assets filters, which every build loads. On its own, the watcher fix would be smaller to review and to revert. Not a blocker.

I'd like to include the symlink improvement here because it is directly related. It eliminates 100% of the latency of having a watcher even running in many cases.

westonruter and others added 2 commits November 10, 2026 09:29
…ne failure per task

- When the Gruntfile changes, the watch process kept closing its watcher
  before the new process was ready, so an edit made during the new
  process's initial scan, which can take several seconds, was never
  built. The previous process now keeps watching until the new one
  reports over IPC that it is ready, and then hands it the changes seen
  in the meantime.
- The watch task's replacement for `grunt.fail.warn` returns to the
  failed task instead of exiting, as grunt's own does, so a task can
  fail again on the result it did not get. grunt-contrib-uglify, for
  example, followed "Uglification failed." with "Cannot read properties
  of undefined (reading 'min')". Only the first failure of a task is
  now reported, and any further ones are logged with `--verbose`.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: d249ded5-c78c-44ee-aadc-986582dfafb8
@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5 (medium effort)

Both minor findings from #15863 (review) are fixed in 46c7a23. An edit made during a reload is now built by the new process, and a failed uglify reports a single warning.

Details
  • An edit made during a reload is lost: restart() no longer closes the watcher right away. The previous process keeps watching until the new one reports over IPC that its initial scan is done. It then hands over the changes seen in the meantime, which the new process runs, and closes its own watcher. To verify, Gruntfile.js was changed under grunt _watch --dev, and a marker was appended to src/js/_enqueues/lib/emoji-loader.js right after Reloading watch config.... The new process built it into wp-emoji-loader.js.
  • A failed uglify prints a second warning: the replaced grunt.fail.warn still returns rather than exiting, so that the watcher keeps running. Only the first failure of a task is now reported, and further ones from the same task are logged only with --verbose, until the next task starts. With --force, every warning is still logged, as before. With a syntax error added to emoji-loader.js, only Warning: Uglification failed. was logged. The watcher kept running, and the next save rebuilt the file.

The suggestion to move --symlink to its own ticket was not taken up, so it stays in this PR.

@lancewillett

Copy link
Copy Markdown
Member

Confirmed both fixes at 69ae362 with the real watcher in --dev and build/ modes on macOS. Before the follow-up, an edit during restart was lost; now it is copied and minified. Invalid JavaScript now produces one warning, and the watcher rebuilds after correction. Two successive failure/recovery cycles passed in each mode.

My approval stands. This was a focused check of the two reported issues, not exhaustive race testing.

AI review · gpt-6

wporg-sync pushed a commit that referenced this pull request Nov 10, 2026
The `npm run dev` watcher often missed changes entirely, such as edits to `src/js/_enqueues/lib/emoji-loader.js`. The causes lay in grunt-contrib-watch and its gaze dependency, so they could not be fixed through configuration:
* Gaze enumerated every file under a pattern before applying exclusions, including every installed plugin and bundled `node_modules` directory, which could block the watcher for minutes.
* Each file was then polled every two seconds.
* The watchers were recreated after each run, missing changes made in the meantime.

The watch task is therefore replaced with one built on chokidar, which uses native file system events, skips ignored directories, and keeps a single watcher for the session. Polling remains available via `CHOKIDAR_USEPOLLING=1` for network drives and Windows drives accessed from WSL. Version 4 of chokidar is used, matching `@wordpress/build` in Gutenberg, since version 5 is ESM-only.

The new watch task also changes what is rebuilt:
* Only the files that the build uses are now watched, and the `all` target copies the same files that `copy:files` does instead of everything in `src`.
* The `:dynamic` task configuration is now built from all files changed together and reset on each run. Previously only the last changed file was rebuilt, and a stale `clean:dynamic` list could delete uploaded files.
* Changed scripts are minified by the same uglify target as in the build.
* When the Gruntfile changes, the watcher restarts in a new process, and the previous one keeps watching until the new one is ready, so edits made during the restart are still built.

A new opt-in `--symlink` option for `npm run build:dev` and `npm run dev` symlinks the scripts copied unchanged from `src/js/_enqueues`, so edits take effect without the watcher. It is opt-in because Docker Desktop's file sharing can fail to read a path that switches between a file and a symlink until the environment is restarted.

Developed in #15863.
Follow-up to r43309, r43324, r43327, r44359, r56461, r61475.

Props westonruter, lancewillett, siliconforks, sirlouen.
See #44271, #63606, #64563.
Fixes #66638, #63605.


git-svn-id: https://develop.svn.wordpress.org/trunk@65396 602fd350-edb4-49c9-b593-d223f7449a82
@github-actions

Copy link
Copy Markdown

A commit was made that fixes the Trac ticket referenced in the description of this pull request.

SVN changeset: 65396
GitHub commit: 6a8e0f8

This PR will be closed, but please confirm the accuracy of this and reopen if there is more work to be done.

@github-actions github-actions Bot closed this Nov 10, 2026
wporg-sync pushed a commit to WordPress/WordPress that referenced this pull request Nov 10, 2026
The `npm run dev` watcher often missed changes entirely, such as edits to `src/js/_enqueues/lib/emoji-loader.js`. The causes lay in grunt-contrib-watch and its gaze dependency, so they could not be fixed through configuration:
* Gaze enumerated every file under a pattern before applying exclusions, including every installed plugin and bundled `node_modules` directory, which could block the watcher for minutes.
* Each file was then polled every two seconds.
* The watchers were recreated after each run, missing changes made in the meantime.

The watch task is therefore replaced with one built on chokidar, which uses native file system events, skips ignored directories, and keeps a single watcher for the session. Polling remains available via `CHOKIDAR_USEPOLLING=1` for network drives and Windows drives accessed from WSL. Version 4 of chokidar is used, matching `@wordpress/build` in Gutenberg, since version 5 is ESM-only.

The new watch task also changes what is rebuilt:
* Only the files that the build uses are now watched, and the `all` target copies the same files that `copy:files` does instead of everything in `src`.
* The `:dynamic` task configuration is now built from all files changed together and reset on each run. Previously only the last changed file was rebuilt, and a stale `clean:dynamic` list could delete uploaded files.
* Changed scripts are minified by the same uglify target as in the build.
* When the Gruntfile changes, the watcher restarts in a new process, and the previous one keeps watching until the new one is ready, so edits made during the restart are still built.

A new opt-in `--symlink` option for `npm run build:dev` and `npm run dev` symlinks the scripts copied unchanged from `src/js/_enqueues`, so edits take effect without the watcher. It is opt-in because Docker Desktop's file sharing can fail to read a path that switches between a file and a symlink until the environment is restarted.

Developed in WordPress/wordpress-develop#15863.
Follow-up to r43309, r43324, r43327, r44359, r56461, r61475.

Props westonruter, lancewillett, siliconforks, sirlouen.
See #44271, #63606, #64563.
Fixes #66638, #63605.

Built from https://develop.svn.wordpress.org/trunk@65396


git-svn-id: http://core.svn.wordpress.org/trunk@64535 1a063a9b-81f0-0310-95a4-ce76da25c4cd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants