diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 76a1253d580..f26f3a7f712 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -389,19 +389,16 @@ export function useLinkProps< // computation when the leaf route/params context changes. // eslint-disable-next-line react-hooks/rules-of-hooks const currentLeafMatchId = useStore(router.stores.lastMatchId, (id) => id) - const from = options.from // eslint-disable-next-line react-hooks/rules-of-hooks const _options = React.useMemo( - () => { - return { ...options, from } - }, + () => options, // eslint-disable-next-line react-hooks/exhaustive-deps [ router, currentLeafMatchId, currentLocation.hash, - from, + options.from, options._fromLocation, options.hash, options.to, diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index b0a3a2197b5..3ebf1f2f340 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -13,62 +13,20 @@ import { Dynamic } from 'solid-js/web' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { useRouter } from './useRouter' import { CatchNotFound } from './not-found' -import { - matchContext, - pendingMatchContext, - routeIdContext, -} from './matchContext' +import { nearestMatchContext } from './matchContext' import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core' -/** - * Resolve the active match state for a given matchId, with fallback - * to routeId-based lookup during same-route transitions. - * - * Uses direct pool access instead of the byId/byRouteId derived stores, - * avoiding intermediate lookup object allocations. - */ -function useResolvedActiveMatch(matchId: Solid.Accessor) { +export const Match = (props: { matchId: string }) => { const router = useRouter() - // Keep the last seen routeId to recover from transient stale matchId values - // during same-route transitions (e.g. loaderDepsHash changes). - const lastKnownRouteId = Solid.createMemo( - (previousRouteId) => { - const id = matchId() - if (!id) return previousRouteId - // Track matchesId so this re-evaluates when the pool changes - router.stores.matchesId.state - const routeId = router.stores.activeMatchStoresById.get(id)?.routeId - return routeId ?? previousRouteId - }, - ) - - return Solid.createMemo(() => { - const id = matchId() + const match = Solid.createMemo(() => { + const id = props.matchId if (!id) return undefined - - // Track matchesId for pool changes - router.stores.matchesId.state - - // Primary: look up by matchId from the pool directly - const store = router.stores.activeMatchStoresById.get(id) - if (store) return store.state - - // Fallback: matchId is stale, resolve by routeId through the signal graph - const routeId = lastKnownRouteId() - if (routeId) return router.stores.getMatchStoreByRouteId(routeId).state - - return undefined + return router.stores.activeMatchStoresById.get(id)?.state }) -} - -export const Match = (props: { matchId: string }) => { - const router = useRouter() - const match = useResolvedActiveMatch(() => props.matchId) - const resetKey = Solid.createMemo(() => router.stores.loadedAt.state) const rawMatchState = Solid.createMemo(() => { const currentMatch = match() @@ -89,133 +47,136 @@ export const Match = (props: { matchId: string }) => { } }) - const matchState: typeof rawMatchState = Solid.createMemo( - (previous) => rawMatchState() ?? previous ?? null, - null, - ) - - const pendingRouteIds = Solid.createMemo( - () => router.stores.pendingRouteIds.state, - ) const hasPendingMatch = Solid.createMemo(() => { - const currentRouteId = matchState()?.routeId - return currentRouteId ? Boolean(pendingRouteIds()[currentRouteId]) : false + const currentRouteId = rawMatchState()?.routeId + return currentRouteId + ? Boolean(router.stores.pendingRouteIds.state[currentRouteId]) + : false }) + const nearestMatch = { + matchId: () => rawMatchState()?.matchId, + routeId: () => rawMatchState()?.routeId, + match, + hasPending: hasPendingMatch, + } - // If match doesn't exist yet, return null (component is being unmounted or not ready) - if (!matchState()) return null - - const route: () => AnyRoute = () => router.routesById[matchState()!.routeId] + return ( + + {(currentMatchState) => { + const route: () => AnyRoute = () => + router.routesById[currentMatchState().routeId] - const resolvePendingComponent = () => - route().options.pendingComponent ?? router.options.defaultPendingComponent + const resolvePendingComponent = () => + route().options.pendingComponent ?? + router.options.defaultPendingComponent - const routeErrorComponent = () => - route().options.errorComponent ?? router.options.defaultErrorComponent + const routeErrorComponent = () => + route().options.errorComponent ?? router.options.defaultErrorComponent - const routeOnCatch = () => - route().options.onCatch ?? router.options.defaultOnCatch + const routeOnCatch = () => + route().options.onCatch ?? router.options.defaultOnCatch - const routeNotFoundComponent = () => - route().isRoot - ? // If it's the root route, use the globalNotFound option, with fallback to the notFoundRoute's component - (route().options.notFoundComponent ?? - router.options.notFoundRoute?.options.component) - : route().options.notFoundComponent + const routeNotFoundComponent = () => + route().isRoot + ? // If it's the root route, use the globalNotFound option, with fallback to the notFoundRoute's component + (route().options.notFoundComponent ?? + router.options.notFoundRoute?.options.component) + : route().options.notFoundComponent - const resolvedNoSsr = - matchState()!.ssr === false || matchState()!.ssr === 'data-only' + const resolvedNoSsr = + currentMatchState().ssr === false || + currentMatchState().ssr === 'data-only' - const ResolvedSuspenseBoundary = () => Solid.Suspense + const ResolvedSuspenseBoundary = () => Solid.Suspense - const ResolvedCatchBoundary = () => - routeErrorComponent() ? CatchBoundary : SafeFragment + const ResolvedCatchBoundary = () => + routeErrorComponent() ? CatchBoundary : SafeFragment - const ResolvedNotFoundBoundary = () => - routeNotFoundComponent() ? CatchNotFound : SafeFragment + const ResolvedNotFoundBoundary = () => + routeNotFoundComponent() ? CatchNotFound : SafeFragment - const ShellComponent = route().isRoot - ? ((route().options as RootRouteOptions).shellComponent ?? SafeFragment) - : SafeFragment + const ShellComponent = route().isRoot + ? ((route().options as RootRouteOptions).shellComponent ?? + SafeFragment) + : SafeFragment - return ( - - matchState()!.matchId}> - matchState()!.routeId}> - - - ) - } - > + return ( + + resetKey()} - errorComponent={routeErrorComponent() || ErrorComponent} - onCatch={(error: Error) => { - // Forward not found errors (we don't want to show the error component for these) - if (isNotFound(error)) throw error - warning( - false, - `Error in route match: ${matchState()!.routeId}`, + component={ResolvedSuspenseBoundary()} + fallback={ + // Don't show fallback on server when using no-ssr mode to avoid hydration mismatch + (isServer ?? router.isServer) && resolvedNoSsr ? undefined : ( + ) - routeOnCatch()?.(error) - }} + } > { - // If the current not found handler doesn't exist or it has a - // route ID which doesn't match the current route, rethrow the error - if ( - !routeNotFoundComponent() || - (error.routeId && - error.routeId !== matchState()!.routeId) || - (!error.routeId && !route().isRoot) - ) - throw error - - return ( - + component={ResolvedCatchBoundary()} + getResetKey={() => router.stores.loadedAt.state} + errorComponent={routeErrorComponent() || ErrorComponent} + onCatch={(error: Error) => { + // Forward not found errors (we don't want to show the error component for these) + if (isNotFound(error)) throw error + warning( + false, + `Error in route match: ${currentMatchState().routeId}`, ) + routeOnCatch()?.(error) }} > - - - - } - > - - - - - - - + { + // If the current not found handler doesn't exist or it has a + // route ID which doesn't match the current route, rethrow the error + if ( + !routeNotFoundComponent() || + (error.routeId && + error.routeId !== currentMatchState().routeId) || + (!error.routeId && !route().isRoot) + ) + throw error + + return ( + + ) + }} + > + + + + } + > + + + + + + + + - - - - - - {matchState()?.parentRouteId === rootRouteId ? ( - <> - - - - ) : null} - + + + {currentMatchState().parentRouteId === rootRouteId ? ( + <> + + + + ) : null} + + ) + }} + ) } @@ -243,9 +204,9 @@ function OnRendered() { return null } -export const MatchInner = (props: { matchId: string }): any => { +export const MatchInner = (): any => { const router = useRouter() - const match = useResolvedActiveMatch(() => props.matchId) + const match = Solid.useContext(nearestMatchContext).match const rawMatchState = Solid.createMemo(() => { const currentMatch = match() @@ -279,174 +240,172 @@ export const MatchInner = (props: { matchId: string }): any => { } }) - const matchState: typeof rawMatchState = Solid.createMemo( - (previous) => rawMatchState() ?? previous ?? null, - null, - ) + return ( + + {(currentMatchState) => { + const route = () => router.routesById[currentMatchState().routeId]! - // If match doesn't exist yet, return null (component is being unmounted or not ready) - if (!matchState()) return null + const currentMatch = () => currentMatchState().match - const route = () => router.routesById[matchState()!.routeId]! + const componentKey = () => + currentMatchState().key ?? currentMatchState().match.id - const currentMatch = () => matchState()!.match + const out = () => { + const Comp = + route().options.component ?? router.options.defaultComponent + if (Comp) { + return + } + return + } - const componentKey = () => matchState()!.key ?? matchState()!.match.id + const keyedOut = () => ( + + {(_key) => out()} + + ) - const out = () => { - const Comp = route().options.component ?? router.options.defaultComponent - if (Comp) { - return - } - return - } + return ( + + + {(_) => { + const [displayPendingResult] = Solid.createResource( + () => + router.getMatch(currentMatch().id)?._nonReactive + .displayPendingPromise, + ) - const keyedOut = () => ( - - {(_key) => out()} - - ) + return <>{displayPendingResult()} + }} + + + {(_) => { + const [minPendingResult] = Solid.createResource( + () => + router.getMatch(currentMatch().id)?._nonReactive + .minPendingPromise, + ) - return ( - - - {(_) => { - const [displayPendingResult] = Solid.createResource( - () => - router.getMatch(currentMatch().id)?._nonReactive - .displayPendingPromise, - ) - - return <>{displayPendingResult()} - }} - - - {(_) => { - const [minPendingResult] = Solid.createResource( - () => - router.getMatch(currentMatch().id)?._nonReactive - .minPendingPromise, - ) - - return <>{minPendingResult()} - }} - - - {(_) => { - const pendingMinMs = - route().options.pendingMinMs ?? router.options.defaultPendingMinMs - - if (pendingMinMs) { - const routerMatch = router.getMatch(currentMatch().id) - if (routerMatch && !routerMatch._nonReactive.minPendingPromise) { - // Create a promise that will resolve after the minPendingMs - if (!(isServer ?? router.isServer)) { - const minPendingPromise = createControlledPromise() - - routerMatch._nonReactive.minPendingPromise = minPendingPromise - - setTimeout(() => { - minPendingPromise.resolve() - // We've handled the minPendingPromise, so we can delete it - routerMatch._nonReactive.minPendingPromise = undefined - }, pendingMinMs) - } - } - } + return <>{minPendingResult()} + }} + + + {(_) => { + const pendingMinMs = + route().options.pendingMinMs ?? + router.options.defaultPendingMinMs + + if (pendingMinMs) { + const routerMatch = router.getMatch(currentMatch().id) + if ( + routerMatch && + !routerMatch._nonReactive.minPendingPromise + ) { + // Create a promise that will resolve after the minPendingMs + if (!(isServer ?? router.isServer)) { + const minPendingPromise = createControlledPromise() + + routerMatch._nonReactive.minPendingPromise = + minPendingPromise + + setTimeout(() => { + minPendingPromise.resolve() + // We've handled the minPendingPromise, so we can delete it + routerMatch._nonReactive.minPendingPromise = undefined + }, pendingMinMs) + } + } + } + + const [loaderResult] = Solid.createResource(async () => { + await new Promise((r) => setTimeout(r, 0)) + return router.getMatch(currentMatch().id)?._nonReactive + .loadPromise + }) + + const FallbackComponent = + route().options.pendingComponent ?? + router.options.defaultPendingComponent + + return ( + <> + {FallbackComponent && pendingMinMs > 0 ? ( + + ) : null} + {loaderResult()} + + ) + }} + + + {(_) => { + invariant( + isNotFound(currentMatch().error), + 'Expected a notFound error', + ) - const [loaderResult] = Solid.createResource(async () => { - await new Promise((r) => setTimeout(r, 0)) - return router.getMatch(currentMatch().id)?._nonReactive.loadPromise - }) - - const FallbackComponent = - route().options.pendingComponent ?? - router.options.defaultPendingComponent - - return ( - <> - {FallbackComponent && pendingMinMs > 0 ? ( - - ) : null} - {loaderResult()} - - ) - }} - - - {(_) => { - invariant( - isNotFound(currentMatch().error), - 'Expected a notFound error', - ) - - // Use Show with keyed to ensure re-render when routeId changes - return ( - - {(_routeId) => - renderRouteNotFound(router, route(), currentMatch().error) - } - - ) - }} - - - {(_) => { - invariant( - isRedirect(currentMatch().error), - 'Expected a redirect error', - ) - - const [loaderResult] = Solid.createResource(async () => { - await new Promise((r) => setTimeout(r, 0)) - return router.getMatch(currentMatch().id)?._nonReactive.loadPromise - }) - - return <>{loaderResult()} - }} - - - {(_) => { - if (isServer ?? router.isServer) { - const RouteErrorComponent = - (route().options.errorComponent ?? - router.options.defaultErrorComponent) || - ErrorComponent - - return ( - - ) - } + // Use Show with keyed to ensure re-render when routeId changes + return ( + + {(_routeId) => + renderRouteNotFound(router, route(), currentMatch().error) + } + + ) + }} + + + {(_) => { + invariant( + isRedirect(currentMatch().error), + 'Expected a redirect error', + ) - throw currentMatch().error - }} - - - {keyedOut()} - - + const [loaderResult] = Solid.createResource(async () => { + await new Promise((r) => setTimeout(r, 0)) + return router.getMatch(currentMatch().id)?._nonReactive + .loadPromise + }) + + return <>{loaderResult()} + }} + + + {(_) => { + if (isServer ?? router.isServer) { + const RouteErrorComponent = + (route().options.errorComponent ?? + router.options.defaultErrorComponent) || + ErrorComponent + + return ( + + ) + } + + throw currentMatch().error + }} + + + {keyedOut()} + + + ) + }} + ) } export const Outlet = () => { const router = useRouter() - const parentRouteIdContext = Solid.useContext(routeIdContext) - - const parentMatch = Solid.createMemo(() => { - const routeId = parentRouteIdContext() - return routeId - ? router.stores.getMatchStoreByRouteId(routeId).state - : undefined - }) - - const routeId = Solid.createMemo( - () => parentMatch()?.routeId as string | undefined, - ) + const nearestParentMatch = Solid.useContext(nearestMatchContext) + const parentMatch = nearestParentMatch.match + const routeId = nearestParentMatch.routeId const route = Solid.createMemo(() => routeId() ? router.routesById[routeId()!] : undefined, ) diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx index fffcc9db412..f8af20d0b5d 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -6,7 +6,7 @@ import { shallow } from './store' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { useRouter } from './useRouter' import { Transitioner } from './Transitioner' -import { matchContext, routeIdContext } from './matchContext' +import { nearestMatchContext } from './matchContext' import { SafeFragment } from './SafeFragment' import { Match } from './Match' import type { @@ -68,13 +68,26 @@ export function Matches() { function MatchesInner() { const router = useRouter() const matchId = Solid.createMemo(() => router.stores.firstMatchId.state) - const routeId = Solid.createMemo(() => { - const id = matchId() - if (!id) return undefined - router.stores.matchesId.state - return router.stores.activeMatchStoresById.get(id)?.routeId + const routeId = Solid.createMemo(() => (matchId() ? rootRouteId : undefined)) + const match = Solid.createMemo(() => { + const currentRouteId = routeId() + return currentRouteId + ? router.stores.getMatchStoreByRouteId(currentRouteId).state + : undefined + }) + const hasPendingMatch = Solid.createMemo(() => { + const currentRouteId = routeId() + return currentRouteId + ? Boolean(router.stores.pendingRouteIds.state[currentRouteId]) + : false }) const resetKey = Solid.createMemo(() => router.stores.loadedAt.state) + const nearestMatch = { + matchId, + routeId, + match, + hasPending: hasPendingMatch, + } const matchComponent = () => { return ( @@ -85,31 +98,29 @@ function MatchesInner() { } return ( - - - {router.options.disableGlobalCatchBoundary ? ( - matchComponent() - ) : ( - resetKey()} - errorComponent={ErrorComponent} - onCatch={ - process.env.NODE_ENV !== 'production' - ? (error) => { - warning( - false, - `The following error wasn't caught by any route! At the very least, consider setting an 'errorComponent' in your RootRoute!`, - ) - warning(false, error.message || error.toString()) - } - : undefined - } - > - {matchComponent()} - - )} - - + + {router.options.disableGlobalCatchBoundary ? ( + matchComponent() + ) : ( + resetKey()} + errorComponent={ErrorComponent} + onCatch={ + process.env.NODE_ENV !== 'production' + ? (error) => { + warning( + false, + `The following error wasn't caught by any route! At the very least, consider setting an 'errorComponent' in your RootRoute!`, + ) + warning(false, error.message || error.toString()) + } + : undefined + } + > + {matchComponent()} + + )} + ) } @@ -238,7 +249,7 @@ export function useParentMatches< >( opts?: UseMatchesBaseOptions, ): Solid.Accessor> { - const contextMatchId = Solid.useContext(matchContext) + const contextMatchId = Solid.useContext(nearestMatchContext).matchId return useMatches({ select: (matches: Array>) => { @@ -257,7 +268,7 @@ export function useChildMatches< >( opts?: UseMatchesBaseOptions, ): Solid.Accessor> { - const contextMatchId = Solid.useContext(matchContext) + const contextMatchId = Solid.useContext(nearestMatchContext).matchId return useMatches({ select: (matches: Array>) => { diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 2fb4cff6b43..5fe00491d84 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -51,8 +51,8 @@ export function useLinkProps< const [local, rest] = Solid.splitProps( Solid.mergeProps( { - activeProps: () => ({ class: 'active' }), - inactiveProps: () => ({}), + activeProps: STATIC_ACTIVE_PROPS_GET, + inactiveProps: STATIC_INACTIVE_PROPS_GET, }, options, ), @@ -127,20 +127,14 @@ export function useLinkProps< () => router.stores.location.state.searchStr, ) - const from = options.from - - const _options = () => { - return { - ...options, - from, - } - } + const _options = () => options const next = Solid.createMemo(() => { // rebuild location when search changes currentSearch() + const options = _options() as any // untrack because router-core will also access stores, which are signals in solid - return Solid.untrack(() => router.buildLocation(_options() as any)) + return Solid.untrack(() => router.buildLocation(options)) }) const hrefOption = Solid.createMemo(() => { @@ -176,11 +170,9 @@ export function useLinkProps< return _href.href } const to = _options().to - const isSafeInternal = - typeof to === 'string' && - to.charCodeAt(0) === 47 && // '/' - to.charCodeAt(1) !== 47 // but not '//' - if (isSafeInternal) return undefined + const safeInternal = isSafeInternal(to) + if (safeInternal) return undefined + if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined try { new URL(to as any) // Block dangerous protocols like javascript:, blob:, data: @@ -206,47 +198,49 @@ export function useLinkProps< const isActive = Solid.createMemo(() => { if (externalLink()) return false - if (local.activeOptions?.exact) { + const activeOptions = local.activeOptions + const current = currentLocation() + const nextLocation = next() + + if (activeOptions?.exact) { const testExact = exactPathTest( - currentLocation().pathname, - next().pathname, + current.pathname, + nextLocation.pathname, router.basepath, ) if (!testExact) { return false } } else { - const currentPathSplit = removeTrailingSlash( - currentLocation().pathname, - router.basepath, - ).split('/') - const nextPathSplit = removeTrailingSlash( - next()?.pathname, + const currentPath = removeTrailingSlash(current.pathname, router.basepath) + const nextPath = removeTrailingSlash( + nextLocation.pathname, router.basepath, - )?.split('/') - - const pathIsFuzzyEqual = nextPathSplit?.every( - (d, i) => d === currentPathSplit[i], ) + + const pathIsFuzzyEqual = + currentPath.startsWith(nextPath) && + (currentPath.length === nextPath.length || + currentPath[nextPath.length] === '/') if (!pathIsFuzzyEqual) { return false } } - if (local.activeOptions?.includeSearch ?? true) { - const searchTest = deepEqual(currentLocation().search, next().search, { - partial: !local.activeOptions?.exact, - ignoreUndefined: !local.activeOptions?.explicitUndefined, + if (activeOptions?.includeSearch ?? true) { + const searchTest = deepEqual(current.search, nextLocation.search, { + partial: !activeOptions?.exact, + ignoreUndefined: !activeOptions?.explicitUndefined, }) if (!searchTest) { return false } } - if (local.activeOptions?.includeHash) { + if (activeOptions?.includeHash) { const currentHash = - shouldHydrateHash && !hasHydrated() ? '' : currentLocation().hash - return currentHash === next().hash + shouldHydrateHash && !hasHydrated() ? '' : current.hash + return currentHash === nextLocation.hash } return true }) @@ -384,100 +378,139 @@ export function useLinkProps< } } - /** Call a JSX.EventHandlerUnion with the event. */ - function callHandler( - event: TEvent & { currentTarget: T; target: Element }, - handler: Solid.JSX.EventHandlerUnion | undefined, - ) { - if (handler) { - if (typeof handler === 'function') { - handler(event) - } else { - handler[0](handler[1], event) - } - } + const simpleStyling = Solid.createMemo( + () => + local.activeProps === STATIC_ACTIVE_PROPS_GET && + local.inactiveProps === STATIC_INACTIVE_PROPS_GET && + local.class === undefined && + local.style === undefined, + ) - return event.defaultPrevented + const onClick = createComposedHandler(() => local.onClick, handleClick) + const onBlur = createComposedHandler(() => local.onBlur, handleLeave) + const onFocus = createComposedHandler( + () => local.onFocus, + enqueueIntentPreload, + ) + const onMouseEnter = createComposedHandler( + () => local.onMouseEnter, + enqueueIntentPreload, + ) + const onMouseOver = createComposedHandler( + () => local.onMouseOver, + enqueueIntentPreload, + ) + const onMouseLeave = createComposedHandler( + () => local.onMouseLeave, + handleLeave, + ) + const onMouseOut = createComposedHandler(() => local.onMouseOut, handleLeave) + const onTouchStart = createComposedHandler( + () => local.onTouchStart, + handleTouchStart, + ) + + type ResolvedLinkStateProps = Omit, 'style'> & { + style?: Solid.JSX.CSSProperties } - function composeEventHandlers( - handlers: Array | undefined>, - ) { - return (event: any) => { - for (const handler of handlers) { - callHandler(event, handler) + const resolvedProps = Solid.createMemo(() => { + const active = isActive() + + const base = { + href: hrefOption()?.href, + ref: mergeRefs(setRef, _options().ref), + onClick, + onBlur, + onFocus, + onMouseEnter, + onMouseOver, + onMouseLeave, + onMouseOut, + onTouchStart, + disabled: !!local.disabled, + target: local.target, + ...(local.disabled && STATIC_DISABLED_PROPS), + ...(isTransitioning() && STATIC_TRANSITIONING_ATTRIBUTES), + } + + if (simpleStyling()) { + return { + ...base, + ...(active && STATIC_DEFAULT_ACTIVE_ATTRIBUTES), } } - } - // Get the active props - const resolvedActiveProps: () => Omit, 'style'> & { - style?: Solid.JSX.CSSProperties - } = () => - isActive() ? (functionalUpdate(local.activeProps as any, {}) ?? {}) : {} - - // Get the inactive props - const resolvedInactiveProps: () => Omit< - Solid.ComponentProps<'a'>, - 'style' - > & { style?: Solid.JSX.CSSProperties } = () => - isActive() ? {} : functionalUpdate(local.inactiveProps, {}) - - const resolvedClassName = () => - [local.class, resolvedActiveProps().class, resolvedInactiveProps().class] + const activeProps: ResolvedLinkStateProps = active + ? (functionalUpdate(local.activeProps as any, {}) ?? EMPTY_OBJECT) + : EMPTY_OBJECT + const inactiveProps: ResolvedLinkStateProps = active + ? EMPTY_OBJECT + : functionalUpdate(local.inactiveProps, {}) + const style = { + ...local.style, + ...activeProps.style, + ...inactiveProps.style, + } + const className = [local.class, activeProps.class, inactiveProps.class] .filter(Boolean) .join(' ') - const resolvedStyle = () => ({ - ...local.style, - ...resolvedActiveProps().style, - ...resolvedInactiveProps().style, + return { + ...activeProps, + ...inactiveProps, + ...base, + ...(Object.keys(style).length ? { style } : undefined), + ...(className ? { class: className } : undefined), + ...(active && STATIC_ACTIVE_ATTRIBUTES), + } as ResolvedLinkStateProps }) - return Solid.mergeProps( - propsSafeToSpread, - resolvedActiveProps, - resolvedInactiveProps, - () => { - return { - href: hrefOption()?.href, - ref: mergeRefs(setRef, _options().ref), - onClick: composeEventHandlers([local.onClick, handleClick]), - onBlur: composeEventHandlers([local.onBlur, handleLeave]), - onFocus: composeEventHandlers([local.onFocus, enqueueIntentPreload]), - onMouseEnter: composeEventHandlers([ - local.onMouseEnter, - enqueueIntentPreload, - ]), - onMouseOver: composeEventHandlers([ - local.onMouseOver, - enqueueIntentPreload, - ]), - onMouseLeave: composeEventHandlers([local.onMouseLeave, handleLeave]), - onMouseOut: composeEventHandlers([local.onMouseOut, handleLeave]), - onTouchStart: composeEventHandlers([ - local.onTouchStart, - handleTouchStart, - ]), - disabled: !!local.disabled, - target: local.target, - ...(() => { - const s = resolvedStyle() - return Object.keys(s).length ? { style: s } : {} - })(), - ...(() => { - const c = resolvedClassName() - return c ? { class: c } : {} - })(), - ...(local.disabled && { - role: 'link', - 'aria-disabled': true, - }), - ...(isActive() && { 'data-status': 'active', 'aria-current': 'page' }), - ...(isTransitioning() && { 'data-transitioning': 'transitioning' }), - } - }, - ) as any + return Solid.mergeProps(propsSafeToSpread, resolvedProps) as any +} + +const STATIC_ACTIVE_PROPS = { class: 'active' } +const STATIC_ACTIVE_PROPS_GET = () => STATIC_ACTIVE_PROPS +const EMPTY_OBJECT = {} +const STATIC_INACTIVE_PROPS_GET = () => EMPTY_OBJECT +const STATIC_DEFAULT_ACTIVE_ATTRIBUTES = { + class: 'active', + 'data-status': 'active', + 'aria-current': 'page', +} +const STATIC_DISABLED_PROPS = { + role: 'link', + 'aria-disabled': true, +} +const STATIC_ACTIVE_ATTRIBUTES = { + 'data-status': 'active', + 'aria-current': 'page', +} +const STATIC_TRANSITIONING_ATTRIBUTES = { + 'data-transitioning': 'transitioning', +} + +/** Call a JSX.EventHandlerUnion with the event. */ +function callHandler( + event: TEvent & { currentTarget: T; target: Element }, + handler: Solid.JSX.EventHandlerUnion, +) { + if (typeof handler === 'function') { + handler(event) + } else { + handler[0](handler[1], event) + } + return event.defaultPrevented +} + +function createComposedHandler( + getHandler: () => Solid.JSX.EventHandlerUnion | undefined, + fallback: (event: TEvent) => void, +) { + return (event: TEvent & { currentTarget: T; target: Element }) => { + const handler = getHandler() + if (!handler || !callHandler(event, handler)) fallback(event) + } } export type UseLinkPropsOptions< @@ -636,8 +669,12 @@ export const Link: LinkComponent<'a'> = (props) => { ) } + if (!local._asChild) { + return {children()} + } + return ( - + {children()} ) @@ -647,6 +684,13 @@ function isCtrlEvent(e: MouseEvent) { return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) } +function isSafeInternal(to: unknown) { + if (typeof to !== 'string') return false + const zero = to.charCodeAt(0) + if (zero === 47) return to.charCodeAt(1) !== 47 // '/' but not '//' + return zero === 46 // '.', '..', './', '../' +} + export type LinkOptionsFnOptions< TOptions, TComp, diff --git a/packages/solid-router/src/matchContext.tsx b/packages/solid-router/src/matchContext.tsx index 94029445248..f6efc37c6b4 100644 --- a/packages/solid-router/src/matchContext.tsx +++ b/packages/solid-router/src/matchContext.tsx @@ -1,13 +1,19 @@ import * as Solid from 'solid-js' +import type { AnyRouteMatch } from '@tanstack/router-core' -export const matchContext = Solid.createContext< - Solid.Accessor ->(() => undefined) +export type NearestMatchContextValue = { + matchId: Solid.Accessor + routeId: Solid.Accessor + match: Solid.Accessor + hasPending: Solid.Accessor +} -export const routeIdContext = Solid.createContext< - Solid.Accessor ->(() => undefined) +const defaultNearestMatchContext: NearestMatchContextValue = { + matchId: () => undefined, + routeId: () => undefined, + match: () => undefined, + hasPending: () => false, +} -export const pendingMatchContext = Solid.createContext>( - () => false, -) +export const nearestMatchContext = + Solid.createContext(defaultNearestMatchContext) diff --git a/packages/solid-router/src/useLocation.tsx b/packages/solid-router/src/useLocation.tsx index f9f3d7f50c5..a156c73933b 100644 --- a/packages/solid-router/src/useLocation.tsx +++ b/packages/solid-router/src/useLocation.tsx @@ -26,11 +26,17 @@ export function useLocation< opts?: UseLocationBaseOptions, ): Accessor> { const router = useRouter() + + if (!opts?.select) { + return (() => router.stores.location.state) as Accessor< + UseLocationResult + > + } + + const select = opts.select + return Solid.createMemo( - () => { - const state = router.stores.location.state - return opts?.select ? opts.select(state) : state - }, + () => select(router.stores.location.state), undefined, { equals: shallow }, ) as Accessor> diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index 4724e2c4fd6..609bbdbdfe4 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -1,8 +1,8 @@ import * as Solid from 'solid-js' import invariant from 'tiny-invariant' -import { pendingMatchContext, routeIdContext } from './matchContext' -import { useRouter } from './useRouter' +import { nearestMatchContext } from './matchContext' import { shallow } from './store' +import { useRouter } from './useRouter' import type { AnyRouter, MakeRouteMatch, @@ -13,6 +13,8 @@ import type { ThrowOrOptional, } from '@tanstack/router-core' +type MatchPick = 'search' | 'params' | '_strictParams' + export interface UseMatchBaseOptions< TRouter extends AnyRouter, TFrom, @@ -24,6 +26,8 @@ export interface UseMatchBaseOptions< match: MakeRouteMatch, ) => TSelected shouldThrow?: TThrow + /** @internal */ + __pick?: MatchPick } export type UseMatchRoute = < @@ -71,46 +75,36 @@ export function useMatch< ThrowOrOptional, TThrow> > { const router = useRouter() - const nearestRouteId: Solid.Accessor = opts.from - ? () => undefined - : Solid.useContext(routeIdContext) - const hasPendingNearestMatch: Solid.Accessor = opts.from - ? () => false - : Solid.useContext(pendingMatchContext) + const nearestMatch = opts.from + ? undefined + : Solid.useContext(nearestMatchContext) const match = Solid.createMemo(() => { - const routeId = opts.from ?? nearestRouteId() - return routeId - ? router.stores.getMatchStoreByRouteId(routeId).state - : undefined - }) + if (opts.from) { + return router.stores.getMatchStoreByRouteId(opts.from).state + } - const matchState = Solid.createMemo(() => { - const selectedMatch = match() + return nearestMatch?.match() + }) - if (selectedMatch === undefined) { - const hasPendingMatch = opts.from - ? Boolean(router.stores.pendingRouteIds.state[opts.from!]) - : hasPendingNearestMatch() + const shouldThrow = Solid.createMemo(() => { + if (match() !== undefined) { + return false + } - const error = - !hasPendingMatch && - !router.stores.isTransitioning.state && - (opts.shouldThrow ?? true) + const hasPendingMatch = opts.from + ? Boolean(router.stores.pendingRouteIds.state[opts.from!]) + : (nearestMatch?.hasPending() ?? false) - return { match: undefined, error } - } - const result: any = opts.select - ? opts.select(selectedMatch as any) - : selectedMatch - return { match: result } + return ( + !hasPendingMatch && + !router.stores.isTransitioning.state && + (opts.shouldThrow ?? true) + ) }) - // Use createEffect to throw errors outside the reactive selector context - // This allows error boundaries to properly catch the errors Solid.createEffect(() => { - const state = matchState() - if (state.error) { + if (shouldThrow()) { invariant( false, `Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, @@ -118,8 +112,21 @@ export function useMatch< } }) - // Return an accessor that extracts just the match value - return Solid.createMemo(() => matchState().match, undefined, { - equals: shallow, - }) as any + return Solid.createMemo( + () => { + const selectedMatch = match() + + if (selectedMatch === undefined) { + return undefined + } + + if (opts.__pick) { + return selectedMatch[opts.__pick] + } + + return opts.select ? opts.select(selectedMatch as any) : selectedMatch + }, + undefined, + { equals: opts.__pick ? Object.is : shallow }, + ) as any } diff --git a/packages/solid-router/src/useParams.tsx b/packages/solid-router/src/useParams.tsx index 1788f69fe94..67158ea3c23 100644 --- a/packages/solid-router/src/useParams.tsx +++ b/packages/solid-router/src/useParams.tsx @@ -1,4 +1,6 @@ +import * as Solid from 'solid-js' import { useMatch } from './useMatch' +import { shallow } from './store' import type { Accessor } from 'solid-js' import type { AnyRouter, @@ -60,14 +62,29 @@ export function useParams< ): Accessor< ThrowOrOptional, TThrow> > { - return useMatch({ + const params = useMatch({ from: opts.from!, - shouldThrow: opts.shouldThrow, strict: opts.strict, - select: (match) => { - const params = opts.strict === false ? match.params : match._strictParams + shouldThrow: opts.shouldThrow, + __pick: opts.strict === false ? 'params' : '_strictParams', + }) as Accessor + + if (!opts.select) { + return params as Accessor + } - return opts.select ? opts.select(params) : params + const select = opts.select + + return Solid.createMemo( + () => { + const selectedParams = params() + if (selectedParams === undefined) { + return undefined + } + + return select(selectedParams) }, - }) as Accessor + undefined, + { equals: shallow }, + ) as Accessor } diff --git a/packages/solid-router/src/useRouterState.tsx b/packages/solid-router/src/useRouterState.tsx index 615024a53d1..96a119f679c 100644 --- a/packages/solid-router/src/useRouterState.tsx +++ b/packages/solid-router/src/useRouterState.tsx @@ -69,17 +69,22 @@ export function useRouterState< > } + if (!opts?.select) { + return (() => router.stores.__store.state) as Accessor< + UseRouterStateResult + > + } + + const select = opts.select + return Solid.createMemo( - () => { - const state = router.stores.__store.state - if (opts?.select) return opts.select(state) - return state - }, + () => select(router.stores.__store.state), + undefined, { // Use deep equality to match behavior of solid-store 0.7.0 which used // reconcile(). This ensures updates work correctly when selectors // return new object references but with the same values. - equal: deepEqual, + equals: deepEqual, }, ) as Accessor> } diff --git a/packages/solid-router/src/useSearch.tsx b/packages/solid-router/src/useSearch.tsx index f795646cc25..9ba4911c77a 100644 --- a/packages/solid-router/src/useSearch.tsx +++ b/packages/solid-router/src/useSearch.tsx @@ -1,4 +1,6 @@ +import * as Solid from 'solid-js' import { useMatch } from './useMatch' +import { shallow } from './store' import type { Accessor } from 'solid-js' import type { AnyRouter, @@ -60,12 +62,29 @@ export function useSearch< ): Accessor< ThrowOrOptional, TThrow> > { - return useMatch({ + const search = useMatch({ from: opts.from!, strict: opts.strict, shouldThrow: opts.shouldThrow, - select: (match: any) => { - return opts.select ? opts.select(match.search) : match.search + __pick: 'search', + }) as Accessor + + if (!opts.select) { + return search + } + + const select = opts.select + + return Solid.createMemo( + () => { + const selectedSearch = search() + if (selectedSearch === undefined) { + return undefined + } + + return select(selectedSearch) }, - }) as any + undefined, + { equals: shallow }, + ) as any }