Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/solid-router-observe-navigation.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 51 additions & 1 deletion packages/solid-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useRouter>,
expected: Array<AnyRouteMatch>,
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<typeof useRouter>) {
const resolvedLocation = router.stores.resolvedLocation.get()
if (
Expand All @@ -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) => {
Expand All @@ -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 {
Expand Down Expand Up @@ -71,6 +120,7 @@ export function Transitioner() {

Solid.onSettled(() => {
const unsub = router.history.subscribe(() => {
requestedAt ??= performance.now()
queueMicrotask(() => router.load().catch(console.error))
})

Expand Down
102 changes: 102 additions & 0 deletions packages/solid-router/tests/observe-navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <Outlet /> })
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div data-testid="home">Home</div>,
})
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 <div data-testid="user">{data().name}</div>
},
})
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(() => <RouterProvider router={router} />)
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(() => <RouterProvider router={router} />)
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)
})
Loading