Repository navigation
Description
Describe the bug
With solid-js / @solidjs/signals / @solidjs/web 2.0.0-rc.20 and @solidjs/router 2.0.0-next.43, useIsRouting() renders false for the whole of a held navigation (a route whose lazy children have not resolved yet). This happens only when both of these exist:
- a
createEffectthat tracks theuseIsRouting()accessor (an empty effect callback is enough), and - any
<Show when={latest(x)}>in the tree, even ifxhas nothing to do with routing.
Remove the effect, or use <Show when={x()}>, and the rendered value is true again. On rc.19 all three variants render true. Reading the accessor untracked from outside the tree returns true in every case, so only the tracked readers are stuck.
It looks like the same symptom as #4050 (fixed earlier) coming back. Navigation feedback built on useIsRouting() + latest() (a delayed spinner, for example) never shows on rc.20.
Your Example Website or App
Standalone vitest repro (below), with no app code.
Steps to Reproduce the Bug or Issue
package.json
{
"type": "module",
"scripts": { "test": "vitest run" },
"dependencies": {
"@solidjs/router": "2.0.0-next.43",
"@solidjs/web": "2.0.0-rc.20",
"solid-js": "2.0.0-rc.20"
},
"devDependencies": {
"@solidjs/vite-plugin": "3.0.0-next.53",
"jsdom": "^30.1.3",
"vitest": "^5.0.5"
},
"overrides": { "@solidjs/signals": "2.0.0-rc.20" }
}vite.config.ts
import solid from "@solidjs/vite-plugin";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [solid()],
resolve: { conditions: ["browser", "development"] },
test: { environment: "jsdom" },
});isrouting.test.tsx
import { createRouter, memoryHistory, useIsRouting, useNavigate, type Navigator } from "@solidjs/router";
import { render } from "@solidjs/web";
import { createEffect, createSignal, flush, latest, Show } from "solid-js";
import { expect, it } from "vitest";
async function run(opts: { effect: boolean; useLatest: boolean }) {
const Router = createRouter({
history: memoryHistory(),
scrollRestoration: false,
routes: [
{ path: "/", component: () => <p>home</p> },
// children never resolve: the navigation stays pending
{ path: "/slow", component: (p: any) => <>{p.children}</>, children: () => new Promise<never>(() => {}) },
],
});
let navigate!: Navigator;
let pending!: () => boolean;
function Probe() {
navigate = useNavigate();
pending = useIsRouting();
const [other] = createSignal(false);
if (opts.effect) createEffect(() => pending(), () => {});
return (
<>
<i>{String(pending())}</i>
<Show when={opts.useLatest ? latest(other) : other()}><b /></Show>
</>
);
}
const root = document.createElement("div");
const dispose = render(() => <Router>{(p) => <><Probe />{p.children}</>}</Router>, root);
await new Promise(r => setTimeout(r, 0));
flush();
navigate("/slow");
await new Promise(r => setTimeout(r, 50));
flush();
const result = { page: root.querySelector("p")?.textContent, rendered: root.querySelector("i")?.textContent, untracked: pending() };
dispose();
return result;
}
it("effect + <Show when={latest(x)}>", async () => {
expect(await run({ effect: true, useLatest: true })).toEqual({ page: "home", rendered: "true", untracked: true });
});
it("no effect + <Show when={latest(x)}>", async () => {
expect(await run({ effect: false, useLatest: true })).toEqual({ page: "home", rendered: "true", untracked: true });
});
it("effect + <Show when={x()}>", async () => {
expect(await run({ effect: true, useLatest: false })).toEqual({ page: "home", rendered: "true", untracked: true });
});Run bun install && bunx vitest run (or the npm/pnpm equivalent).
effect + latest(x) |
no effect + latest(x) |
effect + x() |
|
|---|---|---|---|
| rc.19 (solid-js, web and signals all rc.19) | true ✅ |
true ✅ |
true ✅ |
| rc.20 | false ❌ |
true ✅ |
true ✅ |
rc.20 failure:
× effect + <Show when={latest(x)}>
- "rendered": "true",
+ "rendered": "false",
Expected behavior
While the navigation to /slow is held, every tracked reader of useIsRouting() sees true (the old page stays on screen), as on rc.19. Adding an effect that tracks the accessor, or a latest() read elsewhere in the tree, should not change that.
Platform
- solid-js / @solidjs/web / @solidjs/signals: 2.0.0-rc.20 (rc.19 passes)
- @solidjs/router: 2.0.0-next.43
- @solidjs/vite-plugin 3.0.0-next.53, vitest 5 + jsdom (dev build,
developmentcondition) - macOS, Node 26, Bun 1.4
Additional context
Bisect. I built packages/signals, packages/solid and packages/web from source at each commit in [email protected]@2.0.0-rc.20 and ran the first test case. The first bad commit is c9d1eeb fix(signals): refresh(memo) inside an action with an isPending reader settles (#4349) (#4356). Its parent 425a713 passes. next (still at the rc.20 release commit) fails.
Suspected code. The change is in laneSeams (packages/signals/src/core/lanes.ts). It added a non-lane arm to the skip condition: a stale reader that a non-lane transaction holds is skipped at the seam unless it read a staging ("it runs at the landing"). In rc.20 that arm reads:
: l &&
!(
r._flags & REACTIVE_PROBE_UNANSWERED ||
(r._flags & REACTIVE_STAGED_READ && blocked(l) && !bornInto(r, l))
))I logged the readers this condition skips in the repro's dev build. The only one is the router's routingPending memo: a transparent verdict memo, createMemo(() => isPending(() => { matches(); location.search; location.hash })) in src/pending.ts, created on first useIsRouting() call. r._x._transaction is set to the navigation's transaction, REACTIVE_STAGED_READ is not set, and lanes.length === 1, presumably the lane that latest(other) opens. The effect and the <i> text re-run, but they read the un-re-derived routingPending, which still holds false. The landing it is deferred to only comes when the slow route resolves, so the UI shows false for the whole navigation.
If I replace that arm with : false (the pre-#4356 behavior for non-lane l) in rc.20, all three cases pass again. I have not checked what that does to the #4349 loop this arm was added to stop. It only confirms that the arm causes this.
I could not reproduce this without the router. A hand-built model with a source signal, a URL/pathname memo chain, a lazily created never-resolving transparent memo read inside matches, a lazily created transparent isPending memo under the root owner, ownedWrite, an onSettled registered at write time, and a keyed <Show> outlet never put a reader through laneSeams, so some part of the router's held navigation is part of the trigger. Since the bisect points at a signals change, I am filing here rather than in solid-router.
Activity
chore(size): raise caps for the #4439 seam re-derive
ryansolid commentedon Oct 30, 2026
Fixed on
nextby #4449 (7135a77); it ships in the next RC.Generated by Claude Code
fix(signals): a parked frame re-derives an isPending memo that read a…