Skip to content

fix(preview): serve fresh HTML for project pages after .qmd edit#14548

Draft
cderv wants to merge 10 commits into
mainfrom
fix/issue-10392
Draft

fix(preview): serve fresh HTML for project pages after .qmd edit#14548
cderv wants to merge 10 commits into
mainfrom
fix/issue-10392

Conversation

@cderv
Copy link
Copy Markdown
Member

@cderv cderv commented May 26, 2026

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. Stopping and restarting quarto preview cleared it for the first edit; the next edit reproduced the bug.

Root Cause

watcher.project() returns a long-lived ProjectContext whose fileInformationCache.fullMarkdown caches the expanded markdown per input. The HTTP-handler render in src/project/serve/serve.ts reuses this context, so after a watcher-triggered re-render, projectResolveFullMarkdownForFile returned the pre-edit expanded markdown. The regenerated HTML body therefore reflected the previous content.

Fix

Two layers, motivated separately:

  1. Surgical (src/project/serve/watch.ts): invalidate the project context's fileInformationCache for each changed input inside the submitRender callback. Inside the callback (not before submitRender) so the cache mutation is serialized with any in-flight HTTP-handler render via the existing render queue — invalidateForFile may delete a transient .quarto_ipynb, and running it before enqueue could race a concurrent read.
  2. Defense-in-depth (src/project/project-shared.ts): guard the fullMarkdown cache entry by source file mtime + size. A stale entry is dropped on the next read even if a future caller forgets to invalidate. Size catches the case where filesystems with coarse mtime resolution (e.g. 2-second FAT/SMB) place a rapid edit in the same tick.

Scope

This PR fixes the direct symptom for non-index project pages where the edit is to the .qmd file itself. Related cases raised in the issue thread are not covered here and are tracked separately:

Test Plan

Automated (tests/unit/project/file-information-cache.test.ts): mtime guard, size guard, per-file invalidation.

Manual spec: tests/docs/manual/preview/10392-project-preview-stale-cache-after-edit.md.

  • Edit a non-index .qmd in a website project — served HTML reflects the edit (T1)
  • Repeat without restarting preview — second edit also reflects (T2)
  • Edit index.qmd — no regression on the index path (T3)
  • Concurrent saves on a Jupyter input — no file-not-found errors in preview console (T7, race regression)
  • Single-file preview Jupyter regression — .quarto_ipynb accumulation tests pass

#11475 and #13755 report the same root cause on older release lines (1.6, 1.8); this PR addresses it on main for 1.10.

Fixes #10392
Fixes #11475
Fixes #13755

cderv added 4 commits May 26, 2026 17:10
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.
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.
…#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.
@posit-snyk-bot
Copy link
Copy Markdown
Collaborator

posit-snyk-bot commented May 26, 2026

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

cderv added 5 commits May 29, 2026 19:12
…hness guard

The preview architecture doc only described the single-file re-render path.
It did not capture the project-preview two-invocation chain (watcher render
plus HTTP-handler render reusing the persistent context) that #10392 lives
on, nor the fullMarkdown mtime+size freshness guard added to defend it.
The single-input row in the Context Computation Count table counted +1 for
the serve.ts HTTP-handler render, but that render reuses the persistent
project context (renderProject(watcher.project(), ...)) and computes no new
context. Counting +1 misstated context-creation cost in a table whose column
tracks context computations. Changed to +0 and clarified the note that the
row reflects two render invocations but only one new context computation.
…ix (#10392)

The fix spans two surfaces: a project-preview-only invalidation call site
and a freshness guard that runs on every render, inspect, and execute
path. T8-T14 cover the wider surface so a future change can confirm the
guard introduces no regression off the preview path and the new statSync
per cache hit stays negligible at book scale.
Manual testing showed a sub-second save burst is coalesced by the watcher
into a single render, so the final body can reflect the first read of the
burst rather than the last keystroke. That is render-scheduling behavior,
not cache staleness, and is tracked as a separate follow-up. T7 asserts
transient-notebook safety under concurrency, which is what the fix governs.
@cderv
Copy link
Copy Markdown
Member Author

cderv commented May 29, 2026

Manual preview testing

I ran the manual preview protocols against this branch (dev build, Windows), focusing on the two surfaces the fix touches: the project-preview-only invalidateForFile call in watch.ts, and the mtime + size freshness guard in projectResolveFullMarkdownForFile that runs on every render / inspect / execute path. The protocols live in tests/docs/manual/preview/ (the 10392-… doc plus a blast-radius regression matrix) so they're repeatable.

Jupyter .qmd freshness + transient cleanup

Edited a Python cell in a website project mid-preview. The served HTML and _site/ both picked up the change on the next reload, and at most one *.quarto_ipynb existed during a render with none left at rest — no numbered _1/_2 variants.

Raw .ipynb input

Edited a markdown cell of a native .ipynb page in the project. Output refreshed and the source .ipynb was preserved — the watcher's invalidateForFile skips safeRemoveSync for non-transient targets, so a user notebook is never deleted by the new call site.

Concurrent saves during an in-flight render

Used a time.sleep(5) Python cell to widen the render window, then fired several sub-second saves while a render was in flight. No ENOENT / BadResource / "cannot read .quarto_ipynb" errors — invalidation runs inside the render queue, so a transient notebook isn't removed while a render is still reading it.

One thing I noticed while doing the burst test: under a sub-second save burst the watcher coalesces the saves into a single render, so the final body can reflect the first read of the burst rather than the last keystroke. That's render-scheduling behavior, separate from the cache fix here — single edits, and edits a second or more apart, always render fresh. I'm tracking it as a follow-up and it doesn't affect this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants