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
47 changes: 19 additions & 28 deletions packages/history/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,18 +329,14 @@ export function createBrowserHistory(opts?: {

let next:
| undefined
| {
// This is the latest location that we were attempting to push/replace
href: string
// This is the latest state that we were attempting to push/replace
state: any
// This is the latest type that we were attempting to push/replace
isPush: boolean
}

// We need to track the current scheduled update to prevent
// multiple updates from being scheduled at the same time.
let scheduled: undefined | boolean
| [
// The latest location that we were attempting to push/replace
href: string,
// The latest state that we were attempting to push/replace
state: any,
// Whether any queued update needs to push rather than replace
isPush: boolean,
]

// This function flushes the next update to the browser history
const flush = () => {
Expand All @@ -352,46 +348,41 @@ export function createBrowserHistory(opts?: {
history._ignoreSubscribers = true

// Update the browser history
;(next.isPush ? win.history.pushState : win.history.replaceState)(
next.state,
;(next[2 /* is push */] ? win.history.pushState : win.history.replaceState)(
next[1 /* state */],
'',
next.href,
next[0 /* href */],
)

// Stop ignoring subscriber updates
history._ignoreSubscribers = false

// Reset the nextIsPush flag and clear the scheduled update
// Clear the queued action after it reaches browser history.
next = undefined
scheduled = false
rollbackLocation = undefined
}

// This function queues up a call to update the browser history
const queueHistoryAction = (
type: 'push' | 'replace',
isPush: boolean,
destHref: string,
state: any,
) => {
const href = createHref(destHref)
const hasPendingAction = !!next

if (!scheduled) {
if (!hasPendingAction) {
rollbackLocation = currentLocation
}

// Update the location in memory
currentLocation = parseHref(destHref, state)

// Keep track of the next location we need to flush to the URL
next = {
href,
state,
isPush: next?.isPush || type === 'push',
}
next = [href, state, next?.[2 /* is push */] || isPush]

if (!scheduled) {
if (!hasPendingAction) {
// Schedule an update to the browser history
scheduled = true
queueMicrotask(() => flush())
}
}
Expand Down Expand Up @@ -489,8 +480,8 @@ export function createBrowserHistory(opts?: {
const history = createHistory({
getLocation,
getLength: () => win.history.length,
pushState: (href, state) => queueHistoryAction('push', href, state),
replaceState: (href, state) => queueHistoryAction('replace', href, state),
pushState: (href, state) => queueHistoryAction(true, href, state),
replaceState: (href, state) => queueHistoryAction(false, href, state),
back: (ignoreBlocker) => {
if (ignoreBlocker) skipBlockerNextPop = true
ignoreNextBeforeUnload = true
Expand Down
106 changes: 106 additions & 0 deletions packages/history/tests/createBrowserHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, test, vi } from 'vitest'
import { createBrowserHistory } from '../src'

function createBrowserHistoryHarness() {
const location = {
pathname: '/',
search: '',
hash: '',
}
const pushState = vi.fn()
const replaceState = vi.fn()
const nativeHistory = {
state: { __TSR_index: 0, __TSR_key: 'initial' },
length: 1,
pushState,
replaceState,
back: vi.fn(),
forward: vi.fn(),
go: vi.fn(),
}
const window = {
location,
history: nativeHistory,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}
const history = createBrowserHistory({ window })

return {
history,
pushState,
replaceState,
}
}

describe('createBrowserHistory', () => {
test('coalesces consecutive replaces into the latest replace', async () => {
const { history, pushState, replaceState } = createBrowserHistoryHarness()

history.replace('/first', { value: 1 })
history.replace('/second', { value: 2 })
await Promise.resolve()

expect(pushState).not.toHaveBeenCalled()
expect(replaceState).toHaveBeenCalledTimes(1)
expect(replaceState).toHaveBeenCalledWith(
expect.objectContaining({ value: 2 }),
'',
'/second',
)
history.destroy()
})

test('promotes a queued replace to a push', async () => {
const { history, pushState, replaceState } = createBrowserHistoryHarness()

history.replace('/first', { value: 1 })
history.push('/second', { value: 2 })
await Promise.resolve()

expect(replaceState).not.toHaveBeenCalled()
expect(pushState).toHaveBeenCalledTimes(1)
expect(pushState).toHaveBeenCalledWith(
expect.objectContaining({ value: 2 }),
'',
'/second',
)
history.destroy()
})

test('keeps a queued push when followed by a replace', async () => {
const { history, pushState, replaceState } = createBrowserHistoryHarness()

history.push('/first', { value: 1 })
history.replace('/second', { value: 2 })
await Promise.resolve()

expect(replaceState).not.toHaveBeenCalled()
expect(pushState).toHaveBeenCalledTimes(1)
expect(pushState).toHaveBeenCalledWith(
expect.objectContaining({ value: 2 }),
'',
'/second',
)
history.destroy()
})

test('flushes a later action after an explicit flush', async () => {
const { history, pushState, replaceState } = createBrowserHistoryHarness()

history.replace('/first', { value: 1 })
history.flush()
await Promise.resolve()
history.replace('/second', { value: 2 })
await Promise.resolve()

expect(pushState).not.toHaveBeenCalled()
expect(replaceState).toHaveBeenCalledTimes(2)
expect(replaceState).toHaveBeenLastCalledWith(
expect.objectContaining({ value: 2 }),
'',
'/second',
)
history.destroy()
})
})
37 changes: 20 additions & 17 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -963,12 +963,12 @@ export function runRouteLifecycle(
}
}

type LightweightRouteMatchResult = {
matchedRoutes: ReadonlyArray<AnyRoute>
fullPath: string
search: Record<string, unknown>
params: Record<string, unknown>
}
type LightweightRouteMatchResult = [
matchedRoutes: ReadonlyArray<AnyRoute>,
fullPath: string,
search: Record<string, unknown>,
params: Record<string, unknown>,
]

type LightweightRouteMatchCacheEntry = [
lastMatchId: string | undefined,
Expand Down Expand Up @@ -1822,12 +1822,12 @@ export class RouterCore<
params = strictParams
}

const result = {
const result: LightweightRouteMatchResult = [
matchedRoutes,
fullPath: lastRoute.fullPath,
search: accumulatedSearch,
lastRoute.fullPath,
accumulatedSearch,
params,
}
]
this.lightweightCache.set(location, [lastStateMatchId, result])
return result
}
Expand Down Expand Up @@ -1862,12 +1862,15 @@ export class RouterCore<
) {
const [allFromMatches] = this.getMatchedRoutes(dest.from)

const matchedFrom = findLast(lightweightResult.matchedRoutes, (d) => {
return comparePaths(d.fullPath, dest.from!)
})
const matchedFrom = findLast(
lightweightResult[0 /* matchedRoutes */],
(d) => {
return comparePaths(d.fullPath, dest.from!)
},
)

const matchedCurrent = findLast(allFromMatches, (d) => {
return comparePaths(d.fullPath, lightweightResult.fullPath)
return comparePaths(d.fullPath, lightweightResult[1 /* fullPath */])
})

// for from to be invalid it shouldn't just be unmatched to currentLocation
Expand All @@ -1880,15 +1883,15 @@ export class RouterCore<
const defaultedFromPath =
dest.unsafeRelative === 'path'
? currentLocation.pathname
: (dest.from ?? lightweightResult.fullPath)
: (dest.from ?? lightweightResult[1 /* fullPath */])
const destTo = dest.to ? `${dest.to}` : undefined

// From search should always use the current location
const fromSearch = lightweightResult.search
const fromSearch = lightweightResult[2 /* search */]
// Same with params. It can't hurt to provide as many as possible
const fromParams = Object.assign(
Object.create(null),
lightweightResult.params,
lightweightResult[3 /* params */],
)

const isAbsoluteTo = destTo?.charCodeAt(0) === 47
Expand Down
13 changes: 5 additions & 8 deletions packages/solid-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,7 @@ export const Match = (props: { routeId: string }) => {
() => router.stores.byRoute.get(props.routeId)!.get()!,
)

const nearestMatch = {
routeId: () => props.routeId,
match: currentMatch,
}
const nearestMatch = [() => props.routeId, currentMatch] as const

const route: AnyRoute = router.routesById[props.routeId]

Expand Down Expand Up @@ -162,8 +159,8 @@ export const Match = (props: { routeId: string }) => {
export const MatchInner = (): any => {
const router = useRouter()
const nearestMatch = Solid.useContext(nearestMatchContext)
const match = nearestMatch.match
const routeId = nearestMatch.routeId
const match = nearestMatch[1 /* match */]
const routeId = nearestMatch[0 /* route id */]
const route = router.routesById[routeId()!]!
const currentMatch = () => match()!

Expand Down Expand Up @@ -230,8 +227,8 @@ export const MatchInner = (): any => {
export const Outlet = () => {
const router = useRouter()
const nearestParentMatch = Solid.useContext(nearestMatchContext)
const parentMatch = nearestParentMatch.match
const routeId = nearestParentMatch.routeId
const parentMatch = nearestParentMatch[1 /* match */]
const routeId = nearestParentMatch[0 /* route id */]
const route = router.routesById[routeId()!]!

const childRouteId = () => {
Expand Down
9 changes: 3 additions & 6 deletions packages/solid-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,7 @@ function MatchesInner() {
const routeId = () => router.stores.ids.get()[0]
const match = () =>
routeId() ? router.stores.byRoute.get(routeId()!)?.get() : undefined
const nearestMatch = {
routeId,
match,
}
const nearestMatch = [routeId, match] as const

const matchComponent = () => {
return (
Expand Down Expand Up @@ -219,7 +216,7 @@ export function useParentMatches<
>(
opts?: UseMatchesBaseOptions<TRouter, TSelected>,
): Solid.Accessor<UseMatchesResult<TRouter, TSelected>> {
const contextRouteId = Solid.useContext(nearestMatchContext).routeId
const contextRouteId = Solid.useContext(nearestMatchContext)[0 /* route id */]

return useMatches({
select: (matches: Array<MakeRouteMatchUnion<TRouter>>) => {
Expand All @@ -238,7 +235,7 @@ export function useChildMatches<
>(
opts?: UseMatchesBaseOptions<TRouter, TSelected>,
): Solid.Accessor<UseMatchesResult<TRouter, TSelected>> {
const contextRouteId = Solid.useContext(nearestMatchContext).routeId
const contextRouteId = Solid.useContext(nearestMatchContext)[0 /* route id */]

return useMatches({
select: (matches: Array<MakeRouteMatchUnion<TRouter>>) => {
Expand Down
16 changes: 8 additions & 8 deletions packages/solid-router/src/matchContext.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import * as Solid from 'solid-js'
import type { AnyRouteMatch } from '@tanstack/router-core'

export type NearestMatchContextValue = {
routeId: Solid.Accessor<string | undefined>
match: Solid.Accessor<AnyRouteMatch | undefined>
}
export type NearestMatchContextValue = readonly [
routeId: Solid.Accessor<string | undefined>,
match: Solid.Accessor<AnyRouteMatch | undefined>,
]

const defaultNearestMatchContext: NearestMatchContextValue = {
routeId: () => undefined,
match: () => undefined,
}
const defaultNearestMatchContext: NearestMatchContextValue = [
() => undefined,
() => undefined,
]

export const nearestMatchContext =
Solid.createContext<NearestMatchContextValue>(defaultNearestMatchContext)
2 changes: 1 addition & 1 deletion packages/solid-router/src/useMatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function useMatch<
return router.stores.getMatchStore(opts.from).get()
}

return nearestMatch?.match()
return nearestMatch?.[1 /* match */]()
}

Solid.createEffect(() => {
Expand Down
Loading
Loading