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.tsx b/packages/mosaic/src/hooks/__tests__/use-now.test.tsx new file mode 100644 index 00000000000..b0a591a875b --- /dev/null +++ b/packages/mosaic/src/hooks/__tests__/use-now.test.tsx @@ -0,0 +1,115 @@ +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 { MosaicProvider } from '../../mosaic-provider'; +import { MosaicNowProvider, 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); + }); + 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 new file mode 100644 index 00000000000..2e62ec1b571 --- /dev/null +++ b/packages/mosaic/src/hooks/use-now.ts @@ -0,0 +1,20 @@ +import { createContext, useContext, useEffect, useState } from 'react'; + +const MosaicNowContext = createContext(null); + +export const MosaicNowProvider = MosaicNowContext.Provider; + +export function useNow({ updateInterval }: { updateInterval?: number } = {}): Date { + const providerNow = useContext(MosaicNowContext); + const [now, setNow] = useState(() => providerNow ?? new Date()); + + useEffect(() => { + if (updateInterval === undefined) { + return; + } + const id = window.setInterval(() => setNow(new Date()), updateInterval); + return () => window.clearInterval(id); + }, [updateInterval]); + + return now; +} 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} + );