Skip to content

Nav Lint

The .nav baseline reviews how navigation changed. navLint looks at what it is: it walks the merged graph and reports screens the user can't reach, a missing or duplicated start destination, and routes no @NavDestination binds.

The distinction matters, because navCheck is perfectly happy with a broken graph. An unreachable screen is recorded in the baseline just like any other, so it passes check on every build, forever. Nothing today asks whether the graph is actually navigable.

./gradlew :app:navLint
navgraph: 3 navigation lint finding(s):

  [unreachable] Checkout
      no path from Home
  [unbound-route] Orphan
      declared as a route but no @NavDestination binds a screen to it
  [multiple-starts] Onboarding
      marked start, and so are 1 other destination(s)

Silence a rule with navgraph { navLintDisabledRules }, or one route with navgraph { navLintIgnoredRoutes }.

Rules

Rule id What it catches
unreachable No directed path from a start destination reaches the screen. You added it and forgot to wire it, or deleted the last transition into it.
no-start No destination is marked start — nothing carries @NavGraphRoot, so the graph has no anchor. Reachability can't run without it, so it is reported alone.
multiple-starts More than one destination is marked start. Easy to hit in a multi module app, where the flag is unioned across every module that declares a root.
unbound-route A route exists in the graph but no @NavDestination binds a screen to it.

unreachable is not the exports' "Unconnected screens"

They answer different questions, and the sets genuinely differ. The exports split off screens with no transition at all so they don't clutter the picture — that is a decluttering device, and it ignores edge direction and the start destination entirely. Two screens that link to each other but that nothing reaches from Home are drawn inside the flow, and lint reports both. Lint asks the question that matters to a user: can I get there.

Warns by default

navLint warns; it does not fail. Turn it into a gate where you want one:

navgraph {
    failOnNavLint.set(true)  // default: false (warn)
}

Why it isn't in check

navCheck runs on every check because it reads the render-free extracted manifest. navLint reads the aggregated graph — every module merged — because that is the only place the question can be answered honestly: in a single module's own manifest a route another module owns appears as a bare stub, indistinguishable from a genuinely unbound route, so a thin :app would report almost every screen. Gating check on the aggregated graph would make check build every dependency module's graph, thumbnail renders included.

So run it as its own CI step:

- run: ./gradlew check navLint

If your build has no dependency modules to drag in, or you accept the cost, wire it up:

navgraph {
    lintOnCheck.set(true)  // default: false
}

When lint runs on a module that isn't aggregating, the report says so, so a partial-graph finding can be discounted rather than chased.

Suppressing

Turn off a rule everywhere, or exempt individual routes by FQN:

navgraph {
    navLintDisabledRules.add("unbound-route")
    navLintIgnoredRoutes.add("com.app.DeepLinkOnly")
}

An ignored route reports nothing from any rule. Use it for a destination that is unwired on purpose — one reached only by a deep link, or a screen still being built.