diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx index bcb4f31f76..f812c44e59 100644 --- a/app/editor/components/MentionMenu.tsx +++ b/app/editor/components/MentionMenu.tsx @@ -67,9 +67,27 @@ function MentionMenu({ search, isActive, ...rest }: Props) { const maxResultsInSection = search ? 25 : 5; // Surface a date suggestion when the search query parses as a natural - // language date (e.g. "tomorrow", "next friday", "jan 2"). - const parsedDate = search ? parseNaturalLanguageDate(search) : null; - const parsedISODate = parsedDate ? toISODate(parsedDate) : undefined; + // language date (e.g. "tomorrow", "next friday", "jan 2"). Parsing is + // asynchronous as chrono-node is loaded lazily, so the result is held in + // state and applied once resolved. + const [parsedISODate, setParsedISODate] = useState(); + + useEffect(() => { + if (!search) { + setParsedISODate(undefined); + return; + } + let cancelled = false; + void parseNaturalLanguageDate(search).then((date) => { + if (!cancelled) { + setParsedISODate(date ? toISODate(date) : undefined); + } + }); + return () => { + cancelled = true; + }; + }, [search]); + const dateItems: MentionItem[] = actorId && parsedISODate ? [ diff --git a/shared/utils/parseNaturalLanguageDate.test.ts b/shared/utils/parseNaturalLanguageDate.test.ts index 669a117779..bf1f3a2869 100644 --- a/shared/utils/parseNaturalLanguageDate.test.ts +++ b/shared/utils/parseNaturalLanguageDate.test.ts @@ -3,42 +3,42 @@ import { parseNaturalLanguageDate } from "./parseNaturalLanguageDate"; describe("parseNaturalLanguageDate", () => { const reference = new Date(2024, 0, 1); // Mon Jan 1, 2024 - it("returns null for empty input", () => { - expect(parseNaturalLanguageDate("", reference)).toBeNull(); - expect(parseNaturalLanguageDate(" ", reference)).toBeNull(); + it("returns null for empty input", async () => { + expect(await parseNaturalLanguageDate("", reference)).toBeNull(); + expect(await parseNaturalLanguageDate(" ", reference)).toBeNull(); }); - it("returns null for non-date input", () => { - expect(parseNaturalLanguageDate("hello world", reference)).toBeNull(); + it("returns null for non-date input", async () => { + expect(await parseNaturalLanguageDate("hello world", reference)).toBeNull(); }); - it("parses 'today'", () => { - const result = parseNaturalLanguageDate("today", reference); + it("parses 'today'", async () => { + const result = await parseNaturalLanguageDate("today", reference); expect(result).toEqual(new Date(2024, 0, 1)); }); - it("parses 'tomorrow'", () => { - const result = parseNaturalLanguageDate("tomorrow", reference); + it("parses 'tomorrow'", async () => { + const result = await parseNaturalLanguageDate("tomorrow", reference); expect(result).toEqual(new Date(2024, 0, 2)); }); - it("parses 'yesterday'", () => { - const result = parseNaturalLanguageDate("yesterday", reference); + it("parses 'yesterday'", async () => { + const result = await parseNaturalLanguageDate("yesterday", reference); expect(result).toEqual(new Date(2023, 11, 31)); }); - it("parses 'in 3 days'", () => { - const result = parseNaturalLanguageDate("in 3 days", reference); + it("parses 'in 3 days'", async () => { + const result = await parseNaturalLanguageDate("in 3 days", reference); expect(result).toEqual(new Date(2024, 0, 4)); }); - it("parses an explicit month and day", () => { - const result = parseNaturalLanguageDate("February 3", reference); + it("parses an explicit month and day", async () => { + const result = await parseNaturalLanguageDate("February 3", reference); expect(result).toEqual(new Date(2024, 1, 3)); }); - it("normalizes the time component to local midnight", () => { - const result = parseNaturalLanguageDate("tomorrow at 5pm", reference); + it("normalizes the time component to local midnight", async () => { + const result = await parseNaturalLanguageDate("tomorrow at 5pm", reference); expect(result?.getHours()).toBe(0); expect(result?.getMinutes()).toBe(0); }); diff --git a/shared/utils/parseNaturalLanguageDate.ts b/shared/utils/parseNaturalLanguageDate.ts index 16eb58fc21..60e8db400b 100644 --- a/shared/utils/parseNaturalLanguageDate.ts +++ b/shared/utils/parseNaturalLanguageDate.ts @@ -1,4 +1,21 @@ -import * as chrono from "chrono-node"; +// Type-only import is fully erased at compile time, so it does not pull +// chrono-node into the bundle. +import type * as Chrono from "chrono-node"; + +/** + * chrono-node is a sizeable dependency, so it is loaded lazily on first use + * via a dynamic import. The bundler splits it into its own chunk that is only + * fetched when a date actually needs to be parsed (i.e. when the user types in + * the mention menu), keeping it out of the main bundle. + */ +let chronoPromise: Promise | undefined; + +function loadChrono(): Promise { + if (!chronoPromise) { + chronoPromise = import("chrono-node"); + } + return chronoPromise; +} /** * Parse a natural language string such as "tomorrow", "next friday", @@ -6,23 +23,25 @@ import * as chrono from "chrono-node"; * * The time component is intentionally discarded as date mentions are * day-granular; only the year, month and day of the matched date are - * returned. + * returned. chrono-node is loaded asynchronously the first time this is + * called. * * @param input the natural language string to parse. * @param referenceDate the date relative to which terms like "tomorrow" * are resolved, defaults to now. - * @returns the matched date with the time set to local midnight, or null - * when no date could be confidently parsed. + * @returns a promise resolving to the matched date with the time set to + * local midnight, or null when no date could be confidently parsed. */ -export function parseNaturalLanguageDate( +export async function parseNaturalLanguageDate( input: string, referenceDate: Date = new Date() -): Date | null { +): Promise { const trimmed = input.trim(); if (!trimmed) { return null; } + const chrono = await loadChrono(); const results = chrono.parse(trimmed, referenceDate, { forwardDate: true }); const result = results[0]; if (!result) {