fix(links): stop losing the invite between three owners

An invite link could be consumed and then silently dropped, so the join sheet
appeared only sometimes (TODO #148), and the legacy schemes dead-ended on a
blank screen that only a force-stop cleared (TODO #144). Both are deep-link
delivery, so they are fixed together.

#148 - delivery crosses three owners and each lets go before the next has hold:

  1. native LinkModule captures the VIEW intent into `pendingLink`
  2. the RN poller reads it - and getPendingLink() NULLS it on read, so native
     has now forgotten it
  3. the shell stores it, then clears that state and injects
     `if (window.__pearHandleInvite) { … }`

Step 3 is the hole. `webViewReady` means the DOM loaded, not that the bundle has
run, so the guard can be false - and then the injection is a silent no-op with
the URL already gone from native AND from React state. Nothing retries, because
nothing knows anything was lost. main.jsx already buffered a LATER version of
this race (an invite arriving before <App> mounts its listener), but that only
helps once the handler exists.

The injected snippet is now total: deliver if it can, park the URL on
`window.__pearEarlyInvites` if it cannot, and the bundle drains the park the
moment it defines the handler. No polling, no retry state machine.

#144 - app/join.tsx rendered an empty View, waited 2s, emitted a `pearLink`
DeviceEvent and never navigated. Two things wrong: NOTHING LISTENS FOR
`pearLink` - there is not one addListener for it in the app, so the route
delivered nothing at all and the URL only ever arrived via the native queue -
and it never navigated away, so on a cold start it was the only stack entry and
Back exited the app. The route now replaces straight to index, whose poller does
the actual delivery.

313 unit tests, 10 new. They do not test a string: they EVALUATE the injected
snippet against a stub window in both orders, and the bundle-ready-second order
is the bug. Confirmed by reverting the injection to its old form, where 6 of
them fail including the regression itself. Also covers order preservation, no
double delivery on a second drain, and that a URL carrying quotes, backslashes,
newlines or U+2028/U+2029 cannot break out of the snippet - it arrives from a
link someone else sent, so that is script injection into our own WebView.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HG8ayyquJuDMPSVLKDQKVh
This commit is contained in:
Your Name
2026-07-27 09:02:10 -05:00
co-authored by Claude Opus 5
parent eca5316989
commit 91ce15cba6
5 changed files with 236 additions and 23 deletions
+9 -3
View File
@@ -40,6 +40,7 @@ let _backgroundedAt = 0
const WEBVIEW_RECOVERY_MIN_BG_MS = 20_000
const { makeStartLock } = require('../src/lib/backendBootstrap')
const { buildInviteInjection } = require('../src/lib/inviteDelivery')
const { createEventRegistry } = require('../src/lib/eventRegistry')
const {
createSyncNotifyState, decideSyncNotify, contentId, contentKey,
@@ -568,9 +569,14 @@ export default function Root () {
sendToWorklet({ method: 'consumePairLink', args: [url], id: bareId })
return
}
webViewRef.current?.injectJavaScript(
`if(window.__pearHandleInvite) { window.__pearHandleInvite(${JSON.stringify(url)}); } true;`
)
// TODO #148 - this used to be `if(window.__pearHandleInvite){…}`, which is
// a SILENT no-op when the WebView's DOM has loaded but its bundle has not
// run yet. By this point the link is already gone from native
// (getPendingLink nulls it on read) and from React state (cleared just
// above), so the invite was simply lost and the join sheet never appeared.
// buildInviteInjection parks it on `window` instead, and the bundle drains
// it the moment it defines the handler.
webViewRef.current?.injectJavaScript(buildInviteInjection(url))
}
}, [pendingInvite, dbReady, webViewReady])
+28 -20
View File
@@ -1,23 +1,31 @@
import { useEffect } from 'react'
import { useLocalSearchParams } from 'expo-router'
import { DeviceEventEmitter, View } from 'react-native'
import { Redirect } from 'expo-router'
// TODO #144 - this route used to park the app on a blank screen that only a
// force-stop could clear.
//
// It rendered an empty dark View, waited 2s, emitted a `pearLink` DeviceEvent,
// and never navigated anywhere. Two things were wrong with that:
//
// 1. NOTHING LISTENS FOR `pearLink`. There is not one addListener for it
// anywhere in the app, so the emit was dead code and this route delivered
// nothing at all. The URL reaches the app by a completely different path -
// the native LinkModule captures the VIEW intent and the index screen
// polls getPendingLink() - which is why https invites worked and the
// legacy schemes dead-ended here.
// 2. It never navigated away, so it sat on top of whatever it had triggered.
// On a cold start it is the only entry in the stack, so Back exits the app
// rather than going anywhere useful.
//
// So this route's entire job is to get out of the way, handing off to the index
// screen which owns the WebView and the poller that actually delivers the
// invite.
//
// `<Redirect>` rather than `router.replace()` in an effect, and that distinction
// is not cosmetic: a cold open lands here BEFORE the root navigator has mounted,
// so the imperative call throws "Attempted to navigate before mounting the Root
// Layout component" and leaves a redbox where the blank screen used to be.
// Caught on the TCL, not by reading. Redirect is declarative and expo-router
// defers it until the navigator is ready.
export default function JoinRoute() {
const params = useLocalSearchParams<Record<string, string>>()
useEffect(() => {
const entries = Object.entries(params)
.filter(([k]) => k !== 'screen')
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&')
const url = `https://peerloomllc.com/join?${entries}`
console.log('JoinRoute emitting pearLink:', url)
// Delay to ensure WebView is mounted
setTimeout(() => {
DeviceEventEmitter.emit('pearLink', url)
}, 2000)
}, [])
return <View style={{ flex: 1, backgroundColor: '#111' }} />
return <Redirect href="/" />
}
+64
View File
@@ -0,0 +1,64 @@
// TODO #148 - an invite link could be consumed and then dropped, so the join
// sheet appeared only sometimes.
//
// Delivery crosses three owners and each one lets go before the next has hold:
//
// 1. the native LinkModule captures the VIEW intent into `pendingLink`
// 2. the RN poller reads it - and `getPendingLink()` NULLS it on read, so
// native has now forgotten it
// 3. the shell sets `pendingInvite`, then on the next render clears that state
// and injects `if (window.__pearHandleInvite) { … }` into the WebView
//
// Step 3 is the hole. `webViewReady` means the DOM loaded, not that the bundle
// has run, so the guard can be false - and then the injection is a silent no-op
// with the URL already gone from native and from React state. Nothing retries,
// because nothing knows anything was lost.
//
// main.jsx already had a buffer for a LATER version of this race (an invite
// arriving before <App> mounts its listener), but that one only helps once
// `__pearHandleInvite` exists. This closes the window before that.
//
// The fix is to make the injected snippet itself total: deliver if it can, park
// it on `window` if it cannot, and have the bundle drain the park as soon as it
// defines the handler. No polling, no retry state machine, and it cannot lose
// the URL unless the page itself goes away.
'use strict'
// Where an early-arriving invite waits. Named on `window` rather than closed
// over because the two halves run in different worlds: the shell injects a
// string into a page whose bundle may not have executed yet.
const EARLY_INVITE_KEY = '__pearEarlyInvites'
// The JavaScript the shell injects for one invite URL.
//
// Deliberately an IIFE returning `true`: react-native-webview evaluates the
// string and a bare trailing expression can warn on some Android versions,
// which is why every other injectJavaScript call here ends the same way.
function buildInviteInjection (url) {
const u = JSON.stringify(String(url))
return `(function(){var u=${u};` +
`if(window.__pearHandleInvite){window.__pearHandleInvite(u);}` +
`else{(window.${EARLY_INVITE_KEY}=window.${EARLY_INVITE_KEY}||[]).push(u);}` +
`})(); true;`
}
// Called by the bundle immediately after it defines `__pearHandleInvite`.
// Returns what it drained, so a caller can log or assert on it.
//
// Idempotent and safe to call when nothing is parked, because the common case
// is that nothing is: the race only bites on a cold open from a link.
function drainEarlyInvites (win, handler) {
if (!win || typeof handler !== 'function') return []
const parked = win[EARLY_INVITE_KEY]
if (!Array.isArray(parked) || parked.length === 0) return []
const urls = parked.splice(0)
for (const u of urls) handler(u)
return urls
}
module.exports = {
EARLY_INVITE_KEY,
buildInviteInjection,
drainEarlyInvites,
}
+10
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import App, { emitter } from './App.jsx'
import { installFixtures } from './screenshot-fixtures.js'
import { injectGlobalStyles } from './theme.js'
import { drainEarlyInvites } from '../lib/inviteDelivery.js'
// Tokens + reset go in before the first render, so nothing paints unthemed.
// (:root is the dark palette, so no data-theme attribute is needed to start
@@ -298,6 +299,15 @@ window.__pearDrainInvites = function() {
return __pearInviteBuffer.splice(0)
}
// TODO #148 - and the race one step EARLIER than the buffer above. The shell
// injects the invite as soon as the WebView's DOM is ready, which can be before
// this bundle has run at all; the old injection was guarded by
// `if (window.__pearHandleInvite)` and so silently threw the URL away, after
// native had already forgotten it. The shell now parks such invites on
// `window.__pearEarlyInvites` instead. Drain them here, immediately after the
// handler exists, so they land in the buffer above and reach <App> on mount.
drainEarlyInvites(window, window.__pearHandleInvite)
// pearcal://pair URLs go straight to bare's consumePairLink, NOT through the
// join-sheet flow same split mobile does in app/index.tsx:434-439. The
// renderer only sees them if the host injects this function (Electron does;
+125
View File
@@ -0,0 +1,125 @@
// TODO #148 - an invite could be consumed by the shell and then silently
// dropped, so the join sheet appeared only sometimes.
//
// These do not test a string. They EVALUATE the snippet the shell injects,
// against a stub window, in both orders that matter: bundle-ready-first and
// bundle-ready-second. The second order is the bug, and before the fix it loses
// the URL entirely.
// (bugfix/deep-link-delivery)
const test = require('node:test')
const assert = require('node:assert/strict')
const vm = require('node:vm')
const { buildInviteInjection, drainEarlyInvites, EARLY_INVITE_KEY } = require('../src/lib/inviteDelivery.js')
const URL_A = 'https://peerloomllc.com/join?group=Zw%3D%3D&name=Family&key=' + 'a'.repeat(64)
const URL_B = 'https://peerloomllc.com/join?group=aA%3D%3D&name=Work&key=' + 'b'.repeat(64)
// Run an injection payload the way react-native-webview does: as source text,
// against a page's `window`.
function inject (win, url) {
const ctx = vm.createContext({ window: win })
return vm.runInContext(buildInviteInjection(url), ctx)
}
function freshWindow () {
return {}
}
test('handler already defined: delivered straight through', () => {
// The lucky ordering, and the only one that ever worked.
const seen = []
const win = freshWindow()
win.__pearHandleInvite = (u) => seen.push(u)
inject(win, URL_A)
assert.deepEqual(seen, [URL_A])
assert.equal(win[EARLY_INVITE_KEY], undefined, 'nothing should be parked when it was delivered')
})
test('THE #148 REGRESSION: handler not defined yet, and the invite survives', () => {
// The unlucky ordering. `webViewReady` means the DOM loaded, not that the
// bundle ran, and by this point the URL is gone from native (getPendingLink
// nulls on read) and from React state. Before the fix the guard was
// `if (window.__pearHandleInvite)` and this dropped it on the floor.
const win = freshWindow()
inject(win, URL_A)
// `Array.from`: the park array is created inside the vm realm, so its
// prototype differs and deepStrictEqual would reject it on that alone.
assert.deepEqual(Array.from(win[EARLY_INVITE_KEY]), [URL_A], 'the invite must be parked, not lost')
// Bundle runs, defines the handler, drains.
const seen = []
win.__pearHandleInvite = (u) => seen.push(u)
const drained = drainEarlyInvites(win, win.__pearHandleInvite)
assert.deepEqual(Array.from(drained), [URL_A])
assert.deepEqual(seen, [URL_A], 'the invite must arrive once the handler exists')
})
test('several early invites keep their order', () => {
const win = freshWindow()
inject(win, URL_A)
inject(win, URL_B)
const seen = []
win.__pearHandleInvite = (u) => seen.push(u)
drainEarlyInvites(win, win.__pearHandleInvite)
assert.deepEqual(seen, [URL_A, URL_B])
})
test('draining twice does not deliver twice', () => {
// The bundle may call this on a re-entry; a duplicate would open the join
// sheet a second time for an invite already handled.
const win = freshWindow()
inject(win, URL_A)
const seen = []
win.__pearHandleInvite = (u) => seen.push(u)
drainEarlyInvites(win, win.__pearHandleInvite)
drainEarlyInvites(win, win.__pearHandleInvite)
assert.deepEqual(seen, [URL_A])
})
test('draining an empty park is a no-op, which is the common case', () => {
// Most opens are not from a link at all.
const win = freshWindow()
win.__pearHandleInvite = () => { throw new Error('must not be called') }
assert.deepEqual(drainEarlyInvites(win, win.__pearHandleInvite), [])
})
test('drain tolerates a missing window or handler', () => {
assert.deepEqual(drainEarlyInvites(null, () => {}), [])
assert.deepEqual(drainEarlyInvites({}, undefined), [])
})
test('a URL with quotes and backslashes cannot break out of the snippet', () => {
// The URL is attacker-influenced: it arrives from a link someone else sent.
// If it could terminate the string literal it would be script injection into
// the app's own WebView.
const nasty = `https://peerloomllc.com/join?name=");alert('x');//&key=` + 'c'.repeat(64) + '\\'
const win = freshWindow()
inject(win, nasty)
assert.deepEqual(Array.from(win[EARLY_INVITE_KEY]), [nasty], 'the URL must survive verbatim')
})
test('a URL with newlines and unicode separators survives', () => {
// JSON.stringify escapes U+2028 / U+2029, which a JS parser treats as line
// terminators and which would otherwise split the statement in two.
const weird = 'https://peerloomllc.com/join?name=a\u2028b\u2029c\nd&key=' + 'd'.repeat(64)
const win = freshWindow()
inject(win, weird)
assert.deepEqual(Array.from(win[EARLY_INVITE_KEY]), [weird])
})
test('the snippet evaluates to true, as react-native-webview expects', () => {
// Every other injectJavaScript call in the shell ends `true;` for the same
// reason: a bare trailing expression warns on some Android versions.
const win = freshWindow()
win.__pearHandleInvite = () => {}
assert.equal(inject(win, URL_A), true)
})
test('the snippet is a single statement with no stray newlines', () => {
// It is injected as one line into a WebView; a raw newline in the source
// would be harmless here but a literal one INSIDE the URL would not, which is
// what the test above covers. This pins the shape.
const src = buildInviteInjection(URL_A)
assert.doesNotMatch(src, /\n/)
assert.match(src, /^\(function\(\)\{/)
})