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. diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index 279635c2842..7b85cafd0e8 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, @@ -424,6 +424,14 @@ 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) { + project.fileInformationCache.delete(file); + } + // render const renderResult = await render(file, { services, @@ -485,8 +493,6 @@ export async function renderForPreview( [], )); - renderResult.context.cleanup(); - return { file, format: renderResult.files[0].format, diff --git a/src/core/sass/cache.ts b/src/core/sass/cache.ts index ee3d7a80fee..0073b55d1e4 100644 --- a/src/core/sass/cache.ts +++ b/src/core/sass/cache.ts @@ -145,7 +145,9 @@ 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(); if (temp) safeRemoveIfExists(this.path); diff --git a/src/inspect/inspect.ts b/src/inspect/inspect.ts index 1faae17408a..3e98cc443fb 100644 --- a/src/inspect/inspect.ts +++ b/src/inspect/inspect.ts @@ -139,11 +139,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(file); + // Output key: project-relative for portability + const outputKey = relative(context.dir, normalizePath(file)); + fileInformation[outputKey] = { + includeMap: cacheEntry?.includeMap ?? [], + codeCells: cacheEntry?.codeCells ?? [], + metadata: cacheEntry?.metadata ?? {}, }; }; diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index 083484360f4..cf121d63d88 100644 --- a/src/project/project-shared.ts +++ b/src/project/project-shared.ts @@ -506,7 +506,7 @@ export const ensureFileInformationCache = ( file: string, ) => { if (!project.fileInformationCache) { - project.fileInformationCache = new Map(); + project.fileInformationCache = new FileInformationCacheMap(); } assert( project.fileInformationCache instanceof Map, @@ -655,11 +655,36 @@ export async function projectResolveBrand( } } -// Create a class that extends Map and implements Cloneable +// 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 { + 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)); + } + + // 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; } } 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/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 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..8ef119e22e1 --- /dev/null +++ b/tests/unit/project/file-information-cache.test.ts @@ -0,0 +1,109 @@ +/* + * file-information-cache.test.ts + * + * Tests for fileInformationCache path normalization + * Related to issue #13955 + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assert } from "testing/asserts"; +import { join } from "../../../src/deno_ral/path.ts"; +import { + ensureFileInformationCache, + FileInformationCacheMap, +} 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", + ); + }, +); + +// 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 new file mode 100644 index 00000000000..e7b6c375e29 --- /dev/null +++ b/tests/unit/project/utils.ts @@ -0,0 +1,53 @@ +/* + * utils.ts + * + * Test utilities for project-related unit tests + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { ProjectContext } from "../../../src/project/types.ts"; +import { FileInformationCacheMap } from "../../../src/project/project-shared.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" }); + const ownsDir = dir === undefined; + + return { + dir: projectDir, + engines: [], + files: { input: [] }, + notebookContext: {} as ProjectContext["notebookContext"], + fileInformationCache: new FileInformationCacheMap(), + 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: () => { + if (ownsDir) { + try { + Deno.removeSync(projectDir, { recursive: true }); + } catch { + // Ignore cleanup errors in tests + } + } + }, + } as ProjectContext; +}