Architecture
This is a design rationale, not a tutorial. For the what, read the crate docs. For the why, read on.
1. Crate layout
src/
├── lib.rs Public API and re-exports. The crate root re-exports
│ every type a typical consumer will reach for.
├── active_link/ The NavLink<R> component, the Match enum, and the
│ ├── mod.rs per-render utility that decides the active state.
│ ├── nav_link.rs
│ ├── props.rs
│ ├── mode.rs
│ └── utils.rs
├── nav/ Structural primitives that don't care about routing:
│ NavList, NavItem, NavDivider. These are render-only.
├── components/ Higher-level UI components built on top of nav/:
│ badges, dropdowns, headers, icons, tabs, pagination,
│ etc.
├── hooks/ Reactive hooks. Split into route_info/ (read-only
│ state) and navigation/ (effects + query params).
├── utils/ Pure functions (paths, URL codec, keyboard helpers).
│ No yew dependency.
├── attrs.rs Type-safe attribute builders for consumers who roll
│ their own elements.
└── errors.rs NavError + NavResult<T>.
The split keeps three rules invariant:
utils/is leaf — no dependency on yew. It is unit-testable on its own and could be carved into a sibling crate later without churn.active_link/is the only place that knows about active-state matching. Hooks delegate to the same primitive (is_path_prefix).- Components never own routing state.
NavTabsandPaginationaccept the active index / page through props; the consumer holds theuse_state. The library is render-only above the routing layer.
2. The active-state algorithm
NavLink<R> and the active-state hooks all answer one question: given a
target route to: R and the currently matched route, is to active?
#![allow(unused)]
fn main() {
fn is_active<R: Routable + PartialEq>(
current: Option<R>,
target: &R,
partial: bool,
) -> bool {
let Some(current) = current else { return false };
if partial {
is_path_prefix(&target.to_path(), ¤t.to_path())
} else {
¤t == target
}
}
}
is_path_prefix(prefix, full) is segment-wise: /docs is a prefix of
/docs/api but not of /documentation. We compute it by splitting both on
/, dropping empty fragments, and checking that the prefix’s segments
match the head of full’s segments.
Why segment-wise? Because string-prefix matching has a long history of
false positives (e.g. /admin matching /administrator) and yew-router’s
Routable enum gives us proper to_path() strings to work with.
3. The hook contract
Every hook reads from yew-router’s reactive state via use_route::<R>()
and returns a value, not a callback registration. They re-render their
caller when the URL changes; that is the entire integration point.
URL changes ──► yew-router state updates ──► use_route()
│
├── use_is_active(...)
├── use_is_exact_active(...)
├── use_is_partial_active(...)
├── use_breadcrumbs()
└── use_route_info()
use_navigation() is the dual: pure outputs that write to the URL via
yew-router’s Navigator. It hands back ready-made Callback<()> so
consumers don’t have to repeat the Callback::from(move |_| ...) boiler
plate.
4. Custom breadcrumb labels
use_breadcrumbs walks the current path and synthesises a list of
BreadcrumbItem<R> entries — but the label for each entry is delegated
to a BreadcrumbLabelProvider trait. The provider is injected through
Yew’s context system using a public newtype:
┌─────────────────────────────────┐
│ BreadcrumbLabelProviderContext │ newtype around
│ ├── Rc<dyn ...Provider> │ Rc<dyn Provider>
└────────────┬────────────────────┘
│
<ContextProvider<BreadcrumbLabelProviderContext> context={…}>
<App/> │
... │
use_breadcrumbs() ──► reads ctx ──► label_for_path()
Why a newtype rather than putting Rc<dyn Provider> directly into context?
Because yew-router’s reactive context needs PartialEq, and we implement
that as Rc::ptr_eq so re-renders only happen when the concrete provider
value changes, not on every render where the provider is re-created via
Rc::clone.
This is the only stateful pattern in the crate; everything else is pure props.
5. What we don’t use, and why
- No
unsafe. There is no FFI surface and no performance hot-path that would justify it. - No
no_std. Yew requiresstd(allocations,Rc, threading primitives behind WASM); the CIno_stdjob documents this and exits successfully so a no-std intention is impossible to merge by accident. - No async. The hooks are synchronous;
use_navigationreturns callbacks and yew-router does the rest. - No internal
RefCell/Rcmutability beyond the breadcrumb provider context, which is logically immutable per app lifecycle.
6. Test layout
tests/ # integration tests, Yew-aware
benches/ # criterion benchmarks (path utils)
src/**/mod.rs (#[cfg(test)] modules) # unit tests, focused per module
Integration tests live in tests/ so they run against the public API
exactly as a consumer would write code; this catches accidental breakage
in re-exports that unit tests would miss.
7. Demo crate (example/)
The demo is a cdylib SPA served by trunk. It is intentionally not a
workspace member of the library crate — its only purpose is to demonstrate
the public API end-to-end and to be deployed to GitHub Pages.
The demo is structured around a DemoCard component: every public
component, hook, and utility appears in at least one card whose live
preview is rendered side-by-side with the exact Rust snippet that
produces it. The breadcrumb label provider is mounted at the top of the
tree so use_breadcrumbs has a real provider to read.
8. Decision history
This document is intentionally short on why. The reasoning behind each non-trivial design choice lives in the Architecture Decision Records, one per file in MADR format.
| Decision | ADR |
|---|---|
class / active_class use &'static str, not AttrValue | 0001 |
The macros feature was dropped in 0.9.0 | 0002 |
NavError is #[non_exhaustive] from 0.10.0 | 0003 |
NavLink renders a manual <a> instead of wrapping yew_router::Link | 0004 |
Active links emit aria-current="page" | 0005 |
New non-trivial decisions land as new ADRs; this table is updated in the same PR.