[GH-ISSUE #1013] adding any .ts/.tsx file inside app/ triggers a full page reload — watcher uses broad extension regex instead of route-file regex #227

Closed
opened 2026-05-06 12:38:21 +02:00 by BreizhHardware · 0 comments

Originally created by @eashish93 on GitHub (May 2, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/1013

Summary

The dev-server file watcher fires invalidateAppRouteCache() + hot.send({ type: "full-reload" }) for any .ts/.tsx/.jsx/.js file added (or removed) under app/, including co-located components, helpers, types, and other non-route files. Next.js explicitly supports co-locating non-route files inside app/ (docs) — only specific filenames (page, route, layout, default, template, loading, error, not-found) are routes.

Effect: every app/components/Button.tsx, app/_lib/helpers.ts, etc. that you create during development triggers a full page hard-refresh instead of HMR. This is especially painful for AI-driven scaffolding flows that create many co-located files in quick succession — the user sees the app constantly hard-reload mid-iteration.

Reproduce

Minimal vinext app with App Router. With dev server running:

touch app/components/Button.tsx
# Browser console: [vite] full reload
# Browser hard-refreshes

Compared to the normal HMR path:

echo "// edit" >> app/page.tsx
# Browser: hot update applied — no full reload

The route-relevant case works:

touch app/about/page.tsx
# Full reload — correct, this IS a new route

The bug is that any other file in app/ produces the same full reload as a real route addition.

Root cause

dist/index.js:911-921 (compiled from src/index.ts) uses the broad extension regex:

const pageExtensions = fileMatcher.extensionRegex;  // /\.(?:tsx|ts|jsx|js)$/
// ...
server.watcher.on("add", (filePath) => {
  if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) invalidateRouteCache(pagesDir);
  if (hasAppDir && filePath.startsWith(appDir) && pageExtensions.test(filePath)) {
    invalidateAppRouteCache();
    invalidateRscEntryModule();   // ← sends `{ type: "full-reload" }` to all clients
  }
});
server.watcher.on("unlink", (filePath) => {
  // same broad check — same problem on file deletion
});

pageExtensions.test(filePath) is true for any .ts/.tsx file, regardless of whether its basename matches a route filename. Adding app/components/Button.tsx invalidates the App Router route cache and forces a full reload.

The kicker: dist/routing/file-matcher.js already exports the correct regex right next to extensionRegex:

const appRouterPageRegex = createLeafPattern(["page", "route"]);
const appLayoutRegex     = createLeafPattern(["layout"]);
const appDefaultRegex    = createLeafPattern(["default"]);

These specifically match (^|/)page.tsx, (^|/)layout.tsx, etc. The watcher just isn't using them.

There's also a likely-similar issue at dist/index.js:877 in the hotUpdate handler for the pages router:

if (options.file.startsWith(pagesDir) && fileMatcher.extensionRegex.test(options.file)) {
  options.server.environments.client.hot.send({ type: "full-reload" });
  return [];
}

Same broad check, same shape — non-route files in pages/ (if anyone co-locates) would full-reload too.

Proposed fix

Use the already-exported route-file regex set, and only invalidate the route cache + full-reload when the filename actually corresponds to a routing file. App Router route files: page.*, route.*, layout.*, default.*, template.*, loading.*, error.*, not-found.*, forbidden.*, unauthorized.*, global-error.*, plus the middleware.* and instrumentation.* at the project root.

Suggested shape (sketch):

const appRouteFileRegex = new RegExp(
  `(^|[\\\\/])(page|route|layout|default|template|loading|error|not-found|forbidden|unauthorized|global-error)\\.(?:${exts})$`
);

server.watcher.on("add", (filePath) => {
  if (hasPagesDir && filePath.startsWith(pagesDir + path.sep) && pageExtensions.test(filePath)) {
    invalidateRouteCache(pagesDir);
  }
  if (hasAppDir && filePath.startsWith(appDir + path.sep) && appRouteFileRegex.test(filePath)) {
    invalidateAppRouteCache();
    invalidateRscEntryModule();
  }
});

Two micro-improvements bundled in:

  • Append path.sep to the prefix check (appDir + path.sep) so a sibling like app-utils/ doesn't accidentally match appDir = "/proj/app".
  • Use the route-file regex for app/; keep the broad pageExtensions for pages/ since the Pages Router treats every .tsx under pages/ as a route by design.

Environment

vinext: 0.0.45
node: 24
bun: 1.3.13
host: macOS arm64 (also reproduces on linux)
Originally created by @eashish93 on GitHub (May 2, 2026). Original GitHub issue: https://github.com/cloudflare/vinext/issues/1013 ## Summary The dev-server file watcher fires `invalidateAppRouteCache()` + `hot.send({ type: "full-reload" })` for **any** `.ts`/`.tsx`/`.jsx`/`.js` file added (or removed) under `app/`, including co-located components, helpers, types, and other non-route files. Next.js explicitly supports co-locating non-route files inside `app/` ([docs](https://nextjs.org/docs/app/getting-started/project-structure#colocation)) — only specific filenames (`page`, `route`, `layout`, `default`, `template`, `loading`, `error`, `not-found`) are routes. Effect: every `app/components/Button.tsx`, `app/_lib/helpers.ts`, etc. that you create during development triggers a full page hard-refresh instead of HMR. This is especially painful for AI-driven scaffolding flows that create many co-located files in quick succession — the user sees the app constantly hard-reload mid-iteration. ## Reproduce Minimal vinext app with App Router. With dev server running: ```bash touch app/components/Button.tsx # Browser console: [vite] full reload # Browser hard-refreshes ``` Compared to the normal HMR path: ```bash echo "// edit" >> app/page.tsx # Browser: hot update applied — no full reload ``` The route-relevant case works: ```bash touch app/about/page.tsx # Full reload — correct, this IS a new route ``` The bug is that **any other file** in `app/` produces the same full reload as a real route addition. ## Root cause `dist/index.js:911-921` (compiled from `src/index.ts`) uses the broad extension regex: ```js const pageExtensions = fileMatcher.extensionRegex; // /\.(?:tsx|ts|jsx|js)$/ // ... server.watcher.on("add", (filePath) => { if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) invalidateRouteCache(pagesDir); if (hasAppDir && filePath.startsWith(appDir) && pageExtensions.test(filePath)) { invalidateAppRouteCache(); invalidateRscEntryModule(); // ← sends `{ type: "full-reload" }` to all clients } }); server.watcher.on("unlink", (filePath) => { // same broad check — same problem on file deletion }); ``` `pageExtensions.test(filePath)` is true for *any* `.ts`/`.tsx` file, regardless of whether its basename matches a route filename. Adding `app/components/Button.tsx` invalidates the App Router route cache and forces a full reload. The kicker: `dist/routing/file-matcher.js` already exports the **correct** regex right next to `extensionRegex`: ```js const appRouterPageRegex = createLeafPattern(["page", "route"]); const appLayoutRegex = createLeafPattern(["layout"]); const appDefaultRegex = createLeafPattern(["default"]); ``` These specifically match `(^|/)page.tsx`, `(^|/)layout.tsx`, etc. The watcher just isn't using them. There's also a likely-similar issue at `dist/index.js:877` in the `hotUpdate` handler for the pages router: ```js if (options.file.startsWith(pagesDir) && fileMatcher.extensionRegex.test(options.file)) { options.server.environments.client.hot.send({ type: "full-reload" }); return []; } ``` Same broad check, same shape — non-route files in `pages/` (if anyone co-locates) would full-reload too. ## Proposed fix Use the already-exported route-file regex set, and only invalidate the route cache + full-reload when the filename actually corresponds to a routing file. App Router route files: `page.*`, `route.*`, `layout.*`, `default.*`, `template.*`, `loading.*`, `error.*`, `not-found.*`, `forbidden.*`, `unauthorized.*`, `global-error.*`, plus the `middleware.*` and `instrumentation.*` at the project root. Suggested shape (sketch): ```js const appRouteFileRegex = new RegExp( `(^|[\\\\/])(page|route|layout|default|template|loading|error|not-found|forbidden|unauthorized|global-error)\\.(?:${exts})$` ); server.watcher.on("add", (filePath) => { if (hasPagesDir && filePath.startsWith(pagesDir + path.sep) && pageExtensions.test(filePath)) { invalidateRouteCache(pagesDir); } if (hasAppDir && filePath.startsWith(appDir + path.sep) && appRouteFileRegex.test(filePath)) { invalidateAppRouteCache(); invalidateRscEntryModule(); } }); ``` Two micro-improvements bundled in: - Append `path.sep` to the prefix check (`appDir + path.sep`) so a sibling like `app-utils/` doesn't accidentally match `appDir = "/proj/app"`. - Use the route-file regex for `app/`; keep the broad `pageExtensions` for `pages/` since the Pages Router treats every `.tsx` under `pages/` as a route by design. ## Environment ``` vinext: 0.0.45 node: 24 bun: 1.3.13 host: macOS arm64 (also reproduces on linux) ```
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#227
No description provided.