Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-use-now.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
3 changes: 2 additions & 1 deletion packages/mosaic/src/features/api-keys/api-keys-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -29,6 +30,7 @@ export function useCreateAPIKeyController({
onCreate,
}: CreateAPIKeyControllerOptions): CreateAPIKeyController {
const locale = useLocale();
const now = useNow({ updateInterval: 60_000 });
const trigger = useRef<HTMLButtonElement | null>(null);
const [open, setOpen] = useState(false);
const [secret, setSecret] = useState<string | null>(null);
Expand All @@ -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 => {
Expand Down
115 changes: 115 additions & 0 deletions packages/mosaic/src/hooks/__tests__/use-now.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<MosaicNowProvider value={providerNow}>{children}</MosaicNowProvider>
);

const { result } = renderHook(() => useNow(), { wrapper });

expect(result.current).toBe(providerNow);
});

it('ticks forward from the provider time', async () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<MosaicNowProvider value={new Date('2025-06-01T00:00:00Z')}>{children}</MosaicNowProvider>
);

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(
<MosaicProvider>
<Consumer />
</MosaicProvider>,
);
vi.setSystemTime(new Date('2026-01-01T00:05:00Z'));
rerender(
<MosaicProvider>
<Consumer />
<Consumer />
</MosaicProvider>,
);

expect(seen.at(-1)).toEqual(new Date('2026-01-01T00:00:00Z'));
expect(new Set(seen).size).toBe(1);
});
});
20 changes: 20 additions & 0 deletions packages/mosaic/src/hooks/use-now.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createContext, useContext, useEffect, useState } from 'react';

const MosaicNowContext = createContext<Date | null>(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;
}
Comment on lines +7 to +20

@Ephem Ephem Oct 7, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wont work well with SSR, the state initializer will have different times on the server and the client, causing hydration mismatches.

I suggest we add the new Date() to the MosaicProvider instead and put it on a context that the useNow hook reads its initial state from. The interval still belongs here since different components might want different granularity for their reactivity (re-rendering all of these every second is heavy).

When we have that shape from the start, it's a lot easier to add SSR support by passing the new Date() result from server->client. That part would live in our framework SDKs though, they all do it a bit differently (but all already pass data server->client).

An optional improvement could be to have all the intervals at the top as well so they de-duplicate. All components that should update every minute do so together instead of on their own timer, and we end up with less timers. I think this leads to quite a bit more complexity though so I'd defer that for later/if we notice it's necessary.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haven't been considering ssr support to much since their client components, but a good reminder to keep in mind 👍🏼 673b8a6

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

6 changes: 5 additions & 1 deletion packages/mosaic/src/mosaic-provider.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<MosaicIconsProvider value={iconsValue}>
<MosaicLocalizationProvider value={localizationValue}>
<ToastProvider>{children}</ToastProvider>
<MosaicNowProvider value={now}>
<ToastProvider>{children}</ToastProvider>
</MosaicNowProvider>
</MosaicLocalizationProvider>
</MosaicIconsProvider>
);
Expand Down
Loading