fix(card): blank share asset — wait for the card-face canvas before capture#2302
Conversation
…sset The launch-day blank share asset: PixelatedCardFace paints the pixelated hand into a <canvas> appended asynchronously (new Image() -> onload -> appendChild), and captureShareAsset called html-to-image without waiting for it. html-to-image awaits <img> but not a not-yet-mounted <canvas>, so a fast/first-of-session share captured a blank card — just the pink box + its floating drop-shadow (the 'fucked shadow'). Silent: capture succeeds, nothing hits Sentry. Gate capture on document.fonts.ready + every <img>.decode() + the hand <canvas> being mounted, bounded by a 2.5s timeout so it never hangs. Win/celebration asset only; rejection asset has no canvas and is unaffected.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds a ChangesPre-capture readiness gate
Estimated code review effort🎯 2 (Simple) | ⏱️ ~8 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
Code-analysis diffPainscore total: 5846.74 → 5846.79 (+0.05) 🆕 New findings (1)
✅ Resolved (1)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with 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.
Inline comments:
In `@src/components/Card/share-asset/captureShareAsset.ts`:
- Around line 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.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 37529d31-ee0b-45ef-8731-4ace47e1a741
📒 Files selected for processing (1)
src/components/Card/share-asset/captureShareAsset.ts
| 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())) | ||
| } |
There was a problem hiding this comment.
🩺 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.
| 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.
Crit: blank share asset (launch day)
Influencers shared the "I'M IN!" card asset with a blank card (empty pink box + a floating drop-shadow); stickers, hero burst, and the
peanut.me/<handle>pill rendered fine.Root cause
PixelatedCardFacepaints the pixelated hand into a<canvas>appended asynchronously (new Image()→onload→appendChild; PixelatedCardFace.tsx rasterImg/PixelatedHand).captureShareAssetcalled html-to-image without waiting for it. html-to-image awaits<img>elements but cannot wait for a not-yet-mounted<canvas>, so a fast / first-share-of-session race captured the card before the hand mounted → blank card + its synchronous box-shadow floating behind it. Silent — capture succeeds, so nothing reached Sentry (confirmed: zero results).Fix (single file — captureShareAsset.ts)
Before
toBlob, gate on:document.fonts.ready+ every<img>.decode()+ the hand<canvas>being mounted — bounded by a 2.5s timeout so a stuck asset still captures (never hangs).Blast radius
Win/celebration asset only (
CardUnlockDrawer/BadgeSkipCelebration→ShareAssetD3). The rejection asset has no canvas and is unaffected. A first-share-of-session race (therasterCachemakes later shares synchronous), widened on launch day by cold caches.Follow-ups (not in this hotfix)
ShareAssetActions).<img>(delete the async-canvas dependency html-to-image can't await).