Skip to content

Releases: rangojs/rango

@rangojs/router 0.33.2

Choose a tag to compare

@ivogt ivogt released this 02 Nov 11:34
e7da3dc

Fixed: router.refresh() keeps the page's location state (#1369)

Before. useRouter().refresh(), and the refresh another tab's server
action triggers, replaced the history entry with one that held only the
state the server set on the refreshed response. Every useLocationState
reader lost its value in the commit that showed the refreshed page (a
"load more" list dropped the pages it carried), and Back then Forward onto
the entry restored nothing.

Now. A refresh keeps the entry's location state, typed slots and plain
state, on the entry and in every reader, with the same value identity.
State the server sets on the refreshed response merges over it. A flash value
already shown stays on screen until the next navigation and is not written
back to the entry, so Back/Forward and a reload do not show it again. A
refresh of an open intercept still renders the full page, now with the
entry's location state.

Fixed: a remounted useLoader shows its own route's data, not an earlier page's refresh (#1370)

Before. After a load() (or a useRefreshLoaders() refresh) of a loader
registered on the route, the refreshed value stayed in the client's shared
store, and a reader that mounted later showed it instead of the data its own
navigation brought: on
another page with the same loader, on a new visit to the refreshed page, and
after passing through a page without the loader. A reader that stayed
mounted across a navigation drew the refresh for a frame before the new
route data, and a load() still in flight when the user left landed on the
next page, with its spinner and its error.

Now. A refreshed value belongs to the route data of the readers on screen
when load() was called. A reader whose route data is other data (a new
page, a new visit, a navigation or action that brought new data) shows that
data from its first frame, and its isLoading, error and throwOnError
ignore a refresh requested for other data. Readers of the same route data
still converge on the refresh, and Back onto a refreshed page from the
history cache shows the refresh again.

renderRoute now answers useLoader().load() and refresh-group refreshes
from the loader values the test gives (the render seeds, or
router.respondToLoads({ loaders })), through the testing kit's
@vitejs/plugin-rsc/browser alias: a test no longer mocks that module or
stubs fetch to drive a refresh.

@rangojs/router 0.33.1

Choose a tag to compare

@ivogt ivogt released this 01 Nov 21:59
175a0a0

Fixed: no "MISSING SEGMENT" error for an action whose page left while it was in flight (#1366)

An action sent from one page whose response landed after a navigation to
another (a clientUrls() destination's mount effect runs at the click, before
the navigation commits) logged [Browser] MISSING SEGMENT: <id> not in cache!
for the previous page's held segments, a revalidate(false) loader for one.
The response commits nothing, as before, and is no longer reconciled against
the new page, so nothing is logged. Such an action still runs in the previous
page's request context: pass what it needs as arguments.

Docs: moving a layout's <Outlet /> between branches remounts its children (#1367)

A layout that returns a bare <Outlet /> on one branch and a wrapped one on
another remounts every page below it when the branch flips: state resets and
mount effects run again. The /layout skill now states the rule (keep one
shape, or a stable key on the outlet when the only difference is a
top-level fragment), and /app-structure lists it among its rules.

@rangojs/router 0.33.0

Choose a tag to compare

@ivogt ivogt released this 01 Nov 19:35
378e67e

Changed: prefetch: false on an ssr: false loader of a ppr route keeps it out of a prefetch that has no document shell (#1364)

Before. On a ppr route, loader(Def, { ssr: false, prefetch: false })
ignored prefetch: false, and development warned that the loader "is
registered with ssr: false and prefetch: false on a ppr route". Every
viewport prefetch of a page whose shell no document request had captured ran
the loader.

Now. A speculative prefetch defers such a loader unless it serves the
loader from a pin of a shell a document request captured, as a route
without ppr defers it. The same holds for an ssr: false loader behind the
fallback of a flagged layout. The warning is gone.

The prefetch The flagged ssr: false loader
Replays a document-captured shell (HIT) served from its pin, never run; the click sends nothing
Replays a navigation-only shell, or misses (cold page) deferred; the click sends one fill that runs it
Serves an explicit cache() hit deferred; the click sends one fill that runs it
Hover (X-Rango-Prefetch: intent), navigation, fill unchanged: runs or is served as before

A cold prefetch still schedules the background shell capture, which runs the
loader once. A prefetch that defers it is scoped to its source page and
carries Cache-Control: private, no-cache, as every deferring prefetch does.
Such a loader is now deferrable, so await ctx.rendered() in it throws, on
every request, as in any loader that can be deferred: drop the ctx.rendered()
call or the prefetch: false flag.

Changed: a prefetch answered from storage carries Cache-Control again, so the browser can reuse it (#1356)

Before. Since 0.25.0 no prefetch response carried Cache-Control (#1170).
A prefetch of a page served whole from a cache() record, a Prerender entry
or the document cache went to the server again from every new document, and
after the in-memory entry expired, though nothing in it could fail.

Now. A prefetch with nothing live in it carries
Cache-Control: private, max-age=<prefetchCacheTTL>, as every prefetch did
before 0.25.0, and the browser HTTP cache answers a later prefetch, or the
click, of that URL with it. Nothing live means:

Prefetch answer Cache-Control
Document cache fresh HIT (its loaders' output replayed) private, max-age=<ttl>
Every segment it sends from a cache() record or a Prerender entry, no loader runs private, max-age=<ttl>
A segment rendered live beside stored ones (a layout above a cache() the page lacks) none of the router's
A loader runs (a stored route's or a Prerender route's) none of the router's
A live render, a failed one included none of the router's
Scoped to its source page, or carrying prefetch: false deferred units private, no-cache, as before
Document cache STALE hit, stale cache() record, stale on-demand Prerender entry, navigations, fills, prefetchCacheTTL: false unchanged

A stored copy is safe to keep: no server cache stores a render that reported
an error, so only a live part can fail after the 200's headers left. The
header is set after every middleware and replaces a route's own on such a
prefetch, as before 0.25.0, unless the route's says no-store, no-cache or
a zero max-age (max-age=0, with or without must-revalidate);
the document cache still stores the answer by the route's s-maxage. A
redirect a middleware returns after the render, and a response carrying a
per-client header (a state cookie rotation, x-rango-keep-cache) or location
state, get none. A server action bumps the state's write part (below), so a
prefetch after it misses the copy from before the write. Nothing to change in
your code, with one exception: the browser keeps the copy for up to
prefetchCacheTTL (default 300 s), across reloads and new tabs, and only
this client's own server actions or a state rotation make it miss. A tag
invalidation that does not pass through this client's action (another user's
action, a webhook, a cron job, or this user's fetch() to a route handler
that calls updateTag()) does not, so a reload can show the page from before
it. When this browser invalidates the tag itself (its fetch() to a route
handler), call invalidateClientCache() in that handler: it rotates only the
calling browser's state. For an invalidation from another user, a webhook or
a cron job, lower prefetchCacheTTL or send Cache-Control: no-cache from
the route.

Fixed: after a server action, a prefetch is no longer answered from the browser's HTTP cache with the value from before the write (#1360)

Before. Since the action stopped rotating the state cookie (#1329, below),
the value every prefetch and navigation sends as X-Rango-State stayed the
same across a write. Responses vary on it, so a partial response that carries
its own Cache-Control: max-age (a route's own header) was answered from the
browser's HTTP cache after the write: a new hover and the click showed the
pre-write value with no revalidation, in the tab that ran the action, after a
reload, and in a tab opened later.

Now. The state value has a third part: {build}:{invalidation}:{write}.
The response of a server action without a directive bumps the write part in
its Set-Cookie (server side, so a form posted before hydration bumps it
too), and every later request of every tab sends the new value, so the
pre-write entries miss. The client's in-memory caches compare only
{build}:{invalidation}, so the action still marks prefetches stale and
flushes nothing. invalidateClientCache() still rotates the invalidation part;
keepClientCache() bumps nothing. A two-part value, from a tab open across
the deploy, still parses.

A tab that hears no broadcast of the action (its page shares no segment with
the acting page, or the app sets crossTabSync: false) no longer serves a
prefetch it made before another tab's write as fresh: its next prefetch or
navigation reads the bumped state, marks its history and prefetches stale,
and a click on the prefetched link shows the page, then revalidates it.

/basket prefetched on hover (Cache-Control: private, max-age=60), cart:0
click "Add to cart"; hover /basket again; click it
  before: the hover is answered from the HTTP cache: /basket shows cart:0
  now:    the hover reaches the server: /basket shows cart:1

Nothing changes in app code. A test that reads the state cookie after an
action sees a third part; compare its first two parts to tell a rotation from
a write.

Fixed: a link to a page in the tab's history shows it fresh after another tab's write (#1362)

Before. A tab that hears no broadcast of another tab's server action or
invalidateClientCache() (its page shares no segment with the other tab's,
or the app sets crossTabSync: false) learns of the write when it next reads
the state cookie. A link or router.push() to a page in its history cache
looked up that page's entry before reading the cookie, so the request named
the entry's segments as held. The server kept the route and every layout
loader with no revalidate(), and the tab showed the page as it was before
the write. Back read the cookie first and was not affected.

Now. A navigation reads the state cookie before it looks up the target's
history entry, as Back does. The entry is marked stale first, the request
names none of its segments as held, and the page renders fresh.

tab B: /shop/basket (cart:0), then /account; no broadcast reaches it
tab A: "Add to cart"
tab B: link to /shop/basket
  before: cart:0, held from the history entry
  now:    cart:1

Nothing changes in app code.

Breaking: a server action marks prefetches stale instead of flushing them, and stops rotating the state cookie (#1329, #1343)

Before. Every action without keepClientCache() flushed the prefetch
cache and rotated the rango-state cookie. Every visible link prefetched
again after each action, and other tabs flushed theirs.

Now. A prefetch made before the action stays. A click on it shows the
page at once, then revalidates it in the background, as Back does on a page
an action made stale; useNavigation() stays idle and nothing remounts. The
pre-write values show for one round trip, a layout loader that re-runs on
navigation included. Visible links do not prefetch again; a new hover
replaces a stale prefetch with a fresh one. A stale prefetch answers only a
click, once: a second click on the same link fetches the page, and so do
Back/Forward onto a page the history cache no longer holds and leaving an
intercept. Other tabs that share a segment
with the page mark their histor...

Read more

@rangojs/router 0.32.0

Choose a tag to compare

@ivogt ivogt released this 30 Oct 20:12
27d591b

Fixed: a prefetch held by a revalidate() that read the source page no longer shows another page's layout (#1333)

Before. A layout whose revalidate() compares the page you leave with
the page you go to (currentParams, currentUrl, fromRouteName), such as
a template that shows a category hero on category pages, could show the
wrong copy of itself. A prefetch of /cart sent from a product page kept
the layout, which is right for the product page. The prefetch is shared by
every page, so a click on the same link from a category page took the same
answer and kept the category page's layout: the cart rendered under the
category hero, and stayed there on the next navigation. Production's
"viewport" default made this likely; development, which prefetches
nothing by default, did not show it.

Now. When a revalidate() reads the page navigated away from and keeps
the segment, the response is stored for the page that prefetched it
(x-rsc-prefetch-scope: source), as an intercept target's already is. A
click from another page prefetches its own answer, or asks the server. A
revalidate() that re-renders, or that reads only the destination
(nextParams, nextUrl, isAction(), toRouteName), keeps one shared
prefetch.

A prerendered target is covered too: its stored entry runs a held layout's
revalidate() only when the params change, so on equal params the kept
layout scopes the prefetch the same way. So does a prerendered page kept on
a search-only change (/item/1?tab=a to /item/1?tab=b): a click from
/item/2 no longer shows item 2.

No change in your code. prefetchKey=":source" stays for a response that
differs by source in ways the server cannot see; the router now scopes the
revalidate()-driven case itself.

Fixed: rango generate --runtime resolves through plugins that set up in configResolved (#1332)

Before. rango generate --runtime forwarded the app's resolver plugins
straight from the unresolved Vite config, so their config and
configResolved hooks never ran. A resolver that builds its state there,
such as vite-tsconfig-paths, matched nothing, and discovery failed with
Cannot find module '~routes/shop'. A plugin with apply: "build" was
forwarded into the serve-mode discovery too.

Now. --runtime resolves the Vite config as vite dev would, without
rango() and rsc(), and forwards the resolver plugins from the result. An
alias added by a plugin's config() hook resolves, and apply: "build"
plugins, string or function form, are left out. define and
resolve.conditions set from a plugin's config hook are still not picked
up (docs/manifests.md).

Added: location state on document responses (#1334)

Before. ctx.setLocationState() and redirect(url, { state }) reached
history.state only through client navigations and actions. A document
response dropped the state: a <form action={fn}> posted without JS (or
before hydration), a payment provider redirecting the shopper back to a
handler, a link from an email.

Now. A document response carries it:

  • a page load or no-JS form post that renders a page carries the state in
    its bootstrap payload; the client writes it into the entry when it starts,
    and useLocationState returns it once the page has hydrated (as it does
    for stored state);
  • a document request that redirects carries the state in a one-shot cookie
    (<stateCookieName>-ls, __Host- on https, HttpOnly, SameSite=Lax,
    60 seconds) that the target's document request reads and clears, so a
    refresh does not show a flash again. A prefetch or fetch() of the target
    (Sec-Fetch-Dest other than a browsing context) leaves it to the
    navigation. The cookie holds JSON: give state
    that crosses a document redirect plain JSON data. State over 4000 bytes,
    or that JSON cannot encode, is dropped with a warning;
  • such a document, and the redirect carrying the cookie, are sent
    Cache-Control: private, no-store; the document cache neither stores nor
    serves them. A ppr route keeps its shell: state set by middleware or the
    cookie rides the live payload of a HIT or MISS (no shell holds it), and a
    route whose handler sets state is not captured and renders in full.
// Works the same for a client action, a no-JS form post and a request from
// outside the app.
path("/checkout/callback", async (ctx) =>
  redirect("/orders/confirmed", { state: [Flash({ text: "Paid" })] }),
);

If a handler or middleware calls setLocationState on every request, it is
now delivered on document loads too; a clearOnReload slot it sets applies
on the load it arrived with. On a ppr route, set state from middleware: a
shell HIT runs no handler. Without JS the state reaches no reader (only the
client applies it), and a no-store page is kept out of the browser's
back/forward cache. A document redirect from one router to a path another
router on the same origin serves loses its state: each router reads only its
own cookie.

Docs: one location-state definition keeps only its last write (#1334)

Entries accumulate across definitions, but for one definition the last write
wins: two setLocationState(Toasts(...)) calls in one response, or
redirect(url, { state: [Toasts(a), Toasts(b)] }), deliver only the later
value. The setLocationState, createLocationState and redirect JSDoc and
the /hooks state.md skill now say so. Write an array once to combine
messages.

@rangojs/router 0.30.1

Choose a tag to compare

@ivogt ivogt released this 28 Oct 19:13
fa3213e

Fixed: per-app cache versions stay the same across build directories with rolldown 1.2.15 (#1320)

Before. rolldown 1.2.15, which a fresh install of vite 8.2 or later
resolves to, changed the comment it prints above each module of the server
build, and the per-app cache versions hashed the new comments. Building
unchanged code with vite build started from another directory gave every
app a new data and document version, and building it from a checkout at
another path did so for every app with a server component that imports CSS,
so the deploy threw away caches that were still valid. Adding a server action
to one app also changed the versions of the other apps.

Now. The same source builds to the same versions in any directory, on
vite 8.0.16 through 8.3.5, and a new action changes only the apps that reach
it. With rolldown 1.2.15, the first deploy with this fix gives every app a new
version once; builds on an earlier rolldown keep their versions.

No change in your code.

@rangojs/router 0.30.0

Choose a tag to compare

@ivogt ivogt released this 28 Oct 10:34
cecf76b

Fixed: the URL hooks show the page's URL when an urgent page ends a hold (#1316)

Before. While React held a transition() page, an urgent commit to the
URL it held (a transition={false} link, a prefetch: false fill whose
answer changed the tree) showed the new page for one screen with
usePathname(), useParams(), useSearchParams() and useSegments()
selectors still reading the page being left.

Now. Every reader shows the new page's URL on its first screen. Each such
reader renders once more on that page, and React bails out of it.

No change in your code.

Changed: renderRoute's navigate() shows its page while React holds one, without act() (#1313)

Before. While React held a transition() page, a router.navigate()
that committed urgently (transition: false, or with streamUntil) kept
the old page on screen, and React printed "A component suspended inside an
act scope", unless the test wrapped the call in
await act(async () => { ... }).

Now. await router.navigate(...) shows its page with or without act(),
and React prints no warning. A <Link> click over a held page still goes
inside await act(async () => { ... }).

Changed: useAction() keeps a call's result and error until the action's next call (#1314)

Before. result, error and revalidationError cleared about 100 ms
after the call settled. A form that rendered useAction(save).error.message
showed the message for a blink, and a "Saved" line rendered from result
vanished on its own, on a transition() page even before the page the
action refreshed had shown. A component that mounted after the call settled
read nothing. To keep a value, you copied it into local state from an
effect.

Now. The call's state (result, error, revalidationError, and its
payload and actionId) stays until the same action is called again: past
the settle, through navigations and Back/Forward, and in every reader, one
that mounts later included. The next call reads loading with no result,
error or revalidationError, then its own. A call that settles after a
later call of the same action started still changes nothing the hook
reports, and a latest call that was aborted still reads idle with nothing.
A string id (useAction("save")) reads the latest call of any action with
that export name, settled or not. A document load starts with nothing. The
router keeps one settled call per action.

If you copied result or error into local state to keep it, you can read
the hook directly. To clear a message before the next call, keep a
dismissed flag in your component.

@rangojs/router 0.29.0

Choose a tag to compare

@ivogt ivogt released this 27 Oct 19:28
9c16588

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)

  • duration was never set. It is now the time from the request's start to
    the error, dispatch() from @rangojs/router/testing included. A
    background error counts from the start of the request that started it.
  • env was unset on an error the segment walk catches (a route, layout or
    slot handler that throws). It is now the request's env.
  • An intercept handler's error reported the layout that declares the
    intercept (segmentType: "layout", its segmentId). It now reports the
    intercept's slot (segmentType: "parallel", segmentId ending in the
    slot name), as a streamed intercept handler already did. The error
    boundary's fallback and the handler.error telemetry event receive the
    same segmentType and segmentId.
  • A streamed (loading()) handler's rejection on a navigation reported
    isPartial unset. It now reports true, 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 it...

Read more

@rangojs/router 0.28.0

Choose a tag to compare

@ivogt ivogt released this 25 Oct 10:22
f514d4e

Fixed: a rewritten request's telemetry events carry one requestId (#1269)

Before. With createRouter({ telemetry }) and no request-id header
(x-rsc-router-request-id, x-request-id or cf-ray), a request that
middleware rewrote with throw rewrite() reported two generated ids. The
rewritten pass's events (request.start, loader.*, cache.decision,
request.end) carried one, and a stream-idle request.timeout another, so a
sink that groups events by requestId could not tie the timeout to the
request it cut off. dispatch() from @rangojs/router/testing already
reported one id for a rewritten request, so the testing primitive and
router.fetch disagreed.

Now. Every event of a rewritten request, through any number of rewrites,
carries the visitor's id, in router.fetch and dispatch() alike. A request
that sends a request-id header reports that header's value, as before. No
change in your code.

Fixed: a no-JS form post's telemetry events carry one requestId (#1274)

Before. With createRouter({ telemetry }) and no request-id header, the
re-render after a no-JS form post (progressive enhancement), rewritten or not,
reported its own generated id on request.start, loader.*,
cache.decision and request.end, while a stream-idle request.timeout
carried the visitor's id. A sink that groups events by requestId saw two
requests.

Now. The re-render's events carry the visitor's id, the one the
request.timeout carries. A request that sends a request-id header reports
that header's value, as before. No change in your code.

Fixed: a PPR shell capture sends no telemetry events (#1276)

Before. With createRouter({ telemetry }), a document request or a
navigation that missed a ppr route's shell started a background capture,
and the capture sent the sink its own loader.* events. A live loader, which
the capture holds back, reported a loader.start with no loader.end. An
ssr: false loader, which runs in the capture, reported its loader.start
and loader.end a second time, and a loader.error when it failed only
there. On a navigation these events carried the id of a request the capture
builds, so a sink that groups events by requestId saw a second request. A
handle pushed after handle collection completed also reported its
handler.error twice, once from the visitor's render and once from the
capture.

Now. The capture sends nothing to the sink, as its request.*,
cache.decision, revalidation.decision and other handler.error events
already did not. The visitor's request reports its own loader.* and
handler.error events once, under one id. onError is unchanged. No change
in your code.

Fixed: a stale cache() HIT's background refresh sets no fresh-reads cookie on the visitor's response (#1259)

Before. With CFCacheStore or VercelCacheStore, a handler under a
stale cache() entry that called revalidateTag() or updateTag() during
the background refresh could set the fresh-reads cookie
(rango-state-fresh by default) on the response of the visitor whose request
served the stale entry. It did whenever that response's cookies were merged
after the refresh reached the call, for example from a global middleware that
awaits work after next() and returns the response. The visitor mutated
nothing, yet the response carried a Set-Cookie, which the document cache
does not store, and the visitor's next requests skipped the stores' isolate
memos for a few seconds. Once the response was sent, the same call printed a
development warning blaming a streaming loader or render.

Now. The refresh's invalidation still runs, but sets no cookie and prints
no warning, as the refresh's other response writes already reach nothing. No
change in your code.

Fixed: a stale "use cache" entry's, cached loader's or document cache entry's background refresh sets no fresh-reads cookie on the visitor's response (#1264)

Before. With CFCacheStore or VercelCacheStore, a "use cache"
function, or a loader bound with its own cache(), that called
revalidateTag() or updateTag() while it refreshed a stale entry in the
background could set the fresh-reads cookie (rango-state-fresh by default)
on the response of the visitor whose request served the stale entry, as the
stale cache() refresh above could. So could a route handler, middleware or
render that called it while createDocumentCacheMiddleware() re-ran a stale
page in the background. There the re-run's own response carried the cookie
too, so the document cache did not store the refreshed page, and later
requests kept getting the stale one. Each refresh runs on that visitor's
request context. Once the response was sent, the same call printed the
development warning blaming a streaming loader or render.

Now. The refresh's invalidation still runs, but sets no cookie, prints no
warning, and no longer keeps the refreshed page out of the document cache. An
invalidation the request itself makes (in a server action, or in a route
handler, middleware or render outside a background refresh) sets the cookie
as before. No change in your code.

Fixed: a stale document cache entry's background re-run no longer writes cookies or headers on the visitor's response (#1267)

Before. When createDocumentCacheMiddleware() served a stale entry, it
re-ran the page in the background on the visitor's own request context. A
cookie (cookies().set()) that middleware, a route handler or the render
wrote in that re-run, or a header that a route handler or route middleware
wrote (ctx.headers.set(), ctx.header()) and the stored entry lacks, could
reach the visitor's STALE response, for example when a global middleware
above the cache awaited work after next() and then returned the response:
the visitor got Set-Cookie and headers from a render whose body they never
received. The other way round, a cookie that middleware set on the visitor's
response after next() could land on the re-run's response, and the document
cache, which stores no response with a Set-Cookie, then kept the refreshed
page out, so later requests kept getting the stale one.

Now. The re-run writes to a response of its own. It starts from what
middleware ahead of the cache wrote to the response before next(), as a
cache miss does, and is stored by the same rules: its headers are kept with
the entry, and a cookie it sets keeps the refreshed page out of the cache. The
visitor's STALE response carries the stored entry and the visitor's own
writes. An invalidateClientCache() in the re-run rotates no client's state
cookie, as in a stale cache() refresh. A loader that middleware ahead of
the cache started through getRequestContext().use() runs again in the
re-run, as after a rewrite: its side effects happen twice per stale hit, and
if it reads the route's params, the refreshed page holds them where a cache
miss reuses the run that started before the match. No change in your code.

Fixed: global middleware keeps the STALE response while a stale document cache entry re-runs (#1278)

Before. On a stale hit, createDocumentCacheMiddleware() returns the
stored entry and re-runs the rest of the middleware chain in the background
through its next(). That re-run shared the response the visitor's chain
held, so when a global middleware's post-next() work outlasted the re-run:

  • A global middleware ahead of the cache that returns nothing after
    await next() answered with the re-run's response, whose body the cache had
    already read into the store: the request failed with
    TypeError: Body is unusable: Body has already been read.
  • A header that middleware ahead of the cache set after next()
    (ctx.header(), ctx.headers) went to the re-run's response, not the
    visitor's.
  • A header that a global middleware after the cache set before next(), past
    an await, landed on the visitor's STALE response instead of the
    refreshed entry.
  • The re-run's handler-classify entry reached the visitor's
    Server-Timing.

Now. The re-run builds its own response. The middleware ahead of the
cache sees the STALE response after next(), writes land on it, and the
re-run's headers go to the refreshed entry. No change in your code.

Fixed: a middleware that answers while its next() still runs keeps its answer (#1278)

Before. A middleware that settled while the next() it called was still
running shared its response with the render it abandoned. It may have
returned or thrown its own Response, thrown an error, or thrown or returned
a rewrite(), which after next() is an error. Examples are a timeout
fallback (Promise.race([next(), timeout])), a fire-and-forget next(), and
a throw that a middleware ahead catches. When a middleware ahead was still
working after next() once that render finished, the outcome depended on
timing:

  • A middleware ahead that returns nothing after await next() could answer
    with the abandoned render's page instead of the fallback, or instead of the
    fallback it answered a caught error with.
  • A header that a middleware ahead set after next() could land on the
    abandoned render's response and be lost.
const timeoutFallback: Middleware = (ctx, next) =>
  Promise.race([
    next(),
    new Promise<Response>((resolve) =>
      setTimeout(
        () => resolve(new Response("Try again", { status: 503 })),
        2000,
      ),
    ),
  ]);

Now. The middleware ahead get what the middleware settled with: its
Response, which they see after `next()...

Read more

v0.27.0 — Back after an action shows the write; intercept and action fixes

Choose a tag to compare

@ivogt ivogt released this 23 Oct 08:58
1d052ad

Breaking: Back or Forward onto a page a server action made stale re-runs its loaders and route, as the action's own revalidation does, and every stale request renders live (#1231)

Before. After a server action, the browser marks every page in its history
stale: Back renders the cached page at once, then revalidates it in the
background. That revalidation took a navigation's defaults: it kept a layout's
loader, and a route whose params did not change. So after Back, a cart count a
layout's loader reads still showed its count from before the action, and
leaving an intercept with Back after an action in it put the count back to its
value from before the action. Only a loader that declared
revalidate(({ stale }) => stale) re-ran.

After. That revalidation takes the defaults of an action's own
revalidation: the route, every loader (a layout's included) and the route's own
children re-render; the layouts above the route keep their content. Back shows
the write with no opt-in. It renders live, as the action does: no cache()
record or PPR navigation replay answers it, and it stores nothing (an action's
render is never stored, so a record can predate the write). revalidate()
still decides: revalidate(false), or a predicate that returns false, keeps
a loader as it was. On that request stale is true, isAction() is false
and actionId is undefined.

Every stale request renders live. The browser marks a partial request
stale (_rsc_stale) in more places than Back after an action, and each one now
skips the route's cache() records (no read, no store) and the PPR navigation
replay:

  • router.refresh(), and the cross-tab refresh: after an action, every open
    tab showing a page that shares a segment with it refreshes, and each of those
    refreshes renders at the origin;
  • Back or Forward after invalidateClientCache(), or after the router's state
    cookie rotated (a sibling tab, a server Set-Cookie);
  • the follow-up revalidation after an action the user navigated away from;
  • the request that holds no segments after a prefetch and the page's own
    answer disagree, an action's redirect navigation, and the HMR repair
    refetch.

A ppr route answers such a request with x-rango-ppr-replay: BYPASS; reason=stale, and PprReplayBypassReason (from @rangojs/router/testing)
gains "stale". A "use cache" function is served by its own profile on these
requests, as on an action within its TTL: tag it and call updateTag() to
refresh it after a write.

// Before: the layout's count showed the write after Back only with an opt-in.
layout(ShopLayout, () => [
  loader(CartCountLoader, () => [revalidate(({ stale }) => stale)]),
  path("/products/:id", ProductPage, { name: "product" }),
  path("/cart", CartPage, { name: "cart" }),
]);

// After: the default re-runs it.
layout(ShopLayout, () => [
  loader(CartCountLoader),
  path("/products/:id", ProductPage, { name: "product" }),
  path("/cart", CartPage, { name: "cart" }),
]);

// To keep an expensive loader as it was on Back after an action:
loader(RecommendationsLoader, () => [
  revalidate(({ stale }) => (stale ? false : undefined)),
]);

Migration. A page handler or loader that reads a value its parent layout's
handler set with ctx.set() now re-runs on that Back without the layout, as it
already does after an action on its own page, and reads undefined. Get the
value from middleware, or make the layout's revalidate() contract return
true on stale as well as on its actions:

// Before
export const revalidateCartData: Revalidate = (ctx) =>
  ctx.isAction(addToCart) || undefined;

// After
export const revalidateCartData: Revalidate = (ctx) =>
  ctx.stale || ctx.isAction(addToCart) || undefined;

It costs one live render of the page you go back to, the work the action's
response does for its own page, once per page after each action. An action
that calls keepClientCache() marks nothing stale. A loader that fails on that
background revalidation leaves the page as it was and logs
[Browser] Background revalidation failed.

Fixed: the page under an intercept stays when a redirect opens the intercept, or on Back/Forward over an intercept left early (#1227)

Before. Under an intercept the server never renders the page under it:
that page comes from the segments the browser says it holds. A few requests
hold none, and their answer listed only the layouts and the intercept's slot,
so the page under the intercept disappeared, leaving the layout with an empty
outlet. A consumer hit this in two ways:

  • a server action on a page that calls redirect() to a route intercepted
    from that page, or a link to a route that redirects to one;
  • Back, then Forward, over an intercept whose handler pushed a handle value
    that had not resolved when the user left it.

In dev, every server edit with an intercept open did the same, and the page's
handle pushes (breadcrumbs, Meta) went with it.

After. The browser keeps the page the answer lands over, as the server
lists it for a request that holds it. Its handle pushes stay. It keeps its
last render: after a server action redirects into an intercept, the page under
it shows its pre-action data until the intercept closes, as it does after an
action submitted from inside an open intercept. The layouts and the
intercept are rendered fresh. No change in your code.

Fixed: an action's write shows on the page the user navigated to while it ran (#1228)

Before. A user clicks "Add to cart" and then a link, or goes Back, before
the action lands. The action's revalidation was dropped, so the new page kept
its pre-write data in every loader the navigation held, such as a layout's
cart count, until something else re-ran it. An action that failed after the
user left got nothing at all. And the refetch an action started when it landed
superseded a click still in flight, so that click's page never committed.

Now. Once the last action in flight settles, the router revalidates the
page on screen once, in the background, whenever an action's own response
could not reach it:

  • Every loader on that page re-runs: the request holds no segments, so no
    revalidate() predicate is consulted.
  • The result commits with no loading() fallback and no remount.
    useNavigation().state stays "idle".
  • It waits for the user's navigation and never cancels it. A click in flight
    when the action lands reads "loading" until its page commits.
  • Several actions share one revalidation, and a redirect() replaces it.
  • Nothing runs while an intercept is on screen. Leaving it with Back sends a
    stale revalidation, which re-runs the page underneath as the Breaking entry
    above describes.
  • A user who stayed on the page gets no extra request.

No change in your code. keepClientCache() turns this off for that action.

Fixed: the document cache never answers a stale request (#1228)

Before. On a route with s-maxage, createDocumentCacheMiddleware() could
answer Back's revalidation after an action from a body cached before the write.
The page then kept showing pre-write data, even though the router had marked
it stale.

Now. A partial request carrying _rsc_stale is neither served from nor
stored in the document cache, and the server answers it with
private, no-cache. Stale requests are Back/Forward onto a page an action made
stale, the background revalidation after an action, and refresh(). For a
shared entry refreshed after a write, tag it and call updateTag().

Fixed: Back/Forward onto an intercept entry the client no longer holds no longer opens the intercept over another page (#1229)

Before. After a reload, or once the history cache (initBrowserApp's
storeOptions.cacheSize, 20 by default) evicted it, Back/Forward onto an
entry that showed an intercept opened the intercept over whatever page was on
screen.

Now. Only a client navigation from the source page shows an intercept.
When such an entry must be fetched and its source page is on screen, the
intercept opens over it as before; from any other page the entry loads as a
document (a full page load, from the top) and renders its URL's full page. A
document load never leaves an entry marked as an intercept, a reload on an
open intercept included. No change in your code.

Changed: going Back or Forward out of an intercept no longer aborts its actions (#1228)

Before. Back or Forward into or out of an intercept aborted every
action in flight, although the server had already received it and ran it.
useAction() went back to idle with no result, and the caller's await
resolved with undefined.

Now. The action keeps running. useAction() and the caller's await get
its real result, and the page underneath gets the background revalidation
above. This matches what already happened when a link left the intercept.

v0.26.6

Choose a tag to compare

@ivogt ivogt released this 21 Oct 17:30
d824ba6

Fixed: an inline "use server" action rendered by Static() or Prerender() runs on plugin-rsc 0.5.37+ (#1223)

Before. A Static() or Prerender() handler that renders an inline
"use server" function capturing a value (an article id, a build token) built
fine, but in production every call to that action failed on the server with
DOMException [OperationError], and the client never got a result. A fresh
install hit this: the router's ^0.5.36 range already resolves plugin-rsc
0.5.38. plugin-rsc 0.5.37 binds the encrypted captures to the action id, and
the build-time render encrypted them under the development id
(/src/urls/prerender.tsx#...) while the production bundle decrypts under the
hashed id. Dev, runtime cache()/"use cache" hits and runtime PPR shells
were not affected.

After. The build rewrites the action id to the production hash before it
encrypts, so the action runs. No change in your code; rebuild.

Changed: the router requires @vitejs/plugin-rsc ^0.5.38 (#1223)

The router's range was ^0.5.36, so a fresh install already resolved 0.5.38,
but the router's own tests ran on 0.5.36. They now run on the version a fresh
install gets.

If you set your own persistent key with defineEncryptionKey, one thing
changes when you move from 0.5.36: plugin-rsc 0.5.37 binds the encrypted values
an inline "use server" function captures to that function's action id. During
a rolling deploy from 0.5.36, a form or action whose encrypted captures were
issued by the old build fails to decrypt on the new one. Without a persistent
key, captures already never decrypt across builds, so nothing changes.