From 2657202a509323095041f8cc5a11b156fc0e71a1 Mon Sep 17 00:00:00 2001 From: lllleolin-max <244385774+lllleolin-max@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:44:45 +0800 Subject: [PATCH] fix(node): invalidate content loader cache for included files --- __tests__/e2e/data-loading/data.test.ts | 44 +++++++++- __tests__/unit/node/contentLoader.test.ts | 101 +++++++++++++++++++++- src/node/contentLoader.ts | 45 +++++++++- 3 files changed, 185 insertions(+), 5 deletions(-) diff --git a/__tests__/e2e/data-loading/data.test.ts b/__tests__/e2e/data-loading/data.test.ts index 4a4e87a80..d896dd908 100644 --- a/__tests__/e2e/data-loading/data.test.ts +++ b/__tests__/e2e/data-loading/data.test.ts @@ -1,4 +1,4 @@ -import { writeFile, unlink } from 'node:fs/promises' +import { readFile, writeFile, unlink } from 'node:fs/promises' import { fileURLToPath } from 'node:url' describe('static data file support in vite 3', () => { @@ -86,6 +86,48 @@ describe('static data file support in vite 3', () => { } }) + test.runIf(!process.env.VITE_TEST_BUILD)( + 'hmr updates content rendered from included files', + async () => { + const foo = fileURLToPath(new URL('./content/foo.md', import.meta.url)) + const bar = fileURLToPath(new URL('./content/bar.md', import.meta.url)) + const originalFoo = await readFile(foo, 'utf8') + const originalBar = await readFile(bar, 'utf8') + + try { + await writeFile( + foo, + originalFoo.replace('Hello', '') + ) + await page.waitForFunction(() => { + const data = JSON.parse( + document.querySelector('pre#content')!.textContent! + ) + return data.some( + (item: { url: string; src: string }) => + item.url.endsWith('/foo.html') && item.src.includes('@include:') + ) + }) + + await writeFile(bar, originalBar.replace('Hello', 'Updated include')) + await page.waitForFunction(() => { + const data = JSON.parse( + document.querySelector('pre#content')!.textContent! + ) + return data.some( + (item: { url: string; html: string; excerpt: string }) => + item.url.endsWith('/foo.html') && + item.html.includes('Updated include') && + item.excerpt.includes('Updated include') + ) + }) + } finally { + await writeFile(bar, originalBar) + await writeFile(foo, originalFoo) + } + } + ) + /* MODIFY a.json with { a: false } this should trigger a hmr update and the content should be updated to [{ a: false }, { b: true }] diff --git a/__tests__/unit/node/contentLoader.test.ts b/__tests__/unit/node/contentLoader.test.ts index d82982a5b..6ae221f7d 100644 --- a/__tests__/unit/node/contentLoader.test.ts +++ b/__tests__/unit/node/contentLoader.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' @@ -82,6 +82,105 @@ describe('node/contentLoader', () => { expect(data[0].excerpt).toContain('Intro says My Post.') }) + test.each([ + ['nested include', ''], + ['code snippet', '<<< ./included.txt'] + ])( + 'refreshes cached HTML and excerpts after a %s changes', + async (_, src) => { + await setup(false) + const included = path.join(root!, 'included.txt') + await writeFile(included, 'Before the edit') + await writeFile( + path.join(root!, 'partial.md'), + '' + ) + await writeFile(path.join(root!, 'post.md'), `${src}\n\n---\n\nBody`) + const loader = createContentLoader(['post.md', 'other.md'], { + render: true, + excerpt: true + }) + const initial = await loader.load() + const initialPost = initial.find((page) => page.url === '/post.html')! + const initialOther = initial.find((page) => page.url === '/other.html')! + expect(initialPost.html).toContain('Before the edit') + expect(initialPost.excerpt).toContain('Before the edit') + + const unchanged = await loader.load() + expect(unchanged.find((page) => page.url === '/post.html')).toBe( + initialPost + ) + + // Change only the dependency, keeping the page's source and mtime intact. + const timestamp = (await stat(included)).mtimeMs + 1000 + await writeFile(included, 'After the edit') + await utimes(included, timestamp / 1000, timestamp / 1000) + + const updated = await loader.load() + const updatedPost = updated.find((page) => page.url === '/post.html')! + expect(updatedPost.html).toContain('After the edit') + expect(updatedPost.excerpt).toContain('After the edit') + expect(updatedPost.html).not.toContain('Before the edit') + expect(updated.find((page) => page.url === '/other.html')).toBe( + initialOther + ) + } + ) + + test('tracks dependencies rendered only by a custom excerpt', async () => { + await setup(false) + const included = path.join(root!, 'included.txt') + await writeFile(included, 'Before the edit') + const loader = createContentLoader('index.md', { + excerpt(file) { + file.excerpt = '' + } + }) + const initial = await loader.load() + expect(initial[0].html).toBeUndefined() + expect(initial[0].excerpt).toContain('Before the edit') + + const timestamp = (await stat(included)).mtimeMs + 1000 + await writeFile(included, 'After the edit') + await utimes(included, timestamp / 1000, timestamp / 1000) + + expect((await loader.load())[0].excerpt).toContain('After the edit') + }) + + test('reports deleted dependencies instead of returning cached HTML', async () => { + await setup(false) + await writeFile( + path.join(root!, 'post.md'), + '' + ) + const loader = createContentLoader('post.md', { render: true }) + expect((await loader.load())[0].html).toContain('Other') + + await rm(path.join(root!, 'other.md')) + + await expect(loader.load()).rejects.toThrow('Include file not found:') + }) + + test('refreshes silent includes when a missing file is created or deleted', async () => { + await setup(false) + const config = (global as any).VITEPRESS_CONFIG + config.markdown = { include: { silent: true } } + config.logger.warn = vi.fn() + await writeFile( + path.join(root!, 'post.md'), + '' + ) + const loader = createContentLoader('post.md', { render: true }) + expect((await loader.load())[0].html).not.toContain('Created content') + + const included = path.join(root!, 'missing.md') + await writeFile(included, 'Created content') + expect((await loader.load())[0].html).toContain('Created content') + + await rm(included) + expect((await loader.load())[0].html).not.toContain('Created content') + }) + test.each([false, true])( 'uses the rewritten locale for rendered content and excerpts (render: %s)', async (render) => { diff --git a/src/node/contentLoader.ts b/src/node/contentLoader.ts index 5fc4f6219..c97c79b19 100644 --- a/src/node/contentLoader.ts +++ b/src/node/contentLoader.ts @@ -91,7 +91,14 @@ export function createContentLoader( ) } - const cache = new Map() + const cache = new Map< + string, + { + data: ContentData + timestamp: number + dependencies: Map + } + >() watch = normalizeGlob(watch, config.srcDir) @@ -122,7 +129,20 @@ export function createContentLoader( const timestamp = (await stat(file)).mtimeMs const cached = cache.get(file) - if (cached && timestamp === cached.timestamp) return cached.data + if ( + cached && + timestamp === cached.timestamp && + ( + await Promise.all( + [...cached.dependencies].map( + async ([file, timestamp]) => + (await getDependencyTimestamp(file)) === timestamp + ) + ) + ).every(Boolean) + ) { + return cached.data + } const src = await readTextFile(file) @@ -172,7 +192,15 @@ export function createContentLoader( url } - cache.set(file, { data, timestamp }) + // Both the full render and the excerpt can import other files. + const dependencies = new Map( + await Promise.all( + [...new Set(env.includes)].map(async (file) => { + return [file, await getDependencyTimestamp(file)] as const + }) + ) + ) + cache.set(file, { data, timestamp, dependencies }) return data }, { concurrency: config.buildConcurrency } @@ -183,3 +211,14 @@ export function createContentLoader( } } } + +async function getDependencyTimestamp(file: string) { + try { + return (await stat(file)).mtimeMs + } catch (error) { + // Silent includes can refer to missing files. Their creation must also + // invalidate the cache, while deleted files need to be rendered again. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + } +}