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
5 changes: 5 additions & 0 deletions .changeset/hot-clocks-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensadmin": patch
---

Fix relative time values display on "Latest indexed registrar actions" view.
6 changes: 6 additions & 0 deletions .changeset/witty-snails-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@docs/ensnode": patch
"@docs/ensrainbow": patch
---

Capitalize "Namehash Labs" to "NameHash Labs"
11 changes: 6 additions & 5 deletions apps/ensadmin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@
"graphiql": "5.2.0",
"graphql": "^16.10.0",
"lucide-react": "^0.548.0",
"next": "16.0.1",
"next": "16.0.7",
"next-themes": "^0.4.6",
"react": "^19",
"react-dom": "^19",
"react": "19.2.1",
"react-dom": "19.2.1",
"rooks": "^8.4.0",
"serve": "^14.2.5",
"sonner": "^2.0.3",
Expand All @@ -67,9 +67,10 @@
"viem": "catalog:"
},
"devDependencies": {
"@testing-library/react": "catalog:",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"postcss": "^8",
"tailwindcss": "^3.4.17",
"typescript": "^5",
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
49 changes: 49 additions & 0 deletions apps/ensadmin/src/hooks/use-now.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";

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

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

/** Default time to refresh: `1` second. */
export const DEFAULT_TIME_TO_REFRESH: Duration = 1;

export interface UseNowProps {
/**
* 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;
}
33 changes: 33 additions & 0 deletions apps/ensadmin/src/hooks/use-system-clock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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);

// syncedSystemClock will be undefined on the initial render,
// so we return the snapshot result to ensure there's always
// some UnixTime value returned.
return syncedSystemClock ?? getSnapshot();
}
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 { HighResolutionSyncedClock } from "./synced-clock";

describe("HighResolutionSyncedClock", () => {
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 = new HighResolutionSyncedClock();
const now = Date.now();
expect(clock.currentTime).toStrictEqual(now);
});

it("should start and stop the clock", async () => {
const clock = new HighResolutionSyncedClock();
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 = new HighResolutionSyncedClock();
const listener = vi.fn();

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

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

it("should remove listeners", () => {
const clock = new HighResolutionSyncedClock();
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 = new HighResolutionSyncedClock();
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 = new HighResolutionSyncedClock();
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 = new HighResolutionSyncedClock();
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 = new HighResolutionSyncedClock();
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