Skip to content
Merged
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
40 changes: 40 additions & 0 deletions src/components/Card/share-asset/captureShareAsset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,48 @@ export class ShareAssetCaptureError extends Error {
}
}

/**
* Wait for the asset's content to be painted before we snapshot.
*
* The pink card + its drop-shadow are synchronous, but the card's pixelated
* hand is drawn into a <canvas> that PixelatedCardFace appends ASYNCHRONOUSLY
* (new Image() → onload → appendChild — see rasterImg / PixelatedHand). Unlike
* an <img>, html-to-image cannot wait for a not-yet-mounted <canvas>, so
* capturing too early yields a blank card — just the pink box + its floating
* shadow (the launch-day "blank share asset" bug; silent — capture succeeds,
* so nothing reaches Sentry). Gate on:
* - document.fonts.ready (the hero/username use a web font)
* - every <img> decoded (badge stickers + the card's small logo)
* - the async hand <canvas> being mounted
* bounded by a timeout so a genuinely-stuck asset still captures (never hangs).
*/
const CAPTURE_READY_TIMEOUT_MS = 2500

async function waitForAssetReady(node: HTMLElement): Promise<void> {
if (typeof document !== 'undefined' && document.fonts?.ready) {
try {
await document.fonts.ready
} catch {
// fonts.ready can reject in odd states — capture anyway.
}
}
await Promise.all(
Array.from(node.querySelectorAll('img')).map((img) =>
typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve()
)
)
// Poll for the async hand canvas to mount (it's appended on image.onload,
// outside React's tree, so html-to-image can't wait for it on its own).
const start = typeof performance !== 'undefined' ? performance.now() : 0
const elapsed = (): number => (typeof performance !== 'undefined' ? performance.now() : Infinity) - start
while (!node.querySelector('canvas') && elapsed() < CAPTURE_READY_TIMEOUT_MS) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
Comment on lines +60 to +79

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The 2.5s cap does not bound the full readiness wait.

Lines 61-72 await document.fonts.ready and every img.decode() before the timeout is applied, and the canvas loop itself can still overshoot if requestAnimationFrame is throttled/paused. Since ShareAssetActions awaits captureShareAsset, a stalled font/image load can still hang the share action instead of falling back after 2500ms. Race the entire readiness sequence against a real timer.

Proposed fix
 const CAPTURE_READY_TIMEOUT_MS = 2500
 
 async function waitForAssetReady(node: HTMLElement): Promise<void> {
-    if (typeof document !== 'undefined' && document.fonts?.ready) {
-        try {
-            await document.fonts.ready
-        } catch {
-            // fonts.ready can reject in odd states — capture anyway.
-        }
-    }
-    await Promise.all(
-        Array.from(node.querySelectorAll('img')).map((img) =>
-            typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve()
-        )
-    )
-    // Poll for the async hand canvas to mount (it's appended on image.onload,
-    // outside React's tree, so html-to-image can't wait for it on its own).
-    const start = typeof performance !== 'undefined' ? performance.now() : 0
-    const elapsed = (): number => (typeof performance !== 'undefined' ? performance.now() : Infinity) - start
-    while (!node.querySelector('canvas') && elapsed() < CAPTURE_READY_TIMEOUT_MS) {
-        await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
-    }
+    await Promise.race([
+        (async () => {
+            if (typeof document !== 'undefined' && document.fonts?.ready) {
+                try {
+                    await document.fonts.ready
+                } catch {
+                    // fonts.ready can reject in odd states — capture anyway.
+                }
+            }
+
+            await Promise.all(
+                Array.from(node.querySelectorAll('img')).map((img) =>
+                    typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve()
+                )
+            )
+
+            while (!node.querySelector('canvas')) {
+                await new Promise<void>((resolve) => setTimeout(resolve, 16))
+            }
+        })(),
+        new Promise<void>((resolve) => setTimeout(resolve, CAPTURE_READY_TIMEOUT_MS)),
+    ])
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function waitForAssetReady(node: HTMLElement): Promise<void> {
if (typeof document !== 'undefined' && document.fonts?.ready) {
try {
await document.fonts.ready
} catch {
// fonts.ready can reject in odd states — capture anyway.
}
}
await Promise.all(
Array.from(node.querySelectorAll('img')).map((img) =>
typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve()
)
)
// Poll for the async hand canvas to mount (it's appended on image.onload,
// outside React's tree, so html-to-image can't wait for it on its own).
const start = typeof performance !== 'undefined' ? performance.now() : 0
const elapsed = (): number => (typeof performance !== 'undefined' ? performance.now() : Infinity) - start
while (!node.querySelector('canvas') && elapsed() < CAPTURE_READY_TIMEOUT_MS) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
async function waitForAssetReady(node: HTMLElement): Promise<void> {
await Promise.race([
(async () => {
if (typeof document !== 'undefined' && document.fonts?.ready) {
try {
await document.fonts.ready
} catch {
// fonts.ready can reject in odd states — capture anyway.
}
}
await Promise.all(
Array.from(node.querySelectorAll('img')).map((img) =>
typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve()
)
)
while (!node.querySelector('canvas')) {
await new Promise<void>((resolve) => setTimeout(resolve, 16))
}
})(),
new Promise<void>((resolve) => setTimeout(resolve, CAPTURE_READY_TIMEOUT_MS)),
])
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Card/share-asset/captureShareAsset.ts` around lines 60 - 79,
The readiness timeout in waitForAssetReady is only applied after fonts and image
decoding complete, so a slow font or img.decode can still block
captureShareAsset beyond the intended cap. Update waitForAssetReady to bound the
entire readiness sequence with a real timeout, racing document.fonts.ready, all
img.decode() calls, and the canvas polling loop together against
CAPTURE_READY_TIMEOUT_MS. Keep the fix localized to waitForAssetReady and ensure
ShareAssetActions can fall back promptly instead of hanging.

}

export async function captureShareAsset(node: HTMLElement): Promise<Blob> {
try {
await waitForAssetReady(node)
const blob = await toBlob(node, {
width: CANVAS_W,
height: CANVAS_H,
Expand Down
Loading