Skip to content

Commit 0ff33a3

Browse files
authored
Merge pull request #13967 from quarto-dev/fix/issue-13955
Fix preview issue after #13804 changing how project context is handled now by being reused across project preview render.
2 parents 6645d55 + 979807e commit 0ff33a3

9 files changed

Lines changed: 217 additions & 12 deletions

File tree

news/changelog-1.9.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ All changes included in 1.9:
127127

128128
- ([#4426](https://github.com/quarto-dev/quarto-cli/issues/4426)): New `quarto install verapdf` command installs [veraPDF](https://verapdf.org/) for PDF/A and PDF/UA validation. When verapdf is available, PDFs created with the `pdf-standard` option are automatically validated for compliance. Also supports `quarto uninstall verapdf`, `quarto update verapdf`, and `quarto tools`.
129129

130+
### `preview`
131+
132+
- ([#13804](https://github.com/quarto-dev/quarto-cli/pull/13804)): Fix intermittent preview crashes during re-renders by properly managing project context lifecycle. Resolves issues with missing temporary directories and `quarto_ipynb` files when editing notebooks and qmd files together.
133+
130134
## Extensions
131135

132136
- Metadata and brand extensions now work without a `_quarto.yml` project. (Engine extensions do too.) A temporary default project is created in memory.

src/command/preview/preview.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Copyright (C) 2020-2022 Posit Software, PBC
55
*/
66

7-
import { info, warning } from "../../deno_ral/log.ts";
7+
import { debug, info, warning } from "../../deno_ral/log.ts";
88
import {
99
basename,
1010
dirname,
@@ -424,6 +424,14 @@ export async function renderForPreview(
424424
pandocArgs: string[],
425425
project?: ProjectContext,
426426
): Promise<RenderForPreviewResult> {
427+
// Invalidate file cache for the file being rendered so changes are picked up.
428+
// The project context persists across re-renders in preview mode, but the
429+
// fileInformationCache contains file content that needs to be refreshed.
430+
// TODO(#13955): Consider adding a dedicated invalidateForFile() method on ProjectContext
431+
if (project?.fileInformationCache) {
432+
project.fileInformationCache.delete(file);
433+
}
434+
427435
// render
428436
const renderResult = await render(file, {
429437
services,
@@ -485,8 +493,6 @@ export async function renderForPreview(
485493
[],
486494
));
487495

488-
renderResult.context.cleanup();
489-
490496
return {
491497
file,
492498
format: renderResult.files[0].format,

src/core/sass/cache.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ class SassCache implements Cloneable<SassCache> {
145145
// add a cleanup method to register a cleanup handler
146146
cleanup(temp: TempContext | undefined) {
147147
const registerCleanup = temp ? temp.onCleanup : onCleanup;
148+
const cachePath = this.path;
148149
registerCleanup(() => {
150+
log.debug(`SassCache cleanup executing for ${cachePath}`);
149151
try {
150152
this.kv.close();
151153
if (temp) safeRemoveIfExists(this.path);

src/inspect/inspect.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,13 @@ const populateFileInformation = async (
139139
}
140140
await projectResolveCodeCellsForFile(context, engine, file);
141141
await projectFileMetadata(context, file);
142-
fileInformation[file] = {
143-
includeMap: context.fileInformationCache.get(file)?.includeMap ??
144-
[],
145-
codeCells: context.fileInformationCache.get(file)?.codeCells ?? [],
146-
metadata: context.fileInformationCache.get(file)?.metadata ?? {},
142+
const cacheEntry = context.fileInformationCache.get(file);
143+
// Output key: project-relative for portability
144+
const outputKey = relative(context.dir, normalizePath(file));
145+
fileInformation[outputKey] = {
146+
includeMap: cacheEntry?.includeMap ?? [],
147+
codeCells: cacheEntry?.codeCells ?? [],
148+
metadata: cacheEntry?.metadata ?? {},
147149
};
148150
};
149151

src/project/project-shared.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ export const ensureFileInformationCache = (
506506
file: string,
507507
) => {
508508
if (!project.fileInformationCache) {
509-
project.fileInformationCache = new Map();
509+
project.fileInformationCache = new FileInformationCacheMap();
510510
}
511511
assert(
512512
project.fileInformationCache instanceof Map,
@@ -655,11 +655,36 @@ export async function projectResolveBrand(
655655
}
656656
}
657657

658-
// Create a class that extends Map and implements Cloneable
658+
// A Map that normalizes path keys for cross-platform consistency.
659+
// All path operations normalize keys (forward slashes, lowercase on Windows).
660+
// Implements Cloneable but shares state intentionally - in preview mode,
661+
// the project context is reused across renders and cache state must persist.
659662
export class FileInformationCacheMap extends Map<string, FileInformation>
660663
implements Cloneable<Map<string, FileInformation>> {
664+
override get(key: string): FileInformation | undefined {
665+
return super.get(normalizePath(key));
666+
}
667+
668+
override has(key: string): boolean {
669+
return super.has(normalizePath(key));
670+
}
671+
672+
override set(key: string, value: FileInformation): this {
673+
return super.set(normalizePath(key), value);
674+
}
675+
676+
override delete(key: string): boolean {
677+
return super.delete(normalizePath(key));
678+
}
679+
680+
// Note: Iterator methods (keys(), entries(), forEach(), [Symbol.iterator])
681+
// return normalized keys as stored. Code iterating over the cache sees
682+
// normalized paths, which is consistent with how keys are stored.
683+
684+
// Returns this instance (shared reference) rather than a copy.
685+
// This is intentional: in preview mode, project context is cloned for
686+
// each render but the cache must be shared so invalidations persist.
661687
clone(): Map<string, FileInformation> {
662-
// Return the same instance (reference) instead of creating a clone
663688
return this;
664689
}
665690
}

src/project/types/single-file/single-file.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ export async function singleFileProjectContext(
4545
const temp = globalTempContext();
4646
const projectCacheBaseDir = temp.createDir();
4747

48+
const normalizedDir = normalizePath(dirname(source));
49+
4850
const result: ProjectContext = {
4951
clone: () => result,
5052
resolveBrand: (fileName?: string) => projectResolveBrand(result, fileName),
51-
dir: normalizePath(dirname(source)),
53+
dir: normalizedDir,
5254
engines: [],
5355
files: {
5456
input: [],
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
/.quarto/
2+
3+
**/*.quarto_ipynb
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* file-information-cache.test.ts
3+
*
4+
* Tests for fileInformationCache path normalization
5+
* Related to issue #13955
6+
*
7+
* Copyright (C) 2026 Posit Software, PBC
8+
*/
9+
10+
import { unitTest } from "../../test.ts";
11+
import { assert } from "testing/asserts";
12+
import { join } from "../../../src/deno_ral/path.ts";
13+
import {
14+
ensureFileInformationCache,
15+
FileInformationCacheMap,
16+
} from "../../../src/project/project-shared.ts";
17+
import { createMockProjectContext } from "./utils.ts";
18+
19+
// deno-lint-ignore require-await
20+
unitTest(
21+
"fileInformationCache - same path returns same entry",
22+
async () => {
23+
const project = createMockProjectContext();
24+
25+
// Use cross-platform absolute path (backslashes on Windows, forward on Linux)
26+
const path1 = join(project.dir, "doc.qmd");
27+
const path2 = join(project.dir, "doc.qmd");
28+
29+
const entry1 = ensureFileInformationCache(project, path1);
30+
const entry2 = ensureFileInformationCache(project, path2);
31+
32+
assert(
33+
entry1 === entry2,
34+
"Same path should return same cache entry",
35+
);
36+
assert(
37+
project.fileInformationCache.size === 1,
38+
"Should have exactly one cache entry",
39+
);
40+
},
41+
);
42+
43+
// deno-lint-ignore require-await
44+
unitTest(
45+
"fileInformationCache - different paths create different entries",
46+
async () => {
47+
const project = createMockProjectContext();
48+
49+
const path1 = join(project.dir, "doc1.qmd");
50+
const path2 = join(project.dir, "doc2.qmd");
51+
52+
const entry1 = ensureFileInformationCache(project, path1);
53+
const entry2 = ensureFileInformationCache(project, path2);
54+
55+
assert(
56+
entry1 !== entry2,
57+
"Different paths should return different cache entries",
58+
);
59+
assert(
60+
project.fileInformationCache.size === 2,
61+
"Should have two cache entries for different paths",
62+
);
63+
},
64+
);
65+
66+
// deno-lint-ignore require-await
67+
unitTest(
68+
"fileInformationCache - cache entry persists across calls",
69+
async () => {
70+
const project = createMockProjectContext();
71+
72+
const path = join(project.dir, "doc.qmd");
73+
74+
// First call creates entry
75+
const entry1 = ensureFileInformationCache(project, path);
76+
// Modify the entry
77+
entry1.metadata = { title: "Test" };
78+
79+
// Second call should return same entry with our modification
80+
const entry2 = ensureFileInformationCache(project, path);
81+
82+
assert(
83+
entry2.metadata?.title === "Test",
84+
"Cache entry should persist modifications",
85+
);
86+
assert(
87+
entry1 === entry2,
88+
"Should return same cache entry object",
89+
);
90+
},
91+
);
92+
93+
// deno-lint-ignore require-await
94+
unitTest(
95+
"ensureFileInformationCache - creates FileInformationCacheMap when cache is missing",
96+
async () => {
97+
const project = createMockProjectContext();
98+
// Simulate minimal ProjectContext without cache (as in command-utils.ts)
99+
// deno-lint-ignore no-explicit-any
100+
(project as any).fileInformationCache = undefined;
101+
102+
ensureFileInformationCache(project, join(project.dir, "doc.qmd"));
103+
104+
assert(
105+
project.fileInformationCache instanceof FileInformationCacheMap,
106+
"Should create FileInformationCacheMap, not plain Map",
107+
);
108+
},
109+
);

tests/unit/project/utils.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* utils.ts
3+
*
4+
* Test utilities for project-related unit tests
5+
*
6+
* Copyright (C) 2026 Posit Software, PBC
7+
*/
8+
9+
import { ProjectContext } from "../../../src/project/types.ts";
10+
import { FileInformationCacheMap } from "../../../src/project/project-shared.ts";
11+
12+
/**
13+
* Create a minimal mock ProjectContext for testing.
14+
* Only provides the essential properties needed for cache-related tests.
15+
*
16+
* @param dir - The project directory (defaults to a temp directory)
17+
* @returns A mock ProjectContext suitable for unit testing
18+
*/
19+
export function createMockProjectContext(
20+
dir?: string,
21+
): ProjectContext {
22+
const projectDir = dir ?? Deno.makeTempDirSync({ prefix: "quarto-test" });
23+
const ownsDir = dir === undefined;
24+
25+
return {
26+
dir: projectDir,
27+
engines: [],
28+
files: { input: [] },
29+
notebookContext: {} as ProjectContext["notebookContext"],
30+
fileInformationCache: new FileInformationCacheMap(),
31+
resolveBrand: () => Promise.resolve(undefined),
32+
resolveFullMarkdownForFile: () => Promise.resolve({} as never),
33+
fileExecutionEngineAndTarget: () => Promise.resolve({} as never),
34+
fileMetadata: () => Promise.resolve({}),
35+
environment: () => Promise.resolve({} as never),
36+
renderFormats: () => Promise.resolve({}),
37+
clone: function () {
38+
return this;
39+
},
40+
isSingleFile: false,
41+
diskCache: {} as ProjectContext["diskCache"],
42+
temp: {} as ProjectContext["temp"],
43+
cleanup: () => {
44+
if (ownsDir) {
45+
try {
46+
Deno.removeSync(projectDir, { recursive: true });
47+
} catch {
48+
// Ignore cleanup errors in tests
49+
}
50+
}
51+
},
52+
} as ProjectContext;
53+
}

0 commit comments

Comments
 (0)