Skip to content

fix(card): blank share asset — wait for the card-face canvas before capture#2302

Merged
Hugo0 merged 1 commit into
mainfrom
hotfix/share-asset-blank-capture
Jun 29, 2026
Merged

fix(card): blank share asset — wait for the card-face canvas before capture#2302
Hugo0 merged 1 commit into
mainfrom
hotfix/share-asset-blank-capture

Conversation

@Hugo0

@Hugo0 Hugo0 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

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

PixelatedCardFace paints the pixelated hand into a <canvas> appended asynchronously (new Image()onloadappendChild; PixelatedCardFace.tsx rasterImg/PixelatedHand). captureShareAsset called 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 / BadgeSkipCelebrationShareAssetD3). The rejection asset has no canvas and is unaffected. A first-share-of-session race (the rasterCache makes later shares synchronous), widened on launch day by cold caches.

Follow-ups (not in this hotfix)

  • Deterministic gate: disable Share/Save until the canvas signals ready (ShareAssetActions).
  • Root-cause cleanup: render the hand as a synchronous pre-rasterised data-URI <img> (delete the async-canvas dependency html-to-image can't await).

…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.
@vercel

vercel Bot commented Jun 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jun 29, 2026 1:15pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a waitForAssetReady helper to captureShareAsset.ts that gates PNG capture behind font readiness, image decoding, and a polling loop for async <canvas> mount, bounded by CAPTURE_READY_TIMEOUT_MS. captureShareAsset now awaits this helper before calling toBlob.

Changes

Pre-capture readiness gate

Layer / File(s) Summary
waitForAssetReady helper and integration
src/components/Card/share-asset/captureShareAsset.ts
Adds CAPTURE_READY_TIMEOUT_MS constant and waitForAssetReady(node) that awaits document.fonts.ready, decodes all <img> elements, and polls via requestAnimationFrame until a <canvas> appears or the timeout expires. captureShareAsset calls this before toBlob.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the blank share-asset bug and the canvas wait fix.
Description check ✅ Passed The description matches the PR's root cause and fix for the blank share asset issue.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 5846.74 → 5846.79 (+0.05)
Findings: 0 net (+1 new, -1 resolved)

🆕 New findings (1)

  • medium complexity — src/components/Card/share-asset/captureShareAsset.ts — CC 28, MI 61.28, SLOC 94

✅ Resolved (1)

  • src/components/Card/share-asset/captureShareAsset.ts — CC 15, MI 58, SLOC 58

@Hugo0 Hugo0 merged commit aeb2c46 into main Jun 29, 2026
17 of 21 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 1601 ran, 0 failed, 0 skipped, 24.7s

📊 Coverage (unit)

metric %
statements 54.4%
branches 36.9%
functions 42.0%
lines 54.3%
⏱ 10 slowest test cases
time test
3.9s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
0.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in updateUserById
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in updateUserById body
0.2s src/components/Global/Slider/__tests__/index.test.tsx › keeps the thumb label at 50% when the controlled value drifts to 49.98%
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid 9-digit US account
0.1s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Perk claimed shows shake class + go home button
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle invalid ETH address (too short)
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle too long for US account
0.1s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Perk claim in progress shows disabled button + progress
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@coderabbitai coderabbitai Bot left a comment

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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 91be7b6 and 248c33e.

📒 Files selected for processing (1)
  • src/components/Card/share-asset/captureShareAsset.ts

Comment on lines +60 to +79
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()))
}

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant