Skip to content
Open
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
28 changes: 20 additions & 8 deletions hooks/useCatalogValuation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import type {
import { runValuationFlow } from "@/lib/valuation/runValuationFlow";
import { captureRunLead } from "@/lib/valuation/captureRunLead";
import { linkArtistToAccount } from "@/lib/valuation/linkArtistToAccount";
import { savePendingIntent } from "@/lib/valuation/savePendingIntent";
import { readPendingIntent } from "@/lib/valuation/readPendingIntent";
import { clearPendingIntent } from "@/lib/valuation/clearPendingIntent";

type Phase = "idle" | "running" | "done" | "error";

Expand All @@ -28,7 +31,10 @@ export type CatalogValuationState = {
/**
* Drives the catalog valuation behind the Privy sign-in gate (chat#1798). The
* run trigger opens Privy when signed out and, on login, auto-fires the run for
* the **originally** selected artist. Render inside `PrivyProvider`.
* the **originally** selected artist. The deferred intent is persisted to
* sessionStorage — not just a ref — because fresh-signup auth churn can remount
* this hook's component mid-auth and a ref dies with its instance (chat#1850
* post-auth intent drop). Render inside `PrivyProvider`.
*/
export function useCatalogValuation(): CatalogValuationState {
const { authenticated, login, getAccessToken, user } = usePrivy();
Expand Down Expand Up @@ -65,20 +71,25 @@ export function useCatalogValuation(): CatalogValuationState {
if (!picked) return;
// Gate: signed out → open Privy and defer the run to a successful login.
if (!authenticated) {
pendingRun.current = picked;
pendingRun.current = picked; // fast path when this instance survives auth
savePendingIntent(picked); // source of truth: survives remount + reload
login();
return;
}
await doRun(picked);
}

// Auto-fire the deferred run once the user signs in, for the stored artist.
// Auto-fire the deferred run once `authenticated` is true — on the sign-in
// flip when this instance survives, or on mount of a fresh instance after an
// auth-churn remount / full reload (then the intent comes from storage).
useEffect(() => {
if (authenticated && pendingRun.current) {
const artist = pendingRun.current;
pendingRun.current = null;
void doRun(artist);
}
if (!authenticated) return;
const artist = pendingRun.current ?? readPendingIntent();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Stale signed-out intents can still auto-run when the component instance survives auth, because pendingRun.current bypasses the 15-minute expiry enforced only by readPendingIntent(). Consider storing { artist, savedAt } in the ref or validating the ref against the same max-age before preferring it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useCatalogValuation.ts, line 87:

<comment>Stale signed-out intents can still auto-run when the component instance survives auth, because `pendingRun.current` bypasses the 15-minute expiry enforced only by `readPendingIntent()`. Consider storing `{ artist, savedAt }` in the ref or validating the ref against the same max-age before preferring it.</comment>

<file context>
@@ -65,20 +71,25 @@ export function useCatalogValuation(): CatalogValuationState {
-      void doRun(artist);
-    }
+    if (!authenticated) return;
+    const artist = pendingRun.current ?? readPendingIntent();
+    pendingRun.current = null;
+    clearPendingIntent(); // consume-once: never re-fire on later mounts
</file context>

pendingRun.current = null;
clearPendingIntent(); // consume-once: never re-fire on later mounts
if (!artist) return;
setPicked(artist); // a remount lost `picked`; restore it for the result UI
void doRun(artist);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authenticated]);

Expand All @@ -92,6 +103,7 @@ export function useCatalogValuation(): CatalogValuationState {
pick: setPicked,
clearPick: () => {
pendingRun.current = null; // also drop a deferred signed-out run
clearPendingIntent(); // …including its persisted copy
setPicked(null);
},
run,
Expand Down
24 changes: 24 additions & 0 deletions lib/valuation/__tests__/clearPendingIntent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { clearPendingIntent } from "../clearPendingIntent";
import { readPendingIntent } from "../readPendingIntent";
import { savePendingIntent } from "../savePendingIntent";
import { fakeSessionStorage } from "./fakeSessionStorage";

const artist = { id: "spotify-123", name: "Del Water Gap" };

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

describe("clearPendingIntent", () => {
it("removes a stored intent", () => {
vi.stubGlobal("sessionStorage", fakeSessionStorage());
savePendingIntent(artist, 1_000);
clearPendingIntent();
expect(readPendingIntent(2_000)).toBeNull();
});

it("is a no-op when sessionStorage is unavailable (SSR)", () => {
expect(() => clearPendingIntent()).not.toThrow();
});
});
21 changes: 21 additions & 0 deletions lib/valuation/__tests__/fakeSessionStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Map-backed Storage stand-in so the pending-intent helpers can be tested in
* Node (vitest runs without a DOM, so there is no real `sessionStorage`).
*/
export function fakeSessionStorage(): Storage {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => store.clear(),
key: (index: number) => [...store.keys()][index] ?? null,
get length() {
return store.size;
},
};
}
74 changes: 74 additions & 0 deletions lib/valuation/__tests__/readPendingIntent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readPendingIntent } from "../readPendingIntent";
import { savePendingIntent } from "../savePendingIntent";
import {
PENDING_INTENT_KEY,
PENDING_INTENT_MAX_AGE_MS,
} from "../pendingIntent";
import { fakeSessionStorage } from "./fakeSessionStorage";

const artist = {
id: "spotify-123",
name: "Del Water Gap",
image: "https://i.scdn.co/x.jpg",
followers: 42,
};

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

describe("readPendingIntent", () => {
it("returns null when nothing is stored", () => {
vi.stubGlobal("sessionStorage", fakeSessionStorage());
expect(readPendingIntent()).toBeNull();
});

it("round-trips an artist saved by savePendingIntent", () => {
vi.stubGlobal("sessionStorage", fakeSessionStorage());
savePendingIntent(artist, 1_000);
expect(readPendingIntent(2_000)).toEqual(artist);
});

it("returns the intent just inside the max age", () => {
vi.stubGlobal("sessionStorage", fakeSessionStorage());
savePendingIntent(artist, 1_000);
expect(readPendingIntent(1_000 + PENDING_INTENT_MAX_AGE_MS)).toEqual(
artist,
);
});

it("ignores intents older than the max age", () => {
vi.stubGlobal("sessionStorage", fakeSessionStorage());
savePendingIntent(artist, 1_000);
expect(readPendingIntent(1_001 + PENDING_INTENT_MAX_AGE_MS)).toBeNull();
});

it("returns null for malformed JSON", () => {
const storage = fakeSessionStorage();
storage.setItem(PENDING_INTENT_KEY, "{not json");
vi.stubGlobal("sessionStorage", storage);
expect(readPendingIntent()).toBeNull();
});

it("returns null when the stored shape is invalid", () => {
const storage = fakeSessionStorage();
storage.setItem(
PENDING_INTENT_KEY,
JSON.stringify({ artist: { name: "no id" }, savedAt: 1_000 }),
);
vi.stubGlobal("sessionStorage", storage);
expect(readPendingIntent(2_000)).toBeNull();
});

it("returns null when the timestamp is missing", () => {
const storage = fakeSessionStorage();
storage.setItem(PENDING_INTENT_KEY, JSON.stringify({ artist }));
vi.stubGlobal("sessionStorage", storage);
expect(readPendingIntent(2_000)).toBeNull();
});

it("returns null when sessionStorage is unavailable (SSR)", () => {
expect(readPendingIntent()).toBeNull();
});
});
37 changes: 37 additions & 0 deletions lib/valuation/__tests__/savePendingIntent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { savePendingIntent } from "../savePendingIntent";
import { PENDING_INTENT_KEY } from "../pendingIntent";
import { fakeSessionStorage } from "./fakeSessionStorage";

const artist = { id: "spotify-123", name: "Del Water Gap" };

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

describe("savePendingIntent", () => {
it("persists the artist and timestamp under the namespaced key", () => {
const storage = fakeSessionStorage();
vi.stubGlobal("sessionStorage", storage);

savePendingIntent(artist, 1_000);

const raw = storage.getItem(PENDING_INTENT_KEY);
expect(raw).not.toBeNull();
expect(JSON.parse(raw as string)).toEqual({ artist, savedAt: 1_000 });
});

it("is a no-op when sessionStorage is unavailable (SSR)", () => {
expect(() => savePendingIntent(artist)).not.toThrow();
});

it("swallows storage write errors (private mode quotas)", () => {
vi.stubGlobal("sessionStorage", {
...fakeSessionStorage(),
setItem: () => {
throw new Error("QuotaExceededError");
},
});
expect(() => savePendingIntent(artist)).not.toThrow();
});
});
14 changes: 14 additions & 0 deletions lib/valuation/clearPendingIntent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { PENDING_INTENT_KEY } from "@/lib/valuation/pendingIntent";

/**
* Drop the persisted post-auth valuation intent — after a resume consumes it,
* or when the user clears their pick (matching the ref-clearing semantic).
*/
export function clearPendingIntent(): void {
try {
if (typeof sessionStorage === "undefined") return;
sessionStorage.removeItem(PENDING_INTENT_KEY);
} catch {
// Storage unavailable — nothing to clear.
}
}
15 changes: 15 additions & 0 deletions lib/valuation/pendingIntent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Artist } from "@/components/valuation/types";

/**
* The "run valuation after auth" intent persisted at the sign-in gate
* (chat#1850). It lives in sessionStorage — not a React ref — because fresh
* signups churn enough state to remount the valuation component mid-auth,
* and a ref dies with its component instance. Versioned key per the
* `recoupable-theme:v1` convention.
*/
export const PENDING_INTENT_KEY = "recoupable-valuation-pending-intent:v1";

/** Ignore intents older than this — a stale tab shouldn't auto-run a valuation. */
export const PENDING_INTENT_MAX_AGE_MS = 15 * 60 * 1000;

export type PendingIntent = { artist: Artist; savedAt: number };
32 changes: 32 additions & 0 deletions lib/valuation/readPendingIntent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Artist } from "@/components/valuation/types";
import {
PENDING_INTENT_KEY,
PENDING_INTENT_MAX_AGE_MS,
} from "@/lib/valuation/pendingIntent";

/**
* Read the persisted post-auth valuation intent. Returns null when absent,
* malformed, stale (> max age — a stale tab must not auto-run), or when
* storage is unavailable (SSR / private mode). Read-only: the caller clears.
*/
export function readPendingIntent(now: number = Date.now()): Artist | null {
try {
if (typeof sessionStorage === "undefined") return null;
const raw = sessionStorage.getItem(PENDING_INTENT_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return null;
const { artist, savedAt } = parsed as Partial<{
artist: Artist;
savedAt: number;
}>;
if (typeof artist?.id !== "string" || typeof artist.name !== "string") {
return null;
}
if (typeof savedAt !== "number") return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Malformed timestamps can bypass the expiry guard and still resume a valuation intent. The savedAt check only verifies number, so NaN/Infinity pass; adding a finite-number guard keeps malformed storage data from being accepted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/valuation/readPendingIntent.ts, line 26:

<comment>Malformed timestamps can bypass the expiry guard and still resume a valuation intent. The `savedAt` check only verifies `number`, so `NaN`/`Infinity` pass; adding a finite-number guard keeps malformed storage data from being accepted.</comment>

<file context>
@@ -0,0 +1,32 @@
+    if (typeof artist?.id !== "string" || typeof artist.name !== "string") {
+      return null;
+    }
+    if (typeof savedAt !== "number") return null;
+    if (now - savedAt > PENDING_INTENT_MAX_AGE_MS) return null;
+    return artist;
</file context>

if (now - savedAt > PENDING_INTENT_MAX_AGE_MS) return null;
return artist;
} catch {
return null;
}
}
22 changes: 22 additions & 0 deletions lib/valuation/savePendingIntent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Artist } from "@/components/valuation/types";
import { PENDING_INTENT_KEY } from "@/lib/valuation/pendingIntent";

/**
* Persist the picked artist before opening the auth modal so the run can
* resume even if the component remounts (or the page reloads) mid-auth.
* Best-effort: storage failures fall back to the in-memory ref path.
*/
export function savePendingIntent(
artist: Artist,
now: number = Date.now(),
): void {
try {
if (typeof sessionStorage === "undefined") return;
sessionStorage.setItem(
PENDING_INTENT_KEY,
JSON.stringify({ artist, savedAt: now }),
);
} catch {
// Private-mode quota / disabled storage — the ref fast path still works.
}
}