From c3cfc0a87624cfa80f8484c9cb595a5e7b9b8e3d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 08:36:21 +0000 Subject: [PATCH 1/2] feat: name data-layer and location primitives in dev (#691) query version signals, liveQuery status signals, and the router's location/url/submissions nodes carry names behind DEV, so attribution diagnostics identify them. Zero production bytes (the previously unguarded "location" name now folds out too). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fmb4aQtDJWoA8CUktR9QoZ --- .changeset/name-router-primitives.md | 5 +++++ src/data/action.ts | 5 ++++- src/data/liveQuery.ts | 10 +++++++++- src/data/query.ts | 11 +++++++++-- src/routers/factory.tsx | 2 +- src/routing.ts | 3 ++- test/data/live-query.spec.ts | 28 +++++++++++++++++++++++++++- test/data/query.spec.ts | 26 +++++++++++++++++++++++++- 8 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 .changeset/name-router-primitives.md diff --git a/.changeset/name-router-primitives.md b/.changeset/name-router-primitives.md new file mode 100644 index 00000000..232c2974 --- /dev/null +++ b/.changeset/name-router-primitives.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +Dev builds name the data layer's primitives after their keys (`query:`, `liveQuery.status:`) and the router's location, url and submissions nodes (`router.*`), so attribution diagnostics say which query is waiting instead of `"signal"`. The names sit behind `DEV &&` and fold out of production bundles. diff --git a/src/data/action.ts b/src/data/action.ts index 5a8ad4d1..f3043f07 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -315,7 +315,10 @@ export function useSubmissions, U, V>( router.submissions[0]().filter( s => s.url === (fn as any).base && (!filter || filter(s.input)) ), - { equals: (a, b) => a.length === b.length && a.every((s, i) => s === b[i]) } + { + equals: (a, b) => a.length === b.length && a.every((s, i) => s === b[i]), + ...(DEV && { name: "router.submissions" }) + } ); return new Proxy[]>([] as any, { get(_, property) { diff --git a/src/data/liveQuery.ts b/src/data/liveQuery.ts index 40c5544e..14b2573c 100644 --- a/src/data/liveQuery.ts +++ b/src/data/liveQuery.ts @@ -1,4 +1,5 @@ import { createSignal, type Signal } from "solid-js"; +import { DEV } from "solid-js"; import { getRequestEvent, getServerFunctionMetadata, @@ -68,7 +69,14 @@ function statusSignal(key: string) { // ownedWrite: the first write happens synchronously inside whatever // computation pulled the channel open — this is channel-owned state, not // the computation's own. - if (!s) statusMap.set(key, (s = createSignal("idle", { ownedWrite: true }))); + if (!s) + statusMap.set( + key, + (s = createSignal("idle", { + ownedWrite: true, + ...(DEV && { name: `liveQuery.status:${key}` }) + })) + ); return s; } diff --git a/src/data/query.ts b/src/data/query.ts index 2e595bbf..06c98471 100644 --- a/src/data/query.ts +++ b/src/data/query.ts @@ -1,3 +1,4 @@ +import { DEV } from "solid-js"; import { createSignal, getObserver, @@ -319,7 +320,10 @@ export function query any>(fn: T, name: string): Cac res, , intent, - createSignal(stamp, { ownedWrite: true }) as Signal & { count: number } + createSignal(stamp, { + ownedWrite: true, + ...(DEV && { name: `query:${key}` }) + }) as Signal & { count: number } ]) ); cached[4].count = 0; @@ -434,7 +438,10 @@ query.set = (key: string, value: T extends Promise ? never : T) => { Promise.resolve(value), value, "preload", - createSignal(now, { ownedWrite: true }) as Signal & { count: number } + createSignal(now, { + ownedWrite: true, + ...(DEV && { name: `query:${key}` }) + }) as Signal & { count: number } ]) ); cached[4].count = 0; diff --git a/src/routers/factory.tsx b/src/routers/factory.tsx index 33cfc8cb..0320b2e2 100644 --- a/src/routers/factory.tsx +++ b/src/routers/factory.tsx @@ -364,7 +364,7 @@ function createIntegration( const [read, write] = createSignal(wrap(history.get()), { equals: (a, b) => a.value === b.value && a.state === b.state && a._navigation === b._navigation, ownedWrite: true, - name: "location" + ...(DEV && { name: "router.location" }) }); const signal: RouterIntegration["signal"] = [ read, diff --git a/src/routing.ts b/src/routing.ts index 8034e637..e24e0a15 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -694,7 +694,8 @@ function createLocation( } }, { - equals: (a, b) => a.href === b.href + equals: (a, b) => a.href === b.href, + ...(DEV && { name: "router.url" }) } ); diff --git a/test/data/live-query.spec.ts b/test/data/live-query.spec.ts index 71fe78af..5305774e 100644 --- a/test/data/live-query.spec.ts +++ b/test/data/live-query.spec.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import { flush } from "solid-js"; +import { DEV, createRoot, flush } from "solid-js"; import { createServerReference, getServerFunctionMetadata, @@ -648,3 +648,29 @@ describe("hydration takeover resume", () => { await it.return!(); }); }); + +// Dev builds name the data layer's primitives after their keys, so +// attribution diagnostics (SILENT_HOLD, waterfalls) say which query is +// waiting instead of "signal". Production folds the names out (`DEV &&`). +function namesCreatedBy(run: () => void): string[] { + const names: string[] = []; + DEV!.hooks.onGraph = (node: any) => node?._name && names.push(node._name); + try { + createRoot(run); + } finally { + DEV!.hooks.onGraph = undefined; + } + return names; +} + +describe("liveQuery primitive names", () => { + test("the status signal carries the channel key", () => { + const lq = liveQuery(async function* () { + yield 1; + }, uniqueName()); + const names = namesCreatedBy(() => { + lq.status(); + }); + expect(names).toContain(`liveQuery.status:${lq.keyFor()}`); + }); +}); diff --git a/test/data/query.spec.ts b/test/data/query.spec.ts index 7ccd637e..e10db827 100644 --- a/test/data/query.spec.ts +++ b/test/data/query.spec.ts @@ -1,4 +1,4 @@ -import { createRoot } from "solid-js"; +import { DEV, createRoot } from "solid-js"; import { vi } from "vitest"; import { GET, @@ -623,3 +623,27 @@ describe("hashKey should", () => { ); }); }); + +// Dev builds name the data layer's primitives after their keys, so +// attribution diagnostics (SILENT_HOLD, waterfalls) say which query is +// waiting instead of "signal". Production folds the names out (`DEV &&`). +function namesCreatedBy(run: () => void): string[] { + const names: string[] = []; + DEV!.hooks.onGraph = (node: any) => node?._name && names.push(node._name); + try { + createRoot(run); + } finally { + DEV!.hooks.onGraph = undefined; + } + return names; +} + +describe("query primitive names", () => { + test("a query's version signal carries its key", () => { + const q = query(async (id: string) => id, "names-query"); + const names = namesCreatedBy(() => { + q("a"); + }); + expect(names).toContain(`query:${q.keyFor("a")}`); + }); +}); From 835fd634bd74a545ca0641bbb0fd330ff93e3561 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 08:39:32 +0000 Subject: [PATCH 2/2] test: Forward during a held Back, and a navigation during a held one, land on the newer target (#687) Both pass on next: the 2.0 commit path already keeps the last write. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Fmb4aQtDJWoA8CUktR9QoZ --- test/navigate-commit.spec.tsx | 87 +++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/test/navigate-commit.spec.tsx b/test/navigate-commit.spec.tsx index d2c381f3..99e2703b 100644 --- a/test/navigate-commit.spec.tsx +++ b/test/navigate-commit.spec.tsx @@ -406,6 +406,93 @@ describe("navigateFromRoute never drops the last navigation", () => { } }); + test("Forward during a pending Back lands on the Forward target (#687)", async () => { + const history = memoryHistory(); + history.set({ value: "/slow" }); + history.set({ value: "/fast" }); + + let releaseSlow!: () => void; + let gate = Promise.resolve(); + const routes = [ + { path: "/", component: page("home") }, + { + path: "/slow", + component: () => { + const value = createMemo(async () => { + await gate; + return "slow"; + }); + return
{value()}
; + } + }, + { path: "/fast", component: page("fast") } + ] as const; + const app = mount(routes, history); + try { + await settle(); + expect(app.text()).toBe("fast"); + gate = new Promise(resolve => (releaseSlow = resolve)); + + history.back(); + await settle(); + // held on /slow's data: still showing /fast + expect(app.text()).toBe("fast"); + + history.forward(); + await settle(); + releaseSlow(); + await settle(); + + expect(history.get()).toBe("/fast"); + expect(app.text()).toBe("fast"); + } finally { + app.cleanup(); + } + }); + + test("a navigation during a held one lands on the newer target (#687)", async () => { + const history = memoryHistory(); + let navigate!: Navigator; + let releaseSlow!: () => void; + const gate = new Promise(resolve => (releaseSlow = resolve)); + const routes = [ + { + path: "/", + component: () => { + navigate = useNavigate(); + return
home
; + } + }, + { + path: "/slow", + component: () => { + const value = createMemo(async () => { + await gate; + return "slow"; + }); + return
{value()}
; + } + }, + { path: "/fast", component: page("fast") } + ] as const; + const app = mount(routes, history); + try { + navigate("/slow"); + await settle(); + expect(app.text()).toBe("home"); + + navigate("/fast"); + await settle(); + releaseSlow(); + await settle(); + + expect(history.get()).toBe("/fast"); + expect(app.text()).toBe("fast"); + } finally { + app.cleanup(); + } + }); + test("navigation intent is derived from the pending source transition", async () => { const history = memoryHistory(); let navigate!: Navigator;