feat(native): deferred deep linking through the store install (TASK-20772) - #2560
Conversation
…0772) Carry locale + invite code + campaign tag + destination path from mobile web through the app-store hop. Android rides the Play Install Referrer (app-local InstallReferrerPlugin, resolve-once + 5s timeout); iOS rides a clipboard hand-off double-gated by prompt-free UIPasteboard checks (hasStrings + detectPatterns probable-web-url) so unrelated clipboard content never triggers the paste alert. One-shot restore on first launch: 30-day normalized inviteCode/campaignTag cookies (setup flow already reads them), locale persisted under the app-locale key the upcoming in-app i18n (on dev) reads — @capacitor/preferences added so the preference lands in native storage and the plugin ships in this binary — and navigation to the destination only when no real deep link landed. Restore is not awaited in init so a pending paste prompt or slow referrer service can never block the push listener or splash hide. Base is main (not dev) so the native release pipeline can cut a testable binary; back-merge to dev will need a small locale-handling reconcile with dev's src/i18n/app (marked in deferred-link.ts).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds deferred deep-link payload generation, Android and iOS restoration, native navigation coordination, Capacitor Preferences support, a guarded development page, and comprehensive tests. ChangesDeferred deep-linking
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested labels: Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant WebPage
participant Store
participant NativeApp
participant DeferredContext
WebPage->>Store: Open handoff URL with deferred payload
Store->>NativeApp: Install application
NativeApp->>DeferredContext: Restore referrer or clipboard payload
DeferredContext-->>NativeApp: Return destination and locale
NativeApp->>NativeApp: Navigate when no launch deep link handled
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
Code-analysis diffPainscore total: 6334.17 → 6350.2 (+16.03) 🆕 New findings (27)
…and 7 more. ✅ Resolved (21)
…and 1 more. 📈 Painscore deltas (top movers)
|
🧪 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/app/dev/deferred/page.tsx`:
- Around line 92-114: Reset the copied state when building a new payload in the
deferred page’s payload-generation button. Update the onClick handler that calls
buildDeferredPayload to also set copied to false, while preserving the existing
setCopied(true) behavior after copyIOSHandoff completes.
🪄 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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 530cb036-dd58-415a-979d-78df4ff5291d
⛔ Files ignored due to path filters (7)
android/app/build.gradleis excluded by!android/**android/app/capacitor.build.gradleis excluded by!android/**android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.javais excluded by!android/**android/app/src/main/java/me/peanut/wallet/MainActivity.javais excluded by!android/**android/capacitor.settings.gradleis excluded by!android/**ios/App/App/ClipboardDetectPlugin.swiftis excluded by!ios/**pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (8)
package.jsonsrc/app/dev/deferred/page.tsxsrc/constants/general.consts.tssrc/hooks/__tests__/useNativePlugins.test.tsxsrc/hooks/useNativePlugins.tssrc/utils/__tests__/deferred-link.test.tssrc/utils/clipboard-detect.tssrc/utils/deferred-link.ts
…s, PIX exit, verification tasks Cherry-picks the open native-facing work onto the release branch: - KYC now routes every native entry point through the Sumsub Cordova SDK instead of the WebSDK (peanut-ui #2562) — the one change here that genuinely requires a new binary rather than an OTA. - Deferred deep linking survives the store install, so a link followed before install lands on its destination on first launch (#2560, TASK-20772). - PIX deposit "Done" exits to home from both the completed and processing states instead of dropping the user into a new deposit (#2548). - Pending Bridge verification tasks card on home, dismissible and resurfaced under Unlocked regions (#2549). - Native demo/passkey follow-ups: the awaited token clear in clearAuthState plus the two callers it changed (#2517 — the rest shipped in 1.0.40). - Documents left running on a superseded deployment now reload (#2563). - Android safe zone sized from natively measured insets. Not carried: the CSP allow-list gaps (#2564). It builds on the #2519 collector rework — a /api/csp-report route and ten commits that are not on this branch — and the policy is report-only, so the gaps cost report fidelity, not behaviour.
🤖 High-effort code review — 10 verified findingsAutomated multi-agent review (4 finders + independent verification per finding; 22 candidates, 3 refuted). The happy path works, but the restore path breaks in several everyday situations. 🔴 Blockers1. Existing users get locked out of login ( 2. Unmapped dests navigate to routes that don't exist ( 3. Transient Android referrer failure burns the one-shot flag ( 4. Late restore yanks users out of mid-onboarding ( 🟡 Should fix5. iOS re-prompts after kill-during-prompt ( 6. 7. First-launch race: invite gate vs. referrer read ( 8. 🧹 Cleanup9. Dev page forks the prod wall ( 10. Inline invite normalization duplicates 🤖 Generated with Claude Code |
|
@kushagrasarathe — I'd explored this problem in a design plan a couple of days before this PR went up, and landed on the same two channels you built (Play Install Referrer + iOS clipboard). Having compared the two, your serverless shape is the better call, and I want to be specific about why, since my version had a server tier and I now think that was wrong:
Three findings, one of them shipped as a PR: 1. Telemetry — #2587 (stacked on this branch). The restore path emits nothing today, and the clipboard 2. The fix looks small: rewrite 3. Claim links — a warning about the obvious next step, not about this PR. This PR sensibly carries only non-secret context. When claim links come up, please don't do what my plan proposed: it put Separately, #2584 fixes a pre-existing bug I hit while reading this: Nice work on the double-gated clipboard read and the one-shot flag ordering — the "set the flag before the clear so an interrupted clear can't re-read" detail is the kind of thing that bites later. |
- inviteCode restored as SESSION cookie matching InvitesPage (a 30-day cookie routes /setup past Landing, the only Log In screen — the #2346 lockout class); campaignTag stays 30d, it no longer gates the step - dest must map through deepLinkToNativePath like openDeepLink — an unmappable dest is dropped, not pushed verbatim into the static export - android: a transient null referrer read no longer burns the one-shot; the read is prompt-free and the referrer lives ~90 days, so next launch retries. definitive reads (incl. play's organic utm) consume. - iOS: consume BEFORE the prompt-raising read — kill-during-prompt can never re-prompt on later launches - late-resolving restore (prompt left up, slow referrer service) no longer navigates: 10s window from launch, cookies/locale still apply - stripLocalePrefix handles /pt-br?x=1 (query split before segmenting) - invite normalization now calls toInviteCode instead of duplicating it
|
Thanks @jjramirezn — all 10 triaged; 7 fixed in ffc9e79, 1 refuted with evidence, 2 accepted/declined with reasoning below. Fixed (1–5, 8, 10):
Refuted (6 — camera in the binary): Accepted, no code change (7 — invite-gate race): when the referrer read lands after setup's ~100ms cookie check, the user sees Landing instead of the signup shortcut — but the cookie lands well before they finish tapping through, so invite/campaign attribution and the badge award are intact; only the gate-skip nicety degrades, on slow reads only. Wiring a re-check event into the setup page isn't worth the coupling for v1; happy to revisit if funnel data shows it matters. Declined (9 — dev-page wall fork): the page must be reachable unauthenticated in the native prod build — it's the landing target for the fresh-install e2e ( |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/hooks/__tests__/useNativePlugins.test.tsx (1)
82-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRestore the
Date.nowspy outside the assertion path.
nowSpy.mockRestore()sits afterexpect, so a failure at Line 94 leaks a frozenDate.nowinto every later test in this file, cascading unrelated failures.♻️ Move restoration into teardown
- const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => fakeNow) + jest.spyOn(Date, 'now').mockImplementation(() => fakeNow) mockRestore.mockImplementation(() => { // e.g. the paste prompt sat unanswered for 20s before the user allowed it fakeNow += 20_000 return Promise.resolve({ dest: '/claim?x=1', locale: null }) }) renderHook(() => useNativePlugins()) await waitFor(() => expect(mockRestore).toHaveBeenCalled()) await new Promise((r) => setTimeout(r, 0)) expect(push).not.toHaveBeenCalledWith('/claim?x=1') - nowSpy.mockRestore() })Add alongside the existing
beforeEach:afterEach(() => { jest.restoreAllMocks() })🤖 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/hooks/__tests__/useNativePlugins.test.tsx` around lines 82 - 96, Move mock cleanup out of the assertion path in the useNativePlugins test suite by adding an afterEach teardown alongside the existing beforeEach that calls jest.restoreAllMocks(). Remove the local nowSpy.mockRestore() call so Date.now and other spies are restored even when expectations fail.
🤖 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.
Nitpick comments:
In `@src/hooks/__tests__/useNativePlugins.test.tsx`:
- Around line 82-96: Move mock cleanup out of the assertion path in the
useNativePlugins test suite by adding an afterEach teardown alongside the
existing beforeEach that calls jest.restoreAllMocks(). Remove the local
nowSpy.mockRestore() call so Date.now and other spies are restored even when
expectations fail.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: a11e7936-a788-4686-8816-5e27d23d4f39
📒 Files selected for processing (4)
src/hooks/__tests__/useNativePlugins.test.tsxsrc/hooks/useNativePlugins.tssrc/utils/__tests__/deferred-link.test.tssrc/utils/deferred-link.ts
|
@innolope-dev thanks — this is a genuinely useful review, and the serverless-vs-server post-mortem is worth keeping (the CGNAT-collision + Private Relay points against fingerprinting deserve to be quotable next time someone proposes it). All three verified; here's where each lands: 1. Telemetry (#2587) — keep the PR, don't close it. Reviewed here: direction and privacy shape are right, but it's stacked on 2. 3. Claim-link warning — agreed, and adopting it as a design constraint: the deferred channel carries only non-secret, replayable context (locale / invite / campaign / dest). Anything secret-bearing — Also read #2584 — real pre-existing bug, and the fix shape is right (re-attach the fragment once above the branches so a new route can't forget it; the off-host reject still short-circuits before it). It composes cleanly with this PR: |
…telemetry branch #2560's review pass replaced the unconditional one-shot burn with markConsumed(): a transient android referrer read no longer consumes and retries on the next launch. Fold the telemetry into that: track whether the one-shot was actually burned and skip the empty-outcome capture when it wasn't — an unconsumed restore is a pending retry, not an organic install, and firing there would double-count the install and file a broken read as organic. The android-null test moves to the iOS empty-clipboard case, which is what an organic install now looks like on the reporting side; a new test pins the silent-retry behaviour. Merges the base branch rather than main: main carries unsigned content-submodule commits that the signature ruleset re-flags on a feature ref (GH013).
…ge un-prune Carries peanut-ui#2598: the #2560 review fixes (one-shot retry on transient reads, iOS consume-before-prompt, session invite cookie — fixes the login lockout live since 1.0.43 — must-map dests, 10s late-nav window) and keeps dev/deferred in the bundled export so the deferred-link test surface stops reload-looping into a setup bounce.
Summary
Adds a dev-only preview page at
/dev/migrationthat showcases every UI surface for the PWA → app-store migration, composed from the real app components (not lookalikes) so the states read like production.Surfaces covered:
ForceIOSPWAInstall).PeanutActionDetailsCard+ActionListCard+ the real "Continue with Peanut" button.CarouselCTA.<Hero>component, CTA swapped to "Download now".ActionModalpre-prompts.Device-aware throughout: one primary CTA per device (mobile → store button; desktop → QR).
Risk
None to production. Dev-only route —
/devisnotFound()on prod (dev/layout.tsx), the whole page is behind that gate. No production code touched; only a new file undersrc/app/(mobile-ui)/dev/.QA
Toggle iOS / Android / Desktop (top-right) to see the device-aware CTAs; open each modal; on Desktop the download CTAs show the dual-store QR.
Screenshots
Dev-only preview route — best viewed live at
/dev/migration(steps above). Every surface is rendered from the real components; toggling the platform pill drives the CTA variants.Design notes
peanut.me/appredirecting by device) would remove the toggle — noted in a code comment as the lighter alternative, pending a product call.🤖 Generated with Claude Code
Summary by CodeRabbit