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) +})