diff --git a/llm-docs/preview-architecture.md b/llm-docs/preview-architecture.md index 358d3a26cce..7dd5128268f 100644 --- a/llm-docs/preview-architecture.md +++ b/llm-docs/preview-architecture.md @@ -1,6 +1,6 @@ --- -main_commit: eca40cdab -analyzed_date: 2026-05-22 +main_commit: fc0cf88dc +analyzed_date: 2026-05-29 key_files: - src/command/preview/cmd.ts - src/command/preview/preview.ts @@ -124,6 +124,71 @@ The in-flight gate avoids a race: `invalidateForFile` calls `safeRemoveSync` on For unchanged frontmatter, `previewFormat` repopulates the cache with the same value and the compatibility verdict is identical — only the cache lookup runs again. Cost: one cache re-read per IDE-driven render request, no functional change. +## Project Preview Re-render Path (Paths B/D) + +Project preview (website/book) does NOT go through `renderForPreview()`. A single +source edit produces **two** Pandoc invocations from two different call sites, and +they share state in a way that caused #10392. + +1. **Watcher invocation** (`watch.ts`, `watchProject()`). The file watcher fires on + the edit. The single-input branch calls `render(inputs[0], …)` **without** + passing `pContext`, so a fresh `ProjectContext` (empty `fileInformationCache`) is + built inside `render()`. `projectResolveFullMarkdownForFile` reads source from + disk — this invocation sees the edit. It writes the correct HTML. + +2. **HTTP-handler invocation** (`serve.ts`). The watcher signals reload, the browser + refetches, and the serve handler calls `renderProject(watcher.project(), …, + [inputFile])`. `watcher.project()` is the **persistent** context whose + `fileInformationCache.fullMarkdown` was populated at preview startup. Before the + #10392 fix this invocation read pre-edit expanded markdown from that cache and + overwrote invocation #1's fresh HTML with stale content. + +``` +edit saved + │ + ▼ +watch.ts ── render(input) ── fresh ctx, reads disk ──► correct HTML (#1) + │ + ▼ (reload signal → browser refetch) +serve.ts ── renderProject(watcher.project(), [input]) + │ + └─ persistent ctx, cached fullMarkdown ──► STALE HTML overwrites (#2) +``` + +### The #10392 fix (two layers) + +1. **Surgical** — `watch.ts` invalidates the persistent context's cache for each + changed input before rendering: + + ```typescript + for (const input of inputs) { + project.fileInformationCache?.invalidateForFile(input); + } + ``` + + This runs **inside** the `submitRender()` callback so it is serialized on the + render queue. `invalidateForFile` may delete a transient `.quarto_ipynb`; running + it outside the queue could race a concurrent in-flight render still reading that + notebook. Invalidation drops the WHOLE entry (`metadata`, `codeCells`, `engine`, + `target`, `fullMarkdown`), so it covers more than the freshness guard alone. + +2. **Defense-in-depth** — the `fullMarkdown` mtime+size guard (above). Even if a + future caller forgets to invalidate, invocation #2 re-reads on the mtime/size + mismatch. The two layers are complementary, not redundant: the guard refreshes + only `fullMarkdown`; `invalidateForFile` refreshes all fields. + +### Out of scope for #10392 + +The freshness guard fingerprints the edited input file itself. It does NOT detect +edits to a file's non-input dependencies, because the input's own mtime/size is +unchanged: + +- `{{< include >}}`d files — the includer's cache is not invalidated when the + includee changes (tracked in #2795). +- `template-partials:` files (tracked in #14561). + +These need a dependency→consumer map in the watch list, not a per-file stat. + ## FileInformationCache and invalidateForFile `FileInformationCacheMap` stores per-file cached data: @@ -131,6 +196,8 @@ For unchanged frontmatter, `previewFormat` repopulates the cache with the same v | Field | Content | Cost of re-computation | |-------|---------|----------------------| | `fullMarkdown` | Expanded markdown with includes | Re-reads file, re-expands includes | +| `sourceMtime` | Source file mtime (ms) when `fullMarkdown` was cached | Stat of source file | +| `sourceSize` | Source file byte size when `fullMarkdown` was cached | Stat of source file | | `includeMap` | Include source→target mappings | Recomputed with markdown | | `codeCells` | Parsed code cells | Recomputed from markdown | | `engine` | Execution engine instance | Re-determined | @@ -138,6 +205,23 @@ For unchanged frontmatter, `previewFormat` repopulates the cache with the same v | `metadata` | YAML front matter | Recomputed from markdown | | `brand` | Resolved `_brand.yml` data | Re-loaded from disk | +### fullMarkdown freshness guard (added for #10392) + +`projectResolveFullMarkdownForFile()` (`project-shared.ts`) no longer trusts a +populated `fullMarkdown` unconditionally. It stats the source file on every call +and returns the cached value only when both `sourceMtime` and `sourceSize` match +the current file. On any mismatch (or if the stat fails) it re-reads and +re-expands, then stores the new mtime+size alongside the result. + +Size is checked alongside mtime to catch an edit that lands within a single +mtime tick on a coarse-resolution filesystem (FAT32 ~2 s, some network mounts) +while still changing the byte count. + +This is defense-in-depth for the project-preview path (below), where a +persistent context's cache is reused across renders and a caller may forget to +invalidate. The guard refreshes only `fullMarkdown`; stale `metadata`, +`codeCells`, `engine`, or `target` still require an explicit `invalidateForFile()`. + ### invalidateForFile() (added for #14281) Before each preview re-render, the cache entry for the changed file must be invalidated so fresh content is picked up. `invalidateForFile()` does two things: @@ -169,10 +253,16 @@ When rendering a `.qmd` with a Jupyter kernel, the engine creates a transient `. | Single file, no project | 1 (cmd.ts, passed to preview) | 0 (cached project reused) | | Single file in serveable project | 1 (cmd.ts, passed to serveProject) | See project rows | | Project directory | 1 (serve.ts) | See project rows | -| Project: single input changed | — | 1 (render() without pContext) | -| Project: multiple inputs changed | — | 0 (renderProject reuses cached) | +| Project: single input changed | — | 1 (watcher render() without pContext) + 0 (serve.ts HTTP-handler renderProject reuses persistent ctx) | +| Project: multiple inputs changed | — | 0 new (watcher renderProject reuses cached) | | Project: config file changed (HTML) | — | 1 (refreshProjectConfig) | +The single-input row's `+ 0` reflects two render invocations but only one new +context computation: the watcher render builds a fresh context, while the +HTTP-handler render reuses the persistent one (0 new computations). Both +invocations must still produce fresh output — see "Project Preview Re-render +Path" for the #10392 stale-cache interaction between them. + ## Key Files | File | Purpose | diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 034e6f56493..bae8e84d513 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -48,6 +48,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. - ([#14533](https://github.com/quarto-dev/quarto-cli/issues/14533)): Fix `quarto preview` not detecting a frontmatter `format:` change until the second render request. The first request after the edit now correctly restarts the preview process with the new format. 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/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!, 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/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..b6b3da4aa09 --- /dev/null +++ b/tests/docs/manual/preview/10392-project-preview-stale-cache-after-edit.md @@ -0,0 +1,190 @@ +# 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 (`ENOENT` / `BadResource` / "cannot read `.quarto_ipynb`"). All renders complete cleanly. The HTML reflects the last *coalesced* render — under a sub-second burst the watcher collapses the saves into a single execution, so the final body may correspond to the first read of the burst rather than the very last keystroke. That coalescing is render-scheduling behavior, not a cache-staleness failure; a single edit or edits seconds apart always render fresh. The trailing-render-after-coalesce gap is tracked separately as a follow-up. +- **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). This case asserts transient-notebook safety under concurrency, not last-keystroke freshness. + +## 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. + +## Blast-radius regression protocol + +The fix touches two surfaces with different reach: + +- **A — `invalidateForFile(input)` loop in `src/project/serve/watch.ts`.** Runs in **project preview only**. Calls a function already used elsewhere (#14281, `preview.ts`); only the call site is new. +- **B — `mtime + size` guard in `projectResolveFullMarkdownForFile` (`src/project/project-shared.ts`).** Runs **everywhere** the function is called: every render (single doc + project), the single-file project type, `quarto inspect`, and the knitr (`rmd.ts`) and jupyter (`jupyter.ts`) execute paths. + +Outside preview, B is functionally a no-op (a one-shot context never sees the source change mid-life, so the guard always agrees with the old cache), but it adds one `Deno.statSync` per cache hit. The cases below confirm both that the fix works on its target surfaces and that the wide change B introduces no regression or perceptible cost elsewhere. + +These are interactive preview tests (require real file-save events) and cannot run as automated smoke tests. Drive them with `/quarto-preview-test`. The `#14281` accumulation matrix in `README.md` covers transient-notebook *accumulation*; the cases here cover *content freshness after an edit*, a different axis. + +### P1: Critical + +#### T8: Single-document preview — freshness after body edit (guard B, single-file path) + +- **Setup:** `tests/docs/manual/preview/plain.qmd` (pure markdown, no project). +- **Steps:** + 1. `quarto preview tests/docs/manual/preview/plain.qmd`. + 2. Edit a body line to `MARKER-T8`. Save. Wait 5s. + 3. `curl http://127.0.0.1:/ | Select-String MARKER-T8` and reload the browser. +- **Expected:** Both find `MARKER-T8`. +- **Catches:** Guard B regressing the single-file project type's resolve path — the one surface where single-doc preview leans on B alone (no watcher invalidation A). + +#### T9: Project + Jupyter `.qmd` — freshness after edit, no accumulation + +- **Setup:** In `project-preview/`, add `pycell.qmd` with a Python cell (`print("v1")`) and link it from `index.qmd`. Requires the Python test env. +- **Steps:** + 1. `cd tests/docs/manual/preview/project-preview && quarto preview`. + 2. Open `/pycell.html`. Change the cell to `print("v2")`. Save. Wait 10s. + 3. Check `_site/pycell.html` and the served page for `v2`. + 4. `ls *.quarto_ipynb*` — confirm at most one `pycell.quarto_ipynb`, no `_1`/`_2` variants. +- **Expected:** Fresh `v2` output; at most one transient notebook; zero after Ctrl+C. +- **Catches:** A interacting badly with the #14281 transient cleanup, or stale jupyter output surviving the edit. + +#### T10: Project + raw `.ipynb` input — source preserved, content fresh + +- **Setup:** Copy a native `.ipynb` (the `T11` notebook from the README #14281 matrix, or any executed notebook) into `project-preview/` as `notebook.ipynb`; link from `index.qmd`. +- **Steps:** + 1. `quarto preview` the project. Open `/notebook.html`. + 2. Edit a markdown cell in `notebook.ipynb` to add `MARKER-T10`. Save. Wait 10s. + 3. After Ctrl+C, confirm `notebook.ipynb` still exists on disk. +- **Expected:** `MARKER-T10` in served HTML; `notebook.ipynb` **not deleted** (its cache entry has `transient = false`, so `invalidateForFile` must not `safeRemoveSync` it). +- **Catches:** The new A call site firing `invalidateForFile` on a user `.ipynb` and wrongly deleting the source. + +### P2: Important + +#### T11: Project + knitr `.qmd` — freshness after edit (regression for guard B on `rmd.ts`) + +- **Setup:** In `project-preview/`, add `rcell.qmd` with `engine: knitr` and an R cell (`print("r-v1")`); link from `index.qmd`. Requires the R test env. +- **Steps:** Preview the project; change the cell to `print("r-v2")`; save; wait 10s; check served `/rcell.html`. +- **Expected:** Fresh `r-v2`. No `.quarto_ipynb` files (knitr has no jupyter intermediate). +- **Catches:** Guard B regressing the knitr resolve path (`src/execute/rmd.ts:245`). + +#### T12: Format-change + stale-body invalidation coexist in one project preview + +- **Setup:** `project-preview/about.qmd`, default `format: html`. +- **Steps:** + 1. Preview the project. Edit `about.qmd` body to `MARKER-T12a`. Save. Confirm fresh (the #10392 path, A). + 2. Without restarting, edit `about.qmd` frontmatter to `format: typst`. Save. Trigger one render. +- **Expected:** Step 1 serves fresh body; step 2 detects the format change (#14533) without the two invalidation sites — `watch.ts` (A) and `preview.ts` compatibility check — conflicting. +- **Catches:** Double-invalidation interaction (over-eager eviction → spurious 404, or under-eager → stale format). Cross-ref `README.md` #14533 T20–T25. + +### P3: Polish + +#### T13: `quarto inspect` after an edit (guard B + added `statSync`) + +- **Setup:** Any project file. +- **Steps:** `quarto inspect `, edit the file, `quarto inspect ` again in the same shell. +- **Expected:** Both succeed; second reflects the edit. No error from the added `Deno.statSync`. +- **Catches:** Guard B or the stat breaking the inspect resolve calls (`src/inspect/inspect.ts`). + +#### T14: Book / many-file project — perf sanity for per-hit `statSync` + +- **Setup:** A multi-chapter book (10+ inputs), e.g. `quarto create project book`. +- **Steps:** `quarto render` the book cold, then `quarto preview` and edit one chapter. +- **Expected:** No perceptible slowdown vs `upstream/main`; edited chapter renders fresh. +- **Catches:** The extra `Deno.statSync` per cache hit (guard B) becoming a noticeable cost at scale. Expected negligible; this is the explicit check. + +## Cleanup + +`quarto preview` exit (Ctrl+C). No persistent state to clean up beyond `_site/` which the next preview run rebuilds. Remove any ad-hoc fixtures added for T9–T14 (`pycell.qmd`, `notebook.ipynb`, `rcell.qmd`) — they are not committed. + +## 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) diff --git a/tests/docs/manual/preview/project-preview/.gitignore b/tests/docs/manual/preview/project-preview/.gitignore index ad293093b07..91e4dc52c80 100644 --- a/tests/docs/manual/preview/project-preview/.gitignore +++ b/tests/docs/manual/preview/project-preview/.gitignore @@ -1,2 +1,3 @@ /.quarto/ +/_site/ **/*.quarto_ipynb 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(); + }, +);