From 366919e0c0a592e89bda3ff168ffbf6b554841be Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 30 Mar 2026 16:06:00 +0200 Subject: [PATCH 1/2] Fix transient .quarto_ipynb files accumulating during preview (#14281) Preview re-renders delete the fileInformationCache entry to pick up file changes, but this loses track of the transient .quarto_ipynb path. The collision-avoidance loop in jupyter.ts target() then sees the old file on disk and creates numbered variants (_1, _2, etc.) that are never cleaned up until preview exits. Add invalidateForFile() to FileInformationCacheMap that cleans up any transient notebook file from disk before removing the cache entry. Replace the bare delete() call in renderForPreview() with this method. Fixes #14281 --- src/command/preview/preview.ts | 5 +- src/project/project-shared.ts | 19 ++++- src/project/types.ts | 11 ++- .../project/file-information-cache.test.ts | 77 +++++++++++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index 7b85cafd0e8..edb2ac0b75c 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -427,9 +427,10 @@ export async function renderForPreview( // 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 + // Uses invalidateForFile() to also clean up transient notebook files + // (.quarto_ipynb) from disk before removing the cache entry (#14281). if (project?.fileInformationCache) { - project.fileInformationCache.delete(file); + project.fileInformationCache.invalidateForFile(file); } // render diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index cf121d63d88..d9daaf0803c 100644 --- a/src/project/project-shared.ts +++ b/src/project/project-shared.ts @@ -22,6 +22,7 @@ import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts"; import { FileInclusion, FileInformation, + FileInformationCache, kProjectOutputDir, kProjectType, ProjectConfig, @@ -660,7 +661,7 @@ export async function projectResolveBrand( // 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> { + implements FileInformationCache, Cloneable> { override get(key: string): FileInformation | undefined { return super.get(normalizePath(key)); } @@ -681,6 +682,22 @@ export class FileInformationCacheMap extends Map // return normalized keys as stored. Code iterating over the cache sees // normalized paths, which is consistent with how keys are stored. + // Removes a cache entry and cleans up any associated transient files. + // In preview mode, this should be used instead of delete() to ensure + // transient notebooks (.quarto_ipynb) are removed from disk before the + // cache entry is dropped. Without this, the collision-avoidance logic + // in jupyter.ts target() creates numbered variants on each re-render. + invalidateForFile(key: string): void { + const existing = this.get(key); + if (existing?.target?.data) { + const data = existing.target.data as { transient?: boolean }; + if (data.transient && existing.target.input) { + safeRemoveSync(existing.target.input); + } + } + this.delete(key); + } + // 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. diff --git a/src/project/types.ts b/src/project/types.ts index 2c4948dc182..d36f04f2282 100644 --- a/src/project/types.ts +++ b/src/project/types.ts @@ -68,6 +68,13 @@ export type FileInformation = { brand?: LightDarkBrandDarkFlag; }; +export interface FileInformationCache extends Map { + // Removes a cache entry and cleans up any associated transient files from disk. + // Use this instead of delete() when invalidating entries that may reference + // transient notebooks (.quarto_ipynb) to prevent file accumulation. + invalidateForFile(key: string): void; +} + export interface ProjectContext extends Cloneable { dir: string; engines: string[]; @@ -76,7 +83,7 @@ export interface ProjectContext extends Cloneable { notebookContext: NotebookContext; outputNameIndex?: Map; - fileInformationCache: Map; + fileInformationCache: FileInformationCache; // This is a cache of _brand.yml for a project brandCache?: { brand?: LightDarkBrandDarkFlag }; @@ -182,7 +189,7 @@ export interface EngineProjectContext { * For file information cache management * Used for the transient notebook tracking in Jupyter */ - fileInformationCache: Map; + fileInformationCache: FileInformationCache; /** * Get the output directory for the project diff --git a/tests/unit/project/file-information-cache.test.ts b/tests/unit/project/file-information-cache.test.ts index 9f552e9c96b..666a0d2915b 100644 --- a/tests/unit/project/file-information-cache.test.ts +++ b/tests/unit/project/file-information-cache.test.ts @@ -9,6 +9,8 @@ import { unitTest } from "../../test.ts"; import { assert } from "testing/asserts"; +import { asMappedString } from "../../../src/core/lib/mapped-text.ts"; +import { existsSync } from "../../../src/deno_ral/fs.ts"; import { join, relative } from "../../../src/deno_ral/path.ts"; import { ensureFileInformationCache, @@ -130,3 +132,78 @@ unitTest( ); }, ); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - invalidateForFile deletes transient notebook file", + async () => { + const project = createMockProjectContext(); + const sourcePath = join(project.dir, "doc.qmd"); + + // Create a real temp file simulating a transient .quarto_ipynb + const notebookPath = join(project.dir, "doc.quarto_ipynb"); + Deno.writeTextFileSync(notebookPath, '{"cells": []}'); + assert(existsSync(notebookPath), "Temp notebook file should exist"); + + // Populate cache entry with a transient target pointing to the file + const entry = ensureFileInformationCache(project, sourcePath); + entry.target = { + source: sourcePath, + input: notebookPath, + markdown: asMappedString(""), + metadata: {}, + data: { transient: true, kernelspec: {} }, + }; + + // Invalidate the cache entry for this file + project.fileInformationCache.invalidateForFile(sourcePath); + + // The transient file should be deleted from disk + assert( + !existsSync(notebookPath), + "Transient notebook file should be deleted on invalidation", + ); + // The cache entry should be removed + assert( + !project.fileInformationCache.has(sourcePath), + "Cache entry should be removed after invalidation", + ); + }, +); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - invalidateForFile preserves non-transient files", + async () => { + const project = createMockProjectContext(); + const sourcePath = join(project.dir, "notebook.ipynb"); + + // Create a real file simulating a user's .ipynb (non-transient) + const notebookPath = join(project.dir, "notebook.ipynb"); + Deno.writeTextFileSync(notebookPath, '{"cells": []}'); + + // Populate cache entry with a non-transient target + const entry = ensureFileInformationCache(project, sourcePath); + entry.target = { + source: sourcePath, + input: notebookPath, + markdown: asMappedString(""), + metadata: {}, + data: { transient: false, kernelspec: {} }, + }; + + // Invalidate the cache entry + project.fileInformationCache.invalidateForFile(sourcePath); + + // The non-transient file should NOT be deleted + assert( + existsSync(notebookPath), + "Non-transient file should be preserved on invalidation", + ); + // But the cache entry should still be removed + assert( + !project.fileInformationCache.has(sourcePath), + "Cache entry should be removed after invalidation", + ); + }, +); From e94451f4aa642f29efbb2ea9d700ea3032288080 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 31 Mar 2026 12:37:43 +0200 Subject: [PATCH 2/2] docs: add changelog entry for #14281 backport --- news/changelog-1.9.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 2e4216d74ac..46c821a13fc 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -3,6 +3,7 @@ ## In this release - ([#14267](https://github.com/quarto-dev/quarto-cli/issues/14267)): Fix Windows paths with accented characters (e.g., `C:\Users\Sébastien\`) breaking dart-sass compilation. +- ([#14281](https://github.com/quarto-dev/quarto-cli/issues/14281)): Fix transient `.quarto_ipynb` files accumulating during `quarto preview` with Jupyter engine. ## In previous releases