From acf13d00db7b78d96f1cd22763d28fdadbbf3dce Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Tue, 26 May 2026 13:21:27 +0200 Subject: [PATCH 1/4] fix(preview): invalidate fileInformationCache on watcher events In project preview mode, the persistent ProjectContext returned by watcher.project() owns a long-lived fileInformationCache populated at preview startup. When the watcher fires on a source edit, two render paths follow: the watcher itself dispatches to a render call that builds an ephemeral context (no stale cache), but the subsequent HTTP-handler render in serve.ts reuses the persistent context and reads the pre-edit expanded markdown back out of cache.fullMarkdown. The regenerated HTML mtime advances while the body content stays at the pre-edit revision (#10392). Invalidate cache entries for each changed input before the watcher's render dispatch, mirroring the renderForPreview pattern in src/command/preview/preview.ts. A companion commit adds a source-mtime and size fingerprint inside projectResolveFullMarkdownForFile so the cache contract self-validates even if a future caller forgets to invalidate; this surgical fix is the single-commit cherry-pick target for the v1.9 backport. Closes #10392. --- src/project/serve/watch.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/project/serve/watch.ts b/src/project/serve/watch.ts index 69ffe6f5459..f555f7f477e 100644 --- a/src/project/serve/watch.ts +++ b/src/project/serve/watch.ts @@ -143,6 +143,21 @@ export function watchProject( const services = renderServices(nbContext); try { const result = await renderManager.submitRender(() => { + // Invalidate the persistent project context's cache for + // each changed input. The HTTP-handler render in + // serve.ts reuses watcher.project() with its long-lived + // fileInformationCache; without this invalidation, + // projectResolveFullMarkdownForFile returns the pre-edit + // expanded markdown and the regenerated HTML keeps the + // stale body (#10392). The invalidation runs inside the + // render queue so it is serialized with any in-flight + // render — invalidateForFile may delete a transient + // .quarto_ipynb, and running it outside the queue could + // race with a concurrent HTTP-handler render that is + // still reading that notebook. + for (const input of inputs) { + project.fileInformationCache?.invalidateForFile(input); + } if (inputs.length > 1) { return renderProject( project!, From 0063bef7776e01af914e3654c491fee80911cdcd Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Tue, 26 May 2026 12:37:24 +0200 Subject: [PATCH 2/4] fix(preview): guard fullMarkdown cache by source file mtime + size The persistent ProjectContext used by website/book preview kept a fileInformationCache.fullMarkdown entry populated at startup with no freshness fingerprint, so subsequent renders fed Pandoc the pre-edit expanded markdown for the HTTP-handler renderProject() call site. Adds sourceMtime + sourceSize fields on FileInformation and re-reads when either differs from the cached value. Pairs with the watcher-side invalidation in the preceding commit; this layer closes the contract gap for any future caller that forgets to invalidate. Size is included alongside mtime to catch the edge case where an edit lands within a single mtime tick on a coarse-resolution filesystem but changes the byte count. Relates to #10392. --- src/project/project-shared.ts | 32 ++++++- src/project/types.ts | 2 + .../project/file-information-cache.test.ts | 87 +++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index 7f8d2d53015..e17b4556ea4 100644 --- a/src/project/project-shared.ts +++ b/src/project/project-shared.ts @@ -468,7 +468,35 @@ export async function projectResolveFullMarkdownForFile( force?: boolean, ): Promise { const cache = ensureFileInformationCache(project, file); - if (!force && cache.fullMarkdown) { + + // Source-mtime + size guard: in preview mode the persistent project + // context (and its fileInformationCache) is reused across renders. If + // the source file was edited since the cache entry was populated, the + // cached expanded markdown is stale (#10392). Re-read in that case. + // Size is checked alongside mtime to catch the edge case where an + // edit lands within a single mtime tick on a coarse-resolution + // filesystem but changes the byte count. + let currentMtime: number | undefined; + let currentSize: number | undefined; + try { + const stat = Deno.statSync(file); + currentMtime = stat.mtime?.getTime(); + currentSize = stat.size; + } catch { + currentMtime = undefined; + currentSize = undefined; + } + + if ( + !force && + cache.fullMarkdown && + cache.sourceMtime !== undefined && + cache.sourceSize !== undefined && + currentMtime !== undefined && + currentSize !== undefined && + cache.sourceMtime === currentMtime && + cache.sourceSize === currentSize + ) { return cache.fullMarkdown; } @@ -495,6 +523,8 @@ export async function projectResolveFullMarkdownForFile( try { const result = await expandIncludes(markdown, options, file); cache.fullMarkdown = result; + cache.sourceMtime = currentMtime; + cache.sourceSize = currentSize; cache.includeMap = options.state?.include.includes as FileInclusion[]; return result; } finally { diff --git a/src/project/types.ts b/src/project/types.ts index d36f04f2282..8014c0d067c 100644 --- a/src/project/types.ts +++ b/src/project/types.ts @@ -60,6 +60,8 @@ export type FileInclusion = { export type FileInformation = { fullMarkdown?: MappedString; + sourceMtime?: number; + sourceSize?: number; includeMap?: FileInclusion[]; codeCells?: InspectedMdCell[]; engine?: ExecutionEngineInstance; diff --git a/tests/unit/project/file-information-cache.test.ts b/tests/unit/project/file-information-cache.test.ts index d883f021d2a..f851495fcca 100644 --- a/tests/unit/project/file-information-cache.test.ts +++ b/tests/unit/project/file-information-cache.test.ts @@ -15,6 +15,7 @@ import { join, relative } from "../../../src/deno_ral/path.ts"; import { ensureFileInformationCache, FileInformationCacheMap, + projectResolveFullMarkdownForFile, } from "../../../src/project/project-shared.ts"; import { createMockProjectContext } from "./utils.ts"; @@ -246,3 +247,89 @@ unitTest( ); }, ); + +unitTest( + "projectResolveFullMarkdownForFile - re-reads when source file mtime changes", + async () => { + const project = createMockProjectContext(); + const file = join(project.dir, "doc.qmd"); + + // First read populates the cache. + Deno.writeTextFileSync(file, "# v1\n"); + const result1 = await projectResolveFullMarkdownForFile( + project, + undefined, + file, + ); + assert( + result1.value.includes("v1"), + `Expected v1 in first read, got: ${result1.value}`, + ); + + // Modify content and force mtime strictly forward via utimeSync. + // writeTextFileSync alone may collide with the prior write's mtime + // on coarse-resolution filesystems (FAT32 ~2 s, some network + // mounts), so utimeSync is mandatory here — removing it would let + // this test pass vacuously on a fast filesystem and silently + // regress the guard. + Deno.writeTextFileSync(file, "# v2\n"); + const future = new Date(Date.now() + 2000); + Deno.utimeSync(file, future, future); + + // Second read must re-fetch from disk via the mtime guard, otherwise + // the project preview path serves stale rendered output (#10392). + const result2 = await projectResolveFullMarkdownForFile( + project, + undefined, + file, + ); + assert( + result2.value.includes("v2"), + `Expected v2 after mtime change, got: ${result2.value}`, + ); + + project.cleanup(); + }, +); + +unitTest( + "projectResolveFullMarkdownForFile - re-reads when size changes but mtime is preserved", + async () => { + const project = createMockProjectContext(); + const file = join(project.dir, "doc.qmd"); + + // First read populates the cache with mtime + size of v1. + Deno.writeTextFileSync(file, "# v1\n"); + const mtimeV1 = Deno.statSync(file).mtime!; + const result1 = await projectResolveFullMarkdownForFile( + project, + undefined, + file, + ); + assert( + result1.value.includes("v1"), + `Expected v1 in first read, got: ${result1.value}`, + ); + + // Overwrite content with a different size, then restore the original + // mtime. This simulates the edge case where an edit lands within a + // single mtime tick on a coarse-resolution filesystem: mtime is + // unchanged but content (and therefore size) differs. + Deno.writeTextFileSync(file, "# v2 with extra bytes to change size\n"); + Deno.utimeSync(file, mtimeV1, mtimeV1); + + // Second read must re-fetch via the size guard, since the mtime + // alone would not detect the change. + const result2 = await projectResolveFullMarkdownForFile( + project, + undefined, + file, + ); + assert( + result2.value.includes("v2"), + `Expected v2 after size change, got: ${result2.value}`, + ); + + project.cleanup(); + }, +); From 26a0fa55de90f9bac612238b84bf68cc059be4ee Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Tue, 26 May 2026 16:19:50 +0200 Subject: [PATCH 3/4] docs(tests): add manual preview test spec for project cache staleness (#10392) Manual T1/T2/T3 (P1) plus T4-T6 (P2/P3) covering the project preview stale-render reproduction. Reproduces deterministically against the existing website fixture at tests/docs/manual/preview/project-preview/. Originally drafted on the debug/preview-cache-logging investigation branch; landing here so the spec accompanies the fix. Relates to #10392. --- ...-project-preview-stale-cache-after-edit.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/docs/manual/preview/10392-project-preview-stale-cache-after-edit.md diff --git a/tests/docs/manual/preview/10392-project-preview-stale-cache-after-edit.md b/tests/docs/manual/preview/10392-project-preview-stale-cache-after-edit.md new file mode 100644 index 00000000000..e5ae590ee8f --- /dev/null +++ b/tests/docs/manual/preview/10392-project-preview-stale-cache-after-edit.md @@ -0,0 +1,112 @@ +# Project preview serves stale rendered output after source edit (#10392) + +Manual preview test for the `fileInformationCache.fullMarkdown` staleness bug on the project preview path (website / book). Reproduces deterministically. + +## What this catches + +In a project preview, after editing a non-index `.qmd`: + +- The HTML file in `_site/` IS regenerated (mtime advances). +- The HTML body content does NOT contain the edit. +- The browser reload signal fires but the served HTML is stale. +- Stopping and restarting `quarto preview` clears the bug for the first edit; the second edit reproduces it. + +**Root cause:** the persistent `ProjectContext` returned by `watcher.project()` owns a long-lived `fileInformationCache`. The HTTP-handler render in `src/project/serve/serve.ts` reuses that context. Without invalidation on watcher events, `projectResolveFullMarkdownForFile` (`src/project/project-shared.ts`) returned the pre-edit expanded markdown for the post-watcher HTTP-handler render call. The fix invalidates on watcher events (surgical) and adds an `mtime + size` freshness check to the cache (defense-in-depth). + +## Setup + +Use the existing website fixture at `tests/docs/manual/preview/project-preview/`. It contains: + +``` +_quarto.yml # type: website +index.qmd # home page +about.qmd # non-index page used by T1, T2, T6, T7 +styles.css +``` + +The bug does not require code execution; pure prose edits to `about.qmd` trigger it. T7 needs a Jupyter-flavored input — use `tests/docs/manual/preview/keep-ipynb.qmd` or any `.qmd` with a Python code cell. + +## P1: Critical + +### T1: Edit a non-index `.qmd`, observe stale HTML body + +- **Setup:** Use the fixture `tests/docs/manual/preview/project-preview/`. The default body of `about.qmd` is `About this site`. +- **Steps:** + 1. `cd tests/docs/manual/preview/project-preview && quarto preview`. + 2. Wait for preview to load and the default browser tab to open. + 3. Navigate the browser to `http://localhost:/about.html`. + 4. In `about.qmd`, replace `About this site` with `MARKER-UNIQUE-STRING`. Save. + 5. Wait 10 seconds (let watcher + re-render settle). + 6. `Select-String -Path '_site/about.html' -Pattern MARKER-UNIQUE-STRING` + 7. `curl http://127.0.0.1:/about.html | Select-String MARKER-UNIQUE-STRING` + 8. Reload the browser tab. +- **Expected (after fix):** Step 6 and step 7 both return a match. Step 8 shows the new paragraph. +- **Catches:** Persistent `watcher.project().fileInformationCache.fullMarkdown` returning stale expanded markdown to the HTTP-handler `renderProject` call site in `src/project/serve/serve.ts`. + +### T2: Repeat the edit a second time + +- **Setup:** Same as T1. Preview already running. +- **Steps:** + 1. After T1 (do not restart preview). + 2. Edit `about.qmd` again, change `MARKER-UNIQUE-STRING` to `MARKER-SECOND-STRING`. Save. + 3. Wait 10 seconds. + 4. Repeat steps 6-8 of T1 with the new string. +- **Expected (after fix):** Both checks find the new string. +- **Catches:** Second-edit regression — verifies the fix invalidates the cache on every edit, not only on the first. + +### T3: Edit `index.qmd` + +- **Setup:** Same project. +- **Steps:** + 1. Edit `index.qmd`, add `INDEX-MARKER.` paragraph. Save. + 2. Wait 10 seconds. + 3. Check `_site/index.html` and the served `/index.html` for `INDEX-MARKER`. +- **Expected:** Both find the marker. This case worked before the fix too — keep it in the suite to confirm no regression on the index path. + +## P2: Important + +### T4: Edit affects sibling pages via cross-reference + +- **Setup:** Extend the fixture: add a `secondary.qmd` next to `about.qmd` containing a figure with `#fig-x`, then in `about.qmd` reference it via `@fig-x`. +- **Steps:** + 1. Preview running. + 2. Edit the `#fig-x` label in `secondary.qmd`. + 3. Verify `_site/about.html` cross-reference text updates. +- **Catches:** Cache invalidation does not propagate to other files in the project that reference the changed file. Lower priority because the immediate symptom (stale body on the directly-edited file) is the main bug. + +### T7: Concurrent saves during in-flight HTTP-handler render (Jupyter input) + +This case proves the surgical-fix invalidation is correctly serialized with the render queue. Before the fix at the queue level, `invalidateForFile` ran **before** `submitRender`, so a concurrent in-flight render (typically the HTTP-handler's `renderProject` call) could lose its transient `.quarto_ipynb` mid-read and fail intermittently. After the fix, invalidation runs inside the `submitRender` callback so the queue serializes it with prior renders. + +- **Setup:** Use a Jupyter `.qmd` (e.g., `tests/docs/manual/preview/keep-ipynb.qmd` rendered into a website project, or copy a Python-cell `.qmd` into `project-preview/`). The input must produce a transient `.quarto_ipynb` for the race to be reachable. +- **Steps:** + 1. `quarto preview` against the project containing the Jupyter `.qmd`. + 2. Open the page in the browser to trigger an HTTP-handler render that reads the transient `.quarto_ipynb`. + 3. While the HTTP-handler render is still in flight (best done by editing during a long-running Python cell), save the `.qmd` 5 times in quick succession (≤ 1s between saves). Aim to fire the watcher while step 2's render is still executing. + 4. Watch the `quarto preview` console for errors of the form "file not found" / "cannot read `.quarto_ipynb`" / `ENOENT`. +- **Expected (after fix):** No file-not-found errors in the console. All renders complete cleanly. Final HTML reflects the latest edit. +- **Catches:** Race between watcher-triggered `invalidateForFile` and an in-flight render holding the transient notebook open. Window is narrow but reproducible on slow Python cells (use a `time.sleep(5)` cell to widen it). + +## P3: Polish + +### T5: `freeze: auto` + pure markdown edit + +- **Setup:** Add `execute: freeze: auto` to `_quarto.yml`. +- **Steps:** + 1. T1 with `freeze: auto`. +- **Expected:** Same outcome as T1 (freeze should not affect plain markdown). + +### T6: WebSocket reload target points at the right page + +- **Setup:** Browser open on `/about.html`. +- **Steps:** T1, watch the browser console. +- **Expected:** WebSocket reload signal fires; the page reloads with the new content. + +## Cleanup + +`quarto preview` exit (Ctrl+C). No persistent state to clean up beyond `_site/` which the next preview run rebuilds. + +## Related + +- `tests/docs/manual/preview/README.md` — manual preview test matrix +- `tests/docs/manual/preview/14281-quarto-ipynb-accumulation.qmd` — neighboring manual test (different bug, similar structure) From 33a16ba5987f9b5918409167fe9b12a273587d16 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Tue, 26 May 2026 17:22:09 +0200 Subject: [PATCH 4/4] docs(changelog): add entry for #10392 preview cache fix --- news/changelog-1.10.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index b24b9f8d288..91d7ba93799 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -47,6 +47,7 @@ All changes included in 1.10: ### `quarto preview` +- ([#10392](https://github.com/quarto-dev/quarto-cli/issues/10392)): Fix `quarto preview` of a website or book project showing stale HTML for non-index pages after editing the source `.qmd`. - ([#14281](https://github.com/quarto-dev/quarto-cli/issues/14281)): Avoid creating a duplicate `.quarto_ipynb` file on preview startup for single-file Jupyter documents. ### `install`