Skip to content

feat: register link handlers during init(), bound every request, and report how the install report ended - #16

Merged
onamfc merged 2 commits into
mainfrom
fix/init-ordering-and-deadlines
Oct 29, 2026
Merged

onamfc merged 2 commits into
mainfrom
fix/init-ordering-and-deadlines

Conversation

@onamfc

@onamfc onamfc commented Oct 29, 2026

Copy link
Copy Markdown
Member

What changes

onDeepLink() right after init(), without awaiting it, is now supported. init() creates the deep link handler and the link-hosts list before its first await. Previously a handler registered during init() was built without the link hosts and then replaced at the end of init(), which left its Linking listener alive for the session: links on the workspace's other hosts were never recognized by it, and a second onDeepLink() call added a second listener, so every link arrived twice. Now there is one handler, it receives the link hosts when they arrive (a link handled before then waits up to 3 s, as before), and one listener however often onDeepLink() is called.

Every request has a deadline (requestTimeoutMs, default 10 s). React Native sets no connect/read/write timeout on Android, so a server that accepted the connection and never answered left init() pending. An abandoned request rejects with code: 'timeout'; an abandoned install report is retried on the next launch. The user agent lookup is bounded too (3 s): on iOS it runs a WKWebView on the main thread and can fail to settle, in which case a generated <app>/<version> iOS/<os> user agent is sent, the form the native SDKs use, which the server already matches by platform and OS version.

onDeferredDeepLink callbacks get a second argument, { status: 'attributed' | 'organic' | 'failed', error? }, so a failed report (retried next launch) is distinguishable from an organic install. The callback is still called with null in both cases, since apps use it as the "routing decision is in" signal; one-argument callbacks keep working. Delivery now happens once, after the report's try/catch, so a callback that throws surfaces as such rather than being logged as a failed report and followed by a second null delivery.

Events tracked while init() runs are held (up to 100) and sent with their original timestamp once the install ID is known, instead of dropped. If that launch's report fails they are dropped with one warning.

init() never rejects for a storage failure (AsyncStorage native module not linked). It logs the cause once per launch and behaves as on a first launch, so the SDK keeps working for the session. The only error init() rejects with is one thrown by the app's own onDeferredDeepLink callback.

HTTP errors name the status: HTTP 502, or HTTP 400: <message> when the body is JSON. A proxy's HTML error page no longer reads as "Network error".

Not changed, deliberately

No terminal state for a 4xx install response. An install that is never reported is a permanent loss (the reason for the recovery logic added in 1.9.2), and a transient server-side 400 would make that permanent for every device that launched during it. One small request per cold start is the cheaper failure. The install endpoint has no auth, so 401/403 do not occur there.

Tests

29 new or updated cases: registration during init() with a workspace link host, the request deadline (fake timers, default and configured), the three callback statuses, a throwing callback, held events (sent / dropped), broken storage, error messages, and the user agent fallback. npm test: 118 passing. npm run build clean.

…report how the install report ended

init() now creates the deep link handler and the link-hosts list before its first await, so onDeepLink() may be called right after init() without awaiting it. Until now a handler registered while init() was running was built without the link hosts and then replaced by init(), which left its Linking listener live for the session: links on the workspace's other hosts were never recognized by it, and a second onDeepLink() call added a second listener, so every link was delivered twice. The handler is now the one init() created, it receives the link hosts when they arrive, and there is one listener however often onDeepLink() is called.

Every request is abandoned after requestTimeoutMs (default 10 seconds). React Native sets no connect, read or write timeout on Android, so a server that accepted the connection and never answered left init() pending for the session. An abandoned request rejects with code 'timeout', and an abandoned install report is retried on the next launch like any other failure. The device's user agent lookup is bounded too (3 seconds): on iOS it runs a WKWebView on the main thread and can fail to settle, in which case a generated `<app>/<version> iOS/<os>` user agent is sent, the form the native SDKs use, which the server matches by platform and OS version.

onDeferredDeepLink callbacks receive a second argument, { status: 'attributed' | 'organic' | 'failed', error? }, so an app can tell an organic install from a report that failed and will be retried. The callback is still called with null in both cases, because apps use it as the signal that the routing decision is in; a callback that takes only the data keeps working. The delivery now happens once, after the report's try/catch, so a callback that throws surfaces as such instead of being logged as a failed report and followed by a second, contradicting delivery.

Events tracked while init() is still running are held (up to 100) and sent with their original timestamp once init() has the install ID, instead of being dropped; if that launch's report fails they are dropped with one warning.

init() no longer rejects when storage fails (the AsyncStorage native module not linked): the failure is logged once per launch, a read counts as absent and a write is skipped, so the SDK behaves as on a first launch and keeps working for the session. The only error init() rejects with is one thrown by the app's own onDeferredDeepLink callback.

An HTTP error response is now reported as `HTTP <status>` with the body's message or error appended when the body is JSON; a non-JSON body (a proxy's error page) no longer reads as "Network error".
…erred link once initialized, query the launch URL once

A second init() call, including one made while the first is still running (React 18 StrictMode runs effects twice in development), now logs a warning and returns the first call's promise. Until now the guard was the initialized flag, set only at the end, so a second call during the first replaced the handler and the link-hosts list while the first handler kept its Linking listener: two listeners, every link delivered twice.

The deferred deep link is delivered by init() after the SDK is initialized, so an app callback that throws rejects init() but leaves a working SDK behind, and awaiting init() no longer waits on the sends of events held during it (sendEvent never rejects).

DeepLinkHandler asks for the launch URL once per handler, so re-registering onDeepLink() (an effect whose callback identity changes) no longer delivers the launch URL again each time.
@onamfc
onamfc merged commit 977d01d into main Oct 29, 2026
1 check passed
github-actions Bot pushed a commit that referenced this pull request Oct 29, 2026
## 1.10.0 (2026-10-29)

* feat: register link handlers during init(), bound every request, and report how the install report e ([977d01d](977d01d)), closes [#16](#16)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.10.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants