mirror of
https://github.com/Such-Software/smirk-website.git
synced 2026-10-17 05:51:28 +02:00
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:
+45
-6
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+59
-8
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user