From 3fab8d581e6997721d50e7de1a070e25d3b68549 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 6 Oct 2026 15:36:56 -0400 Subject: [PATCH 1/2] refactor(mosaic): read the current time through useNow instead of during render --- .changeset/mosaic-use-now.md | 2 + .../src/features/api-keys/api-keys-table.tsx | 3 +- .../api-keys/create-api-key.controller.ts | 4 +- .../src/hooks/__tests__/use-now.test.ts | 68 +++++++++++++++++++ packages/mosaic/src/hooks/use-now.ts | 15 ++++ 5 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 .changeset/mosaic-use-now.md create mode 100644 packages/mosaic/src/hooks/__tests__/use-now.test.ts create mode 100644 packages/mosaic/src/hooks/use-now.ts diff --git a/.changeset/mosaic-use-now.md b/.changeset/mosaic-use-now.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-use-now.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.tsx b/packages/mosaic/src/features/api-keys/api-keys-table.tsx index 5ef1ab54590..e12de235cd1 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.tsx +++ b/packages/mosaic/src/features/api-keys/api-keys-table.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from 'react'; import { FormSubmitError } from '../../components/form'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; +import { useNow } from '../../hooks/use-now'; import { useLocale, useMessages } from '../../localization'; import { formatDate, formatRelativeTime } from './api-keys-table.format'; import { resolveAPIKeysTableMessages } from './api-keys-table.messages'; @@ -123,7 +124,7 @@ function useDebouncedSearch() { function useApiKeyLabels(apiKeys: APIKeyResource[]): APIKey[] { const locale = useLocale(); - const now = new Date(); + const now = useNow({ updateInterval: 60_000 }); return apiKeys.map(key => ({ id: key.id, diff --git a/packages/mosaic/src/features/api-keys/create-api-key.controller.ts b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts index 7a710737749..54659b4d27a 100644 --- a/packages/mosaic/src/features/api-keys/create-api-key.controller.ts +++ b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts @@ -2,6 +2,7 @@ import type { MouseEventHandler } from 'react'; import { useRef, useState } from 'react'; import { useForm } from '../../components/form'; +import { useNow } from '../../hooks/use-now'; import { useLocale } from '../../localization'; import { formatDate, getExpirationDate } from './api-keys-table.format'; import type { APIKeysTableMessages } from './api-keys-table.types'; @@ -29,6 +30,7 @@ export function useCreateAPIKeyController({ onCreate, }: CreateAPIKeyControllerOptions): CreateAPIKeyController { const locale = useLocale(); + const now = useNow({ updateInterval: 60_000 }); const trigger = useRef(null); const [open, setOpen] = useState(false); const [secret, setSecret] = useState(null); @@ -46,7 +48,7 @@ export function useCreateAPIKeyController({ }); const { expiration } = form.values; - const expirationDate = expiration === null ? null : getExpirationDate(expiration, new Date()); + const expirationDate = expiration === null ? null : getExpirationDate(expiration, now); return { onOpen: event => { diff --git a/packages/mosaic/src/hooks/__tests__/use-now.test.ts b/packages/mosaic/src/hooks/__tests__/use-now.test.ts new file mode 100644 index 00000000000..61171832478 --- /dev/null +++ b/packages/mosaic/src/hooks/__tests__/use-now.test.ts @@ -0,0 +1,68 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useNow } from '../use-now'; + +describe('useNow', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const render = (updateInterval?: number) => + renderHook(({ updateInterval }) => useNow({ updateInterval }), { initialProps: { updateInterval } }); + + const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms)); + + it('returns the time at mount', () => { + const { result } = render(60_000); + expect(result.current).toEqual(new Date('2026-01-01T00:00:00Z')); + }); + + it('keeps the same value across re-renders between ticks', () => { + const { result, rerender } = render(60_000); + const first = result.current; + + vi.setSystemTime(new Date('2026-01-01T00:00:30Z')); + rerender({ updateInterval: 60_000 }); + + expect(result.current).toBe(first); + }); + + it('advances on each interval', async () => { + const { result } = render(60_000); + + await advance(60_000); + expect(result.current).toEqual(new Date('2026-01-01T00:01:00Z')); + + await advance(60_000); + expect(result.current).toEqual(new Date('2026-01-01T00:02:00Z')); + }); + + it('does not tick without an update interval', async () => { + const { result } = render(); + + await advance(60_000); + expect(result.current).toEqual(new Date('2026-01-01T00:00:00Z')); + }); + + it('starts ticking when the interval is set after mount', async () => { + const { result, rerender } = render(); + + await advance(5_000); + rerender({ updateInterval: 1_000 }); + await advance(1_000); + + expect(result.current).toEqual(new Date('2026-01-01T00:00:06Z')); + }); + + it('stops its timer on unmount', () => { + const { unmount } = render(1_000); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/mosaic/src/hooks/use-now.ts b/packages/mosaic/src/hooks/use-now.ts new file mode 100644 index 00000000000..4bae0fd4f74 --- /dev/null +++ b/packages/mosaic/src/hooks/use-now.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; + +export function useNow({ updateInterval }: { updateInterval?: number } = {}): Date { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + if (updateInterval === undefined) { + return; + } + const id = window.setInterval(() => setNow(new Date()), updateInterval); + return () => window.clearInterval(id); + }, [updateInterval]); + + return now; +} From 673b8a6754b7946de7a70b1536744c8a53240371 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 7 Oct 2026 08:18:33 -0400 Subject: [PATCH 2/2] refactor(mosaic): seed useNow from a time captured by MosaicProvider --- .../{use-now.test.ts => use-now.test.tsx} | 51 ++++++++++++++++++- packages/mosaic/src/hooks/use-now.ts | 9 +++- packages/mosaic/src/mosaic-provider.tsx | 6 ++- 3 files changed, 61 insertions(+), 5 deletions(-) rename packages/mosaic/src/hooks/__tests__/{use-now.test.ts => use-now.test.tsx} (53%) diff --git a/packages/mosaic/src/hooks/__tests__/use-now.test.ts b/packages/mosaic/src/hooks/__tests__/use-now.test.tsx similarity index 53% rename from packages/mosaic/src/hooks/__tests__/use-now.test.ts rename to packages/mosaic/src/hooks/__tests__/use-now.test.tsx index 61171832478..b0a591a875b 100644 --- a/packages/mosaic/src/hooks/__tests__/use-now.test.ts +++ b/packages/mosaic/src/hooks/__tests__/use-now.test.tsx @@ -1,7 +1,9 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, render as renderTree, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useNow } from '../use-now'; +import { MosaicProvider } from '../../mosaic-provider'; +import { MosaicNowProvider, useNow } from '../use-now'; describe('useNow', () => { beforeEach(() => { @@ -65,4 +67,49 @@ describe('useNow', () => { unmount(); expect(vi.getTimerCount()).toBe(0); }); + it('starts from the provider time instead of reading the clock', () => { + const providerNow = new Date('2025-06-01T00:00:00Z'); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useNow(), { wrapper }); + + expect(result.current).toBe(providerNow); + }); + + it('ticks forward from the provider time', async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useNow({ updateInterval: 1_000 }), { wrapper }); + await advance(1_000); + + expect(result.current).toEqual(new Date('2026-01-01T00:00:01Z')); + }); + + it('gives components mounted later under MosaicProvider the time the provider mounted', () => { + const seen: Date[] = []; + const Consumer = () => { + seen.push(useNow()); + return null; + }; + + const { rerender } = renderTree( + + + , + ); + vi.setSystemTime(new Date('2026-01-01T00:05:00Z')); + rerender( + + + + , + ); + + expect(seen.at(-1)).toEqual(new Date('2026-01-01T00:00:00Z')); + expect(new Set(seen).size).toBe(1); + }); }); diff --git a/packages/mosaic/src/hooks/use-now.ts b/packages/mosaic/src/hooks/use-now.ts index 4bae0fd4f74..2e62ec1b571 100644 --- a/packages/mosaic/src/hooks/use-now.ts +++ b/packages/mosaic/src/hooks/use-now.ts @@ -1,7 +1,12 @@ -import { useEffect, useState } from 'react'; +import { createContext, useContext, useEffect, useState } from 'react'; + +const MosaicNowContext = createContext(null); + +export const MosaicNowProvider = MosaicNowContext.Provider; export function useNow({ updateInterval }: { updateInterval?: number } = {}): Date { - const [now, setNow] = useState(() => new Date()); + const providerNow = useContext(MosaicNowContext); + const [now, setNow] = useState(() => providerNow ?? new Date()); useEffect(() => { if (updateInterval === undefined) { diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx index 0c80f7ce149..fdcfe4ff0c4 100644 --- a/packages/mosaic/src/mosaic-provider.tsx +++ b/packages/mosaic/src/mosaic-provider.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { ToastProvider } from './components/toast/toast'; +import { MosaicNowProvider } from './hooks/use-now'; import type { MosaicIconOverrides } from './icons/overrides'; import { MosaicIconsProvider } from './icons/overrides'; import type { MosaicLocalization } from './localization'; @@ -20,11 +21,14 @@ export interface MosaicProviderProps { export function MosaicProvider({ children, icons, localization }: MosaicProviderProps): React.ReactElement { const iconsValue = React.useMemo(() => icons ?? {}, [icons]); const localizationValue = React.useMemo(() => resolveLocalization(localization), [localization]); + const [now] = React.useState(() => new Date()); return ( - {children} + + {children} + );