|
1 | 1 | --- |
2 | | -main_commit: eca40cdab |
3 | | -analyzed_date: 2026-05-22 |
| 2 | +main_commit: fc0cf88dc |
| 3 | +analyzed_date: 2026-05-29 |
4 | 4 | key_files: |
5 | 5 | - src/command/preview/cmd.ts |
6 | 6 | - src/command/preview/preview.ts |
@@ -124,20 +124,104 @@ The in-flight gate avoids a race: `invalidateForFile` calls `safeRemoveSync` on |
124 | 124 |
|
125 | 125 | 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. |
126 | 126 |
|
| 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 | + disk — this 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 | + |
127 | 192 | ## FileInformationCache and invalidateForFile |
128 | 193 |
|
129 | 194 | `FileInformationCacheMap` stores per-file cached data: |
130 | 195 |
|
131 | 196 | | Field | Content | Cost of re-computation | |
132 | 197 | |-------|---------|----------------------| |
133 | 198 | | `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 | |
134 | 201 | | `includeMap` | Include source→target mappings | Recomputed with markdown | |
135 | 202 | | `codeCells` | Parsed code cells | Recomputed from markdown | |
136 | 203 | | `engine` | Execution engine instance | Re-determined | |
137 | 204 | | `target` | Execution target (includes `.quarto_ipynb` path) | Re-created by `target()` | |
138 | 205 | | `metadata` | YAML front matter | Recomputed from markdown | |
139 | 206 | | `brand` | Resolved `_brand.yml` data | Re-loaded from disk | |
140 | 207 |
|
| 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 | + |
141 | 225 | ### invalidateForFile() (added for #14281) |
142 | 226 |
|
143 | 227 | 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 `. |
169 | 253 | | Single file, no project | 1 (cmd.ts, passed to preview) | 0 (cached project reused) | |
170 | 254 | | Single file in serveable project | 1 (cmd.ts, passed to serveProject) | See project rows | |
171 | 255 | | 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) | |
174 | 258 | | Project: config file changed (HTML) | — | 1 (refreshProjectConfig) | |
175 | 259 |
|
| 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 | + |
176 | 266 | ## Key Files |
177 | 267 |
|
178 | 268 | | File | Purpose | |
|
0 commit comments