Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 main and 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.md in 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:

partialMatch condition
false (default)exact equality between current and to
trueto.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 the class prop (&'static str).
  • The active class — defaults to "active", overridable via the active_class prop (&'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.

FR-FN-1. Return an Html value that contains a NavLink<R> whose partial flag is derived from a Match argument: Match::Exactpartial = false, Match::Partialpartial = 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.

ComponentRole
NavList<ul> with sensible ARIA defaults
NavItem<li>
NavDivider<hr>
NavHeadersection heading inside a list
NavTextinert text inside a list
NavBadgeinline pill, variant + optional pill=true
NavIcon, NavLinkWithIconicon container + paired layout helper
NavTabs, NavTab, NavTabPaneltab strip; consumer drives active
NavDropdown, NavDropdownItem, NavDropdownDividerself-managed open/close menu
Pagination, PageItem, PageLinkfull 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

RequirementValue
MSRVRust 1.95+, enforced by CI’s MSRV matrix on Linux/macOS/Windows
Edition2024
Yew0.23+
yew-router0.20+
no_stdNot supported. The Yew runtime requires std; this is a deliberate non-goal documented in CI’s no_std job.
Browser supportWhatever 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 -- --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery
  • cargo nextest run --all-features --profile ci
  • cargo test --doc --all-features
  • cargo llvm-cov upload to Codecov
  • cargo deny check
  • cargo audit
  • reuse lint
  • actionlint
  • trunk build --release against example/
  • 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. NavTabs and Pagination accept the active index / page through props; the consumer holds the use_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(), &current.to_path())
    } else {
        &current == 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 requires std (allocations, Rc, threading primitives behind WASM); the CI no_std job documents this and exits successfully so a no-std intention is impossible to merge by accident.
  • No async. The hooks are synchronous; use_navigation returns callbacks and yew-router does the rest.
  • No internal RefCell / Rc mutability 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.

DecisionADR
class / active_class use &'static str, not AttrValue0001
The macros feature was dropped in 0.9.00002
NavError is #[non_exhaustive] from 0.10.00003
NavLink renders a manual <a> instead of wrapping yew_router::Link0004
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_breadcrumbs segment-loop shadow binding, and SPDX-tagged every source file.
  • 0.9.2 surfaced BreadcrumbLabelProvider at the crate root.
  • 0.9.3 made BreadcrumbLabelProviderContext part 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, made urlencoding_decode UTF-8 aware, and stopped pagination_page panics 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:

  • NavError is #[non_exhaustive], leaving room for new variants under semver-minor.
  • BreadcrumbLabelProviderContext’s tuple field is private; construct via ::new and read via .provider().
  • The orphan route_params.rs module is removed; migrate to use_route::<R>() with a Routable enum.
  • Active NavLink emits aria-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.rs becomes a semver-stable surface; subsequent breaking changes require a 2.0.
  • Documented backwards-compatibility window in SECURITY.md and docs/REQUIREMENTS.md: how long 1.x receives security patches.
  • Migration guide from 0.x in CHANGELOG.md for 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 their Routable enum from a backend.
  • A standalone aria-current consumer 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):

RuleValue
Required status checkCI Success (aggregate of every job in .github/workflows/ci.yml)
Strict status checksyes — PR must be up to date with main
Linear historyyes — no merge commits
Force pushesblocked
Branch deletionblocked
Approving reviews required1 (CODEOWNERS)
Stale review dismissalyes

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:

PrefixCHANGELOG sectionTriggers minor/patch bump under release-plz
featFeaturesminor
fixBug Fixespatch
docsDocumentationpatch
refactorRefactoringpatch
ciCIpatch
deps, chore(deps)Dependenciespatch
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

FieldRequirement
TitleIssue number only (e.g. 123). The squash commit subject inherits this.
BodyMust include Closes #123 so the issue auto-closes on merge.
Status checksCI Success must be green.
Reviews1 approving review from a CODEOWNERS entry (currently @RAprogramm).
Up-to-dateStrict 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

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

IDTitleStatus
0000Record architecture decisionsaccepted
0001class and active_class are &'static straccepted
0002Drop the macros feature in 0.9.0accepted
0003NavError is #[non_exhaustive] from 0.10.0accepted
0004Render a manual <a> instead of wrapping yew_router::Linkaccepted
0005Active 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.md becomes 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 String built 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 NavLink specifically cannot do it through the prop. They must either lift the class to the parent or wrap NavLink in 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:

  1. 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, download for export links). yew_router::Link does not expose hooks for the attribute set it renders.
  2. Basename handling for sub-path deployments. The demo deploys at https://raprogramm.github.io/yew-nav-link/, i.e. under a non-empty basename. Link renders href from to.to_path() directly, which produces visually wrong hrefs on hover (/about instead 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:

  • href is built from to.to_path() prepended with the navigator’s basename when present.
  • An onclick handler 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 captured Navigator.
  • 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 href under 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’s html! 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_router used to maintain. If yew_router changes how Navigator::push interacts with the browser’s popstate, 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 overrode active_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 .active continue 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 — the aria_current binding.
  • CHANGELOG [0.10.0] — under “Breaking changes” and “Added”.