diff --git a/dist/functions/bundler/all-pages-esbuild-context-bundler.d.ts b/dist/functions/bundler/all-pages-esbuild-context-bundler.d.ts index ad5cc8f..dcb14e1 100644 --- a/dist/functions/bundler/all-pages-esbuild-context-bundler.d.ts +++ b/dist/functions/bundler/all-pages-esbuild-context-bundler.d.ts @@ -7,5 +7,9 @@ type Params = { build_only?: boolean; start?: boolean; }; +/** + * True when `local_path` is a hydration entry of the current esbuild context. + */ +export declare function isHydrationEntryPage(local_path: string): boolean; export default function allPagesESBuildContextBundler(params?: Params): Promise; export {}; diff --git a/dist/functions/bundler/all-pages-esbuild-context-bundler.js b/dist/functions/bundler/all-pages-esbuild-context-bundler.js index 4d2653e..31c8d2e 100644 --- a/dist/functions/bundler/all-pages-esbuild-context-bundler.js +++ b/dist/functions/bundler/all-pages-esbuild-context-bundler.js @@ -16,6 +16,16 @@ const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames(); // be recreated (entry points are fixed at context creation). let last_entry_keys = undefined; let last_root_file_path = undefined; +// Local paths of the pages that made it into the current entry set. Pages +// skipped at build time (e.g. created empty, no default export yet) are +// absent, so the watcher can treat their later edits as structural. +let last_entry_pages = new Set(); +/** + * True when `local_path` is a hydration entry of the current esbuild context. + */ +export function isHydrationEntryPage(local_path) { + return last_entry_pages.has(local_path); +} function arraysEqual(a, b) { if (a.length !== b.length) return false; @@ -97,6 +107,7 @@ export default async function allPagesESBuildContextBundler(params) { }); last_entry_keys = entry_keys; last_root_file_path = root_file_path; + last_entry_pages = new Set([...entryToPage.values()].map((p) => p.local_path)); } await global.BUNEXT_BUNDLER_CTX.rebuild(); } diff --git a/dist/functions/server/watcher-esbuild-ctx.d.ts b/dist/functions/server/watcher-esbuild-ctx.d.ts index c146518..75306d7 100644 --- a/dist/functions/server/watcher-esbuild-ctx.d.ts +++ b/dist/functions/server/watcher-esbuild-ctx.d.ts @@ -9,4 +9,6 @@ export declare function restartWatcher(): void; * watchdog should consider re-creating it again. */ export declare function watcherRestartAvailable(): boolean; -export default function watcherEsbuildCTX(): Promise; +export default function watcherEsbuildCTX(params?: { + pages_snapshot?: string; +}): Promise; diff --git a/dist/functions/server/watcher-esbuild-ctx.js b/dist/functions/server/watcher-esbuild-ctx.js index 1b349bd..f00c419 100644 --- a/dist/functions/server/watcher-esbuild-ctx.js +++ b/dist/functions/server/watcher-esbuild-ctx.js @@ -5,7 +5,8 @@ import grabDirNames from "../../utils/grab-dir-names"; import fullRebuild from "./full-rebuild"; import { AppData } from "../../data/app-data"; import checkExcludedPatterns from "../../utils/check-excluded-patterns"; -import allPagesESBuildContextBundler from "../bundler/all-pages-esbuild-context-bundler"; +import allPagesESBuildContextBundler, { isHydrationEntryPage, } from "../bundler/all-pages-esbuild-context-bundler"; +import grabAllPages from "../../utils/grab-all-pages"; import serverPostBuildFn from "./server-post-build-fn"; import { log } from "../../utils/log"; import { logBuildErrorsOnce } from "../../utils/build-error-log"; @@ -23,13 +24,28 @@ export function restartWatcher() { return; if (Date.now() - last_watcher_restart < 1000) return; + // Files created between close() and the new watcher's initial scan + // completing emit no `add` (ignoreInitial treats them as pre-existing). + // Snapshot the page set so the new watcher can detect them on `ready`. + const pages_snapshot = grabPagesSnapshot(); try { global.BUNEXT_PAGES_SRC_WATCHER.close(); } catch (error) { } global.BUNEXT_PAGES_SRC_WATCHER = undefined; last_watcher_restart = Date.now(); - watcherEsbuildCTX(); + watcherEsbuildCTX({ pages_snapshot }); +} +function grabPagesSnapshot() { + try { + return grabAllPages() + .map((p) => p.local_path) + .sort() + .join("\n"); + } + catch (error) { + return undefined; + } } /** * True when the watcher was last (re)created long enough ago that the @@ -47,12 +63,21 @@ const REBUILD_DEBOUNCE_MS = 200; let pending_structural = false; let pending_content_file = undefined; let debounce_timer = undefined; +// Guards against overlapping runners: BUNEXT_RECOMPILING alone is not enough +// since the artifact tracker clears it mid-run. +let runner_active = false; function scheduleRebuild() { if (debounce_timer) { clearTimeout(debounce_timer); } debounce_timer = setTimeout(async () => { debounce_timer = undefined; + // Another rebuild (fullRebuild, or a runner already looping) owns the + // bundler. Retry later rather than dropping the pending events. + if (runner_active || global.BUNEXT_RECOMPILING) { + scheduleRebuild(); + return; + } try { await runRebuilds(); } @@ -62,10 +87,6 @@ function scheduleRebuild() { logBuildErrorsOnce([{ text: `Watcher rebuild failed: ${msg}` }], (line) => log.error(line)); } } - finally { - global.BUNEXT_RECOMPILING = false; - global.BUNEXT_IS_SERVER_COMPONENT = false; - } }, REBUILD_DEBOUNCE_MS); } /** @@ -76,9 +97,7 @@ function scheduleRebuild() { * tracker then queues the single-flighted SSR rebuild and pushes HMR updates. */ async function runRebuilds() { - if (global.BUNEXT_RECOMPILING) { - return; - } + runner_active = true; global.BUNEXT_RECOMPILING = true; global.BUNEXT_IS_SERVER_COMPONENT = false; try { @@ -124,6 +143,7 @@ async function runRebuilds() { } } finally { + runner_active = false; global.BUNEXT_RECOMPILING = false; global.BUNEXT_IS_SERVER_COMPONENT = false; } @@ -174,13 +194,23 @@ function buildIgnoredPatterns() { } return ignored; } -export default async function watcherEsbuildCTX() { +export default async function watcherEsbuildCTX(params) { const pages_src_watcher = chokidar.watch(ROOT_DIR, { ignored: buildIgnoredPatterns(), persistent: true, ignoreInitial: true, }); watcherHandlers(pages_src_watcher, (event, filePath) => handleEvent(event, filePath)); + const pages_snapshot = params?.pages_snapshot; + if (pages_snapshot !== undefined) { + pages_src_watcher.on("ready", () => { + const current = grabPagesSnapshot(); + if (current !== undefined && current !== pages_snapshot) { + pending_structural = true; + scheduleRebuild(); + } + }); + } global.BUNEXT_PAGES_SRC_WATCHER = pages_src_watcher; } function handleEvent(event, filePath) { @@ -220,10 +250,22 @@ function handleEvent(event, filePath) { return; } if (event === "change") { - if (filename.match(TARGET_FILES_MATCH)) { - pending_content_file = full_file_path; - scheduleRebuild(); + if (!filename.match(TARGET_FILES_MATCH)) { + return; } + // A page that isn't a hydration entry yet (e.g. created empty, + // then given its default export) needs the entry set recomputed; + // an incremental rebuild of the existing context would miss it. + if (filename.match(/^src\/pages\//) && + !filename.match(/^src\/pages\/api\/|\.server\.tsx?$/) && + !checkExcludedPatterns({ path: filename }) && + !isHydrationEntryPage(full_file_path)) { + pending_structural = true; + } + else { + pending_content_file = full_file_path; + } + scheduleRebuild(); return; } // add / unlink / addDir / unlinkDir diff --git a/package.json b/package.json index e106459..420fb22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@moduletrace/bunext", - "version": "1.1.16", + "version": "1.1.17", "main": "dist/index.js", "module": "index.ts", "dependencies": { diff --git a/src/functions/bundler/all-pages-esbuild-context-bundler.ts b/src/functions/bundler/all-pages-esbuild-context-bundler.ts index 80a6aef..59620fe 100644 --- a/src/functions/bundler/all-pages-esbuild-context-bundler.ts +++ b/src/functions/bundler/all-pages-esbuild-context-bundler.ts @@ -32,6 +32,17 @@ type HydrationVirtualEntry = PageFiles & { tsx: string }; // be recreated (entry points are fixed at context creation). let last_entry_keys: string[] | undefined = undefined; let last_root_file_path: string | undefined = undefined; +// Local paths of the pages that made it into the current entry set. Pages +// skipped at build time (e.g. created empty, no default export yet) are +// absent, so the watcher can treat their later edits as structural. +let last_entry_pages = new Set(); + +/** + * True when `local_path` is a hydration entry of the current esbuild context. + */ +export function isHydrationEntryPage(local_path: string): boolean { + return last_entry_pages.has(local_path); +} function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; @@ -133,6 +144,9 @@ export default async function allPagesESBuildContextBundler(params?: Params) { last_entry_keys = entry_keys; last_root_file_path = root_file_path; + last_entry_pages = new Set( + [...entryToPage.values()].map((p) => p.local_path), + ); } await global.BUNEXT_BUNDLER_CTX!.rebuild(); diff --git a/src/functions/server/watcher-esbuild-ctx.ts b/src/functions/server/watcher-esbuild-ctx.ts index 81ad7fa..b924869 100644 --- a/src/functions/server/watcher-esbuild-ctx.ts +++ b/src/functions/server/watcher-esbuild-ctx.ts @@ -5,7 +5,10 @@ import grabDirNames from "../../utils/grab-dir-names"; import fullRebuild from "./full-rebuild"; import { AppData } from "../../data/app-data"; import checkExcludedPatterns from "../../utils/check-excluded-patterns"; -import allPagesESBuildContextBundler from "../bundler/all-pages-esbuild-context-bundler"; +import allPagesESBuildContextBundler, { + isHydrationEntryPage, +} from "../bundler/all-pages-esbuild-context-bundler"; +import grabAllPages from "../../utils/grab-all-pages"; import serverPostBuildFn from "./server-post-build-fn"; import { log } from "../../utils/log"; import { logBuildErrorsOnce } from "../../utils/build-error-log"; @@ -26,12 +29,28 @@ export function restartWatcher() { if (Date.now() - last_watcher_restart < 1000) return; + // Files created between close() and the new watcher's initial scan + // completing emit no `add` (ignoreInitial treats them as pre-existing). + // Snapshot the page set so the new watcher can detect them on `ready`. + const pages_snapshot = grabPagesSnapshot(); + try { global.BUNEXT_PAGES_SRC_WATCHER.close(); } catch (error) {} global.BUNEXT_PAGES_SRC_WATCHER = undefined; last_watcher_restart = Date.now(); - watcherEsbuildCTX(); + watcherEsbuildCTX({ pages_snapshot }); +} + +function grabPagesSnapshot(): string | undefined { + try { + return grabAllPages() + .map((p) => p.local_path) + .sort() + .join("\n"); + } catch (error) { + return undefined; + } } /** @@ -52,6 +71,9 @@ const REBUILD_DEBOUNCE_MS = 200; let pending_structural = false; let pending_content_file: string | undefined = undefined; let debounce_timer: ReturnType | undefined = undefined; +// Guards against overlapping runners: BUNEXT_RECOMPILING alone is not enough +// since the artifact tracker clears it mid-run. +let runner_active = false; function scheduleRebuild() { if (debounce_timer) { @@ -59,6 +81,14 @@ function scheduleRebuild() { } debounce_timer = setTimeout(async () => { debounce_timer = undefined; + + // Another rebuild (fullRebuild, or a runner already looping) owns the + // bundler. Retry later rather than dropping the pending events. + if (runner_active || global.BUNEXT_RECOMPILING) { + scheduleRebuild(); + return; + } + try { await runRebuilds(); } catch (error: any) { @@ -69,9 +99,6 @@ function scheduleRebuild() { (line) => log.error(line), ); } - } finally { - global.BUNEXT_RECOMPILING = false; - global.BUNEXT_IS_SERVER_COMPONENT = false; } }, REBUILD_DEBOUNCE_MS); } @@ -84,9 +111,7 @@ function scheduleRebuild() { * tracker then queues the single-flighted SSR rebuild and pushes HMR updates. */ async function runRebuilds() { - if (global.BUNEXT_RECOMPILING) { - return; - } + runner_active = true; global.BUNEXT_RECOMPILING = true; global.BUNEXT_IS_SERVER_COMPONENT = false; @@ -140,6 +165,7 @@ async function runRebuilds() { } } } finally { + runner_active = false; global.BUNEXT_RECOMPILING = false; global.BUNEXT_IS_SERVER_COMPONENT = false; } @@ -193,7 +219,9 @@ function buildIgnoredPatterns() { return ignored; } -export default async function watcherEsbuildCTX() { +export default async function watcherEsbuildCTX(params?: { + pages_snapshot?: string; +}) { const pages_src_watcher = chokidar.watch(ROOT_DIR, { ignored: buildIgnoredPatterns(), persistent: true, @@ -205,6 +233,17 @@ export default async function watcherEsbuildCTX() { (event, filePath) => handleEvent(event, filePath), ); + const pages_snapshot = params?.pages_snapshot; + if (pages_snapshot !== undefined) { + pages_src_watcher.on("ready", () => { + const current = grabPagesSnapshot(); + if (current !== undefined && current !== pages_snapshot) { + pending_structural = true; + scheduleRebuild(); + } + }); + } + global.BUNEXT_PAGES_SRC_WATCHER = pages_src_watcher as any; } @@ -252,10 +291,24 @@ function handleEvent( } if (event === "change") { - if (filename.match(TARGET_FILES_MATCH)) { - pending_content_file = full_file_path; - scheduleRebuild(); + if (!filename.match(TARGET_FILES_MATCH)) { + return; } + + // A page that isn't a hydration entry yet (e.g. created empty, + // then given its default export) needs the entry set recomputed; + // an incremental rebuild of the existing context would miss it. + if ( + filename.match(/^src\/pages\//) && + !filename.match(/^src\/pages\/api\/|\.server\.tsx?$/) && + !checkExcludedPatterns({ path: filename }) && + !isHydrationEntryPage(full_file_path) + ) { + pending_structural = true; + } else { + pending_content_file = full_file_path; + } + scheduleRebuild(); return; }