From aa1cae0086520454f48ac65d3db2ea0a3964217a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Jan 2026 15:22:34 +0100 Subject: [PATCH 01/11] log, debug - Add more debug step log messages to track SassCache creation and reuse. --- src/core/sass/cache.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/sass/cache.ts b/src/core/sass/cache.ts index ee3d7a80fee..c65eaab9d49 100644 --- a/src/core/sass/cache.ts +++ b/src/core/sass/cache.ts @@ -145,9 +145,12 @@ class SassCache implements Cloneable { // add a cleanup method to register a cleanup handler cleanup(temp: TempContext | undefined) { const registerCleanup = temp ? temp.onCleanup : onCleanup; + const cachePath = this.path; registerCleanup(() => { + log.debug(`SassCache cleanup EXECUTING for ${cachePath}`); try { this.kv.close(); + log.debug(`SassCache KV closed for ${cachePath}`); if (temp) safeRemoveIfExists(this.path); } catch (error) { log.info( @@ -155,6 +158,7 @@ class SassCache implements Cloneable { ); } }); + log.debug(`SassCache cleanup REGISTERED for ${cachePath}`); } } @@ -199,7 +203,7 @@ export async function sassCache( temp: TempContext | undefined, ): Promise { if (!_sassCache[path]) { - log.debug(`Creating SassCache at ${path}`); + log.debug(`Creating NEW SassCache at ${path}`); ensureDirSync(path); const kvFile = join(path, "sass.kv"); const kv = await Deno.openKv(kvFile); @@ -207,6 +211,8 @@ export async function sassCache( _sassCache[path] = new SassCache(kv, path); // register cleanup for this cache _sassCache[path].cleanup(temp); + } else { + log.debug(`Found EXISTING SassCache entry for ${path} (may be stale if KV was closed)`); } log.debug(`Returning SassCache at ${path}`); const result = _sassCache[path]; From 00b1f2cddb67a4a0f5b7c9883404c62e54799f76 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Jan 2026 18:49:12 +0100 Subject: [PATCH 02/11] preview, projectcontext - Don't call renderResult.context.cleanup() at each render in preview mode the project context is owned by `preview()` and cleaned up when preview exits. Calling `cleanup()` here would close shared resources (like SassCache KV handles) causing BadResource errors on subsequent re-renders (#13955). --- src/command/preview/preview.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index 279635c2842..c005dd56328 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -485,8 +485,6 @@ export async function renderForPreview( [], )); - renderResult.context.cleanup(); - return { file, format: renderResult.files[0].format, From dde993db6ffd73a4457460e9fb3ec0ac04aa4512 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Jan 2026 18:50:19 +0100 Subject: [PATCH 03/11] preview, projectContext - Invalidate file cache for rendered files to ensure changes are picked up in preview mode --- src/command/preview/preview.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index c005dd56328..c91e581c4f0 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -424,6 +424,17 @@ export async function renderForPreview( pandocArgs: string[], project?: ProjectContext, ): Promise { + // Invalidate file cache for the file being rendered so changes are picked up. + // The project context persists across re-renders in preview mode, but the + // fileInformationCache contains file content that needs to be refreshed. + // TODO(#13955): Consider adding a dedicated invalidateForFile() method on ProjectContext + if (project?.fileInformationCache) { + const normalizedFile = normalizePath(file); + if (project.fileInformationCache.has(normalizedFile)) { + project.fileInformationCache.delete(normalizedFile); + } + } + // render const renderResult = await render(file, { services, From c7c7c41faad8247ab790c540a6d0ad5dc5205989 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Jan 2026 18:57:53 +0100 Subject: [PATCH 04/11] saasCache, log - simplify logging for sassCache --- src/core/sass/cache.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/core/sass/cache.ts b/src/core/sass/cache.ts index c65eaab9d49..0073b55d1e4 100644 --- a/src/core/sass/cache.ts +++ b/src/core/sass/cache.ts @@ -147,10 +147,9 @@ class SassCache implements Cloneable { const registerCleanup = temp ? temp.onCleanup : onCleanup; const cachePath = this.path; registerCleanup(() => { - log.debug(`SassCache cleanup EXECUTING for ${cachePath}`); + log.debug(`SassCache cleanup executing for ${cachePath}`); try { this.kv.close(); - log.debug(`SassCache KV closed for ${cachePath}`); if (temp) safeRemoveIfExists(this.path); } catch (error) { log.info( @@ -158,7 +157,6 @@ class SassCache implements Cloneable { ); } }); - log.debug(`SassCache cleanup REGISTERED for ${cachePath}`); } } @@ -203,7 +201,7 @@ export async function sassCache( temp: TempContext | undefined, ): Promise { if (!_sassCache[path]) { - log.debug(`Creating NEW SassCache at ${path}`); + log.debug(`Creating SassCache at ${path}`); ensureDirSync(path); const kvFile = join(path, "sass.kv"); const kv = await Deno.openKv(kvFile); @@ -211,8 +209,6 @@ export async function sassCache( _sassCache[path] = new SassCache(kv, path); // register cleanup for this cache _sassCache[path].cleanup(temp); - } else { - log.debug(`Found EXISTING SassCache entry for ${path} (may be stale if KV was closed)`); } log.debug(`Returning SassCache at ${path}`); const result = _sassCache[path]; From 5344ad84af0ccf4ca0f64456a9777c22f0353b2b Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Jan 2026 18:16:49 +0100 Subject: [PATCH 05/11] changelog - Add preview improvements entry Documents the project context lifecycle fix from #13804 that resolves intermittent preview crashes when editing notebooks and qmd files. --- news/changelog-1.9.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index ff9ffefd755..363661d1ac9 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -127,6 +127,10 @@ All changes included in 1.9: - ([#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`. +### `preview` + +- ([#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. + ## Extensions - Metadata and brand extensions now work without a `_quarto.yml` project. (Engine extensions do too.) A temporary default project is created in memory. From b3edfe3304f46cdf228804feaa89720c9080cecd Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 29 Jan 2026 19:00:29 +0100 Subject: [PATCH 06/11] Use unnormalized paths when removing fileInformationCache entries and add a debug log message when we do so. --- src/command/preview/preview.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index c91e581c4f0..8252f21c4ed 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -4,7 +4,7 @@ * Copyright (C) 2020-2022 Posit Software, PBC */ -import { info, warning } from "../../deno_ral/log.ts"; +import { debug, info, warning } from "../../deno_ral/log.ts"; import { basename, dirname, @@ -429,10 +429,8 @@ export async function renderForPreview( // fileInformationCache contains file content that needs to be refreshed. // TODO(#13955): Consider adding a dedicated invalidateForFile() method on ProjectContext if (project?.fileInformationCache) { - const normalizedFile = normalizePath(file); - if (project.fileInformationCache.has(normalizedFile)) { - project.fileInformationCache.delete(normalizedFile); - } + debug(`[renderForPreview] Invalidating file information cache for ${file}`); + project.fileInformationCache.delete(file); } // render From ee78d1ccf0305a17f4f0f7654f887af6ca652627 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 30 Jan 2026 15:37:17 +0100 Subject: [PATCH 07/11] Normalize paths in fileInformationCache for consistent cache keys The fileInformationCache was using inconsistent path formats as keys, causing cache misses during preview. On Windows, paths could be stored with forward slashes but looked up with backslashes (or vice versa). Apply normalizePath() at all cache access points: - ensureFileInformationCache() in project-shared.ts (entry point) - renderForPreview() deletion in preview.ts - populateFileInformation() and inspectDocumentConfig() in inspect.ts - cleanupNotebook() in jupyter.ts Add unit tests for fileInformationCache behavior with reusable createMockProjectContext() factory in tests/unit/project/utils.ts. Co-Authored-By: Claude Opus 4.5 --- src/command/preview/preview.ts | 4 +- src/execute/jupyter/jupyter.ts | 4 +- src/inspect/inspect.ts | 16 ++-- src/project/project-shared.ts | 7 +- src/project/types/single-file/single-file.ts | 4 +- .../project/file-information-cache.test.ts | 88 +++++++++++++++++++ tests/unit/project/utils.ts | 43 +++++++++ 7 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 tests/unit/project/file-information-cache.test.ts create mode 100644 tests/unit/project/utils.ts diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index 8252f21c4ed..bd24c6c8b31 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -429,8 +429,8 @@ export async function renderForPreview( // fileInformationCache contains file content that needs to be refreshed. // TODO(#13955): Consider adding a dedicated invalidateForFile() method on ProjectContext if (project?.fileInformationCache) { - debug(`[renderForPreview] Invalidating file information cache for ${file}`); - project.fileInformationCache.delete(file); + const normalizedFile = normalizePath(file); + project.fileInformationCache.delete(normalizedFile); } // render diff --git a/src/execute/jupyter/jupyter.ts b/src/execute/jupyter/jupyter.ts index 378515454cb..e10ec968b75 100644 --- a/src/execute/jupyter/jupyter.ts +++ b/src/execute/jupyter/jupyter.ts @@ -8,6 +8,7 @@ import { basename, dirname, join, relative } from "../../deno_ral/path.ts"; import { satisfies } from "semver/mod.ts"; import { existsSync } from "../../deno_ral/fs.ts"; +import { normalizePath } from "../../core/path.ts"; import { error, info } from "../../deno_ral/log.ts"; @@ -865,7 +866,8 @@ function cleanupNotebook( ) { // Make notebook non-transient when keep-ipynb is set const data = target.data as JupyterTargetData; - const cached = project.fileInformationCache.get(target.source); + const normalizedSource = normalizePath(target.source); + const cached = project.fileInformationCache.get(normalizedSource); if (cached && data.transient && format.execute[kKeepIpynb]) { if (cached.target && cached.target.data) { (cached.target.data as JupyterTargetData).transient = false; diff --git a/src/inspect/inspect.ts b/src/inspect/inspect.ts index 1faae17408a..fd9c02fceaa 100644 --- a/src/inspect/inspect.ts +++ b/src/inspect/inspect.ts @@ -125,6 +125,7 @@ const populateFileInformation = async ( fileInformation: Record, file: string, ) => { + const normalizedFile = normalizePath(file); const engine = await fileExecutionEngine(file, undefined, context); const src = await context.resolveFullMarkdownForFile(engine, file); if (engine) { @@ -139,11 +140,13 @@ const populateFileInformation = async ( } await projectResolveCodeCellsForFile(context, engine, file); await projectFileMetadata(context, file); - fileInformation[file] = { - includeMap: context.fileInformationCache.get(file)?.includeMap ?? - [], - codeCells: context.fileInformationCache.get(file)?.codeCells ?? [], - metadata: context.fileInformationCache.get(file)?.metadata ?? {}, + const cacheEntry = context.fileInformationCache.get(normalizedFile); + // Output key: project-relative for portability + const outputKey = relative(context.dir, normalizedFile); + fileInformation[outputKey] = { + includeMap: cacheEntry?.includeMap ?? [], + codeCells: cacheEntry?.codeCells ?? [], + metadata: cacheEntry?.metadata ?? {}, }; }; @@ -216,7 +219,8 @@ const inspectDocumentConfig = async (path: string) => { await context.resolveFullMarkdownForFile(engine, path); await projectResolveCodeCellsForFile(context, engine, path); await projectFileMetadata(context, path); - const fileInformation = context.fileInformationCache.get(path); + const normalizedPath = normalizePath(path); + const fileInformation = context.fileInformationCache.get(normalizedPath); // data to write const config: InspectedDocumentConfig = { diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index 083484360f4..14168aea881 100644 --- a/src/project/project-shared.ts +++ b/src/project/project-shared.ts @@ -505,6 +505,7 @@ export const ensureFileInformationCache = ( project: ProjectContext, file: string, ) => { + const normalizedFile = normalizePath(file); if (!project.fileInformationCache) { project.fileInformationCache = new Map(); } @@ -512,10 +513,10 @@ export const ensureFileInformationCache = ( project.fileInformationCache instanceof Map, JSON.stringify(project.fileInformationCache), ); - if (!project.fileInformationCache.has(file)) { - project.fileInformationCache.set(file, {} as FileInformation); + if (!project.fileInformationCache.has(normalizedFile)) { + project.fileInformationCache.set(normalizedFile, {} as FileInformation); } - return project.fileInformationCache.get(file)!; + return project.fileInformationCache.get(normalizedFile)!; }; export async function projectResolveBrand( diff --git a/src/project/types/single-file/single-file.ts b/src/project/types/single-file/single-file.ts index 1aa24b6c308..6c1ee7f23fa 100644 --- a/src/project/types/single-file/single-file.ts +++ b/src/project/types/single-file/single-file.ts @@ -45,10 +45,12 @@ export async function singleFileProjectContext( const temp = globalTempContext(); const projectCacheBaseDir = temp.createDir(); + const normalizedDir = normalizePath(dirname(source)); + const result: ProjectContext = { clone: () => result, resolveBrand: (fileName?: string) => projectResolveBrand(result, fileName), - dir: normalizePath(dirname(source)), + dir: normalizedDir, engines: [], files: { input: [], diff --git a/tests/unit/project/file-information-cache.test.ts b/tests/unit/project/file-information-cache.test.ts new file mode 100644 index 00000000000..585074f775d --- /dev/null +++ b/tests/unit/project/file-information-cache.test.ts @@ -0,0 +1,88 @@ +/* + * file-information-cache.test.ts + * + * Tests for fileInformationCache path normalization + * Related to issue #13955 + * + * Copyright (C) 2020-2022 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assert } from "testing/asserts"; +import { join } from "../../../src/deno_ral/path.ts"; +import { ensureFileInformationCache } from "../../../src/project/project-shared.ts"; +import { createMockProjectContext } from "./utils.ts"; + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - same path returns same entry", + async () => { + const project = createMockProjectContext(); + + // Use cross-platform absolute path (backslashes on Windows, forward on Linux) + const path1 = join(project.dir, "doc.qmd"); + const path2 = join(project.dir, "doc.qmd"); + + const entry1 = ensureFileInformationCache(project, path1); + const entry2 = ensureFileInformationCache(project, path2); + + assert( + entry1 === entry2, + "Same path should return same cache entry", + ); + assert( + project.fileInformationCache.size === 1, + "Should have exactly one cache entry", + ); + }, +); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - different paths create different entries", + async () => { + const project = createMockProjectContext(); + + const path1 = join(project.dir, "doc1.qmd"); + const path2 = join(project.dir, "doc2.qmd"); + + const entry1 = ensureFileInformationCache(project, path1); + const entry2 = ensureFileInformationCache(project, path2); + + assert( + entry1 !== entry2, + "Different paths should return different cache entries", + ); + assert( + project.fileInformationCache.size === 2, + "Should have two cache entries for different paths", + ); + }, +); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - cache entry persists across calls", + async () => { + const project = createMockProjectContext(); + + const path = join(project.dir, "doc.qmd"); + + // First call creates entry + const entry1 = ensureFileInformationCache(project, path); + // Modify the entry + entry1.metadata = { title: "Test" }; + + // Second call should return same entry with our modification + const entry2 = ensureFileInformationCache(project, path); + + assert( + entry2.metadata?.title === "Test", + "Cache entry should persist modifications", + ); + assert( + entry1 === entry2, + "Should return same cache entry object", + ); + }, +); diff --git a/tests/unit/project/utils.ts b/tests/unit/project/utils.ts new file mode 100644 index 00000000000..9416621f5be --- /dev/null +++ b/tests/unit/project/utils.ts @@ -0,0 +1,43 @@ +/* + * utils.ts + * + * Test utilities for project-related unit tests + * + * Copyright (C) 2020-2022 Posit Software, PBC + */ + +import { ProjectContext } from "../../../src/project/types.ts"; + +/** + * Create a minimal mock ProjectContext for testing. + * Only provides the essential properties needed for cache-related tests. + * + * @param dir - The project directory (defaults to a temp directory) + * @returns A mock ProjectContext suitable for unit testing + */ +export function createMockProjectContext( + dir?: string, +): ProjectContext { + const projectDir = dir ?? Deno.makeTempDirSync({ prefix: "quarto-test" }); + + return { + dir: projectDir, + engines: [], + files: { input: [] }, + notebookContext: {} as ProjectContext["notebookContext"], + fileInformationCache: new Map(), + resolveBrand: () => Promise.resolve(undefined), + resolveFullMarkdownForFile: () => Promise.resolve({} as never), + fileExecutionEngineAndTarget: () => Promise.resolve({} as never), + fileMetadata: () => Promise.resolve({}), + environment: () => Promise.resolve({} as never), + renderFormats: () => Promise.resolve({}), + clone: function () { + return this; + }, + isSingleFile: false, + diskCache: {} as ProjectContext["diskCache"], + temp: {} as ProjectContext["temp"], + cleanup: () => {}, + } as ProjectContext; +} From d8aa33def1562116851040f73fadc2bbb846325f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 30 Jan 2026 17:49:05 +0100 Subject: [PATCH 08/11] Refactor path normalization into FileInformationCacheMap class Move normalization logic from call sites into the cache class itself. The class now overrides get/has/set/delete to normalize keys automatically, ensuring consistent cache behavior without requiring callers to remember to normalize paths. Also fix ensureFileInformationCache fallback to create FileInformationCacheMap (not plain Map) and add test coverage for this case. Co-Authored-By: Claude Opus 4.5 --- src/command/preview/preview.ts | 3 +-- src/execute/jupyter/jupyter.ts | 4 +-- src/inspect/inspect.ts | 8 +++--- src/project/project-shared.ts | 26 +++++++++++++++---- .../project/file-information-cache.test.ts | 23 +++++++++++++++- tests/unit/project/utils.ts | 14 ++++++++-- 6 files changed, 60 insertions(+), 18 deletions(-) diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index bd24c6c8b31..7b85cafd0e8 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -429,8 +429,7 @@ export async function renderForPreview( // fileInformationCache contains file content that needs to be refreshed. // TODO(#13955): Consider adding a dedicated invalidateForFile() method on ProjectContext if (project?.fileInformationCache) { - const normalizedFile = normalizePath(file); - project.fileInformationCache.delete(normalizedFile); + project.fileInformationCache.delete(file); } // render diff --git a/src/execute/jupyter/jupyter.ts b/src/execute/jupyter/jupyter.ts index e10ec968b75..378515454cb 100644 --- a/src/execute/jupyter/jupyter.ts +++ b/src/execute/jupyter/jupyter.ts @@ -8,7 +8,6 @@ import { basename, dirname, join, relative } from "../../deno_ral/path.ts"; import { satisfies } from "semver/mod.ts"; import { existsSync } from "../../deno_ral/fs.ts"; -import { normalizePath } from "../../core/path.ts"; import { error, info } from "../../deno_ral/log.ts"; @@ -866,8 +865,7 @@ function cleanupNotebook( ) { // Make notebook non-transient when keep-ipynb is set const data = target.data as JupyterTargetData; - const normalizedSource = normalizePath(target.source); - const cached = project.fileInformationCache.get(normalizedSource); + const cached = project.fileInformationCache.get(target.source); if (cached && data.transient && format.execute[kKeepIpynb]) { if (cached.target && cached.target.data) { (cached.target.data as JupyterTargetData).transient = false; diff --git a/src/inspect/inspect.ts b/src/inspect/inspect.ts index fd9c02fceaa..3e98cc443fb 100644 --- a/src/inspect/inspect.ts +++ b/src/inspect/inspect.ts @@ -125,7 +125,6 @@ const populateFileInformation = async ( fileInformation: Record, file: string, ) => { - const normalizedFile = normalizePath(file); const engine = await fileExecutionEngine(file, undefined, context); const src = await context.resolveFullMarkdownForFile(engine, file); if (engine) { @@ -140,9 +139,9 @@ const populateFileInformation = async ( } await projectResolveCodeCellsForFile(context, engine, file); await projectFileMetadata(context, file); - const cacheEntry = context.fileInformationCache.get(normalizedFile); + const cacheEntry = context.fileInformationCache.get(file); // Output key: project-relative for portability - const outputKey = relative(context.dir, normalizedFile); + const outputKey = relative(context.dir, normalizePath(file)); fileInformation[outputKey] = { includeMap: cacheEntry?.includeMap ?? [], codeCells: cacheEntry?.codeCells ?? [], @@ -219,8 +218,7 @@ const inspectDocumentConfig = async (path: string) => { await context.resolveFullMarkdownForFile(engine, path); await projectResolveCodeCellsForFile(context, engine, path); await projectFileMetadata(context, path); - const normalizedPath = normalizePath(path); - const fileInformation = context.fileInformationCache.get(normalizedPath); + const fileInformation = context.fileInformationCache.get(path); // data to write const config: InspectedDocumentConfig = { diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index 14168aea881..0ecdeefc456 100644 --- a/src/project/project-shared.ts +++ b/src/project/project-shared.ts @@ -505,18 +505,17 @@ export const ensureFileInformationCache = ( project: ProjectContext, file: string, ) => { - const normalizedFile = normalizePath(file); if (!project.fileInformationCache) { - project.fileInformationCache = new Map(); + project.fileInformationCache = new FileInformationCacheMap(); } assert( project.fileInformationCache instanceof Map, JSON.stringify(project.fileInformationCache), ); - if (!project.fileInformationCache.has(normalizedFile)) { - project.fileInformationCache.set(normalizedFile, {} as FileInformation); + if (!project.fileInformationCache.has(file)) { + project.fileInformationCache.set(file, {} as FileInformation); } - return project.fileInformationCache.get(normalizedFile)!; + return project.fileInformationCache.get(file)!; }; export async function projectResolveBrand( @@ -657,8 +656,25 @@ export async function projectResolveBrand( } // Create a class that extends Map and implements Cloneable +// All operations normalize keys for cross-platform consistency export class FileInformationCacheMap extends Map implements Cloneable> { + override get(key: string): FileInformation | undefined { + return super.get(normalizePath(key)); + } + + override has(key: string): boolean { + return super.has(normalizePath(key)); + } + + override set(key: string, value: FileInformation): this { + return super.set(normalizePath(key), value); + } + + override delete(key: string): boolean { + return super.delete(normalizePath(key)); + } + clone(): Map { // Return the same instance (reference) instead of creating a clone return this; diff --git a/tests/unit/project/file-information-cache.test.ts b/tests/unit/project/file-information-cache.test.ts index 585074f775d..62eb28ba647 100644 --- a/tests/unit/project/file-information-cache.test.ts +++ b/tests/unit/project/file-information-cache.test.ts @@ -10,7 +10,10 @@ import { unitTest } from "../../test.ts"; import { assert } from "testing/asserts"; import { join } from "../../../src/deno_ral/path.ts"; -import { ensureFileInformationCache } from "../../../src/project/project-shared.ts"; +import { + ensureFileInformationCache, + FileInformationCacheMap, +} from "../../../src/project/project-shared.ts"; import { createMockProjectContext } from "./utils.ts"; // deno-lint-ignore require-await @@ -86,3 +89,21 @@ unitTest( ); }, ); + +// deno-lint-ignore require-await +unitTest( + "ensureFileInformationCache - creates FileInformationCacheMap when cache is missing", + async () => { + const project = createMockProjectContext(); + // Simulate minimal ProjectContext without cache (as in command-utils.ts) + // deno-lint-ignore no-explicit-any + (project as any).fileInformationCache = undefined; + + ensureFileInformationCache(project, join(project.dir, "doc.qmd")); + + assert( + project.fileInformationCache instanceof FileInformationCacheMap, + "Should create FileInformationCacheMap, not plain Map", + ); + }, +); diff --git a/tests/unit/project/utils.ts b/tests/unit/project/utils.ts index 9416621f5be..8fb8b4c641d 100644 --- a/tests/unit/project/utils.ts +++ b/tests/unit/project/utils.ts @@ -7,6 +7,7 @@ */ import { ProjectContext } from "../../../src/project/types.ts"; +import { FileInformationCacheMap } from "../../../src/project/project-shared.ts"; /** * Create a minimal mock ProjectContext for testing. @@ -19,13 +20,14 @@ export function createMockProjectContext( dir?: string, ): ProjectContext { const projectDir = dir ?? Deno.makeTempDirSync({ prefix: "quarto-test" }); + const ownsDir = dir === undefined; return { dir: projectDir, engines: [], files: { input: [] }, notebookContext: {} as ProjectContext["notebookContext"], - fileInformationCache: new Map(), + fileInformationCache: new FileInformationCacheMap(), resolveBrand: () => Promise.resolve(undefined), resolveFullMarkdownForFile: () => Promise.resolve({} as never), fileExecutionEngineAndTarget: () => Promise.resolve({} as never), @@ -38,6 +40,14 @@ export function createMockProjectContext( isSingleFile: false, diskCache: {} as ProjectContext["diskCache"], temp: {} as ProjectContext["temp"], - cleanup: () => {}, + cleanup: () => { + if (ownsDir) { + try { + Deno.removeSync(projectDir, { recursive: true }); + } catch { + // Ignore cleanup errors in tests + } + } + }, } as ProjectContext; } From 69f90b080b15f02e461a1916479930e882b8237b Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 30 Jan 2026 19:51:20 +0100 Subject: [PATCH 09/11] tests - new .gitignore addition from running tests with recent quarto --- tests/docs/smoke-all/2025/05/21/keep_ipynb_project/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/docs/smoke-all/2025/05/21/keep_ipynb_project/.gitignore b/tests/docs/smoke-all/2025/05/21/keep_ipynb_project/.gitignore index 075b2542afb..0e3521a7d0f 100644 --- a/tests/docs/smoke-all/2025/05/21/keep_ipynb_project/.gitignore +++ b/tests/docs/smoke-all/2025/05/21/keep_ipynb_project/.gitignore @@ -1 +1,3 @@ /.quarto/ + +**/*.quarto_ipynb From d846d869b64bb01c96a52f319749ef727603cfdc Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 30 Jan 2026 20:01:24 +0100 Subject: [PATCH 10/11] tests - Correct copyight year --- tests/unit/project/file-information-cache.test.ts | 2 +- tests/unit/project/utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/project/file-information-cache.test.ts b/tests/unit/project/file-information-cache.test.ts index 62eb28ba647..8ef119e22e1 100644 --- a/tests/unit/project/file-information-cache.test.ts +++ b/tests/unit/project/file-information-cache.test.ts @@ -4,7 +4,7 @@ * Tests for fileInformationCache path normalization * Related to issue #13955 * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2026 Posit Software, PBC */ import { unitTest } from "../../test.ts"; diff --git a/tests/unit/project/utils.ts b/tests/unit/project/utils.ts index 8fb8b4c641d..e7b6c375e29 100644 --- a/tests/unit/project/utils.ts +++ b/tests/unit/project/utils.ts @@ -3,7 +3,7 @@ * * Test utilities for project-related unit tests * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2026 Posit Software, PBC */ import { ProjectContext } from "../../../src/project/types.ts"; From 979807e96a31818ba65834fe4af7c09f697bb8f2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 30 Jan 2026 20:25:33 +0100 Subject: [PATCH 11/11] docs - add comments about new FileInformationCacheMap class to explain --- src/project/project-shared.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index 0ecdeefc456..cf121d63d88 100644 --- a/src/project/project-shared.ts +++ b/src/project/project-shared.ts @@ -655,8 +655,10 @@ export async function projectResolveBrand( } } -// Create a class that extends Map and implements Cloneable -// All operations normalize keys for cross-platform consistency +// A Map that normalizes path keys for cross-platform consistency. +// All path operations normalize keys (forward slashes, lowercase on Windows). +// Implements Cloneable but shares state intentionally - in preview mode, +// the project context is reused across renders and cache state must persist. export class FileInformationCacheMap extends Map implements Cloneable> { override get(key: string): FileInformation | undefined { @@ -675,8 +677,14 @@ export class FileInformationCacheMap extends Map return super.delete(normalizePath(key)); } + // Note: Iterator methods (keys(), entries(), forEach(), [Symbol.iterator]) + // return normalized keys as stored. Code iterating over the cache sees + // normalized paths, which is consistent with how keys are stored. + + // Returns this instance (shared reference) rather than a copy. + // This is intentional: in preview mode, project context is cloned for + // each render but the cache must be shared so invalidations persist. clone(): Map { - // Return the same instance (reference) instead of creating a clone return this; } }