perf: avoid duplicated git log calls in loadContent() and postBuild() for untracked Git files (#11211)

Co-authored-by: slorber <749374+slorber@users.noreply.github.com>
This commit is contained in:
Sébastien Lorber 2025-05-28 14:03:10 +02:00 committed by GitHub
parent 68aa3c876b
commit 264774a550
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 160 additions and 21 deletions

View file

@ -521,9 +521,9 @@ declare module '@docusaurus/plugin-content-blog' {
readingTime: ReadingTimeFunctionOption; readingTime: ReadingTimeFunctionOption;
/** Governs the direction of blog post sorting. */ /** Governs the direction of blog post sorting. */
sortPosts: 'ascending' | 'descending'; sortPosts: 'ascending' | 'descending';
/** Whether to display the last date the doc was updated. */ /** Whether to display the last date the blog post was updated. */
showLastUpdateTime: boolean; showLastUpdateTime: boolean;
/** Whether to display the author who last updated the doc. */ /** Whether to display the author who last updated the blog post. */
showLastUpdateAuthor: boolean; showLastUpdateAuthor: boolean;
/** An optional function which can be used to transform blog posts /** An optional function which can be used to transform blog posts
* (filter, modify, delete, etc...). * (filter, modify, delete, etc...).

View file

@ -225,5 +225,66 @@ describe('createSitemapItem', () => {
`); `);
}); });
}); });
describe('read from both - route metadata lastUpdatedAt null', () => {
const route = {
path: '/routePath',
metadata: {
sourceFilePath: 'route/file.md',
lastUpdatedAt: null,
},
};
it('lastmod default option', async () => {
await expect(
test({
route,
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": null,
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
it('lastmod date option', async () => {
await expect(
test({
route,
options: {
lastmod: 'date',
},
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": null,
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
it('lastmod datetime option', async () => {
await expect(
test({
route,
options: {
lastmod: 'datetime',
},
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": null,
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
});
}); });
}); });

View file

@ -13,13 +13,19 @@ import type {PluginOptions} from './options';
async function getRouteLastUpdatedAt( async function getRouteLastUpdatedAt(
route: RouteConfig, route: RouteConfig,
): Promise<number | undefined> { ): Promise<number | null | undefined> {
// Important to bail-out early here
// This can lead to duplicated getLastUpdate() calls and performance problems
// See https://github.com/facebook/docusaurus/pull/11211
if (route.metadata?.lastUpdatedAt === null) {
return null;
}
if (route.metadata?.lastUpdatedAt) { if (route.metadata?.lastUpdatedAt) {
return route.metadata?.lastUpdatedAt; return route.metadata?.lastUpdatedAt;
} }
if (route.metadata?.sourceFilePath) { if (route.metadata?.sourceFilePath) {
const lastUpdate = await getLastUpdate(route.metadata?.sourceFilePath); const lastUpdate = await getLastUpdate(route.metadata?.sourceFilePath);
return lastUpdate?.lastUpdatedAt; return lastUpdate?.lastUpdatedAt ?? null;
} }
return undefined; return undefined;

View file

@ -852,8 +852,8 @@ declare module '@theme/EditMetaRow' {
export interface Props { export interface Props {
readonly className: string; readonly className: string;
readonly editUrl: string | null | undefined; readonly editUrl: string | null | undefined;
readonly lastUpdatedAt: number | undefined; readonly lastUpdatedAt: number | null | undefined;
readonly lastUpdatedBy: string | undefined; readonly lastUpdatedBy: string | null | undefined;
} }
export default function EditMetaRow(props: Props): ReactNode; export default function EditMetaRow(props: Props): ReactNode;
} }
@ -1024,8 +1024,8 @@ declare module '@theme/LastUpdated' {
import type {ReactNode} from 'react'; import type {ReactNode} from 'react';
export interface Props { export interface Props {
readonly lastUpdatedAt?: number; readonly lastUpdatedAt?: number | null;
readonly lastUpdatedBy?: string; readonly lastUpdatedBy?: string | null;
} }
export default function LastUpdated(props: Props): ReactNode; export default function LastUpdated(props: Props): ReactNode;

View file

@ -56,12 +56,19 @@ export type RouteMetadata = {
/** /**
* The last updated date of this route * The last updated date of this route
* This is generally read from the Git history of the sourceFilePath * This is generally read from the Git history of the sourceFilePath
* but can also be provided through other means (usually front matter) * but can also be provided through other means (usually front matter).
* *
* This has notably been introduced for adding "lastmod" support to the * This has notably been introduced for adding "lastmod" support to the
* sitemap plugin, see https://github.com/facebook/docusaurus/pull/9954 * sitemap plugin, see https://github.com/facebook/docusaurus/pull/9954
*
* `undefined` means we haven't tried to compute the value for this route.
* This is usually the case for routes created by third-party plugins that do
* not need this metadata.
*
* `null` means we already tried to compute a lastUpdatedAt, but we know for
* sure there isn't any. This usually happens for untracked Git files.
*/ */
lastUpdatedAt?: number; lastUpdatedAt?: number | null;
}; };
/** /**

View file

@ -14,8 +14,10 @@ import execa from 'execa';
import { import {
getGitLastUpdate, getGitLastUpdate,
LAST_UPDATE_FALLBACK, LAST_UPDATE_FALLBACK,
LAST_UPDATE_UNTRACKED_GIT_FILEPATH,
readLastUpdateData, readLastUpdateData,
} from '../lastUpdateUtils'; } from '../lastUpdateUtils';
import type {FrontMatterLastUpdate} from '../lastUpdateUtils';
describe('getGitLastUpdate', () => { describe('getGitLastUpdate', () => {
const {repoDir} = createTempRepo(); const {repoDir} = createTempRepo();
@ -109,6 +111,34 @@ describe('readLastUpdateData', () => {
const testTimestamp = new Date(testDate).getTime(); const testTimestamp = new Date(testDate).getTime();
const testAuthor = 'ozaki'; const testAuthor = 'ozaki';
describe('on untracked Git file', () => {
function test(lastUpdateFrontMatter: FrontMatterLastUpdate | undefined) {
return readLastUpdateData(
LAST_UPDATE_UNTRACKED_GIT_FILEPATH,
{showLastUpdateAuthor: true, showLastUpdateTime: true},
lastUpdateFrontMatter,
);
}
it('reads null at/by from Git', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await test({});
expect(lastUpdatedAt).toBeNull();
expect(lastUpdatedBy).toBeNull();
});
it('reads null at from Git and author from front matter', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await test({author: testAuthor});
expect(lastUpdatedAt).toBeNull();
expect(lastUpdatedBy).toEqual(testAuthor);
});
it('reads null by from Git and date from front matter', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await test({date: testDate});
expect(lastUpdatedBy).toBeNull();
expect(lastUpdatedAt).toEqual(testTimestamp);
});
});
it('read last time show author time', async () => { it('read last time show author time', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData( const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData(
'', '',

View file

@ -154,12 +154,12 @@ export async function getFileCommitDate(
file, file,
)}"`; )}"`;
const result = (await GitCommandQueue.add(() => const result = (await GitCommandQueue.add(() => {
execa(command, { return execa(command, {
cwd: path.dirname(file), cwd: path.dirname(file),
shell: true, shell: true,
}), });
))!; }))!;
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Error( throw new Error(

View file

@ -15,10 +15,18 @@ import {
import type {PluginOptions} from '@docusaurus/types'; import type {PluginOptions} from '@docusaurus/types';
export type LastUpdateData = { export type LastUpdateData = {
/** A timestamp in **milliseconds**, usually read from `git log` */ /**
lastUpdatedAt?: number; * A timestamp in **milliseconds**, usually read from `git log`
/** The author's name, usually coming from `git log` */ * `undefined`: not read
lastUpdatedBy?: string; * `null`: no value to read (usual for untracked files)
*/
lastUpdatedAt: number | undefined | null;
/**
* The author's name, usually coming from `git log`
* `undefined`: not read
* `null`: no value to read (usual for untracked files)
*/
lastUpdatedBy: string | undefined | null;
}; };
let showedGitRequirementError = false; let showedGitRequirementError = false;
@ -68,9 +76,15 @@ export const LAST_UPDATE_FALLBACK: LastUpdateData = {
lastUpdatedBy: 'Author', lastUpdatedBy: 'Author',
}; };
// Not proud of this, but convenient for tests :/
export const LAST_UPDATE_UNTRACKED_GIT_FILEPATH = `file/path/${Math.random()}.mdx`;
export async function getLastUpdate( export async function getLastUpdate(
filePath: string, filePath: string,
): Promise<LastUpdateData | null> { ): Promise<LastUpdateData | null> {
if (filePath === LAST_UPDATE_UNTRACKED_GIT_FILEPATH) {
return null;
}
if ( if (
process.env.NODE_ENV !== 'production' || process.env.NODE_ENV !== 'production' ||
process.env.DOCUSAURUS_DISABLE_LAST_UPDATE === 'true' process.env.DOCUSAURUS_DISABLE_LAST_UPDATE === 'true'
@ -103,7 +117,7 @@ export async function readLastUpdateData(
const {showLastUpdateAuthor, showLastUpdateTime} = options; const {showLastUpdateAuthor, showLastUpdateTime} = options;
if (!showLastUpdateAuthor && !showLastUpdateTime) { if (!showLastUpdateAuthor && !showLastUpdateTime) {
return {}; return {lastUpdatedBy: undefined, lastUpdatedAt: undefined};
} }
const frontMatterAuthor = lastUpdateFrontMatter?.author; const frontMatterAuthor = lastUpdateFrontMatter?.author;
@ -116,9 +130,21 @@ export async function readLastUpdateData(
// If all the data is provided as front matter, we do not call it // If all the data is provided as front matter, we do not call it
const getLastUpdateMemoized = _.memoize(() => getLastUpdate(filePath)); const getLastUpdateMemoized = _.memoize(() => getLastUpdate(filePath));
const getLastUpdateBy = () => const getLastUpdateBy = () =>
getLastUpdateMemoized().then((update) => update?.lastUpdatedBy); getLastUpdateMemoized().then((update) => {
// Important, see https://github.com/facebook/docusaurus/pull/11211
if (update === null) {
return null;
}
return update?.lastUpdatedBy;
});
const getLastUpdateAt = () => const getLastUpdateAt = () =>
getLastUpdateMemoized().then((update) => update?.lastUpdatedAt); getLastUpdateMemoized().then((update) => {
// Important, see https://github.com/facebook/docusaurus/pull/11211
if (update === null) {
return null;
}
return update?.lastUpdatedAt;
});
const lastUpdatedBy = showLastUpdateAuthor const lastUpdatedBy = showLastUpdateAuthor
? frontMatterAuthor ?? (await getLastUpdateBy()) ? frontMatterAuthor ?? (await getLastUpdateBy())

View file

@ -35,6 +35,7 @@ export type BuildLocaleParams = {
}; };
const SkipBundling = process.env.DOCUSAURUS_SKIP_BUNDLING === 'true'; const SkipBundling = process.env.DOCUSAURUS_SKIP_BUNDLING === 'true';
const ExitAfterLoading = process.env.DOCUSAURUS_EXIT_AFTER_LOADING === 'true';
const ExitAfterBundling = process.env.DOCUSAURUS_EXIT_AFTER_BUNDLING === 'true'; const ExitAfterBundling = process.env.DOCUSAURUS_EXIT_AFTER_BUNDLING === 'true';
export async function buildLocale({ export async function buildLocale({
@ -59,6 +60,10 @@ export async function buildLocale({
}), }),
); );
if (ExitAfterLoading) {
return process.exit(0);
}
const {props} = site; const {props} = site;
const {outDir, plugins, siteConfig} = props; const {outDir, plugins, siteConfig} = props;

View file

@ -316,6 +316,10 @@ export default async function createConfigAsync() {
'./src/plugins/changelog/index.ts', './src/plugins/changelog/index.ts',
{ {
blogTitle: 'Docusaurus changelog', blogTitle: 'Docusaurus changelog',
// Not useful, but permits to run git commands earlier
// Otherwise the sitemap plugin will run them in postBuild()
showLastUpdateAuthor: true,
showLastUpdateTime: true,
blogDescription: blogDescription:
'Keep yourself up-to-date about new features in every release', 'Keep yourself up-to-date about new features in every release',
blogSidebarCount: 'ALL', blogSidebarCount: 'ALL',