Skip to content

[2.0 rc.20] useIsRouting() stays false through a held navigation when an effect tracks it and a <Show when={latest(x)}> exists (regression from #4356) #4439

Closed

Description

@ethan-huo

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 createEffect that tracks the useIsRouting() accessor (an empty effect callback is enough), and
  • any <Show when={latest(x)}> in the tree, even if x has 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, development condition)
  • 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

  1. added a commit that references this issue on Oct 30, 2026
  2. ryansolid commented on Oct 30, 2026

    @ryansolid
    Member

    Fixed on next by #4449 (7135a77); it ships in the next RC.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions