diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index 0f3be8242e..063174c732 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -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 = () => { @@ -352,30 +348,30 @@ 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 } @@ -383,15 +379,10 @@ export function createBrowserHistory(opts?: { 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()) } } @@ -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 diff --git a/packages/history/tests/createBrowserHistory.test.ts b/packages/history/tests/createBrowserHistory.test.ts new file mode 100644 index 0000000000..97b3c40ef3 --- /dev/null +++ b/packages/history/tests/createBrowserHistory.test.ts @@ -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() + }) +}) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 74b6f015a8..cc83e358de 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -963,12 +963,12 @@ export function runRouteLifecycle( } } -type LightweightRouteMatchResult = { - matchedRoutes: ReadonlyArray - fullPath: string - search: Record - params: Record -} +type LightweightRouteMatchResult = [ + matchedRoutes: ReadonlyArray, + fullPath: string, + search: Record, + params: Record, +] type LightweightRouteMatchCacheEntry = [ lastMatchId: string | undefined, @@ -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 } @@ -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 @@ -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 diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 8a400a8580..16038453c0 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -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] @@ -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()! @@ -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 = () => { diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx index ff270ecbd9..704e5026f4 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -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 ( @@ -219,7 +216,7 @@ export function useParentMatches< >( opts?: UseMatchesBaseOptions, ): Solid.Accessor> { - const contextRouteId = Solid.useContext(nearestMatchContext).routeId + const contextRouteId = Solid.useContext(nearestMatchContext)[0 /* route id */] return useMatches({ select: (matches: Array>) => { @@ -238,7 +235,7 @@ export function useChildMatches< >( opts?: UseMatchesBaseOptions, ): Solid.Accessor> { - const contextRouteId = Solid.useContext(nearestMatchContext).routeId + const contextRouteId = Solid.useContext(nearestMatchContext)[0 /* route id */] return useMatches({ select: (matches: Array>) => { diff --git a/packages/solid-router/src/matchContext.tsx b/packages/solid-router/src/matchContext.tsx index 50ab0d4b76..87cd32f4a8 100644 --- a/packages/solid-router/src/matchContext.tsx +++ b/packages/solid-router/src/matchContext.tsx @@ -1,15 +1,15 @@ import * as Solid from 'solid-js' import type { AnyRouteMatch } from '@tanstack/router-core' -export type NearestMatchContextValue = { - routeId: Solid.Accessor - match: Solid.Accessor -} +export type NearestMatchContextValue = readonly [ + routeId: Solid.Accessor, + match: Solid.Accessor, +] -const defaultNearestMatchContext: NearestMatchContextValue = { - routeId: () => undefined, - match: () => undefined, -} +const defaultNearestMatchContext: NearestMatchContextValue = [ + () => undefined, + () => undefined, +] export const nearestMatchContext = Solid.createContext(defaultNearestMatchContext) diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index 97880329db..dc3d6ff37d 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -78,7 +78,7 @@ export function useMatch< return router.stores.getMatchStore(opts.from).get() } - return nearestMatch?.match() + return nearestMatch?.[1 /* match */]() } Solid.createEffect(() => { diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 2e993205bc..5c467bcdf0 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -157,11 +157,10 @@ export async function serverFnFetcher( let body = undefined if (first.method === 'POST') { - const fetchBody = await getFetchBody(first) - if (fetchBody?.contentType) { - headers.set('content-type', fetchBody.contentType) + body = await getFetchBody(first) + if (typeof body === 'string') { + headers.set('content-type', 'application/json') } - body = fetchBody?.body } return await getResponse(async () => @@ -204,7 +203,7 @@ async function serialize(data: any) { async function getFetchBody( opts: FunctionMiddlewareClientFnOptions, -): Promise<{ body: FormData | string; contentType?: string } | undefined> { +): Promise { if (opts.data instanceof FormData) { let serializedContext = undefined // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -214,11 +213,11 @@ async function getFetchBody( if (serializedContext !== undefined) { opts.data.set(TSS_FORMDATA_CONTEXT, serializedContext) } - return { body: opts.data } + return opts.data } const serializedBody = await serializePayload(opts) if (serializedBody) { - return { body: serializedBody, contentType: 'application/json' } + return serializedBody } return undefined } diff --git a/packages/vue-router/src/Scripts.tsx b/packages/vue-router/src/Scripts.tsx index 8c0a1f87f3..4c0d8feed0 100644 --- a/packages/vue-router/src/Scripts.tsx +++ b/packages/vue-router/src/Scripts.tsx @@ -6,13 +6,6 @@ import { Asset } from './Asset' import { useRouter } from './useRouter' import type { RouterManagedTag } from '@tanstack/router-core' -type ScriptsRenderState = { - scripts: Array - assetScripts: Array - mounted: boolean - nonce?: string -} - export const Scripts = Vue.defineComponent({ name: 'Scripts', setup() { @@ -53,20 +46,19 @@ export const Scripts = Vue.defineComponent({ }) return () => { - const [userScripts, assetScripts] = scripts.value - return renderScripts(router, { - scripts: userScripts, - assetScripts, - mounted: mounted.value, - nonce, - }) + return renderScripts(router, scripts.value, mounted.value, nonce) } }, }) function renderScripts( router: ReturnType, - { scripts, assetScripts, mounted, nonce }: ScriptsRenderState, + [scripts, assetScripts]: readonly [ + Array, + Array, + ], + mounted: boolean, + nonce?: string, ) { const allScripts: Array = [] diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 3a5a9875a8..fb0e4413b3 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -180,28 +180,21 @@ export function useLinkProps< // Avoid store subscriptions, effects and observers on the server. if (isServer ?? router.isServer) { const next = router.buildLocation(options as any) - const href = getHref({ - options: options as AnyLinkPropsOptions, - router, - nextLocation: next, - }) + const href = getHref(options as AnyLinkPropsOptions, router, next) - const isActive = getIsActive({ - loc: router.stores.location.get(), - nextLoc: next, - activeOptions: options.activeOptions, + const isActive = getIsActive( + router.stores.location.get(), + next, + options.activeOptions, router, - }) + ) const { resolvedActiveProps, resolvedInactiveProps, resolvedClassName, resolvedStyle, - } = resolveStyleProps({ - options: options as AnyLinkPropsOptions, - isActive, - }) + } = resolveStyleProps(options as AnyLinkPropsOptions, isActive) const result = combineResultProps({ href, @@ -242,12 +235,12 @@ export function useLinkProps< ) const isActive = Vue.computed(() => - getIsActive({ - activeOptions: options.activeOptions, - loc: currentLocation.value, - nextLoc: next.value, + getIsActive( + currentLocation.value, + next.value, + options.activeOptions, router, - }), + ), ) const doPreload = () => @@ -378,18 +371,11 @@ export function useLinkProps< // Get the active and inactive props const resolvedStyleProps = Vue.computed(() => - resolveStyleProps({ - options: options as AnyLinkPropsOptions, - isActive: isActive.value, - }), + resolveStyleProps(options as AnyLinkPropsOptions, isActive.value), ) const href = Vue.computed(() => - getHref({ - options: options as AnyLinkPropsOptions, - router, - nextLocation: next.value, - }), + getHref(options as AnyLinkPropsOptions, router, next.value), ) // Create static event handlers that don't change between renders @@ -449,13 +435,7 @@ export function useLinkProps< return computedProps as unknown as LinkHTMLAttributes } -function resolveStyleProps({ - options, - isActive, -}: { - options: AnyLinkPropsOptions - isActive: boolean -}) { +function resolveStyleProps(options: AnyLinkPropsOptions, isActive: boolean) { const activeProps = options.activeProps || (() => ({ class: 'active' })) const resolvedActiveProps: StyledProps = (isActive ? typeof activeProps === 'function' @@ -645,25 +625,20 @@ const getPropsSafeToSpread = (options: AnyLinkPropsOptions) => { return propsSafeToSpread } -function getIsActive({ - activeOptions, - loc, - nextLoc, - router, -}: { - activeOptions: LinkOptions['activeOptions'] +function getIsActive( loc: { pathname: string search: any hash: string - } + }, nextLoc: { pathname: string search: any hash: string - } - router: AnyRouter -}) { + }, + activeOptions: LinkOptions['activeOptions'], + router: AnyRouter, +) { if (activeOptions?.exact) { const testExact = exactPathTest( loc.pathname, @@ -702,15 +677,11 @@ function getIsActive({ return true } -function getHref({ - options, - router, - nextLocation, -}: { - options: AnyLinkPropsOptions - router: AnyRouter - nextLocation?: ParsedLocation -}) { +function getHref( + options: AnyLinkPropsOptions, + router: AnyRouter, + nextLocation?: ParsedLocation, +) { if (options.disabled) { return undefined }