Skip to content

start dev: invalidate the worker for edits outside src/ - #345

Merged
raphamorim merged 3 commits into
mainfrom
oscar/start-watch-app-root
Oct 10, 2026
Merged

raphamorim merged 3 commits into
mainfrom
oscar/start-watch-app-root

Conversation

@odlozano

@odlozano odlozano commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

A TanStack Start app on @cloudflare/vite-plugin kept rendering the old version of any module outside src/ until the dev server restarted. The browser got the HMR update, but every fresh load (reload, curl, Playwright) came from the worker with the old module, and the client then hydrated over the stale HTML. The Lovable monorepo hits this all day: its web app keeps most code in shared/ and modules/ beside src/, and developers kill the lovable-web process several times per session to get a fresh render.

Why: Start mode ran two watchers. oj_server's dev watcher covers the app root (minus node_modules, .oj-cache, dist, .git and server.watch.ignored) plus the outside-root directories of served files, as Vite does. The worker invalidation (/__oj_invalidate), the lazy runner's dirty flag and the client rebundle hung off a second watcher that start_dev opened on <root>/src only, so nothing outside src/ ever reached the worker. The src/ scope came with the first Start reload commit (34888a0), and the worker invalidation was later attached to it (a9ce550).

The fix drops the second watcher. oj_server exposes its watcher as a WatchFeed, and the Start watcher thread subscribes to it right after the dev server is built, so it covers exactly what client HMR covers. Events are published from the notify callback, before the debounce and before the watcher thread's plugin-hook RPCs: publishing settled batches from the watcher thread put a 10 ms debounce in front of the early invalidate, and it could have held worker invalidation behind a watchChange RPC that waits on a still-initializing plugin host (up to OJ_PLUGIN_INIT_TIMEOUT). The Start loop itself is unchanged: same change filter, settle window, early and settled invalidates, gate hold and coalescing rebundle.

A root-wide watch also sees two things src/ never did, so watch_relevant now skips them:

  • the route generator's temp files under .tanstack/tmp/ (renamed into the route tree; each regen would otherwise trigger one extra rebundle)
  • declaration files (.d.ts, .d.mts, .d.cts): never runtime modules, and generated message catalogs write them by the thousand (Paraglide writes one per message)

Test

e2e/start-cloudflare-dev.mjs gains a module in shared/ imported by /about, edits it, and requires the next document to be fresh. Red-proven on unpatched main: every existing step passes and the new one fails with /about still stale 20s after an edit outside src/. With the fix it is fresh in about 140 ms. A unit test pins the feed contract (ignored paths filtered, dropped subscribers pruned).

Edit-to-fresh-document latency, five interleaved runs each on the same machine (median, range):

first edit rapid second edit edit outside src/
main 146 ms (129–159) 57 ms (38–129) stale
this PR 134 ms (132–147) 43 ms (28–147) 143 ms (141–193)

The other Start scripts that drive edits or restarts pass locally: start, start-hmr-gate, start-narration, start-memory, start-client-transfer, start-ssr-worker-url, runner-env-prewarm, config-restart.

Open points

  • Plain oj dev (no Start) with runner-backed environments never posts /__oj_invalidate; only start_dev does. If a non-Start app can render through a worker environment, its worker would be stale for every edit. Not covered by any test; worth a look.
  • A module only the SSR runner imports, outside the app root, is still unwatched: ensure_watched_file only adds directories of files served to the browser. Vite also calls ensureWatchedFile from the SSR transform. Same gap as client HMR today; not in scope here.

Start mode ran a second file watcher on <root>/src only, and the worker
invalidation, the lazy runner's dirty flag and the client rebundle hung off
it. An edit outside src/ reached the browser through oj_server's watcher but
never the worker, which kept rendering the old module until a restart.

The Start watcher thread now subscribes to oj_server's watcher (WatchFeed),
published from the notify callback so it adds no debounce and never waits on
the watcher thread's plugin-hook RPCs. watch_relevant also skips the route
generator's .tanstack/ temp files and declaration files, which a root-wide
watch now sees.
* adopt resolved server.watch.ignored from vite config

* dump server log and page loads on playground failure

* log which paths trigger a start rebuild
* watch new top-level entries, skip relocated cache dir

* exclude Vite config-loader temp files from watching

* adopt dirs renamed into the root, not just created

* gate Start rebundle and reload on served module graphs (#349)
@raphamorim
raphamorim merged commit 66bb602 into main Oct 10, 2026
2 of 4 checks passed
@raphamorim
raphamorim deleted the oscar/start-watch-app-root branch October 10, 2026 09:16
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.

2 participants