Skip to content

fix: deliver a cold start deep link once the app can open it - #692

Open
21Mill wants to merge 4 commits into
MostroP2P:mainfrom
21Mill:fix/deep-link-delivery
Open

fix: deliver a cold start deep link once the app can open it#692
21Mill wants to merge 4 commits into
MostroP2P:mainfrom
21Mill:fix/deep-link-delivery

Conversation

@21Mill

@21Mill 21Mill commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Depends on #673 — this branch sits on top of it, so its two commits show up in the diff. Review only fix: deliver a cold start deep link once the app can open it; merge #673 first.

Problem

#673 stops the cold start crash, but it does not make the link open. That part is still the code on main, and it drops the link in two opposite timing windows:

  • _processInitialDeepLink hands the link to addPostFrameCallback and then waits 100 ms before checking _router != null. On a slow start the router is not there yet and the link is discarded with nothing in the log. On the device I measured 0.9 s between the link being detected and the app being able to open it, so this is not theoretical.
  • addPostFrameCallback requests no frame of its own (flutter/lib/src/scheduler/binding.dart:792-797"whenever that may be, if ever"). Once the app has settled, the callback simply never runs.
  • The interceptor path (customUrlStream) dropped a link outright when _router == null.

Change

MostroApp keeps the link until there is a navigator to open it with — not until a timer expires — and hands it over as soon as there is one. The condition is router.routerDelegate.navigatorKey.currentContext != null, which is exactly what DeepLinkHandler._handleMostroDeepLink needs and aborts without.

Delivery reports a result

handleInitialDeepLink now returns Future<bool>: false means the app was not in a state to attempt the link, and only then is it kept for another frame.

Outcome Reported Why
No navigator / context false never attempted
Another link is being opened false never attempted
Navigated to the order true done
Duplicate within the 2 s window true the first one is doing it
Unsupported scheme true the user got unsupportedLinkFormat
processMostroLink failed true the user got the error
User declined the Mostro switch true the user said no

This is the point @grunch raised in the last review: retaining on catch was dead code, because DeepLinkHandler swallows every ordinary failure and returns normally. Retaining on any failure would be worse than useless — a link would reopen an order the user already dismissed. Only "the app could not try" is retained.

The retry rides on the frames it asks for and gives up after ten, so a retained link cannot surface minutes later in the middle of something else.

Two smaller fixes in the handler

  • The 2 s duplicate window was stamped before the context check, so a link that was never attempted came back and was mistaken for a duplicate of itself. It is stamped after the check now.
  • The processingContext == null early return left the loading dialog up.

Recovering the link the router throws away

createRouter discards the platform default location when it carries one of our schemes (#673). If app_links.getInitialLink() returns null or throws, that discarded value is now read back in MostroApp as a fallback, so the two paths cannot both come up empty. Duplicates are absorbed by the handler's 2 s window — confirmed on device, where both paths fire and only one navigation happens.

Scope note

_processInitialDeepLink used to filter on scheme == 'mostro' and now accepts any non-web scheme, matching exactly what createRouter discards. Nothing else can arrive today (AndroidManifest.xml registers only mostro as an intent filter; iOS lists only mostro in CFBundleURLSchemes), and a lightning: link would reach DeepLinkHandler, which answers with the localized unsupportedLinkFormat instead of vanishing.

Tests

test/core/app_deep_link_test.dart mounts the real MostroApp with appInitializerProvider behind a Completer, the app_links method channel mocked and a recording DeepLinkHandler that — like the real one — never throws and reports a result instead.

Every test was checked by mutation; each one fails against the code without its fix:

Test Mutation that breaks it
waits for the router when the app is still starting up drop _deliverPendingDeepLink() after _router ??= createRouter(ref)
is delivered without a new frame when the app is running deliver only from a post frame callback
is handed over again when the app could not attempt it ignore the reported result
is dropped once the retries run out remove the attempt cap
the newest link wins when two arrive before the router _pendingDeepLink ??= uri
falls back to the platform default when app_links has none drop the fallback

test/core/deep_link_handler_test.dart covers the new contract on the real handler: false when there is no navigator, true for a scheme it answers with a message. The first fails if that early return is changed to true.

One case is not covered by a test: the ensureVisualUpdate() on the retry path is unconditional now (it is already a no-op outside idle and postFrameCallbacks, per scheduler/binding.dart:906-917), which fixes the narrow == idle guard from the previous round. I could not build a test that distinguishes it — the navigator mounts in the same frame as the router — so it rests on the source contract, as @grunch described it.

Test plan

  • flutter analyze lib/core test/core — only the two pre-existing containsSemantics infos
  • flutter test test/core/ — green
  • Full flutter test — 906 passing, the same 11 pre-existing load failures from the missing generated mocks (Dart run build_runner build fails on Flutter 3.44.0, source_gen 3.1.0 incompatible with analyzer 8.x #606)
  • OnePlus 8T, Android 14, debug build, live order a2561e55-…:
    • cold start (am force-stop first): detected 22:15:06.477 → handled 22:15:07.493Navigating to: /take_sell/a2561e55-… 22:15:09.468. The second delivery through the other path is collapsed by the duplicate window.
    • app in the foreground: intercepted 22:16:54.866 → navigated 22:16:54.933, 67 ms, no frame needed.
    • plain launch, no link: order book, no deep link lines at all.
  • CI green

21Mill added 4 commits August 22, 2026 01:37
Opening a mostro: link while the app was not running crashed it before
anything rendered:

  'package:go_router/src/match.dart': Failed assertion: line 245 pos 12:
  'uriPathToCompare.startsWith(newMatchedLocationToCompare)': is not true.

With no activity alive, Android hands the link over as the engine's
defaultRouteName rather than through pushRouteInformation, and go_router
prefers that over initialLocation whenever it is not '/'. So the router
started up trying to match mostro:<id>?relays=..., which is an opaque URI:
its path is the bare id, with no leading slash, and matching it against '/'
fails the assertion. The link never reached DeepLinkInterceptor, which
guards the other delivery path, and the redirect that sends custom schemes
home never ran either, since matching asserts before redirects are
consulted.

createRouter now sets overridePlatformDefaultLocation when the platform
default carries a scheme of ours, so the app starts at '/' and the initial
link is left to the handler in MostroApp that already reads it through
app_links. The override is conditional rather than always on because on
web the platform default is the location the user asked for, and
discarding it would break opening the app at a URL.

The "is this one of our schemes" test existed twice, in the interceptor and
in the redirect, and this adds a third caller, so it now lives in one place
as DeepLinkInterceptor.isCustomSchemeUri / isCustomSchemeLocation.

Covered by a test that fakes the platform default through
TestPlatformDispatcher: against the unfixed router it reports the initial
location as the mostro: link itself, which is the defect exactly.
isCustomSchemeUri asked whether the scheme starts with 'http', which the
predicate it replaced already did in both of its copies. A scheme like
httpfoo: passes that test, so such a link would be handed to go_router as
an ordinary location and assert during a cold start, which is the failure
this branch exists to remove.

Uri normalises the scheme to lower case, so an exact comparison needs no
case handling of its own; a test pins that rather than a defensive
toLowerCase.
app_routes.dart importing DeepLinkInterceptor for two static predicates
pointed the dependency the wrong way: routing is the lower layer here and
the interceptor is one of its consumers. The predicate now lives on its
own in deep_link_schemes.dart, which both callers import, and the private
alias in the interceptor goes away with it.

createRouter also logs the platform default it discards, so a link that
goes missing on a cold start leaves a trace instead of nothing.

The router test built the router inside a Consumer.builder, which may run
more than once and leave routers nobody disposes; it now holds a single
one for the test and disposes it on teardown.
A link that arrived before the router existed was dropped: the listener
only forwarded it when _router was already set, and the initial link was
handed to a post frame callback that requests no frame of its own, so on
a slow start it ran too early and on a settled app it never ran at all.

MostroApp now holds the link until there is a navigator to open it with,
and hands it over as soon as there is one. Delivery is not a fire and
forget: handleInitialDeepLink reports whether the app was in a state to
attempt the link, and only the cases where it was not - no navigator, or
another link being opened - put it back for another frame. Anything the
user already saw an answer for, including a failed lookup and a declined
Mostro switch, counts as done. The retry rides on consecutive frames and
gives up after ten, so a link cannot surface minutes later in the middle
of something else.

The handler used to stamp the duplicate window before knowing whether it
could go ahead, which turned a link it never attempted into a duplicate
of itself; it stamps after the check now.

app_links is no longer the only way back to a cold start link either: it
falls back to the platform default location that createRouter discards.
@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 51 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f7d07837-9952-49ee-86eb-3da0886c6a5b

📥 Commits

Reviewing files that changed from the base of the PR and between c5ef339 and 411adec.

📒 Files selected for processing (9)
  • lib/core/app.dart
  • lib/core/app_routes.dart
  • lib/core/deep_link_handler.dart
  • lib/core/deep_link_interceptor.dart
  • lib/core/deep_link_schemes.dart
  • test/core/app_deep_link_test.dart
  • test/core/app_routes_test.dart
  • test/core/deep_link_handler_test.dart
  • test/core/deep_link_schemes_test.dart

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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