Skip to content

Fix/improve deep link race conditions#2998

Open
StylianosGakis wants to merge 8 commits into
developfrom
fix/improve-deep-link-race-conditions
Open

Fix/improve deep link race conditions#2998
StylianosGakis wants to merge 8 commits into
developfrom
fix/improve-deep-link-race-conditions

Conversation

@StylianosGakis

@StylianosGakis StylianosGakis commented Jun 17, 2026

Copy link
Copy Markdown
Member

This is an attempt to fix the race conditions around deep links by making sure reconcilation happens first, and then if a deep link is pending, it is handled appropriately, meaning taking over the entire backstack if required.

Need to test a bit more before merge

AI description of the reason the new timing corodingation which works well compared to letting the coroutines race each other:

1. The collector now waits. It calls externalDeepLinkHandler.handle(uri), which is suspend and whose first line is readySignal.first { it }. It cannot touch the back stack until isReady == true. (Before: synchronous openUri, no wait.)
2. navigateToExternalDeepLink lands alone in both auth states: logged out → pendingDeepLink = key (consumed later by setLoggedIn, which lands it alone); logged in → reseed(listOf(key)) → stack becomes exactly [destination]. Neither branch ever appends onto an existing root. (Before: logged-in branch did remove + add = append.)
3. reconcile() sets the root, then flips the gate: determineStartScene() settles the root first, and only then isReadyState.value = true. So the moment the gate opens, the root is already final. (Before: same internal order, but nothing downstream waited for it.)
4. The gate enforces the ordering the launch order only hinted at. MainActivity still launches reconcile() then setContent, but now even if the collector starts first and the auth/member-id flows suspend, handle() parks on readySignal.first { it } and yields back — it physically cannot run before reconcile finishes.

So the deciding factor from before — did the auth/member-id flows emit synchronously or suspend? — no longer changes the result:

- Flows suspend → handle() blocks on the gate until reconcile completes → reads the settled state → lands alone.
- Flows cached → reconcile completes first anyway → handle() reads the same settled state → lands alone.

Both timings now converge on one outcome: [destination] alone, Home never beneath it, system Back exits to Slack. The two random outcomes collapse into one. That’s
the determinism — it comes from the gate (point 1) removing the timing dependence, and reseed (point 2) removing the “append onto Home” outcome even in the logged-in
branch.

Clarifies that this back-stack entry point handles in-app link taps
(Compose LocalUriHandler) which join the current task, distinct from
external/notification deep links. No behavior change.
External App Links and notification taps were reusing the in-app uri
handler, which appends onto the live stack and never gated on auth
readiness. Concurrent with SessionReconciler's setLoggedIn, this raced:
the deep-linked screen sometimes landed alone, sometimes on top of Home,
so system Back went nondeterministically to the app's Home or back out to
the launching app.

Add ExternalDeepLinkHandler, which waits for the ready signal, then lands
a matched key (or a Home fallback for an unmatched own-host uri) alone via
the new BackstackController.navigateToExternalDeepLink. The Home fallback
matches our autoVerify'd hosts by host and path prefix, mirroring the
manifest filters; foreign hosts are ignored rather than opened.
NavDisplay rendered the seeded LoginKey root for a few frames before
SessionReconciler flipped it to the authenticated root. The OS splash
hid this on a normal cold start, but an App Link launched into another
app's task shows no splash, briefly exposing the login screen.

Gate NavDisplay on the reconciler's ready signal and make reconcile()
latch isReady idempotently (a config-change re-entry is now a no-op
rather than dipping isReady back to false and blanking the content).
Captures the empirical finding that onNewIntent fires on a standard
launchMode Activity only when a caller sets FLAG_ACTIVITY_SINGLE_TOP,
so MainActivity's addOnNewIntentListener is a defensive safety net
rather than a regularly-exercised path today.
ExternalDeepLinkHandler had no Compose dependency — it backed no
CompositionLocal and only the deepLinkChannel collector used it. It was
in HedvigApp only because the collector began life as a LaunchedEffect.

Build it as a per-Activity collaborator in MainActivity (alongside
androidAppHost) and collect the channel in a lifecycleScope job, next to
SessionReconciler's — the per-Activity nav/auth coordinator it mirrors.
HedvigApp no longer takes deepLinkChannel. The handler and its test are
untouched; the UNLIMITED channel still buffers links until collection
starts, so behavior is unchanged.
Comments should describe the current code, not a removed predecessor a
new reader never saw. Remove the "Replaces Nav2's ... NavController"
sentence from the deepLinkChannel doc and the "(Nav2 parity)" aside from
navigateToInAppLink; both surrounding docs already explain the behavior
in present terms.
Same cleanup as the prior commit, for comments that predate this branch:
the BackstackController class doc no longer recounts the old
rememberSerializable/app-singleton designs (it now justifies the retained
ViewModel on its own terms), currentDestination drops the Nav2 comparison,
and SessionReconciler stops noting it once lived as loose effects.
@StylianosGakis StylianosGakis marked this pull request as ready for review June 17, 2026 14:51
@StylianosGakis StylianosGakis requested a review from a team as a code owner June 17, 2026 14:51
@StylianosGakis StylianosGakis enabled auto-merge June 18, 2026 09:06
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