Repository navigation
@rangojs/router 0.29.0
Fixed: a second click over a held page no longer waits on a breadcrumb's pending content (#1311)
Before. On a transition() route, clicking a second link before the
first page showed could keep the old page on screen until a handle value's
promise content resolved: a breadcrumb whose content is read with use() in
a <Suspense> that was already revealed. The new page was ready, and the
screen stayed on the old one for seconds.
Now. A handle value that changes while React holds a page no longer holds
that page: every handle value for a page React holds in a transition, its own
included, is applied right after that page commits. A crumb's pending content
waits in its own boundary and the page shows when its response arrives.
Without view transitions the values land before paint. With view transitions,
the transition captures the page being left's crumb and title, never the
clicked-through page's any more, and the new values apply right after.
Unchanged: useSegments()'s path and location still commit with their page.
A page that commits at once gets its handle values in its commit, and late values
(push(promise), streaming) still arrive when they settle.
No change in your code.
Fixed: useNavigation() readers agree while React holds a page (#1310)
Before. On a transition() route, React holds the next page until it is
ready. A progress bar reading useNavigation((n) => n.isStreaming) dropped
when the response stream ended, while a status line reading the full
useNavigation() kept isStreaming: true and "loading" over the same old
page until the new one showed. Which one you got depended on whether the
reader happened to have a value React had not committed yet. A page that
ended the hold urgently (a Back to a page without transition(), a link with
transition={false}) could show with a selector still reading the held
page's "loading" or location.
Now. While React holds a page, every field commits by one rule, for every
useNavigation() reader mounted before the page was handed over, through a
selector or the full state: work that starts shows at once ("loading",
isStreaming and isNavigating turning true, a newer click's
pendingUrl), and what settles commits with the page ("idle", false, a
null pendingUrl). A page that ends the hold urgently shows with its own
values in every useNavigation() reader.
Two readers still differ. A useNavigation() reader that mounts while the
page is held reads the live values, the destination's. And when a page ends
the hold urgently, usePathname() can read the path of the page being left
on that page's first screen, until React finishes the render of the page it
held.
No change in your code.
Fixed: an earlier action's background revalidation keeps the error boundary a later action shows (#1303)
Before. The user fires a slow action on one page and moves to another
before it lands. Its response is not shown, so the page on screen gets one
background revalidation once the last action in flight ends. On that page the
user fires a second action, which throws into the page's errorBoundary().
The boundary showed, and about one round trip later the revalidation brought
the page back over it: the error vanished without explanation, though the
second action's await rejected.
Now. The revalidation keeps the boundary on screen, as the same node: its
<Suspense> content keeps streaming, and the handle values it pushed stay.
Every layout, slot and loader above the boundary re-renders with the earlier
action's write, as a revalidation that holds nothing does, whatever their
revalidate() decides for the second action. Nothing at or below the
boundary runs. When nothing is above it (the router's default error boundary
at the root), no request is sent. A navigation's error boundary (a handler
that threw on a GET) is a read, and the revalidation re-renders it as before.
A link to a page whose history entry shows an action's error boundary now
renders the page again, from that page or another one. So does a link from
another page to a page whose GET failed into a boundary. Before, either could
serve the entry's cached copy and show the old error.
After an action's boundary shows, the next link from the page re-runs what is
at or below the boundary: the route or layout it replaced, their loaders and
everything under them, so they show data read after the write. Before, the page kept holding them, so a link
could show a loader at the boundary with its data from before the action.
Under the router's default error boundary at the root, a link re-renders the
whole page; before, it kept every layout loader's data from before the
action.
No change in your code.
Fixed: Back, Forward and another tab's action keep the error boundary an action shows (#1306)
Before. An action threw into the page's errorBoundary(), and the user
followed a link away, then went Back. The boundary came back from the history
cache, and about one round trip later the page's background revalidation
rendered the page over it. When the entry was not revalidated (the action
called keepClientCache()), the boundary stayed, but a link to the page
itself left the error on screen. In another tab, any action made this tab
refresh, and the refresh rendered the page over the boundary.
Now. Back and Forward keep the boundary on screen, as the same node. The
revalidation re-renders every layout, slot and loader above it with what was
written since, and nothing at or below it. Another tab's action refreshes
the page the same way. A link to the page renders it again. A
router.refresh() your code calls still renders the whole page, the
boundary included, and a navigation's error boundary (a handler that threw on
a GET) is re-rendered as before.
No change in your code.
Fixed: onError reports duration, env, an intercept's slot and a streamed handler's isPartial (#1304)
durationwas never set. It is now the time from the request's start to
the error,dispatch()from@rangojs/router/testingincluded. A
backgrounderror counts from the start of the request that started it.envwas unset on an error the segment walk catches (a route, layout or
slot handler that throws). It is now the request'senv.- An intercept handler's error reported the layout that declares the
intercept (segmentType: "layout", itssegmentId). It now reports the
intercept's slot (segmentType: "parallel",segmentIdending in the
slot name), as a streamed intercept handler already did. The error
boundary's fallback and thehandler.errortelemetry event receive the
samesegmentTypeandsegmentId. - A streamed (
loading()) handler's rejection on a navigation reported
isPartialunset. It now reportstrue, as a handler that throws before it
returns does.
Fixed: onError names a parallel slot or orphan layout that throws, not the entry that owns it (#1305)
Before. A parallel slot handler that threw (with no loading(), or before
it streamed under one), or a routeless (orphan) layout's handler that threw,
reached onError as an error of the layout or route that owns it: that
entry's segmentType and segmentId. A slot's rejection under loading()
reported the slot, so one slot showed up under two identities.
Now. Both report the segment that threw: segmentType: "parallel" and the
slot's id, or the orphan layout's id. The owning entry's error boundary still
renders the fallback in the same place, and its fallback receives the same
segmentType and segmentId. No change in your code.
Fixed: useAction() follows the latest call when an earlier call returns after it (#1309)
Before. With the same action called twice and the later call returning
first, useAction() went back to the earlier call, which was still running:
an AddToCart button showed "Adding…" again, never showed the later call's
result, and ended on the earlier call's result.
Now. The hook reports the last call to start. It shows that call's result
when it returns, and an earlier call that settles afterwards changes nothing
the hook reports, a result or an error alike (its revalidation still
re-renders the page, and an error-boundary response still replaces it). A
latest call that was aborted reports idle with nothing, never an earlier
call. A string id (useAction("addToCart")) follows the latest call of any
action with that export name; a function or a full id follows the latest call
of that action. The hook tracks one call at a time, so a reader that filters
on payload cannot track an earlier call once a later one starts.
Fixed: a server action during a held navigation leaves the page its transition types (#1309)
Before. With transition() and a view transition, a useAction() state
change while React held the next page (a Save pressed during the wait)
committed in a transition of its own and took the page's transition types,
so a <ViewTransition> keyed by push did not animate the page.
Now. useAction() sets every state urgently, and useLinkStatus()
sets pending urgently and ends it with the page; neither starts a
transition of its own, so the page's commit carries its types. A
useAction() value never waits for the page.
Fixed: useNavigation() reaches idle with the page React holds (#1309)
Before. On a transition() route, a filter UI that reads
useSearchParams() and useNavigation() (#1164) removes a chip; React holds
the new page while its data loads. If a server action settled during that
wait, or a newer navigation ended with no page (an external redirect, a
blocked redirect payload, a reload), useNavigation() committed idle while
the old page and its search were still on screen, and the removed chip came
back until the new page arrived.
Now. While React holds a page, idle arrives with that page. The same
holds for an action's revalidated content: the idle that ends an action
arrives with it. A newer navigation that fails shows the error screen, and
idle arrives with it; while a <form action> is still pending, React holds
that error screen until the action ends, and state reads "loading" until
then. Work that starts during the wait still shows at once: a newer click's
"loading" and pendingUrl, also while an action is in flight.
useLinkStatus() answers for its own link: a link whose navigation is
aborted ends its pending at once, while useNavigation() keeps "loading"
until a tree React holds for another flow lands.
No change in your code.
Fixed: useNavigation().location stays the page on screen when a second click goes out (#1309)
Before. Click a link to item 2 on a transition() route; React holds item
2's page while its data loads. Click item 3 during the wait: the full
useNavigation() state showed pendingUrl item 3 and location item 2,
while item 1 was still on screen. If item 3's page replaced item 2's first,
item 2 never showed, yet location had named it.
Now. location is the page on screen until the next page shows, and
pendingUrl is the page being fetched. The second click shows "loading"
and its pendingUrl at once and keeps location on item 1. location moves
to item 2 only if item 2's page shows, and to item 3 with item 3's page. A
selector that reads only location.href already behaved this way.
No change in your code.
Changed: renderRoute restores the page on back/forward, and navigate() takes streamUntil (#1310)
Before. renderRoute kept the tree on one page. A popstate after
router.navigate(url, { state }), such as window.history.back(), left the
destination on screen and only handed readers the entry's location state. A
navigation had no response stream, so useNavigation().isStreaming stayed
false.
Now. A back/forward onto the entry renderRoute mounted, or one a
navigate() wrote, restores that entry's page with its params, location
state and handle values, as production's history-cache restore does;
transition({ when }) decides it as kind "pop". An entry the test writes
over with history.replaceState() restores nothing: readers take it as
history holds it, as before. A stale entry's revalidation and a restore that
fetches stay at e2e.
navigate(url, { streamUntil }) keeps the navigation's response stream open
past the commit until the promise settles, or until a newer navigation aborts
it, as in production:
const tail = deferred<void>();
await router.navigate("/items/2", { streamUntil: tail.promise });
// useNavigation(): "idle", isStreaming true
await act(async () => tail.resolve());
// useNavigation(): "idle", isStreaming falseA test that went back after a navigate() with state and expected the
destination to stay on screen now sees the page it went back to. A test that
only checks the entry's location state writes it with
history.replaceState(entry, "") before the popstate.
A navigation started while React holds a page goes inside
await act(async () => { ... }). A synchronous act() drops React's retry
of the held page, and an urgent commit after it never renders.
Added: onError marks an error from a background refresh with background: true (#1304)
Before. A stale cache() record, document cache entry, PPR shell,
"use cache" function or cached loader is served stale and refreshed beside
the request. An error in that refresh reached onError like the visitor's
own: the same request, url and phase, and handledByBoundary: true for
a fallback no one saw. An error tracker counted a refresh that failed behind a
page that rendered fine as a failed page view. A PPR route whose recapture
failed reported the same handler error a visitor's own render would.
Now. OnErrorContext.background is true for an error from work whose
output goes only to a cache: a stale record's refresh, the write of a
navigation that kept a layout mounted, a stale document's re-run, a shell
capture, a stale "use cache" function's or cached loader's refresh. The
refresh's own failure, reported with phase: "cache", carries it too.
handledByBoundary is false on such an error: the failed render is not
stored, and the stale entry keeps serving. Every other error leaves
background unset and reports as before, a router.prerender() warm's own
render included.
An error object both the visitor's render and a refresh reach, such as a
memoized rejection, is now reported by each. Before, whichever reported first
took the place of the other, so a refresh's report could stand in for the
visitor's.
createRouter({
onError: ({ error, background }) => {
errorTracker.capture(error, { level: background ? "warning" : "error" });
},
});Added: useNavigation().isNavigating is typed (#1309)
Before. useNavigation() always returned isNavigating, but
NavigationState did not declare it, so useNavigation((n) => n.isNavigating)
failed to typecheck. The docs said state reads "loading" during a
navigation; it also reads "loading" while a server action runs, so a
progress bar driven by state ran on every action.
Now. isNavigating is part of NavigationState: true only while a
navigation is in flight. Drive a navigation progress bar from it, and an
action spinner from state. The values are unchanged.
const isNavigating = useNavigation((n) => n.isNavigating);Changed: useLinkStatus() stays pending until the page shows (#1309)
Before. A link's pending ended when the response started streaming.
Under transition(), React then held the page being left for the rest of the
wait, with the spinner already gone. Through a server redirect, the clicked
link's spinner ended before the redirect was read.
Now. pending is true from the click's notification until the commit
that shows the destination: its loading() skeleton, or under
transition() the page itself, which React holds until it is ready.
Through a server redirect the clicked link stays pending until the target
page commits. A newer navigation or an abort ends it at once, and a failure
ends it with the error. Like useNavigation(), a change commits in the lane
of the notification that carries it: one that another flow's commit flushes
inside its transition (a server action's, handed to React in the click's
task) commits with that commit, so in that narrow case the spinner shows,
or a newer click or an abort ends it, when that commit lands. useNavigation().pendingUrl is unchanged: it still
turns null when the response arrives, so while the response streams a
link reads pending and pendingUrl is null. renderRoute does the
same: pending is held with a transition navigate()'s commit.