Skip to content

@rangojs/router 0.33.0

Choose a tag to compare

@ivogt ivogt released this 01 Nov 19:35
· 38 commits to main since this release
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 history and prefetches stale and refresh. The
cookie's invalidation part is not rotated; only its write part is bumped
(above), so a partial response a route caches with its own Cache-Control
misses in the browser's HTTP cache after the write. Tabs
that share no segment with the page, and apps with crossTabSync: false,
get no message and do not refresh; they mark their caches stale when they
next read the bumped state (above). For the old full flush, call invalidateClientCache() in
the action: other tabs then adopt the rotated state, flush and refresh.
keepClientCache() is unchanged.

/product/1: /cart prefetched (cart:2), click "Add to cart"
  before: the prefetch is dropped; the click on /cart requests it (cart:3)
  now:    the click on /cart shows the prefetch at once (cart:2),
          then one background revalidation (cart:3)
// The old behavior, for an action whose write makes every prefetch wrong.
"use server";
import { invalidateClientCache } from "@rangojs/router";

export async function switchCurrency(currency: string) {
  await prefs.set({ currency });
  invalidateClientCache();
}

@rangojs/router/testing: renderRoute's router handle gains prefetch(url)
and action({ loaders, keepClientCache }), so a test can run an action and
then a click on a stale prefetch, and revalidations() to read the
revalidation requests it made.

Fixed: revalidate() sees currentUrl without the page's #fragment (#1359)

Before. On a partial request, currentUrl kept the #fragment of the page
on screen, while nextUrl never has one. A rule comparing the two addresses
(currentUrl.href === nextUrl.href) saw /t/42#questions and /t/42 as two
addresses, so on a page with an anchor in its address, a rule of the form
"same address: decide as usual, otherwise hold" held its segment, even after
an action had made it stale. The page showed what it held before the write.

Now. The server drops the fragment from the client path, the rewrite
target and the intercept source when it builds the navigation snapshot
(router/navigation-snapshot.ts), for every request lane (navigation,
prefetch, action, fill). currentUrl and nextUrl of one address are equal;
route matching, params, query and fromRouteName are unchanged, and no
client code changes.

Fixed: rango generate reads the exported createRouter chain, not a helper router earlier in the file (#1354)

Before. A router file with several createRouter chains took routes and
basename from the first chain with a readable mount, exported or not. With
const helper = createRouter({ basename: "/x" }).routes(urls(...)) above
export const router = createRouter({}).routes(buildUrls()), the command
exited 0 and wrote the helper's routes under /x, so the app got the wrong
routes and basename without a message.

Now. The exported chain (export const, export default, or a name in
export { ... }) always wins over one that is not exported, in routes and
basename alike. A chain inside an exported function or class (a test
factory) is not the exported router and does not count. When its mount cannot be read, rango generate reports it as
unresolvable, as it does for a single-chain file, instead of falling back to
the helper. Among several exported chains the first one with a readable mount
still wins, and a file whose chains are all non-exported is read as before.

Changed: a hover prefetch runs prefetch: false work, so the click sends no follow-up request (#1353)

Before. Every prefetch deferred loader(Def, { prefetch: false }) and the
work behind loading(fallback, { prefetch: false }), the hover's included. A
visitor who hovered a link and clicked it still waited for a second request
after the click: the page committed with its fallback and the fill brought
the deferred part.

Now. A hover is intent to click, and so is useRouter().prefetch().
Their prefetch sends X-Rango-Prefetch: intent and asks for exactly what the
navigation would, only earlier: the flagged loaders and loading() work run
in it, and the click that adopts it commits the whole page with no fill. This
holds for a hover on a "hover", "viewport" or "adaptive" link and on a
delegated plain anchor.

Prefetch Header prefetch: false work
Hover, useRouter().prefetch() X-Rango-Prefetch: intent runs in the prefetch
Viewport, render X-Rango-Prefetch: 1 deferred, as before
Hover over a link already cached or in flight none sent as the cached entry
  • A hover sends its prefetch only when the link has none cached or in flight.
    A link a viewport prefetch already answered is used as it is, with what it
    deferred, and its click sends the fill as before. There is no upgrade and
    no second render.
  • There is no hover delay: every cursor pass over a "hover" link with
    nothing cached runs the flagged work. Touch devices have no hover.
  • A hover prefetch that defers nothing is answered like a navigation: no
    private, no-cache, no Vary: X-Rango-Prefetch, no source scope (an
    intercept target stays source-scoped).

No API change. A test that serves a prefetch with serveShellRequest(router, url, { partial: { prefetch: true } }) still gets a speculative one; add
headers: { "X-Rango-Prefetch": "intent" } for a hover's. Code that checks
X-Rango-Prefetch === "1" to recognise a prefetch should check that the
header is present.

Fixed: the document cache no longer serves one page's navigation to another page with the same layouts (#1355)

Before. createDocumentCacheMiddleware() keyed a client navigation by its
URL and the segments the client held, not by the page it came from. Under a
layout whose revalidate() re-renders a category hero only when the category
changes, /c/audio -> /c/audio?sort=x kept the hero and was stored. /c/tv
holds the same segments, so its click to /c/audio?sort=x got that answer and
kept the tv hero above the audio list, for every visitor until the entry
expired. An intercept whose when reads the page being left had the same gap.

After. A navigation whose answer depends on the page it came from (a
revalidate() that read currentParams, currentUrl or fromRouteName kept
a segment, or an intercept targets the route) is sent cache-control: private, no-cache over the route's own header and is not stored, even when a
middleware sets Cache-Control after next(). These are the answers the
browser's prefetch cache already scopes with x-rsc-prefetch-scope. A
navigation whose rule re-rendered the segment is right from every page and is
stored and served as before, and documents are unchanged. Nothing changes in
your code.

What it costs. A client navigation to a route an intercept targets, from
any page, and any navigation that kept a segment from its source, is no longer
stored by documentCache or by a CDN, and the browser no longer reuses it
under the route's max-age: each one is rendered again. Only these
navigations lose the route's Cache-Control; every other partial and every
document keeps its own.

Fixed: @rangojs/router/testing/e2e stops the whole dev or preview server, not only pnpm (#1352)

Before. useFixture started pnpm dev or pnpm preview without making it a
process-group leader, so its teardown's group kill threw ESRCH and fell back
to stopping pnpm alone. The vite process pnpm spawned kept running after the
suite, holding its port and memory, and a teardown could time out waiting on it.

After. The server is spawned detached and the teardown signals its whole
process group, waits until no process of the group is left, and sends SIGKILL
(logging the pid) to a group that still has one after the wait. Windows is
unchanged: the server is not detached there and taskkill /t ends the tree.
Nothing changes in how you call useFixture.

Changed: rango generate reads an inline createRouter().routes(urls(...)) and stops on a root tree it cannot read (#1349)

Before. rango generate read a router's tree only from a variable or a
builder function. An inline call, createRouter({}).routes(urls(...)) or
createRouter({ urls: urls(...) }), was skipped: the command exited 0,
printed nothing and wrote no router.named-routes.gen.ts, so href() and
reverse() had no route names to check. A mount argument of any other shape,
such as .routes(buildUrls()), was skipped the same way.

Now. An inline urls(...) or clientUrls(...) call is read like a
builder function, includes and all, under its own name or a renamed import
(import { urls as u } from "@rangojs/router"), and as, satisfies and
parentheses around a mount are unwrapped. vite dev's static generation uses
the same reader, so it now also writes router.named-routes.gen.ts for an
inline urls() router. A mount argument the static parser cannot read
is reported as unresolvable-routes, naming the mount: plain
rango generate exits 1 and writes nothing, --static warns, and
--runtime reads the real tree. A variable whose value is not a urls()
call (const tree = buildUrls()) is still not reported. Test files, Flight
tests' *.rsc-test.* included, are no longer read as router roots by
rango generate or vite dev, so their inline routers get no
named-routes.gen.ts beside them; the default discovery.exclude lists
*.rsc-test.* too.

Changed: a prefetch a revalidate() kept from the page being left is shared by the pages with equal values of what it read (#1344)

Before. Since 0.32.0, a revalidate() that read the page being left
(currentParams, currentUrl, fromRouteName) and kept its segment made
the prefetch page-only, keyed by the page's full URL. The documented locale
rule (currentParams.locale !== nextParams.locale || undefined) keeps the
layout on every same-locale navigation, so in an i18n app nearly every page
prefetched its links again, page 2 of a list included.

Now. The server records what the rules that kept a segment read and
answers x-rsc-prefetch-scope: params; <label> with those values; the
browser stores the answer under them and serves it to every page whose
committed params give the same values. Under the locale rule, one prefetch
of a link serves every page of that locale, and a page of another locale
asks for its own. A category rule groups by category, so the #1331 hero
stays off /cart.

  • A param read by name groups by that param. Spreading currentParams, or
    passing it to JSON.stringify or structuredClone, reads every param.
    The page's set of params is always part of the group.
  • fromRouteName groups by the source's route name.
  • currentUrl, an intercept, a prefetch: false deferral, and a kept answer
    that sends no segment stay page-only (x-rsc-prefetch-scope: source).
  • A rule on a route, or on a segment inside its path(), that read the page
    being left and kept the segment through the route's default (returning
    undefined, or the defaultShouldRevalidate it was given) stays
    page-only: that default compares the target with the page being left.
  • A prerendered target kept on equal params is shared by the pages with the
    same params.

No change in your code. prefetchKey stays ":source" only. To share more,
read the params a rule needs by name rather than spreading currentParams.

Fixed: a handle a loading() route or slot pushes after an await shows in its own place in useHandle (#1262)

Before. The router does not wait for a route or parallel slot handler
under loading(): it goes on to the route's slots, the next slot or the
route while that handler runs. A ctx.use(Handle) push the handler made
after its first await (a fetch, a timer, any promise) was filed under the
segment the router had reached by then, so useHandle showed it out of
order. The same went for a loader the handler started after the await, for
the pushes of a "use cache" function given the ctx, on a miss and on the
replay of a hit, and for a loader that another loader's body started after
an await, in any route.

// Under a layout that pushes { label: "Shop" }.
path(
  "/product/:id",
  async (ctx) => {
    const product = await getProduct(ctx.params.id);
    ctx.use(Breadcrumbs)({ label: product.name });
    return <Product product={product} />;
  },
  { name: "product" },
  () => [
    loading(<ProductSkeleton />),
    parallel({
      "@reviews": (ctx) => {
        ctx.use(Breadcrumbs)({ label: "Reviews" });
        return <Reviews />;
      },
    }),
  ],
);
// Before: useHandle(Breadcrumbs) gave Shop, Reviews, Product.
// After:  Shop, Product, Reviews.

After. Each push lands under the segment whose handler or loader made
it, wherever the await sits, on the document and on a navigation. No
change in your code. A handler without loading() and a push function bound
before the await (const push = ctx.use(Handle)) were filed right before,
and are unchanged.

Fixed: under a store keyGenerator that ignores defaultKey, a document request and a plain navigation never render an intercept's record (#1298)

Before. A store keyGenerator's result was the record key as returned.
The default key it receives starts with the request kind (doc:,
partial:, intercept:<intercept>:), and a result that did not keep it
gave a document request, a client navigation and every intercept navigation
to the URL one record. So the defects #1293 fixed under cache({ key })
happened without any key(): after an intercept navigation stored its
record, a reload of the item URL rendered the intercept and no page, and a
layout's loading(..., { ssr: false }) fallback showed on a navigation after
a document load as it never does live, or streamed on a document load after
a navigation.

const store = new MemorySegmentCacheStore({
  // A tenant per host, ignoring defaultKey.
  keyGenerator: (ctx) => `tenant:${ctx.url.host}${ctx.url.pathname}`,
});
// /list -> /item/1 stored the intercept's record under that key; a reload
// of /item/1 then rendered the layout with ItemModal and no ItemPage.

Now. A route record keeps its request kind whatever the keyGenerator
returns. A result that starts with its kind, which every result built on
defaultKey (`${defaultKey}|${tenant}`) and the default key itself
do, is the key as before, byte for byte. A result that does not gets the kind
as a last part: items is stored as items|doc, items|partial or
items|intercept%3A…. An empty result, which was the key "" for every
kind, is stored as |doc, |partial or |intercept%3A…; where a result
partitions a nested cache() or a ppr shell, "" still fails key
resolution, as before.

The documented ${x}:${defaultKey} examples change keys. The store
keyGenerator JSDoc and the caching skill showed the prepend form
(`${locale}:${defaultKey}`). It kept the request kind, but its result
does not start with it, so its records get new keys: en:doc:… is now
stored as en%3Adoc%3A…|doc. On MemorySegmentCacheStore and a custom
store each such record misses once and is stored again, and the old one
expires with its TTL; CFCacheStore and VercelCacheStore already miss
every record once on a rango upgrade, so they lose nothing more. Build on
defaultKey instead, `${defaultKey}|${locale}`, as the examples now
do: a result that starts with defaultKey is the record key as you return
it.

On a ppr route under a cache() whose store has a keyGenerator that
ignores defaultKey, the first navigation after a document request is now
answered from the PPR shell (x-rango-ppr-replay: HIT) instead of the
document's record (BYPASS), as under a keyGenerator built on
defaultKey. The PPR shell partition (the raw result), a loader's entry and
a response route's entry are unchanged. Your keyGenerator keeps working
unchanged.

Fixed: a server action from an open intercept keeps that intercept when several target the route (#1294)

Before. When several intercepts target one route, each with its own
when(), a server action submitted while one of them was open re-rendered
the first intercept declared for the route, whichever was open: an action
skipped when(). With @modal (from /list) declared before @drawer (from
/other), an action from the drawer opened over /other opened the modal next
to it, and the drawer's loaders did not re-run, so it kept the data it had
before the action. Two intercepts on one layout that fill the same slot showed
the other intercept's content and loader data in that slot. The same happened
under cache(), over a Prerender() route, and inside an include() whose
intercepts target the route by its own name and by its local name.

Now. An action re-renders the intercept on screen, the one whose slot the
page holds: its loaders re-run as after any action, and no other intercept
opens. An open intercept still stays open after an action when its when()
would now reject, also beside a parallel() default of another intercept's
slot name. When the page holds the slots of more than one of the route's
intercepts (two that fill one slot share it, and a parallel() of an
intercept's slot name holds that slot whether or not an intercept is open
there), whether they are declared for the route's name or its local name
inside include(), the action sets aside the ones whose slot a parallel()
fills and renders the one left, its when() not evaluated. Among several
left, it evaluates their when() again, with from the intercept's source
page, and the first that passes renders; when none passes, the first of them
renders. When a parallel() fills every held slot, nothing but when() tells
them apart, and that rule applies to all of them. when() selects an
intercept; it does not authorize one: guard data in the target route's
middleware or the intercept's loaders. No change in your code.

Fixed: a layout with parallel() slots under a route's loader no longer remounts after an action or a same-route navigation (#1350)

Before. A child layout with parallel() slots gets a copy of its route's
loaders, so a slot's useLoader() can read them through the route's
loading() fallback. Two shapes sent that copy on one request and not on the
next, so the layout gained or lost a loader entry, and React remounted it and
its <Outlet />, resetting its local state:

  • a route loader held with revalidate(() => false) and no loading(): the
    document sent no copy, but an action or a same-route navigation (and Back
    after it) sent the copy alone;
  • loading(fallback) with the route's handler held (revalidate(() => false)
    on the route): the document sent the copy, but an action that re-ran the
    loader dropped it.

loading(false) also sent the loader's data twice on every document.

path("/p", Page, { name: "p" }, () => [
  loader(Data, () => [revalidate(() => false)]),
  layout(Inner, () => [parallel({ "@side": SideReadingUseLoaderData })]),
]);
// Before: an action on /p, or a link to /p?x=2, remounted Inner.
// After:  Inner keeps its state.

Now. The copy is sent only when the route renders a loading() fallback
(including loading(fallback, { ssr: false })), and then on every request;
with no loading() or loading(false) it is never sent. Slots read the same
data. No change in your code.

Fixed: a link clicked during a slow Back no longer keeps the previous page's layout (#1357)

Before. A link clicked or hovered while a Back/Forward that missed the
history cache was still fetching sent the popped URL as the source page,
together with the segment ids of the page still on screen. The server judged
a held layout's revalidate() against the popped page and kept it, so a
layout rendered for another page stayed: the category hero showed above a
product page.

Now. Every request that sends held segment ids names the page on screen
(the committed URL, or an intercept's source) as the source, so the layout is
judged against the page it was rendered for. A queued prefetch whose page
moved before it ran is dropped. No change in your code.

Fixed: rango --help and static rango generate run without vite installed (#1348)

Before. With the optional vite peer not installed, every rango
command, --help included, failed with ERR_MODULE_NOT_FOUND for vite,
because the bundled CLI loaded the runtime-discovery module's vite imports
up front. Static runs also paid vite's load time.

Now. The CLI is split into chunks: the entry holds only the static path
(typescript, picomatch), and rango generate --runtime loads vite on
demand and names what to install when it is missing. No change in your code.

Fixed: location state on a ppr shell HIT (#1330, #1339)

Two edges of the new document location state on a ppr route:

  • A degraded HIT kept the redirect's state. A shell HIT that cannot
    finish its tail reloads the page into a full render. When that HIT had
    read the one-shot cookie a document redirect left, its headers had already
    cleared it, so the reload showed no flash. The reload script now sets the
    cookie again first (from script, so without HttpOnly, for the moment the
    reload takes), and the reload shows the state.
  • A loader's write never rides a HIT. A HIT's headers leave before its
    tail runs, and they are private, no-store only when state was set before
    the render. A loader that called getRequestContext().setLocationState()
    during the tail could put state on a HIT whose headers allowed shared
    caching. The tail now sends exactly the state set before the render.

Setting location state from a loader is not supported: a loader has no
setLocationState, and a call through getRequestContext() may be dropped
(the payload takes the state before loaders settle). Set it from middleware,
the handler or an action. The /loader, /ppr and /hooks state.md
skills and the setLocationState JSDoc now say so.