Skip to content

Commit 3bfbe4b

Browse files
authored
fix(preview): serve fresh HTML for project pages after .qmd edit (#14548)
When previewing a website or book project, editing a non-index .qmd regenerated the HTML on disk but the preview server kept serving the pre-edit body. A preview restart cleared it only for the first edit; the next edit reproduced the bug. Root cause: the long-lived ProjectContext reused by the HTTP-handler render held a fullMarkdown cache that was never invalidated after the watcher-triggered re-render, so projectResolveFullMarkdownForFile returned pre-edit expanded markdown. Two-layer fix: - Surgical: invalidate fileInformationCache for each changed input inside the submitRender callback so cache mutation is serialized with any in-flight HTTP-handler render via the existing render queue - Defense-in-depth: guard fullMarkdown cache entries by source file mtime + size so a stale entry is dropped on next read even if a future caller forgets to invalidate (also covers coarse-mtime filesystems like FAT/SMB) Includes unit tests for the mtime guard, size guard, and per-file invalidation. The scope is limited to direct .qmd edits on non-index project pages; include-file and template-partials invalidation are tracked separately. Fixes #10392 Fixes #11475 Fixes #13755
1 parent b217f5a commit 3bfbe4b

8 files changed

Lines changed: 421 additions & 5 deletions

File tree

llm-docs/preview-architecture.md

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
2-
main_commit: eca40cdab
3-
analyzed_date: 2026-05-22
2+
main_commit: fc0cf88dc
3+
analyzed_date: 2026-05-29
44
key_files:
55
- src/command/preview/cmd.ts
66
- src/command/preview/preview.ts
@@ -124,20 +124,104 @@ The in-flight gate avoids a race: `invalidateForFile` calls `safeRemoveSync` on
124124

125125
For unchanged frontmatter, `previewFormat` repopulates the cache with the same value and the compatibility verdict is identicalonly the cache lookup runs again. Cost: one cache re-read per IDE-driven render request, no functional change.
126126

127+
## Project Preview Re-render Path (Paths B/D)
128+
129+
Project preview (website/book) does NOT go through `renderForPreview()`. A single
130+
source edit produces **two** Pandoc invocations from two different call sites, and
131+
they share state in a way that caused #10392.
132+
133+
1. **Watcher invocation** (`watch.ts`, `watchProject()`). The file watcher fires on
134+
the edit. The single-input branch calls `render(inputs[0], …)` **without**
135+
passing `pContext`, so a fresh `ProjectContext` (empty `fileInformationCache`) is
136+
built inside `render()`. `projectResolveFullMarkdownForFile` reads source from
137+
diskthis invocation sees the edit. It writes the correct HTML.
138+
139+
2. **HTTP-handler invocation** (`serve.ts`). The watcher signals reload, the browser
140+
refetches, and the serve handler calls `renderProject(watcher.project(), …,
141+
[inputFile])`. `watcher.project()` is the **persistent** context whose
142+
`fileInformationCache.fullMarkdown` was populated at preview startup. Before the
143+
#10392 fix this invocation read pre-edit expanded markdown from that cache and
144+
overwrote invocation #1's fresh HTML with stale content.
145+
146+
```
147+
edit saved
148+
149+
150+
watch.ts ── render(input) ── fresh ctx, reads disk ──► correct HTML (#1)
151+
152+
▼ (reload signal → browser refetch)
153+
serve.ts ── renderProject(watcher.project(), [input])
154+
155+
└─ persistent ctx, cached fullMarkdown ──► STALE HTML overwrites (#2)
156+
```
157+
158+
### The #10392 fix (two layers)
159+
160+
1. **Surgical** — `watch.ts` invalidates the persistent context's cache for each
161+
changed input before rendering:
162+
163+
```typescript
164+
for (const input of inputs) {
165+
project.fileInformationCache?.invalidateForFile(input);
166+
}
167+
```
168+
169+
This runs **inside** the `submitRender()` callback so it is serialized on the
170+
render queue. `invalidateForFile` may delete a transient `.quarto_ipynb`; running
171+
it outside the queue could race a concurrent in-flight render still reading that
172+
notebook. Invalidation drops the WHOLE entry (`metadata`, `codeCells`, `engine`,
173+
`target`, `fullMarkdown`), so it covers more than the freshness guard alone.
174+
175+
2. **Defense-in-depth** — the `fullMarkdown` mtime+size guard (above). Even if a
176+
future caller forgets to invalidate, invocation #2 re-reads on the mtime/size
177+
mismatch. The two layers are complementary, not redundant: the guard refreshes
178+
only `fullMarkdown`; `invalidateForFile` refreshes all fields.
179+
180+
### Out of scope for #10392
181+
182+
The freshness guard fingerprints the edited input file itself. It does NOT detect
183+
edits to a file's non-input dependencies, because the input's own mtime/size is
184+
unchanged:
185+
186+
- `{{< include >}}`d files — the includer's cache is not invalidated when the
187+
includee changes (tracked in #2795).
188+
- `template-partials:` files (tracked in #14561).
189+
190+
These need a dependency→consumer map in the watch list, not a per-file stat.
191+
127192
## FileInformationCache and invalidateForFile
128193

129194
`FileInformationCacheMap` stores per-file cached data:
130195

131196
| Field | Content | Cost of re-computation |
132197
|-------|---------|----------------------|
133198
| `fullMarkdown` | Expanded markdown with includes | Re-reads file, re-expands includes |
199+
| `sourceMtime` | Source file mtime (ms) when `fullMarkdown` was cached | Stat of source file |
200+
| `sourceSize` | Source file byte size when `fullMarkdown` was cached | Stat of source file |
134201
| `includeMap` | Include source→target mappings | Recomputed with markdown |
135202
| `codeCells` | Parsed code cells | Recomputed from markdown |
136203
| `engine` | Execution engine instance | Re-determined |
137204
| `target` | Execution target (includes `.quarto_ipynb` path) | Re-created by `target()` |
138205
| `metadata` | YAML front matter | Recomputed from markdown |
139206
| `brand` | Resolved `_brand.yml` data | Re-loaded from disk |
140207

208+
### fullMarkdown freshness guard (added for #10392)
209+
210+
`projectResolveFullMarkdownForFile()` (`project-shared.ts`) no longer trusts a
211+
populated `fullMarkdown` unconditionally. It stats the source file on every call
212+
and returns the cached value only when both `sourceMtime` and `sourceSize` match
213+
the current file. On any mismatch (or if the stat fails) it re-reads and
214+
re-expands, then stores the new mtime+size alongside the result.
215+
216+
Size is checked alongside mtime to catch an edit that lands within a single
217+
mtime tick on a coarse-resolution filesystem (FAT32 ~2 s, some network mounts)
218+
while still changing the byte count.
219+
220+
This is defense-in-depth for the project-preview path (below), where a
221+
persistent context's cache is reused across renders and a caller may forget to
222+
invalidate. The guard refreshes only `fullMarkdown`; stale `metadata`,
223+
`codeCells`, `engine`, or `target` still require an explicit `invalidateForFile()`.
224+
141225
### invalidateForFile() (added for #14281)
142226

143227
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 `.
169253
| Single file, no project | 1 (cmd.ts, passed to preview) | 0 (cached project reused) |
170254
| Single file in serveable project | 1 (cmd.ts, passed to serveProject) | See project rows |
171255
| Project directory | 1 (serve.ts) | See project rows |
172-
| Project: single input changed || 1 (render() without pContext) |
173-
| Project: multiple inputs changed || 0 (renderProject reuses cached) |
256+
| Project: single input changed || 1 (watcher render() without pContext) + 0 (serve.ts HTTP-handler renderProject reuses persistent ctx) |
257+
| Project: multiple inputs changed || 0 new (watcher renderProject reuses cached) |
174258
| Project: config file changed (HTML) || 1 (refreshProjectConfig) |
175259

260+
The single-input row's `+ 0` reflects two render invocations but only one new
261+
context computation: the watcher render builds a fresh context, while the
262+
HTTP-handler render reuses the persistent one (0 new computations). Both
263+
invocations must still produce fresh output — see "Project Preview Re-render
264+
Path" for the #10392 stale-cache interaction between them.
265+
176266
## Key Files
177267

178268
| File | Purpose |

news/changelog-1.10.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ All changes included in 1.10:
4848

4949
### `quarto preview`
5050

51+
- ([#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`.
5152
- ([#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.
5253
- ([#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.
5354

src/project/project-shared.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,35 @@ export async function projectResolveFullMarkdownForFile(
468468
force?: boolean,
469469
): Promise<MappedString> {
470470
const cache = ensureFileInformationCache(project, file);
471-
if (!force && cache.fullMarkdown) {
471+
472+
// Source-mtime + size guard: in preview mode the persistent project
473+
// context (and its fileInformationCache) is reused across renders. If
474+
// the source file was edited since the cache entry was populated, the
475+
// cached expanded markdown is stale (#10392). Re-read in that case.
476+
// Size is checked alongside mtime to catch the edge case where an
477+
// edit lands within a single mtime tick on a coarse-resolution
478+
// filesystem but changes the byte count.
479+
let currentMtime: number | undefined;
480+
let currentSize: number | undefined;
481+
try {
482+
const stat = Deno.statSync(file);
483+
currentMtime = stat.mtime?.getTime();
484+
currentSize = stat.size;
485+
} catch {
486+
currentMtime = undefined;
487+
currentSize = undefined;
488+
}
489+
490+
if (
491+
!force &&
492+
cache.fullMarkdown &&
493+
cache.sourceMtime !== undefined &&
494+
cache.sourceSize !== undefined &&
495+
currentMtime !== undefined &&
496+
currentSize !== undefined &&
497+
cache.sourceMtime === currentMtime &&
498+
cache.sourceSize === currentSize
499+
) {
472500
return cache.fullMarkdown;
473501
}
474502

@@ -495,6 +523,8 @@ export async function projectResolveFullMarkdownForFile(
495523
try {
496524
const result = await expandIncludes(markdown, options, file);
497525
cache.fullMarkdown = result;
526+
cache.sourceMtime = currentMtime;
527+
cache.sourceSize = currentSize;
498528
cache.includeMap = options.state?.include.includes as FileInclusion[];
499529
return result;
500530
} finally {

src/project/serve/watch.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,21 @@ export function watchProject(
143143
const services = renderServices(nbContext);
144144
try {
145145
const result = await renderManager.submitRender(() => {
146+
// Invalidate the persistent project context's cache for
147+
// each changed input. The HTTP-handler render in
148+
// serve.ts reuses watcher.project() with its long-lived
149+
// fileInformationCache; without this invalidation,
150+
// projectResolveFullMarkdownForFile returns the pre-edit
151+
// expanded markdown and the regenerated HTML keeps the
152+
// stale body (#10392). The invalidation runs inside the
153+
// render queue so it is serialized with any in-flight
154+
// render — invalidateForFile may delete a transient
155+
// .quarto_ipynb, and running it outside the queue could
156+
// race with a concurrent HTTP-handler render that is
157+
// still reading that notebook.
158+
for (const input of inputs) {
159+
project.fileInformationCache?.invalidateForFile(input);
160+
}
146161
if (inputs.length > 1) {
147162
return renderProject(
148163
project!,

src/project/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export type FileInclusion = {
6060

6161
export type FileInformation = {
6262
fullMarkdown?: MappedString;
63+
sourceMtime?: number;
64+
sourceSize?: number;
6365
includeMap?: FileInclusion[];
6466
codeCells?: InspectedMdCell[];
6567
engine?: ExecutionEngineInstance;

0 commit comments

Comments
 (0)