Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/changelog-1.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions src/command/preview/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion src/project/project-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts";
import {
FileInclusion,
FileInformation,
FileInformationCache,
kProjectOutputDir,
kProjectType,
ProjectConfig,
Expand Down Expand Up @@ -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<string, FileInformation>
implements Cloneable<Map<string, FileInformation>> {
implements FileInformationCache, Cloneable<Map<string, FileInformation>> {
override get(key: string): FileInformation | undefined {
return super.get(normalizePath(key));
}
Expand All @@ -681,6 +682,22 @@ export class FileInformationCacheMap extends Map<string, FileInformation>
// 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.
Expand Down
11 changes: 9 additions & 2 deletions src/project/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export type FileInformation = {
brand?: LightDarkBrandDarkFlag;
};

export interface FileInformationCache extends Map<string, FileInformation> {
// 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<ProjectContext> {
dir: string;
engines: string[];
Expand All @@ -76,7 +83,7 @@ export interface ProjectContext extends Cloneable<ProjectContext> {
notebookContext: NotebookContext;
outputNameIndex?: Map<string, { file: string; format: Format } | undefined>;

fileInformationCache: Map<string, FileInformation>;
fileInformationCache: FileInformationCache;

// This is a cache of _brand.yml for a project
brandCache?: { brand?: LightDarkBrandDarkFlag };
Expand Down Expand Up @@ -182,7 +189,7 @@ export interface EngineProjectContext {
* For file information cache management
* Used for the transient notebook tracking in Jupyter
*/
fileInformationCache: Map<string, FileInformation>;
fileInformationCache: FileInformationCache;

/**
* Get the output directory for the project
Expand Down
77 changes: 77 additions & 0 deletions tests/unit/project/file-information-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
);
},
);
Loading