Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions apps/ensadmin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"viem": "catalog:"
},
"devDependencies": {
"@testing-library/react": "catalog:",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
NamedRegistrarAction,
RegistrarActionReferral,
RegistrarActionTypes,
UnixTimestamp,
ZERO_ENCODED_REFERRER,
} from "@ensnode/ensnode-sdk";

Expand All @@ -18,6 +19,7 @@ import { ResolveAndDisplayIdentity } from "@/components/identity";
import { NameDisplay, NameLink } from "@/components/identity/utils";
import { ExternalLink } from "@/components/link";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNow } from "@/hooks/use-now";
import { getBlockExplorerUrlForTransactionHash } from "@/lib/namespace-utils";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -132,6 +134,7 @@ export function DisplayRegistrarActionCardPlaceholder() {
export interface DisplayRegistrarActionCardProps {
namespaceId: ENSNamespaceId;
namedRegistrarAction: NamedRegistrarAction;
now: UnixTimestamp;
}

/**
Expand All @@ -140,6 +143,7 @@ export interface DisplayRegistrarActionCardProps {
export function DisplayRegistrarActionCard({
namespaceId,
namedRegistrarAction,
now,
}: DisplayRegistrarActionCardProps) {
const { registrant, registrationLifecycle, type, referral, transactionHash } =
namedRegistrarAction.action;
Expand Down Expand Up @@ -178,6 +182,7 @@ export function DisplayRegistrarActionCard({
tooltipPosition="top"
conciseFormatting={true}
contentWrapper={withTransactionLink}
relativeTo={now}
/>
</LabeledField>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { useRawConnectionUrlParam } from "@/hooks/use-connection-url-param";
import { useNow } from "@/hooks/use-now";
import { formatOmnichainIndexingStatus } from "@/lib/indexing-status";

import {
Expand All @@ -31,6 +32,7 @@ function DisplayRegistrarActionsList({
registrarActions,
}: DisplayRegistrarActionsListProps) {
const [animationParent] = useAutoAnimate();
const now = useNow();

return (
<div
Expand All @@ -42,6 +44,7 @@ function DisplayRegistrarActionsList({
key={namedRegistrarAction.action.id}
namespaceId={namespaceId}
namedRegistrarAction={namedRegistrarAction}
now={now}
/>
))}
</div>
Expand Down
48 changes: 48 additions & 0 deletions apps/ensadmin/src/hooks/use-now.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect, useState } from "react";

import { Duration, UnixTimestamp } from "@ensnode/ensnode-sdk";

import { useSystemClock } from "./use-system-clock";

const DEFAULT_TIME_TO_REFRESH: Duration = 1;
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated

interface UseNowProps {
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
/**
* Duration after which time value will be refreshed.
*
* Defaults to {@link DEFAULT_TIME_TO_REFRESH}.
*/
timeToRefresh?: Duration;
}

/**
* Use now
*
* This hook returns the current system time while following `timeToRefresh` param.
*
* @param timeToRefresh Duration after which time value will be refreshed.
*
* @example
* ```ts
* // `now` will be updated each second (by default)
* const now = useNow();
* ```
* @example
* ```ts
* // `now` will be updated each 5 seconds
* const now = useNow({ timeToRefresh: 5 });
* ```
*/
export function useNow(props?: UseNowProps): UnixTimestamp {
const clock = useSystemClock();
const [throttledClock, setThrottledClock] = useState(clock);
const { timeToRefresh = DEFAULT_TIME_TO_REFRESH } = props || {};

useEffect(() => {
if (clock - throttledClock >= timeToRefresh) {
setThrottledClock(clock);
}
}, [timeToRefresh, clock, throttledClock]);

return throttledClock;
}
30 changes: 30 additions & 0 deletions apps/ensadmin/src/hooks/use-system-clock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { getUnixTime } from "date-fns";
import { useSyncExternalStore } from "react";

import { UnixTimestamp } from "@ensnode/ensnode-sdk";

import { systemClock } from "@/lib/system-clock";

/**
* Use System Clock
*
* Allows reading the current system time {@link UnixTimestamp} which stays
* exactly the same across all callers of this hook.
*
* @see https://react.dev/reference/react/useSyncExternalStore
*/
export function useSystemClock(): UnixTimestamp {
const subscribe = (callback: () => void) => {
systemClock.addListener(callback);

return () => {
systemClock.removeListener(callback);
};
};
const getSnapshot = () => getUnixTime(new Date(systemClock.currentTime));
const getServerSnapshot = () => getUnixTime(new Date());

const syncedSystemClock = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

return syncedSystemClock || getSnapshot();
Comment thread
lightwalker-eth marked this conversation as resolved.
Outdated
}
176 changes: 176 additions & 0 deletions apps/ensadmin/src/lib/synced-clock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { createSyncedClock } from "./synced-clock";

describe("createSyncedClock", () => {
const mockedSystemTime = new Date("2025-01-01 00:00:00Z");

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(mockedSystemTime);
});

afterEach(() => {
vi.useRealTimers();
});

describe("basic functionality", () => {
it("should create a clock instance with current time", () => {
const clock = createSyncedClock();
const now = Date.now();
expect(clock.currentTime).toStrictEqual(now);
});

it("should start and stop the clock", async () => {
const clock = createSyncedClock();
const initialTime = clock.currentTime;

// No listener → clock stopped
await act(() => vi.advanceTimersByTimeAsync(1000));
expect(clock.currentTime).toBe(initialTime);

const listener = vi.fn();
act(() => clock.addListener(listener));

await act(() => vi.advanceTimersByTimeAsync(2000));

expect(clock.currentTime).toStrictEqual(clock.currentTime);
expect(listener).toHaveBeenCalled();
});
});

describe("listener management", () => {
it("should add listeners", () => {
const clock = createSyncedClock();
const listener = vi.fn();

act(() => {
clock.addListener(listener);
vi.advanceTimersByTime(100);
});

expect(listener).toHaveBeenCalled();
});

it("should remove listeners", () => {
const clock = createSyncedClock();
const listener = vi.fn();

act(() => {
clock.addListener(listener);
vi.advanceTimersByTime(100);
});
expect(listener).toHaveBeenCalled();

vi.clearAllMocks();

act(() => {
clock.removeListener(listener);
vi.advanceTimersByTime(100);
});

expect(listener).not.toHaveBeenCalled();
});

it("should start clock when first listener is added", () => {
const clock = createSyncedClock();
const initial = clock.currentTime;
const listener = vi.fn();

act(() => {
clock.addListener(listener);
vi.advanceTimersByTime(1000);
});

expect(clock.currentTime).toBeGreaterThan(initial);
expect(listener).toHaveBeenCalled();
});

it("should stop clock when last listener is removed", () => {
const clock = createSyncedClock();
const l1 = vi.fn();
const l2 = vi.fn();

// add multiple listeners
act(() => {
clock.addListener(l1);
clock.addListener(l2);
vi.advanceTimersByTime(100);
});
expect(l1).toHaveBeenCalled();
expect(l2).toHaveBeenCalled();

const timeWithOne = clock.currentTime;

l1.mockReset();
l2.mockReset();

// remove the one of multiple listeners
act(() => {
clock.removeListener(l1);
vi.advanceTimersByTime(1000);
});
expect(clock.currentTime).toBeGreaterThan(timeWithOne);
expect(l1).not.toHaveBeenCalled();
expect(l2).toHaveBeenCalled();

l1.mockReset();
l2.mockReset();

const timeBeforeStop = clock.currentTime;

// remove the last listener
act(() => {
clock.removeListener(l2);
vi.advanceTimersByTime(1000);
});

expect(clock.currentTime).toStrictEqual(timeBeforeStop);
expect(l1).not.toHaveBeenCalled();
expect(l2).not.toHaveBeenCalled();
});

it("should handle multiple listeners", () => {
const clock = createSyncedClock();
const l1 = vi.fn(),
l2 = vi.fn(),
l3 = vi.fn();

act(() => {
clock.addListener(l1);
clock.addListener(l2);
clock.addListener(l3);
vi.advanceTimersByTime(100);
});

expect(l1).toHaveBeenCalled();
expect(l2).toHaveBeenCalled();
expect(l3).toHaveBeenCalled();
});

it("should deduplicate listeners when same listener is added multiple times", () => {
const clock = createSyncedClock();
const listener = vi.fn();

act(() => {
clock.addListener(listener);
clock.addListener(listener);
vi.advanceTimersByTime(100);
});

expect(listener).toHaveBeenCalled();

listener.mockReset();
const before = clock.currentTime;

act(() => {
clock.removeListener(listener);
vi.advanceTimersByTime(100);
});

expect(clock.currentTime).toBe(before);
expect(listener).not.toHaveBeenCalled();
});
});
});
Loading