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 [checkingSession, setCheckingSession] = useState(true);
|
||||||
const [userCount, setUserCount] = useState<number | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const check = () => setHasExtension(!!window.smirk);
|
let cancelled = false;
|
||||||
// Check immediately and after a delay (extension might inject late)
|
const found = () => {
|
||||||
check();
|
if (!cancelled && window.smirk) {
|
||||||
const timeout = setTimeout(check, 500);
|
setHasExtension(true);
|
||||||
return () => clearTimeout(timeout);
|
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
|
// Fetch user count on mount
|
||||||
|
|||||||
@@ -30,12 +30,38 @@ export default function TipPageClient({ tipId }: { tipId: string }) {
|
|||||||
const [claimError, setClaimError] = useState<string | null>(null);
|
const [claimError, setClaimError] = useState<string | null>(null);
|
||||||
const [claimSuccess, setClaimSuccess] = useState<{ txid: 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(() => {
|
useEffect(() => {
|
||||||
// Check for extension
|
let cancelled = false;
|
||||||
const check = () => setHasExtension(!!window.smirk);
|
const found = () => {
|
||||||
check();
|
if (!cancelled && window.smirk) {
|
||||||
const timeout = setTimeout(check, 500);
|
setHasExtension(true);
|
||||||
return () => clearTimeout(timeout);
|
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
|
// 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 {
|
interface SmirkPublicKeys {
|
||||||
btc: string;
|
btc: string | null;
|
||||||
ltc: string;
|
ltc: string | null;
|
||||||
xmr: string;
|
xmr: string | null;
|
||||||
wow: string;
|
wow: string | null;
|
||||||
grin: string;
|
grin: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SmirkAddresses {
|
||||||
|
btc: string | null;
|
||||||
|
ltc: string | null;
|
||||||
|
xmr: string | null;
|
||||||
|
wow: string | null;
|
||||||
|
grin: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SmirkSignature {
|
interface SmirkSignature {
|
||||||
asset: 'btc' | 'ltc' | 'xmr' | 'wow' | 'grin';
|
asset: SmirkAsset;
|
||||||
signature: string;
|
signature: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
}
|
}
|
||||||
@@ -25,16 +43,49 @@ interface SmirkClaimResult {
|
|||||||
error?: string;
|
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 {
|
interface SmirkAPI {
|
||||||
isSmirk: boolean;
|
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>;
|
disconnect(): Promise<void>;
|
||||||
isConnected(): Promise<boolean>;
|
isConnected(): Promise<boolean>;
|
||||||
getPublicKeys(): Promise<SmirkPublicKeys | null>;
|
getPublicKeys(): Promise<SmirkPublicKeys | null>;
|
||||||
|
getAddresses(): Promise<SmirkAddresses | null>;
|
||||||
signMessage(message: string): Promise<SmirkSignResult>;
|
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>;
|
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 {
|
interface Window {
|
||||||
smirk?: SmirkAPI;
|
smirk?: SmirkAPI;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user