Introduction
This book is the architectural reference for yew-nav-link. It
is not a tutorial — the crate’s README is the place to learn
what the library does and how to drop it into your Yew app. The book
collects the documents that explain why the crate is shaped the way
it is and the contracts it commits to.
What is here
- Requirements — the functional and non-functional contracts the crate commits to. Every claim in this document is exercised by the test suite, the demo, or the CI pipeline.
- Architecture — the module layout, the active-state algorithm, hook contracts, and the breadcrumb context flow.
- Roadmap — what is in flight between the current 0.10 line and the 1.0 freeze.
- Branching and merge policy — how changes reach
mainand what the squash-merge contract is. - Architecture Decision Records — one MADR file per non-trivial decision. The reasoning behind 0.9 → 0.10 lives here.
What is not here
- API docs. Those live on docs.rs and are generated from the crate’s source. The book intentionally does not duplicate them.
- The contributor workflow. That is in
CONTRIBUTING.mdin the repository root. - The release procedure. That is in
RELEASE.md. - The changelog. That is in
CHANGELOG.md.
How to read this book
ADRs are append-only and small. Read the index first
(adr/README.md), pick the decision you want context on,
and the body answers in roughly one screen. The Reference and Process
sections are longer prose; read top-to-bottom on first visit, jump back
to a section by its heading on subsequent visits.
When a record is superseded, it stays in place and the new record marks
it superseded by NNNN. The history is intact — the file index always
reflects the latest accepted state.
Requirements
This document captures what yew-nav-link is contractually expected to do
(functional requirements) and the constraints under which it does it
(non-functional requirements). Anything claimed here is exercised by the
test suite, the demo, or the CI pipeline.
1. Functional requirements
1.1 NavLink<R>
FR-NL-1. Render a yew-router Link<R> with the same target and child
content. The component is a wrapper, never a replacement.
FR-NL-2. Compute an active state by comparing the current route to the target on every render:
partial | Match condition |
|---|---|
false (default) | exact equality between current and to |
true | to.to_path() is a path-segment prefix of current.to_path() |
FR-NL-3. Apply two CSS classes to the rendered anchor:
- The base class — defaults to
"nav-link", overridable via theclassprop (&'static str). - The active class — defaults to
"active", overridable via theactive_classprop (&'static str). Only emitted when active per FR-NL-2.
FR-NL-4. When the user clicks the link, the browser history is updated
to to and the framework re-renders dependent hooks. Behaviour is delegated
to yew_router::Link; the wrapper does not intercept clicks.
1.2 nav_link() function
FR-FN-1. Return an Html value that contains a NavLink<R> whose
partial flag is derived from a Match argument:
Match::Exact → partial = false, Match::Partial → partial = true.
FR-FN-2. Accept the link text as &str; it is rendered as a single
text child.
1.3 Reactive hooks
FR-HK-1. use_route_info::<R>() -> Option<R> returns the currently
matched route, or None when no registered route matches.
FR-HK-2. use_is_active(route) and the use_is_exact_active(route)
alias return true iff the current route equals route.
FR-HK-3. use_is_partial_active(route) returns true iff
route.to_path() is a path-segment prefix of the current path.
FR-HK-4. use_navigation::<R>() -> Navigation<R> returns a value-type
struct exposing pre-built callbacks: push_callback, replace_callback,
go_callback, go_back, go_forward. Each callback is Callback<()>
(or Callback<i32> for go_callback) so consumers can adapt with
.reform(...) for onclick handlers.
FR-HK-5. use_query_params() -> HashMap<String, String> parses the
current URL’s query string into a flat map; reactive on every URL change.
FR-HK-6. use_breadcrumbs::<R>() -> Vec<BreadcrumbItem<R>> builds a
breadcrumb trail from the current path. The label of each item comes from a
BreadcrumbLabelProvider injected into the tree via
BreadcrumbLabelProviderContext. When no provider is present the path
itself is used as the label. The last item has is_active == true.
1.4 Components
The crate ships UI components that are render-only — they hold no business logic and accept all required state through props.
| Component | Role |
|---|---|
NavList | <ul> with sensible ARIA defaults |
NavItem | <li> |
NavDivider | <hr> |
NavHeader | section heading inside a list |
NavText | inert text inside a list |
NavBadge | inline pill, variant + optional pill=true |
NavIcon, NavLinkWithIcon | icon container + paired layout helper |
NavTabs, NavTab, NavTabPanel | tab strip; consumer drives active |
NavDropdown, NavDropdownItem, NavDropdownDivider | self-managed open/close menu |
Pagination, PageItem, PageLink | full pagination renderer + lower-level building blocks |
1.5 Errors
FR-ER-1. NavError is a #[non_exhaustive] pub enum. As of 0.10
it has three variants: RouteNotFound, InvalidRoute(String),
NavigationCancelled. It implements std::fmt::Display,
std::error::Error, Clone, PartialEq, Eq, and Debug. Future
minor releases may add new variants without bumping the major version;
consumer matches must include a _ => arm.
FR-ER-2. NavResult<T> is a public alias for Result<T, NavError>.
1.6 Utilities
FR-UT-1. is_absolute(path) returns true iff path starts with a
URL scheme (scheme://...).
FR-UT-2. join_paths(a, b) concatenates two path segments,
collapsing duplicate separators.
FR-UT-3. normalize_path(path) resolves . and .. segments without
escaping the root.
FR-UT-4. urlencoding_encode percent-encodes a string;
urlencoding_decode returns Option<String> (None on malformed input).
2. Non-functional requirements
2.1 Compatibility
| Requirement | Value |
|---|---|
| MSRV | Rust 1.95+, enforced by CI’s MSRV matrix on Linux/macOS/Windows |
| Edition | 2024 |
| Yew | 0.23+ |
| yew-router | 0.20+ |
no_std | Not supported. The Yew runtime requires std; this is a deliberate non-goal documented in CI’s no_std job. |
| Browser support | Whatever Yew CSR + wasm32-unknown-unknown supports |
2.2 Versioning
NFR-V-1. The crate adheres to Semantic Versioning 2.0.0 and to the
Cargo 0.x interpretation: while the major version is 0, every
breaking change increases the minor (0.x → 0.(x+1)); additive changes
increase the patch (0.x.y → 0.x.(y+1)).
NFR-V-2. Every release is tagged on main (vX.Y.Z), published to
crates.io, and published as a GitHub release whose body reproduces the
matching CHANGELOG.md section.
NFR-V-3. CHANGELOG.md follows
Keep a Changelog and is written
ahead of the merge that tags the release.
2.3 Quality gates
NFR-Q-1. Every commit on main was produced by a PR whose CI passed
the following gates:
cargo +nightly fmt --all -- --checkcargo clippy --workspace --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nurserycargo nextest run --all-features --profile cicargo test --doc --all-featurescargo llvm-covupload to Codecovcargo deny checkcargo auditreuse lintactionlinttrunk build --releaseagainstexample/- Lighthouse CI thresholds (perf 0.85, a11y 0.9, best-practices 0.9, SEO 0.9)
NFR-Q-2. No unsafe blocks in src/ or tests/.
NFR-Q-3. No unwrap() or expect() outside #[cfg(test)] in the
public crate — error handling uses ?, Option::map_or_else,
Option::unwrap_or, etc.
NFR-Q-4. Every public item carries a /// doc comment and a doctest
where it makes sense.
2.4 Accessibility
NFR-A-1. Library components emit ARIA attributes appropriate to their
role: NavList carries role="navigation" + aria-label, NavTabs set
role="tab" / aria-selected / aria-controls, etc.
NFR-A-2. The bundled demo (example/) honours prefers-color-scheme: dark, prefers-reduced-motion: reduce, ships a skip-to-content link, and
maintains a visible :focus-visible ring across the whole UI.
2.5 Security
NFR-S-1. Every supply-chain advisory surfaced by cargo audit or
cargo deny check blocks the release pipeline. Two unmaintained warnings
on transitive proc-macro-error (pulled through yew-macro) are
acknowledged in CI as informational; they do not have a known exploit path.
NFR-S-2. Disclosure policy lives in SECURITY.md.
2.6 Licensing
NFR-L-1. The crate is MIT-licensed. Every Rust, SCSS, HTML, YAML,
and Markdown file carries SPDX-FileCopyrightText and
SPDX-License-Identifier headers, and reuse lint passes in CI.
3. Out-of-scope
- Server-side rendering — Yew SSR is supported by yew-router, but
yew-nav-link’s active-state computation has only been tested under CSR. - A custom router — the crate is a companion to
yew-router, not a replacement. - Internationalisation of breadcrumb labels — provided by the consumer via
BreadcrumbLabelProvider. - Telemetry / analytics integration.
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.
Roadmap
Public plan toward stabilising yew-nav-link at 1.0. Anything here is a
target, not a promise; the GitHub milestones track the
authoritative cutline of each release.
0.9.x — previous line (closed)
Released. Status: closed; no further patches. The active line is 0.10.x.
- 0.9.0 dropped the macros feature.
- 0.9.1 replaced the multi-page demo with a single-file SPA, fixed the
use_breadcrumbssegment-loop shadow binding, and SPDX-tagged every source file. - 0.9.2 surfaced
BreadcrumbLabelProviderat the crate root. - 0.9.3 made
BreadcrumbLabelProviderContextpart of the public API so consumers can actually inject a provider (the trait alone was reachable but inert). - 0.9.4 fixed
normalize_path./..resolution, madeurlencoding_decodeUTF-8 aware, and stoppedpagination_pagepanics on adversarial inputs.
0.10.x — current line
Released 2026-05-10. Status: maintained.
The breaking-change pass before 1.0 — four targeted breakages consumers upgrade through in one hop:
NavErroris#[non_exhaustive], leaving room for new variants under semver-minor.BreadcrumbLabelProviderContext’s tuple field is private; construct via::newand read via.provider().- The orphan
route_params.rsmodule is removed; migrate touse_route::<R>()with aRoutableenum. - Active
NavLinkemitsaria-current="page"on the rendered<a>for screen-reader-correct active state.
MSRV review for the line completed with no bump: stable Rust 1.95 was the latest stable when 0.10 was cut and remained so through the window (see closed issue #71).
Subsequent 0.10.x patches address CI, dependency bumps, and documentation only — no public API changes.
1.0.0 — API freeze
Target window: earliest 2026-08, after 0.10.x has spent at least one quarter in the ecosystem with no public API changes. Status: dependent on 0.10 feedback.
The 1.0 commitment is small and deliberately boring:
- Public API freeze. Every name re-exported from
lib.rsbecomes a semver-stable surface; subsequent breaking changes require a 2.0. - Documented backwards-compatibility window in
SECURITY.mdanddocs/REQUIREMENTS.md: how long 1.x receives security patches. - Migration guide from 0.x in
CHANGELOG.mdfor the major bump.
Nothing about 1.0 is meant to be flashy. It is the version we keep shipping for years.
Beyond 1.0 — speculative
Tracked in GitHub Discussions, not committed to:
- SSR support: yew-router supports it, but the active-state hooks have not been tested under SSR.
- Optional
axum-router-style integration helpers for projects that generate theirRoutableenum from a backend. - A standalone
aria-currentconsumer hook for projects that build their own link components but want yew-nav-link’s matching algorithm.
How to influence the roadmap
Open an issue with the enhancement label and a clear use case. Concrete,
narrow proposals beat large redesigns; we will steer toward what existing
consumers actually need.
Branching and merge policy
This document is the source of truth for how changes reach main. It
codifies what .github/settings.yml enforces at the GitHub layer and how
CONTRIBUTING.md instructs contributors to interact with it.
1. The default branch
main is the only long-lived branch. Every release ships from it.
Protection rules (set by .github/settings.yml):
| Rule | Value |
|---|---|
| Required status check | CI Success (aggregate of every job in .github/workflows/ci.yml) |
| Strict status checks | yes — PR must be up to date with main |
| Linear history | yes — no merge commits |
| Force pushes | blocked |
| Branch deletion | blocked |
| Approving reviews required | 1 (CODEOWNERS) |
| Stale review dismissal | yes |
There is no develop, no release/*, no hotfix/*. Earlier versions of
settings.yml mention a develop branch; that is legacy configuration and
is not used in practice.
2. Feature branches
One branch per GitHub issue, named after the issue number:
git checkout -b 123
No prefixes (feat/, feature/, bugfix/), no descriptive slugs. The
issue is the source of truth for what the branch is about; the title and
body of the PR carry the human-readable framing.
A branch may carry multiple commits — the squash-merge collapses them into
a single line on main. Keep commits small and focused; rebase locally to
clean up before pushing if needed.
3. Commit format
Every commit message starts with the issue reference and a type prefix:
#<issue> <type> <description>
No colon after the type, no (scope). cliff.toml rewrites this form
into a conventional-commit subject (<type>: <description>) before
git-cliff classifies it, so the prefixes still decide which CHANGELOG
section the commit lands in:
| Prefix | CHANGELOG section | Triggers minor/patch bump under release-plz |
|---|---|---|
feat | Features | minor |
fix | Bug Fixes | patch |
docs | Documentation | patch |
refactor | Refactoring | patch |
ci | CI | patch |
deps, chore(deps) | Dependencies | patch |
test | (skipped) | none |
chore | (skipped) | none |
Breaking changes carry a ! after the type (feat!, refactor!) and
trigger a major bump.
4. Merge style
Squash merge, delete branch. Configured in .github/settings.yml:
allow_squash_merge: true
allow_merge_commit: false
allow_rebase_merge: true
delete_branch_on_merge: true
squash_merge_commit_title: PR_TITLE
squash_merge_commit_message: PR_BODY
Rebase merge is allowed for the rare case where individual intermediate
commits carry value on their own (e.g. a sequence of independent refactors
that should be bisectable). Default is squash: one PR = one commit on
main, with the PR title becoming the conventional-commit subject and the
PR body becoming the commit message.
Force pushes to main are blocked at the GitHub layer; this is not a
convention, it is a hard wall.
5. Pull request
| Field | Requirement |
|---|---|
| Title | Issue number only (e.g. 123). The squash commit subject inherits this. |
| Body | Must include Closes #123 so the issue auto-closes on merge. |
| Status checks | CI Success must be green. |
| Reviews | 1 approving review from a CODEOWNERS entry (currently @RAprogramm). |
| Up-to-date | Strict mode is on — rebase onto main if it has advanced since CI ran. |
The PR body is what readers of the commit history will see on main, so
write it for that audience, not just the reviewer.
6. Reverting
A bad change is rolled back with a revert PR, never with a force-push or history rewrite:
git revert <sha>
git checkout -b <new-issue>
git push -u origin <new-issue>
gh pr create --title "<new-issue>" --body "Closes #<new-issue>"
The revert commit follows the same #<issue> revert <description> form,
and the new issue documents why the original change was rolled back.
7. Releases
Releases happen on main. There is no release branch.
The bump → publish flow is documented in RELEASE.md. In
short: a release PR (today hand-edited, eventually opened by release-plz)
bumps Cargo.toml version and prepends a section to CHANGELOG.md;
merging it triggers the publish job. Every release tag is annotated and
signed.
8. References
CONTRIBUTING.md— full contributor workflow.RELEASE.md— release procedure..github/settings.yml— enforced settings.cliff.toml— conventional commit → CHANGELOG mapping.
Architecture Decision Records
Each non-trivial design decision in yew-nav-link is captured as a
short MADR-style document. Decisions are append-only — a record
is never edited after its status moves to accepted. Reversals are
expressed as new ADRs that mark the prior record superseded by NNNN.
Index
| ID | Title | Status |
|---|---|---|
| 0000 | Record architecture decisions | accepted |
| 0001 | class and active_class are &'static str | accepted |
| 0002 | Drop the macros feature in 0.9.0 | accepted |
| 0003 | NavError is #[non_exhaustive] from 0.10.0 | accepted |
| 0004 | Render a manual <a> instead of wrapping yew_router::Link | accepted |
| 0005 | Active NavLink emits aria-current="page" | accepted |
When to write an ADR
Write one when the answer to “why is it this way” is non-obvious and
would not be derivable from reading the code alone. Renaming a field is
not an ADR. Choosing &'static str over AttrValue is an ADR.
Template
Copy 0000-record-architecture-decisions.md as a starting point. Keep
each record to roughly one screen — context, decision, consequences. The
goal is to read fast and rot slowly.
0000 — Record architecture decisions
- Status: accepted
- Date: 2026-05-12
- Deciders: RAprogramm
Context
docs/ARCHITECTURE.md describes the current shape of the crate. It is
silent on why that shape was chosen, on what alternatives were rejected,
and on what would have to change for the decision to be revisited.
Reviewers (KaiCode jurors among them) reading the source can see the
result, not the reasoning. The reasoning lives in PR descriptions, git
history, and the maintainer’s head — three places that decay differently
and none of which the casual reader will dig through.
Decision
Maintain an Architecture Decision Record log in docs/adr/, one
decision per file, using the MADR template.
Each ADR carries:
- A numeric, monotonically increasing ID (
0001-,0002-, …) baked into the filename so cross-references survive renames. - A status (
proposed,accepted,superseded by NNNN,deprecated). - The context that made the decision necessary.
- The decision itself, in declarative form.
- The consequences — positive, negative, and what it costs to reverse.
ADRs are written when the decision is made, not retrofitted later. The opening batch (0001–0005) is the exception: it documents decisions that shaped the 0.9/0.10 cycle and would otherwise stay implicit.
Consequences
Positive
docs/ARCHITECTURE.mdbecomes a living overview that links to the ADR log instead of trying to encode rationale inline.- New contributors can see why a constraint exists before proposing to break it.
- Breaking changes carry an ADR — the ROADMAP entry becomes one line pointing at the ADR for the why.
Negative
- One more place to write to when shipping a non-trivial change. Mitigated by keeping ADRs short — a single screen is the target.
Cost to reverse
Low. ADRs are markdown files; removing them does not change any code.
0001 — class and active_class are &'static str
- Status: accepted
- Date: 2026-05-12
- Deciders: RAprogramm
Context
NavLinkProps exposes two CSS-class slots:
#![allow(unused)]
fn main() {
#[prop_or("nav-link")]
pub class: &'static str,
#[prop_or("active")]
pub active_class: &'static str,
}
The natural alternatives in the Yew ecosystem are AttrValue (an
internally Rc<str>-backed handle used by attribute values) and
Classes (an internal Vec<AttrValue> with deduplication and lazy
join). Both accept runtime-built strings; &'static str does not.
The question is whether the cost of &'static str — consumers cannot
pass an interpolated class list — outweighs the cost of the alternatives:
an allocation on every render, a deeper trait stack, and a less obvious
“what is this argument” answer for newcomers.
Decision
Keep &'static str for both class slots.
The crate’s contract is that class slots configure the wrapper. A
consumer who needs runtime classes composes them on the parent element
or via a tailwind/CSS-in-JS layer, not by funnelling a format!("{}{}", …) through NavLink. The 99% case is a literal class name; making
literals zero-cost is the right trade.
For the breadcrumb chain and other components where class composition is
unavoidable, those props use Classes directly. The asymmetry is
deliberate: NavLink is the hot path, breadcrumbs are not.
Consequences
Positive
- Zero allocation per render for the common case.
- Compile-time enforcement that class names are constants — a
Stringbuilt from user input cannot land in a CSS class slot, eliminating a class of HTML-injection footguns at the type level. NavLinkProps: Copy-ish: every field is trivially clonable, making#[derive(Clone, PartialEq)]cheap.
Negative
- A consumer who needs runtime class composition on
NavLinkspecifically cannot do it through the prop. They must either lift the class to the parent or wrapNavLinkin their own component. - The breadcrumb-vs-NavLink asymmetry is a small surprise for new
readers and is documented in
docs/ARCHITECTURE.md.
Cost to reverse
Moderate. Switching to AttrValue is a semver-breaking signature change
across the public props of NavLink. The internal build_class helper
would also need to grow an allocation path. No data migration; consumer
upgrade is one-line per call site.
0002 — Drop the macros feature in 0.9.0
- Status: accepted
- Date: 2026-04-16
- Deciders: RAprogramm
Context
Versions 0.1 – 0.8 shipped a proc-macro crate (yew-nav-link-macros)
behind a macros feature flag. It offered a nav_link!() declarative
macro that expanded to the same NavLink invocation that the function
syntax (nav_link(...)) already produced.
The macro existed for one reason: callers who wanted to spell the link
inline (html! { nav_link!(Route::Home, "Home") }) without an extra
{ } around the function call. That ergonomic gain came with a
disproportionate maintenance tail:
- A whole proc-macro crate (build-script, separate
Cargo.toml, separate publish step) for what compiled down to one line of code. - Trybuild tests for the macro’s diagnostic messages — flaky across toolchain versions, and yet another CI matrix to maintain.
- A second documented API surface every contributor had to keep in sync with the function and component syntaxes.
- A feature flag that almost no public consumer enabled (visible from reverse-deps on crates.io at the time of the decision).
Decision
Remove the macros feature and the yew-nav-link-macros sub-crate in
0.9.0. Keep both the component syntax (<NavLink<R> ...>) and the
function syntax (nav_link(Route::Home, "Home", Match::Exact)).
nav_link() is the de facto replacement for the macro: it composes
into html! { } without extra braces (html! { { nav_link(...) } }
becomes html! { nav_link(...) } because Html implements the
necessary conversions inline).
Consequences
Positive
- One less crate to publish, one less feature matrix to test, one less proc-macro to debug across compiler upgrades.
- The two remaining APIs (component, function) are clearly differentiated: component when you need props, function when you need an inline expression.
- MSRV bumps and Yew bumps land faster because there is no proc-macro layer to chase.
Negative
- A breaking change for consumers who had enabled
macros. Migration is mechanical:nav_link!(R, "x")→nav_link(R, "x", Match::Exact). - One historical convenience is gone.
Cost to reverse
High. The proc-macro crate would have to be re-published from scratch with its own version history, and the feature flag re-introduced. We do not expect to reverse.
References
- CHANGELOG
[0.9.0]— “Macros feature removed”. - ROADMAP
0.10.0 — breaking-change pass.
0003 — NavError is #[non_exhaustive] from 0.10.0
- Status: accepted
- Date: 2026-05-10
- Deciders: RAprogramm
Context
NavError carries the failure cases the navigation layer surfaces to
consumers:
#![allow(unused)]
fn main() {
pub enum NavError {
RouteNotFound,
InvalidRoute(String),
NavigationCancelled,
}
}
Through 0.9.x the enum was plain — consumers could write exhaustive
match expressions without a wildcard arm. Pleasant in the short term;
a semver bear-trap in the long term, because adding a variant is then
a breaking change.
The ROADMAP slates several future variants: redirect cancellation distinct from generic cancellation, permission-denied (when the consumer wires up a route guard), timeout. Each addition under plain enum semantics would force a major bump.
Decision
Mark NavError #[non_exhaustive] as part of the 0.10.0 breaking-change
pass.
#![allow(unused)]
fn main() {
#[non_exhaustive]
pub enum NavError {
RouteNotFound,
InvalidRoute(String),
NavigationCancelled,
}
}
Foreign-crate exhaustive matches must add a wildcard _ =>. Internal
matches inside yew-nav-link are unaffected — #[non_exhaustive] does
not apply within the defining crate.
Consequences
Positive
- New variants ship under semver-minor bumps. No further major bumps for this reason alone.
- Forces consumers to confront the “what if a new error appears” question at compile time, which is the right place for it.
Negative
- One-time mechanical migration for every consumer that exhaustively
matched
NavError. The compiler points exactly at the missing arm. - Wildcard arms can hide forgotten handling. Mitigated by recommending
_ => unreachable!("unknown NavError variant: {err:?}")for developer-built handlers that want loud failures.
Cost to reverse
High. Dropping #[non_exhaustive] is itself a non-trivial change:
consumers may rely on it to keep their wildcard arms exhaustive-by-fiat,
and removing it would be a semver-minor that enables exhaustive
matching but does not require it. We do not expect to reverse.
References
src/errors.rs:65— the#[non_exhaustive]attribute.- CHANGELOG
[0.10.0]— “Breaking changes”.
0004 — Render a manual <a> instead of wrapping yew_router::Link
- Status: accepted
- Date: 2026-05-10
- Deciders: RAprogramm
Context
Through 0.9.x NavLink was a thin wrapper around yew_router::Link. The
wrapper computed the active state, then handed off rendering to Link,
which produced the <a> element and intercepted clicks.
Two needs piled up against this layering:
- Attribute control. Active links should emit
aria-current="page"(ADR 0005), and the rendered set of attributes should be fully under our control for future additions (e.g.data-active, custom event handlers,downloadfor export links).yew_router::Linkdoes not expose hooks for the attribute set it renders. - Basename handling for sub-path deployments. The demo deploys at
https://raprogramm.github.io/yew-nav-link/, i.e. under a non-empty basename.Linkrendershreffromto.to_path()directly, which produces visually wrong hrefs on hover (/aboutinstead of/yew-nav-link/about). The demo worked around this with a runtime<base>hack — fragile and only addresses the symptom on that one site.
Decision
From 0.10.0, render the <a> manually:
hrefis built fromto.to_path()prepended with the navigator’s basename when present.- An
onclickhandler intercepts left-clicks (and only left-clicks — modifier-clicks fall through to the browser for “open in new tab”) and pushes the route via the capturedNavigator. - The full attribute set is under the component’s control.
Modifier-click behaviour matches Link exactly: meta, ctrl, shift,
alt, and middle-click skip prevent_default so the browser takes the
default route (new tab, save target, etc.).
Consequences
Positive
- Correct
hrefunder any basename, with no runtime workaround. aria-current="page"lands on active links (ADR 0005).- Future attribute additions are a one-line change inside
NavLink’shtml!instead of a router-layer feature request. - One fewer indirection in the render tree.
Negative
- We now own a small navigation-affordance contract that
yew_routerused to maintain. Ifyew_routerchanges howNavigator::pushinteracts with the browser’spopstate, we need to track that. Mitigated by exhaustive integration tests (issue #113, #114). - The wrapper grows from ~10 lines to ~50. Acceptable: the additional lines are intent-revealing.
Cost to reverse
Low. Reverting to a Link wrapper is a localised change in
src/active_link/nav_link.rs. Consumer-visible behaviour for the
non-basename case is identical, so a revert ships as a patch release.
References
src/active_link/nav_link.rs— the manual implementation.- CHANGELOG
[0.10.0]— “Changed: NavLink is rendered as a manual<a>”.
0005 — Active NavLink emits aria-current="page"
- Status: accepted
- Date: 2026-05-10
- Deciders: RAprogramm
Context
Through 0.9.x, active NavLinks were distinguished by a CSS class only
(active by default, overridable via active_class). This is enough
for sighted users — the visual state is conveyed by stylesheet — and
zero for assistive technology, which has no semantic signal that one of
the links in a navigation list is the current page.
The web platform has a dedicated answer:
aria-current. The value "page" declares that the
link points at the current document; screen readers (NVDA, VoiceOver,
JAWS) announce it as “current page” or equivalent. This is the WAI-ARIA
authoring guideline for navigation menus.
Decision
Active NavLinks emit aria-current="page" in addition to the active
class:
#![allow(unused)]
fn main() {
let aria_current = if is_active { Some("page") } else { None };
html! { <a class={class} href={href} aria-current={aria_current} ...> }
}
The attribute is omitted entirely (not set to a falsy value) when the link is inactive, so the rendered DOM is minimal on non-current links.
Consequences
Positive
- Screen readers correctly identify the current page in navigation menus.
- CSS authors get a second hook for active-state styling that does not
depend on the class name configuration —
[aria-current="page"] { … }works regardless of whether the consumer overrodeactive_class. - Aligns with WAI-ARIA Authoring Practices for navigation patterns; Lighthouse accessibility audits credit the attribute.
Negative
- Behaviour change in the rendered DOM. Consumers whose existing CSS
only targets
.activecontinue to work. Consumers whose CSS matched[aria-current]for unrelated reasons may see new matches. Documented in CHANGELOG[0.10.0]under “Breaking changes” so the surprise is visible. - A small amount of attribute churn during re-renders. Negligible — Yew’s DOM diffing handles the toggle cleanly.
Cost to reverse
Low. Dropping the attribute is a one-line change. We do not expect to reverse because the standard is clear and the accessibility benefit is unconditional.
References
- ADR 0004 — Manual
<a>rendering enabled this attribute addition. src/active_link/nav_link.rs— thearia_currentbinding.- CHANGELOG
[0.10.0]— under “Breaking changes” and “Added”.