From 72609b51bdeaee870bf3fa6d6efc03bef44673d1 Mon Sep 17 00:00:00 2001 From: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> Date: Tue, 12 May 2026 16:18:13 +0100 Subject: [PATCH] perf: Add formatted dates cache to avoid repeated date formatting calls Signed-off-by: Eamonn Mansour <47121388+eamansour@users.noreply.github.com> --- .../test-runs/results/TestRunsTable.tsx | 41 ++++---- .../src/contexts/DateTimeFormatContext.tsx | 47 ++++++---- .../contexts/DateTimeFormatContext.test.tsx | 93 +++++++++++++++++++ 3 files changed, 146 insertions(+), 35 deletions(-) diff --git a/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx b/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx index c825bbd9..57266e6f 100644 --- a/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx +++ b/galasa-ui/src/components/test-runs/results/TestRunsTable.tsx @@ -93,6 +93,19 @@ export default function TestRunsTable({ const currentVisibleColumns = visibleColumns.join(','); + // Memoize formatted dates for all runs to avoid repeated formatting + const formattedDatesCache = useMemo(() => { + const cache = new Map(); + if (runsList && runsList.length > 0) { + runsList.forEach((row) => { + if (row.submittedAt && !cache.has(row.submittedAt)) { + cache.set(row.submittedAt, formatDate(new Date(row.submittedAt))); + } + }); + } + return cache; + }, [runsList, formatDate]); + // Filter rows in the current paginatedRows if the text in the // persistent toolbar search box changes and matches any table info. const filteredRows = useMemo(() => { @@ -110,14 +123,10 @@ export default function TestRunsTable({ return false; } let value = ''; - // Reimplement in future - formatDate causes performance issues... - // if (field === 'submittedAt') { - // value = - // row.submittedAt?.trim() !== '' - // ? formatDate(new Date(row.submittedAt.toLowerCase())) - // : ''; - // } - if (field === 'tags') { + if (field === 'submittedAt') { + value = + row.submittedAt?.trim() !== '' ? formattedDatesCache.get(row.submittedAt) || '' : ''; + } else if (field === 'tags') { value = row.tags?.trim() !== '' ? row.tags.toLowerCase() : 'n/a'; } else { value = row[field]?.toLowerCase() ?? ''; @@ -125,7 +134,7 @@ export default function TestRunsTable({ return value.includes(searchLowerCase); }); }); - }, [currentVisibleColumns, runsList, search]); + }, [currentVisibleColumns, runsList, search, formattedDatesCache]); // Calculate the paginated rows based on the current page and page size, // and currently filtered rows (if there is a filter in the toolbar) @@ -219,7 +228,7 @@ export default function TestRunsTable({ }; // Navigate to the test run details page using the runId - const handleRowClick = (runId: string, runName: string) => { + const handleRowClick = (runId: string) => { // Navigate to the test run details page router.push(`/test-runs/${runId}`); }; @@ -262,10 +271,10 @@ export default function TestRunsTable({ ); } else if (header === 'submittedAt') { - // Format the date using the context's formatDate function + const formattedDateValue = formattedDatesCache.get(value) || formatDate(new Date(value)); cellComponent = ( - {formatDate(new Date(value))} + {formattedDateValue} ); @@ -356,13 +365,7 @@ export default function TestRunsTable({ - handleRowClick( - row.id, - row.cells.find((cell) => cell.info.header === 'testRunName') - ?.value as string - ) - } + onClick={() => handleRowClick(row.id)} id={styles.clickableRow} > {row.cells.map((cell) => ( diff --git a/galasa-ui/src/contexts/DateTimeFormatContext.tsx b/galasa-ui/src/contexts/DateTimeFormatContext.tsx index 456e2366..9720bf2b 100644 --- a/galasa-ui/src/contexts/DateTimeFormatContext.tsx +++ b/galasa-ui/src/contexts/DateTimeFormatContext.tsx @@ -13,7 +13,7 @@ import { TimeZone, TimeZoneFormats, } from '@/utils/types/dateTimeSettings'; -import { useCallback, useState, createContext, useContext } from 'react'; +import { useCallback, useState, createContext, useContext, useRef } from 'react'; const LOCAL_STORAGE_KEY = 'dateTimeFormatSettings'; @@ -67,6 +67,9 @@ export function DateTimeFormatProvider({ children }: { children: React.ReactNode return currentPreferences; }); + // Cache for Intl.DateTimeFormat instances to avoid expensive re-creation + const formattersCache = useRef>(new Map()); + const updatePreferences = (newPreferences: Partial) => { const updatedPreferences = { ...preferences, ...newPreferences }; @@ -82,6 +85,9 @@ export function DateTimeFormatProvider({ children }: { children: React.ReactNode updatedPreferences[PREFERENCE_KEYS.TIME_ZONE] = defaultPreferences[PREFERENCE_KEYS.TIME_ZONE]; } + // Clear formatters cache when preferences change + formattersCache.current.clear(); + setPreferences(updatedPreferences); localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updatedPreferences)); }; @@ -97,6 +103,20 @@ export function DateTimeFormatProvider({ children }: { children: React.ReactNode return specifiedTimeZone; }, [preferences.timeZoneType, preferences.timeZone]); + // Helper function to get or create a cached formatter + const getOrCreateFormatter = useCallback( + (locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat => { + const cacheKey = JSON.stringify({ locale, options }); + + if (!formattersCache.current.has(cacheKey)) { + formattersCache.current.set(cacheKey, new Intl.DateTimeFormat(locale, options)); + } + + return formattersCache.current.get(cacheKey)!; + }, + [] + ); + const formatDate = useCallback( (date: Date): string => { let formattedDate: string = '-'; @@ -120,24 +140,19 @@ export function DateTimeFormatProvider({ children }: { children: React.ReactNode timeZone: resolvedTimeZone, }; - // Define options specifically to extract the timezone name - const timeZoneNameOptions: Intl.DateTimeFormatOptions = { - timeZone: resolvedTimeZone, - timeZoneName: 'short', // e.g., PST, EDT, EEST - }; - // Determine the locale to use const effectiveLocale = dateTimeFormatType === 'browser' ? undefined : locale; - // Format the two parts separately - const mainPart = new Intl.DateTimeFormat(effectiveLocale, dateTimeOptions).format(date); + const mainFormatter = getOrCreateFormatter(effectiveLocale, dateTimeOptions); + const mainPart = mainFormatter.format(date); - // Get the full string with timezone - const fullStringWithTz = new Intl.DateTimeFormat(effectiveLocale, { - ...dateTimeOptions, - ...timeZoneNameOptions, - }).format(date); - const timeZonePart = fullStringWithTz.split(' ').pop() || ''; + const timeZoneOptions: Intl.DateTimeFormatOptions = { + timeZone: resolvedTimeZone, + timeZoneName: 'short', + }; + const timeZoneFormatter = getOrCreateFormatter(effectiveLocale, timeZoneOptions); + const parts = timeZoneFormatter.formatToParts(date); + const timeZonePart = parts.find((part) => part.type === 'timeZoneName')?.value || ''; // Combine them into the desired final format (e.g., "MM/DD/YYYY, HH:mm:ss (GMT+X)") formattedDate = `${mainPart} (${timeZonePart})`; @@ -147,7 +162,7 @@ export function DateTimeFormatProvider({ children }: { children: React.ReactNode return formattedDate; }, - [preferences, getResolvedTimeZone] + [preferences, getResolvedTimeZone, getOrCreateFormatter] ); const value = { preferences, updatePreferences, formatDate, getResolvedTimeZone }; diff --git a/galasa-ui/src/tests/contexts/DateTimeFormatContext.test.tsx b/galasa-ui/src/tests/contexts/DateTimeFormatContext.test.tsx index b378f0b1..825ed409 100644 --- a/galasa-ui/src/tests/contexts/DateTimeFormatContext.test.tsx +++ b/galasa-ui/src/tests/contexts/DateTimeFormatContext.test.tsx @@ -279,6 +279,99 @@ describe('DateTimeFormatContext', () => { 'Formatted Date: 10/01/2023, 09:00:00 PM (GMT+9)' ); }); + + test('clears formatter cache when preferences are updated', () => { + render( + + + + ); + + // Update preferences + const button = screen.getByRole('button', { name: /Update Locale/i }); + fireEvent.click(button); + + // The formatted date should be updated with new locale + const updatedFormatted = screen.getByText(/Formatted Date:/).textContent; + expect(updatedFormatted).toBeDefined(); + }); + }); + + describe('Performance: Formatter caching', () => { + test('reuses cached formatters for multiple calls with same parameters', () => { + const dateTimeFormatSpy = jest.spyOn(Intl, 'DateTimeFormat'); + + const MultipleCallsComponent = () => { + const { formatDate } = useDateTimeFormat(); + const date1 = new Date('2023-10-01T12:00:00Z'); + const date2 = new Date('2023-10-02T12:00:00Z'); + const date3 = new Date('2023-10-03T12:00:00Z'); + + return ( +
+

Date 1: {formatDate(date1)}

+

Date 2: {formatDate(date2)}

+

Date 3: {formatDate(date3)}

+
+ ); + }; + + render( + + + + ); + + const callCount = dateTimeFormatSpy.mock.calls.length; + expect(callCount).toBeLessThan(10); + + dateTimeFormatSpy.mockRestore(); + }); + + test('creates new formatters after cache is cleared', () => { + const dateTimeFormatSpy = jest.spyOn(Intl, 'DateTimeFormat'); + + render( + + + + ); + + const initialCallCount = dateTimeFormatSpy.mock.calls.length; + + // Update preferences which should clear the cache + const button = screen.getByRole('button', { name: /Update Locale/i }); + fireEvent.click(button); + + // After clearing cache, new formatters should be created + expect(dateTimeFormatSpy.mock.calls.length).toBeGreaterThan(initialCallCount); + + dateTimeFormatSpy.mockRestore(); + }); + + test('formatToParts is used for efficient timezone extraction', () => { + const formatToPartsSpy = jest.fn(() => [{ type: 'timeZoneName', value: 'UTC' }]); + + jest.spyOn(Intl, 'DateTimeFormat').mockImplementation( + () => + ({ + format: jest.fn(() => '10/01/2023, 12:00:00 PM'), + formatToParts: formatToPartsSpy, + resolvedOptions: jest.fn(() => ({ timeZone: 'UTC' })), + }) as unknown as Intl.DateTimeFormat + ); + + render( + + + + ); + + // Verify formatToParts was called for timezone extraction + expect(formatToPartsSpy).toHaveBeenCalled(); + + jest.restoreAllMocks(); + }); }); }); });