fix: race condition on Connect with Smirk button + sync TS types

The extension's window.smirk injection runs as `<script type=module>`,
which browsers defer until after DOMContentLoaded. The previous
detection (sync check + once at +500ms) could race past that on
heavier Next.js pages, leaving the Connect button stuck greyed-out
even though the extension was installed and unlocked.

Three-layer fix in both `app/page.tsx` and `app/tip/[id]/TipPageClient.tsx`:

  1. Immediate sync check (fast path: extension already there).
  2. Listen for `smirk-ready` CustomEvent (extension v0.2.5+ dispatches it).
  3. Polling fallback at 100ms intervals up to 3s, for older
     extensions or any future browser quirk. Cheap; stops as soon as
     window.smirk appears.

`hasExtension` stays `null` (initial) until either we resolve `true`
or hit the 3s timeout and resolve `false` — so the install prompt
doesn't flash on first paint while polling is still in flight.

Also updated `types/smirk.d.ts` to current extension API surface:
asset-scoped `connect(assets?: SmirkAsset[])`, `getAddresses`,
`requestPayment`, and the `smirk-ready` WindowEventMap entry.
Per-asset return fields are nullable now since the user can approve
a subset of assets at connect time (cross-chain deanon mitigation).
This commit is contained in:
jwinterm
2026-05-09 14:46:42 -04:00
parent 9b9a2d2d7f
commit be48317caa
3 changed files with 135 additions and 19 deletions
+45 -6
View File
@@ -26,13 +26,52 @@ export default function Home() {
const [checkingSession, setCheckingSession] = useState(true);
const [userCount, setUserCount] = useState<number | null>(null);
// Check for extension on mount
// Check for extension on mount.
//
// The Smirk extension injects via `<script type="module">`, which
// the browser DEFERS until after DOMContentLoaded — so a single
// immediate-or-500ms check could race past the injection on a busy
// Next.js page (the cause of an old "Connect" stays-greyed-out
// bug). Three layers cover all timings:
//
// 1. Immediate sync check (fast path: extension already there).
// 2. Listen for the `smirk-ready` CustomEvent (extension v0.2.5+).
// 3. Polling fallback up to ~3s, for older extensions + any
// future browser quirk. Cheap; stops as soon as found.
useEffect(() => {
const check = () => setHasExtension(!!window.smirk);
// Check immediately and after a delay (extension might inject late)
check();
const timeout = setTimeout(check, 500);
return () => clearTimeout(timeout);
let cancelled = false;
const found = () => {
if (!cancelled && window.smirk) {
setHasExtension(true);
return true;
}
return false;
};
// Layer 1: immediate.
if (found()) return;
// Layer 2: event from the extension (v0.2.5+).
const onReady = () => found();
window.addEventListener('smirk-ready', onReady);
// Layer 3: polling fallback (100ms intervals up to 3s).
const poll = setInterval(() => {
if (found()) clearInterval(poll);
}, 100);
// Final timeout: definitively mark as missing if still not seen.
const giveUp = setTimeout(() => {
clearInterval(poll);
if (!cancelled && !window.smirk) setHasExtension(false);
}, 3000);
return () => {
cancelled = true;
window.removeEventListener('smirk-ready', onReady);
clearInterval(poll);
clearTimeout(giveUp);
};
}, []);
// Fetch user count on mount
+31 -5
View File
@@ -30,12 +30,38 @@ export default function TipPageClient({ tipId }: { tipId: string }) {
const [claimError, setClaimError] = useState<string | null>(null);
const [claimSuccess, setClaimSuccess] = useState<{ txid: string } | null>(null);
// Detect extension. See `app/page.tsx` for rationale — module-script
// injection is deferred, so we layer a sync check + smirk-ready
// event + 3s polling fallback.
useEffect(() => {
// Check for extension
const check = () => setHasExtension(!!window.smirk);
check();
const timeout = setTimeout(check, 500);
return () => clearTimeout(timeout);
let cancelled = false;
const found = () => {
if (!cancelled && window.smirk) {
setHasExtension(true);
return true;
}
return false;
};
if (found()) return;
const onReady = () => found();
window.addEventListener('smirk-ready', onReady);
const poll = setInterval(() => {
if (found()) clearInterval(poll);
}, 100);
const giveUp = setTimeout(() => {
clearInterval(poll);
if (!cancelled && !window.smirk) setHasExtension(false);
}, 3000);
return () => {
cancelled = true;
window.removeEventListener('smirk-ready', onReady);
clearInterval(poll);
clearTimeout(giveUp);
};
}, []);
// Extract fragment key from URL
+59 -8
View File
@@ -1,15 +1,33 @@
// TypeScript types for the Smirk extension's injected window.smirk API
// TypeScript types for the Smirk extension's injected window.smirk API.
//
// Surface mirrors `smirk-extension/src/inject/smirk-api.ts`. Keep in
// sync when the extension's API changes — when something doesn't
// match here, the website breaks silently (the extension's actual
// runtime is the source of truth, this file just lets us autocomplete).
type SmirkAsset = 'btc' | 'ltc' | 'xmr' | 'wow' | 'grin';
// Asset-scoped connect (extension v0.2.x onward) returns only the
// approved assets — fields for un-approved assets are `null` rather
// than missing, so callers can typecheck per-asset without rebinding.
interface SmirkPublicKeys {
btc: string;
ltc: string;
xmr: string;
wow: string;
grin: string;
btc: string | null;
ltc: string | null;
xmr: string | null;
wow: string | null;
grin: string | null;
}
interface SmirkAddresses {
btc: string | null;
ltc: string | null;
xmr: string | null;
wow: string | null;
grin: string | null;
}
interface SmirkSignature {
asset: 'btc' | 'ltc' | 'xmr' | 'wow' | 'grin';
asset: SmirkAsset;
signature: string;
publicKey: string;
}
@@ -25,16 +43,49 @@ interface SmirkClaimResult {
error?: string;
}
interface SmirkPaymentRequest {
asset: 'btc' | 'ltc' | 'xmr' | 'wow';
amount: string; // Atomic-units string (avoid float)
address: string;
memo?: string;
}
interface SmirkPaymentResult {
success: boolean;
txid?: string;
error?: string;
}
interface SmirkAPI {
isSmirk: boolean;
connect(): Promise<SmirkPublicKeys>;
/**
* Connect to Smirk wallet — requests user approval to share public keys.
* @param assets Optional asset scope. If omitted, requests all assets.
* Per-asset requests reduce metadata exposure (UI_DESIGN
* Principle 7).
*/
connect(assets?: SmirkAsset[]): Promise<SmirkPublicKeys>;
disconnect(): Promise<void>;
isConnected(): Promise<boolean>;
getPublicKeys(): Promise<SmirkPublicKeys | null>;
getAddresses(): Promise<SmirkAddresses | null>;
signMessage(message: string): Promise<SmirkSignResult>;
/**
* Request a payment from the user. Surfaces the Smirk send flow
* pre-filled with the asset/amount/address; user must approve.
*/
requestPayment(request: SmirkPaymentRequest): Promise<SmirkPaymentResult>;
claimPublicTip(tipId: string, fragmentKey: string): Promise<SmirkClaimResult>;
}
// CustomEvent fired on `window` when the extension finishes injecting
// its API. Listen for this in your detection logic alongside an
// initial sync check; the extension may inject before OR after your
// listener mounts (module scripts defer until after DOMContentLoaded).
interface WindowEventMap {
'smirk-ready': CustomEvent<void>;
}
interface Window {
smirk?: SmirkAPI;
}