From 058fbf73040239c04f5229923c3a8ca47eafa613 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 11 Sep 2026 12:07:22 +0800 Subject: [PATCH 1/4] launch form: a cleared first-buy suggestion is visible and reversible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing the field, toggling a preset off, or pressing "No first buy" all marked the suggestion declined for the whole tab session, and the form then showed "· optional" with no explanation and no way back except typing an amount. A creator who touched the field once while exploring saw no suggestion afterwards and could not tell why. Now: clearing the field or toggling a chip off declines in memory only; the explicit button is the one action that persists for the session. Whenever the suggestion is declined and a wallet is connected on the chain, the form says "No first buy. The launch stays free." with a "Suggest 0.01 ETH again" link. While the balance is still being read, or when the read failed, it says so instead of silently showing "optional". Form source tests pin all three. --- app/src/components/launchpad/LaunchForm.tsx | 13 ++++++++++--- app/src/components/launchpad/launch-form.test.ts | 6 ++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index ef366d22..8b6ab038 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -51,7 +51,7 @@ const PERMIT_EXPIRY_S = 30 * 24 * 3600; const LAUNCH_GAS_WEI = 1_000_000_000_000_000n; // 0.001 ETH: deploy + pool init + position mint const BUY_GAS_WEI = 500_000_000_000_000n; // 0.0005 ETH: approvals + swap const GAS_RESERVE_WEI = LAUNCH_GAS_WEI + BUY_GAS_WEI; -const FIRST_BUY_DECLINED_KEY = "ol:first-buy-declined"; // session only: a creator who cleared the suggestion is not nagged on the next launch +const FIRST_BUY_DECLINED_KEY = "ol:first-buy-declined"; // session only, and only via the explicit "No first buy" button: a creator who said no is not nagged on the next launch function randomSalt(): Hex { const b = new Uint8Array(32); @@ -193,7 +193,10 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = " const typedBuy = typedBuyFor && typedBuyFor.quoteId === quoteId ? typedBuyFor.amount : ""; // Read once at mount. Safe for hydration: no wallet is connected on the first render, so the suggestion is absent either way. const [buyDeclined, setBuyDeclined] = useState(() => { try { return typeof sessionStorage !== "undefined" && sessionStorage.getItem(FIRST_BUY_DECLINED_KEY) === "1"; } catch { return false; /* storage blocked: suggest as usual */ } }); - function declineFirstBuy() { setTypedBuyFor(null); setBuyDeclined(true); try { sessionStorage.setItem(FIRST_BUY_DECLINED_KEY, "1"); } catch { /* ignore */ } } + // Clearing the field or toggling a chip off declines for now (in memory); the button declines for the session. Both are + // visible on the form with a way back, so a creator who cleared it while exploring can never wonder where it went. + function declineFirstBuy(forSession = false) { setTypedBuyFor(null); setBuyDeclined(true); if (forSession) { try { sessionStorage.setItem(FIRST_BUY_DECLINED_KEY, "1"); } catch { /* ignore */ } } } + function suggestAgain() { setTypedBuyFor(null); setBuyDeclined(false); try { sessionStorage.removeItem(FIRST_BUY_DECLINED_KEY); } catch { /* ignore */ } } function chooseFirstBuy(v: string) { setTypedBuyFor({ amount: v, quoteId }); setBuyDeclined(false); try { sessionStorage.removeItem(FIRST_BUY_DECLINED_KEY); } catch { /* ignore */ } } // Generated lazily at launch time (a render-time random value would break hydration). const saltRef = useRef(null); @@ -716,7 +719,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = " {initialBuyRaw && buyBalance !== undefined ? ( balance {fmtQuoteUnits(units(buyBalance, quote.decimals), quote.decimals)} ) : null} - {initialBuyRaw ? : null} + {initialBuyRaw ? : null} {buyPreview ? (

@@ -730,6 +733,10 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = "

Suggested {fmtQuoteUnits(Number(defaultFirstBuy(quote)), quote.decimals)} {quote.symbol}, but this wallet has no ETH left for the buy's gas. The launch stays free; you can buy on the token page later.

) : suggestion.reason === "no-wallet" ? (

Connect a wallet on {CHAIN_LABEL} to see the suggested amount.

+ ) : suggestion.reason === "unknown-balance" ? ( +

{buyBalanceFailed || ethBal.isError ? "Could not read your balance, so nothing is suggested. The launch stays free; you can still type an amount." : "Checking your balance for the suggested amount…"}

+ ) : suggestion.reason === "declined" && !typedBuy && address && onChain ? ( +

No first buy. The launch stays free.{defaultFirstBuy(quote) ? <> : null}

) : null}

A token with no holders and no price move looks dead on every screener and sits under quiet launches on the home page. Your first buy opens the chart. Clear it and the launch stays free.

Other traders can buy before you. First-buy slippage tolerance: {FIRST_BUY_SLIPPAGE_BPS / 100}%. Network gas and pool fees apply.

diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index 94c2581c..c36a17de 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -39,6 +39,12 @@ test("launch merge retains chain-scoped marks and honest optional-buy copy", () assert.match(source, /setTypedBuyFor\(\{ amount: v, quoteId \}\)/); assert.match(source, /No first buy/); assert.match(source, /Clear it and the launch stays free/); + // a cleared suggestion is visible and reversible; only the explicit button persists for the session + assert.match(source, /suggestion\.reason === "declined" && !typedBuy && address && onChain/); + assert.match(source, /onClick=\{suggestAgain\}/); + assert.match(source, /onClick=\{\(\) => declineFirstBuy\(true\)\}/); + assert.match(source, /if \(forSession\) \{ try \{ sessionStorage\.setItem\(FIRST_BUY_DECLINED_KEY/); + assert.match(source, /suggestion\.reason === "unknown-balance"/); assert.match(source, /Other traders can buy before you/); assert.match(source, /First-buy slippage tolerance: \{FIRST_BUY_SLIPPAGE_BPS \/ 100\}%/); assert.match(source, /Network gas and pool fees apply/); From 5d88cc1bc49006d3db71bbfeef5b54b15e5ecf4a Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 11 Sep 2026 12:12:36 +0800 Subject: [PATCH 2/4] launch form: the suggested first buy is selected from the start, wallet or not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suggestion only appeared once a wallet was connected and its balance read, so the form opened with "First buy · optional", empty, and "Connect a wallet to see the suggested amount". The default should be visible immediately: 0.01 ETH selected, header "· suggested". A connected wallet can only take it away: dropped when the balance cannot cover amount + gas (or the token balance the amount, or the native balance the gas, for an ERC-20 quote) or when a balance read failed; kept, marked provisional, while a read is still pending so it does not flicker. The rule still never blocks a launch. The "no wallet" note is gone; the cleared note shows without a wallet too. --- app/src/components/launchpad/LaunchForm.tsx | 11 ++++----- .../components/launchpad/launch-form.test.ts | 4 ++-- app/src/lib/launchpad/first-buy.test.ts | 24 ++++++++++--------- app/src/lib/launchpad/first-buy.ts | 24 +++++++++++-------- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index 8b6ab038..752e13c2 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -222,8 +222,9 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = " const nativeBalance: bigint | undefined = ethBal.data?.value; const buyBalance: bigint | undefined = quote.key === "eth" ? nativeBalance : (quoteBal.data as bigint | undefined); const buyBalanceFailed = quote.key === "eth" ? ethBal.isError : quoteBal.isError; - // A suggested buy exists only when the wallet is connected and can cover it and its gas, so it can never block the launch below. - const suggestion = suggestFirstBuy({ quote, connected: Boolean(address) && onChain, balance: buyBalance, nativeBalance, gasReserve: GAS_RESERVE_WEI, declined: buyDeclined || Boolean(typedBuy), parse: parseUnits }); + // The suggested buy is selected from the start; a connected wallet's balances can only take it away (or a failed read), + // so it can never block the launch below. A typed amount keeps the strict checks. + const suggestion = suggestFirstBuy({ quote, connected: Boolean(address) && onChain, balance: buyBalance, nativeBalance, balanceFailed: buyBalanceFailed || ethBal.isError, gasReserve: GAS_RESERVE_WEI, declined: buyDeclined || Boolean(typedBuy), parse: parseUnits }); const initialBuy = typedBuy || suggestion.amount || ""; const buySource: "typed" | "suggested" | "none" = typedBuy ? "typed" : suggestion.amount ? "suggested" : "none"; const initialBuyRaw = parseBuyAmount(initialBuy, quote.decimals); @@ -731,11 +732,9 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = "

Suggested {fmtQuoteUnits(Number(defaultFirstBuy(quote)), quote.decimals)} {quote.symbol}, but this wallet holds only gas. The launch stays free; you can buy on the token page later.

) : suggestion.reason === "no-gas" ? (

Suggested {fmtQuoteUnits(Number(defaultFirstBuy(quote)), quote.decimals)} {quote.symbol}, but this wallet has no ETH left for the buy's gas. The launch stays free; you can buy on the token page later.

- ) : suggestion.reason === "no-wallet" ? ( -

Connect a wallet on {CHAIN_LABEL} to see the suggested amount.

) : suggestion.reason === "unknown-balance" ? ( -

{buyBalanceFailed || ethBal.isError ? "Could not read your balance, so nothing is suggested. The launch stays free; you can still type an amount." : "Checking your balance for the suggested amount…"}

- ) : suggestion.reason === "declined" && !typedBuy && address && onChain ? ( +

Could not read your balance, so nothing is suggested. The launch stays free; you can still type an amount.

+ ) : suggestion.reason === "declined" && !typedBuy ? (

No first buy. The launch stays free.{defaultFirstBuy(quote) ? <> : null}

) : null}

A token with no holders and no price move looks dead on every screener and sits under quiet launches on the home page. Your first buy opens the chart. Clear it and the launch stays free.

diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index c36a17de..ee8910fe 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -29,7 +29,7 @@ test("launch merge retains chain-scoped marks and honest optional-buy copy", () assert.match(source, / declineFirstBuy\(true\)\}/); assert.match(source, /if \(forSession\) \{ try \{ sessionStorage\.setItem\(FIRST_BUY_DECLINED_KEY/); diff --git a/app/src/lib/launchpad/first-buy.test.ts b/app/src/lib/launchpad/first-buy.test.ts index 45d684b2..cd853c00 100644 --- a/app/src/lib/launchpad/first-buy.test.ts +++ b/app/src/lib/launchpad/first-buy.test.ts @@ -8,7 +8,7 @@ const usdg = { key: "usdg" as const, decimals: 6, usd: 1 }; const gitlawb = { key: "gitlawb" as const, decimals: 18, usd: 0.00002 }; const nvda = { key: "stock" as const, decimals: 18, usd: 223.05 }; const GAS = parseUnits("0.0015", 18); // the form's reserve: launch gas + buy gas -const base = { connected: true, declined: false, parse: parseUnits }; +const base = { connected: true, declined: false, balanceFailed: false, parse: parseUnits }; test("default: the first preset for ETH, USDG and GITLAWB; $25 worth for a stock; nothing for a stock without a price", () => { assert.equal(defaultFirstBuy(eth), BUY_PRESETS.eth[0]); @@ -36,25 +36,27 @@ test("amountForUsd: short decimals parseUnits accepts, never more places than th } }); -test("suggestFirstBuy (ETH quote): only when connected and the balance is known and covers amount + gas; every other case launches free", () => { +test("suggestFirstBuy (ETH quote): selected from the start; a connected wallet's balance can only take it away", () => { const enough = parseUnits("0.02", 18); const ethIn = (balance: bigint | undefined, more = {}) => suggestFirstBuy({ ...base, quote: eth, balance, nativeBalance: balance, gasReserve: GAS, ...more }); - assert.deepEqual(ethIn(enough), { amount: "0.01" }); + assert.deepEqual(ethIn(undefined, { connected: false }), { amount: "0.01", provisional: true }, "no wallet yet: the default is shown as selected"); + assert.deepEqual(ethIn(undefined), { amount: "0.01", provisional: true }, "wallet connected, balance still loading: keep it, no flicker"); + assert.deepEqual(ethIn(undefined, { balanceFailed: true }), { amount: null, reason: "unknown-balance" }, "the read failed: drop it so it cannot block the launch"); + assert.deepEqual(ethIn(enough), { amount: "0.01", provisional: false }, "confirmed against the balance"); assert.deepEqual(ethIn(parseUnits("0.0114", 18)), { amount: null, reason: "insufficient" }, "covers the amount and the buy's gas but not the launch's"); - assert.deepEqual(ethIn(parseUnits("0.0115", 18)), { amount: "0.01" }, "exactly amount + both reserves"); - assert.deepEqual(ethIn(undefined), { amount: null, reason: "unknown-balance" }, "a failed or pending balance read never blocks"); - assert.deepEqual(ethIn(undefined, { connected: false }), { amount: null, reason: "no-wallet" }); + assert.deepEqual(ethIn(parseUnits("0.0115", 18)), { amount: "0.01", provisional: false }, "exactly amount + both reserves"); assert.deepEqual(ethIn(enough, { declined: true }), { amount: null, reason: "declined" }); + assert.deepEqual(ethIn(undefined, { connected: false, declined: true }), { amount: null, reason: "declined" }, "cleared stays cleared without a wallet too"); assert.deepEqual(suggestFirstBuy({ ...base, quote: { ...nvda, usd: null }, balance: enough, nativeBalance: enough, gasReserve: 0n }), { amount: null, reason: "no-price" }); }); test("suggestFirstBuy (ERC-20 quote): the token balance must cover the amount and the native balance must cover the gas", () => { const usdgIn = (balance: bigint | undefined, nativeBalance: bigint | undefined) => suggestFirstBuy({ ...base, quote: usdg, balance, nativeBalance, gasReserve: GAS }); - assert.deepEqual(usdgIn(parseUnits("25", 6), GAS), { amount: "25" }, "exact token balance is enough; gas is separate"); + assert.deepEqual(usdgIn(parseUnits("25", 6), GAS), { amount: "25", provisional: false }, "exact token balance is enough; gas is separate"); assert.deepEqual(usdgIn(parseUnits("24.99", 6), GAS), { amount: null, reason: "insufficient" }); assert.deepEqual(usdgIn(parseUnits("100", 6), GAS - 1n), { amount: null, reason: "no-gas" }, "tokens, but one wei short of gas for the launch, the approvals and the swap"); - assert.deepEqual(usdgIn(parseUnits("100", 6), GAS), { amount: "25" }, "exactly the reserve"); - assert.deepEqual(usdgIn(parseUnits("100", 6), undefined), { amount: null, reason: "unknown-balance" }, "native balance unknown: no suggestion, launch stays free"); - assert.deepEqual(usdgIn(undefined, GAS), { amount: null, reason: "unknown-balance" }); - assert.deepEqual(suggestFirstBuy({ ...base, quote: nvda, balance: parseUnits("1", 18), nativeBalance: GAS, gasReserve: GAS }), { amount: "0.11" }); + assert.deepEqual(usdgIn(parseUnits("100", 6), GAS), { amount: "25", provisional: false }, "exactly the reserve"); + assert.deepEqual(usdgIn(parseUnits("100", 6), undefined), { amount: "25", provisional: true }, "native balance still loading: keep it"); + assert.deepEqual(suggestFirstBuy({ ...base, quote: usdg, balance: undefined, nativeBalance: GAS, gasReserve: GAS, balanceFailed: true }), { amount: null, reason: "unknown-balance" }, "token balance read failed: drop it"); + assert.deepEqual(suggestFirstBuy({ ...base, quote: nvda, balance: parseUnits("1", 18), nativeBalance: GAS, gasReserve: GAS }), { amount: "0.11", provisional: false }); }); diff --git a/app/src/lib/launchpad/first-buy.ts b/app/src/lib/launchpad/first-buy.ts index 2ee8c2f0..759306cc 100644 --- a/app/src/lib/launchpad/first-buy.ts +++ b/app/src/lib/launchpad/first-buy.ts @@ -4,10 +4,11 @@ * The launch form pre-fills a small buy (about SUGGESTED_BUY_USD in the quote asset) so a token opens with a holder and * a price move: with neither it reads as dead on every screener and sits under "quiet launches" on the home page. * - * One rule: a SUGGESTED buy never blocks a launch. It exists only when the wallet is connected, the balances are known, - * the quote balance covers the amount, the native balance covers the buy's gas (for an ETH quote both come from the - * same balance), and the creator has not cleared it. In every other case the form launches for free, exactly as - * before. An amount the creator TYPED keeps the strict checks (LaunchForm.tsx). + * One rule: a SUGGESTED buy never blocks a launch. The default is selected from the start, wallet or not, so the form + * always shows what it suggests; once a wallet is connected the balances can only take it away: it is dropped when the + * quote balance cannot cover the amount, the native balance cannot cover the buy's gas (for an ETH quote both come from + * the same balance), or a balance read failed. While a read is still pending the suggestion stays, marked provisional. + * The creator can clear it at any time. An amount the creator TYPED keeps the strict checks (LaunchForm.tsx). */ import type { Quote } from "./config"; @@ -17,8 +18,9 @@ export const SUGGESTED_BUY_USD = 25; /** Quick-pick amounts per quote; the first one is the suggestion (about $25 at the prices this was written at). */ export const BUY_PRESETS: Record = { eth: ["0.01", "0.05", "0.1", "0.25"], usdg: ["25", "100", "250"], gitlawb: ["500000", "1000000", "5000000"], stock: [] }; -export type NoSuggestion = "no-price" | "no-wallet" | "unknown-balance" | "insufficient" | "no-gas" | "declined"; -export type FirstBuySuggestion = { amount: string; reason?: undefined } | { amount: null; reason: NoSuggestion }; +export type NoSuggestion = "no-price" | "unknown-balance" | "insufficient" | "no-gas" | "declined"; +/** `provisional`: shown as selected but not yet confirmed against a balance (no wallet yet, or the read is pending). */ +export type FirstBuySuggestion = { amount: string; provisional: boolean; reason?: undefined } | { amount: null; reason: NoSuggestion }; function trimZeros(s: string): string { return s.includes(".") ? s.replace(/0+$/, "").replace(/\.$/, "") : s; @@ -52,6 +54,8 @@ export type SuggestInput = { balance: bigint | undefined; /** Native balance, which pays the buy's gas (and the approvals an ERC-20 quote needs first); undefined while unknown. */ nativeBalance: bigint | undefined; + /** A balance read failed (as opposed to still loading): the suggestion is dropped rather than left blocking the launch. */ + balanceFailed: boolean; /** Native amount kept back for that gas. */ gasReserve: bigint; /** The creator cleared the suggestion (this session). */ @@ -64,11 +68,11 @@ export function suggestFirstBuy(i: SuggestInput): FirstBuySuggestion { if (i.declined) return { amount: null, reason: "declined" }; const amount = defaultFirstBuy(i.quote); if (!amount) return { amount: null, reason: "no-price" }; - if (!i.connected) return { amount: null, reason: "no-wallet" }; - if (i.balance === undefined || i.nativeBalance === undefined) return { amount: null, reason: "unknown-balance" }; + if (!i.connected) return { amount, provisional: true }; // selected from the start; a wallet can only take it away + if (i.balance === undefined || i.nativeBalance === undefined) return i.balanceFailed ? { amount: null, reason: "unknown-balance" } : { amount, provisional: true }; const raw = i.parse(amount, i.quote.decimals); - if (i.quote.key === "eth") return raw + i.gasReserve > i.balance ? { amount: null, reason: "insufficient" } : { amount }; + if (i.quote.key === "eth") return raw + i.gasReserve > i.balance ? { amount: null, reason: "insufficient" } : { amount, provisional: false }; if (raw > i.balance) return { amount: null, reason: "insufficient" }; if (i.nativeBalance < i.gasReserve) return { amount: null, reason: "no-gas" }; - return { amount }; + return { amount, provisional: false }; } From 9ed0a2fbc78fbc5fdb05c3fd9e5b9c96320ef7e8 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 11 Sep 2026 13:14:07 +0800 Subject: [PATCH 3/4] first buy (CodeRabbit, PR #26): read the session flag through useSyncExternalStore, not a state initializer Once the default became selected without a wallet, a tab holding the "No first buy" flag rendered the suggestion on the server and the cleared state on the client: a hydration mismatch. The flag is now an external store (lib/launchpad/first-buy-session.ts) read with useSyncExternalStore: the server snapshot is always false, so both renders match, and the client re-renders with the real flag right after. No state is set in an effect. The form never touches sessionStorage directly. Tests: the store's server/client snapshots, notifications and blocked-storage behaviour; the form source test pins the hook and forbids direct storage access. --- app/src/components/launchpad/LaunchForm.tsx | 21 ++++---- .../components/launchpad/launch-form.test.ts | 5 +- .../lib/launchpad/first-buy-session.test.ts | 48 +++++++++++++++++++ app/src/lib/launchpad/first-buy-session.ts | 40 ++++++++++++++++ 4 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 app/src/lib/launchpad/first-buy-session.test.ts create mode 100644 app/src/lib/launchpad/first-buy-session.ts diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index 752e13c2..f896acd2 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import { useAccount, useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; import { getPublicClient, getWalletClient } from "wagmi/actions"; @@ -15,6 +15,7 @@ import { ERC20_MIN_ABI, ERC20_TRANSFER_EVENT, LAUNCH_FACTORY_ABI, PERMIT2_ABI, U import { BPS, DEFAULT_SUPPLY, FEE_PRESETS, MCAP_PRESETS, TICK_SPACING, launchpad, quoteUsdOf, type Quote } from "@/lib/launchpad/config"; import { fdvForStartTick, fmtCompact, fmtQuoteUnits, fmtUsd, initialBuyPreview, minOut, startTickForFdv, tickToTokensPerQuote, units } from "@/lib/launchpad/math"; import { BUY_PRESETS, defaultFirstBuy, suggestFirstBuy } from "@/lib/launchpad/first-buy"; +import { getFirstBuyDeclined, getFirstBuyDeclinedServer, setFirstBuyDeclined, subscribeFirstBuyDeclined } from "@/lib/launchpad/first-buy-session"; import { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap"; import { stockMcapPresets } from "@/lib/launchpad/stocks"; import { GITLAWB_SITE, gitlawbMcapPresets } from "@/lib/launchpad/gitlawb"; @@ -51,7 +52,6 @@ const PERMIT_EXPIRY_S = 30 * 24 * 3600; const LAUNCH_GAS_WEI = 1_000_000_000_000_000n; // 0.001 ETH: deploy + pool init + position mint const BUY_GAS_WEI = 500_000_000_000_000n; // 0.0005 ETH: approvals + swap const GAS_RESERVE_WEI = LAUNCH_GAS_WEI + BUY_GAS_WEI; -const FIRST_BUY_DECLINED_KEY = "ol:first-buy-declined"; // session only, and only via the explicit "No first buy" button: a creator who said no is not nagged on the next launch function randomSalt(): Hex { const b = new Uint8Array(32); @@ -191,13 +191,16 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = " const [typedBuyFor, setTypedBuyFor] = useState<{ amount: string; quoteId: string } | null>(null); const quoteId = `${chain}:${quote.address.toLowerCase()}`; const typedBuy = typedBuyFor && typedBuyFor.quoteId === quoteId ? typedBuyFor.amount : ""; - // Read once at mount. Safe for hydration: no wallet is connected on the first render, so the suggestion is absent either way. - const [buyDeclined, setBuyDeclined] = useState(() => { try { return typeof sessionStorage !== "undefined" && sessionStorage.getItem(FIRST_BUY_DECLINED_KEY) === "1"; } catch { return false; /* storage blocked: suggest as usual */ } }); - // Clearing the field or toggling a chip off declines for now (in memory); the button declines for the session. Both are - // visible on the form with a way back, so a creator who cleared it while exploring can never wonder where it went. - function declineFirstBuy(forSession = false) { setTypedBuyFor(null); setBuyDeclined(true); if (forSession) { try { sessionStorage.setItem(FIRST_BUY_DECLINED_KEY, "1"); } catch { /* ignore */ } } } - function suggestAgain() { setTypedBuyFor(null); setBuyDeclined(false); try { sessionStorage.removeItem(FIRST_BUY_DECLINED_KEY); } catch { /* ignore */ } } - function chooseFirstBuy(v: string) { setTypedBuyFor({ amount: v, quoteId }); setBuyDeclined(false); try { sessionStorage.removeItem(FIRST_BUY_DECLINED_KEY); } catch { /* ignore */ } } + // Declined for this tab session (the "No first buy" button) is an external store read with useSyncExternalStore: the + // server snapshot is false, so server and client markup match during hydration and the client re-renders with the + // real flag right after (lib/launchpad/first-buy-session.ts). Clearing the field or a chip declines in memory only. + // Both states are visible on the form with a way back, so a creator who cleared it while exploring never wonders where it went. + const sessionDeclined = useSyncExternalStore(subscribeFirstBuyDeclined, getFirstBuyDeclined, getFirstBuyDeclinedServer); + const [declinedNow, setDeclinedNow] = useState(false); + const buyDeclined = declinedNow || sessionDeclined; + function declineFirstBuy(forSession = false) { setTypedBuyFor(null); setDeclinedNow(true); if (forSession) setFirstBuyDeclined(true); } + function suggestAgain() { setTypedBuyFor(null); setDeclinedNow(false); setFirstBuyDeclined(false); } + function chooseFirstBuy(v: string) { setTypedBuyFor({ amount: v, quoteId }); setDeclinedNow(false); setFirstBuyDeclined(false); } // Generated lazily at launch time (a render-time random value would break hydration). const saltRef = useRef(null); // The metadataURI is keyed by meta_key, so findSalt can change the salt freely within one attempt. Both refs are diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index ee8910fe..add7223b 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -43,7 +43,10 @@ test("launch merge retains chain-scoped marks and honest optional-buy copy", () assert.match(source, /suggestion\.reason === "declined" && !typedBuy/); assert.match(source, /onClick=\{suggestAgain\}/); assert.match(source, /onClick=\{\(\) => declineFirstBuy\(true\)\}/); - assert.match(source, /if \(forSession\) \{ try \{ sessionStorage\.setItem\(FIRST_BUY_DECLINED_KEY/); + assert.match(source, /if \(forSession\) setFirstBuyDeclined\(true\)/); + // hydration: the session flag is read through useSyncExternalStore with a false server snapshot, never in a state initializer + assert.match(source, /useSyncExternalStore\(subscribeFirstBuyDeclined, getFirstBuyDeclined, getFirstBuyDeclinedServer\)/); + assert.doesNotMatch(source, /sessionStorage/, "the form never touches sessionStorage directly"); assert.match(source, /suggestion\.reason === "unknown-balance"/); assert.match(source, /Other traders can buy before you/); assert.match(source, /First-buy slippage tolerance: \{FIRST_BUY_SLIPPAGE_BPS \/ 100\}%/); diff --git a/app/src/lib/launchpad/first-buy-session.test.ts b/app/src/lib/launchpad/first-buy-session.test.ts new file mode 100644 index 00000000..fa0fbe7d --- /dev/null +++ b/app/src/lib/launchpad/first-buy-session.test.ts @@ -0,0 +1,48 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { FIRST_BUY_DECLINED_KEY, getFirstBuyDeclined, getFirstBuyDeclinedServer, setFirstBuyDeclined, subscribeFirstBuyDeclined } from "./first-buy-session.ts"; + +function fakeStorage(): Storage & { store: Map } { + const store = new Map(); + return { store, getItem: (k) => store.get(k) ?? null, setItem: (k, v) => void store.set(k, String(v)), removeItem: (k) => void store.delete(k), clear: () => store.clear(), key: () => null, length: 0 } as Storage & { store: Map }; +} + +test("server snapshot is always false; client snapshot follows the flag; writes notify subscribers", () => { + const g = globalThis as { sessionStorage?: Storage }; + const prev = g.sessionStorage; + g.sessionStorage = fakeStorage(); + try { + assert.equal(getFirstBuyDeclinedServer(), false); + assert.equal(getFirstBuyDeclined(), false, "cold tab"); + let notified = 0; + const off = subscribeFirstBuyDeclined(() => { notified += 1; }); + setFirstBuyDeclined(true); + assert.equal(getFirstBuyDeclined(), true); + assert.equal((g.sessionStorage as ReturnType).store.get(FIRST_BUY_DECLINED_KEY), "1"); + setFirstBuyDeclined(false); + assert.equal(getFirstBuyDeclined(), false); + assert.equal(notified, 2); + off(); + setFirstBuyDeclined(true); + assert.equal(notified, 2, "unsubscribed"); + assert.equal(getFirstBuyDeclinedServer(), false, "the server never sees the flag, whatever the tab holds: that is the hydration guarantee"); + } finally { + if (prev === undefined) delete g.sessionStorage; else g.sessionStorage = prev; + } +}); + +test("storage blocked or absent: reads are false and writes do not throw", () => { + const g = globalThis as { sessionStorage?: Storage }; + const prev = g.sessionStorage; + delete g.sessionStorage; + try { + assert.equal(getFirstBuyDeclined(), false); + assert.doesNotThrow(() => setFirstBuyDeclined(true)); + Object.defineProperty(g, "sessionStorage", { configurable: true, get() { throw new Error("blocked"); } }); + assert.equal(getFirstBuyDeclined(), false); + assert.doesNotThrow(() => setFirstBuyDeclined(false)); + } finally { + delete g.sessionStorage; + if (prev !== undefined) g.sessionStorage = prev; + } +}); diff --git a/app/src/lib/launchpad/first-buy-session.ts b/app/src/lib/launchpad/first-buy-session.ts new file mode 100644 index 00000000..ba51bbcd --- /dev/null +++ b/app/src/lib/launchpad/first-buy-session.ts @@ -0,0 +1,40 @@ +/** + * "No first buy" for this tab session, as an external store so the launch form can read it with useSyncExternalStore: + * the server snapshot is always false, so server and client render the same markup during hydration, and the client + * re-renders with the real flag right after. (A lazy useState read of sessionStorage rendered the suggestion on the + * server and the cleared state on the client: a hydration mismatch. An effect that sets state is rejected by the lint + * rules.) Only the explicit "No first buy" button writes the flag; clearing the field or a chip is in-memory only. + */ + +export const FIRST_BUY_DECLINED_KEY = "ol:first-buy-declined"; + +const listeners = new Set<() => void>(); + +export function subscribeFirstBuyDeclined(cb: () => void): () => void { + listeners.add(cb); + return () => { listeners.delete(cb); }; +} + +/** Client snapshot: the flag, or false when storage is blocked or absent. */ +export function getFirstBuyDeclined(): boolean { + try { + return typeof sessionStorage !== "undefined" && sessionStorage.getItem(FIRST_BUY_DECLINED_KEY) === "1"; + } catch { + return false; + } +} + +/** Server snapshot: never declined, so the first client render matches the server's. */ +export function getFirstBuyDeclinedServer(): boolean { + return false; +} + +export function setFirstBuyDeclined(declined: boolean): void { + try { + if (declined) sessionStorage.setItem(FIRST_BUY_DECLINED_KEY, "1"); + else sessionStorage.removeItem(FIRST_BUY_DECLINED_KEY); + } catch { + /* storage blocked: the in-memory state still applies for this render */ + } + for (const cb of listeners) cb(); +} From 9302fdc1d4a181795ffe2f79e7b1116a339bb700 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 11 Sep 2026 13:48:31 +0800 Subject: [PATCH 4/4] first buy (CodeRabbit, PR #26): keep the session flag in memory when storage is blocked With sessionStorage blocked or absent, the explicit "No first buy" was lost on a remount within the tab because reads returned false. The store now keeps the latest value in memory and serves it whenever storage cannot be read, so such a tab keeps the choice until reload. The server snapshot stays false, so the hydration behaviour is unchanged. Tests cover true and false under absent and blocked storage. --- app/src/lib/launchpad/first-buy-session.test.ts | 11 ++++++++--- app/src/lib/launchpad/first-buy-session.ts | 12 ++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/src/lib/launchpad/first-buy-session.test.ts b/app/src/lib/launchpad/first-buy-session.test.ts index fa0fbe7d..2c1a46ed 100644 --- a/app/src/lib/launchpad/first-buy-session.test.ts +++ b/app/src/lib/launchpad/first-buy-session.test.ts @@ -31,18 +31,23 @@ test("server snapshot is always false; client snapshot follows the flag; writes } }); -test("storage blocked or absent: reads are false and writes do not throw", () => { +test("storage blocked or absent: writes do not throw and the latest value is served from memory until reload (CodeRabbit, PR #26)", () => { const g = globalThis as { sessionStorage?: Storage }; const prev = g.sessionStorage; delete g.sessionStorage; try { - assert.equal(getFirstBuyDeclined(), false); + setFirstBuyDeclined(false); + assert.equal(getFirstBuyDeclined(), false, "absent storage, nothing declined yet"); assert.doesNotThrow(() => setFirstBuyDeclined(true)); + assert.equal(getFirstBuyDeclined(), true, "absent storage: the explicit dismissal survives a remount within the tab"); Object.defineProperty(g, "sessionStorage", { configurable: true, get() { throw new Error("blocked"); } }); - assert.equal(getFirstBuyDeclined(), false); + assert.equal(getFirstBuyDeclined(), true, "blocked storage: still the in-memory value"); assert.doesNotThrow(() => setFirstBuyDeclined(false)); + assert.equal(getFirstBuyDeclined(), false, "and it clears the same way"); + assert.equal(getFirstBuyDeclinedServer(), false, "the server snapshot ignores memory too"); } finally { delete g.sessionStorage; if (prev !== undefined) g.sessionStorage = prev; + setFirstBuyDeclined(false); } }); diff --git a/app/src/lib/launchpad/first-buy-session.ts b/app/src/lib/launchpad/first-buy-session.ts index ba51bbcd..dda7ffbf 100644 --- a/app/src/lib/launchpad/first-buy-session.ts +++ b/app/src/lib/launchpad/first-buy-session.ts @@ -9,18 +9,21 @@ export const FIRST_BUY_DECLINED_KEY = "ol:first-buy-declined"; const listeners = new Set<() => void>(); +/** The latest value, for tabs where storage is blocked or absent: it then holds until reload, the closest thing to a session such a tab has. */ +let inMemoryDeclined = false; export function subscribeFirstBuyDeclined(cb: () => void): () => void { listeners.add(cb); return () => { listeners.delete(cb); }; } -/** Client snapshot: the flag, or false when storage is blocked or absent. */ +/** Client snapshot: the flag from storage, or the in-memory value when storage is blocked or absent. */ export function getFirstBuyDeclined(): boolean { try { - return typeof sessionStorage !== "undefined" && sessionStorage.getItem(FIRST_BUY_DECLINED_KEY) === "1"; + if (typeof sessionStorage === "undefined") return inMemoryDeclined; + return sessionStorage.getItem(FIRST_BUY_DECLINED_KEY) === "1"; } catch { - return false; + return inMemoryDeclined; } } @@ -30,11 +33,12 @@ export function getFirstBuyDeclinedServer(): boolean { } export function setFirstBuyDeclined(declined: boolean): void { + inMemoryDeclined = declined; try { if (declined) sessionStorage.setItem(FIRST_BUY_DECLINED_KEY, "1"); else sessionStorage.removeItem(FIRST_BUY_DECLINED_KEY); } catch { - /* storage blocked: the in-memory state still applies for this render */ + /* storage blocked: the in-memory value above is what reads return until reload */ } for (const cb of listeners) cb(); }