Skip to content

Commit 43f8125

Browse files
KidkArolisclaude
andcommitted
Split link state out of useLinkProps into useLinkState and data-pending
useLinkProps now returns only spreadable anchor props — the non-enumerable isCurrent/isPending trick is gone. Pending links carry a data-pending attribute for CSS styling, and useLinkState(to) serves programmatic reads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a8b7fd5 commit 43f8125

13 files changed

Lines changed: 162 additions & 86 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
## 0.7.0
22

33
- **Breaking**: move route state inside `<Router>` and remove the old prop-based lifecycle hooks (`useRoute`, `onNavigating`, `onNavigated`). Read the current route with `useRoute()` and observe committed route changes with regular React effects.
4-
- **Breaking**: remove function-form `<Link>` props and `extraProps`. Use `aria-current="page"` for CSS styling, or `useLinkProps()` when active/pending state needs to affect rendered output.
4+
- **Breaking**: remove function-form `<Link>` props and `extraProps`. Use `aria-current="page"` and `data-pending` for CSS styling, or `useLinkState()` when active/pending state needs to affect rendered output.
55
- Add Suspense-aware route transitions backed by React `useTransition`, exposed through `usePending()`.
66
- Add route `resolver` support for code-split route segments. Resolvers are preloaded during navigation and rendered through `React.lazy`.
77
- Add route `prepare(ctx)` support for fetch-as-you-render data loading. Returned `PreparedHandle`s are pinned while the route is committed and released on the next commit or `<Routes>` unmount.
88
- Add `<DelayedSuspense>` and `pendingDelayMs` for delayed skeleton fallbacks during in-flight route navigations.
99
- Add `transformRoute(route)` for synchronous pre-commit route rewrites, including URL replacement when the transformed route changes `url`.
1010
- Inject matched path params as props onto the route segment that declares each param.
11-
- Add per-link pending state through `useLinkProps(to).isPending`.
11+
- Add per-link pending state: `<Link>` and `useLinkProps(to)` set a `data-pending` attribute while the link's navigation is in flight (style with `a[data-pending]`), and `useLinkState(to)` returns `{ isCurrent, isPending }` for programmatic reads.
1212
- Add `usePendingRoute()` exposing the transformed route an in-flight navigation is heading to. Registers clicks, programmatic navigation, and browser back/forward alike.
1313
- Prepare the initial route during the first render so cold direct loads suspend on prepared data instead of reading an unseeded cache, and so chunk download and data loading overlap on direct loads too.
1414
- Deliver browser back/forward navigations outside the popstate task, via space-router 1.2's pluggable `schedule` option. React 19 flushes popstate-scheduled updates synchronously, which showed Suspense fallbacks instead of holding the previous route and never painted pending state; deferring traversal emits to a macrotask restores the same async transition semantics as link clicks.

MIGRATION.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,11 +220,12 @@ sets:
220220
}
221221
```
222222

223-
For active-aware logic that cannot be expressed in CSS, use `useLinkProps()`:
223+
For active-aware logic that cannot be expressed in CSS, use `useLinkState()`:
224224

225225
```tsx
226226
const linkProps = useLinkProps('/settings')
227-
return <a {...linkProps}>{linkProps.isCurrent ? 'Settings' : 'Go to settings'}</a>
227+
const { isCurrent } = useLinkState('/settings')
228+
return <a {...linkProps}>{isCurrent ? 'Settings' : 'Go to settings'}</a>
228229
```
229230

230231
User `onClick` handlers now compose with the router's internal click handling.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ function IssueSection() {
9090
- `useNavigate()` returns a programmatic navigation function accepting a string URL or Space Router target object.
9191
- `usePending()` returns React transition pending state for route navigation.
9292
- `usePendingRoute()` returns the route an in-flight navigation is heading to (post-transform), or `null` when idle. Covers clicks, programmatic navigation, and browser back/forward.
93-
- `useLinkProps(to)` returns anchor props plus non-enumerable `isCurrent` and `isPending`.
93+
- `useLinkProps(to)` returns spreadable anchor props: `{ href, aria-current, data-pending, onClick }`. Style current links with `a[aria-current='page']` and in-flight links with `a[data-pending]` in CSS.
94+
- `useLinkState(to)` returns `{ isCurrent, isPending }` for programmatic per-target state — tabs, sidebar items, breadcrumb spinners.
9495
- `useMakeHref()` returns the underlying `router.href` helper.
9596
- `useInternalRouterInstance()` exposes the underlying Space Router instance for rare escape-hatch use.
9697

demo/src/pages/ModeD.tsx

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -69,24 +69,22 @@ export default function ModeD({ id = ITEMS[0].id }: { id?: string }) {
6969

7070
<div className='item-demo'>
7171
<aside className='item-list' aria-label='Items'>
72-
{ITEMS.map((item) => {
73-
const isCurrent = item.id === currentId
74-
const isRequested = item.id === pendingItemId
75-
76-
return (
77-
<Link
78-
key={item.id}
79-
href={{
80-
url: `/mode-d/${item.id}`,
81-
current: isCurrent,
82-
}}
83-
className={`item-link${isRequested ? ' requested' : ''}`}
84-
>
85-
<strong>{item.name}</strong>
86-
<small>{item.meta}</small>
87-
</Link>
88-
)
89-
})}
72+
{ITEMS.map((item) => (
73+
// The requested item lights up through the `data-pending`
74+
// attribute `<Link>` sets while its navigation is in flight —
75+
// styled in CSS via `.item-link[data-pending]`, no JS derivation.
76+
<Link
77+
key={item.id}
78+
href={{
79+
url: `/mode-d/${item.id}`,
80+
current: item.id === currentId,
81+
}}
82+
className='item-link'
83+
>
84+
<strong>{item.name}</strong>
85+
<small>{item.meta}</small>
86+
</Link>
87+
))}
9088
</aside>
9189

9290
<section className={`item-detail${isFading ? ' is-fading' : ''}`} aria-live='polite'>

demo/src/styles.css

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ code {
138138
border-left-color: var(--text);
139139
}
140140

141+
/* the link whose navigation is in flight — attribute set by <Link> */
142+
.nav-link[data-pending] {
143+
background: var(--surface-muted);
144+
border-left-color: var(--text-dim);
145+
}
146+
141147
.nav-link small {
142148
display: block;
143149
margin-top: 1px;
@@ -442,7 +448,7 @@ code {
442448
border-left-color: var(--text);
443449
}
444450

445-
.item-link.requested {
451+
.item-link[data-pending] {
446452
background: var(--surface-muted);
447453
}
448454

dist/index.d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,26 @@ export declare function useMakeHref(): (to: import("space-router").To, curr?: Ro
130130
export interface LinkPropsResult {
131131
href: string;
132132
'aria-current': 'page' | undefined;
133+
'data-pending': '' | undefined;
133134
onClick: (e: MouseEvent<HTMLAnchorElement>) => void;
135+
}
136+
export interface LinkState {
134137
isCurrent: boolean;
135138
isPending: boolean;
136139
}
140+
/**
141+
* Anchor props for a router-driven `<a>`: `{ href, aria-current, data-pending,
142+
* onClick }`. Everything returned is spreadable. Style current links with
143+
* `a[aria-current='page']` and pending links with `a[data-pending]` in CSS;
144+
* for programmatic reads use `useLinkState(to)`.
145+
*/
137146
export declare function useLinkProps(to: LinkTo): LinkPropsResult;
147+
/**
148+
* Per-target link state without the anchor props: `{ isCurrent, isPending }`.
149+
* Accepts the same target as `useLinkProps`, but works for any navigable UI,
150+
* not just anchors — tab strips, sidebar items, breadcrumb spinners.
151+
*/
152+
export declare function useLinkState(to: LinkTo): LinkState;
138153
export interface LinkOwnProps {
139154
href?: LinkTo;
140155
replace?: boolean;

dist/index.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js

Lines changed: 28 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/content/_index.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -307,22 +307,34 @@ const linkProps = useLinkProps(to)
307307
<a {...linkProps} />
308308
```
309309

310-
Returns `{ href, aria-current, onClick, isCurrent, isPending }` so you can build your own anchor and get full router behavior without using `<Link />`. `isCurrent` and `isPending` are non-enumerable, so `<a {...useLinkProps(to)} />` stays safe, but you can still read them for active and per-link loading UI.
310+
Returns `{ href, aria-current, data-pending, onClick }` so you can build your own anchor and get full router behavior without using `<Link />`. Everything returned is spreadable — current and pending state ride along as attributes, so both can be styled in plain CSS:
311+
312+
```css
313+
a[aria-current='page'] {
314+
font-weight: 600;
315+
}
316+
a[data-pending] {
317+
opacity: 0.6;
318+
}
319+
```
311320

312321
Takes a `string` URL or an object — same fields as `useNavigate`, plus:
313322

314323
- `current` override automatic current-page detection.
315324

316-
For programmatic active-aware UI, read `isCurrent` from the returned props:
325+
### `useLinkState`
326+
327+
```js
328+
const { isCurrent, isPending } = useLinkState(to)
329+
```
330+
331+
Per-target link state for logic that cannot be expressed in CSS. Takes the same target as `useLinkProps`, but works for any navigable UI, not just anchors — tab strips, sidebar items, breadcrumb spinners:
317332

318333
```tsx
319334
const linkProps = useLinkProps('/settings')
335+
const { isCurrent } = useLinkState('/settings')
320336

321-
return (
322-
<a {...linkProps} className={cn('nav-link', linkProps.isCurrent && 'active')}>
323-
Settings
324-
</a>
325-
)
337+
return <a {...linkProps}>{isCurrent ? 'Settings' : 'Go to settings'}</a>
326338
```
327339

328340
### `useMakeHref`

0 commit comments

Comments
 (0)