From 8f4665517734778b16a7ddf55f742c48811c9db4 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 12 Sep 2026 01:39:08 -0700 Subject: [PATCH] feat(solid-router): declare navigations to Solid's observe tier On Solid's dev and observe builds the match publish inside startTransition is wrapped in OBSERVE.attribution.withOrigin: the ref names the destination route (fullPath, params, to/from pathnames) and dates from the history change that started the load, so the record spans the loader wait even though the publish comes after it. The pending offer, the initial load and same-location reloads are published undeclared. Production is unchanged (OBSERVE is undefined). Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/solid-router-observe-navigation.md | 5 + packages/solid-router/src/Transitioner.tsx | 52 ++++++++- .../tests/observe-navigation.test.tsx | 102 ++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 .changeset/solid-router-observe-navigation.md create mode 100644 packages/solid-router/tests/observe-navigation.test.tsx diff --git a/.changeset/solid-router-observe-navigation.md b/.changeset/solid-router-observe-navigation.md new file mode 100644 index 00000000000..78147b945b6 --- /dev/null +++ b/.changeset/solid-router-observe-navigation.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-router': patch +--- + +Declare navigations to Solid's observe tier. On Solid's dev and observe builds (`OBSERVE` defined), the match publish inside `startTransition` is wrapped in `OBSERVE.attribution.withOrigin` with the destination route's `fullPath`, params, `to`/`from` pathnames and `at` from the history change that started the load, so the holds and re-runs a navigation causes are named after the route and the record spans the loader wait. The pending offer, the initial load and same-location reloads are published undeclared. Nothing changes in production, where `OBSERVE` is undefined. diff --git a/packages/solid-router/src/Transitioner.tsx b/packages/solid-router/src/Transitioner.tsx index aac680e2690..cc9848741a6 100644 --- a/packages/solid-router/src/Transitioner.tsx +++ b/packages/solid-router/src/Transitioner.tsx @@ -2,8 +2,44 @@ import * as Solid from 'solid-js' import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' +import type { NavigationRef } from 'solid-js' import type { AnyRouteMatch } from '@tanstack/router-core' +/** + * Solid's observe tier (`OBSERVE` is defined on the dev and observe builds, + * undefined in production) attributes what the user waited on to the + * navigation that caused it. The rule for every router is the same: wrap the + * write whose landing is the destination showing, and pass `at` when the + * request predates that write. Here that write is the match publish inside + * `startTransition` — the loaders were awaited in router-core before it — + * so the ref names the destination route from the expected matches and + * dates from the history change that started the load. The pending offer + * (`offerPending`, a match with `status: 'pending'`) is not the destination + * and is published undeclared; the initial load and a same-location reload + * are not navigations. + */ +function describeNavigation( + router: ReturnType, + expected: Array, + at: number | undefined, +): NavigationRef | undefined { + if (expected.some((match) => match.status === 'pending')) return + const to = router.latestLocation + const from = router.stores.resolvedLocation.get() + // Nothing shown yet (the initial load), or a reload of what is shown. + if (!from || from.href === to.href) return + const leaf = expected[expected.length - 1] + const ref: NavigationRef = { + kind: 'navigation', + name: leaf?.fullPath || to.pathname, + to: to.pathname, + from: from.pathname, + } + if (leaf && Object.keys(leaf.params).length) ref.params = leaf.params + if (at !== undefined) ref.at = at + return ref +} + function getResolvedLocation(router: ReturnType) { const resolvedLocation = router.stores.resolvedLocation.get() if ( @@ -30,6 +66,10 @@ export function Transitioner() { committed.length === expected.length && expected.every((match, index) => committed![index] === match) + // When the history changed since the last declared publish: the moment + // the user asked, which is where the navigation's wait starts. + let requestedAt: number | undefined + // Ack when the commit's transition settles (the atomic swap), not when the // flush parks it; superseded or rolled-back commits resolve false. router.startTransition = (fn, expectedMatches) => { @@ -40,7 +80,16 @@ export function Transitioner() { return new Promise((resolve) => { const ack: Ack = [expectedMatches, resolve] acks.push(ack) - Solid.runWithOwner(null, fn) + let publish = fn + if (Solid.OBSERVE !== undefined) { + const ref = describeNavigation(router, expectedMatches, requestedAt) + if (ref !== undefined) { + requestedAt = undefined + const observe = Solid.OBSERVE + publish = () => observe.attribution.withOrigin(ref, fn) + } + } + Solid.runWithOwner(null, publish) try { Solid.flush() } catch { @@ -71,6 +120,7 @@ export function Transitioner() { Solid.onSettled(() => { const unsub = router.history.subscribe(() => { + requestedAt ??= performance.now() queueMicrotask(() => router.load().catch(console.error)) }) diff --git a/packages/solid-router/tests/observe-navigation.test.tsx b/packages/solid-router/tests/observe-navigation.test.tsx new file mode 100644 index 00000000000..cbc9d3776df --- /dev/null +++ b/packages/solid-router/tests/observe-navigation.test.tsx @@ -0,0 +1,102 @@ +// Solid's observe tier: the match publish inside `startTransition` is +// declared to the attribution engine as the navigation, so the holds and +// re-runs it causes are named after the route and the record spans from +// the history change that started the load. `OBSERVE` is defined on the +// dev build the tests resolve; in production it is undefined and the +// declaration folds out. +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { afterEach, beforeEach, expect, test } from 'vitest' +import { attribution } from 'solid-js/attribution' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +beforeEach(() => attribution.enable({ log: false })) +afterEach(() => { + attribution.disable() + cleanup() +}) + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +function makeRouter(loaderMs: number) { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const userRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users/$id', + loader: async ({ params }) => { + await sleep(loaderMs) + return { name: `user ${params.id}` } + }, + component: () => { + const data = userRoute.useLoaderData() + return
{data().name}
+ }, + }) + const routeTree = rootRoute.addChildren([indexRoute, userRoute]) + return createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) +} + +test('the match publish is declared as the navigation, dated from the history change', async () => { + const router = makeRouter(30) + render(() => ) + await waitFor(() => expect(screen.getByTestId('home')).toBeTruthy()) + // The initial load publishes matches for the location already shown: not + // a navigation. + expect(attribution.navigations()).toHaveLength(0) + + const requested = performance.now() + await router.navigate({ to: '/users/$id', params: { id: '42' } }) + await waitFor(() => expect(screen.getByTestId('user')).toBeTruthy()) + await sleep(0) + + // One record for the navigation — the pending offer (a match with + // `status: 'pending'`) is published undeclared. + const navs = attribution.navigations() + expect(navs).toHaveLength(1) + const nav = navs[0]! + expect(nav.name).toBe('/users/$id') + expect(nav.to).toBe('/users/42') + expect(nav.from).toBe('/') + expect(nav.params).toEqual({ id: '42' }) + expect(nav.outcome).toBe('committed') + // `at` is the history change inside navigate(), before the loader ran: + // the record spans the loader wait even though the publish came after it. + expect(nav.at).toBeGreaterThanOrEqual(requested) + expect(nav.at).toBeLessThan(requested + 30) + expect(nav.settledMs!).toBeGreaterThanOrEqual(30) +}) + +test('a navigation superseded before it published leaves one record for the destination that showed', async () => { + const router = makeRouter(30) + render(() => ) + await waitFor(() => expect(screen.getByTestId('home')).toBeTruthy()) + + const requested = performance.now() + void router.navigate({ to: '/users/$id', params: { id: '1' } }) + await sleep(5) + await router.navigate({ to: '/users/$id', params: { id: '2' } }) + await waitFor(() => + expect(screen.getByTestId('user').textContent).toBe('user 2'), + ) + await sleep(0) + + const navs = attribution.navigations() + expect(navs).toHaveLength(1) + expect(navs[0]!.to).toBe('/users/2') + // Dated from the first request: that is when the user started waiting. + expect(navs[0]!.at).toBeLessThan(requested + 5) +})