Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 94 additions & 4 deletions llm-docs/preview-architecture.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -124,20 +124,104 @@ 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:

| 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 |
| `target` | Execution target (includes `.quarto_ipynb` path) | Re-created by `target()` |
| `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:
Expand Down Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
32 changes: 31 additions & 1 deletion src/project/project-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,35 @@ export async function projectResolveFullMarkdownForFile(
force?: boolean,
): Promise<MappedString> {
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;
}

Expand All @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions src/project/serve/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!,
Expand Down
2 changes: 2 additions & 0 deletions src/project/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export type FileInclusion = {

export type FileInformation = {
fullMarkdown?: MappedString;
sourceMtime?: number;
sourceSize?: number;
includeMap?: FileInclusion[];
codeCells?: InspectedMdCell[];
engine?: ExecutionEngineInstance;
Expand Down
Loading
Loading