Skip to content

Move a site's script runner and build watch out of SiteRow into hooks - #582

Merged
zaerl merged 3 commits into
trunkfrom
update/site-build-watch-hook
Oct 1, 2026
Merged

zaerl merged 3 commits into
trunkfrom
update/site-build-watch-hook

Conversation

@zaerl

@zaerl zaerl commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Why

SiteRow holds the state of eight separate things in one function, and #554 splits it into hooks by domain so the redesign in #542 can rebuild the screen around pieces it can hold. Mail, the logs and the terminal have left. The issue's next item is "dev server and watch". This is the first half of it: the build watch, and the script runner it and everything else runs through. The dev server follows in a PR of its own.

What changes

Two hooks, because they are two things.

  • src/renderer/hooks/use-site-scripts.jsx is the npm runner. It holds runInstall, runScript and killCurrent, whether an install or a build is running, whether the last build failed, whether the last thing to write build/ was cut short, and the run a stop should reach. The terminal, the setup, the update, the apply, the ticket switch and the build watch all run through it.
  • src/renderer/hooks/use-build-watch.jsx is the build watch. It holds the watch's state, its run, who is waiting for it to be ready, and start, stop, pause, resume and hand-off. It takes the runner and the terminal's lock as arguments: a site with no build is built once before it can be watched, and that one build holds the terminal.
  • What the chains read from outside a render is handed out as it was: the watch's state ref, the queue of who is waiting for it, and the ref that says which apply's hand-off is current. SiteRow takes them out under the names it always used, so the update, the apply and the ticket switch did not change.
  • One dependency list gained a ref, stopDevServer's, for the reason the terminal's PR gave: the linter cannot know that a ref from a hook is stable.

Not in this PR: the dev server (stopDevServer, startPhpServer, toggleDevServer and their state), which is next.

No journey touched the build watch, so a new build-watch.spec.js has two, and the terminal's journey now pins the "a build is running" and "an install is running" flags, which nothing on screen had depended on alone. All were written against the inline code first and pass on both.

How to test this

Platforms: any. Use the Buildkite build for the current head of this branch.

The journeys carry this, from the repository root: npx playwright test --project=journeys tests/e2e/journeys/build-watch.spec.js tests/e2e/journeys/terminal.spec.js.

  • The first build-watch journey, on a built Core site, asserts that the watch's button starts the watcher in the site's directory and its tab says what it ran; that what it prints goes to its tab; that it does not hold the terminal; that the button stops that run and the tab says so; that a run started straight after is not told it has exited when the stopped one finally goes; and that an exit nobody asked for is shown with its code.
  • The second, on a Gutenberg checkout with no build, asserts that the button runs the build first; that the build holds the terminal; that a failed build starts no watcher and says why; that a good one is followed by the watcher with nothing clicked; that the watcher is not "watching" until it prints its ready line; and that it does not hold the terminal the build held.
  • The terminal's journey now also asserts that a running build and a running install are known to the rest of the site's view, through a button that waits for both, and that their ending is too.

What they cannot reach is a real watcher and a real build:

Starting state: a Core site that has finished setup, with no dev server and no build watch running.

  1. Click "Start build watch". The dot on the button goes green and the "Build watcher" tab under "Logs" reads "Build watcher (watching)". Click that tab: it shows what was run.
  2. Edit and save a file under src/. The tab shows the watcher compiling it.
  3. In the terminal, type npm run build and press Enter. It runs: the watch does not hold the terminal.
  4. Click "Stop build watch". The dot goes grey and the tab reads "Build watcher".
  5. Click "Update to latest trunk" in the "More" menu with the build watch running, on a site where an update is available. The tab reads "Build watcher (paused)" while the update runs and the watch is back afterwards.

What must not have happened:

  • After step 4, no watcher process is left behind. On macOS, pgrep -fl grunt in a terminal shows none for the site's directory.

Risks and limitations

  • Found on the way, and not fixed here: "Start build watch" is meant to open the watch's tab and does not. The app calls its own "select this tab" function, but the tab panel cannot be switched from outside it, so the screen stays where it was while the app believes the watch's tab is open. That is older than this PR (Decouple the build watcher from the dev server, with its own control (#247) #272) and has its own consequences for the panes that follow their last line. The journeys open the tab themselves and say why; nothing here pins the behaviour either way.
  • Pausing, resuming and handing a change to the watch have no journey with the watch running. Step 5 above is the manual check. The journeys for the update, the apply and the ticket switch run through pauseWatcher and resumeWatcher with no watch running, where both do nothing. A journey for it belongs with the PR that moves the trunk update's state, which needs the same stand-ins; it is left for that one on purpose.
  • One local run of the build-watch journeys failed and I could not explain it. In 17 runs of build-watch.spec.js on the final test code, one failed at the first click of the second journey; the 14 after it passed, and the full suite passed. My script did not keep that run's output, so the cause is unknown. Test windows take focus on the desktop they run on, and a click or a key from the person at it is the likeliest cause, but that is a guess. CI is the clean run.
  • Killing the watcher when the site's view goes has no journey. It is the effect that moved furthest, from the top of SiteRow into the hook; the code is the same.
  • The state is created about 700 lines further down SiteRow than it was, where the hooks are called. Nothing reads it before that point; the one thing that did, the list of log tabs, moved below the hook.
  • The manual steps above were not run.
  • The journeys cost two more app launches on every pull request, on both platforms: about two and a half seconds locally on macOS.
  • Checked on macOS only. Windows is covered by CI.
  • Review, two passes: 6 findings to fix, all in the tests or in comments, all fixed here; 3 follow-ups, listed in the review outcome below.

Related

Part of #554: the first half of "dev server and watch", after mail in #579, the logs in #580 and the terminal in #581. Part of #542.


Design decisions and alternatives considered

The watch and the runner now, the dev server next. The three together are about 450 lines of logic that guards against each other's late callbacks. The dev server starts behind the watch and reads its state; the watch knows nothing of the server. So the watch can move first and the server can then depend on a hook.

"The build was cut short" lives with the runner, not with the watch. The watch sets it and a build ending well clears it, and the runner is where a build ends. With the flag in the watch's hook, the runner would need the watch and the watch needs the runner.

Which apply's hand-off is current stays in the watch's hook for now. It is the apply's state, and pausing the watch is what invalidates it. When the apply chain moves, it can take the ref with it and give the watch a function to call.

The watch takes eleven arguments. Eight of them are other hooks' functions and refs. That is the coupling the code already had, written down in one place; making it smaller means changing what the watch does, not where it lives.

Review outcome (required — see AGENTS.md)

First pass: 4 [fix here] · 2 [follow-up]. It compared the lines removed from index.jsx with the two hooks as a sorted set and in order, and found them identical apart from the headers, the imports and the return values.

  • Tests, medium, [fix here], fixed in 4cecbb6: the build watch's journey said pausing and resuming had journeys of their own. They do not. The header says so.
  • Tests, low, [fix here], fixed in 4cecbb6: the hint under the terminal and a tab of the Logs panel were each located in more than one file. Both are in tests/e2e/helpers/ui.cjs.
  • Tests, low, [fix here], fixed in 4cecbb6: the wait on the hint before the first click is what keeps the click off a site not yet known to be built, and was labelled as a characterisation. It says what it is for.
  • Tests, low, [fix here], fixed in 4cecbb6: the checks on the build and install flags did not tell the flags from the terminal's lock. The button they watch is now shown to stay enabled while the lock alone is held, and with the button rewired to follow the lock the journey goes red on that line.
  • Tests, medium, [follow-up]: watch paths with no journey: pause and resume, the hand-off and the compiling state, the kill when the view goes, stopping during the first build, refusing to start while the terminal is busy, and a dev-server start queued behind Gutenberg's ready line. In Risks above.
  • Architecture, low, [follow-up]: the watch's button does not open its tab. In Risks above. The reviewer found one more consequence: a contributor reading debug.log who starts the watch sees an unread count rise on the tab they are reading.

Second pass, on 4cecbb6 alone: 2 [fix here] · 1 [follow-up], all three about comments, the first two fixed in e947290.

  • The journey's header said an update, an applied patch and a restored pull request each pause, resume and hand over. Only an applied patch can do all three, and a ticket switch, which the header did not name, hands over too.
  • The watch hook's header said its button is the only way it is stopped. Ctrl+C stops it too, while its first build holds the terminal.
  • Follow-up: a comment in SiteRow's stopDevServer says the watch is stopped only by its own control, with the same gap. It is in the code the next PR moves.

Both passes confirmed that no moved name is read before its hook is called, including in dependency lists, that no callback is less stable than before, and that nothing real can be started by the journeys.

  • Review: completed — fresh agent context, local, before the PR existed, read-only; head 6c1327b / base 210a73d; committed work only, working tree clean; then a second fresh context on 4cecbb6 alone; outcomes above. CodeRabbit: not run yet.
  • Since review: 4cecbb6 → e947290 checked. The difference is comments in five files. It was not reviewed again by a fresh context.

Checks, macOS, local:

Check On Result
eslint . --max-warnings=0 e947290 clean
unit suite on Node 6c1327b 1697 pass, 2 skipped, 0 fail
unit suite on Electron's Node 6c1327b 1699 pass, 0 fail
npm run test:e2e 4cecbb6 49 of 49 pass
build-watch.spec.js, repeated 4cecbb6 16 of 17 runs pass; see Risks
npm run test:e2e:packaged 6c1327b 15 of 15 pass

After 6c1327b the only changes outside the tests are comments in the two hooks.

Audit of the journeys, on the commit each line was written for, one break at a time, each restored before the next. Every one goes red on the line that makes the claim:

Broken Goes red at
The button does not start the watcher the script asked for, in both journeys
The tab does not say what the watch is doing the tab's name, in both
The tab does not say what was run the line
What the watcher prints is not shown the line, in both
The watcher holds the terminal the hints being links; the last command not running
Stop does not stop the run the stop asked for
Stop leaves the tab saying it is watching the tab's name
A stopped run's late exit is taken for the new run's the tab still saying "watching"
An exit nobody asked for is not shown the tab's name with the code
A site with no build is watched without one the build being asked for
The view does not tell the watch whether the site is built the build being asked for
The first build does not hold the terminal no second script having been started
A failed build is followed by the watcher the sentence saying it was not
A good build is not followed by the watcher the watcher being asked for
The watcher is watching before it says it is ready the tab still saying "building"
The watcher saying it is ready changes nothing the tab saying "watching"
The terminal is still held once the build has ended the next build being asked for
A script ending is not passed on the lines that follow an ending, in three journeys
The run Ctrl+C should stop is not recorded the stop asked for, in the terminal's journey
An install ending is not passed on the line that follows it, in the terminal's journey
A running build is not flagged the button that waits for it
A build that has ended is still flagged the same button
A running install is not flagged the same button
An install that has ended is still flagged the same button
The button that waits for a build follows the terminal's lock the button being enabled while a script that is not a build runs

The first audit found one break nothing caught: the "a build is running" flag. Wherever a build ran in a journey the terminal's lock was held too, and every assertion was satisfied by the lock. The terminal's journey now watches a button that depends on the flag and not on the lock.

🤖 Generated with Claude Code

zaerl and others added 3 commits October 1, 2026 14:19
The fourth domain of #554, in its first half: the dev server follows.

useSiteScripts holds the npm runs a site's view starts: the install and
the scripts, whether one is running, whether the last build failed or
was cut short, and the run a stop should reach. Everything that runs one
goes through it, the terminal and the build watch included.

useBuildWatch holds the build watch: its state, its run, who is waiting
for it to be ready, and start, stop, pause, resume and hand-off. It
takes the runner and the terminal's lock as arguments, because a site
with no build is built once, under the lock, before it can be watched.

What the chains read from outside a render is handed out under the
names SiteRow already used, so the update, the apply and the ticket
switch are untouched. One dependency list gains a ref.

No journey touched the build watch, so two do now, and the terminal's
journey pins the build and install flags, which nothing on screen had
depended on alone. All were written against the inline code and pass on
both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
From the review of the previous commit.

The build watch's journey said pausing and resuming the watch had
journeys of their own. They do not: the update, the apply and the pull
request journeys run with no watch running, where both do nothing. The
header says so.

The hint under the terminal and a tab of the Logs panel were each
located in more than one file, so both are in ui.cjs. The wait on the
hint before the first click is what keeps the click off a site not yet
known to be built, and now says so. The button the terminal's journey
watches for the build and install flags is shown to stay enabled while
the lock alone is held; with the button rewired to follow the lock, the
journey goes red on that line.

Three comments in the hooks corrected with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
From the review of the previous commit. Comments only.

Only an applied patch can both pause the watch and hand it a change; an
update and a restored pull request pause it, and a ticket switch hands
it files. The watch is stopped by its button, and also by Ctrl+C while
its first build holds the terminal. Its run is killed on a pause or its
own stop, not when the dev server stops.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository: WordPress/contributor-toolkit/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 129ee9b1-09d1-4bc2-9b01-2cec2daab404

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@zaerl
zaerl merged commit eed1ab8 into trunk Oct 1, 2026
8 checks passed
@zaerl
zaerl deleted the update/site-build-watch-hook branch October 1, 2026 12:48
zaerl added a commit that referenced this pull request Oct 1, 2026
## Why

`SiteRow` holds the state of eight separate things in one function, and
#554 splits it into hooks by domain so the redesign in #542 can rebuild
the screen around pieces it can hold. The issue's item "dev server and
watch" was split in two: the build watch and the script runner left in
#582. This is the other half: the dev server.

## What changes

`src/renderer/hooks/use-dev-server.jsx` is new. It holds whether the
server is starting or up, its address, the guards around a start and a
stop, and the one button that does both.

- **Starting the server is what brings the rest of a running site to
life, so the hook takes what it needs from the other domains as
arguments**: the build watch it starts behind, the Server pane and the
debug.log tail, the site's mail, the terminal's lock and the script
runner's current run. They are the calls the inline code made, in the
same order.
- **It returns what the screen shows**: the address, whether it is
starting or up, the button's label and its handler, and how long a start
has been going.
- **It is called where the button's handler was**, below the build
watch. Nothing in `SiteRow` read the server's state above that point, so
nothing else moved.
- **The markup stays in `SiteRow`.**

**No journey pressed the button for its own sake**: the mail and the
logs journeys use it to get a running site. A new `dev-server.spec.js`
has two journeys. Both were written against the inline code first and
pass on both.

## How to test this

Platforms: any. Use the Buildkite build for the current head of this
branch.

The journeys carry this: `npx playwright test --project=journeys
tests/e2e/journeys/dev-server.spec.js`, from the repository root.

- The first, on a built Core site, asserts that the button asks for one
server for this site and says it is starting; that pressed again while
it starts it starts nothing more; that once the server has an address
the site is opened in the browser once, the address is shown and the
button offers to stop it; that stopping asks for the server to be
stopped, takes the address away and leaves the build watch running; that
the exit which follows a stop that was asked for is not reported as a
crash; that an exit nobody asked for is; and that a server which could
not start says why and gives the button back.
- The second, on a Gutenberg checkout with no build, asserts that the
button runs the build first and starts no server; that a failed build
cancels the start and says so; that after a good build the server still
waits until the watcher prints its ready line; and that on a build which
is already there the server starts at once and does not start the
watcher that would remove it.

What they cannot reach is a real server:

**Starting state:** a Core site that has finished setup, with no dev
server running.

1. Click "Start dev server". The button reads "Starting dev server..."
and the line under it counts the seconds. The build watch's button reads
"Stop build watch".
2. The site opens in your browser by itself, once. The button reads
"Stop dev server", and the site's address, "wp-admin" and "DB inspect
(Adminer)" are under it.
3. Click "Stop dev server". The address goes and the button reads "Start
dev server". The build watch's button still reads "Stop build watch".
4. Look at the "Server" tab under "Logs". It does not say the server
stopped unexpectedly.

**What must not have happened:**

- The browser did not open the site twice.
- Stopping the server did not stop the build watch.

## Risks and limitations

- **The hook takes eighteen arguments.** That is the dev server's real
reach: it touches the watch, the logs, the mail, the terminal and the
runner. It is written down in one place now, where before it was the
whole of `SiteRow`'s scope.
- **Moved as it was: `waitingForWatch` is never true.** The code only
ever sets it to false, so the part of "is the server starting" that
reads it does nothing. It predates this PR. Removing it is a change of
its own.
- **Three older bugs the reviews found in this code, moved as they were
and not fixed here.** After a start that throws, the button does nothing
until the app restarts. "Stop dev server" releases the terminal's lock,
which the server has not held since #247, so a build typed in the
terminal can lose its Ctrl+C. And a server that exits before it has an
address is probably reported as a crash with its reason lost, because
its exit is announced before its failure is answered; that last one was
read from the code and not confirmed. They change behaviour, so they are
for PRs of their own.
- **States with no journey, as before this PR:** the alert when the site
has no build and the setup was not skipped, the setup checklist's own
start button, a start that throws, a stop pressed while a start is under
way (the button does not offer one), and a failed start in any of the
orders the real server produces: the journey's failed start is an answer
with no exit.
- **The journey reaches one of the app's handlers through a map Electron
does not document**, to count a question being asked and answered while
leaving the answer real. If an Electron upgrade moves it, the journey
fails at its first line and says why.
- **The manual steps above were not run.**
- **The journeys cost two more app launches** on every pull request, on
both platforms: about three and a half seconds locally on macOS.
- **Checked on macOS only.** Windows is covered by CI.
- Review, two passes: 5 findings to fix, all in the new journey, all
fixed here; 6 follow-ups, all older than this PR, the first three above.

## Related

Part of #554: the second half of "dev server and watch", after #582.
Part of #542.

---

<details>
<summary>Design decisions and alternatives considered</summary>

**Arguments, not hooks calling hooks.** The dev server could call
`useSiteMail` and `useSiteLogs` itself. Then the Mail panel and the Logs
panel would have to reach through the server for their own state. Called
side by side in `SiteRow`, each hook owns one thing and the server is
told what to call.

**`toggleDevServer` stays a plain function.** It was one, recreated on
every render, and nothing memoised depends on it. Making it a
`useCallback` would add a long dependency list to get nothing.

**The derived values moved with the state.** Whether the server is
starting, whether anything is running and what the button says are three
expressions over the hook's state. Left in `SiteRow`, they would be the
only reason to return `waitingForWatch`.

</details>

<details>
<summary>Review outcome (required — see AGENTS.md)</summary>

**First pass: 4 [fix here] · 6 [follow-up].** It diffed each old block
against the hook and found one difference, the comment that was reworded
on purpose.

- Tests, low, [fix here], fixed in 05e8508: the check that Core's watch
was started read the stand-in once, though the watch and the server are
asked for in one go. It is polled.
- Tests, low, [fix here], fixed in 05e8508: the wait before counting
servers after a second press was a guess. The stand-in now counts the
app's question about the site being asked and answered, and the journey
waits for that.
- Tests, low, [fix here], fixed in 05e8508: a read of "no script was
stopped" after the crash did not wait for the main process first.
- Tests, low, [fix here], fixed in 05e8508: the header left out two
things the stand-in omits.
- Architecture, medium, [follow-up]: the button goes dead after a start
that throws.
- Architecture, medium, [follow-up]: stopping the server releases a
terminal lock it does not hold.
- Architecture, low, [follow-up]: a stop during a start cannot be
reached from the screen; only the main process's two-minute timeout ends
a hung boot.
- Architecture, low, [follow-up]: `waitingForWatch`, its ref and its
effect are dead.
- Architecture, low, [follow-up]: the button's label, the crash-or-stop
decision and the failed-start test are decisions in a hook, where no
unit test reaches them.
- Tests, low, [follow-up]: two reads of the Server pane in the second
journey rely on the watch's button not opening the watch's tab, which is
a bug with a task of its own; fixing it will turn them red, honestly.

**Second pass, on 05e8508 alone: 1 [fix here] · 0 [follow-up]**, fixed
in 9845ffa.

- Tests, low, [fix here]: the header said a real failed start is
followed by an announced exit. That is one order of three. The header
names all three and says none is walked. Reading the main process for
this is what turned up the third bug in Risks.

The second pass traced the new wait step by step with the guard removed
and found that it bounds the path, and confirmed that the map the
stand-in reaches through exists at the pinned Electron version and that
wrapping the handler changes neither its answer nor its timing.

- **Review:** completed — fresh agent context, local, before the PR
existed, read-only; head e85ea99 / base
eed1ab8; committed work only, working
tree clean; then a second fresh context on
05e8508 alone; outcomes above.
CodeRabbit: not run yet.
- **Since review:** `05e8508 → 9845ffa` checked. The difference is two
comments in the journey. It was not reviewed again by a fresh context.

Checks, macOS, local:

| Check | On | Result |
|---|---|---|
| `eslint . --max-warnings=0` | 9845ffa | clean |
| unit suite on Node | e85ea99 | 1697 pass, 2 skipped, 0 fail |
| unit suite on Electron's Node | e85ea99 | 1699 pass, 0 fail |
| `npm run test:e2e` | 05e8508 | 51 of 51 pass |
| `npm run test:e2e:packaged` | e85ea99 | 15 of 15 pass |

After e85ea99 the only changes outside the journey are one removed
heading and one comment.

Audit of the journeys in `dev-server.spec.js`, with `mail.spec.js` and
`logs.spec.js` run beside them, one break at a time, each restored
before the next. Every one goes red on the line that makes the claim:

| Broken | Goes red at |
|---|---|
| A second press is not stopped at the button | one server having been
asked for |
| The button does not say it is starting | the button's name, in both
journeys |
| The site is not opened in the browser | what was opened |
| The server having an address does not make it running | the button
offering to stop |
| Stop does not ask for the server to be stopped | the stop asked for |
| Stop leaves the address on screen | the address being gone |
| Stop stops the last script in the directory too | no script having
been stopped |
| A stop that was asked for is read as a crash | no crash being reported
|
| A crash is not said to be one | the crash being reported |
| A failed start gives no reason | the reason |
| A failed start leaves the button starting | the button offering to
start |
| The server starts with no build to serve | the build being asked for
first, in both |
| A failed build leaves the button starting | the button offering to
start |
| A failed build says nothing about the server | the sentence saying the
start was cancelled |
| The server does not wait for the watcher to be ready | no server
having been asked for yet |
| The watcher being ready does not start the server | the server being
asked for, in both |
| A build that is already there is rebuilt by the watcher first | the
sentence saying it was served as it is |
| Whether the site is built is not read afresh | the same sentence |
| The server starting does not bring the mail list to life | the mail
journey |
| The server starting does not start the debug.log tail | the logs
journey |
| What the server prints is not kept | the logs journey |

The first audit showed the "pressed again" check was too weak: with the
guard removed the journey went red, but four steps later and for another
reason. A second press asks the main process how the site is before it
asks for a server, and that question takes as long as reading the
checkout takes, so one quick round trip was not a long enough wait.
After the review the journey waits for that question itself to have been
answered, and with the guard removed it goes red on the count.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
zaerl added a commit that referenced this pull request Oct 1, 2026
## Why

`SiteRow` holds the state of eight separate things in one function, and
#554 splits it into hooks by domain so the redesign in #542 can rebuild
the screen around pieces it can hold. Five have left. Three remain, and
they are coming in two PRs: this one is the trunk update, and the ticket
and the apply chain, which call each other in both directions, follow
together.

## What changes

`src/renderer/hooks/use-trunk-update.jsx` is new. It holds the update's
chain: the fetch and reset, the install when the lockfile moved, the
rebuild, the hand-off to a resumed watch on projects whose watcher
rebuilds everything, the question the update asks first when the tree
has edits in it, and the retry after a run that was cut short.

- **It is a chain, not a process, so most of what it does comes in as
arguments**: the script runner, the terminal's lock and writer, the
build watch's pause and resume, and the functions that re-read the site
once the tree has changed. They are the calls the inline code made, in
the same order.
- **It returns what the screen shows**: whether an update is under way
and at which step, the summary of the last one, the dirty-tree
question's values, and the four handlers the buttons call.
- **The date of the site's trunk and the "update incomplete" marker stay
in `SiteRow`.** They are read from the site's status with everything
else the status says; the hook only asks for the status to be read
again.
- **The hook is called earlier than the code it replaces was written**,
straight after the helpers it needs, because the ticket card's gate
reads the update's stage a few lines further down.

**The journey owed from #582 is here.** Pausing the build watch and
bringing it back had no journey with a watch running. A new
`trunk-update-watch.spec.js` has two: the update with the watch running
on Core, and on a project where the resumed watch does the update's
build (#507). Both were written against the inline code first and pass
on both.

## How to test this

Platforms: any. Use the Buildkite build for the current head of this
branch.

The journeys carry this, from the repository root: `npx playwright test
--project=journeys tests/e2e/journeys/trunk-update.spec.js
tests/e2e/journeys/trunk-update-watch.spec.js`.

- `trunk-update.spec.js` is the update with no watch running: a clean
tree, a linked ticket, and the question about edits in the tree. It
gains one assertion: after saving edits and updating, the notice names
the file they went to.
- The first new journey, on Core with the watch running, asserts that
the update stops the watcher before it touches the tree (the file about
to be replaced still held the old trunk when the watcher was stopped);
that the tree is reset and the update runs the build itself; that the
watch is not brought back while the build runs; that a failed build
leaves the update incomplete and the watch is brought back all the same;
that a retry stops the watch again, installs and builds; that a good
build completes the update on the screen and in the store; and that a
watcher the update stopped, exiting late, is not taken for the one
running now.
- The second, on a built Gutenberg checkout, asserts that the update
runs no build of its own and brings the watch back at once; that it is
not complete while the watch is still rebuilding, with the watch's stop
enabled while the dev server's button is not; that the watch's ready
line is what completes it; and that a watch stopped before it is ready
leaves the update incomplete, on the screen and in the store, with the
retry offered.

In both new journeys the fetch and the reset are real, against an origin
on disk; the install and the scripts are answered by stubs.

What they cannot reach is a real install and a real build:

**Starting state:** a Core site that has finished setup, whose trunk is
behind `wordpress-develop`, with the build watch running and one file
edited.

1. Click "More", then "Update to latest trunk". A dialog asks what to do
with the edit. Choose to save it and pick a location.
2. The card "Updating to latest trunk" appears and steps through
fetching, installing or skipping the install, and building. The "Build
watcher" tab reads "Build watcher (paused)".
3. When it ends, a green notice says the site is up to date and names
the file your edit was saved to. The tab reads "Build watcher
(watching)" again.

**What must not have happened:**

- The edit is not lost: it is in the file you chose, and the notice
names that file.
- `node_modules` was not reinstalled if the lockfile did not change: the
card names the install step as skipped.

## Risks and limitations

- **The hook takes twenty arguments.** The update touches the runner,
the terminal, the watch and the site's status, and takes each as it was
used. Every function in it is a plain closure, as before, so none of
those arguments is a dependency of anything.
- **Not covered by a journey, as before this PR:** an update that finds
nothing to fetch, an install that fails, and a hand-off where the watch
was stopped during the reset.
- **Pausing the watch still has no journey for the other three things
that do it**: checking out or leaving a pull request, applying a patch,
and switching tickets. Those belong with the PR that moves the apply
chain and the ticket.
- **The manual steps above were not run.**
- **The new journeys cost two more app launches** on every pull request,
on both platforms: about four seconds locally on macOS.
- **Checked on macOS only.** Windows is covered by CI.
- Review, two passes: 5 findings to fix, all in the journeys or their
comments, all fixed here; 3 follow-ups, one of them applied.

## Related

Part of #554: the trunk update, after the dev server in #583. It carries
the pause-and-resume journey that #582 left for it. Part of #542.

---

<details>
<summary>Design decisions and alternatives considered</summary>

**The trunk's date and the incomplete marker are not the update's.**
`loadStatus` sets them with the rest of what the main process says about
the site, and the staleness notice and the sidebar read them whether or
not an update ever runs. Moving them would mean the status loader
writing into the update's hook.

**Plain functions, not `useCallback`.** `beginTrunkUpdate`,
`finishUpdate` and the rest were closures recreated on every render,
called from handlers and from each other. Memoising them here would add
twenty-item dependency lists and change when they see fresh state, which
is the kind of change a move should not make.

**The dirty-tree question's state moved with the update.** The dialog
that asks it is a component of its own (#570) and holds nothing; what it
shows was the update's all along, and the two answers it offers both end
by starting the update.

**A new journey file, not more tests in `trunk-update.spec.js`.** The
existing three run the real build script of the fixture. The new two
need the runner answered by a stub, so that a watch can be "running" and
a build can be made to fail or to wait.

</details>

<details>
<summary>Review outcome (required — see AGENTS.md)</summary>

**First pass: 5 [fix here] · 2 [follow-up].** It diffed the old blocks
against the hook with indentation removed and found one blank line of
difference.

- Tests, low, [fix here], fixed in 54620e5: `build-watch.spec.js` still
said no journey pauses the watch.
- Tests, low, [fix here], fixed in 54620e5: in the second journey, "the
incomplete marker is gone from the screen" could not fail where it
stood, since nothing had re-read the site by then. It is removed; the
store is the check there.
- Tests, low, [fix here], fixed in 54620e5: two comments claimed more
than their assertions. "Before it touches the tree" is now checked: the
stand-in keeps what the file about to be replaced held when the watcher
was stopped. "The one thing that can be pressed" says what is checked.
- Tests, low, [fix here], fixed in 54620e5: the watcher's first line was
said before the main process was known to have answered the start, so on
a slow runner it could be said to nobody. The journey waits for the run
to have been asked for.
- Tests, low, [fix here], fixed in 54620e5: the script the watcher is on
Gutenberg was marked an invariant. It is a characterisation.
- Tests, low, [follow-up]: paths with no journey, in Risks above. One of
them, the notice naming the saved patch, is one assertion in the
existing journey and was added in 54620e5, since the ref that carries it
moved here.
- Architecture, low, [follow-up]: which step runs the build, and the
"already up to date" branch, are decisions in a hook where no unit test
reaches them, as they were in `index.jsx`.

**Second pass, on 54620e5 alone: 0 [fix here] · 1 [follow-up]**, applied
in 07f5ddc: the header of `build-watch.spec.js` listed what pauses the
watch and left out checking out a pull request and leaving one.

Both passes confirmed that no moved name is read before the hook is
called, that each of the twenty arguments is the value the old closures
read, and that the callbacks which fire long after they were made (a
fetch ending, a build ending, the watch being ready) see the same
render's values as before.

- **Review:** completed — fresh agent context, local, before the PR
existed, read-only; head 309d129 / base
62ec328; committed work only, working
tree clean; then a second fresh context on
54620e5 alone; outcomes above.
CodeRabbit: not run yet.
- **Since review:** `54620e5 → 07f5ddc` checked. The difference is one
comment. It was not reviewed again by a fresh context.

Checks, macOS, local:

| Check | On | Result |
|---|---|---|
| `eslint . --max-warnings=0` | 07f5ddc | clean |
| unit suite on Node | 309d129 | 1697 pass, 2 skipped, 0 fail |
| unit suite on Electron's Node | 309d129 | 1699 pass, 0 fail |
| `npm run test:e2e` | 54620e5 | 53 of 53 pass |
| `npm run test:e2e:packaged` | 309d129 | 15 of 15 pass |

After 309d129 every change is in the journeys.

Audit of the journeys, on the commit each line was written for, one
break at a time, each restored before the next. Every one goes red on
the line that makes the claim:

| Broken | Goes red at |
|---|---|
| The update leaves the watch running | the stop asked for, in both new
journeys |
| The watch is not brought back when the update ends | the watcher being
asked for again |
| The site is not re-read when the update ends | the incomplete marker
being gone from the screen |
| A retry leaves the watch running | the second stop asked for |
| A retry does not install | the install asked for |
| The update ending does not give the terminal back | the retry, which
is then refused |
| A completed update is not recorded | the marker gone from the screen
and the store, and the old journey's summary |
| A completed update is not announced | the announcement, in three
journeys |
| The update builds even where the watch will | the scripts asked for |
| The update is complete as soon as the watch is brought back | the card
still showing |
| Nothing can be pressed while the update waits on the watch | the
watch's stop being enabled |
| The watch being ready does not complete the update | the announcement
|
| A watch stopped before it is ready leaves the update hanging | the
incomplete marker |
| Other buttons can be pressed during an update | the card showing |
| An update does not ask about edits in the tree | the question, in the
old journey |
| Saving the edits first saves nothing | the save dialog, in the old
journey |
| The question about edits cannot be closed | the question being gone,
in the old journey |
| The watcher is stopped only after the tree was reset | what the file
held when the watcher was stopped |
| The notice does not say where the edit was saved | the notice, in the
old journey |

One break was not caught, in a journey this PR does not change: with
"discard my edits, then update" made to discard nothing,
`trunk-update.spec.js` still passes, because the update's own reset
removes the edits a moment later. The screen and the tree end the same
either way.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@zaerl zaerl added this to the v2.0.0 milestone Oct 7, 2026
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.

1 participant