diff --git a/src/components/pages/PreviewAddressPage.tsx b/src/components/pages/PreviewAddressPage.tsx
index 361dc30..c24ec03 100644
--- a/src/components/pages/PreviewAddressPage.tsx
+++ b/src/components/pages/PreviewAddressPage.tsx
@@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { useNip19Decode } from "@/hooks/useNip19Decode";
import { nip19 } from "nostr-tools";
-import { Loader2 } from "lucide-react";
import { toast } from "sonner";
/**
@@ -18,11 +17,8 @@ export default function PreviewAddressPage() {
// Reconstruct the full identifier
const fullIdentifier = identifier ? `naddr${identifier}` : undefined;
- // Decode the naddr identifier
- const { decoded, isLoading, error, retry } = useNip19Decode(
- fullIdentifier,
- "naddr"
- );
+ // Decode the naddr identifier (synchronous, memoized)
+ const { decoded, error } = useNip19Decode(fullIdentifier, "naddr");
// Handle redirect when decoded successfully
useEffect(() => {
@@ -36,8 +32,7 @@ export default function PreviewAddressPage() {
const npub = nip19.npubEncode(pointer.pubkey);
navigate(`/${npub}/${pointer.identifier}`, { replace: true });
} else {
- // For other kinds, we could extend this to handle them differently
- // For now, show an error via toast
+ // For other kinds, show error via toast
toast.error(`Addressable events of kind ${pointer.kind} are not yet supported in preview mode`);
}
}, [decoded, navigate]);
@@ -49,19 +44,6 @@ export default function PreviewAddressPage() {
}
}, [error]);
- // Loading state
- if (isLoading) {
- return (
-
-
-
-
Redirecting...
-
Processing address pointer
-
-
- );
- }
-
// Error state
if (error) {
return (
@@ -69,20 +51,12 @@ export default function PreviewAddressPage() {
{error}
-
-
-
-
+
);
}
@@ -104,14 +78,6 @@ export default function PreviewAddressPage() {
);
}
- // Still processing redirect
- return (
-
-
-
-
Redirecting...
-
Processing address pointer
-
-
- );
+ // Redirecting (shown briefly before redirect happens)
+ return null;
}
diff --git a/src/components/pages/PreviewEventPage.tsx b/src/components/pages/PreviewEventPage.tsx
index cd4f951..101ebbc 100644
--- a/src/components/pages/PreviewEventPage.tsx
+++ b/src/components/pages/PreviewEventPage.tsx
@@ -3,7 +3,6 @@ import { useParams, useNavigate, useLocation } from "react-router";
import { useNip19Decode } from "@/hooks/useNip19Decode";
import type { EventPointer } from "nostr-tools/nip19";
import { EventDetailViewer } from "../EventDetailViewer";
-import { Loader2 } from "lucide-react";
import { toast } from "sonner";
/**
@@ -29,8 +28,8 @@ export default function PreviewEventPage() {
return undefined;
}, [identifier, location.pathname]);
- // Decode the event identifier (accepts both nevent and note)
- const { decoded, isLoading, error, retry } = useNip19Decode(fullIdentifier);
+ // Decode the event identifier (synchronous, memoized)
+ const { decoded, error } = useNip19Decode(fullIdentifier);
// Convert decoded entity to EventPointer
const pointer: EventPointer | null = useMemo(() => {
@@ -59,19 +58,6 @@ export default function PreviewEventPage() {
}
}, [decoded]);
- // Loading state
- if (isLoading) {
- return (
-
-
-
-
Loading Event...
-
Decoding identifier
-
-
- );
- }
-
// Error state
if (error || !pointer) {
return (
@@ -79,20 +65,12 @@ export default function PreviewEventPage() {
{error || "Failed to decode event identifier"}
-
-
-
-
+
);
}
diff --git a/src/components/pages/PreviewProfilePage.tsx b/src/components/pages/PreviewProfilePage.tsx
index c961d6e..1668359 100644
--- a/src/components/pages/PreviewProfilePage.tsx
+++ b/src/components/pages/PreviewProfilePage.tsx
@@ -1,9 +1,8 @@
import { useParams, useNavigate } from "react-router";
import { useNip19Decode } from "@/hooks/useNip19Decode";
import { ProfileViewer } from "../ProfileViewer";
-import { Loader2 } from "lucide-react";
-import { toast } from "sonner";
import { useEffect } from "react";
+import { toast } from "sonner";
/**
* PreviewProfilePage - Preview a Nostr profile from an npub identifier
@@ -17,11 +16,8 @@ export default function PreviewProfilePage() {
// Reconstruct the full identifier (react-router splits on /)
const fullIdentifier = identifier ? `npub${identifier}` : undefined;
- // Decode the npub identifier
- const { decoded, isLoading, error, retry } = useNip19Decode(
- fullIdentifier,
- "npub"
- );
+ // Decode the npub identifier (synchronous, memoized)
+ const { decoded, error } = useNip19Decode(fullIdentifier, "npub");
// Show error toast when error occurs
useEffect(() => {
@@ -30,19 +26,6 @@ export default function PreviewProfilePage() {
}
}, [error]);
- // Loading state
- if (isLoading) {
- return (
-
-
-
-
Loading Profile...
-
Decoding identifier
-
-
- );
- }
-
// Error state
if (error) {
return (
@@ -50,20 +33,12 @@ export default function PreviewProfilePage() {
{error}
-
-
-
-
+
);
}
diff --git a/src/hooks/useNip19Decode.test.ts b/src/hooks/useNip19Decode.test.ts
index 4417e93..5e4381b 100644
--- a/src/hooks/useNip19Decode.test.ts
+++ b/src/hooks/useNip19Decode.test.ts
@@ -2,7 +2,7 @@
* @vitest-environment jsdom
*/
import { describe, it, expect } from "vitest";
-import { renderHook, waitFor, act } from "@testing-library/react";
+import { renderHook } from "@testing-library/react";
import { useNip19Decode } from "./useNip19Decode";
import { nip19 } from "nostr-tools";
@@ -14,14 +14,10 @@ describe("useNip19Decode", () => {
"d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027";
describe("npub decoding", () => {
- it("should decode valid npub identifier", async () => {
+ it("should decode valid npub identifier", () => {
const npub = nip19.npubEncode(testPubkey);
const { result } = renderHook(() => useNip19Decode(npub));
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
expect(result.current.decoded).toEqual({
type: "npub",
data: testPubkey,
@@ -29,14 +25,10 @@ describe("useNip19Decode", () => {
expect(result.current.error).toBeNull();
});
- it("should validate expected type for npub", async () => {
+ it("should validate expected type for npub", () => {
const npub = nip19.npubEncode(testPubkey);
const { result } = renderHook(() => useNip19Decode(npub, "npub"));
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
expect(result.current.decoded).toEqual({
type: "npub",
data: testPubkey,
@@ -44,28 +36,20 @@ describe("useNip19Decode", () => {
expect(result.current.error).toBeNull();
});
- it("should error when expected type doesn't match", async () => {
+ it("should error when expected type doesn't match", () => {
const npub = nip19.npubEncode(testPubkey);
const { result } = renderHook(() => useNip19Decode(npub, "note"));
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
expect(result.current.decoded).toBeNull();
expect(result.current.error).toContain("expected note, got npub");
});
});
describe("note decoding", () => {
- it("should decode valid note identifier", async () => {
+ it("should decode valid note identifier", () => {
const note = nip19.noteEncode(testEventId);
const { result } = renderHook(() => useNip19Decode(note));
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
expect(result.current.decoded).toEqual({
type: "note",
data: testEventId,
@@ -75,17 +59,13 @@ describe("useNip19Decode", () => {
});
describe("nevent decoding", () => {
- it("should decode valid nevent identifier", async () => {
+ it("should decode valid nevent identifier", () => {
const nevent = nip19.neventEncode({
id: testEventId,
relays: ["wss://relay.example.com"],
});
const { result } = renderHook(() => useNip19Decode(nevent));
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
expect(result.current.decoded?.type).toBe("nevent");
expect(result.current.decoded?.data).toEqual({
id: testEventId,
@@ -96,7 +76,7 @@ describe("useNip19Decode", () => {
});
describe("naddr decoding", () => {
- it("should decode valid naddr identifier", async () => {
+ it("should decode valid naddr identifier", () => {
const naddr = nip19.naddrEncode({
kind: 30777,
pubkey: testPubkey,
@@ -105,10 +85,6 @@ describe("useNip19Decode", () => {
});
const { result } = renderHook(() => useNip19Decode(naddr));
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
expect(result.current.decoded?.type).toBe("naddr");
expect(result.current.decoded?.data).toEqual({
kind: 30777,
@@ -121,72 +97,34 @@ describe("useNip19Decode", () => {
});
describe("error handling", () => {
- it("should handle missing identifier", async () => {
+ it("should handle missing identifier", () => {
const { result } = renderHook(() => useNip19Decode(undefined));
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
expect(result.current.decoded).toBeNull();
expect(result.current.error).toBe("No identifier provided");
});
- it("should handle invalid identifier format", async () => {
+ it("should handle invalid identifier format", () => {
const { result } = renderHook(() =>
useNip19Decode("invalid-identifier")
);
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
expect(result.current.decoded).toBeNull();
expect(result.current.error).toBeTruthy();
});
- it("should handle corrupted bech32 string", async () => {
+ it("should handle corrupted bech32 string", () => {
const { result } = renderHook(() =>
useNip19Decode("npub1invalidbech32string")
);
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
expect(result.current.decoded).toBeNull();
expect(result.current.error).toBeTruthy();
});
});
- describe("retry functionality", () => {
- it("should retry decoding when retry is called", async () => {
- const { result } = renderHook(() =>
- useNip19Decode("invalid-identifier")
- );
-
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
- expect(result.current.error).toBeTruthy();
-
- // Call retry wrapped in act
- act(() => {
- result.current.retry();
- });
-
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
- // Should still have error since the identifier is still invalid
- expect(result.current.error).toBeTruthy();
- });
- });
-
- describe("identifier changes", () => {
- it("should reset state when identifier changes", async () => {
+ describe("memoization", () => {
+ it("should memoize results for same identifier", () => {
const npub = nip19.npubEncode(testPubkey);
const { result, rerender } = renderHook(
({ id }: { id: string | undefined }) => useNip19Decode(id),
@@ -195,9 +133,24 @@ describe("useNip19Decode", () => {
}
);
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
+ const firstResult = result.current;
+ expect(firstResult.decoded?.type).toBe("npub");
+
+ // Rerender with same identifier
+ rerender({ id: npub as string });
+
+ // Should return exact same object reference (memoized)
+ expect(result.current).toBe(firstResult);
+ });
+
+ it("should recompute when identifier changes", () => {
+ const npub = nip19.npubEncode(testPubkey);
+ const { result, rerender } = renderHook(
+ ({ id }: { id: string | undefined }) => useNip19Decode(id),
+ {
+ initialProps: { id: npub as string },
+ }
+ );
expect(result.current.decoded?.type).toBe("npub");
@@ -205,10 +158,6 @@ describe("useNip19Decode", () => {
const note = nip19.noteEncode(testEventId);
rerender({ id: note });
- await waitFor(() => {
- expect(result.current.isLoading).toBe(false);
- });
-
expect(result.current.decoded?.type).toBe("note");
expect(result.current.error).toBeNull();
});
diff --git a/src/hooks/useNip19Decode.ts b/src/hooks/useNip19Decode.ts
index cbd476b..8005bb5 100644
--- a/src/hooks/useNip19Decode.ts
+++ b/src/hooks/useNip19Decode.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useMemo } from "react";
import { nip19 } from "nostr-tools";
import type {
EventPointer,
@@ -22,30 +22,26 @@ export type DecodedEntity =
| { type: "nprofile"; data: ProfilePointer };
/**
- * Hook result containing decoded data, loading, and error states
+ * Hook result containing decoded data or error
*/
export interface UseNip19DecodeResult {
- /** Decoded entity (null while loading or on error) */
+ /** Decoded entity (null on error) */
decoded: DecodedEntity | null;
- /** Loading state */
- isLoading: boolean;
/** Error message (null if no error) */
error: string | null;
- /** Retry the decode operation */
- retry: () => void;
}
/**
- * Hook to decode NIP-19 encoded entities (npub, note, nevent, naddr, nprofile)
+ * Synchronously decode NIP-19 encoded entities (npub, note, nevent, naddr, nprofile)
+ * Results are memoized - same identifier always yields same result
*
* @param identifier - The NIP-19 encoded string (e.g., "npub1...")
* @param expectedType - Optional expected type for validation
- * @returns Decoded entity with loading and error states
+ * @returns Decoded entity or error
*
* @example
* ```tsx
- * const { decoded, isLoading, error } = useNip19Decode(identifier, "npub");
- * if (isLoading) return ;
+ * const { decoded, error } = useNip19Decode(identifier, "npub");
* if (error) return ;
* if (decoded?.type === "npub") {
* return ;
@@ -56,21 +52,12 @@ export function useNip19Decode(
identifier: string | undefined,
expectedType?: Nip19EntityType
): UseNip19DecodeResult {
- const [decoded, setDecoded] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
- const [retryCount, setRetryCount] = useState(0);
-
- useEffect(() => {
- // Reset state when identifier changes
- setDecoded(null);
- setError(null);
- setIsLoading(true);
-
+ return useMemo(() => {
if (!identifier) {
- setError("No identifier provided");
- setIsLoading(false);
- return;
+ return {
+ decoded: null,
+ error: "No identifier provided",
+ };
}
try {
@@ -78,11 +65,10 @@ export function useNip19Decode(
// Validate expected type if provided
if (expectedType && result.type !== expectedType) {
- setError(
- `Invalid identifier type: expected ${expectedType}, got ${result.type}`
- );
- setIsLoading(false);
- return;
+ return {
+ decoded: null,
+ error: `Invalid identifier type: expected ${expectedType}, got ${result.type}`,
+ };
}
// Map decoded result to typed entity
@@ -105,30 +91,23 @@ export function useNip19Decode(
entity = { type: "nprofile", data: result.data };
break;
default:
- setError(`Unsupported entity type: ${result.type}`);
- setIsLoading(false);
- return;
+ return {
+ decoded: null,
+ error: `Unsupported entity type: ${result.type}`,
+ };
}
- setDecoded(entity);
- setIsLoading(false);
+ return {
+ decoded: entity,
+ error: null,
+ };
} catch (e) {
- console.error("Failed to decode NIP-19 identifier:", identifier, e);
const errorMessage =
e instanceof Error ? e.message : "Failed to decode identifier";
- setError(errorMessage);
- setIsLoading(false);
+ return {
+ decoded: null,
+ error: errorMessage,
+ };
}
- }, [identifier, expectedType, retryCount]);
-
- const retry = () => {
- setRetryCount((prev) => prev + 1);
- };
-
- return {
- decoded,
- isLoading,
- error,
- retry,
- };
+ }, [identifier, expectedType]);
}