diff --git a/packages/router-core/eslint.config.js b/packages/router-core/eslint.config.js new file mode 100644 index 00000000000..8ce6ad05fcd --- /dev/null +++ b/packages/router-core/eslint.config.js @@ -0,0 +1,5 @@ +// @ts-check + +import rootConfig from '../../eslint.config.js' + +export default [...rootConfig] diff --git a/packages/router-core/package.json b/packages/router-core/package.json new file mode 100644 index 00000000000..ecc0251d0d7 --- /dev/null +++ b/packages/router-core/package.json @@ -0,0 +1,65 @@ +{ + "name": "@tanstack/router-core", + "version": "1.90.0", + "description": "Modern and scalable routing for React applications", + "author": "Tanner Linsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/router.git", + "directory": "packages/history" + }, + "homepage": "https://tanstack.com/router", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "history", + "typescript" + ], + "scripts": { + "clean": "rimraf ./dist && rimraf ./coverage", + "test:eslint": "eslint ./src", + "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", + "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js", + "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js", + "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js", + "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js", + "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js", + "test:types:ts57": "tsc", + "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .", + "test:unit": "vitest", + "test:unit:dev": "pnpm run test:unit --watch", + "build": "vite build" + }, + "type": "module", + "types": "dist/esm/index.d.ts", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "engines": { + "node": ">=12" + }, + "dependencies": { + "@tanstack/history": "workspace:*", + "@tanstack/store": "^0.6.0" + } +} diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts new file mode 100644 index 00000000000..6ff0f7390fc --- /dev/null +++ b/packages/router-core/src/Matches.ts @@ -0,0 +1,94 @@ +import type { Constrain } from './utils' + +export type AnyMatchAndValue = { match: any; value: any } + +export type FindValueByIndex< + TKey, + TValue extends ReadonlyArray, +> = TKey extends `${infer TIndex extends number}` ? TValue[TIndex] : never + +export type FindValueByKey = + TValue extends ReadonlyArray + ? FindValueByIndex + : TValue[TKey & keyof TValue] + +export type CreateMatchAndValue = TValue extends any + ? { + match: TMatch + value: TValue + } + : never + +export type NextMatchAndValue< + TKey, + TMatchAndValue extends AnyMatchAndValue, +> = TMatchAndValue extends any + ? CreateMatchAndValue< + TMatchAndValue['match'], + FindValueByKey + > + : never + +export type IsMatchKeyOf = + TValue extends ReadonlyArray + ? number extends TValue['length'] + ? `${number}` + : keyof TValue & `${number}` + : TValue extends object + ? keyof TValue & string + : never + +export type IsMatchPath< + TParentPath extends string, + TMatchAndValue extends AnyMatchAndValue, +> = `${TParentPath}${IsMatchKeyOf}` + +export type IsMatchResult< + TKey, + TMatchAndValue extends AnyMatchAndValue, +> = TMatchAndValue extends any + ? TKey extends keyof TMatchAndValue['value'] + ? TMatchAndValue['match'] + : never + : never + +export type IsMatchParse< + TPath, + TMatchAndValue extends AnyMatchAndValue, + TParentPath extends string = '', +> = TPath extends `${string}.${string}` + ? TPath extends `${infer TFirst}.${infer TRest}` + ? IsMatchParse< + TRest, + NextMatchAndValue, + `${TParentPath}${TFirst}.` + > + : never + : { + path: IsMatchPath + result: IsMatchResult + } + +export type IsMatch = IsMatchParse< + TPath, + TMatch extends any ? { match: TMatch; value: TMatch } : never +> + +/** + * Narrows matches based on a path + * @experimental + */ +export const isMatch = ( + match: TMatch, + path: Constrain['path']>, +): match is IsMatch['result'] => { + const parts = (path as string).split('.') + let part + let value: any = match + + while ((part = parts.shift()) != null && value != null) { + value = value[part] + } + + return value != null +} diff --git a/packages/router-core/src/RouterProvider.ts b/packages/router-core/src/RouterProvider.ts new file mode 100644 index 00000000000..4e7e3d9f821 --- /dev/null +++ b/packages/router-core/src/RouterProvider.ts @@ -0,0 +1,20 @@ +import type { ViewTransitionOptions } from './router' + +export interface MatchLocation { + to?: string | number | null + fuzzy?: boolean + caseSensitive?: boolean + from?: string +} + +export interface CommitLocationOptions { + replace?: boolean + resetScroll?: boolean + hashScrollIntoView?: boolean | ScrollIntoViewOptions + viewTransition?: boolean | ViewTransitionOptions + /** + * @deprecated All navigations use React transitions under the hood now + **/ + startTransition?: boolean + ignoreBlocker?: boolean +} diff --git a/packages/router-core/src/defer.ts b/packages/router-core/src/defer.ts new file mode 100644 index 00000000000..3e26019db56 --- /dev/null +++ b/packages/router-core/src/defer.ts @@ -0,0 +1,52 @@ +import { defaultSerializeError } from './router' + +export const TSR_DEFERRED_PROMISE = Symbol.for('TSR_DEFERRED_PROMISE') + +export type DeferredPromiseState = { + [TSR_DEFERRED_PROMISE]: + | { + status: 'pending' + data?: T + error?: unknown + } + | { + status: 'success' + data: T + } + | { + status: 'error' + data?: T + error: unknown + } +} + +export type DeferredPromise = Promise & DeferredPromiseState + +export function defer( + _promise: Promise, + options?: { + serializeError?: typeof defaultSerializeError + }, +) { + const promise = _promise as DeferredPromise + // this is already deferred promise + if ((promise as any)[TSR_DEFERRED_PROMISE]) { + return promise + } + promise[TSR_DEFERRED_PROMISE] = { status: 'pending' } + + promise + .then((data) => { + promise[TSR_DEFERRED_PROMISE].status = 'success' + promise[TSR_DEFERRED_PROMISE].data = data + }) + .catch((error) => { + promise[TSR_DEFERRED_PROMISE].status = 'error' + ;(promise[TSR_DEFERRED_PROMISE] as any).error = { + data: (options?.serializeError ?? defaultSerializeError)(error), + __isServerError: true, + } + }) + + return promise +} diff --git a/packages/router-core/src/history.ts b/packages/router-core/src/history.ts new file mode 100644 index 00000000000..2271af692cd --- /dev/null +++ b/packages/router-core/src/history.ts @@ -0,0 +1,9 @@ +import type { HistoryLocation } from '@tanstack/history' + +declare module '@tanstack/history' { + interface HistoryState { + __tempLocation?: HistoryLocation + __tempKey?: string + __hashScrollIntoViewOptions?: boolean | ScrollIntoViewOptions + } +} diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts new file mode 100644 index 00000000000..18b62b301d2 --- /dev/null +++ b/packages/router-core/src/index.ts @@ -0,0 +1,20 @@ +export * from './defer' +export * from './history' +export * from './isServerSideError' +export * from './link' +export * from './location' +export * from './manifest' +export * from './Matches' +export * from './path' +export * from './qss' +export * from './root' +export * from './route' +export * from './routeInfo' +export * from './router' +export * from './RouterProvider' +export * from './searchMiddleware' +export * from './searchParams' +export * from './structuralSharing' +export * from './transformer' +export * from './utils' +export * from './validators' diff --git a/packages/router-core/src/isServerSideError.ts b/packages/router-core/src/isServerSideError.ts new file mode 100644 index 00000000000..9f1c539cd9b --- /dev/null +++ b/packages/router-core/src/isServerSideError.ts @@ -0,0 +1,23 @@ +export function isServerSideError(error: unknown): error is { + __isServerError: true + data: Record +} { + if (!(typeof error === 'object' && error && 'data' in error)) return false + if (!('__isServerError' in error && error.__isServerError)) return false + if (!(typeof error.data === 'object' && error.data)) return false + + return error.__isServerError === true +} + +export function defaultDeserializeError(serializedData: Record) { + if ('name' in serializedData && 'message' in serializedData) { + const error = new Error(serializedData.message) + error.name = serializedData.name + if (process.env.NODE_ENV === 'development') { + error.stack = serializedData.stack + } + return error + } + + return serializedData.data +} diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts new file mode 100644 index 00000000000..87805078eec --- /dev/null +++ b/packages/router-core/src/link.ts @@ -0,0 +1,132 @@ +export type IsRequiredParams = + Record extends TParams ? never : true + +export type ParsePathParams = T & + `${string}$${string}` extends never + ? TAcc + : T extends `${string}$${infer TPossiblyParam}` + ? TPossiblyParam extends '' + ? TAcc + : TPossiblyParam & `${string}/${string}` extends never + ? TPossiblyParam | TAcc + : TPossiblyParam extends `${infer TParam}/${infer TRest}` + ? ParsePathParams + : never + : TAcc + +export type AddTrailingSlash = T & `${string}/` extends never + ? `${T & string}/` + : T + +export type RemoveTrailingSlashes = T & `${string}/` extends never + ? T + : T extends `${infer R}/` + ? R + : T + +export type AddLeadingSlash = T & `/${string}` extends never + ? `/${T & string}` + : T + +export type RemoveLeadingSlashes = T & `/${string}` extends never + ? T + : T extends `/${infer R}` + ? R + : T + +export interface ActiveOptions { + exact?: boolean + includeHash?: boolean + includeSearch?: boolean + explicitUndefined?: boolean +} + +export interface LinkOptionsProps { + /** + * The standard anchor tag target attribute + */ + target?: HTMLAnchorElement['target'] + /** + * Configurable options to determine if the link should be considered active or not + * @default {exact:true,includeHash:true} + */ + activeOptions?: ActiveOptions + /** + * The preloading strategy for this link + * - `false` - No preloading + * - `'intent'` - Preload the linked route on hover and cache it for this many milliseconds in hopes that the user will eventually navigate there. + * - `'viewport'` - Preload the linked route when it enters the viewport + */ + preload?: false | 'intent' | 'viewport' | 'render' + /** + * When a preload strategy is set, this delays the preload by this many milliseconds. + * If the user exits the link before this delay, the preload will be cancelled. + */ + preloadDelay?: number + /** + * Control whether the link should be disabled or not + * If set to `true`, the link will be rendered without an `href` attribute + * @default false + */ + disabled?: boolean +} + +type JoinPath = TRight extends '' + ? TLeft + : TLeft extends '' + ? TRight + : `${RemoveTrailingSlashes}/${RemoveLeadingSlashes}` + +export type ResolveCurrentPath = TTo extends '.' + ? TFrom + : TTo extends './' + ? AddTrailingSlash + : TTo & `./${string}` extends never + ? never + : TTo extends `./${infer TRest}` + ? ResolveRelativePath + : never + +type RemoveLastSegment< + T extends string, + TAcc extends string = '', +> = T extends `${infer TSegment}/${infer TRest}` + ? TRest & `${string}/${string}` extends never + ? `${TAcc}${TSegment}` + : RemoveLastSegment + : TAcc + +export type ResolveParentPath = TTo extends '../' + ? RemoveLastSegment + : TTo & `..${string}` extends never + ? never + : TTo extends `..${infer ToRest}` + ? ResolveRelativePath< + RemoveLastSegment, + RemoveLeadingSlashes + > + : never + +export type ResolveRelativePath = string extends TFrom + ? TTo + : string extends TTo + ? TFrom + : undefined extends TTo + ? TFrom + : TFrom extends string + ? TTo extends string + ? TTo & `..${string}` extends never + ? TTo & `.${string}` extends never + ? TTo & `/${string}` extends never + ? AddLeadingSlash> + : TTo + : ResolveCurrentPath + : ResolveParentPath + : never + : never + +export type LinkCurrentTargetElement = { + preloadTimeout?: null | ReturnType +} + +export const preloadWarning = 'Error preloading route! ☝️' diff --git a/packages/router-core/src/location.ts b/packages/router-core/src/location.ts new file mode 100644 index 00000000000..e4a24c6a173 --- /dev/null +++ b/packages/router-core/src/location.ts @@ -0,0 +1,13 @@ +import type { HistoryState } from '@tanstack/history' +import type { AnySchema } from './validators' + +export interface ParsedLocation { + href: string + pathname: string + search: TSearchObj + searchStr: string + state: HistoryState + hash: string + maskedLocation?: ParsedLocation + unmaskOnReload?: boolean +} diff --git a/packages/router-core/src/manifest.ts b/packages/router-core/src/manifest.ts new file mode 100644 index 00000000000..e789e00af47 --- /dev/null +++ b/packages/router-core/src/manifest.ts @@ -0,0 +1,32 @@ +export type Manifest = { + routes: Record< + string, + { + filePath?: string + preloads?: Array + assets?: Array + } + > +} + +export type RouterManagedTag = + | { + tag: 'title' + attrs?: Record + children: string + } + | { + tag: 'meta' | 'link' + attrs?: Record + children?: never + } + | { + tag: 'script' + attrs?: Record + children?: string + } + | { + tag: 'style' + attrs?: Record + children?: string + } diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts new file mode 100644 index 00000000000..cf4b99b9c6a --- /dev/null +++ b/packages/router-core/src/path.ts @@ -0,0 +1,427 @@ +import { last } from './utils' +import type { MatchLocation } from './RouterProvider' +import type { AnyPathParams } from './route' + +export interface Segment { + type: 'pathname' | 'param' | 'wildcard' + value: string +} + +export function joinPaths(paths: Array) { + return cleanPath( + paths + .filter((val) => { + return val !== undefined + }) + .join('/'), + ) +} + +export function cleanPath(path: string) { + // remove double slashes + return path.replace(/\/{2,}/g, '/') +} + +export function trimPathLeft(path: string) { + return path === '/' ? path : path.replace(/^\/{1,}/, '') +} + +export function trimPathRight(path: string) { + return path === '/' ? path : path.replace(/\/{1,}$/, '') +} + +export function trimPath(path: string) { + return trimPathRight(trimPathLeft(path)) +} + +export function removeTrailingSlash(value: string, basepath: string): string { + if (value.endsWith('/') && value !== '/' && value !== `${basepath}/`) { + return value.slice(0, -1) + } + return value +} + +// intended to only compare path name +// see the usage in the isActive under useLinkProps +// /sample/path1 = /sample/path1/ +// /sample/path1/some <> /sample/path1 +export function exactPathTest( + pathName1: string, + pathName2: string, + basepath: string, +): boolean { + return ( + removeTrailingSlash(pathName1, basepath) === + removeTrailingSlash(pathName2, basepath) + ) +} + +// When resolving relative paths, we treat all paths as if they are trailing slash +// documents. All trailing slashes are removed after the path is resolved. +// Here are a few examples: +// +// /a/b/c + ./d = /a/b/c/d +// /a/b/c + ../d = /a/b/d +// /a/b/c + ./d/ = /a/b/c/d +// /a/b/c + ../d/ = /a/b/d +// /a/b/c + ./ = /a/b/c +// +// Absolute paths that start with `/` short circuit the resolution process to the root +// path. +// +// Here are some examples: +// +// /a/b/c + /d = /d +// /a/b/c + /d/ = /d +// /a/b/c + / = / +// +// Non-.-prefixed paths are still treated as relative paths, resolved like `./` +// +// Here are some examples: +// +// /a/b/c + d = /a/b/c/d +// /a/b/c + d/ = /a/b/c/d +// /a/b/c + d/e = /a/b/c/d/e +interface ResolvePathOptions { + basepath: string + base: string + to: string + trailingSlash?: 'always' | 'never' | 'preserve' + caseSensitive?: boolean +} + +export function resolvePath({ + basepath, + base, + to, + trailingSlash = 'never', + caseSensitive, +}: ResolvePathOptions) { + base = removeBasepath(basepath, base, caseSensitive) + to = removeBasepath(basepath, to, caseSensitive) + + let baseSegments = parsePathname(base) + const toSegments = parsePathname(to) + + if (baseSegments.length > 1 && last(baseSegments)?.value === '/') { + baseSegments.pop() + } + + toSegments.forEach((toSegment, index) => { + if (toSegment.value === '/') { + if (!index) { + // Leading slash + baseSegments = [toSegment] + } else if (index === toSegments.length - 1) { + // Trailing Slash + baseSegments.push(toSegment) + } else { + // ignore inter-slashes + } + } else if (toSegment.value === '..') { + baseSegments.pop() + } else if (toSegment.value === '.') { + // ignore + } else { + baseSegments.push(toSegment) + } + }) + + if (baseSegments.length > 1) { + if (last(baseSegments)?.value === '/') { + if (trailingSlash === 'never') { + baseSegments.pop() + } + } else if (trailingSlash === 'always') { + baseSegments.push({ type: 'pathname', value: '/' }) + } + } + + const joined = joinPaths([basepath, ...baseSegments.map((d) => d.value)]) + return cleanPath(joined) +} + +export function parsePathname(pathname?: string): Array { + if (!pathname) { + return [] + } + + pathname = cleanPath(pathname) + + const segments: Array = [] + + if (pathname.slice(0, 1) === '/') { + pathname = pathname.substring(1) + segments.push({ + type: 'pathname', + value: '/', + }) + } + + if (!pathname) { + return segments + } + + // Remove empty segments and '.' segments + const split = pathname.split('/').filter(Boolean) + + segments.push( + ...split.map((part): Segment => { + if (part === '$' || part === '*') { + return { + type: 'wildcard', + value: part, + } + } + + if (part.charAt(0) === '$') { + return { + type: 'param', + value: part, + } + } + + return { + type: 'pathname', + value: decodeURI(part), + } + }), + ) + + if (pathname.slice(-1) === '/') { + pathname = pathname.substring(1) + segments.push({ + type: 'pathname', + value: '/', + }) + } + + return segments +} + +interface InterpolatePathOptions { + path?: string + params: Record + leaveWildcards?: boolean + leaveParams?: boolean + // Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params + decodeCharMap?: Map +} + +export function interpolatePath({ + path, + params, + leaveWildcards, + leaveParams, + decodeCharMap, +}: InterpolatePathOptions) { + const interpolatedPathSegments = parsePathname(path) + const encodedParams: any = {} + + for (const [key, value] of Object.entries(params)) { + const isValueString = typeof value === 'string' + + if (['*', '_splat'].includes(key)) { + // the splat/catch-all routes shouldn't have the '/' encoded out + encodedParams[key] = isValueString ? encodeURI(value) : value + } else { + encodedParams[key] = isValueString + ? encodePathParam(value, decodeCharMap) + : value + } + } + + return joinPaths( + interpolatedPathSegments.map((segment) => { + if (segment.type === 'wildcard') { + const value = encodedParams._splat + if (leaveWildcards) return `${segment.value}${value ?? ''}` + return value + } + + if (segment.type === 'param') { + if (leaveParams) { + const value = encodedParams[segment.value] + return `${segment.value}${value ?? ''}` + } + return encodedParams![segment.value.substring(1)] ?? 'undefined' + } + + return segment.value + }), + ) +} + +function encodePathParam(value: string, decodeCharMap?: Map) { + let encoded = encodeURIComponent(value) + if (decodeCharMap) { + for (const [encodedChar, char] of decodeCharMap) { + encoded = encoded.replaceAll(encodedChar, char) + } + } + return encoded +} + +export function matchPathname( + basepath: string, + currentPathname: string, + matchLocation: Pick, +): AnyPathParams | undefined { + const pathParams = matchByPath(basepath, currentPathname, matchLocation) + // const searchMatched = matchBySearch(location.search, matchLocation) + + if (matchLocation.to && !pathParams) { + return + } + + return pathParams ?? {} +} + +export function removeBasepath( + basepath: string, + pathname: string, + caseSensitive: boolean = false, +) { + // normalize basepath and pathname for case-insensitive comparison if needed + const normalizedBasepath = caseSensitive ? basepath : basepath.toLowerCase() + const normalizedPathname = caseSensitive ? pathname : pathname.toLowerCase() + + switch (true) { + // default behaviour is to serve app from the root - pathname + // left untouched + case normalizedBasepath === '/': + return pathname + + // shortcut for removing the basepath if it matches the pathname + case normalizedPathname === normalizedBasepath: + return '' + + // in case pathname is shorter than basepath - there is + // nothing to remove + case pathname.length < basepath.length: + return pathname + + // avoid matching partial segments - strict equality handled + // earlier, otherwise, basepath separated from pathname with + // separator, therefore lack of separator means partial + // segment match (`/app` should not match `/application`) + case normalizedPathname[normalizedBasepath.length] !== '/': + return pathname + + // remove the basepath from the pathname if it starts with it + case normalizedPathname.startsWith(normalizedBasepath): + return pathname.slice(basepath.length) + + // otherwise, return the pathname as is + default: + return pathname + } +} + +export function matchByPath( + basepath: string, + from: string, + matchLocation: Pick, +): Record | undefined { + // check basepath first + if (basepath !== '/' && !from.startsWith(basepath)) { + return undefined + } + // Remove the base path from the pathname + from = removeBasepath(basepath, from, matchLocation.caseSensitive) + // Default to to $ (wildcard) + const to = removeBasepath( + basepath, + `${matchLocation.to ?? '$'}`, + matchLocation.caseSensitive, + ) + + // Parse the from and to + const baseSegments = parsePathname(from) + const routeSegments = parsePathname(to) + + if (!from.startsWith('/')) { + baseSegments.unshift({ + type: 'pathname', + value: '/', + }) + } + + if (!to.startsWith('/')) { + routeSegments.unshift({ + type: 'pathname', + value: '/', + }) + } + + const params: Record = {} + + const isMatch = (() => { + for ( + let i = 0; + i < Math.max(baseSegments.length, routeSegments.length); + i++ + ) { + const baseSegment = baseSegments[i] + const routeSegment = routeSegments[i] + + const isLastBaseSegment = i >= baseSegments.length - 1 + const isLastRouteSegment = i >= routeSegments.length - 1 + + if (routeSegment) { + if (routeSegment.type === 'wildcard') { + const _splat = decodeURI( + joinPaths(baseSegments.slice(i).map((d) => d.value)), + ) + // TODO: Deprecate * + params['*'] = _splat + params['_splat'] = _splat + return true + } + + if (routeSegment.type === 'pathname') { + if (routeSegment.value === '/' && !baseSegment?.value) { + return true + } + + if (baseSegment) { + if (matchLocation.caseSensitive) { + if (routeSegment.value !== baseSegment.value) { + return false + } + } else if ( + routeSegment.value.toLowerCase() !== + baseSegment.value.toLowerCase() + ) { + return false + } + } + } + + if (!baseSegment) { + return false + } + + if (routeSegment.type === 'param') { + if (baseSegment.value === '/') { + return false + } + if (baseSegment.value.charAt(0) !== '$') { + params[routeSegment.value.substring(1)] = decodeURIComponent( + baseSegment.value, + ) + } + } + } + + if (!isLastBaseSegment && isLastRouteSegment) { + params['**'] = joinPaths(baseSegments.slice(i + 1).map((d) => d.value)) + return !!matchLocation.fuzzy && routeSegment?.value !== '/' + } + } + + return true + })() + + return isMatch ? params : undefined +} diff --git a/packages/router-core/src/qss.ts b/packages/router-core/src/qss.ts new file mode 100644 index 00000000000..c474cf2da90 --- /dev/null +++ b/packages/router-core/src/qss.ts @@ -0,0 +1,91 @@ +/** + * Program uses a modified version of the `qss` package: + * Copyright (c) Luke Edwards luke.edwards05@gmail.com, MIT License + * https://github.com/lukeed/qss/blob/master/license.md + */ + +/** + * Encodes an object into a query string. + * @param obj - The object to encode into a query string. + * @param [pfx] - An optional prefix to add before the query string. + * @returns The encoded query string. + * @example + * ``` + * // Example input: encode({ token: 'foo', key: 'value' }) + * // Expected output: "token=foo&key=value" + * ``` + */ +export function encode(obj: any, pfx?: string) { + let k, + i, + tmp, + str = '' + + for (k in obj) { + if ((tmp = obj[k]) !== void 0) { + if (Array.isArray(tmp)) { + for (i = 0; i < tmp.length; i++) { + str && (str += '&') + str += encodeURIComponent(k) + '=' + encodeURIComponent(tmp[i]) + } + } else { + str && (str += '&') + str += encodeURIComponent(k) + '=' + encodeURIComponent(tmp) + } + } + } + + return (pfx || '') + str +} + +/** + * Converts a string value to its appropriate type (string, number, boolean). + * @param mix - The string value to convert. + * @returns The converted value. + * @example + * // Example input: toValue("123") + * // Expected output: 123 + */ +function toValue(mix: any) { + if (!mix) return '' + const str = decodeURIComponent(mix) + if (str === 'false') return false + if (str === 'true') return true + return +str * 0 === 0 && +str + '' === str ? +str : str +} + +/** + * Decodes a query string into an object. + * @param str - The query string to decode. + * @param [pfx] - An optional prefix to filter out from the query string. + * @returns The decoded key-value pairs in an object format. + * @example + * // Example input: decode("token=foo&key=value") + * // Expected output: { "token": "foo", "key": "value" } + */ +export function decode(str: any, pfx?: string) { + let tmp, k + const out: any = {}, + arr = (pfx ? str.substr(pfx.length) : str).split('&') + + while ((tmp = arr.shift())) { + const equalIndex = tmp.indexOf('=') + if (equalIndex !== -1) { + k = tmp.slice(0, equalIndex) + k = decodeURIComponent(k) + const value = tmp.slice(equalIndex + 1) + if (out[k] !== void 0) { + // @ts-expect-error + out[k] = [].concat(out[k], toValue(value)) + } else { + out[k] = toValue(value) + } + } else { + k = tmp + k = decodeURIComponent(k) + out[k] = '' + } + } + + return out +} diff --git a/packages/router-core/src/root.ts b/packages/router-core/src/root.ts new file mode 100644 index 00000000000..6aa46e3eb99 --- /dev/null +++ b/packages/router-core/src/root.ts @@ -0,0 +1,2 @@ +export const rootRouteId = '__root__' +export type RootRouteId = typeof rootRouteId diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts new file mode 100644 index 00000000000..217ffe35387 --- /dev/null +++ b/packages/router-core/src/route.ts @@ -0,0 +1,313 @@ +import { ParsePathParams } from './link' +import { RootRouteId } from './root' +import { Assign } from './utils' +import { + AnySchema, + AnyStandardSchemaValidator, + AnyValidatorAdapter, + AnyValidatorObj, + StandardSchemaValidator, + ValidatorAdapter, + ValidatorFn, + ValidatorObj, +} from './validators' + +export type AnyPathParams = {} + +export type SearchSchemaInput = { + __TSearchSchemaInput__: 'TSearchSchemaInput' +} + +export type AnyContext = {} + +export interface RouteContext {} + +export type PreloadableObj = { preload?: () => Promise } + +export type RoutePathOptions = + | { + path: TPath + } + | { + id: TCustomId + } + +export interface StaticDataRouteOption {} + +export type RoutePathOptionsIntersection = { + path: TPath + id: TCustomId +} + +export type SearchFilter = (prev: TInput) => TResult + +export type SearchMiddlewareContext = { + search: TSearchSchema + next: (newSearch: TSearchSchema) => TSearchSchema +} + +export type SearchMiddleware = ( + ctx: SearchMiddlewareContext, +) => TSearchSchema + +export type ParseSplatParams = TPath & + `${string}$` extends never + ? TPath & `${string}$/${string}` extends never + ? never + : '_splat' + : '_splat' + +export interface SplatParams { + _splat?: string +} + +export type ResolveParams = + ParseSplatParams extends never + ? Record, string> + : Record, string> & SplatParams + +export type ParseParamsFn = ( + rawParams: ResolveParams, +) => TParams extends Record, any> + ? TParams + : Record, any> + +export type StringifyParamsFn = ( + params: TParams, +) => ResolveParams + +export type ParamsOptions = { + params?: { + parse?: ParseParamsFn + stringify?: StringifyParamsFn + } + + /** + @deprecated Use params.parse instead + */ + parseParams?: ParseParamsFn + + /** + @deprecated Use params.stringify instead + */ + stringifyParams?: StringifyParamsFn +} + +export interface RequiredStaticDataRouteOption { + staticData: StaticDataRouteOption +} + +export interface OptionalStaticDataRouteOption { + staticData?: StaticDataRouteOption +} + +export type UpdatableStaticRouteOption = {} extends StaticDataRouteOption + ? OptionalStaticDataRouteOption + : RequiredStaticDataRouteOption + +export type MetaDescriptor = + | { charSet: 'utf-8' } + | { title: string } + | { name: string; content: string } + | { property: string; content: string } + | { httpEquiv: string; content: string } + | { 'script:ld+json': LdJsonObject } + | { tagName: 'meta' | 'link'; [name: string]: string } + | Record + +type LdJsonObject = { [Key in string]: LdJsonValue } & { + [Key in string]?: LdJsonValue | undefined +} +type LdJsonArray = Array | ReadonlyArray +type LdJsonPrimitive = string | number | boolean | null +type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray + +export type RouteLinkEntry = {} + +export type SearchValidator = + | ValidatorObj + | ValidatorFn + | ValidatorAdapter + | StandardSchemaValidator + | undefined + +export type AnySearchValidator = SearchValidator + +export type DefaultSearchValidator = SearchValidator< + Record, + AnySchema +> + +export type ResolveId< + TParentRoute, + TCustomId extends string, + TPath extends string, +> = TParentRoute extends { id: infer TParentId extends string } + ? RoutePrefix + : RootRouteId + +export type InferFullSearchSchema = TRoute extends { + types: { + fullSearchSchema: infer TFullSearchSchema + } +} + ? TFullSearchSchema + : {} + +export type InferFullSearchSchemaInput = TRoute extends { + types: { + fullSearchSchemaInput: infer TFullSearchSchemaInput + } +} + ? TFullSearchSchemaInput + : {} + +export type InferAllParams = TRoute extends { + types: { + allParams: infer TAllParams + } +} + ? TAllParams + : {} + +export type InferAllContext = unknown extends TRoute + ? TRoute + : TRoute extends { + types: { + allContext: infer TAllContext + } + } + ? TAllContext + : {} + +export type ResolveSearchSchemaFnInput = + TSearchValidator extends (input: infer TSearchSchemaInput) => any + ? TSearchSchemaInput extends SearchSchemaInput + ? Omit + : ResolveSearchSchemaFn + : AnySchema + +export type ResolveSearchSchemaInput = + TSearchValidator extends AnyStandardSchemaValidator + ? NonNullable['input'] + : TSearchValidator extends AnyValidatorAdapter + ? TSearchValidator['types']['input'] + : TSearchValidator extends AnyValidatorObj + ? ResolveSearchSchemaFnInput + : ResolveSearchSchemaFnInput + +export type ResolveSearchSchemaFn = TSearchValidator extends ( + ...args: any +) => infer TSearchSchema + ? TSearchSchema + : AnySchema + +export type ResolveSearchSchema = + unknown extends TSearchValidator + ? TSearchValidator + : TSearchValidator extends AnyStandardSchemaValidator + ? NonNullable['output'] + : TSearchValidator extends AnyValidatorAdapter + ? TSearchValidator['types']['output'] + : TSearchValidator extends AnyValidatorObj + ? ResolveSearchSchemaFn + : ResolveSearchSchemaFn + +export type LooseReturnType = T extends ( + ...args: Array +) => infer TReturn + ? TReturn + : never + +export type LooseAsyncReturnType = T extends ( + ...args: Array +) => infer TReturn + ? TReturn extends Promise + ? TReturn + : TReturn + : never + +export type ContextReturnType = unknown extends TContextFn + ? TContextFn + : LooseReturnType extends never + ? AnyContext + : LooseReturnType + +export type ContextAsyncReturnType = unknown extends TContextFn + ? TContextFn + : LooseAsyncReturnType extends never + ? AnyContext + : LooseAsyncReturnType + +export type ResolveRouteContext = Assign< + ContextReturnType, + ContextAsyncReturnType +> + +export type ResolveLoaderData = unknown extends TLoaderFn + ? TLoaderFn + : LooseAsyncReturnType extends never + ? {} + : LooseAsyncReturnType + +export type RoutePrefix< + TPrefix extends string, + TPath extends string, +> = string extends TPath + ? RootRouteId + : TPath extends string + ? TPrefix extends RootRouteId + ? TPath extends '/' + ? '/' + : `/${TrimPath}` + : `${TPrefix}/${TPath}` extends '/' + ? '/' + : `/${TrimPathLeft<`${TrimPathRight}/${TrimPath}`>}` + : never + +export type TrimPath = '' extends T + ? '' + : TrimPathRight> + +export type TrimPathLeft = + T extends `${RootRouteId}/${infer U}` + ? TrimPathLeft + : T extends `/${infer U}` + ? TrimPathLeft + : T +export type TrimPathRight = T extends '/' + ? '/' + : T extends `${infer U}/` + ? TrimPathRight + : T + +/** + * @deprecated Use `ErrorComponentProps` instead. + */ +export type ErrorRouteProps = { + error: unknown + info?: { componentStack: string } + reset: () => void +} + +export type ErrorComponentProps = { + error: Error + info?: { componentStack: string } + reset: () => void +} +export type NotFoundRouteProps = { + // TODO: Make sure this is `| null | undefined` (this is for global not-founds) + data: unknown +} + +export type SyncRouteComponent = (props: TProps) => any + +export type AsyncRouteComponent = SyncRouteComponent & { + preload?: () => Promise +} + +export type RouteComponent = AsyncRouteComponent + +export type ErrorRouteComponent = RouteComponent + +export type NotFoundRouteComponent = SyncRouteComponent diff --git a/packages/router-core/src/routeInfo.ts b/packages/router-core/src/routeInfo.ts new file mode 100644 index 00000000000..07362b09558 --- /dev/null +++ b/packages/router-core/src/routeInfo.ts @@ -0,0 +1,24 @@ +export type ParseRoute = TRouteTree extends { + types: { children: infer TChildren } +} + ? unknown extends TChildren + ? TAcc + : TChildren extends ReadonlyArray + ? ParseRoute + : ParseRoute< + TChildren[keyof TChildren], + TAcc | TChildren[keyof TChildren] + > + : TAcc + +export type ParentPath = 'always' extends TOption + ? '../' + : 'never' extends TOption + ? '..' + : '../' | '..' + +export type CurrentPath = 'always' extends TOption + ? './' + : 'never' extends TOption + ? '.' + : './' | '.' diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts new file mode 100644 index 00000000000..ed6ec634dbe --- /dev/null +++ b/packages/router-core/src/router.ts @@ -0,0 +1,72 @@ +import { DeferredPromiseState } from './defer' +import { ControlledPromise } from './utils' + +export interface ViewTransitionOptions { + types: Array +} + +export function defaultSerializeError(err: unknown) { + if (err instanceof Error) { + const obj = { + name: err.name, + message: err.message, + } + + if (process.env.NODE_ENV === 'development') { + ;(obj as any).stack = err.stack + } + + return obj + } + + return { + data: err, + } +} + +export interface ExtractedBaseEntry { + dataType: '__beforeLoadContext' | 'loaderData' + type: string + path: Array + id: number + matchIndex: number +} + +export interface ExtractedStream extends ExtractedBaseEntry { + type: 'stream' + streamState: StreamState +} + +export interface ExtractedPromise extends ExtractedBaseEntry { + type: 'promise' + promiseState: DeferredPromiseState +} + +export type ExtractedEntry = ExtractedStream | ExtractedPromise + +export type StreamState = { + promises: Array> +} + +export type TrailingSlashOption = 'always' | 'never' | 'preserve' + +declare global { + interface Window { + __TSR__?: { + matches: Array<{ + __beforeLoadContext?: string + loaderData?: string + extracted?: Array + }> + streamedValues: Record< + string, + { + value: any + parsed: any + } + > + cleanScripts: () => void + dehydrated?: any + } + } +} diff --git a/packages/router-core/src/searchMiddleware.ts b/packages/router-core/src/searchMiddleware.ts new file mode 100644 index 00000000000..03ebbe213f5 --- /dev/null +++ b/packages/router-core/src/searchMiddleware.ts @@ -0,0 +1,54 @@ +import { deepEqual } from './utils' +import type { NoInfer, PickOptional } from './utils' +import type { SearchMiddleware } from './route' +import type { IsRequiredParams } from './link' + +export function retainSearchParams( + keys: Array | true, +): SearchMiddleware { + return ({ search, next }) => { + const result = next(search) + if (keys === true) { + return { ...search, ...result } + } + // add missing keys from search to result + keys.forEach((key) => { + if (!(key in result)) { + result[key] = search[key] + } + }) + return result + } +} + +export function stripSearchParams< + TSearchSchema, + TOptionalProps = PickOptional>, + const TValues = + | Partial> + | Array, + const TInput = IsRequiredParams extends never + ? TValues | true + : TValues, +>(input: NoInfer): SearchMiddleware { + return ({ search, next }) => { + if (input === true) { + return {} + } + const result = next(search) as Record + if (Array.isArray(input)) { + input.forEach((key) => { + delete result[key] + }) + } else { + Object.entries(input as Record).forEach( + ([key, value]) => { + if (deepEqual(result[key], value)) { + delete result[key] + } + }, + ) + } + return result as any + } +} diff --git a/packages/router-core/src/searchParams.ts b/packages/router-core/src/searchParams.ts new file mode 100644 index 00000000000..2fadaa8d378 --- /dev/null +++ b/packages/router-core/src/searchParams.ts @@ -0,0 +1,77 @@ +import { decode, encode } from './qss' +import type { AnySchema } from './validators' + +export const defaultParseSearch = parseSearchWith(JSON.parse) +export const defaultStringifySearch = stringifySearchWith( + JSON.stringify, + JSON.parse, +) + +export function parseSearchWith(parser: (str: string) => any) { + return (searchStr: string): AnySchema => { + if (searchStr.substring(0, 1) === '?') { + searchStr = searchStr.substring(1) + } + + const query: Record = decode(searchStr) + + // Try to parse any query params that might be json + for (const key in query) { + const value = query[key] + if (typeof value === 'string') { + try { + query[key] = parser(value) + } catch (err) { + // + } + } + } + + return query + } +} + +export function stringifySearchWith( + stringify: (search: any) => string, + parser?: (str: string) => any, +) { + function stringifyValue(val: any) { + if (typeof val === 'object' && val !== null) { + try { + return stringify(val) + } catch (err) { + // silent + } + } else if (typeof val === 'string' && typeof parser === 'function') { + try { + // Check if it's a valid parseable string. + // If it is, then stringify it again. + parser(val) + return stringify(val) + } catch (err) { + // silent + } + } + return val + } + + return (search: Record) => { + search = { ...search } + + Object.keys(search).forEach((key) => { + const val = search[key] + if (typeof val === 'undefined' || val === undefined) { + delete search[key] + } else { + search[key] = stringifyValue(val) + } + }) + + const searchStr = encode(search as Record).toString() + + return searchStr ? `?${searchStr}` : '' + } +} + +export type SearchSerializer = (searchObj: Record) => string +export type SearchParser = (searchStr: string) => Record diff --git a/packages/router-core/src/structuralSharing.ts b/packages/router-core/src/structuralSharing.ts new file mode 100644 index 00000000000..6b3e3aa4033 --- /dev/null +++ b/packages/router-core/src/structuralSharing.ts @@ -0,0 +1,7 @@ +import type { Constrain } from './utils' + +export interface OptionalStructuralSharing { + // readonly structuralSharing?: + // | Constrain + // | undefined +} diff --git a/packages/router-core/src/transformer.ts b/packages/router-core/src/transformer.ts new file mode 100644 index 00000000000..7a915aba29d --- /dev/null +++ b/packages/router-core/src/transformer.ts @@ -0,0 +1,68 @@ +import { isPlainObject } from './utils' + +export interface RouterTransformer { + stringify: (obj: unknown) => string + parse: (str: string) => unknown +} + +export const defaultTransformer: RouterTransformer = { + stringify: (value: any) => + JSON.stringify(value, function replacer(key, value) { + const keyVal = this[key] + const transformer = transformers.find((t) => t.stringifyCondition(keyVal)) + + if (transformer) { + return transformer.stringify(keyVal) + } + + return value + }), + parse: (value: string) => + JSON.parse(value, function parser(key, value) { + const keyVal = this[key] + const transformer = transformers.find((t) => t.parseCondition(keyVal)) + + if (transformer) { + return transformer.parse(keyVal) + } + + return value + }), +} + +const transformers = [ + { + // Dates + stringifyCondition: (value: any) => value instanceof Date, + stringify: (value: any) => ({ $date: value.toISOString() }), + parseCondition: (value: any) => isPlainObject(value) && value.$date, + parse: (value: any) => new Date(value.$date), + }, + { + // undefined + stringifyCondition: (value: any) => value === undefined, + stringify: () => ({ $undefined: '' }), + parseCondition: (value: any) => + isPlainObject(value) && value.$undefined === '', + parse: () => undefined, + }, +] as const + +export type TransformerStringify = T extends TSerializable + ? T + : T extends (...args: Array) => any + ? 'Function is not serializable' + : { [K in keyof T]: TransformerStringify } + +export type TransformerParse = T extends TSerializable + ? T + : T extends React.JSX.Element + ? ReadableStream + : { [K in keyof T]: TransformerParse } + +export type DefaultTransformerStringify = TransformerStringify< + T, + Date | undefined +> + +export type DefaultTransformerParse = TransformerParse diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts new file mode 100644 index 00000000000..f0f6d413f8a --- /dev/null +++ b/packages/router-core/src/utils.ts @@ -0,0 +1,388 @@ +export type NoInfer = [T][T extends any ? 0 : never] +export type IsAny = 1 extends 0 & TValue + ? TYesResult + : TNoResult + +export type PickAsRequired = Omit< + TValue, + TKey +> & + Required> + +export type PickRequired = { + [K in keyof T as undefined extends T[K] ? never : K]: T[K] +} + +export type PickOptional = { + [K in keyof T as undefined extends T[K] ? K : never]: T[K] +} + +// from https://stackoverflow.com/a/76458160 +export type WithoutEmpty = T extends any ? ({} extends T ? never : T) : never + +// export type Expand = T +export type Expand = T extends object + ? T extends infer O + ? O extends Function + ? O + : { [K in keyof O]: O[K] } + : never + : T + +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial + } + : T + +export type MakeDifferenceOptional = Omit< + TRight, + keyof TLeft +> & { + [K in keyof TLeft & keyof TRight]?: TRight[K] +} + +// from https://stackoverflow.com/a/53955431 +// eslint-disable-next-line @typescript-eslint/naming-convention +export type IsUnion = ( + T extends any ? (U extends T ? false : true) : never +) extends false + ? false + : true + +export type Assign = TLeft extends any + ? TRight extends any + ? keyof TLeft extends never + ? TRight + : keyof TRight extends never + ? TLeft + : keyof TLeft & keyof TRight extends never + ? TLeft & TRight + : Omit & TRight + : never + : never + +export type Timeout = ReturnType + +export type Updater = + | TResult + | ((prev?: TPrevious) => TResult) + +export type NonNullableUpdater = + | TResult + | ((prev: TPrevious) => TResult) + +export type ExtractObjects = TUnion extends MergeAllPrimitive + ? never + : TUnion + +export type PartialMergeAllObject = + ExtractObjects extends infer TObj + ? { + [TKey in TObj extends any ? keyof TObj : never]?: TObj extends any + ? TKey extends keyof TObj + ? TObj[TKey] + : never + : never + } + : never + +export type MergeAllPrimitive = + | ReadonlyArray + | number + | string + | bigint + | boolean + | symbol + | undefined + | null + +export type ExtractPrimitives = TUnion extends MergeAllPrimitive + ? TUnion + : TUnion extends object + ? never + : TUnion + +export type PartialMergeAll = + | ExtractPrimitives + | PartialMergeAllObject + +export type Constrain = + | (T extends TConstraint ? T : never) + | TDefault + +export type ConstrainLiteral = + | (T & TConstraint) + | TDefault + +/** + * To be added to router types + */ +export type UnionToIntersection = ( + T extends any ? (arg: T) => any : never +) extends (arg: infer T) => any + ? T + : never + +/** + * Merges everything in a union into one object. + * This mapped type is homomorphic which means it preserves stuff! :) + */ +export type MergeAllObjects< + TUnion, + TIntersected = UnionToIntersection>, +> = [keyof TIntersected] extends [never] + ? never + : { + [TKey in keyof TIntersected]: TUnion extends any + ? TUnion[TKey & keyof TUnion] + : never + } + +export type MergeAll = + | MergeAllObjects + | ExtractPrimitives + +export type ValidateJSON = ((...args: Array) => any) extends T + ? unknown extends T + ? never + : 'Function is not serializable' + : { [K in keyof T]: ValidateJSON } + +export function last(arr: Array) { + return arr[arr.length - 1] +} + +function isFunction(d: any): d is Function { + return typeof d === 'function' +} + +export function functionalUpdate( + updater: Updater | NonNullableUpdater, + previous: TResult, +): TResult { + if (isFunction(updater)) { + return updater(previous) + } + + return updater +} + +export function pick( + parent: TValue, + keys: Array, +): Pick { + return keys.reduce((obj: any, key: TKey) => { + obj[key] = parent[key] + return obj + }, {} as any) +} + +/** + * This function returns `prev` if `_next` is deeply equal. + * If not, it will replace any deeply equal children of `b` with those of `a`. + * This can be used for structural sharing between immutable JSON values for example. + * Do not use this with signals + */ +export function replaceEqualDeep(prev: any, _next: T): T { + if (prev === _next) { + return prev + } + + const next = _next as any + + const array = isPlainArray(prev) && isPlainArray(next) + + if (array || (isPlainObject(prev) && isPlainObject(next))) { + const prevItems = array ? prev : Object.keys(prev) + const prevSize = prevItems.length + const nextItems = array ? next : Object.keys(next) + const nextSize = nextItems.length + const copy: any = array ? [] : {} + + let equalItems = 0 + + for (let i = 0; i < nextSize; i++) { + const key = array ? i : (nextItems[i] as any) + if ( + ((!array && prevItems.includes(key)) || array) && + prev[key] === undefined && + next[key] === undefined + ) { + copy[key] = undefined + equalItems++ + } else { + copy[key] = replaceEqualDeep(prev[key], next[key]) + if (copy[key] === prev[key] && prev[key] !== undefined) { + equalItems++ + } + } + } + + return prevSize === nextSize && equalItems === prevSize ? prev : copy + } + + return next +} + +// Copied from: https://github.com/jonschlinkert/is-plain-object +export function isPlainObject(o: any) { + if (!hasObjectPrototype(o)) { + return false + } + + // If has modified constructor + const ctor = o.constructor + if (typeof ctor === 'undefined') { + return true + } + + // If has modified prototype + const prot = ctor.prototype + if (!hasObjectPrototype(prot)) { + return false + } + + // If constructor does not have an Object-specific method + if (!prot.hasOwnProperty('isPrototypeOf')) { + return false + } + + // Most likely a plain Object + return true +} + +function hasObjectPrototype(o: any) { + return Object.prototype.toString.call(o) === '[object Object]' +} + +export function isPlainArray(value: unknown): value is Array { + return Array.isArray(value) && value.length === Object.keys(value).length +} + +function getObjectKeys(obj: any, ignoreUndefined: boolean) { + let keys = Object.keys(obj) + if (ignoreUndefined) { + keys = keys.filter((key) => obj[key] !== undefined) + } + return keys +} + +export function deepEqual( + a: any, + b: any, + opts?: { partial?: boolean; ignoreUndefined?: boolean }, +): boolean { + if (a === b) { + return true + } + + if (typeof a !== typeof b) { + return false + } + + if (isPlainObject(a) && isPlainObject(b)) { + const ignoreUndefined = opts?.ignoreUndefined ?? true + const aKeys = getObjectKeys(a, ignoreUndefined) + const bKeys = getObjectKeys(b, ignoreUndefined) + + if (!opts?.partial && aKeys.length !== bKeys.length) { + return false + } + + return bKeys.every((key) => deepEqual(a[key], b[key], opts)) + } + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false + } + return !a.some((item, index) => !deepEqual(item, b[index], opts)) + } + + return false +} + +export type StringLiteral = T extends string + ? string extends T + ? string + : T + : never + +export type ThrowOrOptional = TThrow extends true + ? T + : T | undefined + +export type ControlledPromise = Promise & { + resolve: (value: T) => void + reject: (value: any) => void + status: 'pending' | 'resolved' | 'rejected' + value?: T +} + +export function createControlledPromise(onResolve?: (value: T) => void) { + let resolveLoadPromise!: (value: T) => void + let rejectLoadPromise!: (value: any) => void + + const controlledPromise = new Promise((resolve, reject) => { + resolveLoadPromise = resolve + rejectLoadPromise = reject + }) as ControlledPromise + + controlledPromise.status = 'pending' + + controlledPromise.resolve = (value: T) => { + controlledPromise.status = 'resolved' + controlledPromise.value = value + resolveLoadPromise(value) + onResolve?.(value) + } + + controlledPromise.reject = (e) => { + controlledPromise.status = 'rejected' + rejectLoadPromise(e) + } + + return controlledPromise +} + +/** + * + * @deprecated use `jsesc` instead + */ +export function escapeJSON(jsonString: string) { + return jsonString + .replace(/\\/g, '\\\\') // Escape backslashes + .replace(/'/g, "\\'") // Escape single quotes + .replace(/"/g, '\\"') // Escape double quotes +} + +export function shallow(objA: T, objB: T) { + if (Object.is(objA, objB)) { + return true + } + + if ( + typeof objA !== 'object' || + objA === null || + typeof objB !== 'object' || + objB === null + ) { + return false + } + + const keysA = Object.keys(objA) + if (keysA.length !== Object.keys(objB).length) { + return false + } + + for (const item of keysA) { + if ( + !Object.prototype.hasOwnProperty.call(objB, item) || + !Object.is(objA[item as keyof T], objB[item as keyof T]) + ) { + return false + } + } + return true +} diff --git a/packages/router-core/src/validators.ts b/packages/router-core/src/validators.ts new file mode 100644 index 00000000000..91730800777 --- /dev/null +++ b/packages/router-core/src/validators.ts @@ -0,0 +1,121 @@ +import type { SearchSchemaInput } from './route' + +export interface StandardSchemaValidatorProps { + readonly types?: StandardSchemaValidatorTypes | undefined + readonly validate: AnyStandardSchemaValidate +} + +export interface StandardSchemaValidator { + readonly '~standard': StandardSchemaValidatorProps +} + +export type AnyStandardSchemaValidator = StandardSchemaValidator + +export interface StandardSchemaValidatorTypes { + readonly input: TInput + readonly output: TOutput +} + +export interface AnyStandardSchemaValidateSuccess { + readonly value: any + readonly issues?: undefined +} + +export interface AnyStandardSchemaValidateFailure { + readonly issues: ReadonlyArray +} + +export interface AnyStandardSchemaValidateIssue { + readonly message: string +} + +export interface AnyStandardSchemaValidateInput { + readonly value: any +} + +export type AnyStandardSchemaValidate = ( + value: unknown, +) => + | (AnyStandardSchemaValidateSuccess | AnyStandardSchemaValidateFailure) + | Promise + +export interface ValidatorObj { + parse: ValidatorFn +} + +export type AnyValidatorObj = ValidatorObj + +export interface ValidatorAdapter { + types: { + input: TInput + output: TOutput + } + parse: (input: unknown) => TOutput +} + +export type AnyValidatorAdapter = ValidatorAdapter + +export type AnyValidatorFn = ValidatorFn + +export type ValidatorFn = (input: TInput) => TOutput + +export type Validator = + | ValidatorObj + | ValidatorFn + | ValidatorAdapter + | StandardSchemaValidator + | undefined + +export type AnyValidator = Validator + +export type AnySchema = {} + +export type DefaultValidator = Validator, AnySchema> + +export type ResolveSearchValidatorInputFn = TValidator extends ( + input: infer TSchemaInput, +) => any + ? TSchemaInput extends SearchSchemaInput + ? Omit + : ResolveValidatorOutputFn + : AnySchema + +export type ResolveSearchValidatorInput = + TValidator extends AnyStandardSchemaValidator + ? NonNullable['input'] + : TValidator extends AnyValidatorAdapter + ? TValidator['types']['input'] + : TValidator extends AnyValidatorObj + ? ResolveSearchValidatorInputFn + : ResolveSearchValidatorInputFn + +export type ResolveValidatorInputFn = TValidator extends ( + input: infer TInput, +) => any + ? TInput + : undefined + +export type ResolveValidatorInput = + TValidator extends AnyStandardSchemaValidator + ? NonNullable['input'] + : TValidator extends AnyValidatorAdapter + ? TValidator['types']['input'] + : TValidator extends AnyValidatorObj + ? ResolveValidatorInputFn + : ResolveValidatorInputFn + +export type ResolveValidatorOutputFn = TValidator extends ( + ...args: any +) => infer TSchema + ? TSchema + : AnySchema + +export type ResolveValidatorOutput = unknown extends TValidator + ? TValidator + : TValidator extends AnyStandardSchemaValidator + ? NonNullable['output'] + : TValidator extends AnyValidatorAdapter + ? TValidator['types']['output'] + : TValidator extends AnyValidatorObj + ? ResolveValidatorOutputFn + : ResolveValidatorOutputFn diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts new file mode 100644 index 00000000000..78fa6d347de --- /dev/null +++ b/packages/router-core/tests/path.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from 'vitest' +import { + exactPathTest, + interpolatePath, + matchPathname, + removeBasepath, + removeTrailingSlash, + resolvePath, +} from '../src/path' + +describe('removeBasepath', () => { + it.each([ + { + name: '`/` should leave pathname as-is', + basepath: '/', + pathname: '/path', + expected: '/path', + }, + { + name: 'should return empty string if basepath is the same as pathname', + basepath: '/path', + pathname: '/path', + expected: '', + }, + { + name: 'should remove basepath from the beginning of the pathname', + basepath: '/app', + pathname: '/app/path/app', + expected: '/path/app', + }, + { + name: 'should remove multisegment basepath from the beginning of the pathname', + basepath: '/app/new', + pathname: '/app/new/path/app/new', + expected: '/path/app/new', + }, + { + name: 'should remove basepath only in case it matches segments completely', + basepath: '/app', + pathname: '/application', + expected: '/application', + }, + { + name: 'should remove multisegment basepath only in case it matches segments completely', + basepath: '/app/new', + pathname: '/app/new-application', + expected: '/app/new-application', + }, + ])('$name', ({ basepath, pathname, expected }) => { + expect(removeBasepath(basepath, pathname)).toBe(expected) + }) + describe('case sensitivity', () => { + describe('caseSensitive = true', () => { + it.each([ + { + name: 'should not remove basepath from the beginning of the pathname', + basepath: '/app', + pathname: '/App/path/App', + expected: '/App/path/App', + }, + { + name: 'should not remove basepath from the beginning of the pathname with multiple segments', + basepath: '/app/New', + pathname: '/App/New/path/App', + expected: '/App/New/path/App', + }, + ])('$name', ({ basepath, pathname, expected }) => { + expect(removeBasepath(basepath, pathname, true)).toBe(expected) + }) + }) + + describe('caseSensitive = false', () => { + it.each([ + { + name: 'should remove basepath from the beginning of the pathname', + basepath: '/App', + pathname: '/app/path/app', + expected: '/path/app', + }, + { + name: 'should remove multisegment basepath from the beginning of the pathname', + basepath: '/App/New', + pathname: '/app/new/path/app', + expected: '/path/app', + }, + ])('$name', ({ basepath, pathname, expected }) => { + expect(removeBasepath(basepath, pathname, false)).toBe(expected) + }) + }) + }) +}) + +describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( + 'removeTrailingSlash with basepath $basepath', + ({ basepath }) => { + it('should remove trailing slash if present', () => { + const input = 'https://example.com/' + const expectedOutput = 'https://example.com' + const result = removeTrailingSlash(input, basepath) + expect(result).toBe(expectedOutput) + }) + it('should not modify the string if no trailing slash present', () => { + const input = 'https://example.com' + const result = removeTrailingSlash(input, basepath) + expect(result).toBe(input) + }) + it('should handle empty string', () => { + const input = '' + const result = removeTrailingSlash(input, basepath) + expect(result).toBe(input) + }) + it('should handle strings with only a slash', () => { + const input = '/' + const result = removeTrailingSlash(input, basepath) + expect(result).toBe(input) + }) + it('should handle strings with multiple slashes', () => { + const input = 'https://example.com/path/to/resource/' + const expectedOutput = 'https://example.com/path/to/resource' + const result = removeTrailingSlash(input, basepath) + expect(result).toBe(expectedOutput) + }) + }, +) + +describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( + 'exactPathTest with basepath $basepath', + ({ basepath }) => { + it('should return true when two paths are exactly the same', () => { + const path1 = 'some-path/additional-path' + const path2 = 'some-path/additional-path' + const result = exactPathTest(path1, path2, basepath) + expect(result).toBe(true) + }) + it('should return true when two paths are the same with or without trailing slash', () => { + const path1 = 'some-path/additional-path' + const path2 = 'some-path/additional-path/' + const result = exactPathTest(path1, path2, basepath) + expect(result).toBe(true) + }) + it('should return true when two paths are the same with or without trailing slash 2', () => { + const path1 = 'some-path/additional-path' + const path2 = 'some-path/additional-path/' + const result = exactPathTest(path1, path2, basepath) + expect(result).toBe(true) + }) + it('should return false when two paths are different', () => { + const path1 = 'some-path/additional-path/' + const path2 = 'some-path2/additional-path/' + const result = exactPathTest(path1, path2, basepath) + expect(result).toBe(false) + }) + it('should return true when both paths are just a slash', () => { + const path1 = '/' + const path2 = '/' + const result = exactPathTest(path1, path2, basepath) + expect(result).toBe(true) + }) + }, +) + +describe('resolvePath', () => { + describe.each([ + ['/', '/', '/', '/'], + ['/', '/', '/a', '/a'], + ['/', '/', 'a/', '/a'], + ['/', '/', '/a/b', '/a/b'], + ['/', 'a', 'b', '/a/b'], + ['/a/b', 'c', '/a/b/c', '/a/b/c'], + ['/a/b', '/', 'c', '/a/b/c'], + ['/a/b', '/', './c', '/a/b/c'], + ['/', '/', 'a/b', '/a/b'], + ['/', '/', './a/b', '/a/b'], + ['/', '/a/b/c', 'd', '/a/b/c/d'], + ['/', '/a/b/c', './d', '/a/b/c/d'], + ['/', '/a/b/c', './../d', '/a/b/d'], + ['/', '/a/b/c/d', './../d', '/a/b/c/d'], + ['/', '/a/b/c', '../d', '/a/b/d'], + ['/', '/a/b/c', '../../d', '/a/d'], + ['/', '/a/b/c', '..', '/a/b'], + ['/', '/a/b/c', '../..', '/a'], + ['/', '/a/b/c', '../../..', '/'], + ['/', '/a/b/c/', '../../..', '/'], + ['/products', '/', '/products-list', '/products/products-list'], + ])('resolves correctly', (base, a, b, eq) => { + it(`Base: ${base} - ${a} to ${b} === ${eq}`, () => { + expect(resolvePath({ basepath: base, base: a, to: b })).toEqual(eq) + }) + it(`Base: ${base} - ${a}/ to ${b} === ${eq} (trailing slash)`, () => { + expect(resolvePath({ basepath: base, base: a + '/', to: b })).toEqual(eq) + }) + it(`Base: ${base} - ${a}/ to ${b}/ === ${eq} (trailing slash + trailing slash)`, () => { + expect( + resolvePath({ basepath: base, base: a + '/', to: b + '/' }), + ).toEqual(eq) + }) + }) + describe('trailingSlash', () => { + describe(`'always'`, () => { + it('keeps trailing slash', () => { + expect( + resolvePath({ + basepath: '/', + base: '/a/b/c', + to: 'd/', + trailingSlash: 'always', + }), + ).toBe('/a/b/c/d/') + }) + it('adds trailing slash', () => { + expect( + resolvePath({ + basepath: '/', + base: '/a/b/c', + to: 'd', + trailingSlash: 'always', + }), + ).toBe('/a/b/c/d/') + }) + }) + describe(`'never'`, () => { + it('removes trailing slash', () => { + expect( + resolvePath({ + basepath: '/', + base: '/a/b/c', + to: 'd/', + trailingSlash: 'never', + }), + ).toBe('/a/b/c/d') + }) + it('does not add trailing slash', () => { + expect( + resolvePath({ + basepath: '/', + base: '/a/b/c', + to: 'd', + trailingSlash: 'never', + }), + ).toBe('/a/b/c/d') + }) + }) + describe(`'preserve'`, () => { + it('keeps trailing slash', () => { + expect( + resolvePath({ + basepath: '/', + base: '/a/b/c', + to: 'd/', + trailingSlash: 'preserve', + }), + ).toBe('/a/b/c/d/') + }) + it('does not add trailing slash', () => { + expect( + resolvePath({ + basepath: '/', + base: '/a/b/c', + to: 'd', + trailingSlash: 'preserve', + }), + ).toBe('/a/b/c/d') + }) + }) + }) +}) + +describe('interpolatePath', () => { + ;[ + { + name: 'should interpolate the path', + path: '/users/$id', + params: { id: '123' }, + result: '/users/123', + }, + { + name: 'should interpolate the path with multiple params', + path: '/users/$id/$name', + params: { id: '123', name: 'tanner' }, + result: '/users/123/tanner', + }, + { + name: 'should interpolate the path with extra params', + path: '/users/$id', + params: { id: '123', name: 'tanner' }, + result: '/users/123', + }, + { + name: 'should interpolate the path with missing params', + path: '/users/$id/$name', + params: { id: '123' }, + result: '/users/123/undefined', + }, + { + name: 'should interpolate the path with missing params and extra params', + path: '/users/$id', + params: { name: 'john' }, + result: '/users/undefined', + }, + { + name: 'should interpolate the path with the param being a number', + path: '/users/$id', + params: { id: 123 }, + result: '/users/123', + }, + { + name: 'should interpolate the path with the param being a falsey number', + path: '/users/$id', + params: { id: 0 }, + result: '/users/0', + }, + { + name: 'should interpolate the path with URI component encoding', + path: '/users/$id', + params: { id: '?#@john+smith' }, + result: '/users/%3F%23%40john%2Bsmith', + }, + { + name: 'should interpolate the path without URI encoding characters in decodeCharMap', + path: '/users/$id', + params: { id: '?#@john+smith' }, + result: '/users/%3F%23@john+smith', + decodeCharMap: new Map( + ['@', '+'].map((char) => [encodeURIComponent(char), char]), + ), + }, + ].forEach((exp) => { + it(exp.name, () => { + const result = interpolatePath({ + path: exp.path, + params: exp.params, + decodeCharMap: exp.decodeCharMap, + }) + expect(result).toBe(exp.result) + }) + }) +}) + +describe('matchPathname', () => { + it.each([ + { + name: 'should match the root path that start with the basepath', + basepath: '/basepath', + pathname: '/basepath', + matchLocation: { + to: '/', + }, + expected: {}, + }, + { + name: 'should match the path that start with the basepath', + basepath: '/basepath', + pathname: '/basepath/abc', + matchLocation: { + to: '/abc', + }, + expected: {}, + }, + { + name: 'should not match the root path that does not start with the basepath', + basepath: '/basepath', + pathname: '/', + matchLocation: { + to: '/', + }, + expected: undefined, + }, + { + name: 'should not match the path that does not start with the basepath', + basepath: '/basepath', + pathname: '/abc', + matchLocation: { + to: '/abc', + }, + expected: undefined, + }, + { + name: 'should not match the path that match partial of the basepath', + basepath: '/base', + pathname: '/basepath/abc', + matchLocation: { + to: '/abc', + }, + expected: undefined, + }, + ])('$name', ({ basepath, pathname, matchLocation, expected }) => { + expect(matchPathname(basepath, pathname, matchLocation)).toStrictEqual( + expected, + ) + }) +}) diff --git a/packages/router-core/tests/qss.test.ts b/packages/router-core/tests/qss.test.ts new file mode 100644 index 00000000000..7f1bbca2399 --- /dev/null +++ b/packages/router-core/tests/qss.test.ts @@ -0,0 +1,91 @@ +/* eslint-disable */ +import { describe, it, expect } from 'vitest' +import { encode, decode } from '../src/qss' + +describe('encode function', () => { + it('should encode an object into a query string without a prefix', () => { + const obj = { token: 'foo', key: 'value' } + const queryString = encode(obj) + expect(queryString).toEqual('token=foo&key=value') + }) + + it('should encode an object into a query string with a prefix', () => { + const obj = { token: 'foo', key: 'value' } + const queryString = encode(obj, 'prefix_/*&?') + expect(queryString).toEqual('prefix_/*&?token=foo&key=value') + }) + + it('should handle encoding an object with empty values and trailing equal signs', () => { + const obj = { token: '', key: 'value=' } + const queryString = encode(obj) + expect(queryString).toEqual('token=&key=value%3D') // token=&key=value= + }) + + it('should handle encoding an object with array values', () => { + const obj = { token: ['foo', 'bar'], key: 'value' } + const queryString = encode(obj) + expect(queryString).toEqual('token=foo&token=bar&key=value') + }) + + it('should handle encoding an object with special characters', () => { + const obj = { token: 'foo?', key: 'value=' } + const queryString = encode(obj) + expect(queryString).toEqual('token=foo%3F&key=value%3D') + }) + + it('should handle encoding a top-level key with a special character', () => { + const obj = { 'foo=bar': 1 } + const queryString = encode(obj) + expect(queryString).toEqual('foo%3Dbar=1') + }) +}) + +describe('decode function', () => { + it('should decode a query string without a prefix', () => { + const queryString = 'token=foo&key=value' + const decodedObj = decode(queryString) + expect(decodedObj).toEqual({ token: 'foo', key: 'value' }) + }) + + it('should decode a query string with a prefix', () => { + const queryString = 'prefix_/*&?token=foo&key=value' + const decodedObj = decode(queryString, 'prefix_/*&?') + expect(decodedObj).toEqual({ token: 'foo', key: 'value' }) + }) + + it('should handle missing values and trailing equal signs', () => { + const queryString = 'token=&key=value=' + const decodedObj = decode(queryString) + expect(decodedObj).toEqual({ token: '', key: 'value=' }) + }) + + it('should handle decoding a query string with array values', () => { + const queryString = 'token=foo&token=bar&key=value' + const decodedObj = decode(queryString) + expect(decodedObj).toEqual({ token: ['foo', 'bar'], key: 'value' }) + }) + + it('should handle decoding a query string with special characters', () => { + const queryString = 'token=foo%3F&key=value%3D' + const decodedObj = decode(queryString) + expect(decodedObj).toEqual({ token: 'foo?', key: 'value=' }) + }) + + it('should handle decoding a top-level key with a special character', () => { + const queryString = 'foo%3Dbar=1' + const decodedObj = decode(queryString) + expect(decodedObj).toEqual({ 'foo=bar': 1 }) + }) + + it('should handle decoding a top-level key with a special character and without a value', () => { + const queryString = 'foo%3Dbar=' + const decodedObj = decode(queryString) + expect(decodedObj).toEqual({ 'foo=bar': '' }) + }) + + it('should handle decoding a value-less top-level key with a special character', () => { + const queryString = 'foo%3Dbar' + const decodedObj = decode(queryString) + expect(decodedObj).toEqual({ 'foo=bar': '' }) + }) +}) diff --git a/packages/router-core/tests/transformer.test.ts b/packages/router-core/tests/transformer.test.ts new file mode 100644 index 00000000000..cf7e6c67a5c --- /dev/null +++ b/packages/router-core/tests/transformer.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'vitest' + +import { defaultTransformer } from '../src/transformer' + +describe('transformer.stringify', () => { + test('should stringify dates', () => { + const date = new Date('2021-08-19T20:00:00.000Z') + expect(defaultTransformer.stringify(date)).toMatchInlineSnapshot(` + "{"$date":"2021-08-19T20:00:00.000Z"}" + `) + }) + + test('should stringify undefined', () => { + expect(defaultTransformer.stringify(undefined)).toMatchInlineSnapshot(` + "{"$undefined":""}" + `) + }) + + test('should stringify object foo="bar"', () => { + expect(defaultTransformer.stringify({ foo: 'bar' })).toMatchInlineSnapshot(` + "{"foo":"bar"}" + `) + }) + + test('should stringify object foo=undefined', () => { + expect(defaultTransformer.stringify({ foo: undefined })) + .toMatchInlineSnapshot(` + "{"foo":{"$undefined":""}}" + `) + }) + + test('should stringify object foo=Date', () => { + const date = new Date('2021-08-19T20:00:00.000Z') + expect(defaultTransformer.stringify({ foo: date })).toMatchInlineSnapshot(` + "{"foo":{"$date":"2021-08-19T20:00:00.000Z"}}" + `) + }) +}) + +describe('transformer.parse', () => { + test('should parse dates', () => { + const date = new Date('2021-08-19T20:00:00.000Z') + const str = defaultTransformer.stringify(date) + expect(defaultTransformer.parse(str)).toEqual(date) + }) + + test('should parse undefined', () => { + const str = defaultTransformer.stringify(undefined) + expect(defaultTransformer.parse(str)).toBeUndefined() + }) + + test('should parse object foo="bar"', () => { + const obj = { foo: 'bar' } + const str = defaultTransformer.stringify(obj) + expect(defaultTransformer.parse(str)).toEqual(obj) + }) + + test('should parse object foo=undefined', () => { + const obj = { foo: undefined } + const str = defaultTransformer.stringify(obj) + expect(defaultTransformer.parse(str)).toEqual(obj) + }) + + test('should parse object foo=Date', () => { + const date = new Date('2021-08-19T20:00:00.000Z') + const obj = { foo: date } + const str = defaultTransformer.stringify(obj) + expect(defaultTransformer.parse(str)).toEqual(obj) + }) +}) diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts new file mode 100644 index 00000000000..486ccf04105 --- /dev/null +++ b/packages/router-core/tests/utils.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, it } from 'vitest' +import { deepEqual, isPlainArray, replaceEqualDeep } from '../src/utils' + +describe('replaceEqualDeep', () => { + it('should return the same object if the input objects are equal', () => { + const obj = { a: 1, b: 2 } + const result = replaceEqualDeep(obj, obj) + expect(result).toBe(obj) + }) + + it('should return a new object with replaced values if the input objects are not equal', () => { + const obj1 = { a: 1, b: 2 } + const obj2 = { a: 1, b: 3 } + const result = replaceEqualDeep(obj1, obj2) + expect(result).toStrictEqual(obj2) + }) + + it('should handle arrays correctly', () => { + const arr1 = [1, 2, 3] + const arr2 = [1, 2, 4] + const result = replaceEqualDeep(arr1, arr2) + expect(result).toStrictEqual(arr2) + }) + + it('should handle nested objects correctly', () => { + const obj1 = { a: 1, b: { c: 2 } } + const obj2 = { a: 1, b: { c: 3 } } + const result = replaceEqualDeep(obj1, obj2) + expect(result).toStrictEqual(obj2) + }) + + it('should properly handle non-existent keys', () => { + const obj1 = { a: 2, c: 123 } + const obj2 = { a: 2, c: 123, b: undefined } + const result = replaceEqualDeep(obj1, obj2) + expect(result).toStrictEqual(obj2) + }) + + it('should correctly handle non-existent keys with the same number of fields', () => { + const obj1 = { a: 2, c: 123 } + const obj2 = { a: 2, b: undefined } + const result = replaceEqualDeep(obj1, obj2) + expect(result).toStrictEqual(obj2) + }) + + it('should return the previous value when the next value is an equal primitive', () => { + expect(replaceEqualDeep(1, 1)).toBe(1) + expect(replaceEqualDeep('1', '1')).toBe('1') + expect(replaceEqualDeep(true, true)).toBe(true) + expect(replaceEqualDeep(false, false)).toBe(false) + expect(replaceEqualDeep(null, null)).toBe(null) + expect(replaceEqualDeep(undefined, undefined)).toBe(undefined) + }) + it('should return the next value when the previous value is a different value', () => { + const date1 = new Date() + const date2 = new Date() + expect(replaceEqualDeep(1, 0)).toBe(0) + expect(replaceEqualDeep(1, 2)).toBe(2) + expect(replaceEqualDeep('1', '2')).toBe('2') + expect(replaceEqualDeep(true, false)).toBe(false) + expect(replaceEqualDeep(false, true)).toBe(true) + expect(replaceEqualDeep(date1, date2)).toBe(date2) + }) + + it('should return the next value when the previous value is a different type', () => { + const array = [1] + const object = { a: 'a' } + expect(replaceEqualDeep(0, undefined)).toBe(undefined) + expect(replaceEqualDeep(undefined, 0)).toBe(0) + expect(replaceEqualDeep(2, undefined)).toBe(undefined) + expect(replaceEqualDeep(undefined, 2)).toBe(2) + expect(replaceEqualDeep(undefined, null)).toBe(null) + expect(replaceEqualDeep(null, undefined)).toBe(undefined) + expect(replaceEqualDeep({}, undefined)).toBe(undefined) + expect(replaceEqualDeep([], undefined)).toBe(undefined) + expect(replaceEqualDeep(array, object)).toBe(object) + expect(replaceEqualDeep(object, array)).toBe(array) + }) + + it('should return the previous value when the next value is an equal array', () => { + const prev = [1, 2] + const next = [1, 2] + expect(replaceEqualDeep(prev, next)).toBe(prev) + }) + + it('should return a copy when the previous value is a different array subset', () => { + const prev = [1, 2] + const next = [1, 2, 3] + const result = replaceEqualDeep(prev, next) + expect(result).toEqual(next) + expect(result).not.toBe(prev) + expect(result).not.toBe(next) + }) + + it('should return a copy when the previous value is a different array superset', () => { + const prev = [1, 2, 3] + const next = [1, 2] + const result = replaceEqualDeep(prev, next) + expect(result).toEqual(next) + expect(result).not.toBe(prev) + expect(result).not.toBe(next) + }) + + it('should return the previous value when the next value is an equal empty array', () => { + const prev: Array = [] + const next: Array = [] + expect(replaceEqualDeep(prev, next)).toBe(prev) + }) + + it('should return the previous value when the next value is an equal empty object', () => { + const prev = {} + const next = {} + expect(replaceEqualDeep(prev, next)).toBe(prev) + }) + + it('should return the previous value when the next value is an equal object', () => { + const prev = { a: 'a' } + const next = { a: 'a' } + expect(replaceEqualDeep(prev, next)).toBe(prev) + }) + + it('should replace different values in objects', () => { + const prev = { a: { b: 'b' }, c: 'c' } + const next = { a: { b: 'b' }, c: 'd' } + const result = replaceEqualDeep(prev, next) + expect(result).toEqual(next) + expect(result).not.toBe(prev) + expect(result).not.toBe(next) + expect(result.a).toBe(prev.a) + expect(result.c).toBe(next.c) + }) + + it('should replace different values in arrays', () => { + const prev = [1, { a: 'a' }, { b: { b: 'b' } }, [1]] as const + const next = [1, { a: 'a' }, { b: { b: 'c' } }, [1]] as const + const result = replaceEqualDeep(prev, next) + expect(result).toEqual(next) + expect(result).not.toBe(prev) + expect(result).not.toBe(next) + expect(result[0]).toBe(prev[0]) + expect(result[1]).toBe(prev[1]) + expect(result[2]).not.toBe(next[2]) + expect(result[2].b.b).toBe(next[2].b.b) + expect(result[3]).toBe(prev[3]) + }) + + it('should replace different values in arrays when the next value is a subset', () => { + const prev = [{ a: 'a' }, { b: 'b' }, { c: 'c' }] + const next = [{ a: 'a' }, { b: 'b' }] + const result = replaceEqualDeep(prev, next) + expect(result).toEqual(next) + expect(result).not.toBe(prev) + expect(result).not.toBe(next) + expect(result[0]).toBe(prev[0]) + expect(result[1]).toBe(prev[1]) + expect(result[2]).toBeUndefined() + }) + + it('should replace different values in arrays when the next value is a superset', () => { + const prev = [{ a: 'a' }, { b: 'b' }] + const next = [{ a: 'a' }, { b: 'b' }, { c: 'c' }] + const result = replaceEqualDeep(prev, next) + expect(result).toEqual(next) + expect(result).not.toBe(prev) + expect(result).not.toBe(next) + expect(result[0]).toBe(prev[0]) + expect(result[1]).toBe(prev[1]) + expect(result[2]).toBe(next[2]) + }) + + it('should copy objects which are not arrays or objects', () => { + const prev = [{ a: 'a' }, { b: 'b' }, { c: 'c' }, 1] + const next = [{ a: 'a' }, new Map(), { c: 'c' }, 2] + const result = replaceEqualDeep(prev, next) + expect(result).not.toBe(prev) + expect(result).not.toBe(next) + expect(result[0]).toBe(prev[0]) + expect(result[1]).toBe(next[1]) + expect(result[2]).toBe(prev[2]) + expect(result[3]).toBe(next[3]) + }) + + it('should support equal objects which are not arrays or objects', () => { + const map = new Map() + const prev = [map, [1]] + const next = [map, [1]] + const result = replaceEqualDeep(prev, next) + expect(result).toBe(prev) + }) + + it('should support non equal objects which are not arrays or objects', () => { + const map1 = new Map() + const map2 = new Map() + const prev = [map1, [1]] + const next = [map2, [1]] + const result = replaceEqualDeep(prev, next) + expect(result).not.toBe(prev) + expect(result).not.toBe(next) + expect(result[0]).toBe(next[0]) + expect(result[1]).toBe(prev[1]) + }) + + it('should support objects which are not plain arrays', () => { + const prev = Object.assign([1, 2], { a: { b: 'b' }, c: 'c' }) + const next = Object.assign([1, 2], { a: { b: 'b' }, c: 'c' }) + const result = replaceEqualDeep(prev, next) + expect(result).toBe(next) + }) + + it('should replace all parent objects if some nested value changes', () => { + const prev = { + todo: { id: '1', meta: { createdAt: 0 }, state: { done: false } }, + otherTodo: { id: '2', meta: { createdAt: 0 }, state: { done: true } }, + } + const next = { + todo: { id: '1', meta: { createdAt: 0 }, state: { done: true } }, + otherTodo: { id: '2', meta: { createdAt: 0 }, state: { done: true } }, + } + const result = replaceEqualDeep(prev, next) + expect(result).toEqual(next) + expect(result).not.toBe(prev) + expect(result).not.toBe(next) + expect(result.todo).not.toBe(prev.todo) + expect(result.todo).not.toBe(next.todo) + expect(result.todo.id).toBe(next.todo.id) + expect(result.todo.meta).toBe(prev.todo.meta) + expect(result.todo.state).not.toBe(next.todo.state) + expect(result.todo.state.done).toBe(next.todo.state.done) + expect(result.otherTodo).toBe(prev.otherTodo) + }) + + it('should replace all parent arrays if some nested value changes', () => { + const prev = { + todos: [ + { id: '1', meta: { createdAt: 0 }, state: { done: false } }, + { id: '2', meta: { createdAt: 0 }, state: { done: true } }, + ], + } + const next = { + todos: [ + { id: '1', meta: { createdAt: 0 }, state: { done: true } }, + { id: '2', meta: { createdAt: 0 }, state: { done: true } }, + ], + } + const result = replaceEqualDeep(prev, next) + expect(result).toEqual(next) + expect(result).not.toBe(prev) + expect(result).not.toBe(next) + expect(result.todos).not.toBe(prev.todos) + expect(result.todos).not.toBe(next.todos) + expect(result.todos[0]).not.toBe(prev.todos[0]) + expect(result.todos[0]).not.toBe(next.todos[0]) + expect(result.todos[0]?.id).toBe(next.todos[0]?.id) + expect(result.todos[0]?.meta).toBe(prev.todos[0]?.meta) + expect(result.todos[0]?.state).not.toBe(next.todos[0]?.state) + expect(result.todos[0]?.state.done).toBe(next.todos[0]?.state.done) + expect(result.todos[1]).toBe(prev.todos[1]) + }) + + it('should be able to share values that contain undefined', () => { + const current = [ + { + data: undefined, + foo: true, + }, + ] + + const next = replaceEqualDeep(current, [ + { + data: undefined, + foo: true, + }, + ]) + + expect(current).toBe(next) + }) + + it('should return the previous value when both values are an array of undefined', () => { + const current = [undefined] + const next = replaceEqualDeep(current, [undefined]) + + expect(next).toBe(current) + }) + + it('should return the previous value when both values are an array that contains undefined', () => { + const current = [{ foo: 1 }, undefined] + const next = replaceEqualDeep(current, [{ foo: 1 }, undefined]) + + expect(next).toBe(current) + }) +}) + +describe('isPlainArray', () => { + it('should return `true` for plain arrays', () => { + expect(isPlainArray([1, 2])).toEqual(true) + }) + + it('should return `false` for non plain arrays', () => { + expect(isPlainArray(Object.assign([1, 2], { a: 'b' }))).toEqual(false) + }) +}) + +describe('deepEqual', () => { + describe.each([false, true])('partial = %s', (partial) => { + it('should return `true` for equal objects', () => { + const a = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] } + const b = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] } + expect(deepEqual(a, b, { partial })).toEqual(true) + expect(deepEqual(b, a, { partial })).toEqual(true) + }) + + it('should return `false` for non equal objects', () => { + const a = { a: { b: 'b' }, c: 'c' } + const b = { a: { b: 'c' }, c: 'c' } + expect(deepEqual(a, b, { partial })).toEqual(false) + expect(deepEqual(b, a, { partial })).toEqual(false) + }) + + it('should return `true` for equal objects and ignore `undefined` properties', () => { + const a = { a: 'a', b: undefined, c: 'c' } + const b = { a: 'a', c: 'c' } + expect(deepEqual(a, b, { partial })).toEqual(true) + expect(deepEqual(b, a, { partial })).toEqual(true) + }) + + it('should return `true` for equal objects and ignore `undefined` nested properties', () => { + const a = { a: { b: 'b', x: undefined }, c: 'c' } + const b = { a: { b: 'b' }, c: 'c', d: undefined } + expect(deepEqual(a, b, { partial })).toEqual(true) + expect(deepEqual(b, a, { partial })).toEqual(true) + }) + + it('should return `true` for equal arrays and ignore `undefined` object properties', () => { + const a = { a: { b: 'b' }, c: undefined } + const b = { a: { b: 'b' } } + expect(deepEqual([a], [b], { partial })).toEqual(true) + expect(deepEqual([b], [a], { partial })).toEqual(true) + }) + + it('should return `true` for equal arrays and ignore nested `undefined` object properties', () => { + const a = { a: { b: 'b', x: undefined }, c: 'c' } + const b = { a: { b: 'b' }, c: 'c' } + expect(deepEqual([a], [b], { partial })).toEqual(true) + expect(deepEqual([b], [a], { partial })).toEqual(true) + }) + }) + + describe('ignoreUndefined = false', () => { + const ignoreUndefined = false + describe('partial = false', () => { + const partial = false + it('should return `false` for objects', () => { + const a = { a: { b: 'b', x: undefined }, c: 'c' } + const b = { a: { b: 'b' }, c: 'c', d: undefined } + expect(deepEqual(a, b, { partial, ignoreUndefined })).toEqual(false) + expect(deepEqual(b, a, { partial, ignoreUndefined })).toEqual(false) + }) + + it('should return `false` for arrays', () => { + const a = { a: { b: 'b', x: undefined }, c: 'c' } + const b = { a: { b: 'b' }, c: 'c' } + expect(deepEqual([a], [b], { partial, ignoreUndefined })).toEqual(false) + expect(deepEqual([b], [a], { partial, ignoreUndefined })).toEqual(false) + }) + }) + describe('partial = true', () => { + const partial = true + it('should return `true` for objects', () => { + const a = { a: { b: 'b' }, c: 'c' } + const b = { a: { b: 'b' }, c: 'c', d: undefined } + expect(deepEqual(a, b, { partial, ignoreUndefined })).toEqual(true) + expect(deepEqual(b, a, { partial, ignoreUndefined })).toEqual(true) + }) + + it('should return `true` for arrays', () => { + const a = { a: { b: 'b', x: undefined }, c: 'c' } + const b = { a: { b: 'b' }, c: 'c' } + expect(deepEqual([a], [b], { partial, ignoreUndefined })).toEqual(true) + expect(deepEqual([b], [a], { partial, ignoreUndefined })).toEqual(true) + }) + }) + }) + + describe('partial comparison', () => { + it('correctly compares partially equal objects', () => { + const a = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] } + const b = { a: { b: 'b' }, c: 'c' } + expect(deepEqual(a, b, { partial: true })).toEqual(true) + expect(deepEqual(b, a, { partial: true })).toEqual(false) + }) + + it('correctly compares partially equal objects and ignores `undefined` object properties', () => { + const a = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }], e: undefined } + const b = { a: { b: 'b' }, c: 'c', d: undefined } + expect(deepEqual(a, b, { partial: true })).toEqual(true) + expect(deepEqual(b, a, { partial: true })).toEqual(false) + }) + }) +}) diff --git a/packages/router-core/tsconfig.json b/packages/router-core/tsconfig.json new file mode 100644 index 00000000000..b36d1bf9242 --- /dev/null +++ b/packages/router-core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "vite.config.ts"] +} diff --git a/packages/router-core/vite.config.ts b/packages/router-core/vite.config.ts new file mode 100644 index 00000000000..5edc0264cba --- /dev/null +++ b/packages/router-core/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/config/vite' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './tests', + watch: false, + environment: 'jsdom', + typecheck: { enabled: true }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: './src/index.ts', + srcDir: './src', + }), +) diff --git a/packages/solid-router/README.md b/packages/solid-router/README.md new file mode 100644 index 00000000000..d83bf5fdf43 --- /dev/null +++ b/packages/solid-router/README.md @@ -0,0 +1,31 @@ + + +# TanStack React Router + +![TanStack Router Header](https://github.com/tanstack/router/raw/main/media/header.png) + +🤖 Type-safe router w/ built-in caching & URL state management for React! + + + #TanStack + + + + + + + + semantic-release + + Join the discussion on Github +Best of JS + + + + + + + +Enjoy this library? Try the entire [TanStack](https://tanstack.com)! [React Query](https://github.com/tannerlinsley/react-query), [React Table](https://github.com/tanstack/react-table), [React Charts](https://github.com/tannerlinsley/react-charts), [React Virtual](https://github.com/tannerlinsley/react-virtual) + +## Visit [tanstack.com/router](https://tanstack.com/router) for docs, guides, API and more! diff --git a/packages/solid-router/eslint.config.ts b/packages/solid-router/eslint.config.ts new file mode 100644 index 00000000000..a2f17156678 --- /dev/null +++ b/packages/solid-router/eslint.config.ts @@ -0,0 +1,24 @@ +import pluginReact from '@eslint-react/eslint-plugin' +// @ts-expect-error +import pluginReactHooks from 'eslint-plugin-react-hooks' +import rootConfig from '../../eslint.config.js' + +export default [ + ...rootConfig, + { + files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'], + }, + { + plugins: { + 'react-hooks': pluginReactHooks, + '@eslint-react': pluginReact, + }, + rules: { + '@eslint-react/no-unstable-context-value': 'off', + '@eslint-react/no-unstable-default-props': 'off', + '@eslint-react/dom/no-missing-button-type': 'off', + 'react-hooks/exhaustive-deps': 'error', + 'react-hooks/rules-of-hooks': 'error', + }, + }, +] diff --git a/packages/solid-router/package.json b/packages/solid-router/package.json new file mode 100644 index 00000000000..46e13828318 --- /dev/null +++ b/packages/solid-router/package.json @@ -0,0 +1,95 @@ +{ + "name": "@tanstack/solid-router", + "version": "1.91.3", + "description": "Modern and scalable routing for React applications", + "author": "Tanner Linsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/router.git", + "directory": "packages/react-router" + }, + "homepage": "https://tanstack.com/router", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "react", + "location", + "router", + "routing", + "async", + "async router", + "typescript" + ], + "scripts": { + "clean": "rimraf ./dist && rimraf ./coverage", + "test:eslint": "eslint", + "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", + "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js -p tsconfig.legacy.json", + "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js -p tsconfig.legacy.json", + "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js -p tsconfig.legacy.json", + "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js -p tsconfig.legacy.json", + "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js -p tsconfig.legacy.json", + "test:types:ts57": "tsc -p tsconfig.legacy.json", + "test:unit": "vitest", + "test:unit:dev": "pnpm run test:unit --watch --hideSkippedTests", + "test:perf": "vitest bench", + "test:perf:dev": "pnpm run test:perf --watch --hideSkippedTests", + "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .", + "build": "vite build && tsc -p tsconfig.build.json" + }, + "type": "module", + "exports": { + ".": { + "solid": { + "types": "./dist/source/index.d.ts", + "default": "./dist/source/index.jsx" + }, + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "engines": { + "node": ">=12" + }, + "dependencies": { + "@solid-devtools/logger": "^0.9.4", + "@solid-primitives/refs": "^1.0.8", + "@tanstack/history": "workspace:*", + "@tanstack/router-core": "workspace:*", + "@tanstack/solid-store": "^0.6.0", + "@tanstack/store": "^0.6.0", + "jsesc": "^3.0.2", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "devDependencies": { + "@solidjs/testing-library": "^0.8.10", + "@testing-library/jest-dom": "^6.6.3", + "@types/jsesc": "^3.0.3", + "@vitest/browser": "^2.1.8", + "@vitest/ui": "2.1.8", + "combinate": "^1.1.11", + "solid-js": "^1", + "vite-plugin-solid": "2.10.2", + "vitest": "^2.1.8", + "zod": "^3.23.8" + }, + "peerDependencies": { + "solid-js": "^1" + } +} diff --git a/packages/solid-router/src/CatchBoundary.tsx b/packages/solid-router/src/CatchBoundary.tsx new file mode 100644 index 00000000000..0c4498f0b2d --- /dev/null +++ b/packages/solid-router/src/CatchBoundary.tsx @@ -0,0 +1,81 @@ +import * as Solid from 'solid-js' +import { Dynamic } from 'solid-js/web' +import type { ErrorRouteComponent } from '@tanstack/router-core' + +export function CatchBoundary( + props: { + resetKey: number | string + errorComponent?: ErrorRouteComponent + onCatch?: (error: Error) => void + } & Solid.ParentProps, +) { + return ( + { + props.onCatch?.(error) + + Solid.createEffect( + Solid.on( + () => props.resetKey, + () => reset(), + { defer: true }, + ), + ) + + return ( + + ) + }} + > + {props.children} + + ) +} + +export function ErrorComponent(props: { error: any }) { + const [show, setShow] = Solid.createSignal( + process.env.NODE_ENV !== 'production', + ) + + return ( +
+
+ Something went wrong! + +
+
+ {show() ? ( +
+
+            {props.error.message ? {props.error.message} : null}
+          
+
+ ) : null} +
+ ) +} diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx new file mode 100644 index 00000000000..6b036af190b --- /dev/null +++ b/packages/solid-router/src/Match.tsx @@ -0,0 +1,348 @@ +import * as Solid from 'solid-js' +import invariant from 'tiny-invariant' +import warning from 'tiny-warning' +import { Dynamic } from 'solid-js/web' +import { + createControlledPromise, + defaultDeserializeError, + isServerSideError, + pick, + rootRouteId, +} from '@tanstack/router-core' +import { CatchBoundary, ErrorComponent } from './CatchBoundary' +import { useRouterState } from './useRouterState' +import { useRouter } from './useRouter' +import { CatchNotFound, isNotFound } from './not-found' +import { isRedirect } from './redirects' +import { matchContext } from './matchContext' +import { SafeFragment } from './SafeFragment' +import { renderRouteNotFound } from './renderRouteNotFound' +import type { AnyRoute } from './route' + +export const Match = (props: { matchId: string }) => { + const e = new Error() + const router = useRouter() + const routeId = useRouterState({ + select: (s) => { + console.warn('Match matchId: ', e.stack) + return s.matches.find((d) => d.id === props.matchId)?.routeId as string + }, + }) + + invariant( + routeId, + `Could not find routeId for matchId "${props.matchId}". Please file an issue!`, + ) + + const route: () => AnyRoute = () => router.routesById[routeId()] + + const PendingComponent = () => + route().options.pendingComponent ?? router.options.defaultPendingComponent + + const routeErrorComponent = () => + route().options.errorComponent ?? router.options.defaultErrorComponent + + 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 ResolvedSuspenseBoundary = () => + // If we're on the root route, allow forcefully wrapping in suspense + (!route().isRoot || route().options.wrapInSuspense) && + (route().options.wrapInSuspense ?? + PendingComponent() ?? + (route().options.errorComponent as any)?.preload) + ? Solid.Suspense + : SafeFragment + + const ResolvedCatchBoundary = () => + routeErrorComponent() ? CatchBoundary : SafeFragment + + const ResolvedNotFoundBoundary = () => + routeNotFoundComponent() ? CatchNotFound : SafeFragment + + const resetKey = useRouterState({ + select: (s) => s.loadedAt, + }) + + return ( + props.matchId}> + } + > + { + // 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: ${props.matchId}`) + 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 !== routeId) || + (!error.routeId && !route().isRoot) + ) + throw error + + return ( + + ) + }} + > + + + + + + ) +} + +export const MatchInner = (props: { matchId: string }): any => { + const router = useRouter() + + // { match, matchIndex, routeId } = + const matchState = useRouterState({ + select: (s) => { + const matchIndex = s.matches.findIndex((d) => d.id === props.matchId) + const match = s.matches[matchIndex]! + const routeId = match.routeId as string + return { + routeId, + matchIndex, + match: pick(match, ['id', 'status', 'error']), + } + }, + }) + + const route = () => router.routesById[matchState().routeId]! + + // function useChangedDiff(value: any) { + // const ref = React.useRef(value) + // const changed = ref.current !== value + // if (changed) { + // console.log( + // 'Changed:', + // value, + // Object.fromEntries( + // Object.entries(value).filter( + // ([key, val]) => val !== ref.current[key], + // ), + // ), + // ) + // } + // ref.current = value + // } + + // useChangedDiff(match) + + const match = () => matchState().match + + return ( + + + {(_) => { + let error: unknown + + // not in a reactive context but it's probably fine + const m = match() + + if (isServerSideError(m.error)) { + const deserializeError = + router.options.errorSerializer?.deserialize ?? + defaultDeserializeError + + error = deserializeError(m.error.data) + } else { + error = match().error + } + + invariant(isNotFound(error), 'Expected a notFound error') + + return renderRouteNotFound(router, route(), error) + }} + + + {(_) => { + // Redirects should be handled by the router transition. If we happen to + // encounter a redirect here, it's a bug. Let's warn, but render nothing. + invariant(isRedirect(match().error), 'Expected a redirect error') + + // warning( + // false, + // 'Tried to render a redirected route match! This is a weird circumstance, please file an issue!', + // ) + const [load] = Solid.createResource( + () => router.getMatch(match().id)?.loadPromise, + ) + + return <>{(load(), (<>))} + }} + + + {(_) => { + // If we're on the server, we need to use React's new and super + // wonky api for throwing errors from a server side render inside + // of a suspense boundary. This is the only way to get + // renderToPipeableStream to not hang indefinitely. + // We'll serialize the error and rethrow it on the client. + if (router.isServer) { + const RouteErrorComponent = + (route().options.errorComponent ?? + router.options.defaultErrorComponent) || + ErrorComponent + + return ( + + ) + } + + const m = match() + if (isServerSideError(m.error)) { + const deserializeError = + router.options.errorSerializer?.deserialize ?? + defaultDeserializeError + throw deserializeError(m.error.data) + } else { + throw m.error + } + }} + + + {(_) => { + // We're pending, and if we have a minPendingMs, we need to wait for it + const pendingMinMs = + route().options.pendingMinMs ?? router.options.defaultPendingMinMs + + if (pendingMinMs && !router.getMatch(match().id)?.minPendingPromise) { + // Create a promise that will resolve after the minPendingMs + if (!router.isServer) { + const minPendingPromise = createControlledPromise() + + Promise.resolve().then(() => { + router.updateMatch(match().id, (prev) => ({ + ...prev, + minPendingPromise, + })) + }) + + setTimeout(() => { + minPendingPromise.resolve() + + // We've handled the minPendingPromise, so we can delete it + router.updateMatch(match().id, (prev) => ({ + ...prev, + minPendingPromise: undefined, + })) + }, pendingMinMs) + } + } + + const [load] = Solid.createResource( + () => router.getMatch(match().id)?.loadPromise, + ) + + return <>{(load(), (<>))} + }} + + + + {router.AfterEachMatch ? ( + + ) : null} + + + ) +} + +export const Outlet = () => { + const router = useRouter() + const matchId = Solid.useContext(matchContext) + const routeId = useRouterState({ + select: (s) => s.matches.find((d) => d.id === matchId())?.routeId as string, + }) + + const route = () => router.routesById[routeId()]! + + const parentGlobalNotFound = useRouterState({ + select: (s) => { + const matches = s.matches + const parentMatch = matches.find((d) => d.id === matchId()) + invariant( + parentMatch, + `Could not find parent match for matchId "${matchId()}"`, + ) + return parentMatch.globalNotFound + }, + }) + + const childMatchId = useRouterState({ + select: (s) => { + const matches = s.matches + const index = matches.findIndex((d) => d.id === matchId()) + const v = matches[index + 1]?.id + console.warn('childMatchId: ', v) + return v + }, + }) + + return ( + + + {renderRouteNotFound(router, route(), undefined)} + + + {(matchId) => { + // const nextMatch = + + return ( + } + > + {/* + } + > + + */} + + ) + }} + + + ) +} diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx new file mode 100644 index 00000000000..0676960fe48 --- /dev/null +++ b/packages/solid-router/src/Matches.tsx @@ -0,0 +1,336 @@ +import * as Solid from 'solid-js' +import warning from 'tiny-warning' +import { Transitioner } from './Transitioner' +import { useRouter } from './useRouter' +import { useRouterState } from './useRouterState' +import { matchContext } from './matchContext' +import { CatchBoundary, ErrorComponent } from './CatchBoundary' +import { Match } from './Match' +import type { AnyRoute, StaticDataRouteOption } from './route' +import type { AnyRouter, RegisteredRouter, RouterState } from './router' +import type { ToOptions } from './link' +import type { + ControlledPromise, + DeepPartial, + NoInfer, + ParseRoute, + ResolveRelativePath, +} from '@tanstack/router-core' +import type { + AllContext, + AllLoaderData, + AllParams, + FullSearchSchema, + RouteById, + RouteByPath, + RouteIds, + RoutePaths, +} from './routeInfo' + +export type MakeRouteMatchFromRoute = RouteMatch< + TRoute['types']['id'], + TRoute['types']['fullPath'], + TRoute['types']['allParams'], + TRoute['types']['fullSearchSchema'], + TRoute['types']['loaderData'], + TRoute['types']['allContext'], + TRoute['types']['loaderDeps'] +> + +export interface RouteMatch< + out TRouteId, + out TFullPath, + out TAllParams, + out TFullSearchSchema, + out TLoaderData, + out TAllContext, + out TLoaderDeps, +> { + id: string + routeId: TRouteId + fullPath: TFullPath + index: number + pathname: string + params: TAllParams + status: 'pending' | 'success' | 'error' | 'redirected' | 'notFound' + isFetching: false | 'beforeLoad' | 'loader' + error: unknown + paramsError: unknown + searchError: unknown + updatedAt: number + loadPromise?: ControlledPromise + beforeLoadPromise?: ControlledPromise + loaderPromise?: ControlledPromise + loaderData?: TLoaderData + __routeContext: Record + __beforeLoadContext: Record + context: TAllContext + search: TFullSearchSchema + fetchCount: number + abortController: AbortController + cause: 'preload' | 'enter' | 'stay' + loaderDeps: TLoaderDeps + preload: boolean + invalid: boolean + meta?: Array + links?: Array + scripts?: Array + headers?: Record + globalNotFound?: boolean + staticData: StaticDataRouteOption + minPendingPromise?: ControlledPromise + pendingTimeout?: ReturnType +} + +export type MakeRouteMatch< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TRouteId = RouteIds, + TStrict extends boolean = true, +> = RouteMatch< + TRouteId, + RouteById['types']['fullPath'], + TStrict extends false + ? AllParams + : RouteById['types']['allParams'], + TStrict extends false + ? FullSearchSchema + : RouteById['types']['fullSearchSchema'], + TStrict extends false + ? AllLoaderData + : RouteById['types']['loaderData'], + TStrict extends false + ? AllContext + : RouteById['types']['allContext'], + RouteById['types']['loaderDeps'] +> + +export type AnyRouteMatch = RouteMatch + +export function Matches() { + const router = useRouter() + + const pendingElement = () => + router.options.defaultPendingComponent ? ( + + ) : null + + // Do not render a root Suspense during SSR or hydrating from SSR + + const inner = ( + + + + + ) + + return router.options.InnerWrap ? ( + {inner} + ) : ( + inner + ) +} + +function MatchesInner() { + const matchId = useRouterState({ + select: (s) => { + const v = s.matches[0]?.id + console.warn('MatchesInner matchId: ', v) + return v + }, + }) + + const resetKey = useRouterState({ + select: (s) => s.loadedAt, + }) + + return ( + + { + 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()) + }} + > + + {(matchId) => } + + + + ) +} + +export interface MatchRouteOptions { + pending?: boolean + caseSensitive?: boolean + includeSearch?: boolean + fuzzy?: boolean +} + +export type UseMatchRouteOptions< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths | string = RoutePaths< + TRouter['routeTree'] + >, + TTo extends string = '', + TMaskFrom extends RoutePaths | string = TFrom, + TMaskTo extends string = '', + TOptions extends ToOptions< + TRouter, + TFrom, + TTo, + TMaskFrom, + TMaskTo + > = ToOptions, + TRelaxedOptions = Omit & + DeepPartial>, +> = TRelaxedOptions & MatchRouteOptions + +export function useMatchRoute() { + const router = useRouter() + + useRouterState({ + select: (s) => [s.location.href, s.resolvedLocation.href, s.status], + }) + + return < + TFrom extends RoutePaths | string = string, + TTo extends string = '', + TMaskFrom extends RoutePaths | string = TFrom, + TMaskTo extends string = '', + TResolved extends string = ResolveRelativePath>, + >( + opts: UseMatchRouteOptions, + ): + | false + | RouteByPath['types']['allParams'] => { + const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts + + return router.matchRoute(rest as any, { + pending, + caseSensitive, + fuzzy, + includeSearch, + }) + } +} + +export type MakeMatchRouteOptions< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths = RoutePaths< + TRouter['routeTree'] + >, + TTo extends string = '', + TMaskFrom extends RoutePaths = TFrom, + TMaskTo extends string = '', +> = UseMatchRouteOptions & { + // If a function is passed as a child, it will be given the `isActive` boolean to aid in further styling on the element it returns + children?: + | (( + params?: RouteByPath< + TRouter['routeTree'], + ResolveRelativePath> + >['types']['allParams'], + ) => Solid.JSXElement) + | Solid.JSXElement +} + +export function MatchRoute< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends RoutePaths = RoutePaths< + TRouter['routeTree'] + >, + TTo extends string = '', + TMaskFrom extends RoutePaths = TFrom, + TMaskTo extends string = '', +>(props: MakeMatchRouteOptions): any { + const matchRoute = useMatchRoute() + const params = matchRoute(props as any) + + if (typeof props.children === 'function') { + return (props.children as any)(params) + } + + return params ? props.children : null +} + +export type MakeRouteMatchUnion< + TRouter extends AnyRouter = RegisteredRouter, + TRoute extends AnyRoute = ParseRoute, +> = TRoute extends any + ? RouteMatch< + TRoute['id'], + TRoute['fullPath'], + TRoute['types']['allParams'], + TRoute['types']['fullSearchSchema'], + TRoute['types']['loaderData'], + TRoute['types']['allContext'], + TRoute['types']['loaderDeps'] + > + : never + +export interface UseMatchesBaseOptions { + select?: (matches: Array>) => TSelected +} + +export type UseMatchesResult< + TRouter extends AnyRouter, + TSelected, +> = unknown extends TSelected ? Array> : TSelected + +export function useMatches< + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseMatchesBaseOptions, +): UseMatchesResult { + return useRouterState({ + select: (state: RouterState) => { + const matches = state.matches + return opts?.select + ? opts.select(matches as Array>) + : matches + }, + } as any) as UseMatchesResult +} + +export function useParentMatches< + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseMatchesBaseOptions, +): UseMatchesResult { + const contextMatchId = Solid.useContext(matchContext) + + return useMatches({ + select: (matches: Array>) => { + matches = matches.slice( + 0, + matches.findIndex((d) => d.id === contextMatchId()), + ) + return opts?.select ? opts.select(matches) : matches + }, + } as any) +} + +export function useChildMatches< + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseMatchesBaseOptions, +): UseMatchesResult { + const contextMatchId = Solid.useContext(matchContext) + + return useMatches({ + select: (matches: Array>) => { + matches = matches.slice( + matches.findIndex((d) => d.id === contextMatchId()) + 1, + ) + return opts?.select ? opts.select(matches) : matches + }, + } as any) +} diff --git a/packages/solid-router/src/RouterProvider.tsx b/packages/solid-router/src/RouterProvider.tsx new file mode 100644 index 00000000000..93d6c8574a2 --- /dev/null +++ b/packages/solid-router/src/RouterProvider.tsx @@ -0,0 +1,111 @@ +import * as Solid from 'solid-js' +import { Dynamic } from 'solid-js/web' +import { Matches } from './Matches' +import { getRouterContext } from './routerContext' +import type { NavigateOptions, ToOptions } from './link' +import type { ParsedLocation } from '@tanstack/router-core' +import type { RoutePaths } from './routeInfo' +import type { + AnyRouter, + RegisteredRouter, + Router, + RouterOptions, +} from './router' + +export * from '@tanstack/router-core' + +export type NavigateFn = < + TRouter extends RegisteredRouter, + TTo extends string | undefined, + TFrom extends RoutePaths | string = string, + TMaskFrom extends RoutePaths | string = TFrom, + TMaskTo extends string = '', +>( + opts: NavigateOptions, +) => Promise | void + +export type BuildLocationFn = < + TRouter extends RegisteredRouter, + TTo extends string | undefined, + TFrom extends RoutePaths | string = string, + TMaskFrom extends RoutePaths | string = TFrom, + TMaskTo extends string = '', +>( + opts: ToOptions & { + leaveParams?: boolean + _includeValidateSearch?: boolean + }, +) => ParsedLocation + +export type InjectedHtmlEntry = string | (() => Promise | string) + +export function RouterContextProvider< + TRouter extends AnyRouter = RegisteredRouter, + TDehydrated extends Record = Record, +>(props: RouterProps & Solid.ParentProps) { + const [local, rest] = Solid.splitProps(props, ['router', 'children']) + + Solid.createMemo(() => { + local.router.update({ + ...local.router.options, + ...rest, + context: { + ...local.router.options.context, + ...rest.context, + }, + } as any) + }) + + const routerContext = getRouterContext() + + const provider = ( + + {local.children} + + ) + + return ( + + {(wrap) => {provider}} + + ) +} + +export function RouterProvider< + TRouter extends AnyRouter = RegisteredRouter, + TDehydrated extends Record = Record, +>(props: RouterProps) { + const [local, rest] = Solid.splitProps(props, ['router']) + return ( + + + + ) +} + +export type RouterProps< + TRouter extends AnyRouter = RegisteredRouter, + TDehydrated extends Record = Record, +> = Omit< + RouterOptions< + TRouter['routeTree'], + NonNullable, + TRouter['history'], + TDehydrated + >, + 'context' +> & { + router: Router< + TRouter['routeTree'], + NonNullable, + TRouter['history'] + > + context?: Partial< + RouterOptions< + TRouter['routeTree'], + NonNullable, + TRouter['history'], + TDehydrated + >['context'] + > +} diff --git a/packages/solid-router/src/SafeFragment.tsx b/packages/solid-router/src/SafeFragment.tsx new file mode 100644 index 00000000000..f95bf0d2b32 --- /dev/null +++ b/packages/solid-router/src/SafeFragment.tsx @@ -0,0 +1,3 @@ +export function SafeFragment(props: any) { + return <>{props.children} +} diff --git a/packages/solid-router/src/ScriptOnce.tsx b/packages/solid-router/src/ScriptOnce.tsx new file mode 100644 index 00000000000..36d198f5f16 --- /dev/null +++ b/packages/solid-router/src/ScriptOnce.tsx @@ -0,0 +1,33 @@ +import jsesc from 'jsesc' +import { splitProps } from 'solid-js' +import type { ComponentProps } from 'solid-js' + +export function ScriptOnce( + props: { children: string; log?: boolean } & Omit< + ComponentProps<'script'>, + 'children' + >, +) { + if (typeof document !== 'undefined') { + return null + } + + const [local, rest] = splitProps(props, ['class', 'children', 'log']) + + return ( + `, + ) + } + + streamedKeys: Set = new Set() + + getStreamedValue = (key: string): T | undefined => { + if (this.isServer) { + return undefined + } + + const streamedValue = window.__TSR__?.streamedValues[key] + + if (!streamedValue) { + return + } + + if (!streamedValue.parsed) { + streamedValue.parsed = this.options.transformer.parse(streamedValue.value) + } + + return streamedValue.parsed + } + + streamValue = (key: string, value: any) => { + warning( + !this.streamedKeys.has(key), + 'Key has already been streamed: ' + key, + ) + + this.streamedKeys.add(key) + this.injectScript( + `__TSR__.streamedValues['${key}'] = { value: ${this.serializer?.(this.options.transformer.stringify(value))}}`, + ) + } + + _handleNotFound = ( + matches: Array, + err: NotFoundError, + { + updateMatch = this.updateMatch, + }: { + updateMatch?: ( + id: string, + updater: (match: AnyRouteMatch) => AnyRouteMatch, + ) => void + } = {}, + ) => { + const matchesByRouteId = Object.fromEntries( + matches.map((match) => [match.routeId, match]), + ) as Record + + // Start at the route that errored or default to the root route + let routeCursor = + (err.global + ? this.looseRoutesById[rootRouteId] + : this.looseRoutesById[err.routeId]) || + this.looseRoutesById[rootRouteId]! + + // Go up the tree until we find a route with a notFoundComponent or we hit the root + while ( + !routeCursor.options.notFoundComponent && + !this.options.defaultNotFoundComponent && + routeCursor.id !== rootRouteId + ) { + routeCursor = routeCursor.parentRoute + + invariant( + routeCursor, + 'Found invalid route tree while trying to find not-found handler.', + ) + } + + const match = matchesByRouteId[routeCursor.id] + + invariant(match, 'Could not find match for route: ' + routeCursor.id) + + // Assign the error to the match + + updateMatch(match.id, (prev) => ({ + ...prev, + status: 'notFound', + error: err, + isFetching: false, + })) + + if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) { + err.routeId = routeCursor.parentRoute.id + this._handleNotFound(matches, err, { + updateMatch, + }) + } + } + + hasNotFoundMatch = () => { + return this.__store.state.matches.some( + (d) => d.status === 'notFound' || d.globalNotFound, + ) + } +} + +// A function that takes an import() argument which is a function and returns a new function that will +// proxy arguments from the caller to the imported function, retaining all type +// information along the way +export function lazyFn< + T extends Record) => any>, + TKey extends keyof T = 'default', +>(fn: () => Promise, key?: TKey) { + return async ( + ...args: Parameters + ): Promise>> => { + const imported = await fn() + return imported[key || 'default'](...args) + } +} + +export class SearchParamError extends Error {} + +export class PathParamError extends Error {} + +export function getInitialRouterState( + location: ParsedLocation, +): RouterState { + return { + loadedAt: 0, + isLoading: false, + isTransitioning: false, + status: 'idle', + resolvedLocation: { ...location }, + location, + matches: [], + pendingMatches: [], + cachedMatches: [], + statusCode: 200, + } +} diff --git a/packages/solid-router/src/routerContext.tsx b/packages/solid-router/src/routerContext.tsx new file mode 100644 index 00000000000..4450c668d35 --- /dev/null +++ b/packages/solid-router/src/routerContext.tsx @@ -0,0 +1,20 @@ +import * as Solid from 'solid-js' +import type { Router } from './router' + +const routerContext = Solid.createContext>( + null as unknown as Router, +) + +export function getRouterContext() { + if (typeof document === 'undefined') { + return routerContext + } + + if (window.__TSR_ROUTER_CONTEXT__) { + return window.__TSR_ROUTER_CONTEXT__ + } + + window.__TSR_ROUTER_CONTEXT__ = routerContext as any + + return routerContext +} diff --git a/packages/solid-router/src/scroll-restoration.tsx b/packages/solid-router/src/scroll-restoration.tsx new file mode 100644 index 00000000000..4f34c8c8463 --- /dev/null +++ b/packages/solid-router/src/scroll-restoration.tsx @@ -0,0 +1,237 @@ +import * as Solid from 'solid-js' +import { functionalUpdate } from '@tanstack/router-core' +import { useRouter } from './useRouter' +import type { ParsedLocation } from '@tanstack/router-core' +import type { NonNullableUpdater } from '@tanstack/router-core' + +const windowKey = 'window' +const delimiter = '___' + +let weakScrolledElements = new WeakSet() + +type CacheValue = Record +type CacheState = { + cached: CacheValue + next: CacheValue +} + +type Cache = { + state: CacheState + set: (updater: NonNullableUpdater) => void +} + +const sessionsStorage = typeof window !== 'undefined' && window.sessionStorage + +const cache: Cache = sessionsStorage + ? (() => { + const storageKey = 'tsr-scroll-restoration-v2' + + const state: CacheState = JSON.parse( + window.sessionStorage.getItem(storageKey) || 'null', + ) || { cached: {}, next: {} } + + return { + state, + set: (updater) => { + cache.state = functionalUpdate(updater, cache.state) + window.sessionStorage.setItem(storageKey, JSON.stringify(cache.state)) + }, + } + })() + : (undefined as any) + +export type ScrollRestorationOptions = { + getKey?: (location: ParsedLocation) => string +} + +/** + * The default `getKey` function for `useScrollRestoration`. + * It returns the `key` from the location state or the `href` of the location. + * + * The `location.href` is used as a fallback to support the use case where the location state is not available like the initial render. + */ +const defaultGetKey = (location: ParsedLocation) => { + return location.state.key! || location.href +} + +export function useScrollRestoration(options?: ScrollRestorationOptions) { + const router = useRouter() + + Solid.createEffect( + Solid.on( + () => options?.getKey || defaultGetKey, + (getKey) => { + const { history } = window + history.scrollRestoration = 'manual' + + const onScroll = (event: Event) => { + if (weakScrolledElements.has(event.target)) return + weakScrolledElements.add(event.target) + + let elementSelector = '' + + if (event.target === document || event.target === window) { + elementSelector = windowKey + } else { + const attrId = (event.target as Element).getAttribute( + 'data-scroll-restoration-id', + ) + + if (attrId) { + elementSelector = `[data-scroll-restoration-id="${attrId}"]` + } else { + elementSelector = getCssSelector(event.target) + } + } + + if (!cache.state.next[elementSelector]) { + cache.set((c) => ({ + ...c, + next: { + ...c.next, + [elementSelector]: { + scrollX: NaN, + scrollY: NaN, + }, + }, + })) + } + } + + if (typeof document !== 'undefined') { + document.addEventListener('scroll', onScroll, true) + } + + const unsubOnBeforeLoad = router.subscribe('onBeforeLoad', (event) => { + if (event.hrefChanged) { + const restoreKey = getKey(event.fromLocation) + for (const elementSelector in cache.state.next) { + const entry = cache.state.next[elementSelector]! + if (elementSelector === windowKey) { + entry.scrollX = window.scrollX || 0 + entry.scrollY = window.scrollY || 0 + } else if (elementSelector) { + const element = document.querySelector(elementSelector) + entry.scrollX = element?.scrollLeft || 0 + entry.scrollY = element?.scrollTop || 0 + } + + cache.set((c) => { + const next = { ...c.next } + delete next[elementSelector] + + return { + ...c, + next, + cached: { + ...c.cached, + [[restoreKey, elementSelector].join(delimiter)]: entry, + }, + } + }) + } + } + }) + + const unsubOnBeforeRouteMount = router.subscribe( + 'onBeforeRouteMount', + (event) => { + if (event.hrefChanged) { + if (!router.resetNextScroll) { + return + } + + router.resetNextScroll = true + + const restoreKey = getKey(event.toLocation) + let windowRestored = false + + for (const cacheKey in cache.state.cached) { + const entry = cache.state.cached[cacheKey]! + const [key, elementSelector] = cacheKey.split(delimiter) + if (key === restoreKey) { + if (elementSelector === windowKey) { + windowRestored = true + window.scrollTo(entry.scrollX, entry.scrollY) + } else if (elementSelector) { + const element = document.querySelector(elementSelector) + if (element) { + element.scrollLeft = entry.scrollX + element.scrollTop = entry.scrollY + } + } + } + } + + if (!windowRestored) { + window.scrollTo(0, 0) + } + + cache.set((c) => ({ ...c, next: {} })) + weakScrolledElements = new WeakSet() + } + }, + ) + + Solid.onCleanup(() => { + document.removeEventListener('scroll', onScroll) + unsubOnBeforeLoad() + unsubOnBeforeRouteMount() + }) + }, + ), + ) +} + +export function ScrollRestoration(props: ScrollRestorationOptions) { + useScrollRestoration(props) + return null +} + +export function useElementScrollRestoration( + options: ( + | { + id: string + getElement?: () => Element | undefined | null + } + | { + id?: string + getElement: () => Element | undefined | null + } + ) & { + getKey?: (location: ParsedLocation) => string + }, +) { + const router = useRouter() + const getKey = options.getKey || defaultGetKey + + let elementSelector = '' + + if (options.id) { + elementSelector = `[data-scroll-restoration-id="${options.id}"]` + } else { + const element = options.getElement?.() + if (!element) { + return + } + elementSelector = getCssSelector(element) + } + + const restoreKey = getKey(router.latestLocation) + const cacheKey = [restoreKey, elementSelector].join(delimiter) + return cache.state.cached[cacheKey] +} + +function getCssSelector(el: any): string { + const path = [] + let parent + while ((parent = el.parentNode)) { + path.unshift( + `${el.tagName}:nth-child(${ + ([].indexOf as any).call(parent.children, el) + 1 + })`, + ) + el = parent + } + return `${path.join(' > ')}`.toLowerCase() +} diff --git a/packages/solid-router/src/typePrimitives.ts b/packages/solid-router/src/typePrimitives.ts new file mode 100644 index 00000000000..70010f16f5f --- /dev/null +++ b/packages/solid-router/src/typePrimitives.ts @@ -0,0 +1,157 @@ +import type { + FromPathOption, + NavigateOptions, + PathParamOptions, + SearchParamOptions, + ToPathOption, +} from './link' +import type { RouteIds } from './routeInfo' +import type { AnyRouter, RegisteredRouter } from './router' +import type { UseParamsOptions, UseParamsResult } from './useParams' +import type { UseSearchOptions, UseSearchResult } from './useSearch' +import type { Constrain, ConstrainLiteral } from '@tanstack/router-core' + +export type ValidateFromPath< + TFrom, + TRouter extends AnyRouter = RegisteredRouter, +> = FromPathOption + +export type ValidateToPath< + TTo extends string | undefined, + TFrom extends string = string, + TRouter extends AnyRouter = RegisteredRouter, +> = ToPathOption + +export type ValidateSearch< + TTo extends string | undefined, + TFrom extends string = string, + TRouter extends AnyRouter = RegisteredRouter, +> = SearchParamOptions + +export type ValidateParams< + TTo extends string | undefined, + TFrom extends string = string, + TRouter extends AnyRouter = RegisteredRouter, +> = PathParamOptions + +/** + * @internal + */ +export type InferFrom = TOptions extends { + from: infer TFrom extends string +} + ? TFrom + : string + +/** + * @internal + */ +export type InferTo = TOptions extends { + to: infer TTo extends string +} + ? TTo + : undefined + +/** + * @internal + */ +export type InferMaskTo = TOptions extends { + mask: { to: infer TTo extends string } +} + ? TTo + : string + +export type InferMaskFrom = TOptions extends { + mask: { from: infer TFrom extends string } +} + ? TFrom + : string + +export type ValidateNavigateOptions< + TOptions, + TRouter extends AnyRouter = RegisteredRouter, +> = Constrain< + TOptions, + NavigateOptions< + TRouter, + InferFrom, + InferTo, + InferMaskFrom, + InferMaskTo + > +> + +export type ValidateNavigateOptionsArray> = + { [K in keyof TOptions]: ValidateNavigateOptions } + +export type ValidateId< + TId extends string, + TRouter extends AnyRouter = RegisteredRouter, +> = ConstrainLiteral> + +/** + * @internal + */ +export type InferStrict = TOptions extends { + strict: infer TStrict extends boolean +} + ? TStrict + : true + +/** + * @internal + */ +export type InferSelected = TOptions extends { + select: (...args: Array) => infer TSelected +} + ? TSelected + : unknown + +export type ValidateUseSearchOptions< + TOptions, + TRouter extends AnyRouter = RegisteredRouter, +> = Constrain< + TOptions, + UseSearchOptions< + TRouter, + InferFrom, + InferStrict, + InferSelected + > +> + +export type ValidateUseSearchResult< + TOptions, + TRouter extends AnyRouter = RegisteredRouter, +> = UseSearchResult< + TRouter, + InferFrom, + InferStrict, + InferSelected +> + +export type ValidateUseParamsOptions< + TOptions, + TRouter extends AnyRouter = RegisteredRouter, +> = Constrain< + TOptions, + UseParamsOptions< + TRouter, + InferFrom, + InferStrict, + InferSelected + > +> + +export type ValidateUseParamsResult< + TOptions, + TRouter extends AnyRouter = RegisteredRouter, +> = Constrain< + TOptions, + UseParamsResult< + TRouter, + InferFrom, + InferStrict, + InferSelected + > +> diff --git a/packages/solid-router/src/useBlocker.tsx b/packages/solid-router/src/useBlocker.tsx new file mode 100644 index 00000000000..754f90f82c7 --- /dev/null +++ b/packages/solid-router/src/useBlocker.tsx @@ -0,0 +1,295 @@ +import * as Solid from 'solid-js' +import { useRouter } from './useRouter' +import type { + BlockerFnArgs, + HistoryAction, + HistoryLocation, +} from '@tanstack/history' +import type { AnyRoute } from './route' +import type { ParseRoute } from '@tanstack/router-core' +import type { AnyRouter, RegisteredRouter } from './router' + +interface ShouldBlockFnLocation< + out TRouteId, + out TFullPath, + out TAllParams, + out TFullSearchSchema, +> { + routeId: TRouteId + fullPath: TFullPath + pathname: string + params: TAllParams + search: TFullSearchSchema +} + +type AnyShouldBlockFnLocation = ShouldBlockFnLocation +type MakeShouldBlockFnLocationUnion< + TRouter extends AnyRouter = RegisteredRouter, + TRoute extends AnyRoute = ParseRoute, +> = TRoute extends any + ? ShouldBlockFnLocation< + TRoute['id'], + TRoute['fullPath'], + TRoute['types']['allParams'], + TRoute['types']['fullSearchSchema'] + > + : never + +type BlockerResolver = + | { + status: 'blocked' + current: MakeShouldBlockFnLocationUnion + next: MakeShouldBlockFnLocationUnion + action: HistoryAction + proceed: () => void + reset: () => void + } + | { + status: 'idle' + current: undefined + next: undefined + action: undefined + proceed: undefined + reset: undefined + } + +type ShouldBlockFnArgs = { + current: MakeShouldBlockFnLocationUnion + next: MakeShouldBlockFnLocationUnion + action: HistoryAction +} + +export type ShouldBlockFn = ( + args: ShouldBlockFnArgs, +) => boolean | Promise +export type UseBlockerOpts< + TRouter extends AnyRouter = RegisteredRouter, + TWithResolver extends boolean = boolean, +> = { + shouldBlockFn: ShouldBlockFn + enableBeforeUnload?: boolean | (() => boolean) + disabled?: boolean + withResolver?: TWithResolver +} + +type LegacyBlockerFn = () => Promise | any +type LegacyBlockerOpts = { + blockerFn?: LegacyBlockerFn + condition?: boolean | any +} + +function _createResolveBlockerOpts( + opts?: UseBlockerOpts | LegacyBlockerOpts | LegacyBlockerFn, + condition?: Solid.Accessor, +): Solid.Accessor> { + function inner(): UseBlockerOpts { + if (opts === undefined) { + return { + shouldBlockFn: () => true, + withResolver: false, + } + } + + if ('shouldBlockFn' in opts) { + return opts + } + + if (typeof opts === 'function') { + const shouldBlock = Boolean(condition ?? true) + + const _customBlockerFn = async () => { + if (shouldBlock) return await opts() + return false + } + + return { + shouldBlockFn: _customBlockerFn, + enableBeforeUnload: shouldBlock, + withResolver: false, + } + } + + const shouldBlock = Boolean(opts.condition ?? true) + const fn = opts.blockerFn + + const _customBlockerFn = async () => { + if (shouldBlock && fn !== undefined) { + return await fn() + } + return shouldBlock + } + + return { + shouldBlockFn: _customBlockerFn, + enableBeforeUnload: shouldBlock, + withResolver: fn === undefined, + } + } + + return () => { + const v = inner() + + v.enableBeforeUnload ??= true + v.disabled ??= false + v.withResolver ??= false + + return v as Required + } +} + +export function useBlocker< + TRouter extends AnyRouter = RegisteredRouter, + TWithResolver extends boolean = false, +>( + opts: UseBlockerOpts, +): Solid.Accessor : void> + +export function useBlocker( + opts?: UseBlockerOpts, + condition?: boolean | any, +): Solid.Accessor { + const resolvedOpts = _createResolveBlockerOpts(opts, condition) + + const router = useRouter() + const { history } = router + + const [resolver, setResolver] = Solid.createSignal({ + status: 'idle', + current: undefined, + next: undefined, + action: undefined, + proceed: undefined, + reset: undefined, + }) + + Solid.createEffect( + Solid.on( + () => resolvedOpts(), + ({ disabled, enableBeforeUnload, shouldBlockFn, withResolver }) => { + const blockerFnComposed = async (blockerFnArgs: BlockerFnArgs) => { + function getLocation( + location: HistoryLocation, + ): AnyShouldBlockFnLocation { + const parsedLocation = router.parseLocation(undefined, location) + const matchedRoutes = router.getMatchedRoutes(parsedLocation) + if (matchedRoutes.foundRoute === undefined) { + throw new Error(`No route found for location ${location.href}`) + } + return { + routeId: matchedRoutes.foundRoute.id, + fullPath: matchedRoutes.foundRoute.fullPath, + pathname: parsedLocation.pathname, + params: matchedRoutes.routeParams, + search: parsedLocation.search, + } + } + + const current = getLocation(blockerFnArgs.currentLocation) + const next = getLocation(blockerFnArgs.nextLocation) + + const shouldBlock = await shouldBlockFn({ + action: blockerFnArgs.action, + current, + next, + }) + if (!withResolver) { + return shouldBlock + } + + if (!shouldBlock) { + return false + } + + const promise = new Promise((resolve) => { + setResolver({ + status: 'blocked', + current, + next, + action: blockerFnArgs.action, + proceed: () => resolve(false), + reset: () => resolve(true), + }) + }) + + const canNavigateAsync = await promise + setResolver({ + status: 'idle', + current: undefined, + next: undefined, + action: undefined, + proceed: undefined, + reset: undefined, + }) + + return canNavigateAsync + } + + return disabled + ? undefined + : history.block({ blockerFn: blockerFnComposed, enableBeforeUnload }) + }, + ), + ) + + return resolver +} + +const _resolvePromptBlockerArgs = ( + props: PromptProps | LegacyPromptProps, +): UseBlockerOpts => { + if ('shouldBlockFn' in props) { + return { ...props } + } + + const shouldBlock = Boolean(props.condition ?? true) + const fn = props.blockerFn + + const _customBlockerFn = async () => { + if (shouldBlock && fn !== undefined) { + return await fn() + } + return shouldBlock + } + + return { + shouldBlockFn: _customBlockerFn, + enableBeforeUnload: shouldBlock, + withResolver: fn === undefined, + } +} + +export function Block< + TRouter extends AnyRouter = RegisteredRouter, + TWithResolver extends boolean = boolean, +>(opts: PromptProps): React.ReactNode + +/** + * @deprecated Use the UseBlockerOpts property instead + */ +export function Block(opts: LegacyPromptProps): React.ReactNode + +export function Block(opts: PromptProps | LegacyPromptProps): React.ReactNode { + const { children, ...rest } = opts + const args = _resolvePromptBlockerArgs(rest) + + const resolver = useBlocker(args) + return children + ? typeof children === 'function' + ? children(resolver as any) + : children + : null +} + +type LegacyPromptProps = { + blockerFn?: LegacyBlockerFn + condition?: boolean | any + children?: React.ReactNode | ((params: BlockerResolver) => React.ReactNode) +} + +type PromptProps< + TRouter extends AnyRouter = RegisteredRouter, + TWithResolver extends boolean = boolean, + TParams = TWithResolver extends true ? BlockerResolver : void, +> = UseBlockerOpts & { + children?: React.ReactNode | ((params: TParams) => React.ReactNode) +} diff --git a/packages/solid-router/src/useLoaderData.tsx b/packages/solid-router/src/useLoaderData.tsx new file mode 100644 index 00000000000..5f369db1b28 --- /dev/null +++ b/packages/solid-router/src/useLoaderData.tsx @@ -0,0 +1,66 @@ +import * as Solid from 'solid-js' +import { useMatch } from './useMatch' +import type { AnyRouter, RegisteredRouter } from './router' +import type { AllLoaderData, RouteById } from './routeInfo' +import type { StrictOrFrom } from './utils' +import type { Expand } from '@tanstack/router-core' + +export interface UseLoaderDataBaseOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> { + select?: (match: ResolveLoaderData) => TSelected +} + +export type UseLoaderDataOptions< + TRouter extends AnyRouter, + TFrom extends string | undefined, + TStrict extends boolean, + TSelected, +> = StrictOrFrom & + UseLoaderDataBaseOptions + +export type ResolveLoaderData< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, +> = TStrict extends false + ? AllLoaderData + : Expand['types']['loaderData']> + +export type UseLoaderDataResult< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> = unknown extends TSelected + ? ResolveLoaderData + : TSelected + +export type UseLoaderDataRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseLoaderDataBaseOptions, +) => Solid.Accessor> + +export function useLoaderData< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends string | undefined = undefined, + TStrict extends boolean = true, + TSelected = unknown, +>( + opts: UseLoaderDataOptions, +): Solid.Accessor> { + return useMatch({ + from: opts.from!, + strict: opts.strict, + select: (s: any) => { + return opts.select ? opts.select(s.loaderData) : s.loaderData + }, + } as any) as Solid.Accessor< + UseLoaderDataResult + > +} diff --git a/packages/solid-router/src/useLoaderDeps.tsx b/packages/solid-router/src/useLoaderDeps.tsx new file mode 100644 index 00000000000..3384b0714db --- /dev/null +++ b/packages/solid-router/src/useLoaderDeps.tsx @@ -0,0 +1,53 @@ +import { useMatch } from './useMatch' +import type { AnyRouter, RegisteredRouter } from './router' +import type { RouteById } from './routeInfo' +import type { StrictOrFrom } from './utils' +import type { Expand } from '@tanstack/router-core' + +export interface UseLoaderDepsBaseOptions< + TRouter extends AnyRouter, + TFrom, + TSelected, +> { + select?: (deps: ResolveLoaderDeps) => TSelected +} + +export type UseLoaderDepsOptions< + TRouter extends AnyRouter, + TFrom extends string | undefined, + TSelected, +> = StrictOrFrom & + UseLoaderDepsBaseOptions + +export type ResolveLoaderDeps = Expand< + RouteById['types']['loaderDeps'] +> + +export type UseLoaderDepsResult< + TRouter extends AnyRouter, + TFrom, + TSelected, +> = unknown extends TSelected ? ResolveLoaderDeps : TSelected + +export type UseLoaderDepsRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseLoaderDepsBaseOptions, +) => UseLoaderDepsResult + +export function useLoaderDeps< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends string | undefined = undefined, + TSelected = unknown, +>( + opts: UseLoaderDepsOptions, +): UseLoaderDepsResult { + const { select, ...rest } = opts + return useMatch({ + ...rest, + select: (s) => { + return select ? select(s.loaderDeps) : s.loaderDeps + }, + }) as UseLoaderDepsResult +} diff --git a/packages/solid-router/src/useLocation.tsx b/packages/solid-router/src/useLocation.tsx new file mode 100644 index 00000000000..e792548cb7d --- /dev/null +++ b/packages/solid-router/src/useLocation.tsx @@ -0,0 +1,25 @@ +import { useRouterState } from './useRouterState' +import type { AnyRouter, RegisteredRouter, RouterState } from './router' + +export interface UseLocationBaseOptions { + select?: (state: RouterState['location']) => TSelected // TODO: might need to ValidateJSON here +} + +export type UseLocationResult< + TRouter extends AnyRouter, + TSelected, +> = unknown extends TSelected + ? RouterState['location'] + : TSelected + +export function useLocation< + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseLocationBaseOptions, +): UseLocationResult { + return useRouterState({ + select: (state: any) => + opts?.select ? opts.select(state.location) : state.location, + } as any) as UseLocationResult +} diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx new file mode 100644 index 00000000000..182719726d8 --- /dev/null +++ b/packages/solid-router/src/useMatch.tsx @@ -0,0 +1,94 @@ +import * as Solid from 'solid-js' +import invariant from 'tiny-invariant' +import { useRouterState } from './useRouterState' +import { matchContext } from './matchContext' +import type { AnyRouter, RegisteredRouter } from './router' +import type { MakeRouteMatch, MakeRouteMatchUnion } from './Matches' +import type { StrictOrFrom } from './utils' +import type { ThrowOrOptional } from '@tanstack/router-core' + +export interface UseMatchBaseOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TThrow, + TSelected, +> { + // TODO: may not even be necessary bc solid is fine grained + select?: ( + match: MakeRouteMatch, + ) => TSelected // TODO: might need to ValidateJSON here + shouldThrow?: TThrow +} + +export type UseMatchRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseMatchBaseOptions, +) => UseMatchResult + +export type UseMatchOptions< + TRouter extends AnyRouter, + TFrom extends string | undefined, + TStrict extends boolean, + TSelected, + TThrow extends boolean, +> = StrictOrFrom & + UseMatchBaseOptions +export type UseMatchResult< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> = unknown extends TSelected + ? TStrict extends true + ? MakeRouteMatch + : MakeRouteMatchUnion + : TSelected + +type ThrowConstraint< + TStrict extends boolean, + TThrow extends boolean, +> = TStrict extends false ? (TThrow extends true ? never : TThrow) : TThrow + +export function useMatch< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends string | undefined = undefined, + TStrict extends boolean = true, + TThrow extends boolean = true, + TSelected = unknown, +>( + // TODO: may need to be an accessor + opts: UseMatchOptions< + TRouter, + TFrom, + TStrict, + TSelected, + ThrowConstraint + >, +): Solid.Accessor< + ThrowOrOptional, TThrow> +> { + const nearestMatchId = opts.from ? undefined : Solid.useContext(matchContext) + + const matchSelection = useRouterState({ + select: (state: any) => { + const match = state.matches.find((d: any) => + opts.from ? opts.from === d.routeId : d.id === nearestMatchId?.(), + ) + invariant( + !((opts.shouldThrow ?? true) && !match), + `Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, + ) + + if (match === undefined) { + return undefined + } + + return opts.select ? opts.select(match) : match + }, + } as any) + + return matchSelection as any +} diff --git a/packages/solid-router/src/useNavigate.tsx b/packages/solid-router/src/useNavigate.tsx new file mode 100644 index 00000000000..37f095d863f --- /dev/null +++ b/packages/solid-router/src/useNavigate.tsx @@ -0,0 +1,61 @@ +import * as Solid from 'solid-js' +import { useRouter } from './useRouter' +import type { FromPathOption, NavigateOptions } from './link' +import type { AnyRouter, RegisteredRouter } from './router' + +export type UseNavigateResult = < + TRouter extends RegisteredRouter, + TTo extends string | undefined, + TFrom extends string = TDefaultFrom, + TMaskFrom extends string = TFrom, + TMaskTo extends string = '', +>({ + from, + ...rest +}: NavigateOptions) => Promise + +export function useNavigate< + TRouter extends AnyRouter = RegisteredRouter, + TDefaultFrom extends string = string, +>(_defaultOpts?: { + from?: FromPathOption +}): UseNavigateResult { + const { navigate } = useRouter() + + return ((options: NavigateOptions) => { + return navigate({ ...options }) + }) as UseNavigateResult +} + +// NOTE: I don't know of anyone using this. It's undocumented, so let's wait until someone needs it +// export function typedNavigate< +// TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], +// TDefaultFrom extends RoutePaths = '/', +// >(navigate: (opts: NavigateOptions) => Promise) { +// return navigate as < +// TFrom extends RoutePaths = TDefaultFrom, +// TTo extends string = '', +// TMaskFrom extends RoutePaths = '/', +// TMaskTo extends string = '', +// >( +// opts?: NavigateOptions, +// ) => Promise +// } // + +export function Navigate< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends string = string, + TTo extends string | undefined = '.', + TMaskFrom extends string = TFrom, + TMaskTo extends string = '.', +>(props: NavigateOptions): null { + const { navigate } = useRouter() + + Solid.onMount(() => { + navigate({ + ...props, + } as any) + }) + + return null +} diff --git a/packages/solid-router/src/useParams.tsx b/packages/solid-router/src/useParams.tsx new file mode 100644 index 00000000000..30f8ca1262e --- /dev/null +++ b/packages/solid-router/src/useParams.tsx @@ -0,0 +1,66 @@ +import type * as Solid from 'solid-js' +import { useMatch } from './useMatch' +import type { AllParams, RouteById } from './routeInfo' +import type { AnyRouter, RegisteredRouter } from './router' +import type { StrictOrFrom } from './utils' +import type { Expand } from '@tanstack/router-core' + +export interface UseParamsBaseOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> { + select?: (params: ResolveParams) => TSelected // TODO: might need to ValidateJSON here +} + +export type UseParamsOptions< + TRouter extends AnyRouter, + TFrom extends string | undefined, + TStrict extends boolean, + TSelected, +> = StrictOrFrom & + UseParamsBaseOptions + +export type ResolveParams< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, +> = TStrict extends false + ? AllParams + : Expand['types']['allParams']> + +export type UseParamsResult< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> = unknown extends TSelected + ? ResolveParams + : TSelected + +export type UseParamsRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseParamsBaseOptions, +) => Solid.Accessor> + +export function useParams< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends string | undefined = undefined, + TStrict extends boolean = true, + TSelected = unknown, +>( + opts: UseParamsOptions, +): Solid.Accessor> { + return useMatch({ + from: opts.from!, + strict: opts.strict, + select: (match: any) => { + return opts.select ? opts.select(match.params) : match.params + }, + } as any) as Solid.Accessor< + UseParamsResult + > +} diff --git a/packages/solid-router/src/useRouteContext.ts b/packages/solid-router/src/useRouteContext.ts new file mode 100644 index 00000000000..c944099806e --- /dev/null +++ b/packages/solid-router/src/useRouteContext.ts @@ -0,0 +1,64 @@ +import * as Solid from 'solid-js' +import { useMatch } from './useMatch' +import type { AllContext, RouteById } from './routeInfo' +import type { AnyRouter, RegisteredRouter } from './router' +import type { StrictOrFrom } from './utils' +import type { Expand } from '@tanstack/router-core' + +export interface UseRouteContextBaseOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> { + select?: (search: ResolveRouteContext) => TSelected +} + +export type UseRouteContextOptions< + TRouter extends AnyRouter, + TFrom extends string | undefined, + TStrict extends boolean, + TSelected, +> = StrictOrFrom & + UseRouteContextBaseOptions + +export type ResolveRouteContext< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, +> = TStrict extends false + ? AllContext + : Expand['types']['allContext']> + +export type UseRouteContextResult< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> = unknown extends TSelected + ? ResolveRouteContext + : TSelected + +export type UseRouteContextRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseRouteContextBaseOptions, +) => UseRouteContextResult + +export function useRouteContext< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends string | undefined = undefined, + TStrict extends boolean = true, + TSelected = unknown, +>( + opts: UseRouteContextOptions, +): Solid.Accessor> { + return useMatch({ + ...(opts as any), + select: (match) => + opts.select ? opts.select(match.context) : match.context, + }) as Solid.Accessor< + UseRouteContextResult + > +} diff --git a/packages/solid-router/src/useRouter.tsx b/packages/solid-router/src/useRouter.tsx new file mode 100644 index 00000000000..8916bf4bbec --- /dev/null +++ b/packages/solid-router/src/useRouter.tsx @@ -0,0 +1,15 @@ +import * as Solid from 'solid-js' +import warning from 'tiny-warning' +import { getRouterContext } from './routerContext' +import type { AnyRouter, RegisteredRouter } from './router' + +export function useRouter(opts?: { + warn?: boolean +}): TRouter { + const value = Solid.useContext(getRouterContext()) + warning( + !((opts?.warn ?? true) && !value), + 'useRouter must be used inside a component!', + ) + return value as any +} diff --git a/packages/solid-router/src/useRouterState.tsx b/packages/solid-router/src/useRouterState.tsx new file mode 100644 index 00000000000..80384976f6b --- /dev/null +++ b/packages/solid-router/src/useRouterState.tsx @@ -0,0 +1,32 @@ +import { useStore } from '@tanstack/solid-store' +import { useRouter } from './useRouter' +import type { Accessor } from 'solid-js' +import type { AnyRouter, RegisteredRouter, RouterState } from './router' + +export type UseRouterStateOptions = { + router?: TRouter + select?: (state: RouterState) => TSelected // TODO: might need to ValidateJSON here +} + +export type UseRouterStateResult< + TRouter extends AnyRouter, + TSelected, +> = unknown extends TSelected ? RouterState : TSelected + +export function useRouterState< + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseRouterStateOptions, +): Accessor> { + const contextRouter = useRouter({ + warn: opts?.router === undefined, + }) + const router = opts?.router || contextRouter + + return useStore(router.__store, (state) => { + if (opts?.select) return opts.select(state) + + return state + }) as Accessor> +} diff --git a/packages/solid-router/src/useSearch.tsx b/packages/solid-router/src/useSearch.tsx new file mode 100644 index 00000000000..7717641fbc3 --- /dev/null +++ b/packages/solid-router/src/useSearch.tsx @@ -0,0 +1,65 @@ +import * as Solid from 'solid-js' +import { useMatch } from './useMatch' +import type { FullSearchSchema, RouteById } from './routeInfo' +import type { AnyRouter, RegisteredRouter } from './router' +import type { StrictOrFrom } from './utils' +import type { Expand } from '@tanstack/router-core' + +export interface UseSearchBaseOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> { + select?: (state: ResolveSearch) => TSelected // TODO: might need to ValidateJSON here +} + +export type UseSearchOptions< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> = StrictOrFrom & + UseSearchBaseOptions + +export type UseSearchResult< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, + TSelected, +> = unknown extends TSelected + ? ResolveSearch + : TSelected + +export type ResolveSearch< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean, +> = TStrict extends false + ? FullSearchSchema + : Expand['types']['fullSearchSchema']> + +export type UseSearchRoute = < + TRouter extends AnyRouter = RegisteredRouter, + TSelected = unknown, +>( + opts?: UseSearchBaseOptions, +) => Solid.Accessor> + +export function useSearch< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends string | undefined = undefined, + TStrict extends boolean = true, + TSelected = unknown, +>( + // TODO: may need to be an accessor + opts: UseSearchOptions, +): Solid.Accessor> { + return useMatch({ + from: opts.from!, + strict: opts.strict, + select: (match: any) => { + return opts.select ? opts.select(match.search) : match.search + }, + }) as Solid.Accessor> +} diff --git a/packages/solid-router/src/utils.ts b/packages/solid-router/src/utils.ts new file mode 100644 index 00000000000..c9cb5b4fcef --- /dev/null +++ b/packages/solid-router/src/utils.ts @@ -0,0 +1,100 @@ +import * as Solid from 'solid-js' +import type { RouteIds } from './routeInfo' +import type { AnyRouter } from './router' +import type { ConstrainLiteral } from '@tanstack/router-core' + +export type StrictOrFrom< + TRouter extends AnyRouter, + TFrom, + TStrict extends boolean = true, +> = TStrict extends false + ? { + from?: never + strict: TStrict + } + : { + from: ConstrainLiteral> + strict?: TStrict + } + +/** + * React hook to wrap `IntersectionObserver`. + * + * This hook will create an `IntersectionObserver` and observe the ref passed to it. + * + * When the intersection changes, the callback will be called with the `IntersectionObserverEntry`. + * + * @param ref - The ref to observe + * @param intersectionObserverOptions - The options to pass to the IntersectionObserver + * @param options - The options to pass to the hook + * @param callback - The callback to call when the intersection changes + * @returns The IntersectionObserver instance + * @example + * ```tsx + * const MyComponent = () => { + * const ref = React.useRef(null) + * useIntersectionObserver( + * ref, + * (entry) => { doSomething(entry) }, + * { rootMargin: '10px' }, + * { disabled: false } + * ) + * return
+ * ``` + */ +export function useIntersectionObserver( + ref: Solid.Accessor, + callback: (entry: IntersectionObserverEntry | undefined) => void, + intersectionObserverOptions: IntersectionObserverInit = {}, + options: { disabled?: boolean } = {}, +): Solid.Accessor { + const isIntersectionObserverAvailable = + typeof IntersectionObserver === 'function' + let observerRef: IntersectionObserver | null = null + + Solid.createEffect(() => { + const r = ref() + if (!r || !isIntersectionObserverAvailable || options.disabled) { + return + } + + observerRef = new IntersectionObserver(([entry]) => { + callback(entry) + }, intersectionObserverOptions) + + observerRef.observe(r) + + Solid.onCleanup(() => { + observerRef?.disconnect() + }) + }) + + return () => observerRef +} + +/** Call a JSX.EventHandlerUnion with the event. */ +export 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) + } + } + + return event.defaultPrevented +} + +/** Create a new event handler which calls all given handlers in the order they were chained with the same event. */ +export function composeEventHandlers( + handlers: Array | undefined>, +) { + return (event: any) => { + for (const handler of handlers) { + callHandler(event, handler) + } + } +} diff --git a/packages/solid-router/tests/Matches.test-d.tsx b/packages/solid-router/tests/Matches.test-d.tsx new file mode 100644 index 00000000000..00f8734d864 --- /dev/null +++ b/packages/solid-router/tests/Matches.test-d.tsx @@ -0,0 +1,247 @@ +import { expectTypeOf, test } from 'vitest' +import { + MatchRoute, + createRootRoute, + createRoute, + createRouter, + isMatch, + useMatchRoute, + useMatches, +} from '../src' +import type { AnyRouteMatch, RouteMatch } from '../src' + +const rootRoute = createRootRoute() + +type RootRoute = typeof rootRoute + +type RootMatch = RouteMatch< + RootRoute['id'], + RootRoute['fullPath'], + RootRoute['types']['allParams'], + RootRoute['types']['fullSearchSchema'], + RootRoute['types']['loaderData'], + RootRoute['types']['allContext'], + RootRoute['types']['loaderDeps'] +> + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +type IndexRoute = typeof indexRoute + +type IndexMatch = RouteMatch< + IndexRoute['id'], + IndexRoute['fullPath'], + IndexRoute['types']['allParams'], + IndexRoute['types']['fullSearchSchema'], + IndexRoute['types']['loaderData'], + IndexRoute['types']['allContext'], + IndexRoute['types']['loaderDeps'] +> + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + loader: () => [{ id: '1' }, { id: '2' }], +}) + +type InvoiceMatch = RouteMatch< + InvoiceRoute['id'], + InvoiceRoute['fullPath'], + InvoiceRoute['types']['allParams'], + InvoiceRoute['types']['fullSearchSchema'], + InvoiceRoute['types']['loaderData'], + InvoiceRoute['types']['allContext'], + InvoiceRoute['types']['loaderDeps'] +> + +type InvoicesRoute = typeof invoicesRoute + +type InvoicesMatch = RouteMatch< + InvoicesRoute['id'], + InvoicesRoute['fullPath'], + InvoicesRoute['types']['allParams'], + InvoicesRoute['types']['fullSearchSchema'], + InvoicesRoute['types']['loaderData'], + InvoicesRoute['types']['allContext'], + InvoicesRoute['types']['loaderDeps'] +> + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +type InvoicesIndexRoute = typeof invoicesIndexRoute + +type InvoicesIndexMatch = RouteMatch< + InvoicesIndexRoute['id'], + InvoicesIndexRoute['fullPath'], + InvoicesIndexRoute['types']['allParams'], + InvoicesIndexRoute['types']['fullSearchSchema'], + InvoicesIndexRoute['types']['loaderData'], + InvoicesIndexRoute['types']['allContext'], + InvoicesIndexRoute['types']['loaderDeps'] +> + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +type InvoiceRoute = typeof invoiceRoute + +const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', +}) + +type LayoutRoute = typeof layoutRoute + +type LayoutMatch = RouteMatch< + LayoutRoute['id'], + LayoutRoute['fullPath'], + LayoutRoute['types']['allParams'], + LayoutRoute['types']['fullSearchSchema'], + LayoutRoute['types']['loaderData'], + LayoutRoute['types']['allContext'], + LayoutRoute['types']['loaderDeps'] +> + +const commentsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'comments/$id', + validateSearch: () => ({ + page: 0, + search: '', + }), + loader: () => + [{ comment: 'one comment' }, { comment: 'two comment' }] as const, +}) + +type CommentsRoute = typeof commentsRoute + +type CommentsMatch = RouteMatch< + CommentsRoute['id'], + CommentsRoute['fullPath'], + CommentsRoute['types']['allParams'], + CommentsRoute['types']['fullSearchSchema'], + CommentsRoute['types']['loaderData'], + CommentsRoute['types']['allContext'], + CommentsRoute['types']['loaderDeps'] +> + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + layoutRoute.addChildren([commentsRoute]), +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +const useDefaultMatchRoute = useMatchRoute + +test('when matching a route with params', () => { + const matchRoute = useDefaultMatchRoute() + + expectTypeOf(matchRoute) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '/' + | '.' + | '..' + | '/invoices' + | '/invoices/$invoiceId' + | '/comments/$id' + | undefined + >() + + expectTypeOf(MatchRoute) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '/' + | '.' + | '..' + | '/invoices' + | '/invoices/$invoiceId' + | '/comments/$id' + | undefined + >() + + expectTypeOf( + matchRoute({ + to: '/invoices/$invoiceId', + }), + ).toEqualTypeOf() +}) + +test('when matching a route with params underneath a layout route', () => { + const matchRoute = useDefaultMatchRoute() + + expectTypeOf( + matchRoute({ + to: '/comments/$id', + }), + ).toEqualTypeOf() +}) + +test('useMatches returns a union of all matches', () => { + expectTypeOf(useMatches()).toEqualTypeOf< + Array< + | RootMatch + | IndexMatch + | InvoicesMatch + | InvoicesIndexMatch + | InvoiceMatch + | LayoutMatch + | CommentsMatch + > + > +}) + +test('when filtering useMatches by search', () => { + const matches = useMatches() + + expectTypeOf(isMatch<(typeof matches)[number], ''>) + .parameter(1) + .toEqualTypeOf() + + expectTypeOf(isMatch<(typeof matches)[number], 'search.'>).parameter(1) + .toEqualTypeOf<'search.page' | 'search.search'> + + expectTypeOf( + matches.filter((match) => isMatch(match, 'search.page')), + ).toEqualTypeOf>() +}) + +test('when filtering useMatches by loaderData with an array', () => { + const matches = useMatches() + + expectTypeOf(isMatch<(typeof matches)[number], ''>) + .parameter(1) + .toEqualTypeOf() + + expectTypeOf(isMatch<(typeof matches)[number], 'loaderData.'>) + .parameter(1) + .toEqualTypeOf<'loaderData.0' | 'loaderData.1' | `loaderData.${number}`>() + + expectTypeOf(isMatch<(typeof matches)[number], 'loaderData.0.'>).parameter(1) + .toEqualTypeOf<'loaderData.0.id' | 'loaderData.0.comment'> + + expectTypeOf( + matches.filter((match) => isMatch(match, 'loaderData.5.id')), + ).toEqualTypeOf>() + + expectTypeOf( + matches.filter((match) => isMatch(match, 'loaderData.0.comment')), + ).toEqualTypeOf>() +}) diff --git a/packages/solid-router/tests/Matches.test.tsx b/packages/solid-router/tests/Matches.test.tsx new file mode 100644 index 00000000000..b77b68e502d --- /dev/null +++ b/packages/solid-router/tests/Matches.test.tsx @@ -0,0 +1,118 @@ +import { expect, test } from 'vitest' +import { fireEvent, render, screen } from '@solidjs/testing-library' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + isMatch, + useMatches, +} from '../src' + +const rootRoute = createRootRoute() + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return To Invoices + }, +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + loader: () => [{ id: '1' }, { id: '2' }], + component: () => , +}) + +const InvoicesIndex = () => { + const matches = useMatches() + + const loaderDataMatches = matches.filter((match) => + isMatch(match, 'loaderData.0.id'), + ) + + const contextMatches = matches.filter((match) => + isMatch(match, 'context.permissions'), + ) + + const incorrectMatches = matches.filter((match) => + isMatch(match, 'loaderData.6.id'), + ) + + return ( +
+
+ Loader Matches -{' '} + {loaderDataMatches.map((match) => match.fullPath).join(',')} +
+
+ Context Matches -{' '} + {contextMatches.map((match) => match.fullPath).join(',')} +
+
+ Incorrect Matches -{' '} + {incorrectMatches.map((match) => match.fullPath).join(',')} +
+
+ ) +} + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + component: InvoicesIndex, + context: () => ({ + permissions: 'permission', + }), +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', +}) + +const commentsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'comments/$id', + validateSearch: () => ({ + page: 0, + search: '', + }), + loader: () => + [{ comment: 'one comment' }, { comment: 'two comment' }] as const, +}) + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + layoutRoute.addChildren([commentsRoute]), +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +test('when filtering useMatches by loaderData', async () => { + render(() => ) + const searchLink = await screen.findByRole('link', { name: 'To Invoices' }) + fireEvent.click(searchLink) + expect( + await screen.findByText('Loader Matches - /invoices'), + ).toBeInTheDocument() + expect( + await screen.findByText('Context Matches - /invoices/'), + ).toBeInTheDocument() + expect(await screen.findByText('Incorrect Matches -')).toBeInTheDocument() +}) diff --git a/packages/solid-router/tests/RouterProvider.test-d.tsx b/packages/solid-router/tests/RouterProvider.test-d.tsx new file mode 100644 index 00000000000..31c5d667382 --- /dev/null +++ b/packages/solid-router/tests/RouterProvider.test-d.tsx @@ -0,0 +1,50 @@ +import { expectTypeOf, test } from 'vitest' +import { + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const rootRoute = createRootRoute() + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +test('can pass default router to the provider', () => { + expectTypeOf(RouterProvider) + .parameter(0) + .toMatchTypeOf<{ + router: DefaultRouter + routeTree?: DefaultRouter['routeTree'] + }>() +}) diff --git a/packages/solid-router/tests/blocker.test.tsx b/packages/solid-router/tests/blocker.test.tsx new file mode 100644 index 00000000000..be10c91ac6c --- /dev/null +++ b/packages/solid-router/tests/blocker.test.tsx @@ -0,0 +1,194 @@ +import '@testing-library/jest-dom/vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import combinate from 'combinate' +import { + Link, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + redirect, + useBlocker, + useNavigate, +} from '../src' +import type { ShouldBlockFn } from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + vi.resetAllMocks() + cleanup() +}) + +interface BlockerTestOpts { + blockerFn: ShouldBlockFn + disabled?: boolean + ignoreBlocker?: boolean +} + +async function setup({ blockerFn, disabled, ignoreBlocker }: BlockerTestOpts) { + const _mockBlockerFn = vi.fn(blockerFn) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Setup() { + const navigate = useNavigate() + useBlocker({ disabled, shouldBlockFn: _mockBlockerFn }) + return ( + <> +

Index

+ + link to posts + + link to foo + + + ) + }, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => ( + <> +

Posts

+ + ), + }) + + const fooRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad: () => { + throw redirect({ to: '/bar' }) + }, + component: () => ( + <> +

Foo

+ + ), + }) + + const barRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + component: () => ( + <> +

Bar

+ + ), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute, + fooRoute, + barRoute, + ]), + }) + + render(() => ) + expect(window.location.pathname).toBe('/') + + const postsLink = await screen.findByRole('link', { name: 'link to posts' }) + const fooLink = await screen.findByRole('link', { name: 'link to foo' }) + const button = await screen.findByRole('button', { name: 'button' }) + + return { + router, + clickable: { postsLink, fooLink, button }, + blockerFn: _mockBlockerFn, + } +} + +const clickTarget = ['postsLink' as const, 'button' as const] + +describe('Blocker', () => { + const doesNotBlockTextMatrix = combinate({ + opts: [ + { + blockerFn: () => false, + disabled: false, + ignoreBlocker: undefined, + }, + { + blockerFn: async () => + await new Promise((resolve) => resolve(false)), + disabled: false, + ignoreBlocker: false, + }, + { + blockerFn: () => true, + disabled: true, + ignoreBlocker: false, + }, + { + blockerFn: () => true, + disabled: false, + ignoreBlocker: true, + }, + ], + clickTarget, + }) + test.each(doesNotBlockTextMatrix)( + 'does not block navigation with blockerFn = $flags.blockerFn, ignoreBlocker = $flags.ignoreBlocker, clickTarget = $clickTarget', + async ({ opts, clickTarget }) => { + const { clickable, blockerFn } = await setup(opts) + + fireEvent.click(clickable[clickTarget]) + expect( + await screen.findByRole('heading', { name: 'Posts' }), + ).toBeInTheDocument() + expect(window.location.pathname).toBe('/posts') + if (opts.ignoreBlocker || opts.disabled) + expect(blockerFn).not.toHaveBeenCalled() + }, + ) + + const blocksTextMatrix = combinate({ + opts: [ + { + blockerFn: () => true, + disabled: false, + ignoreBlocker: undefined, + }, + { + blockerFn: async () => + await new Promise((resolve) => resolve(true)), + disabled: false, + ignoreBlocker: false, + }, + ], + clickTarget, + }) + test.each(blocksTextMatrix)( + 'blocks navigation with condition = $flags.blockerFn, ignoreBlocker = $flags.ignoreBlocker, clickTarget = $clickTarget', + async ({ opts, clickTarget }) => { + const { clickable } = await setup(opts) + + fireEvent.click(clickable[clickTarget]) + await expect( + screen.findByRole('header', { name: 'Posts' }), + ).rejects.toThrow() + expect(window.location.pathname).toBe('/') + }, + ) + + test('blocker function is only called once when navigating to a route that redirects', async () => { + const { clickable, blockerFn } = await setup({ + blockerFn: () => false, + ignoreBlocker: false, + }) + fireEvent.click(clickable.fooLink) + expect( + await screen.findByRole('heading', { name: 'Bar' }), + ).toBeInTheDocument() + expect(window.location.pathname).toBe('/bar') + expect(blockerFn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/solid-router/tests/createLazyRoute.test.tsx b/packages/solid-router/tests/createLazyRoute.test.tsx new file mode 100644 index 00000000000..0b4a9b73e87 --- /dev/null +++ b/packages/solid-router/tests/createLazyRoute.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + cleanup, + configure, + fireEvent, + render, + screen, +} from '@solidjs/testing-library' +import { + Link, + RouterProvider, + createBrowserHistory, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { RouterHistory } from '../src' + +afterEach(() => { + vi.resetAllMocks() + cleanup() +}) + +function createTestRouter(initialHistory?: RouterHistory) { + const history = + initialHistory ?? createMemoryHistory({ initialEntries: ['/'] }) + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+

Index Route

+ Link to heavy +
+ ), + }) + + const heavyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/heavy', + }).lazy(() => import('./lazy/heavy').then((d) => d.default('/heavy'))) + + const routeTree = rootRoute.addChildren([indexRoute, heavyRoute]) + + const router = createRouter({ routeTree, history }) + + return { + router, + routes: { indexRoute, heavyRoute }, + } +} + +describe('preload: matched routes', { timeout: 20000 }, () => { + configure({ reactStrictMode: true }) + + it('should wait for lazy options to be streamed in before ', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/'] }), + ) + + await router.load() + + // Preload the route and navigate to it + router.preloadRoute({ to: '/heavy' }) + await router.navigate({ to: '/heavy' }) + + await router.invalidate() + + expect(router.state.location.pathname).toBe('/heavy') + + const lazyRoute = router.routesByPath['/heavy'] + + expect(lazyRoute.options.component).toBeDefined() + }) + + it('should render the heavy/lazy component', async () => { + const { router } = createTestRouter(createBrowserHistory()) + + render(() => ) + + const linkToHeavy = await screen.findByText('Link to heavy') + expect(linkToHeavy).toBeInTheDocument() + + expect(router.state.location.pathname).toBe('/') + expect(window.location.pathname).toBe('/') + + // click the link to navigate to the heavy route + fireEvent.click(linkToHeavy) + + const heavyElement = await screen.findByText('I am sooo heavy') + + expect(heavyElement).toBeInTheDocument() + + expect(router.state.location.pathname).toBe('/heavy') + expect(window.location.pathname).toBe('/heavy') + + const lazyRoute = router.routesByPath['/heavy'] + expect(lazyRoute.options.component).toBeDefined() + }) +}) diff --git a/packages/solid-router/tests/errorComponent.test.tsx b/packages/solid-router/tests/errorComponent.test.tsx new file mode 100644 index 00000000000..14aea9ea1ae --- /dev/null +++ b/packages/solid-router/tests/errorComponent.test.tsx @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' + +import { + Link, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { ErrorComponentProps } from '../src' + +function MyErrorComponent(props: ErrorComponentProps) { + return
Error: {props.error.message}
+} + +async function asyncToThrowFn() { + await new Promise((resolve) => setTimeout(resolve, 500)) + throw new Error('error thrown') +} + +function throwFn() { + throw new Error('error thrown') +} + +afterEach(() => { + vi.resetAllMocks() + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +describe.each([{ preload: false }, { preload: 'intent' }] as const)( + 'errorComponent is rendered when the preload=$preload', + (options) => { + describe.each([true, false])('with async=%s', (isAsync) => { + const throwableFn = isAsync ? asyncToThrowFn : throwFn + + const callers = [ + { caller: 'beforeLoad', testFn: throwableFn }, + { caller: 'loader', testFn: throwableFn }, + ] + + test.each(callers)( + 'an Error is thrown on navigate in the route $caller function', + async ({ caller, testFn }) => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: caller === 'beforeLoad' ? testFn : undefined, + loader: caller === 'loader' ? testFn : undefined, + component: function Home() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + + const router = createRouter({ + routeTree, + defaultPreload: options.preload, + }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + + expect(linkToAbout).toBeInTheDocument() + fireEvent.mouseOver(linkToAbout) + fireEvent.focus(linkToAbout) + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + `Error: error thrown`, + undefined, + { timeout: 1500 }, + ) + expect(screen.findByText('About route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }, + ) + + test.each(callers)( + 'an Error is thrown on first load in the route $caller function', + async ({ caller, testFn }) => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: caller === 'beforeLoad' ? testFn : undefined, + loader: caller === 'loader' ? testFn : undefined, + component: function Home() { + return
Index route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createRouter({ + routeTree, + defaultPreload: options.preload, + }) + + render(() => ) + + const errorComponent = await screen.findByText( + `Error: error thrown`, + undefined, + { timeout: 750 }, + ) + expect(screen.findByText('Index route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }, + ) + }) + }, +) diff --git a/packages/solid-router/tests/fileRoute.test-d.tsx b/packages/solid-router/tests/fileRoute.test-d.tsx new file mode 100644 index 00000000000..f2f7fccc42a --- /dev/null +++ b/packages/solid-router/tests/fileRoute.test-d.tsx @@ -0,0 +1,102 @@ +import { expectTypeOf, test } from 'vitest' +import { createFileRoute, createRootRoute } from '../src' + +const rootRoute = createRootRoute() + +const indexRoute = createFileRoute('/')() + +const invoicesRoute = createFileRoute('/invoices')() + +const invoiceRoute = createFileRoute('/invoices/$invoiceId')() + +const postLayoutRoute = createFileRoute('/_postLayout')() + +const postsRoute = createFileRoute('/_postLayout/posts')() + +const postRoute = createFileRoute('/_postLayout/posts/$postId_')() + +const protectedRoute = createFileRoute('/(auth)/protected')() + +declare module '../src/fileRoute' { + interface FileRoutesByPath { + '/': { + preLoaderRoute: typeof indexRoute + parentRoute: typeof rootRoute + id: string + fullPath: string + path: string + } + '/(auth)/protected': { + preLoaderRoute: typeof protectedRoute + parentRoute: typeof rootRoute + id: '/protected' + fullPath: '/protected' + path: '(auth)/protected' + } + '/invoices': { + preLoaderRoute: typeof invoicesRoute + parentRoute: typeof indexRoute + id: '/invoices' + fullPath: '/invoices' + path: 'invoices' + } + '/invoices/$invoiceId': { + preLoaderRoute: typeof invoiceRoute + parentRoute: typeof invoicesRoute + id: '/invoices/$invoiceId' + fullPath: '/invoices/$invoiceId' + path: '/$invoiceId' + } + '/_postLayout': { + preLoaderRoute: typeof postLayoutRoute + parentRoute: typeof rootRoute + id: string + fullPath: string + path: string + } + '/_postLayout/posts': { + preLoaderRoute: typeof postsRoute + parentRoute: typeof postLayoutRoute + id: '/_postLayout/posts' + fullPath: '/posts' + path: '/posts' + } + '/_postLayout/posts/$postId_': { + preLoaderRoute: typeof postRoute + parentRoute: typeof postsRoute + id: '/_postLayout/posts/$postId_' + fullPath: '/posts/$postId' + path: '/$postId_' + } + } +} + +test('when creating a file route with a static route', () => { + expectTypeOf<'/invoices'>(invoicesRoute.fullPath) + expectTypeOf<'/invoices'>(invoicesRoute.id) + expectTypeOf<'invoices'>(invoicesRoute.path) +}) + +test('when creating a file route with params', () => { + expectTypeOf<'/invoices/$invoiceId'>(invoiceRoute.fullPath) + expectTypeOf<'/invoices/$invoiceId'>(invoiceRoute.id) + expectTypeOf<'/$invoiceId'>(invoiceRoute.path) +}) + +test('when creating a layout route', () => { + expectTypeOf<'/posts'>(postsRoute.fullPath) + expectTypeOf<'/_postLayout/posts'>(postsRoute.id) + expectTypeOf<'/posts'>(postsRoute.path) +}) + +test('when creating a _ suffix route', () => { + expectTypeOf<'/posts/$postId'>(postRoute.fullPath) + expectTypeOf<'/$postId_'>(postRoute.path) + expectTypeOf<'/_postLayout/posts/$postId_'>(postRoute.id) +}) + +test('when creating a folder group', () => { + expectTypeOf<'/protected'>(protectedRoute.fullPath) + expectTypeOf<'(auth)/protected'>(protectedRoute.path) + expectTypeOf<'/protected'>(protectedRoute.id) +}) diff --git a/packages/solid-router/tests/fileRoute.test.ts b/packages/solid-router/tests/fileRoute.test.ts new file mode 100644 index 00000000000..f6b92298c8e --- /dev/null +++ b/packages/solid-router/tests/fileRoute.test.ts @@ -0,0 +1,50 @@ +/* eslint-disable */ +import { describe, it, expect } from 'vitest' +import { + getRouteApi, + createFileRoute, + createLazyRoute, + createLazyFileRoute, + LazyRoute, +} from '../src' + +describe('createFileRoute has the same hooks as getRouteApi', () => { + const routeApi = getRouteApi('foo') + const hookNames = Object.keys(routeApi).filter((key) => key.startsWith('use')) + // @ts-expect-error + const route = createFileRoute('')({}) + + it.each(hookNames.map((name) => [name]))( + 'should have the "%s" hook defined', + (hookName) => { + expect(route[hookName as keyof LazyRoute]).toBeDefined() + }, + ) +}) + +describe('createLazyFileRoute has the same hooks as getRouteApi', () => { + const routeApi = getRouteApi('foo') + const hookNames = Object.keys(routeApi).filter((key) => key.startsWith('use')) + // @ts-expect-error + const route = createLazyFileRoute('')({}) + + it.each(hookNames.map((name) => [name]))( + 'should have the "%s" hook defined', + (hookName) => { + expect(route[hookName as keyof LazyRoute]).toBeDefined() + }, + ) +}) + +describe('createLazyRoute has the same hooks as getRouteApi', () => { + const routeApi = getRouteApi('foo') + const route = createLazyRoute({})({}) + const hookNames = Object.keys(routeApi).filter((key) => key.startsWith('use')) + + it.each(hookNames.map((name) => [name]))( + 'should have the "%s" hook defined', + (hookName) => { + expect(route[hookName as keyof LazyRoute]).toBeDefined() + }, + ) +}) diff --git a/packages/solid-router/tests/index.test.tsx b/packages/solid-router/tests/index.test.tsx new file mode 100644 index 00000000000..1548bc51789 --- /dev/null +++ b/packages/solid-router/tests/index.test.tsx @@ -0,0 +1,563 @@ +import { expect, test } from 'vitest' + +// keeping this dummy test in since in the future +// we may want to grab the commented out tests from here + +test('index true=true', () => { + expect(true).toBe(true) +}) +// import { render } from '@solidjs/testing-library' + +// +// import { +// Outlet, +// RouterProvider, +// cleanPath, +// createMemoryHistory, +// createRootRoute, +// createRoute, +// createRouter, +// // Location, +// matchPathname, +// // ParsedLocation, +// parsePathname, +// redirect, +// // Route, +// // createMemoryHistory, +// resolvePath, +// // Segment, +// trimPath, +// trimPathLeft, +// } from '../src' + +// import { createTimer, sleep } from './utils' + +// function RouterInstance(opts?: { initialEntries?: string[] }) { +// return new RouterInstance({ +// routes: [], +// history: createMemoryHistory({ +// initialEntries: opts?.initialEntries ?? ['/'], +// }), +// }) +// } + +// function createLocation(location: Partial): ParsedLocation { +// return { +// pathname: '', +// href: '', +// search: {}, +// searchStr: '', +// state: {}, +// hash: '', +// ...location, +// } +// } + +// describe('Router', () => { +// test('mounts to /', async () => { +// const router = RouterInstance() + +// const routes = [ +// { +// path: '/', +// }, +// ] + +// router.update({ +// routes, +// }) + +// const promise = router.mount() +// expect(router.store.pendingMatches[0].id).toBe('/') + +// await promise +// expect(router.state.matches[0].id).toBe('/') +// }) + +// test('mounts to /a', async () => { +// const router = RouterInstance({ initialEntries: ['/a'] }) +// const routes: Route[] = [ +// { +// path: '/', +// }, +// { +// path: '/a', +// }, +// ] + +// router.update({ +// routes, +// }) + +// let promise = router.mount() + +// expect(router.store.pendingMatches[0].id).toBe('/a') +// await promise +// expect(router.state.matches[0].id).toBe('/a') +// }) + +// test('mounts to /a/b', async () => { +// const router = RouterInstance({ +// initialEntries: ['/a/b'], +// }) + +// const routes: Route[] = [ +// { +// path: '/', +// }, +// { +// path: '/a', +// children: [ +// { +// path: '/b', +// }, +// ], +// }, +// ] + +// router.update({ +// routes, +// }) + +// let promise = router.mount() + +// expect(router.store.pendingMatches[1].id).toBe('/a/b') +// await promise +// expect(router.state.matches[1].id).toBe('/a/b') +// }) + +// test('navigates to /a', async () => { +// const router = RouterInstance() +// const routes: Route[] = [ +// { +// path: '/', +// }, +// { +// path: 'a', +// }, +// ] + +// router.update({ +// routes, +// }) + +// let promise = router.mount() + +// expect(router.store.pendingMatches[0].id).toBe('/') + +// await promise +// expect(router.state.matches[0].id).toBe('/') + +// promise = router.navigate({ to: 'a' }) +// expect(router.state.matches[0].id).toBe('/') +// expect(router.store.pendingMatches[0].id).toBe('a') + +// await promise +// expect(router.state.matches[0].id).toBe('a') +// expect(router.store.pending).toBe(undefined) +// }) + +// test('navigates to /a to /a/b', async () => { +// const router = RouterInstance() +// const routes: Route[] = [ +// { +// path: '/', +// }, +// { +// path: 'a', +// children: [ +// { +// path: 'b', +// }, +// ], +// }, +// ] + +// router.update({ +// routes, +// }) + +// await router.mount() +// expect(router.state.location.href).toBe('/') + +// let promise = router.navigate({ to: 'a' }) +// expect(router.store.pendingLocation.href).toBe('/a') +// await promise +// expect(router.state.location.href).toBe('/a') + +// promise = router.navigate({ to: './b' }) +// expect(router.store.pendingLocation.href).toBe('/a/b') +// await promise +// expect(router.state.location.href).toBe('/a/b') + +// expect(router.store.pending).toBe(undefined) +// }) + +// test('async navigates to /a/b', async () => { +// const router = RouterInstance() +// const routes: Route[] = [ +// { +// path: '/', +// }, +// { +// path: 'a', +// loader: () => sleep(10).then((d) => ({ a: true })), +// children: [ +// { +// path: 'b', +// loader: () => sleep(10).then((d) => ({ b: true })), +// }, +// ], +// }, +// ] + +// const timer = createTimer() + +// router.update({ +// routes, +// }) + +// router.mount() + +// timer.start() +// await router.navigate({ to: 'a/b' }) +// expect(router.store.loaderData).toEqual({ +// a: true, +// b: true, +// }) +// expect(timer.getTime()).toBeLessThan(30) +// }) + +// test('async navigates with import + loader', async () => { +// const router = RouterInstance() +// const routes: Route[] = [ +// { +// path: '/', +// }, +// { +// path: 'a', +// import: async () => { +// await sleep(10) +// return { +// loader: () => sleep(10).then((d) => ({ a: true })), +// } +// }, +// children: [ +// { +// path: 'b', +// import: async () => { +// await sleep(10) +// return { +// loader: () => +// sleep(10).then((d) => ({ +// b: true, +// })), +// } +// }, +// }, +// ], +// }, +// ] + +// const timer = createTimer() + +// router.update({ +// routes, +// }) + +// router.mount() + +// timer.start() +// await router.navigate({ to: 'a/b' }) +// expect(router.store.loaderData).toEqual({ +// a: true, +// b: true, +// }) +// expect(timer.getTime()).toBeLessThan(28) +// }) + +// test('async navigates with import + elements + loader', async () => { +// const router = RouterInstance() +// const routes: Route[] = [ +// { +// path: '/', +// }, +// { +// path: 'a', +// import: async () => { +// await sleep(10) +// return { +// element: async () => { +// await sleep(20) +// return 'element' +// }, +// loader: () => sleep(30).then((d) => ({ a: true })), +// } +// }, +// children: [ +// { +// path: 'b', +// import: async () => { +// await sleep(10) +// return { +// element: async () => { +// await sleep(20) +// return 'element' +// }, +// loader: () => +// sleep(30).then((d) => ({ +// b: true, +// })), +// } +// }, +// }, +// ], +// }, +// ] + +// const timer = createTimer() + +// router.update({ +// routes, +// }) + +// router.mount() + +// await router.navigate({ to: 'a/b' }) +// expect(router.store.loaderData).toEqual({ +// a: true, +// b: true, +// }) +// expect(timer.getTime()).toBeLessThan(55) +// }) + +// test('async navigates with pending state', async () => { +// const router = RouterInstance() +// const routes: Route[] = [ +// { +// path: '/', +// }, +// { +// path: 'a', +// pendingMs: 10, +// loader: () => sleep(20), +// children: [ +// { +// path: 'b', +// pendingMs: 30, +// loader: () => sleep(40), +// }, +// ], +// }, +// ] + +// router.update({ +// routes, +// }) + +// await router.mount() + +// const timer = createTimer() +// await router.navigate({ to: 'a/b' }) +// expect(timer.getTime()).toBeLessThan(46) +// }) +// }) + +// describe('matchRoute', () => { +// describe('fuzzy', () => { +// ;( +// [ +// [ +// '/', +// { +// to: '/', +// fuzzy: true, +// }, +// {}, +// ], +// [ +// '/a', +// { +// to: '/', +// fuzzy: true, +// }, +// {}, +// ], +// [ +// '/a', +// { +// to: '/$', +// fuzzy: true, +// }, +// { '*': 'a' }, +// ], +// [ +// '/a/b', +// { +// to: '/$', +// fuzzy: true, +// }, +// { '*': 'a/b' }, +// ], +// [ +// '/a/b/c', +// { +// to: '/$', +// fuzzy: true, +// }, +// { '*': 'a/b/c' }, +// ], +// [ +// '/a/b/c', +// { +// to: '/', +// fuzzy: true, +// }, +// {}, +// ], +// [ +// '/a/b', +// { +// to: '/a/b/', +// fuzzy: true, +// }, +// {}, +// ], +// ] as const +// ).forEach(([a, b, eq]) => { +// test(`${a} == ${b.to}`, () => { +// expect(matchPathname('', a, b)).toEqual(eq) +// }) +// }) +// }) + +// describe('exact', () => { +// ;( +// [ +// [ +// '/a/b/c', +// { +// to: '/', +// }, +// undefined, +// ], +// [ +// '/a/b/c', +// { +// to: '/a/b', +// }, +// undefined, +// ], +// [ +// '/a/b/c', +// { +// to: '/a/b/c', +// }, +// {}, +// ], +// ] as const +// ).forEach(([a, b, eq]) => { +// test(`${a} == ${b.to}`, () => { +// expect(matchPathname('', a, b)).toEqual(eq) +// }) +// }) +// }) + +// describe('basepath', () => { +// ;( +// [ +// [ +// '/base', +// '/base', +// { +// to: '/', +// }, +// {}, +// ], +// [ +// '/base', +// '/base/a', +// { +// to: '/a', +// }, +// {}, +// ], +// [ +// '/base', +// '/base/a/b/c', +// { +// to: '/a/b/c', +// }, +// {}, +// ], +// [ +// '/base', +// '/base/posts', +// { +// fuzzy: true, +// to: '/', +// }, +// {}, +// ], +// [ +// '/base', +// '/base/a', +// { +// to: '/b', +// }, +// undefined, +// ], +// ] as const +// ).forEach(([a, b, c, eq]) => { +// test(`${b} == ${a} + ${c.to}`, () => { +// expect(matchPathname(a, b, c)).toEqual(eq) +// }) +// }) +// }) + +// describe('params', () => { +// ;( +// [ +// [ +// '/a/b', +// { +// to: '/a/$b', +// }, +// { b: 'b' }, +// ], +// [ +// '/a/b/c', +// { +// to: '/a/$b/$c', +// }, +// { b: 'b', c: 'c' }, +// ], +// [ +// '/a/b/c', +// { +// to: '/$a/$b/$c', +// }, +// { a: 'a', b: 'b', c: 'c' }, +// ], +// [ +// '/a/b/c', +// { +// to: '/$a/$', +// }, +// { a: 'a', '*': 'b/c' }, +// ], +// [ +// '/a/b/c', +// { +// to: '/a/$b/c', +// }, +// { b: 'b' }, +// ], +// ] as const +// ).forEach(([a, b, eq]) => { +// test(`${a} == ${b.to}`, () => { +// expect(matchPathname('', a, b)).toEqual(eq) +// }) +// }) +// }) +// }) diff --git a/packages/solid-router/tests/lazy/heavy.tsx b/packages/solid-router/tests/lazy/heavy.tsx new file mode 100644 index 00000000000..d7ca39c40f3 --- /dev/null +++ b/packages/solid-router/tests/lazy/heavy.tsx @@ -0,0 +1,8 @@ +import { createLazyRoute } from '../../src' +import HeavyComponent from './mockHeavyDependenciesRoute' + +export default function Route(id: string) { + return createLazyRoute(id)({ + component: HeavyComponent, + }) +} diff --git a/packages/solid-router/tests/lazy/mockHeavyDependenciesRoute.tsx b/packages/solid-router/tests/lazy/mockHeavyDependenciesRoute.tsx new file mode 100644 index 00000000000..68fafcd3776 --- /dev/null +++ b/packages/solid-router/tests/lazy/mockHeavyDependenciesRoute.tsx @@ -0,0 +1,6 @@ +// This mimicks the waiting of heavy dependencies, which need to be streamed in before the component is available. +await new Promise((resolve) => setTimeout(resolve, 2500)) + +export default function HeavyComponent() { + return

I am sooo heavy

+} diff --git a/packages/solid-router/tests/lazy/normal.tsx b/packages/solid-router/tests/lazy/normal.tsx new file mode 100644 index 00000000000..1addf96c74c --- /dev/null +++ b/packages/solid-router/tests/lazy/normal.tsx @@ -0,0 +1,13 @@ +import { createLazyFileRoute, createLazyRoute } from '../../src' + +export function Route(id: string) { + return createLazyRoute(id)({ + component: () =>

I'm a normal route

, + }) +} + +export function FileRoute(id: string) { + return createLazyFileRoute(id as any)({ + component: () =>

I'm a normal file route

, + }) +} diff --git a/packages/solid-router/tests/link.bench.tsx b/packages/solid-router/tests/link.bench.tsx new file mode 100644 index 00000000000..d55812fc13e --- /dev/null +++ b/packages/solid-router/tests/link.bench.tsx @@ -0,0 +1,182 @@ +import { render } from '@solidjs/testing-library' +import { bench, describe } from 'vitest' +import { + Link, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + interpolatePath, + useRouter, +} from '../src' +import type { LinkProps } from '../src' + +const createRouterRenderer = + (routesCount: number) => (children: React.ReactNode) => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => children, + }) + const paramRoutes = Array.from({ length: routesCount }).map((_, i) => + createRoute({ + getParentRoute: () => rootRoute, + path: `/params/$param${i}`, + }), + ) + const routeTree = rootRoute.addChildren([indexRoute, ...paramRoutes]) + return createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + } + +const InterpolatePathLink = ({ + to, + params, + children, +}: React.PropsWithChildren) => { + const href = interpolatePath({ path: to, params }) + return {children} +} + +const BuildLocationLink = ({ + children, + ...props +}: React.PropsWithChildren) => { + const router = useRouter() + const { href } = router.buildLocation(props) + return {children} +} + +describe.each([ + { + name: 'small router', + numberOfRoutes: 1, + matchedParamId: 0, // range from 0 to numberOfRoutes-1 + numberOfLinks: 5000, + }, + { + name: 'medium router', + numberOfRoutes: 1000, + matchedParamId: 500, // range from 0 to numberOfRoutes-1 + numberOfLinks: 5000, + }, + // { + // name: 'large router', + // numberOfRoutes: 10000, + // matchedParamId: 9999, // range from 0 to numberOfRoutes-1 + // numberOfLinks: 15000, + // }, +])('$name', ({ numberOfRoutes, numberOfLinks, matchedParamId }) => { + const renderRouter = createRouterRenderer(numberOfRoutes) + + bench( + 'hardcoded href', + () => { + const router = renderRouter( + Array.from({ length: numberOfLinks }).map((_, i) => ( + + {i} + + )), + ) + render(() => ) + }, + { warmupIterations: 1 }, + ) + + bench( + 'interpolate path', + () => { + const router = renderRouter( + Array.from({ length: numberOfLinks }).map((_, i) => ( + + {i} + + )), + ) + render(() => ) + }, + { warmupIterations: 1 }, + ) + + bench( + 'build location', + () => { + const router = renderRouter( + Array.from({ length: numberOfLinks }).map((_, i) => ( + + {i} + + )), + ) + render(() => ) + }, + { warmupIterations: 1 }, + ) + + bench( + 'link to absolute path', + () => { + const router = renderRouter( + Array.from({ length: numberOfLinks }).map((_, i) => ( + + {i} + + )), + ) + render(() => ) + }, + { warmupIterations: 1 }, + ) + + bench( + 'link to relative path', + () => { + const router = renderRouter( + Array.from({ length: numberOfLinks }).map((_, i) => ( + + {i} + + )), + ) + render(() => ) + }, + { warmupIterations: 1 }, + ) + + bench( + 'link to current path', + () => { + const router = renderRouter( + Array.from({ length: numberOfLinks }).map((_, i) => ( + + {i} + + )), + ) + render(() => ) + }, + { warmupIterations: 1 }, + ) +}) diff --git a/packages/solid-router/tests/link.test-d.tsx b/packages/solid-router/tests/link.test-d.tsx new file mode 100644 index 00000000000..ed59d80ac09 --- /dev/null +++ b/packages/solid-router/tests/link.test-d.tsx @@ -0,0 +1,4174 @@ +import { expectTypeOf, test } from 'vitest' +import { + Link, + createLink, + createRootRoute, + createRoute, + createRouter, + linkOptions, +} from '../src' +import type { + CreateLinkProps, + ResolveRelativePath, + SearchSchemaInput, +} from '../src' + +const rootRoute = createRootRoute({ + validateSearch: (): { rootPage?: number } => ({ rootPage: 0 }), +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: () => ({ rootIndexPage: 0 }), +}) + +const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', +}) + +const postsIndexRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '/', +}) + +const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +const invoiceEditRoute = createRoute({ + getParentRoute: () => invoiceRoute, + path: 'edit', +}) + +const invoiceDetailsRoute = createRoute({ + getParentRoute: () => invoiceRoute, + path: 'details', + validateSearch: (): { page?: number } => ({ page: 0 }), +}) + +const detailRoute = createRoute({ + getParentRoute: () => invoiceDetailsRoute, + path: '$detailId', +}) + +const linesRoute = createRoute({ + getParentRoute: () => detailRoute, + path: 'lines', + validateSearch: (input: { linesPage?: number } & SearchSchemaInput) => { + if (typeof input.linesPage !== 'number') throw new Error() + + return { + linesPage: input.linesPage, + } + }, +}) + +const routeTreeTuples = rootRoute.addChildren([ + postsRoute.addChildren([postRoute, postsIndexRoute]), + invoicesRoute.addChildren([ + invoicesIndexRoute, + invoiceRoute.addChildren([ + invoiceEditRoute, + invoiceDetailsRoute.addChildren([detailRoute.addChildren([linesRoute])]), + ]), + ]), + indexRoute, +]) + +const routeTreeObjects = rootRoute.addChildren({ + postsRoute: postsRoute.addChildren({ postRoute, postsIndexRoute }), + invoicesRoute: invoicesRoute.addChildren({ + invoicesIndexRoute, + invoiceRoute: invoiceRoute.addChildren({ + invoiceEditRoute, + invoiceDetailsRoute: invoiceDetailsRoute.addChildren({ + detailRoute: detailRoute.addChildren({ linesRoute }), + }), + }), + }), + indexRoute, +}) + +const defaultRouter = createRouter({ + routeTree: routeTreeTuples, +}) + +const defaultRouterObjects = createRouter({ + routeTree: routeTreeObjects, +}) + +const routerAlwaysTrailingSlashes = createRouter({ + routeTree: routeTreeTuples, + trailingSlash: 'always', +}) + +const routerNeverTrailingSlashes = createRouter({ + routeTree: routeTreeTuples, + trailingSlash: 'never', +}) + +const routerPreserveTrailingSlashes = createRouter({ + routeTree: routeTreeTuples, + trailingSlash: 'preserve', +}) + +type DefaultRouter = typeof defaultRouter + +type DefaultRouterObjects = typeof defaultRouterObjects + +type RouterAlwaysTrailingSlashes = typeof routerAlwaysTrailingSlashes + +type RouterNeverTrailingSlashes = typeof routerNeverTrailingSlashes + +type RouterPreserveTrailingSlashes = typeof routerPreserveTrailingSlashes + +test('when navigating to the root', () => { + const DefaultRouterLink = Link + const DefaultRouterObjectsLink = Link + const RouterAlwaysTrailingSlashLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/' + > + const RouterNeverTrailingSlashLink = Link< + RouterNeverTrailingSlashes, + string, + '/' + > + const RouterPreserveTrailingSlashLink = Link< + RouterPreserveTrailingSlashes, + string, + '/' + > + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/edit' + | '/posts' + | '/posts/$postId' + | undefined + >() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/edit' + | '/posts' + | '/posts/$postId' + | undefined + >() + + expectTypeOf(RouterAlwaysTrailingSlashLink) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../' + | './' + | '/' + | '/invoices/' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details/$detailId/lines/' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/edit/' + | '/posts/' + | '/posts/$postId/' + | undefined + >() + + expectTypeOf(RouterNeverTrailingSlashLink) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/edit' + | '/posts' + | '/posts/$postId' + | undefined + >() + + expectTypeOf(RouterPreserveTrailingSlashLink) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | './' + | '../' + | '/' + | '/invoices' + | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/details/$detailId/lines/' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/edit/' + | '/posts' + | '/posts/' + | '/posts/$postId' + | '/posts/$postId/' + | undefined + >() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterAlwaysTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterNeverTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterPreserveTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(RouterAlwaysTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(RouterNeverTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(RouterPreserveTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterAlwaysTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterNeverTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterPreserveTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() +}) + +test('when navigating from a route with no params and no search to the root', () => { + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '' + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/posts' + | '/posts/$postId' + | '$postId' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '' + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/posts' + | '/posts/$postId' + | '$postId' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../' + | './' + | '/' + | '' + | '/invoices/' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/edit/' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details/$detailId/lines/' + | '/posts/' + | '/posts/$postId/' + | '$postId/' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '' + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/posts' + | '/posts/$postId' + | '$postId' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '.' + | '..' + | '../' + | '../' + | './' + | '/' + | '' + | '/invoices' + | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/edit/' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/details/$detailId/lines/' + | '/posts' + | '/posts/' + | '/posts/$postId' + | '/posts/$postId/' + | '$postId' + | '$postId/' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude().toEqualTypeOf<{ + rootPage?: number + rootIndexPage: number + }> + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude().toEqualTypeOf<{ + rootPage?: number + rootIndexPage: number + }> + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude().toEqualTypeOf<{ + rootPage?: number + rootIndexPage: number + }> + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude().toEqualTypeOf<{ + rootPage?: number + rootIndexPage: number + }> + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude().toEqualTypeOf<{ + rootPage?: number + rootIndexPage: number + }> + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ + rootPage?: number + rootIndexPage: number + }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ + rootPage?: number + rootIndexPage: number + }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ + rootPage?: number + rootIndexPage: number + }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ + rootPage?: number + rootIndexPage: number + }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ + rootPage?: number + rootIndexPage: number + }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() +}) + +test('when navigating from a route with no params and no search to the current route', () => { + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf<'./$postId' | undefined | '.'>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf<'./$postId' | undefined | '.'>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf<'./$postId/' | undefined | './'>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf<'./$postId' | undefined | '.'>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf<'./$postId/' | './$postId' | undefined | './' | '.'>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number } | undefined | true>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number } | undefined | true>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number } | undefined | true>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number } | undefined | true>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number } | undefined | true>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() +}) + +test('when navigating from a route with no params and no search to the parent route', () => { + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../posts' + | '../posts/$postId' + | '../invoices/$invoiceId' + | '../invoices/$invoiceId/edit' + | '../invoices/$invoiceId/details' + | '../invoices/$invoiceId/details/$detailId' + | '../invoices/$invoiceId/details/$detailId/lines' + | '../invoices' + | '../' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../posts' + | '../posts/$postId' + | '../invoices/$invoiceId' + | '../invoices/$invoiceId/edit' + | '../invoices/$invoiceId/details' + | '../invoices/$invoiceId/details/$detailId' + | '../invoices/$invoiceId/details/$detailId/lines' + | '../invoices' + | '../' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../posts/' + | '../posts/$postId/' + | '../invoices/$invoiceId/' + | '../invoices/$invoiceId/edit/' + | '../invoices/$invoiceId/details/' + | '../invoices/$invoiceId/details/$detailId/' + | '../invoices/$invoiceId/details/$detailId/lines/' + | '../invoices/' + | '../' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../posts' + | '../posts/$postId' + | '../invoices/$invoiceId' + | '../invoices/$invoiceId/edit' + | '../invoices/$invoiceId/details' + | '../invoices/$invoiceId/details/$detailId' + | '../invoices/$invoiceId/details/$detailId/lines' + | '../invoices' + | '../' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../posts' + | '../posts/' + | '../posts/$postId' + | '../posts/$postId/' + | '../invoices/$invoiceId' + | '../invoices/$invoiceId/' + | '../invoices/$invoiceId/edit' + | '../invoices/$invoiceId/edit/' + | '../invoices/$invoiceId/details' + | '../invoices/$invoiceId/details/' + | '../invoices/$invoiceId/details/$detailId' + | '../invoices/$invoiceId/details/$detailId/' + | '../invoices/$invoiceId/details/$detailId/lines' + | '../invoices/$invoiceId/details/$detailId/lines/' + | '../invoices' + | '../invoices/' + | '../' + | undefined + >() +}) + +test('cannot navigate to a branch with an index', () => { + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '/' + | '/posts' + | '/posts/$postId' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '.' + | '..' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '/' + | '/posts' + | '/posts/$postId' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '.' + | '..' + | undefined + >() + + expectTypeOf( + Link, + ) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '/' + | '/posts/' + | '/posts/$postId/' + | '/invoices/' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/edit/' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details/$detailId/lines/' + | './' + | '../' + | undefined + >() + + expectTypeOf(Link) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '/' + | '/posts' + | '/posts/$postId' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '.' + | '..' + | undefined + >() + + expectTypeOf( + Link, + ) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '/' + | '/posts' + | '/posts/' + | '/posts/$postId' + | '/posts/$postId/' + | '/invoices' + | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/edit/' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/details/$detailId/lines/' + | '.' + | '..' + | './' + | '../' + | undefined + >() +}) + +test('from autocompletes to all absolute routes', () => { + const DefaultRouterLink = Link + const DefaultRouterObjectsLink = Link + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('from') + .toEqualTypeOf< + | '/' + | '/posts/$postId' + | '/posts/' + | '/posts' + | '/invoices' + | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | undefined + >() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('from') + .toEqualTypeOf< + | '/' + | '/posts/$postId' + | '/posts/' + | '/posts' + | '/invoices' + | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | undefined + >() +}) + +test('from does not allow invalid routes', () => { + const DefaultRouterLink = Link + const DefaultRouterObjectsLink = Link + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('from') + .toEqualTypeOf< + | '/' + | '/posts/$postId' + | '/posts/' + | '/posts' + | '/invoices' + | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | undefined + >() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('from') + .toEqualTypeOf< + | '/' + | '/posts/$postId' + | '/posts/' + | '/posts' + | '/invoices' + | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | undefined + >() +}) + +test('when navigating to the same route', () => { + const DefaultRouterLink = Link + const DefaultRouterObjectsLink = Link + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + string + > + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + string + > + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + string + > + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() +}) + +test('when navigating to the parent route', () => { + const DefaultRouterLink = Link + const DefaultRouterObjectsLink = Link + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '../' + > + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '..' + > + const RouterPreserveTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '..' + > + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() +}) + +test('when navigating from a route with params to the same route', () => { + const DefaultRouterLink = Link + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + '/posts/$postId', + string + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/posts/$postId', + string + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/posts/$postId', + string + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + '/posts/$postId', + string + > + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('params') + .extract() + .toEqualTypeOf() +}) + +test('when navigating to a route with params', () => { + const DefaultRouterLink = Link + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + string, + '/posts/$postId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/posts/$postId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/posts/$postId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + '/posts/$postId/' | '/posts/$postId' + > + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const defaultRouterObjectsLinkParams = expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + defaultRouterObjectsLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + defaultRouterLinkParams.returns.toEqualTypeOf<{ postId: string }>() + + defaultRouterObjectsLinkParams.returns.toEqualTypeOf<{ postId: string }>() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + defaultRouterLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + defaultRouterObjectsLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + routerAlwaysTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + routerNeverTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + routerPreserveTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() +}) + +test('when navigating from a route with no params to a route with params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + '/invoices', + './$invoiceId/edit' + > + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + '/invoices', + './$invoiceId/edit' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices', + './$invoiceId/edit/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices', + './$invoiceId/edit' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices', + './$invoiceId/edit' | './invoicesId/edit/' + > + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const defaultRouterObjectsLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string }>() + + defaultRouterObjectsLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string }>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string }>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string }>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string }>() + + defaultRouterLinkParams.returns.toEqualTypeOf<{ invoiceId: string }>() + + defaultRouterObjectsLinkParams.returns.toEqualTypeOf<{ invoiceId: string }>() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId: string + }>() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId: string + }>() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId: string + }>() + + defaultRouterLinkParams.parameter(0).toEqualTypeOf<{}>() + + defaultRouterObjectsLinkParams.parameter(0).toEqualTypeOf<{}>() + + routerAlwaysTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{}>() + + routerNeverTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{}>() + + routerPreserveTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{}>() +}) + +test('when navigating from a route to a route with the same params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + '/invoices/$invoiceId', + './edit' + > + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + '/invoices/$invoiceId', + './edit' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices/$invoiceId', + './edit/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + './edit' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + './edit' | './edit/' + > + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const defaultRouterObjectsLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined } | undefined>() + + defaultRouterObjectsLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined } | undefined>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined } | undefined>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined } | undefined>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined } | undefined>() + + defaultRouterLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string | undefined + }>() + + defaultRouterObjectsLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string | undefined + }>() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string | undefined + }>() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string | undefined + }>() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string | undefined + }>() + + defaultRouterLinkParams.parameter(0).toEqualTypeOf<{ invoiceId: string }>() + + defaultRouterObjectsLinkParams + .parameter(0) + .toEqualTypeOf<{ invoiceId: string }>() + + routerAlwaysTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf<{ invoiceId: string }>() + + routerNeverTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf<{ invoiceId: string }>() + + routerPreserveTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf<{ invoiceId: string }>() +}) + +test('when navigating from a route with params to a route with different params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + '/invoices/$invoiceId', + '../../posts/$postId' + > + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + '/invoices/$invoiceId', + '../../posts/$postId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices/$invoiceId', + '../../posts/$postId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + '../../posts/$postId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + '/invoices/$invoiceId', + '../../posts/$postId' | '../../posts/$postId/' + > + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const defaultRouterObjectsLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + defaultRouterObjectsLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ postId: string }>() + + defaultRouterLinkParams.returns.toEqualTypeOf<{ postId: string }>() + + defaultRouterObjectsLinkParams.returns.toEqualTypeOf<{ postId: string }>() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + postId: string + }>() + + defaultRouterLinkParams.parameter(0).toEqualTypeOf<{ invoiceId: string }>() + + defaultRouterObjectsLinkParams + .parameter(0) + .toEqualTypeOf<{ invoiceId: string }>() + + routerAlwaysTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId: string + }>() + + routerNeverTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId: string + }>() + + routerPreserveTrailingSlashesLinkParams + .parameter(0) + .toEqualTypeOf<{ invoiceId: string }>() +}) + +test('when navigating from a route with params to a route with an additional param', () => { + const DefaultRouterLink = Link< + DefaultRouter, + '/invoices/$invoiceId', + './details/$detailId' + > + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + '/invoices/$invoiceId', + './details/$detailId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices/$invoiceId', + './details/$detailId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + './details/$detailId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + '/invoices/$invoiceId', + './details/$detailId' | './details/$detailId' + > + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const defaultRouterObjectsLinkParams = expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined; detailId: string }>() + + defaultRouterObjectsLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined; detailId: string }>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined; detailId: string }>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined; detailId: string }>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId?: string | undefined; detailId: string }>() + + defaultRouterLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string + detailId: string + }>() + + defaultRouterObjectsLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string + detailId: string + }>() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string + detailId: string + }>() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string + detailId: string + }>() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf<{ + invoiceId?: string + detailId: string + }>() +}) + +test('when navigating to a union of routes with params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + string, + '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + string, + '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/invoices/$invoiceId/edit/' | '/posts/$postId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/edit/' + | '/posts/$postId' + | '/posts/$postId/' + > + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const defaultRouterObjectsLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ params: unknown }>() + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string } | { postId: string }>() + + defaultRouterObjectsLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string } | { postId: string }>() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string } | { postId: string }>() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string } | { postId: string }>() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf<{ invoiceId: string } | { postId: string }>() + + defaultRouterLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } + >() + + defaultRouterObjectsLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } + >() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } + >() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } + >() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } + >() + + defaultRouterLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + defaultRouterObjectsLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + routerAlwaysTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + routerNeverTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + routerPreserveTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() +}) + +test('when navigating to a union of routes including the root', () => { + const DefaultRouterLink = Link< + DefaultRouter, + string, + '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + string, + '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/' | '/invoices/$invoiceId/edit/' | '/posts/$postId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + | '/' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/edit/' + | '/posts/$postId' + | '/posts/$postId/' + > + + const defaultRouterLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const defaultRouterObjectsLinkParams = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('params') + + const routerAlwaysTrailingSlashesLinkParams = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerNeverTrailingSlashesLinkParams = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + const routerPreserveTrailingSlashesLinkParams = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('params') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ params: unknown }>() + + defaultRouterLinkParams + .exclude() + .toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} | undefined + >() + + defaultRouterObjectsLinkParams + .exclude() + .toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} | undefined + >() + + routerAlwaysTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} | undefined + >() + + routerNeverTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} | undefined + >() + + routerPreserveTrailingSlashesLinkParams + .exclude() + .toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} | undefined + >() + + defaultRouterLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} + >() + + defaultRouterObjectsLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} + >() + + routerAlwaysTrailingSlashesLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} + >() + + routerNeverTrailingSlashesLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} + >() + + routerPreserveTrailingSlashesLinkParams.returns.toEqualTypeOf< + { invoiceId: string } | { postId: string } | {} + >() + + defaultRouterLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + defaultRouterObjectsLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + routerAlwaysTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + routerNeverTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() + + routerPreserveTrailingSlashesLinkParams.parameter(0).toEqualTypeOf<{ + invoiceId?: string + postId?: string + detailId?: string + }>() +}) + +test('when navigating from a route with search params to the same route', () => { + const DefaultRouterLink = Link + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + '/invoices/$invoiceId', + string + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices/$invoiceId', + string + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + string + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId', + string + > + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .extract() + .toEqualTypeOf() +}) + +test('when navigating to a route with search params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + string, + '/invoices/$invoiceId/edit' + > + + const DefaultRouterObjectsLink = Link< + DefaultRouter, + string, + '/invoices/$invoiceId/edit' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/invoices/$invoiceId/edit/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/invoices/$invoiceId/edit' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + '/invoices/$invoiceId/edit' | '/invoices/$invoiceId/edit/' + > + + const defaultRouterLinkSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const defaultRouterObjectsLinkSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const routerAlwaysTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerNeverTrailingSlashesLinkSearch = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerPreserveTrailingSlashesLinkSearch = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + defaultRouterLinkSearch + .exclude() + .toEqualTypeOf<{ rootPage?: number; page: number }>() + + defaultRouterObjectsLinkSearch + .exclude() + .toEqualTypeOf<{ rootPage?: number; page: number }>() + + routerAlwaysTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ rootPage?: number; page: number }>() + + routerNeverTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ rootPage?: number; page: number }>() + + routerPreserveTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ rootPage?: number; page: number }>() + + defaultRouterLinkSearch.returns.toEqualTypeOf<{ + page: number + rootPage?: number + }>() + + defaultRouterObjectsLinkSearch.returns.toEqualTypeOf<{ + page: number + rootPage?: number + }>() + + routerAlwaysTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + page: number + rootPage?: number + }>() + + routerNeverTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + rootPage?: number + page: number + }>() + + routerPreserveTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + page: number + rootPage?: number + }>() + + defaultRouterLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + defaultRouterObjectsLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerAlwaysTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerNeverTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerPreserveTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() +}) + +test('when navigating to a route with optional search params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + string, + '/invoices/$invoiceId/details/$detailId' + > + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + string, + '/invoices/$invoiceId/details/$detailId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/invoices/$invoiceId/details/$detailId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/invoices/$invoiceId/details/$detailId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/' + > + + const defaultRouterLinkSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const defaultRouterObjectsLinkSearch = expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + + const routerAlwaysTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerNeverTrailingSlashesLinkSearch = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerPreserveTrailingSlashesLinkSearch = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + defaultRouterLinkSearch.exclude().toEqualTypeOf< + | { + rootPage?: number + page?: number + } + | undefined + >() + + defaultRouterObjectsLinkSearch.exclude().toEqualTypeOf< + | { + rootPage?: number + page?: number + } + | undefined + >() + + routerAlwaysTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf< + | { + rootPage?: number + page?: number + } + | undefined + >() + + routerNeverTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf< + | { + rootPage?: number + page?: number + } + | undefined + >() + + routerPreserveTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf< + | { + rootPage?: number + page?: number + } + | undefined + >() + + defaultRouterLinkSearch.returns.toEqualTypeOf<{ + rootPage?: number + page?: number + }>() + + defaultRouterObjectsLinkSearch.returns.toEqualTypeOf<{ + rootPage?: number + page?: number + }>() + + routerAlwaysTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + rootPage?: number + page?: number + }>() + + routerNeverTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + rootPage?: number + page?: number + }>() + + routerPreserveTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + rootPage?: number + page?: number + }>() + + defaultRouterLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + defaultRouterObjectsLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerAlwaysTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerNeverTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerPreserveTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() +}) + +test('when navigating from a route with no search params to a route with search params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + '/invoices/', + './$invoiceId/edit' + > + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + '/invoices/', + './$invoiceId/edit' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/invoices/', + './$invoiceId/edit/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/invoices/', + './$invoiceId/edit' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + '/invoices/', + './$invoiceId/edit/' | './$invoiceId/edit' + > + + const defaultRouterLinkSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const defaultRouterObjectsLinkSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const routerAlwaysTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerNeverTrailingSlashesLinkSearch = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerPreserveTrailingSlashesLinkSearch = expectTypeOf( + RouterPreserveTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + defaultRouterLinkSearch.exclude().toEqualTypeOf<{ + rootPage?: number + page: number + }>() + + defaultRouterObjectsLinkSearch.exclude().toEqualTypeOf<{ + rootPage?: number + page: number + }>() + + routerAlwaysTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ + rootPage?: number + page: number + }>() + + routerNeverTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf<{ + rootPage?: number + page: number + }>() + + defaultRouterLinkSearch.returns.toEqualTypeOf<{ + rootPage?: number + page: number + }>() + + defaultRouterObjectsLinkSearch.returns.toEqualTypeOf<{ + rootPage?: number + page: number + }>() + + routerAlwaysTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + rootPage?: number + page: number + }>() + + routerNeverTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + rootPage?: number + page: number + }>() + + routerPreserveTrailingSlashesLinkSearch.returns.toEqualTypeOf<{ + rootPage?: number + page: number + }>() + + defaultRouterLinkSearch.parameter(0).toEqualTypeOf<{ rootPage?: number }>() + + defaultRouterObjectsLinkSearch + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() + + routerAlwaysTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() + + routerNeverTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() + + routerPreserveTrailingSlashesLinkSearch + .parameter(0) + .toEqualTypeOf<{ rootPage?: number }>() +}) + +test('when navigating to a union of routes with search params', () => { + const DefaultRouterLink = Link< + DefaultRouter, + string, + '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + string, + '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/invoices/$invoiceId/edit/' | '/posts/$postId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + | '/invoices/$invoiceId/edit' + | '/posts/$postId' + | '/invoices/$invoiceId/edit/' + | '/posts/$postId/' + > + + const defaultRouterLinkSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const defaultRouterObjectsLinkSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const routerAlwaysTrailingSlashesSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerNeverTrailingSlashesSearch = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerPreserveTrailingSlashesSearch = expectTypeOf( + RouterNeverTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + defaultRouterLinkSearch + .exclude() + .toEqualTypeOf< + { rootPage?: number; page: number } | { rootPage?: number } | undefined + >() + + defaultRouterObjectsLinkSearch + .exclude() + .toEqualTypeOf< + { rootPage?: number; page: number } | { rootPage?: number } | undefined + >() + + routerAlwaysTrailingSlashesSearch + .exclude() + .toEqualTypeOf< + { rootPage?: number; page: number } | { rootPage?: number } | undefined + >() + + routerNeverTrailingSlashesSearch + .exclude() + .toEqualTypeOf< + { rootPage?: number; page: number } | { rootPage?: number } | undefined + >() + + routerPreserveTrailingSlashesSearch + .exclude() + .toEqualTypeOf< + { rootPage?: number; page: number } | { rootPage?: number } | undefined + >() + + defaultRouterLinkSearch.returns.toEqualTypeOf< + { rootPage?: number; page: number } | { rootPage?: number } + >() + + defaultRouterObjectsLinkSearch.returns.toEqualTypeOf< + { rootPage?: number; page: number } | { rootPage?: number } + >() + + routerAlwaysTrailingSlashesSearch.returns.toEqualTypeOf< + { rootPage?: number; page: number } | { rootPage?: number } + >() + + routerNeverTrailingSlashesSearch.returns.toEqualTypeOf< + { rootPage?: number; page: number } | { rootPage?: number } + >() + + routerPreserveTrailingSlashesSearch.returns.toEqualTypeOf< + { rootPage?: number; page: number } | { rootPage?: number } + >() + + defaultRouterLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + defaultRouterObjectsLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerAlwaysTrailingSlashesSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerNeverTrailingSlashesSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerPreserveTrailingSlashesSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() +}) + +test('when navigating to a union of routes with search params including the root', () => { + const DefaultRouterLink = Link< + DefaultRouter, + string, + '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const DefaultRouterObjectsLink = Link< + DefaultRouterObjects, + string, + '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + string, + '/' | '/invoices/$invoiceId/edit/' | '/posts/$postId/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + string, + '/' | '/invoices/$invoiceId/edit' | '/posts/$postId' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + string, + | '/' + | '/invoices/$invoiceId/edit' + | '/posts/$postId' + | '/invoices/$invoiceId/edit/' + | '/posts/$postId/' + > + + const defaultRouterSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const defaultRouterObjectsSearch = expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + + const routerAlwaysTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerNeverTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + const routerPreserveTrailingSlashesLinkSearch = expectTypeOf( + RouterAlwaysTrailingSlashesLink, + ) + .parameter(0) + .toHaveProperty('search') + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .not.toMatchTypeOf<{ search: unknown }>() + + defaultRouterSearch + .exclude() + .toEqualTypeOf< + | { rootPage?: number } + | { rootPage?: number; page: number } + | { rootPage?: number; rootIndexPage: number } + | undefined + >() + + defaultRouterObjectsSearch + .exclude() + .toEqualTypeOf< + | { rootPage?: number } + | { rootPage?: number; page: number } + | { rootPage?: number; rootIndexPage: number } + | undefined + >() + + routerAlwaysTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf< + | { rootPage?: number } + | { rootPage?: number; page: number } + | { rootPage?: number; rootIndexPage: number } + | undefined + >() + + routerNeverTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf< + | { rootPage?: number } + | { rootPage?: number; page: number } + | { rootPage?: number; rootIndexPage: number } + | undefined + >() + + routerPreserveTrailingSlashesLinkSearch + .exclude() + .toEqualTypeOf< + | { rootPage?: number } + | { rootPage?: number; page: number } + | { rootPage?: number; rootIndexPage: number } + | undefined + >() + + defaultRouterSearch.returns.toEqualTypeOf< + | { rootPage?: number; page: number } + | { rootPage?: number; rootIndexPage: number } + | { rootPage?: number } + >() + + defaultRouterObjectsSearch.returns.toEqualTypeOf< + | { rootPage?: number; page: number } + | { rootPage?: number; rootIndexPage: number } + | { rootPage?: number } + >() + + routerAlwaysTrailingSlashesLinkSearch.returns.toEqualTypeOf< + | { rootPage?: number; page: number } + | { rootPage?: number; rootIndexPage: number } + | { rootPage?: number } + >() + + routerNeverTrailingSlashesLinkSearch.returns.toEqualTypeOf< + | { rootPage?: number; page: number } + | { rootPage?: number; rootIndexPage: number } + | { rootPage?: number } + >() + + routerPreserveTrailingSlashesLinkSearch.returns.toEqualTypeOf< + | { rootPage?: number; page: number } + | { rootPage?: number; rootIndexPage: number } + | { rootPage?: number } + >() + + defaultRouterSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + defaultRouterObjectsSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerAlwaysTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerNeverTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + routerPreserveTrailingSlashesLinkSearch.parameter(0).toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() +}) + +test('when navigating from the root to /posts', () => { + const DefaultRouterLink = Link + + const DefaultRouterObjectsLink = Link + + const RouterAlwaysTrailingSlashesLink = Link< + RouterAlwaysTrailingSlashes, + '/', + '/posts/' + > + + const RouterNeverTrailingSlashesLink = Link< + RouterNeverTrailingSlashes, + '/', + '/posts' + > + + const RouterPreserveTrailingSlashesLink = Link< + RouterPreserveTrailingSlashes, + '/', + '/posts' | '/posts/' + > + + expectTypeOf(DefaultRouterLink).not.toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink).not.toMatchTypeOf<{ + search: unknown + }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink).not.toMatchTypeOf<{ + search: unknown + }>() + + expectTypeOf(RouterNeverTrailingSlashesLink).not.toMatchTypeOf<{ + search: unknown + }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink).not.toMatchTypeOf<{ + search: unknown + }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number } | undefined>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number } | undefined>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number } | undefined>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number } | undefined>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number } | undefined>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf< + { rootPage?: number } | { rootPage?: number; rootIndexPage: number } + >() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf< + { rootPage?: number } | { rootPage?: number; rootIndexPage: number } + >() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf< + { rootPage?: number } | { rootPage?: number; rootIndexPage: number } + >() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf< + { rootPage?: number } | { rootPage?: number; rootIndexPage: number } + >() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf< + { rootPage?: number } | { rootPage?: number; rootIndexPage: number } + >() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(RouterAlwaysTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(RouterNeverTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(RouterPreserveTrailingSlashesLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number }>() +}) + +test('when navigating to a route with SearchSchemaInput', () => { + expectTypeOf( + Link< + DefaultRouter, + '/invoices/$invoiceId/details/$detailId/lines', + '/invoices/$invoiceId/details/$detailId/lines' + >, + ) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf< + { rootPage?: number; page?: number; linesPage?: number } | undefined + >() + + expectTypeOf( + Link< + DefaultRouterObjects, + '/invoices/$invoiceId/details/$detailId/lines', + '/invoices/$invoiceId/details/$detailId/lines' + >, + ) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf< + { rootPage?: number; page?: number; linesPage?: number } | undefined + >() + + expectTypeOf( + Link< + RouterAlwaysTrailingSlashes, + '/invoices/$invoiceId/details/$detailId/lines', + '/invoices/$invoiceId/details/$detailId/lines/' + >, + ) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf< + { rootPage?: number; page?: number; linesPage?: number } | undefined + >() + + expectTypeOf( + Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId/details/$detailId/lines', + '/invoices/$invoiceId/details/$detailId/lines' + >, + ) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf< + { rootPage?: number; page?: number; linesPage?: number } | undefined + >() + + expectTypeOf( + Link< + RouterPreserveTrailingSlashes, + '/invoices/$invoiceId/details/$detailId/lines', + | '/invoices/$invoiceId/details/$detailId/lines/' + | '/invoices/$invoiceId/details/$detailId/lines' + >, + ) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf< + { rootPage?: number; page?: number; linesPage?: number } | undefined + >() + + expectTypeOf( + Link, + ) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ + rootPage?: number + page?: number + linesPage?: number + }>() + + expectTypeOf( + Link< + DefaultRouterObjects, + '/invoices/$invoiceId/details/$detailId/lines', + '/invoices/$invoiceId/details/$detailId/lines' + >, + ) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ + rootPage?: number + page?: number + linesPage?: number + }>() + + expectTypeOf( + Link< + RouterAlwaysTrailingSlashes, + '/invoices/$invoiceId/details/$detailId/lines', + '/invoices/$invoiceId/details/$detailId/lines/' + >, + ) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ + rootPage?: number + page?: number + linesPage?: number + }>() + + expectTypeOf( + Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId/details/$detailId/lines', + '/invoices/$invoiceId/details/$detailId/lines' + >, + ) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ + rootPage?: number + page?: number + linesPage?: number + }>() + + expectTypeOf( + Link< + RouterPreserveTrailingSlashes, + '/invoices/$invoiceId/details/$detailId/lines', + | '/invoices/$invoiceId/details/$detailId/lines/' + | '/invoices/$invoiceId/details/$detailId/lines' + >, + ) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ + rootPage?: number + page?: number + linesPage?: number + }>() + + expectTypeOf( + Link< + DefaultRouter, + '/invoices/$invoiceId/details/$detailId/lines', + '/invoices/$invoiceId/details/$detailId/lines' + >, + ) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number; page?: number; linesPage: number }>() + + expectTypeOf( + Link< + DefaultRouterObjects, + '/invoices/$invoiceId/details/$detailId/lines', + '/invoices/$invoiceId/details/$detailId/lines' + >, + ) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number; page?: number; linesPage: number }>() + + expectTypeOf( + Link< + RouterAlwaysTrailingSlashes, + '/invoices/$invoiceId/details/$detailId/lines', + '/invoices/$invoiceId/details/$detailId/lines/' + >, + ) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number; page?: number; linesPage: number }>() + + expectTypeOf( + Link< + RouterNeverTrailingSlashes, + '/invoices/$invoiceId/details/$detailId/lines', + '/invoices/$invoiceId/details/$detailId/lines/' + >, + ) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number; page?: number; linesPage: number }>() + + expectTypeOf( + Link< + RouterPreserveTrailingSlashes, + '/invoices/$invoiceId/details/$detailId/lines', + | '/invoices/$invoiceId/details/$detailId/lines/' + | '/invoices/$invoiceId/details/$detailId/lines' + >, + ) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ rootPage?: number; page?: number; linesPage: number }>() +}) + +test('when passing a component with props to createLink and navigating to the root', () => { + const MyLink = createLink((props: { additionalProps: number }) => ( + + )) + + const DefaultRouterLink = MyLink + const DefaultRouterObjectsLink = MyLink + const RouterAlwaysTrailingSlashLink = MyLink< + RouterAlwaysTrailingSlashes, + string, + '/' + > + const RouterNeverTrailingSlashLink = MyLink< + RouterNeverTrailingSlashes, + string, + '/' + > + const RouterPreserveTrailingSlashLink = MyLink< + RouterPreserveTrailingSlashes, + string, + '/' + > + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/edit' + | '/posts' + | '/posts/$postId' + | undefined + >() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/edit' + | '/posts' + | '/posts/$postId' + | undefined + >() + + expectTypeOf(RouterAlwaysTrailingSlashLink) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../' + | './' + | '/' + | '/invoices/' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details/$detailId/lines/' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/edit/' + | '/posts/' + | '/posts/$postId/' + | undefined + >() + + expectTypeOf(RouterNeverTrailingSlashLink) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/edit' + | '/posts' + | '/posts/$postId' + | undefined + >() + + expectTypeOf(RouterPreserveTrailingSlashLink) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | './' + | '../' + | '/' + | '/invoices' + | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/details/$detailId/lines/' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/edit/' + | '/posts' + | '/posts/' + | '/posts/$postId' + | '/posts/$postId/' + | undefined + >() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterAlwaysTrailingSlashLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterNeverTrailingSlashLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(RouterPreserveTrailingSlashLink) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterAlwaysTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterNeverTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterPreserveTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(RouterAlwaysTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(RouterNeverTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(RouterPreserveTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(DefaultRouterObjectsLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterAlwaysTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterNeverTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(RouterPreserveTrailingSlashLink) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('additionalProps') + .toEqualTypeOf() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('activeProps') + .returns.toHaveProperty('additionalProps') + .toEqualTypeOf() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('activeProps') + .exclude<(...args: Array) => any>() + .toEqualTypeOf< + | { + [x: `data-${string}`]: unknown + ref?: React.LegacyRef | undefined + additionalProps?: number | undefined + key?: React.Key | null | undefined + } + | undefined + >() + + expectTypeOf(DefaultRouterLink) + .parameter(0) + .toHaveProperty('activeProps') + .extract<(...args: Array) => any>() + .returns.toEqualTypeOf<{ + [x: `data-${string}`]: unknown + ref?: React.LegacyRef | undefined + additionalProps?: number | undefined + key?: React.Key | null | undefined + }>() + + createLink((props) => expectTypeOf(props).toEqualTypeOf()) +}) + +test('ResolveRelativePath', () => { + expectTypeOf>().toEqualTypeOf<'/posts'>() + + expectTypeOf< + ResolveRelativePath<'/posts/1/comments', '..'> + >().toEqualTypeOf<'/posts/1'>() + + expectTypeOf< + ResolveRelativePath<'/posts/1/comments', '../..'> + >().toEqualTypeOf<'/posts'>() + + expectTypeOf< + ResolveRelativePath<'/posts/1/comments', '../../..'> + >().toEqualTypeOf<'/'>() + + expectTypeOf< + ResolveRelativePath<'/posts/1/comments', './1'> + >().toEqualTypeOf<'/posts/1/comments/1'>() + + expectTypeOf< + ResolveRelativePath<'/posts/1/comments', './1/2'> + >().toEqualTypeOf<'/posts/1/comments/1/2'>() + + expectTypeOf< + ResolveRelativePath<'/posts/1/comments', '../edit'> + >().toEqualTypeOf<'/posts/1/edit'>() + + expectTypeOf< + ResolveRelativePath<'/posts/1/comments', '1'> + >().toEqualTypeOf<'/posts/1/comments/1'>() + + expectTypeOf< + ResolveRelativePath<'/posts/1/comments', './1'> + >().toEqualTypeOf<'/posts/1/comments/1'>() + + expectTypeOf< + ResolveRelativePath<'/posts/1/comments', './1/2'> + >().toEqualTypeOf<'/posts/1/comments/1/2'>() +}) + +test('linkOptions', () => { + const defaultRouterLinkOptions = linkOptions< + { label: string }, + DefaultRouter, + string, + '/' + > + const defaultRouterObjectsLinkOptions = linkOptions< + { label: string }, + DefaultRouter, + string, + '/' + > + + const routerAlwaysTrailingSlashLinkOptions = linkOptions< + { label: string }, + RouterAlwaysTrailingSlashes, + string, + '/' + > + + const routerNeverTrailingSlashLinkOptions = linkOptions< + { label: string }, + RouterNeverTrailingSlashes, + string, + '/' + > + const routerPreserveTrailingSlashLinkOptions = linkOptions< + { label: string }, + RouterPreserveTrailingSlashes, + string, + '/' + > + + expectTypeOf(defaultRouterLinkOptions) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/edit' + | '/posts' + | '/posts/$postId' + | undefined + >() + + expectTypeOf(defaultRouterObjectsLinkOptions) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/edit' + | '/posts' + | '/posts/$postId' + | undefined + >() + + expectTypeOf(routerAlwaysTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '../' + | './' + | '/' + | '/invoices/' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details/$detailId/lines/' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/edit/' + | '/posts/' + | '/posts/$postId/' + | undefined + >() + + expectTypeOf(routerNeverTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | '/' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/edit' + | '/posts' + | '/posts/$postId' + | undefined + >() + + expectTypeOf(routerPreserveTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '..' + | '.' + | './' + | '../' + | '/' + | '/invoices' + | '/invoices/' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/' + | '/invoices/$invoiceId/details/$detailId' + | '/invoices/$invoiceId/details/$detailId/' + | '/invoices/$invoiceId/details/$detailId/lines' + | '/invoices/$invoiceId/details/$detailId/lines/' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/' + | '/invoices/$invoiceId/edit' + | '/invoices/$invoiceId/edit/' + | '/posts' + | '/posts/' + | '/posts/$postId' + | '/posts/$postId/' + | undefined + >() + + expectTypeOf(defaultRouterLinkOptions) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(defaultRouterObjectsLinkOptions) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(routerAlwaysTrailingSlashLinkOptions) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(routerNeverTrailingSlashLinkOptions) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(routerPreserveTrailingSlashLinkOptions) + .parameter(0) + .toMatchTypeOf<{ search: unknown }>() + + expectTypeOf(defaultRouterLinkOptions) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(defaultRouterObjectsLinkOptions) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(routerAlwaysTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(routerNeverTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(routerPreserveTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(defaultRouterLinkOptions) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(defaultRouterObjectsLinkOptions) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(routerAlwaysTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(routerNeverTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(routerPreserveTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ + page?: number + rootIndexPage?: number + rootPage?: number + linesPage?: number + }>() + + expectTypeOf(defaultRouterLinkOptions) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(defaultRouterObjectsLinkOptions) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(routerAlwaysTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(routerNeverTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(routerPreserveTrailingSlashLinkOptions) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ rootPage?: number; rootIndexPage: number }>() + + expectTypeOf(defaultRouterLinkOptions).returns.toEqualTypeOf<{ + label: string + }>() + + expectTypeOf(defaultRouterObjectsLinkOptions).returns.toEqualTypeOf<{ + label: string + }>() + + expectTypeOf(routerAlwaysTrailingSlashLinkOptions).returns.toEqualTypeOf<{ + label: string + }>() + + expectTypeOf(routerNeverTrailingSlashLinkOptions).returns.toEqualTypeOf<{ + label: string + }>() + + expectTypeOf(routerPreserveTrailingSlashLinkOptions).returns.toEqualTypeOf<{ + label: string + }>() +}) diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx new file mode 100644 index 00000000000..a00d734de36 --- /dev/null +++ b/packages/solid-router/tests/link.test.tsx @@ -0,0 +1,4411 @@ +import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@solidjs/testing-library' +import * as Solid from 'solid-js' + +import { z } from 'zod' +import { + Link, + Outlet, + RouterProvider, + createLink, + createMemoryHistory, + createRootRoute, + createRootRouteWithContext, + createRoute, + createRouteMask, + createRouter, + redirect, + retainSearchParams, + stripSearchParams, + useLoaderData, + useMatchRoute, + useParams, + useRouteContext, + useSearch, +} from '../src' +import { + getIntersectionObserverMock, + getSearchParamsFromURI, + sleep, +} from './utils' + +const ioObserveMock = vi.fn() +const ioDisconnectMock = vi.fn() + +beforeEach(() => { + const io = getIntersectionObserverMock({ + observe: ioObserveMock, + disconnect: ioDisconnectMock, + }) + vi.stubGlobal('IntersectionObserver', io) + window.history.replaceState(null, 'root', '/') +}) + +afterEach(() => { + vi.resetAllMocks() + cleanup() +}) + +const WAIT_TIME = 300 + +describe('Link', () => { + // rerender doesn't exist in solid + // test('when using renderHook it returns a hook with same content to prove rerender works', async () => { + // /** + // * This is the hook that will be tested. + // * + // * @returns custom state + // */ + // const useLocationFromState = () => { + // const routerState = useRouterState() + // const location = () => routerState().location + + // // could return anything just to prove it will work. + // const memoLocation = Solid.createMemo(() => ({ + // href: location().href, + // pathname: location().pathname, + // })) + + // return memoLocation + // } + + // const IndexComponent = (props: Solid.ParentProps) => { + // return

{props.children}

+ // } + // const RouterContainer = (props: Solid.ParentProps) => { + // const rootRoute = createRootRoute() + // const indexRoute = createRoute({ + // getParentRoute: () => rootRoute, + // path: '/', + // component: () => {props.children}, + // }) + // const memoedRouteTree = rootRoute.addChildren([indexRoute]) + + // const router = createRouter({ + // routeTree: memoedRouteTree, + // }) + + // const memoedRouter = router + + // return + // } + + // const { result, rerender } = renderHook( + // () => { + // return useLocationFromState() + // }, + // { wrapper: RouterContainer }, + // ) + // await waitFor(() => expect(screen.getByTestId('testId')).toBeVisible()) + // expect(result.current).toBeTruthy() + + // const original = result.current + + // rerender() + + // await waitFor(() => expect(screen.getByTestId('testId')).toBeVisible()) + // const updated = result.current + + // expect(original).toBe(updated) + // }) + + test('when a Link is disabled', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

Index

+ + Posts + + + ), + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + expect(window.location.pathname).toBe('/') + + expect(postsLink).not.toBeDisabled() + expect(postsLink).toHaveAttribute('aria-disabled', 'true') + + fireEvent.click(postsLink) + + await expect( + screen.findByRole('header', { name: 'Posts' }), + ).rejects.toThrow() + }) + + test('when the current route is the root', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + Index + + + Posts + + + ) + }, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return

Posts

+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const indexLink = await screen.findByRole('link', { name: 'Index' }) + + expect(window.location.pathname).toBe('/') + + expect(indexLink).toHaveAttribute('aria-current', 'page') + expect(indexLink).toHaveClass('active') + expect(indexLink).toHaveAttribute('data-status', 'active') + expect(indexLink).toHaveAttribute('href', '/') + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + expect(postsLink).toHaveClass('inactive') + expect(postsLink).toHaveAttribute('href', '/posts') + expect(postsLink).not.toHaveAttribute('aria-current', 'page') + expect(postsLink).not.toHaveAttribute('data-status', 'active') + }) + + describe('when the current route has a search fields with undefined values', () => { + async function runTest(opts: { explicitUndefined: boolean | undefined }) { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + Index exact + + + Index foo=undefined + + + Index foo=undefined-exact + + + Index foo=bar + + + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + }) + + render(() => ) + + // round 1 + const indexExactLink = await screen.findByRole('link', { + name: 'Index exact', + }) + + const indexFooUndefinedLink = await screen.findByRole('link', { + name: 'Index foo=undefined', + }) + + const indexFooUndefinedExactLink = await screen.findByRole('link', { + name: 'Index foo=undefined-exact', + }) + + const indexFooBarLink = await screen.findByRole('link', { + name: 'Index foo=bar', + }) + + expect(window.location.pathname).toBe('/') + + expect(indexExactLink).toHaveClass('active') + expect(indexExactLink).not.toHaveClass('inactive') + expect(indexExactLink).toHaveAttribute('href', '/') + expect(indexExactLink).toHaveAttribute('aria-current', 'page') + expect(indexExactLink).toHaveAttribute('data-status', 'active') + + if (opts.explicitUndefined) { + expect(indexFooUndefinedLink).toHaveClass('active') + expect(indexFooUndefinedLink).not.toHaveClass('inactive') + expect(indexFooUndefinedLink).toHaveAttribute('aria-current', 'page') + expect(indexFooUndefinedLink).toHaveAttribute('data-status', 'active') + } else { + expect(indexFooUndefinedLink).toHaveClass('active') + expect(indexFooUndefinedLink).not.toHaveClass('inactive') + expect(indexFooUndefinedLink).toHaveAttribute('aria-current', 'page') + expect(indexFooUndefinedLink).toHaveAttribute('data-status', 'active') + } + + expect(indexFooUndefinedLink).toHaveAttribute('href', '/') + + if (opts.explicitUndefined) { + expect(indexFooUndefinedExactLink).not.toHaveClass('active') + expect(indexFooUndefinedExactLink).toHaveClass('inactive') + expect(indexFooUndefinedExactLink).not.toHaveAttribute( + 'aria-current', + 'page', + ) + expect(indexFooUndefinedExactLink).not.toHaveAttribute( + 'data-status', + 'active', + ) + } else { + expect(indexFooUndefinedExactLink).toHaveClass('active') + expect(indexFooUndefinedExactLink).not.toHaveClass('inactive') + expect(indexFooUndefinedExactLink).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(indexFooUndefinedExactLink).toHaveAttribute( + 'data-status', + 'active', + ) + } + + expect(indexFooUndefinedExactLink).toHaveAttribute('href', '/') + + expect(indexFooBarLink).toHaveClass('inactive') + expect(indexFooBarLink).not.toHaveClass('active') + expect(indexFooBarLink).toHaveAttribute('href', '/?foo=bar') + expect(indexFooBarLink).not.toHaveAttribute('aria-current', 'page') + expect(indexFooBarLink).not.toHaveAttribute('data-status', 'active') + + // navigate to /?foo=bar + fireEvent.click(indexFooBarLink) + + expect(indexExactLink).toHaveClass('inactive') + expect(indexExactLink).not.toHaveClass('active') + expect(indexExactLink).toHaveAttribute('href', '/') + expect(indexExactLink).not.toHaveAttribute('aria-current', 'page') + expect(indexExactLink).not.toHaveAttribute('data-status', 'active') + + if (opts.explicitUndefined) { + expect(indexFooUndefinedLink).not.toHaveClass('active') + expect(indexFooUndefinedLink).toHaveClass('inactive') + expect(indexFooUndefinedLink).not.toHaveAttribute( + 'aria-current', + 'page', + ) + expect(indexFooUndefinedLink).not.toHaveAttribute( + 'data-status', + 'active', + ) + } else { + expect(indexFooUndefinedLink).toHaveClass('active') + expect(indexFooUndefinedLink).not.toHaveClass('inactive') + expect(indexFooUndefinedLink).toHaveAttribute('aria-current', 'page') + expect(indexFooUndefinedLink).toHaveAttribute('data-status', 'active') + } + + expect(indexFooUndefinedLink).toHaveAttribute('href', '/') + + expect(indexFooUndefinedExactLink).toHaveClass('inactive') + expect(indexFooUndefinedExactLink).not.toHaveClass('active') + expect(indexFooUndefinedExactLink).toHaveAttribute('href', '/') + expect(indexFooUndefinedExactLink).not.toHaveAttribute( + 'aria-current', + 'page', + ) + expect(indexFooUndefinedExactLink).not.toHaveAttribute( + 'data-status', + 'active', + ) + + expect(indexFooBarLink).toHaveClass('active') + expect(indexFooBarLink).not.toHaveClass('inactive') + expect(indexFooBarLink).toHaveAttribute('href', '/?foo=bar') + expect(indexFooBarLink).toHaveAttribute('aria-current', 'page') + expect(indexFooBarLink).toHaveAttribute('data-status', 'active') + } + + test.each([undefined, false])( + 'activeOptions.explicitUndefined=%s', + async (explicitUndefined) => { + await runTest({ explicitUndefined }) + }, + ) + + test('activeOptions.explicitUndefined=true', async () => { + await runTest({ explicitUndefined: true }) + }) + }) + + test('when the current route is the root with beforeLoad that throws', async () => { + const onError = vi.fn() + const rootRoute = createRootRoute({ + onError, + beforeLoad: () => { + throw new Error('Something went wrong!') + }, + errorComponent: () => Oops! Something went wrong!, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + Index + + + Posts + + + ) + }, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return

Posts

+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const errorText = await screen.findByText('Oops! Something went wrong!') + expect(errorText).toBeInTheDocument() + expect(onError).toHaveBeenCalledOnce() + }) + + test('when navigating to /posts', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Index + Posts + + ) + }, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + <> +

Posts

+ Index + + Posts + + + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + fireEvent.click(postsLink) + + const postsHeading = await screen.findByRole('heading', { name: 'Posts' }) + expect(postsHeading).toBeInTheDocument() + + expect(window.location.pathname).toBe('/posts') + + const indexLink = await screen.findByRole('link', { name: 'Index' }) + + expect(window.location.pathname).toBe('/posts') + expect(indexLink).not.toHaveAttribute('aria-current', 'page') + expect(indexLink).not.toHaveAttribute('data-status', 'active') + expect(indexLink).toHaveAttribute('href', '/') + + expect(postsLink).toHaveAttribute('data-status', 'active') + expect(postsLink).toHaveAttribute('aria-current', 'page') + expect(postsLink).toHaveClass('active') + expect(postsLink).toHaveAttribute('href', '/posts') + }) + + test('when navigating to /posts with a base url', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Index + Posts + + ) + }, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + <> +

Posts

+ Index + + Posts + + + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + basepath: '/app', + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + fireEvent.click(postsLink) + + const postsHeading = await screen.findByRole('heading', { name: 'Posts' }) + expect(postsHeading).toBeInTheDocument() + + const indexLink = await screen.findByRole('link', { name: 'Index' }) + + expect(window.location.pathname).toBe('/app/posts') + expect(indexLink).not.toHaveAttribute('aria-current', 'page') + expect(indexLink).not.toHaveAttribute('data-status', 'active') + expect(indexLink).toHaveAttribute('href', '/app/') + + expect(postsLink).toHaveAttribute('data-status', 'active') + expect(postsLink).toHaveAttribute('aria-current', 'page') + expect(postsLink).toHaveClass('active') + expect(postsLink).toHaveAttribute('href', '/app/posts') + }) + + test('when navigating to /posts with search', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + Posts + + + ) + }, + }) + + const PostsComponent = () => { + const search = useSearch({ strict: false }) + return ( + <> +

Posts

+ Page: {search().page} + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + validateSearch: (input: Record) => { + return { + page: input.page, + } + }, + component: PostsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + expect(postsLink).toHaveAttribute('href', '/posts?page=0') + + fireEvent.click(postsLink) + + const postsHeading = await screen.findByRole('heading', { name: 'Posts' }) + expect(postsHeading).toBeInTheDocument() + + expect(window.location.pathname).toBe('/posts') + expect(window.location.search).toBe('?page=0') + + const pageZero = await screen.findByText('Page: 0') + expect(pageZero).toBeInTheDocument() + }) + + test('when navigating to /posts with invalid search', async () => { + const rootRoute = createRootRoute() + const onError = vi.fn() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + Posts + + + ) + }, + }) + + const PostsComponent = () => { + const search = useSearch({ strict: false }) + return ( + <> +

Posts

+ Page: {search().page} + + ) + } + + const ErrorComponent = () => { + return

Oops, something went wrong

+ } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + errorComponent: ErrorComponent, + onError, + validateSearch: (input: Record) => { + const page = Number(input.page) + + if (isNaN(page)) throw Error('Not a number!') + + return { + page, + } + }, + component: PostsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + expect(postsLink).toHaveAttribute('href', '/posts?page=invalid') + + fireEvent.click(postsLink) + + await waitFor(() => expect(onError).toHaveBeenCalledOnce()) + + const errorHeading = await screen.findByRole('heading', { + name: 'Oops, something went wrong', + }) + expect(errorHeading).toBeInTheDocument() + }) + + test('when navigating to /posts with a loader', async () => { + const loader = vi.fn((opts) => { + return Promise.resolve({ pageDoubled: opts.deps.page.page * 2 }) + }) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + Posts + + + ) + }, + }) + + const PostsComponent = () => { + const data = useLoaderData({ strict: false }) + return ( + <> +

Posts

+ Page: {data().pageDoubled} + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + validateSearch: (input: Record) => { + const page = Number(input.page) + + if (isNaN(page)) throw Error('Not a number!') + + return { + page, + } + }, + loaderDeps: (opts) => ({ page: opts.search }), + loader: loader, + component: PostsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + expect(postsLink).toHaveAttribute('href', '/posts?page=2') + + fireEvent.click(postsLink) + + const pageFour = await screen.findByText('Page: 4') + expect(pageFour).toBeInTheDocument() + + expect(loader).toHaveBeenCalledOnce() + }) + + test('when navigating to /posts with a loader that errors', async () => { + const onError = vi.fn() + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + Posts + + + ) + }, + }) + + const PostsComponent = () => { + const loader = useLoaderData({ strict: false }) + return ( + <> +

Posts

+ Page: {loader().pageDoubled} + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + validateSearch: (input: Record) => { + const page = Number(input.page) + + if (isNaN(page)) throw Error('Not a number!') + + return { + page, + } + }, + loaderDeps: (opts) => ({ page: opts.search }), + onError, + errorComponent: () => Something went wrong!, + loader: () => { + throw new Error() + }, + component: PostsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + expect(postsLink).toHaveAttribute('href', '/posts?page=2') + + fireEvent.click(postsLink) + + const errorText = await screen.findByText('Something went wrong!') + expect(errorText).toBeInTheDocument() + + expect(onError).toHaveBeenCalledOnce() + }) + + test('when navigating away from a route with a loader that errors', async () => { + const postsOnError = vi.fn() + const indexOnError = vi.fn() + const rootRoute = createRootRoute({ + component: () => ( + <> +
+ Index Posts +
+
+ + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + ) + }, + onError: indexOnError, + errorComponent: () => IndexError, + }) + + const error = new Error('Something went wrong!') + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + loaderDeps: (opts) => ({ page: opts.search }), + loader: () => { + throw error + }, + onError: postsOnError, + errorComponent: () => PostsError, + component: () => { + return ( + <> +

Posts

+ + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + fireEvent.click(postsLink) + + const postsErrorText = await screen.findByText('PostsError') + expect(postsErrorText).toBeInTheDocument() + + expect(postsOnError).toHaveBeenCalledOnce() + expect(postsOnError).toHaveBeenCalledWith(error) + + const indexLink = await screen.findByRole('link', { name: 'Index' }) + fireEvent.click(indexLink) + + expect(screen.findByText('IndexError')).rejects.toThrow() + expect(indexOnError).not.toHaveBeenCalledOnce() + }) + + test('when navigating to /posts with a beforeLoad that redirects', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + Posts + + + ) + }, + }) + + const PostsComponent = () => { + return

Posts

+ } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + beforeLoad: () => { + throw redirect({ + to: '/login', + }) + }, + component: PostsComponent, + }) + + const authRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'login', + component: () =>

Auth!

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute, authRoute]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + fireEvent.click(postsLink) + + const authText = await screen.findByText('Auth!') + expect(authText).toBeInTheDocument() + }) + + test('when navigating to /posts with a beforeLoad that returns context', async () => { + const rootRoute = createRootRouteWithContext<{ + userId: string + }>()() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + ) + }, + }) + + const PostsComponent = () => { + const context = useRouteContext({ strict: false }) + return ( + <> +

Posts

+ UserId: {context().userId} + Username: {context().username} + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + beforeLoad: () => { + return Promise.resolve({ + username: 'username', + }) + }, + component: PostsComponent, + }) + + const router = createRouter({ + context: { userId: 'userId' }, + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + fireEvent.click(postsLink) + + const userId = await screen.findByText('UserId: userId') + expect(userId).toBeInTheDocument() + }) + + test('when navigating to /posts with a beforeLoad that throws an error', async () => { + const onError = vi.fn() + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + ) + }, + }) + + const PostsComponent = () => { + return

Posts

+ } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + beforeLoad: () => { + throw new Error('Oops. Something went wrong!') + }, + onError, + errorComponent: () => Oops! Something went wrong!, + component: PostsComponent, + }) + + const router = createRouter({ + context: { userId: 'userId' }, + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + fireEvent.click(postsLink) + + const errorText = await screen.findByText('Oops! Something went wrong!') + expect(errorText).toBeInTheDocument() + + expect(onError).toHaveBeenCalledOnce() + }) + + test('when navigating to /posts with a beforeLoad that throws an error bubbles to the root', async () => { + const rootRoute = createRootRoute({ + errorComponent: () => Oops! Something went wrong!, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + ) + }, + }) + + const PostsComponent = () => { + return

Posts

+ } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + beforeLoad: () => { + throw new Error('Oops. Something went wrong!') + }, + component: PostsComponent, + }) + + const router = createRouter({ + context: { userId: 'userId' }, + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + fireEvent.click(postsLink) + + const errorText = await screen.findByText('Oops! Something went wrong!') + expect(errorText).toBeInTheDocument() + }) + + test('when navigating to /posts with a beforeLoad that throws an error bubbles to the nearest parent', async () => { + const rootRoute = createRootRoute({ + errorComponent: () => Root error, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + Post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + errorComponent: () => Oops! Something went wrong!, + component: PostsComponent, + }) + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + beforeLoad: () => { + throw new Error('Oops. Something went wrong!') + }, + }) + + const router = createRouter({ + context: { userId: 'userId' }, + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute]), + ]), + }) + + render(() => ) + + const postLink = await screen.findByRole('link', { name: 'Post' }) + + fireEvent.click(postLink) + + const errorText = await screen.findByText('Oops! Something went wrong!') + expect(errorText).toBeInTheDocument() + }) + + test('when navigating to the root with an error in component', async () => { + const notFoundComponent = vi.fn() + + const rootRoute = createRootRoute({ + errorComponent: () => Expected rendering error message, + notFoundComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + throw new Error( + 'Error from component should not render notFoundComponent', + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + }) + + render(() => ) + + const errorText = await screen.findByText( + 'Expected rendering error message', + ) + expect(errorText).toBeInTheDocument() + expect(notFoundComponent).not.toBeCalled() + }) + + test('when navigating to /posts with params', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + To first post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ Index + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return Params: {params().postId} + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute]), + ]), + }) + + render(() => ) + + const postLink = await screen.findByRole('link', { + name: 'To first post', + }) + + expect(postLink).toHaveAttribute('href', '/posts/id1') + + fireEvent.click(postLink) + + const paramText = await screen.findByText('Params: id1') + expect(paramText).toBeInTheDocument() + }) + + test('when navigating from /posts to ./$postId', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + To first post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostsIndexComponent = () => { + return ( + <> +

Posts Index

+ + To the first post + + + ) + } + + const postsIndexRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '/', + component: PostsIndexComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + Index + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postsIndexRoute, postRoute]), + ]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + expect(postsLink).toHaveAttribute('href', '/posts') + + fireEvent.click(postsLink) + + const postsText = await screen.findByText('Posts Index') + expect(postsText).toBeInTheDocument() + + const postLink = await screen.findByRole('link', { + name: 'To the first post', + }) + + expect(postLink).toHaveAttribute('href', '/posts/id1') + + fireEvent.click(postLink) + + const paramText = await screen.findByText('Params: id1') + expect(paramText).toBeInTheDocument() + + expect(window.location.pathname).toBe('/posts/id1') + }) + + test('when navigating from /posts to ../posts/$postId', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + To first post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostsIndexComponent = () => { + return ( + <> +

Posts Index

+ + To the first post + + + ) + } + + const postsIndexRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '/', + component: PostsIndexComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + Index + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postsIndexRoute, postRoute]), + ]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + + expect(postsLink).toHaveAttribute('href', '/posts') + + fireEvent.click(postsLink) + + const postsIndexText = await screen.findByText('Posts Index') + expect(postsIndexText).toBeInTheDocument() + + const postLink = await screen.findByRole('link', { + name: 'To the first post', + }) + + expect(postLink).toHaveAttribute('href', '/posts/id1') + + fireEvent.click(postLink) + + const paramText = await screen.findByText('Params: id1') + expect(paramText).toBeInTheDocument() + }) + + test('when navigating from /posts/$postId to /posts/$postId/info and the current route is /posts/$postId/details', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + To first post + + + ) + }, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const DetailsComponent = () => { + return ( + <> +

Details!

+ + To Information + + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return

Information

+ } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([detailsRoute, informationRoute]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'To first post' }) + + expect(postsLink).toHaveAttribute('href', '/posts/id1/details') + + fireEvent.click(postsLink) + + const paramsText1 = await screen.findByText('Params: id1') + expect(paramsText1).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const informationLink = await screen.findByRole('link', { + name: 'To Information', + }) + + expect(informationLink).toHaveAttribute('href', '/posts/id1/info') + + fireEvent.click(informationLink) + + const informationText = await screen.findByText('Information') + expect(informationText).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/info') + + const paramsText2 = await screen.findByText('Params: id1') + expect(paramsText2).toBeInTheDocument() + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating from /posts/$postId with a trailing slash to /posts/$postId/info and the current route is /posts/$postId/details', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + To first post + + + ) + }, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId/', + component: PostComponent, + }) + + const DetailsComponent = () => { + return ( + <> +

Details!

+ + To Information + + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return

Information

+ } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([detailsRoute, informationRoute]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'To first post' }) + + expect(postsLink).toHaveAttribute('href', '/posts/id1/details') + + fireEvent.click(postsLink) + + const paramsText1 = await screen.findByText('Params: id1') + expect(paramsText1).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const informationLink = await screen.findByRole('link', { + name: 'To Information', + }) + + expect(informationLink).toHaveAttribute('href', '/posts/id1/info') + + fireEvent.click(informationLink) + + const informationText = await screen.findByText('Information') + expect(informationText).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/info') + + const paramsText2 = await screen.findByText('Params: id1') + expect(paramsText2).toBeInTheDocument() + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating from /posts/$postId to ./info and the current route is /posts/$postId/details', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + To first post + + + ) + }, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const DetailsComponent = () => { + return ( + <> +

Details!

+ + To Information + + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return

Information

+ } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([detailsRoute, informationRoute]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'To first post' }) + + expect(postsLink).toHaveAttribute('href', '/posts/id1/details') + + fireEvent.click(postsLink) + + const paramsText1 = await screen.findByText('Params: id1') + expect(paramsText1).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const informationLink = await screen.findByRole('link', { + name: 'To Information', + }) + + expect(informationLink).toHaveAttribute('href', '/posts/id1/info') + + fireEvent.click(informationLink) + + const informationText = await screen.findByText('Information') + expect(informationText).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/info') + + const paramsText2 = await screen.findByText('Params: id1') + expect(paramsText2).toBeInTheDocument() + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating from /posts/$postId to / and the current route is /posts/$postId/details', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + To first post + + + ) + }, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const DetailsComponent = () => { + return ( + <> +

Details!

+ + To Root + + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([postRoute.addChildren([detailsRoute])]), + ]), + ]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'To first post' }) + + expect(postsLink).toHaveAttribute('href', '/posts/id1/details') + + fireEvent.click(postsLink) + + const paramsText1 = await screen.findByText('Params: id1') + expect(paramsText1).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const rootLink = await screen.findByRole('link', { + name: 'To Root', + }) + + expect(rootLink).toHaveAttribute('href', '/') + + fireEvent.click(rootLink) + + const indexText = await screen.findByText('Index') + expect(indexText).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/') + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating from /posts/$postId with search and to ./info with search and the current route is /posts/$postId/details', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + To first post + + + ) + }, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + validateSearch: () => ({ page: 2 }), + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const DetailsComponent = () => { + return ( + <> +

Details!

+ ({ ...prev, more: true })} + > + To Information + + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return

Information

+ } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + validateSearch: () => ({ more: false }), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([detailsRoute, informationRoute]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'To first post' }) + + expect(postsLink).toHaveAttribute('href', '/posts/id1/details?page=2') + + fireEvent.click(postsLink) + + const paramsText1 = await screen.findByText('Params: id1') + expect(paramsText1).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const informationLink = await screen.findByRole('link', { + name: 'To Information', + }) + + expect(informationLink).toHaveAttribute( + 'href', + '/posts/id1/info?page=2&more=true', + ) + + fireEvent.click(informationLink) + + const informationText = await screen.findByText('Information') + expect(informationText).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/info') + + const paramsText2 = await screen.findByText('Params: id1') + expect(paramsText2).toBeInTheDocument() + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating from /posts/$postId to ../$postId and the current route is /posts/$postId/details', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + To first post + + + ) + }, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const DetailsComponent = () => { + return ( + <> +

Details!

+ + To Post + + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return

Information

+ } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([detailsRoute, informationRoute]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'To first post' }) + + expect(postsLink).toHaveAttribute('href', '/posts/id1/details') + + fireEvent.click(postsLink) + + const paramsText1 = await screen.findByText('Params: id1') + expect(paramsText1).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const postLink = await screen.findByRole('link', { + name: 'To Post', + }) + + expect(postLink).toHaveAttribute('href', '/posts/id1') + + fireEvent.click(postLink) + + const postsText = await screen.findByText('Posts') + expect(postsText).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1') + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating from /posts/$postId with an index to ../$postId and the current route is /posts/$postId/details', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + To first post + + + ) + }, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const postIndexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + component: PostComponent, + }) + + const DetailsComponent = () => { + return ( + <> +

Details!

+ + To Post + + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return

Information

+ } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([ + postIndexRoute, + detailsRoute, + informationRoute, + ]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'To first post' }) + + expect(postsLink).toHaveAttribute('href', '/posts/id1/details') + + fireEvent.click(postsLink) + + const paramsText1 = await screen.findByText('Params: id1') + expect(paramsText1).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const postLink = await screen.findByRole('link', { + name: 'To Post', + }) + + expect(postLink).toHaveAttribute('href', '/posts/id1') + + fireEvent.click(postLink) + + const postsText = await screen.findByText('Posts') + expect(postsText).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1') + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating from /invoices to ./invoiceId and the current route is /posts/$postId/details', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ Posts + + To first post + + + ) + }, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const DetailsComponent = () => { + return ( + <> +

Details!

+ + To Invoices + + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return

Information

+ } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + component: () => ( + <> +

Invoices!

+ + + ), + }) + + const InvoiceComponent = () => { + const params = useParams({ strict: false }) + return invoiceId: {params().invoiceId} + } + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + component: InvoiceComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute]), + postsRoute.addChildren([ + postRoute.addChildren([detailsRoute, informationRoute]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'To first post' }) + + expect(postsLink).toHaveAttribute('href', '/posts/id1/details') + + fireEvent.click(postsLink) + + const invoicesErrorText = await screen.findByText( + 'Invariant failed: Could not find match for from: /invoices', + ) + expect(invoicesErrorText).toBeInTheDocument() + }) + + test('when navigating to /posts/$postId/info which is declaratively masked as /posts/$postId', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + To first post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const InformationComponent = () => { + return

Information

+ } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute.addChildren([informationRoute])]), + ]) + + const routeMask = createRouteMask({ + routeTree, + from: '/posts/$postId/info', + to: '/posts/$postId', + }) + + const router = createRouter({ + routeTree, + routeMasks: [routeMask], + }) + + render(() => ) + + const informationLink = await screen.findByRole('link', { + name: 'To first post', + }) + + expect(informationLink).toHaveAttribute('href', '/posts/id1') + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating to /posts/$postId/info which is imperatively masked as /posts/$postId', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + To first post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const InformationComponent = () => { + return

Information

+ } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute.addChildren([informationRoute])]), + ]) + + const router = createRouter({ + routeTree, + }) + + render(() => ) + + const informationLink = await screen.findByRole('link', { + name: 'To first post', + }) + + expect(informationLink).toHaveAttribute('href', '/posts/id1') + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when preloading /post/$postId with a redirects to /login', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + To first post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const loaderFn = vi.fn() + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const search = vi.fn((prev) => ({ page: prev.postPage })) + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + validateSearch: () => ({ postPage: 0 }), + loader: () => { + loaderFn() + throw redirect({ + to: '/login', + search, + }) + }, + }) + + const LoginComponent = () => { + const [status, setStatus] = Solid.createSignal< + 'idle' | 'success' | 'error' + >('idle') + + Solid.onMount(() => { + const onLoad = async () => { + try { + await router.preloadRoute({ + to: '/posts/$postId', + params: { postId: 'id1' }, + search: { postPage: 0 }, + }) + setStatus('success') + } catch (e) { + setStatus('error') + } + } + onLoad() + }) + + return <>{status() === 'success' ? 'Login!' : 'Waiting...'} + } + + const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'login', + component: LoginComponent, + validateSearch: () => ({ page: 0 }), + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + loginRoute, + postsRoute.addChildren([postRoute]), + ]) + + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + }) + + render(() => ) + + const postLink = await screen.findByRole('link', { + name: 'To first post', + }) + + expect(postLink).toHaveAttribute('href', '/posts/id1') + + fireEvent.mouseOver(postLink) + + await waitFor(() => expect(loaderFn).toHaveBeenCalled()) + + await waitFor(() => expect(search).toHaveBeenCalledWith({ postPage: 0 })) + + fireEvent.click(postLink) + + const loginText = await screen.findByText('Login!') + expect(loginText).toBeInTheDocument() + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating to /post/$postId with a redirect from /post/$postId to ../../login in a loader', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + To first post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const search = vi.fn((prev) => ({ page: prev.postPage })) + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + validateSearch: () => ({ postPage: 0 }), + loader: () => { + throw redirect({ + from: postRoute.fullPath, + to: '../../login', + search, + }) + }, + }) + + const LoginComponent = () => { + return <>Login! + } + + const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'login', + component: LoginComponent, + validateSearch: () => ({ page: 0 }), + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + loginRoute, + postsRoute.addChildren([postRoute]), + ]) + + const router = createRouter({ + routeTree, + }) + + render(() => ) + + const postLink = await screen.findByRole('link', { + name: 'To first post', + }) + + expect(postLink).toHaveAttribute('href', '/posts/id1') + + fireEvent.click(postLink) + + const loginText = await screen.findByText('Login!') + expect(loginText).toBeInTheDocument() + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating to /post/$postId with a redirect from /post/$postId to ../../login in beforeLoad', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + To first post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const search = vi.fn((prev) => ({ page: prev.postPage })) + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + validateSearch: () => ({ postPage: 0 }), + beforeLoad: () => { + throw redirect({ + from: postRoute.fullPath, + to: '../../login', + search, + }) + }, + }) + + const LoginComponent = () => { + return <>Login! + } + + const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'login', + component: LoginComponent, + validateSearch: () => ({ page: 0 }), + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + loginRoute, + postsRoute.addChildren([postRoute]), + ]) + + const router = createRouter({ + routeTree, + }) + + render(() => ) + + const postLink = await screen.findByRole('link', { + name: 'To first post', + }) + + expect(postLink).toHaveAttribute('href', '/posts/id1') + + fireEvent.click(postLink) + + const loginText = await screen.findByText('Login!') + expect(loginText).toBeInTheDocument() + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when preloading /post/$postId with a beforeLoad that navigates to /login', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + To first post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const search = vi.fn((prev) => ({ page: prev.postPage })) + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + validateSearch: () => ({ postPage: 0 }), + beforeLoad: (context) => context.navigate({ to: '/login', search }), + }) + + const LoginComponent = () => { + return <>Login! + } + + const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'login', + component: LoginComponent, + validateSearch: () => ({ page: 0 }), + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + loginRoute, + postsRoute.addChildren([postRoute]), + ]) + + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + }) + + render(() => ) + + const postLink = await screen.findByRole('link', { + name: 'To first post', + }) + + expect(postLink).toHaveAttribute('href', '/posts/id1') + + fireEvent.mouseOver(postLink) + + await waitFor(() => expect(search).toHaveBeenCalledWith({ postPage: 0 })) + + fireEvent.click(postLink) + + const loginText = await screen.findByText('Login!') + expect(loginText).toBeInTheDocument() + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when preloading /post/$postId with a loader that navigates to /login', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + To first post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const search = vi.fn((prev) => ({ page: prev.postPage })) + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + validateSearch: () => ({ postPage: 0 }), + loader: (context) => { + context.navigate({ to: '/login', search }) + }, + }) + + const LoginComponent = () => { + return <>Login! + } + + const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'login', + component: LoginComponent, + validateSearch: () => ({ page: 0 }), + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + loginRoute, + postsRoute.addChildren([postRoute]), + ]) + + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + }) + + render(() => ) + + const postLink = await screen.findByRole('link', { + name: 'To first post', + }) + + expect(postLink).toHaveAttribute('href', '/posts/id1') + + fireEvent.mouseOver(postLink) + + await waitFor(() => expect(search).toHaveBeenCalledWith({ postPage: 0 })) + + fireEvent.click(postLink) + + const loginText = await screen.findByText('Login!') + expect(loginText).toBeInTheDocument() + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating from /posts to /invoices with conditionally rendering Link on the root', async () => { + const ErrorComponent = vi.fn(() =>
Something went wrong!
) + const RootComponent = () => { + const matchRoute = useMatchRoute() + const matchPosts = Boolean(matchRoute({ to: '/posts' })) + const matchInvoices = Boolean(matchRoute({ to: '/invoices' })) + + return ( + <> + {matchPosts && ( + + From posts + + )} + {matchInvoices && ( + + From invoices + + )} + + + ) + } + + const rootRoute = createRootRoute({ + component: RootComponent, + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

Index Route

+ Go to posts + + ), + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: () => ( + <> +

On Posts

+ To invoices + + ), + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + component: () => ( + <> +

On Invoices

+ To posts + + ), + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + postsRoute, + invoicesRoute, + ]) + + const router = createRouter({ + routeTree, + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Go to posts' }) + + fireEvent.click(postsLink) + + const fromPostsLink = await screen.findByRole('link', { + name: 'From posts', + }) + + expect(fromPostsLink).toBeInTheDocument() + + const toInvoicesLink = await screen.findByRole('link', { + name: 'To invoices', + }) + + fireEvent.click(toInvoicesLink) + + const fromInvoicesLink = await screen.findByRole('link', { + name: 'From invoices', + }) + + expect(fromInvoicesLink).toBeInTheDocument() + + expect(fromPostsLink).not.toBeInTheDocument() + + const toPostsLink = await screen.findByRole('link', { + name: 'To posts', + }) + + fireEvent.click(toPostsLink) + + const onPostsText = await screen.findByText('On Posts') + expect(onPostsText).toBeInTheDocument() + + expect(fromInvoicesLink).not.toBeInTheDocument() + + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when linking to self with from prop set and param containing a slash', async () => { + const ErrorComponent = vi.fn(() =>

Something went wrong!

) + + const rootRoute = createRootRoute({ + errorComponent: ErrorComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + + Go to post + + ), + }) + + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/$postId', + component: () => ( + + Link to self with from prop set + + ), + }) + + const routeTree = rootRoute.addChildren([indexRoute, postRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const postLink = await screen.findByRole('link', { + name: 'Go to post', + }) + + expect(postLink).toHaveAttribute('href', '/id%2Fwith-slash') + + fireEvent.click(postLink) + + const selfLink = await screen.findByRole('link', { + name: 'Link to self with from prop set', + }) + + expect(selfLink).toBeInTheDocument() + expect(ErrorComponent).not.toHaveBeenCalled() + }) + + test('when navigating to /$postId with parseParams and stringifyParams', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + + Go to post + + ), + }) + + const parseParams = vi.fn() + const stringifyParams = vi.fn() + + const PostComponent = () => { + const params = useParams({ strict: false }) + return
Post: {params().postId}
+ } + + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '$postId', + parseParams: (params) => { + parseParams(params) + return { + status: 'parsed', + postId: params.postId, + } + }, + stringifyParams: (params) => { + stringifyParams(params) + return { + status: 'stringified', + postId: params.postId, + } + }, + component: PostComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, postRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const postLink = await screen.findByRole('link', { + name: 'Go to post', + }) + + expect(stringifyParams).toHaveBeenCalledWith({ postId: 2 }) + + expect(postLink).toHaveAttribute('href', '/2') + + fireEvent.click(postLink) + + const posts2Text = await screen.findByText('Post: 2') + expect(posts2Text).toBeInTheDocument() + + expect(parseParams).toHaveBeenCalledWith({ status: 'parsed', postId: '2' }) + }) + + test('when navigating to /$postId with params.parse and params.stringify', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + + Go to post + + ), + }) + + const parseParams = vi.fn() + const stringifyParams = vi.fn() + + const PostComponent = () => { + const params = useParams({ strict: false }) + return
Post: {params().postId}
+ } + + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '$postId', + params: { + parse: (params) => { + parseParams(params) + return { + status: 'parsed', + postId: params.postId, + } + }, + stringify: (params) => { + stringifyParams(params) + return { + status: 'stringified', + postId: params.postId, + } + }, + }, + component: PostComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, postRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const postLink = await screen.findByRole('link', { + name: 'Go to post', + }) + + expect(stringifyParams).toHaveBeenCalledWith({ postId: 2 }) + + expect(postLink).toHaveAttribute('href', '/2') + + fireEvent.click(postLink) + + const posts2Text = await screen.findByText('Post: 2') + expect(posts2Text).toBeInTheDocument() + + expect(parseParams).toHaveBeenCalledWith({ status: 'parsed', postId: '2' }) + }) + + test('when navigating to /$postId with params.parse and params.stringify handles falsey inputs', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> + + Go to post 2 + + + Go to post 0 + + + ), + }) + + const stringifyParamsMock = vi.fn() + + const parseParams = ({ postId }: { postId: string }) => { + return { + postId: parseInt(postId), + } + } + + const stringifyParams = ({ postId }: { postId: number }) => { + stringifyParamsMock({ postId }) + return { + postId: postId.toString(), + } + } + + const PostComponent = () => { + const params = useParams({ strict: false }) + return
Post: {params().postId}
+ } + + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '$postId', + params: { + parse: parseParams, + stringify: stringifyParams, + }, + component: PostComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, postRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const postLink2 = await screen.findByRole('link', { + name: 'Go to post 2', + }) + const postLink0 = await screen.findByRole('link', { + name: 'Go to post 0', + }) + + expect(postLink2).toHaveAttribute('href', '/2') + expect(postLink0).toHaveAttribute('href', '/0') + + expect(stringifyParamsMock).toHaveBeenCalledWith({ postId: 2 }) + expect(stringifyParamsMock).toHaveBeenCalledWith({ postId: 0 }) + }) + + test.each([false, 'intent', 'render'] as const)( + 'Router.preload="%s", should not trigger the IntersectionObserver\'s observe and disconnect methods', + async (preload) => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

Index Heading

+ Index Link + + ), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + defaultPreload: preload, + }) + + render(() => ) + + const indexLink = await screen.findByRole('link', { name: 'Index Link' }) + expect(indexLink).toBeInTheDocument() + + expect(ioObserveMock).not.toBeCalled() + expect(ioDisconnectMock).not.toBeCalled() + }, + ) + + test.each([false, 'intent', 'viewport', 'render'] as const)( + 'Router.preload="%s" with Link.preload="false", should not trigger the IntersectionObserver\'s observe method', + async (preload) => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

Index Heading

+ + Index Link + + + ), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + defaultPreload: preload, + }) + + render(() => ) + + const indexLink = await screen.findByRole('link', { name: 'Index Link' }) + expect(indexLink).toBeInTheDocument() + + expect(ioObserveMock).not.toBeCalled() + }, + ) + + test('Router.preload="viewport", should trigger the IntersectionObserver\'s observe and disconnect methods', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

Index Heading

+ Index Link + + ), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + defaultPreload: 'viewport', + }) + + render(() => ) + + const indexLink = await screen.findByRole('link', { name: 'Index Link' }) + expect(indexLink).toBeInTheDocument() + + expect(ioObserveMock).toBeCalled() + expect(ioObserveMock).toBeCalledTimes(2) // since React.StrictMode is enabled it double renders + + expect(ioDisconnectMock).toBeCalled() + expect(ioDisconnectMock).toBeCalledTimes(1) // since React.StrictMode is enabled it should have disconnected + }) + + test("Router.preload='render', should trigger the route loader on render", async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => { + mock() + }, + component: () => ( + <> +

Index Heading

+ About Link + + ), + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () => ( + <> +

About Heading

+ + ), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([aboutRoute, indexRoute]), + defaultPreload: 'render', + }) + + render(() => ) + + const aboutLink = await screen.findByRole('link', { name: 'About Link' }) + expect(aboutLink).toBeInTheDocument() + + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('Router.preload="intent", pendingComponent renders during unresolved route loader', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ + link to posts + +
+ ) + }, + }) + + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loader: () => sleep(WAIT_TIME), + component: () =>
Posts page
, + }) + + const routeTree = rootRoute.addChildren([postRoute, indexRoute]) + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultPendingMs: 200, + defaultPendingComponent: () =>

Loading...

, + }) + + render(() => ) + + const linkToPosts = await screen.findByRole('link', { + name: 'link to posts', + }) + expect(linkToPosts).toBeInTheDocument() + + fireEvent.focus(linkToPosts) + fireEvent.click(linkToPosts) + + const loadingElement = await screen.findByText('Loading...') + expect(loadingElement).toBeInTheDocument() + + const postsElement = await screen.findByText('Posts page') + expect(postsElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/posts') + }) + + describe('when preloading a link, `preload` should be', () => { + async function runTest({ + expectedPreload, + testIdToHover, + }: { + expectedPreload: boolean + testIdToHover: string + }) { + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + To first post + + + To second post + + + + ) + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsBeforeLoadFn = vi.fn() + const postsLoaderFn = vi.fn() + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + beforeLoad: postsBeforeLoadFn, + loader: postsLoaderFn, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + ) + } + + const postBeforeLoadFn = vi.fn() + const postLoaderFn = vi.fn() + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + beforeLoad: postBeforeLoadFn, + loader: postLoaderFn, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute]), + ]) + + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + }) + + render(() => ) + const link = await screen.findByTestId(testIdToHover) + fireEvent.mouseOver(link) + + const expected = expect.objectContaining({ preload: expectedPreload }) + await waitFor(() => + expect(postsBeforeLoadFn).toHaveBeenCalledWith(expected), + ) + await waitFor(() => expect(postsLoaderFn).toHaveBeenCalledWith(expected)) + + await waitFor(() => + expect(postBeforeLoadFn).toHaveBeenCalledWith(expected), + ) + await waitFor(() => expect(postLoaderFn).toHaveBeenCalledWith(expected)) + } + test('`true` when on / and hovering `/posts/id1` ', async () => { + await runTest({ expectedPreload: true, testIdToHover: 'link-1' }) + }) + + test('`false` when on `/posts/id1` and hovering `/posts/id1`', async () => { + window.history.replaceState(null, 'root', '/posts/id1') + await runTest({ expectedPreload: false, testIdToHover: 'link-1' }) + }) + + test('`true` when on `/posts/id1` and hovering `/posts/id2`', async () => { + window.history.replaceState(null, 'root', '/posts/id1') + await runTest({ expectedPreload: false, testIdToHover: 'link-2' }) + }) + }) +}) + +describe('createLink', () => { + it('should pass the "disabled" prop to the rendered target element', async () => { + const CustomLink = createLink('button') + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + + Index + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + }) + + render(() => ) + + const customElement = await screen.findByText('Index') + + expect(customElement).toBeDisabled() + expect(customElement.getAttribute('disabled')).toBe('') + }) + + it('should pass the "foo" prop to the rendered target element', async () => { + const CustomLink = createLink('button') + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + + Index + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + }) + + render(() => ) + + const customElement = await screen.findByText('Index') + + expect(customElement.hasAttribute('foo')).toBe(true) + expect(customElement.getAttribute('foo')).toBe('bar') + }) + + it('should pass activeProps and inactiveProps to the custom link', async () => { + const Button: Solid.Component< + Solid.ParentProps<{ + active?: boolean + foo?: boolean + overrideMeIfYouWant: string + }> + > = (props) => { + const [local, rest] = Solid.splitProps(props, [ + 'active', + 'foo', + 'children', + ]) + + return ( + + ) + } + + const ButtonLink = createLink(Button) + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> + + Button1 + + + Button2 + + + Button3 + + + ), + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const button1 = await screen.findByText('active: yes - foo: no - Button1') + expect(button1.getAttribute('data-hello')).toBe('world') + expect(button1.getAttribute('overrideMeIfYouWant')).toBe( + 'overridden-by-activeProps', + ) + + const button2 = await screen.findByText('active: no - foo: yes - Button2') + expect(button2.getAttribute('data-hello')).toBe('void') + expect(button2.getAttribute('overrideMeIfYouWant')).toBe( + 'overridden-by-inactiveProps', + ) + + const button3 = await screen.findByText('active: no - foo: no - Button3') + expect(button3.getAttribute('overrideMeIfYouWant')).toBe('Button3') + }) +}) + +describe('search middleware', () => { + test('legacy search filters still work', async () => { + const rootRoute = createRootRoute({ + validateSearch: (input) => { + return { + root: input.root as string | undefined, + foo: input.foo as string | undefined, + } + }, + preSearchFilters: [ + (search) => { + return { ...search, foo: 'foo' } + }, + ], + postSearchFilters: [ + (search) => { + return { ...search, root: search.root ?? 'default' } + }, + ], + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ ({ page: 123, foo: p.foo })}> + Posts + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + validateSearch: (input: Record) => { + const page = Number(input.page) + return { + page, + } + }, + component: PostsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/?foo=bar'] }), + }) + + render(() => ) + + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + expect(postsLink).toHaveAttribute('href') + const href = postsLink.getAttribute('href') + const search = getSearchParamsFromURI(href!) + expect(search.size).toBe(3) + expect(search.get('page')).toBe('123') + expect(search.get('root')).toBe('default') + expect(search.get('foo')).toBe('foo') + }) + + test('search middlewares work', async () => { + const rootRoute = createRootRoute({ + validateSearch: (input) => { + return { + root: input.root as string | undefined, + foo: input.foo as string | undefined, + } + }, + search: { + middlewares: [ + ({ search, next }) => { + return next({ ...search, foo: 'foo' }) + }, + ({ search, next }) => { + expect(search.foo).toBe('foo') + const { root, ...result } = next({ ...search, foo: 'hello' }) + return { ...result, root: root ?? search.root } + }, + ], + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const search = indexRoute.useSearch() + + return ( + <> +

Index

+
{search().root ?? '$undefined'}
+ + update search + + + Posts + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + validateSearch: (input: Record) => { + const page = Number(input.page) + return { + page, + } + }, + component: PostsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/?root=abc'] }), + }) + + render(() => ) + + async function checkSearchValue(value: string) { + const searchValue = await screen.findByTestId('search') + expect(searchValue).toHaveTextContent(value) + } + async function checkPostsLink(root: string) { + const postsLink = await screen.findByRole('link', { name: 'Posts' }) + expect(postsLink).toHaveAttribute('href') + const href = postsLink.getAttribute('href') + const search = getSearchParamsFromURI(href!) + expect(search.size).toBe(2) + expect(search.get('page')).toBe('123') + expect(search.get('root')).toBe(root) + } + await checkSearchValue('abc') + await checkPostsLink('abc') + + const updateSearchLink = await screen.findByTestId('update-search') + fireEvent.click(updateSearchLink) + await checkSearchValue('newValue') + await checkPostsLink('newValue') + expect(router.state.location.search).toEqual({ root: 'newValue' }) + }) + + test('search middlewares work with redirect', async () => { + const rootRoute = createRootRoute({ + validateSearch: z.object({ root: z.string().optional() }), + component: () => { + return ( + <> +

Root

+ + posts + {' '} + + invoices + + + + ) + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: () => { + throw redirect({ to: '/posts' }) + }, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + validateSearch: z.object({ + foo: z.string().default('default'), + }), + search: { + middlewares: [ + // @ts-expect-error we cannot use zodValidator here to due to circular dependency + // this means we cannot get the correct input type for this schema + stripSearchParams({ foo: 'default' }), + retainSearchParams(true), + ], + }, + + component: () => { + const search = postsRoute.useSearch() + return ( + <> +

Posts

+
{search().foo}
+ + new + + + ) + }, + }) + + const postsNewRoute = createRoute({ + getParentRoute: () => postsRoute, + path: 'new', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postsNewRoute]), + invoicesRoute, + ]), + }) + + window.history.replaceState(null, 'root', '/?root=abc') + + render(() => ) + + const searchValue = await screen.findByTestId('posts-search') + expect(searchValue).toHaveTextContent('default') + + expect(router.state.location.pathname).toBe('/posts') + expect(router.state.location.search).toEqual({ root: 'abc' }) + + // link to sibling does not retain search param + const invoicesLink = await screen.findByTestId('root-link-invoices') + expect(invoicesLink).toHaveAttribute('href') + const invoicesLinkHref = invoicesLink.getAttribute('href') + const invoicesLinkSearch = getSearchParamsFromURI(invoicesLinkHref!) + expect(invoicesLinkSearch.size).toBe(0) + + // link to child retains search param + const postsNewLink = await screen.findByTestId('posts-link-new') + expect(postsNewLink).toHaveAttribute('href') + const postsNewLinkHref = postsNewLink.getAttribute('href') + const postsNewLinkSearch = getSearchParamsFromURI(postsNewLinkHref!) + expect(postsNewLinkSearch.size).toBe(1) + expect(postsNewLinkSearch.get('root')).toBe('abc') + + const postsLink = await screen.findByTestId('root-link-posts') + expect(postsLink).toHaveAttribute('href') + const postsLinkHref = postsNewLink.getAttribute('href') + const postsLinkSearch = getSearchParamsFromURI(postsLinkHref!) + expect(postsLinkSearch.size).toBe(1) + expect(postsLinkSearch.get('root')).toBe('abc') + expect(postsLink).toHaveAttribute('data-status', 'active') + }) + + describe('reloadDocument', () => { + test('link to /posts with params', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + To first post + + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ Index + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return Params: {params().postId} + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute]), + ]), + }) + + render(() => ) + + const postLink = await screen.findByTestId('link-to-post-1') + + expect(postLink).toHaveAttribute('href', '/posts/id1') + }) + }) +}) diff --git a/packages/solid-router/tests/loaders.test.tsx b/packages/solid-router/tests/loaders.test.tsx new file mode 100644 index 00000000000..103bde16463 --- /dev/null +++ b/packages/solid-router/tests/loaders.test.tsx @@ -0,0 +1,319 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' + +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { z } from 'zod' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +import { sleep } from './utils' + +afterEach(() => { + vi.resetAllMocks() + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +const WAIT_TIME = 100 + +describe('loaders are being called', () => { + test('called on /', async () => { + const indexLoaderMock = vi.fn() + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async () => { + await sleep(WAIT_TIME) + indexLoaderMock('foo') + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/') + expect(window.location.pathname).toBe('/') + + expect(indexLoaderMock).toHaveBeenCalled() + }) + + test('both are called on /nested/foo', async () => { + const nestedLoaderMock = vi.fn() + const nestedFooLoaderMock = vi.fn() + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to foo +
+ ) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + loader: async () => { + await sleep(WAIT_TIME) + nestedLoaderMock('nested') + }, + }) + const fooRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/foo', + loader: async () => { + await sleep(WAIT_TIME) + nestedFooLoaderMock('foo') + }, + component: () =>
Nested Foo page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([fooRoute]), + indexRoute, + ]) + const router = createRouter({ routeTree }) + + render(() => ) + + const linkToAbout = await screen.findByText('link to foo') + fireEvent.click(linkToAbout) + + const fooElement = await screen.findByText('Nested Foo page') + expect(fooElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/foo') + expect(window.location.pathname).toBe('/nested/foo') + + expect(nestedLoaderMock).toHaveBeenCalled() + expect(nestedFooLoaderMock).toHaveBeenCalled() + }) +}) + +describe('loaders parentMatchPromise', () => { + test('parentMatchPromise is defined in a child route', async () => { + const nestedLoaderMock = vi.fn() + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+ Index page + link to foo +
+ ), + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + loader: async () => { + await sleep(WAIT_TIME) + return 'nested' + }, + component: () => , + }) + const fooRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/foo', + loader: async ({ parentMatchPromise }) => { + nestedLoaderMock(parentMatchPromise) + const parentMatch = await parentMatchPromise + expect(parentMatch.loaderData).toBe('nested') + }, + component: () =>
Nested Foo page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([fooRoute]), + indexRoute, + ]) + const router = createRouter({ routeTree }) + + render(() => ) + + const linkToFoo = await screen.findByRole('link', { name: 'link to foo' }) + + expect(linkToFoo).toBeInTheDocument() + + fireEvent.click(linkToFoo) + + const fooElement = await screen.findByText('Nested Foo page') + expect(fooElement).toBeInTheDocument() + + expect(nestedLoaderMock).toHaveBeenCalled() + expect(nestedLoaderMock.mock.calls[0]?.[0]).toBeInstanceOf(Promise) + }) +}) + +test('reproducer for #2031', async () => { + const rootRoute = createRootRoute({ + beforeLoad: () => { + console.log('beforeload called') + }, + }) + + const searchSchema = z.object({ + data: z.string().array().default([]), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index page
, + + validateSearch: searchSchema, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() +}) + +test('reproducer for #2053', async () => { + const rootRoute = createRootRoute({ + beforeLoad: () => { + console.log('beforeload called') + }, + }) + + const fooRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/foo/$fooId', + component: () => { + const params = fooRoute.useParams() + return
fooId: {params().fooId}
+ }, + }) + + window.history.replaceState(null, 'root', '/foo/3ΚΑΠΠΑ') + + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createRouter({ + routeTree, + }) + + render(() => ) + + const fooElement = await screen.findByText('fooId: 3ΚΑΠΠΑ') + expect(fooElement).toBeInTheDocument() +}) + +test('reproducer for #2198 - throw error from beforeLoad upon initial load', async () => { + const rootRoute = createRootRoute({}) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index page
, + beforeLoad: () => { + throw new Error('Test!') + }, + errorComponent: () =>
indexErrorComponent
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ + routeTree, + defaultErrorComponent: () => { + return
defaultErrorComponent
+ }, + }) + + render(() => ) + + const errorElement = await screen.findByText('indexErrorComponent') + expect(errorElement).toBeInTheDocument() +}) + +test('throw error from loader upon initial load', async () => { + const rootRoute = createRootRoute({}) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index page
, + loader: () => { + throw new Error('Test!') + }, + errorComponent: () =>
indexErrorComponent
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ + routeTree, + defaultErrorComponent: () => { + return
defaultErrorComponent
+ }, + }) + + render(() => ) + + const errorElement = await screen.findByText('indexErrorComponent') + expect(errorElement).toBeInTheDocument() +}) + +test('throw error from beforeLoad when navigating to route', async () => { + const rootRoute = createRootRoute({}) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+

Index page

link to foo +
+ ), + errorComponent: () =>
indexErrorComponent
, + }) + + const fooRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + component: () =>
Foo page
, + beforeLoad: () => { + throw new Error('Test!') + }, + errorComponent: () =>
fooErrorComponent
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, fooRoute]) + const router = createRouter({ + routeTree, + defaultErrorComponent: () => { + return
defaultErrorComponent
+ }, + }) + + render(() => ) + + const linkToFoo = await screen.findByRole('link', { name: 'link to foo' }) + + expect(linkToFoo).toBeInTheDocument() + + fireEvent.click(linkToFoo) + + const indexElement = await screen.findByText('fooErrorComponent') + expect(indexElement).toBeInTheDocument() +}) diff --git a/packages/solid-router/tests/navigate.test.tsx b/packages/solid-router/tests/navigate.test.tsx new file mode 100644 index 00000000000..21f4865bb48 --- /dev/null +++ b/packages/solid-router/tests/navigate.test.tsx @@ -0,0 +1,490 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { RouterHistory } from '../src' + +afterEach(() => { + vi.clearAllMocks() +}) + +function createTestRouter(initialHistory?: RouterHistory) { + const history = + initialHistory ?? createMemoryHistory({ initialEntries: ['/'] }) + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/' }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const postIdRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '/$slug', + }) + const projectRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/p', + }) + const projectIdRoute = createRoute({ + getParentRoute: () => projectRoute, + path: '/$projectId', + }) + const projectVersionRoute = createRoute({ + getParentRoute: () => projectIdRoute, + path: '/$version', + }) + const projectFrameRoute = createRoute({ + getParentRoute: () => projectVersionRoute, + path: '/$framework', + }) + + const uRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/u', + }) + const uLayoutRoute = createRoute({ + id: '_layout', + getParentRoute: () => uRoute, + }) + const uUsernameRoute = createRoute({ + getParentRoute: () => uLayoutRoute, + path: '$username', + }) + + const gRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/g', + }) + const gLayoutRoute = createRoute({ + id: 'layout', + getParentRoute: () => gRoute, + }) + const gUsernameRoute = createRoute({ + getParentRoute: () => gLayoutRoute, + path: '$username', + }) + const searchRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'search', + validateSearch: (search: Record) => { + return { + ['foo=bar']: Number(search['foo=bar'] ?? 1), + } + }, + }) + + const projectTree = projectRoute.addChildren([ + projectIdRoute.addChildren([ + projectVersionRoute.addChildren([projectFrameRoute]), + ]), + ]) + const uTree = uRoute.addChildren([uLayoutRoute.addChildren([uUsernameRoute])]) + const gTree = gRoute.addChildren([gLayoutRoute.addChildren([gUsernameRoute])]) + + const routeTree = rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postIdRoute]), + projectTree, + uTree, + gTree, + ]) + const router = createRouter({ routeTree, history }) + + return { + router, + routes: { + indexRoute, + postsRoute, + postIdRoute, + projectRoute, + projectIdRoute, + projectVersionRoute, + projectFrameRoute, + }, + } +} + +describe('router.navigate navigation using a single path param - object syntax for updates', () => { + it('should change $slug in "/posts/$slug" from "tanner" to "tkdodo"', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/posts/tanner'] }), + ) + + await router.load() + + expect(router.state.resolvedLocation.pathname).toBe('/posts/tanner') + + await router.navigate({ + to: '/posts/$slug', + params: { slug: 'tkdodo' }, + }) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/posts/tkdodo') + }) + + it('should change $slug in "/posts/$slug" from "tanner" to "tkdodo" w/o "to" path being provided', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/posts/tanner'] }), + ) + + await router.load() + + expect(router.state.resolvedLocation.pathname).toBe('/posts/tanner') + + await router.navigate({ + params: { slug: 'tkdodo' }, + } as any) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/posts/tkdodo') + }) +}) + +describe('router.navigate navigation using a single path param - function syntax for updates', () => { + it('should change $slug in "/posts/$slug" from "tanner" to "tkdodo"', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/posts/tanner'] }), + ) + + await router.load() + + expect(router.state.resolvedLocation.pathname).toBe('/posts/tanner') + + await router.navigate({ + to: '/posts/$slug', + params: (p: any) => ({ ...p, slug: 'tkdodo' }), + }) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/posts/tkdodo') + }) + + it('should change $slug in "/posts/$slug" from "tanner" to "tkdodo" w/o "to" path being provided', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/posts/tanner'] }), + ) + + await router.load() + + expect(router.state.resolvedLocation.pathname).toBe('/posts/tanner') + + await router.navigate({ + params: (p: any) => ({ ...p, slug: 'tkdodo' }), + } as any) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/posts/tkdodo') + }) +}) + +describe('router.navigate navigation using multiple path params - object syntax for updates', () => { + it('should change $projectId in "/p/$projectId/$version/$framework" from "router" to "query"', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + to: '/p/$projectId/$version/$framework', + params: { projectId: 'query' }, + }) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/query/v1/react') + }) + + it('should change $projectId in "/p/$projectId/$version/$framework" from "router" to "query" w/o "to" path being provided', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + params: { projectId: 'query' }, + } as any) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/query/v1/react') + }) + + it('should change $version in "/p/$projectId/$version/$framework" from "v1" to "v3"', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + to: '/p/$projectId/$version/$framework', + params: { version: 'v3' }, + }) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/router/v3/react') + }) + + it('should change $version in "/p/$projectId/$version/$framework" from "v1" to "v3" w/o "to" path being provided', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + params: { version: 'v3' }, + } as any) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/router/v3/react') + }) + + it('should change $framework in "/p/$projectId/$version/$framework" from "react" to "vue"', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + to: '/p/$projectId/$version/$framework', + params: { framework: 'vue' }, + }) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/router/v1/vue') + }) + + it('should change $framework in "/p/$projectId/$version/$framework" from "react" to "vue" w/o "to" path being provided', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + params: { framework: 'vue' }, + } as any) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/router/v1/vue') + }) +}) + +describe('router.navigate navigation using multiple path params - function syntax for updates', () => { + it('should change $projectId in "/p/$projectId/$version/$framework" from "router" to "query"', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + to: '/p/$projectId/$version/$framework', + params: (p: any) => ({ ...p, projectId: 'query' }), + }) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/query/v1/react') + }) + + it('should change $projectId in "/p/$projectId/$version/$framework" from "router" to "query" w/o "to" path being provided', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + params: (p: any) => ({ ...p, projectId: 'query' }), + } as any) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/query/v1/react') + }) + + it('should change $version in "/p/$projectId/$version/$framework" from "v1" to "v3"', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + to: '/p/$projectId/$version/$framework', + params: (p: any) => ({ ...p, version: 'v3' }), + }) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/router/v3/react') + }) + + it('should change $version in "/p/$projectId/$version/$framework" from "v1" to "v3" w/o "to" path being provided', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + params: (p: any) => ({ ...p, version: 'v3' }), + } as any) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/router/v3/react') + }) + + it('should change $framework in "/p/$projectId/$version/$framework" from "react" to "vue"', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + to: '/p/$projectId/$version/$framework', + params: (p: any) => ({ ...p, framework: 'vue' }), + }) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/router/v1/vue') + }) + + it('should change $framework in "/p/$projectId/$version/$framework" from "react" to "vue" w/o "to" path being provided', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/p/router/v1/react'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/p/router/v1/react') + + await router.navigate({ + params: (p: any) => ({ ...p, framework: 'vue' }), + } as any) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/p/router/v1/vue') + }) +}) + +describe('router.navigate navigation using layout routes resolves correctly', () => { + it('should resolve "/u/tanner" in "/u/_layout/$username" to "/u/tkdodo"', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/u/tanner'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/u/tanner') + + await router.navigate({ + to: '/u/$username', + params: { username: 'tkdodo' }, + }) + + await router.invalidate() + + expect(router.state.location.pathname).toBe('/u/tkdodo') + }) + + it('should resolve "/u/tanner" in "/u/_layout/$username" to "/u/tkdodo" w/o "to" path being provided', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/u/tanner'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/u/tanner') + + await router.navigate({ + params: { username: 'tkdodo' }, + } as any) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/u/tkdodo') + }) + + it('should resolve "/g/tanner" in "/g/layout/$username" to "/g/tkdodo"', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/g/tanner'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/g/tanner') + + await router.navigate({ + to: '/g/$username', + params: { username: 'tkdodo' }, + }) + + await router.invalidate() + + expect(router.state.location.pathname).toBe('/g/tkdodo') + }) + + it('should resolve "/g/tanner" in "/g/layout/$username" to "/g/tkdodo" w/o "to" path being provided', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/g/tanner'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/g/tanner') + + await router.navigate({ + params: { username: 'tkdodo' }, + } as any) + await router.invalidate() + + expect(router.state.location.pathname).toBe('/g/tkdodo') + }) + + it('should handle search params with special characters', async () => { + const { router } = createTestRouter( + createMemoryHistory({ initialEntries: ['/search?foo%3Dbar=2'] }), + ) + + await router.load() + + expect(router.state.location.pathname).toBe('/search') + expect(router.state.location.search).toStrictEqual({ 'foo=bar': 2 }) + + await router.navigate({ + search: { 'foo=bar': 3 }, + } as any) + await router.invalidate() + + expect(router.state.location.search).toStrictEqual({ 'foo=bar': 3 }) + }) +}) diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx new file mode 100644 index 00000000000..bd70d0ec721 --- /dev/null +++ b/packages/solid-router/tests/redirect.test.tsx @@ -0,0 +1,357 @@ +import { + cleanup, + configure, + fireEvent, + render, + screen, +} from '@solidjs/testing-library' + +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { + Link, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + redirect, + useRouter, +} from '../src' + +import { sleep } from './utils' + +afterEach(() => { + vi.clearAllMocks() + vi.resetAllMocks() + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +const WAIT_TIME = 100 + +describe('redirect', () => { + describe('SPA', () => { + configure({ reactStrictMode: true }) + test('when `redirect` is thrown in `beforeLoad`', async () => { + const nestedLoaderMock = vi.fn() + const nestedFooLoaderMock = vi.fn() + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/foo' }) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + loader: async () => { + await sleep(WAIT_TIME) + nestedLoaderMock('nested') + }, + }) + const fooRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/foo', + loader: async () => { + await sleep(WAIT_TIME) + nestedFooLoaderMock('foo') + }, + component: () =>
Nested Foo page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([fooRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree }) + + render(() => ) + + const linkToAbout = await screen.findByText('link to about') + + expect(linkToAbout).toBeInTheDocument() + + fireEvent.click(linkToAbout) + + const fooElement = await screen.findByText('Nested Foo page') + + expect(fooElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/foo') + expect(window.location.pathname).toBe('/nested/foo') + + expect(nestedLoaderMock).toHaveBeenCalled() + expect(nestedFooLoaderMock).toHaveBeenCalled() + }) + + test('when `redirect` is thrown in `loader`', async () => { + const nestedLoaderMock = vi.fn() + const nestedFooLoaderMock = vi.fn() + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ + to: '/nested/foo', + hash: 'some-hash', + search: { someSearch: 'hello123' }, + }) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + loader: async () => { + await sleep(WAIT_TIME) + nestedLoaderMock('nested') + }, + }) + const fooRoute = createRoute({ + validateSearch: (search) => { + return { + someSearch: search.someSearch as string, + } + }, + getParentRoute: () => nestedRoute, + path: '/foo', + loader: async () => { + await sleep(WAIT_TIME) + nestedFooLoaderMock('foo') + }, + component: () =>
Nested Foo page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([fooRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree }) + + render(() => ) + + const linkToAbout = await screen.findByText('link to about') + + expect(linkToAbout).toBeInTheDocument() + + fireEvent.click(linkToAbout) + + const fooElement = await screen.findByText('Nested Foo page') + + expect(fooElement).toBeInTheDocument() + + expect(router.state.location.href).toBe( + '/nested/foo?someSearch=hello123#some-hash', + ) + expect(window.location.pathname).toBe('/nested/foo') + + expect(nestedLoaderMock).toHaveBeenCalled() + expect(nestedFooLoaderMock).toHaveBeenCalled() + }) + + test('when `redirect` is thrown in `loader` after `router.invalidate()`', async () => { + let shouldRedirect = false + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ + link to about + +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + await sleep(WAIT_TIME) + if (shouldRedirect) { + throw redirect({ + to: '/final', + }) + } + }, + component: () => { + const router = useRouter() + return ( + + ) + }, + }) + const finalRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/final', + component: () =>
Final
, + }) + + const routeTree = rootRoute.addChildren([ + aboutRoute, + indexRoute, + finalRoute, + ]) + const router = createRouter({ routeTree }) + + render(() => ) + + const linkToAbout = await screen.findByTestId('link-to-about') + expect(linkToAbout).toBeInTheDocument() + + fireEvent.click(linkToAbout) + + const invalidateButton = await screen.findByTestId('button-invalidate') + expect(invalidateButton).toBeInTheDocument() + + fireEvent.click(invalidateButton) + + expect(await screen.findByText('Final')).toBeInTheDocument() + expect(window.location.pathname).toBe('/final') + }) + }) + + describe('SSR', () => { + test('when `redirect` is thrown in `beforeLoad`', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + beforeLoad: () => { + throw redirect({ + to: '/about', + }) + }, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () => { + return 'About' + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + // Mock server mode + isServer: true, + }) + + await router.load() + + expect(router.state.redirect).toEqual({ + _fromLocation: expect.objectContaining({ + hash: '', + href: '/', + pathname: '/', + search: {}, + searchStr: '', + }), + to: '/about', + headers: {}, + reloadDocument: false, + href: '/about', + isRedirect: true, + routeId: '/', + routerCode: 'BEFORE_LOAD', + statusCode: 307, + }) + }) + + test('when `redirect` is thrown in `loader`', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + loader: () => { + throw redirect({ + to: '/about', + }) + }, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () => { + return 'About' + }, + }) + + const router = createRouter({ + history: createMemoryHistory({ + initialEntries: ['/'], + }), + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + }) + + // Mock server mode + router.isServer = true + + await router.load() + + expect(router.state.redirect).toEqual({ + _fromLocation: expect.objectContaining({ + hash: '', + href: '/', + pathname: '/', + search: {}, + searchStr: '', + }), + to: '/about', + headers: {}, + href: '/about', + isRedirect: true, + reloadDocument: false, + routeId: '/', + statusCode: 307, + }) + }) + }) +}) diff --git a/packages/solid-router/tests/redirects.test-d.tsx b/packages/solid-router/tests/redirects.test-d.tsx new file mode 100644 index 00000000000..2516f14ec08 --- /dev/null +++ b/packages/solid-router/tests/redirects.test-d.tsx @@ -0,0 +1,45 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRoute, createRouter, redirect } from '../src' + +const rootRoute = createRootRoute() + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +test('can redirect to valid route', () => { + expectTypeOf(redirect) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + '/' | '/invoices' | '/invoices/$invoiceId' | '.' | '..' | undefined + >() +}) diff --git a/packages/solid-router/tests/route.test-d.tsx b/packages/solid-router/tests/route.test-d.tsx new file mode 100644 index 00000000000..f54a0cb7a90 --- /dev/null +++ b/packages/solid-router/tests/route.test-d.tsx @@ -0,0 +1,1777 @@ +import { expectTypeOf, test } from 'vitest' +import { + createRootRoute, + createRootRouteWithContext, + createRoute, + createRouter, + redirect, +} from '../src' +import type { + AnyRouter, + BuildLocationFn, + ControlledPromise, + NavigateFn, + NavigateOptions, + ParsedLocation, + Route, + SearchSchemaInput, +} from '../src' +import type { + MakeRouteMatchFromRoute, + MakeRouteMatchUnion, +} from '../src/Matches' + +test('when creating the root', () => { + const rootRoute = createRootRoute() + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() +}) + +test('when creating the root with routeContext', () => { + const rootRoute = createRootRoute({ + context: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: {} + deps: {} + matches: Array + }>() + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() +}) + +test('when creating the root with beforeLoad', () => { + const rootRoute = createRootRoute({ + beforeLoad: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: {} + search: {} + matches: Array + }>() + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() +}) + +test('when creating the root with a loader', () => { + const rootRoute = createRootRoute({ + loader: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + context: {} + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: never + cause: 'preload' | 'enter' | 'stay' + route: Route + }>() + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() +}) + +test('when creating the root route with context and routeContext', () => { + const createRouteResult = createRootRouteWithContext<{ userId: string }>() + const rootRoute = createRouteResult({ + context: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + deps: {} + matches: Array + }>() + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute, + context: { userId: '123' }, + }) + + expectTypeOf(rootRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + }>() + + expectTypeOf(rootRoute.useRouteContext) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() +}) + +test('when creating the root route with context and beforeLoad', () => { + const createRouteResult = createRootRouteWithContext<{ userId: string }>() + + const rootRoute = createRouteResult({ + beforeLoad: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + search: {} + matches: Array + }>() + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute, + context: { userId: '123' }, + }) + + expectTypeOf(rootRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + }>() + + expectTypeOf(rootRoute.useRouteContext) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() +}) + +test('when creating the root route with context and a loader', () => { + const createRouteResult = createRootRouteWithContext<{ userId: string }>() + + const rootRoute = createRouteResult({ + loader: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + context: { userId: string } + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: never + cause: 'preload' | 'enter' | 'stay' + route: Route + }>() + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute, + context: { userId: '123' }, + }) + + expectTypeOf(rootRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + }>() + + expectTypeOf(rootRoute.useRouteContext) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() +}) + +test('when creating the root route with context, routeContext, beforeLoad and a loader', () => { + const createRouteResult = createRootRouteWithContext<{ userId: string }>() + + const rootRoute = createRouteResult({ + context: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + deps: {} + matches: Array + }>() + + return { + env: 'env1' as const, + } + }, + beforeLoad: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string; env: 'env1' } + search: {} + matches: Array + }>() + return { permission: 'view' as const } + }, + loader: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + context: { userId: string; permission: 'view'; env: 'env1' } + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: never + cause: 'preload' | 'enter' | 'stay' + route: Route + }>() + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute, + context: { userId: '123' }, + }) + + expectTypeOf(rootRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + permission: 'view' + env: 'env1' + }>() + + expectTypeOf(rootRoute.useRouteContext) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((context: { + userId: string + permission: 'view' + env: 'env1' + }) => string) + | undefined + >() +}) + +test('when creating a child route from the root route', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + }) + + expectTypeOf(invoicesRoute.fullPath).toEqualTypeOf<'/invoices'>() + expectTypeOf(invoicesRoute.path).toEqualTypeOf<'invoices'>() + expectTypeOf(invoicesRoute.id).toEqualTypeOf<'/invoices'>() +}) + +test('when creating a child route from the root route with context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + context: { userId: '123' }, + }) + + expectTypeOf(rootRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + }>() + + expectTypeOf(rootRoute.useRouteContext) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() +}) + +test('when creating a child route with routeContext from the root route with context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + context: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + deps: {} + matches: Array + }>() + + return { + env: 'env1' as const, + } + }, + }) +}) + +test('when creating a child route with beforeLoad from the root route with context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + beforeLoad: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + search: {} + matches: Array + }>() + }, + }) +}) + +test('when creating a child route with a loader from the root route', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + loader: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + context: {} + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: Promise> + cause: 'preload' | 'enter' | 'stay' + route: Route + }>() + return [{ id: 'invoice1' }, { id: 'invoice2' }] as const + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + }) + + expectTypeOf(invoicesRoute.useLoaderData) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | (( + search: readonly [ + { readonly id: 'invoice1' }, + { readonly id: 'invoice2' }, + ], + ) => string) + | undefined + >() + + expectTypeOf(invoicesRoute.useLoaderData) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf(invoicesRoute.useLoaderData()).toEqualTypeOf< + readonly [{ readonly id: 'invoice1' }, { readonly id: 'invoice2' }] + >() +}) + +test('when creating a child route with a loader from the root route with context', () => { + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + loader: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + context: { userId: string } + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: Promise> + cause: 'preload' | 'enter' | 'stay' + route: Route + }>() + return [{ id: 'invoice1' }, { id: 'invoice2' }] as const + }, + }) + + const rootRoute = createRootRouteWithContext<{ + userId: string + }>()() + + const routeTree = rootRoute.addChildren([invoicesRoute]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree, + context: { userId: '123' }, + }) + + expectTypeOf(invoicesRoute.useLoaderData) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | (( + search: readonly [ + { readonly id: 'invoice1' }, + { readonly id: 'invoice2' }, + ], + ) => string) + | undefined + >() + + expectTypeOf(invoicesRoute.useLoaderData) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf(invoicesRoute.useLoaderData()).toEqualTypeOf< + readonly [{ readonly id: 'invoice1' }, { readonly id: 'invoice2' }] + >() + + expectTypeOf(invoicesRoute.useLoaderData()).toEqualTypeOf< + readonly [{ readonly id: 'invoice1' }, { readonly id: 'invoice2' }] + >() +}) + +test('when creating a child route with search params from the root route', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + }) + + expectTypeOf(invoicesRoute.useSearch()).toEqualTypeOf<{ + page: number + }>() + + expectTypeOf(invoicesRoute.useSearch) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf<((search: { page: number }) => number) | undefined>() + + expectTypeOf(invoicesRoute.useSearch) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) + +test('when creating a child route with optional search params from the root route', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + validateSearch: (): { page?: number } => ({ page: 0 }), + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + }) + + expectTypeOf(invoicesRoute.useSearch()).toEqualTypeOf<{ + page?: number + }>() + + expectTypeOf(invoicesRoute.useSearch) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + ((search: { page?: number | undefined }) => number) | undefined + >() + + expectTypeOf(invoicesRoute.useSearch) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) + +test('when creating a child route with params from the root route', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + path: 'invoices/$invoiceId', + getParentRoute: () => rootRoute, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + }) + + expectTypeOf(invoicesRoute.useParams()).toEqualTypeOf<{ + invoiceId: string + }>() + + expectTypeOf(invoicesRoute.useParams) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf<((params: { invoiceId: string }) => number) | undefined>() + + expectTypeOf(invoicesRoute.useParams) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) + +test('when creating a child route with a splat param from the root route', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + path: 'invoices/$', + getParentRoute: () => rootRoute, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + }) + + expectTypeOf(invoicesRoute.useParams()).toEqualTypeOf<{ + _splat?: string + }>() + + expectTypeOf(invoicesRoute.useParams) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf<((params: { _splat?: string }) => number) | undefined>() + + expectTypeOf(invoicesRoute.useParams) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) + +test('when creating a child route with a param and splat param from the root route', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + path: 'invoices/$invoiceId/$', + getParentRoute: () => rootRoute, + }) + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + }) + + expectTypeOf(invoicesRoute.useParams()).toEqualTypeOf<{ + invoiceId: string + _splat?: string + }>() + + expectTypeOf(invoicesRoute.useParams) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + ((params: { invoiceId: string; _splat?: string }) => number) | undefined + >() + + expectTypeOf(invoicesRoute.useParams) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) + +test('when creating a child route with params, search and loader from the root route', () => { + const rootRoute = createRootRoute() + + createRoute({ + path: 'invoices/$invoiceId', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + loader: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + deps: {} + context: {} + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: Promise> + cause: 'preload' | 'enter' | 'stay' + route: Route + }> + }, + }) +}) + +test('when creating a child route with params, search, loader and loaderDeps from the root route', () => { + const rootRoute = createRootRoute() + + createRoute({ + path: 'invoices/$invoiceId', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + loaderDeps: (deps) => ({ page: deps.search.page }), + loader: (opts) => + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + deps: { page: number } + context: {} + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: Promise> + cause: 'preload' | 'enter' | 'stay' + route: Route + }>(), + }) +}) + +test('when creating a child route with params, search, loader and loaderDeps from the root route with context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + path: 'invoices/$invoiceId', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + loaderDeps: (deps) => ({ page: deps.search.page }), + loader: (opts) => + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + deps: { page: number } + context: { userId: string } + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: Promise> + cause: 'preload' | 'enter' | 'stay' + route: Route + }>(), + }) +}) + +test('when creating a child route with params, search with routeContext from the root route with context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + path: 'invoices/$invoiceId', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + context: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + deps: {} + matches: Array + }>() + }, + }) +}) + +test('when creating a child route with params, search with beforeLoad from the root route with context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + path: 'invoices/$invoiceId', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + beforeLoad: (opts) => { + expectTypeOf(opts).toMatchTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + search: { page: number } + matches: Array + }>() + }, + }) +}) + +test('when creating a child route with params, search with routeContext, beforeLoad and a loader from the root route with context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + path: 'invoices/$invoiceId', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + context: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + deps: {} + matches: Array + }>() + return { + env: 'env1', + } + }, + beforeLoad: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string; env: string } + search: { page: number } + matches: Array + }>() + return { permission: 'view' } as const + }, + loader: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + deps: {} + context: { userId: string; env: string; readonly permission: 'view' } + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: Promise> + cause: 'preload' | 'enter' | 'stay' + route: Route + }>() + }, + }) +}) + +test('when creating a child route with params from a parent with params', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + path: 'invoices/$invoiceId', + getParentRoute: () => rootRoute, + }) + + const detailsRoute = createRoute({ + path: '$detailId', + getParentRoute: () => invoicesRoute, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([ + invoicesRoute.addChildren([detailsRoute]), + ]), + }) + + expectTypeOf(detailsRoute.useParams()).toEqualTypeOf<{ + invoiceId: string + detailId: string + }>() + + expectTypeOf(detailsRoute.useParams) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + ((params: { invoiceId: string; detailId: string }) => number) | undefined + >() + + expectTypeOf(detailsRoute.useParams) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) + +test('when creating a child route with search from a parent with search', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + validateSearch: () => ({ invoicePage: 0 }), + }) + + const detailsRoute = createRoute({ + path: 'details', + getParentRoute: () => invoicesRoute, + validateSearch: () => ({ detailPage: 0 }), + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([ + invoicesRoute.addChildren([detailsRoute]), + ]), + }) + + expectTypeOf(detailsRoute.useSearch()).toEqualTypeOf<{ + invoicePage: number + detailPage: number + }>() + + expectTypeOf(detailsRoute.useSearch) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((params: { invoicePage: number; detailPage: number }) => number) + | undefined + >() + + expectTypeOf(detailsRoute.useSearch) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) + +test('when creating a child route with routeContext from a parent with routeContext', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + context: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + deps: {} + matches: Array + }>() + + return { invoiceId: 'invoiceId1' } + }, + }) + + const detailsRoute = createRoute({ + path: 'details', + getParentRoute: () => invoicesRoute, + context: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string; invoiceId: string } + deps: {} + matches: Array + }>() + + return { detailId: 'detailId1' } + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([ + invoicesRoute.addChildren([detailsRoute]), + ]), + context: { userId: '123' }, + }) + + expectTypeOf(detailsRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + invoiceId: string + detailId: string + }>() + + expectTypeOf(detailsRoute.useRouteContext) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((params: { + userId: string + invoiceId: string + detailId: string + }) => number) + | undefined + >() +}) + +test('when creating a child route with beforeLoad from a parent with beforeLoad', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + beforeLoad: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + search: {} + matches: Array + }>() + return { invoiceId: 'invoiceId1' } + }, + }) + + const detailsRoute = createRoute({ + path: 'details', + getParentRoute: () => invoicesRoute, + beforeLoad: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string; invoiceId: string } + search: {} + matches: Array + }>() + return { detailId: 'detailId1' } + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([ + invoicesRoute.addChildren([detailsRoute]), + ]), + context: { userId: '123' }, + }) + + expectTypeOf(detailsRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + invoiceId: string + detailId: string + }>() + + expectTypeOf(detailsRoute.useRouteContext) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((params: { + userId: string + invoiceId: string + detailId: string + }) => number) + | undefined + >() +}) + +test('when creating a child route with routeContext, beforeLoad, search, params, loaderDeps and loader', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + context: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + deps: {} + matches: Array + }>() + return { env: 'env1' } + }, + beforeLoad: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string; env: string } + search: { page: number } + matches: Array + }>() + return { invoicePermissions: ['view'] as const } + }, + }) + + const invoiceRoute = createRoute({ + path: '$invoiceId', + getParentRoute: () => invoicesRoute, + }) + + const detailsRoute = createRoute({ + path: 'details', + getParentRoute: () => invoiceRoute, + validateSearch: () => ({ detailPage: 0 }), + context: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { + userId: string + env: string + invoicePermissions: readonly ['view'] + } + deps: {} + matches: Array + }>() + return { detailEnv: 'detailEnv' } + }, + beforeLoad: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { + detailEnv: string + userId: string + env: string + invoicePermissions: readonly ['view'] + } + search: { page: number; detailPage: number } + matches: Array + }>() + return { detailsPermissions: ['view'] as const } + }, + }) + + const detailRoute = createRoute({ + path: '$detailId', + getParentRoute: () => detailsRoute, + loaderDeps: (deps) => ({ + detailPage: deps.search.detailPage, + invoicePage: deps.search.page, + }), + context: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string; detailId: string } + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { + userId: string + env: string + invoicePermissions: readonly ['view'] + detailEnv: string + detailsPermissions: readonly ['view'] + } + deps: { detailPage: number; invoicePage: number } + matches: Array + }>() + return { detailEnv: 'detailEnv' } + }, + loader: (opts) => + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string; detailId: string } + deps: { detailPage: number; invoicePage: number } + context: { + userId: string + env: string + invoicePermissions: readonly ['view'] + detailEnv: string + detailsPermissions: readonly ['view'] + } + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: Promise< + MakeRouteMatchFromRoute + > + cause: 'preload' | 'enter' | 'stay' + route: Route + }>(), + }) +}) + +test('when creating a child route with context, search, params and beforeLoad', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + beforeLoad: () => ({ invoicePermissions: ['view'] as const }), + }) + + const invoiceRoute = createRoute({ + path: '$invoiceId', + getParentRoute: () => invoicesRoute, + }) + + const detailsRoute = createRoute({ + path: 'details', + getParentRoute: () => invoiceRoute, + validateSearch: () => ({ detailPage: 0 }), + beforeLoad: () => ({ detailsPermissions: ['view'] as const }), + }) + + const detailRoute = createRoute({ + path: '$detailId', + getParentRoute: () => detailsRoute, + beforeLoad: (opts) => { + expectTypeOf(opts).toMatchTypeOf<{ + params: { detailId: string; invoiceId: string } + search: { detailPage: number; page: number } + context: { + userId: string + detailsPermissions: readonly ['view'] + invoicePermissions: readonly ['view'] + } + }>() + expectTypeOf(opts.buildLocation) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + | '.' + | './' + | './invoices' + | './invoices/$invoiceId' + | './invoices/$invoiceId/details' + | './invoices/$invoiceId/details/$detailId' + | undefined + >() + }, + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([ + invoiceRoute.addChildren([detailsRoute.addChildren([detailRoute])]), + ]), + ]) + + const router = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type Router = typeof router +}) + +test('when creating a child route with context, search, params, loader, loaderDeps and onEnter, onStay, onLeave', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + beforeLoad: () => ({ invoicePermissions: ['view'] as const }), + }) + + const invoiceRoute = createRoute({ + path: '$invoiceId', + getParentRoute: () => invoicesRoute, + }) + + const detailsRoute = createRoute({ + path: 'details', + getParentRoute: () => invoiceRoute, + validateSearch: () => ({ detailPage: 0 }), + beforeLoad: () => ({ detailsPermissions: ['view'] as const }), + }) + + type TExpectedParams = { detailId: string; invoiceId: string } + type TExpectedSearch = { detailPage: number; page: number } + type TExpectedContext = { + userId: string + detailsPermissions: readonly ['view'] + invoicePermissions: readonly ['view'] + detailPermission: boolean + } + type TExpectedLoaderData = { detailLoader: 'detailResult' } + type TExpectedMatch = { + params: TExpectedParams + search: TExpectedSearch + context: TExpectedContext + loaderDeps: { detailPage: number; invoicePage: number } + beforeLoadPromise?: ControlledPromise + loaderPromise?: ControlledPromise + componentsPromise?: Promise> + loaderData?: TExpectedLoaderData + } + + createRoute({ + path: '$detailId', + getParentRoute: () => detailsRoute, + beforeLoad: () => ({ detailPermission: true }), + loaderDeps: (deps) => ({ + detailPage: deps.search.detailPage, + invoicePage: deps.search.page, + }), + loader: () => ({ detailLoader: 'detailResult' }) as const, + onEnter: (match) => expectTypeOf(match).toMatchTypeOf(), + onStay: (match) => expectTypeOf(match).toMatchTypeOf(), + onLeave: (match) => expectTypeOf(match).toMatchTypeOf(), + }) +}) + +test('when creating a child route with parseParams and stringify params without params in path', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + parseParams: (params) => { + expectTypeOf(params).toEqualTypeOf<{}>() + return params + }, + stringifyParams: (params) => { + expectTypeOf(params).toEqualTypeOf<{}>() + return params + }, + beforeLoad: (ctx) => { + expectTypeOf(ctx).toHaveProperty('params').toEqualTypeOf<{}>() + }, + loader: (ctx) => { + expectTypeOf(ctx).toHaveProperty('params').toEqualTypeOf<{}>() + }, + }) + + const routeTree = rootRoute.addChildren([invoicesRoute]) + + const router = createRouter({ routeTree }) + + expectTypeOf(invoicesRoute.useParams()).toEqualTypeOf<{}>() +}) + +test('when creating a child route with params.parse and params.stringify without params in path', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + params: { + parse: (params) => { + expectTypeOf(params).toEqualTypeOf<{}>() + return params + }, + stringify: (params) => { + expectTypeOf(params).toEqualTypeOf<{}>() + return params + }, + }, + beforeLoad: (ctx) => { + expectTypeOf(ctx).toHaveProperty('params').toEqualTypeOf<{}>() + }, + loader: (ctx) => { + expectTypeOf(ctx).toHaveProperty('params').toEqualTypeOf<{}>() + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + }) + + expectTypeOf(invoicesRoute.useParams()).toEqualTypeOf<{}>() +}) + +test('when creating a child route with parseParams and stringifyParams with params in path', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + parseParams: (params) => { + expectTypeOf(params).toEqualTypeOf<{ invoiceId: string }>() + return { invoiceId: Number(params.invoiceId) } + }, + stringifyParams: (params) => { + expectTypeOf(params).toEqualTypeOf<{ invoiceId: number }>() + return { invoiceId: params.invoiceId.toString() } + }, + beforeLoad: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number }>() + }, + loader: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number }>() + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([invoiceRoute]), + }) + + expectTypeOf(invoiceRoute.useParams()).toEqualTypeOf<{ + invoiceId: number + }>() +}) + +test('when creating a child route with params.parse and params.stringify with params in path', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + params: { + parse: (params) => { + expectTypeOf(params).toEqualTypeOf<{ invoiceId: string }>() + return { invoiceId: Number(params.invoiceId) } + }, + stringify: (params) => { + expectTypeOf(params).toEqualTypeOf<{ invoiceId: number }>() + return { invoiceId: params.invoiceId.toString() } + }, + }, + beforeLoad: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number }>() + }, + loader: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number }>() + }, + }) + + const router = createRouter({ + routeTree: invoicesRoute.addChildren([invoiceRoute]), + }) + + expectTypeOf(invoiceRoute.useParams()).toEqualTypeOf<{ + invoiceId: number + }>() +}) + +test('when creating a child route with parseParams and stringifyParams with merged params from parent', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + parseParams: (params) => { + expectTypeOf(params).toEqualTypeOf<{ invoiceId: string }>() + return { invoiceId: Number(params.invoiceId) } + }, + stringifyParams: (params) => { + expectTypeOf(params).toEqualTypeOf<{ invoiceId: number }>() + return { invoiceId: params.invoiceId.toString() } + }, + beforeLoad: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number }>() + }, + loader: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number }>() + }, + }) + + const detailRoute = createRoute({ + getParentRoute: () => invoiceRoute, + path: '$detailId', + + parseParams: (params) => { + expectTypeOf(params).toEqualTypeOf<{ + detailId: string + }>() + return { detailId: Number(params.detailId) } + }, + stringifyParams: (params) => { + expectTypeOf(params).toEqualTypeOf<{ detailId: number }>() + return { detailId: params.detailId.toString() } + }, + beforeLoad: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number; detailId: number }>() + }, + loader: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number; detailId: number }>() + }, + }) + + const router = createRouter({ + routeTree: invoicesRoute.addChildren([ + invoiceRoute.addChildren([detailRoute]), + ]), + }) + + expectTypeOf(detailRoute.useParams()).toEqualTypeOf<{ + detailId: number + invoiceId: number + }>() +}) + +test('when creating a child route with params.parse and params.stringify with merged params from parent', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + params: { + parse: (params) => { + expectTypeOf(params).toEqualTypeOf<{ invoiceId: string }>() + return { invoiceId: Number(params.invoiceId) } + }, + stringify: (params) => { + expectTypeOf(params).toEqualTypeOf<{ invoiceId: number }>() + return { invoiceId: params.invoiceId.toString() } + }, + }, + beforeLoad: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number }>() + }, + loader: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number }>() + }, + }) + + const detailRoute = createRoute({ + getParentRoute: () => invoiceRoute, + path: '$detailId', + params: { + parse: (params) => { + expectTypeOf(params).toEqualTypeOf<{ + detailId: string + }>() + return { detailId: Number(params.detailId) } + }, + stringify: (params) => { + expectTypeOf(params).toEqualTypeOf<{ detailId: number }>() + return { detailId: params.detailId.toString() } + }, + }, + beforeLoad: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number; detailId: number }>() + }, + loader: (ctx) => { + expectTypeOf(ctx) + .toHaveProperty('params') + .toEqualTypeOf<{ invoiceId: number; detailId: number }>() + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute.addChildren([detailRoute])]), + ]), + }) + + expectTypeOf(detailRoute.useParams()).toEqualTypeOf<{ + detailId: number + invoiceId: number + }>() +}) + +test('when routeContext throws', () => { + const rootRoute = createRootRoute() + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => { + throw redirect({ to: '/somewhere' }) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + }) + + expectTypeOf( + invoicesRoute.useRouteContext(), + ).toEqualTypeOf<{}>() +}) + +test('when beforeLoad throws', () => { + const rootRoute = createRootRoute() + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + beforeLoad: () => { + throw redirect({ to: '/somewhere' }) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + }) + + expectTypeOf( + invoicesRoute.useRouteContext(), + ).toEqualTypeOf<{}>() +}) + +test('when creating a child route with no explicit search input', () => { + const rootRoute = createRootRoute({ + validateSearch: (input) => { + expectTypeOf(input).toEqualTypeOf>() + return { + page: 0, + } + }, + }) + + const rootRouteWithContext = createRootRouteWithContext()({ + validateSearch: (input) => { + expectTypeOf(input).toEqualTypeOf>() + return { + page: 0, + } + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (input) => { + expectTypeOf(input).toEqualTypeOf>() + return { + page: 0, + } + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createRouter({ routeTree }) + + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: number + }>() + + expectTypeOf(indexRoute.useSearch()).toEqualTypeOf<{ + page: number + }>() + + expectTypeOf(rootRouteWithContext.useSearch()).toEqualTypeOf<{ + page: number + }>() + + const navigate = indexRoute.useNavigate() + + expectTypeOf(navigate) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ page: number }>() + + expectTypeOf(navigate) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ page: number }>() + + expectTypeOf(navigate) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ page: number }>() +}) + +test('when creating a child route with an explicit search input', () => { + const rootRoute = createRootRoute({ + validateSearch: (input: SearchSchemaInput & { input: string }) => { + return { + page: input.input, + } + }, + }) + + const rootRouteWithContext = createRootRouteWithContext()({ + validateSearch: (input: SearchSchemaInput & { input: string }) => { + return { + page: input.input, + } + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (input: SearchSchemaInput & { input: string }) => { + return { + page: input.input, + } + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + + const router = createRouter({ routeTree }) + + expectTypeOf(rootRoute.useSearch()).toEqualTypeOf<{ + page: string + }>() + + expectTypeOf(indexRoute.useSearch()).toEqualTypeOf<{ + page: string + }>() + + expectTypeOf(rootRouteWithContext.useSearch()).toEqualTypeOf<{ + page: string + }>() + + const navigate = indexRoute.useNavigate() + + expectTypeOf(navigate) + .parameter(0) + .toHaveProperty('search') + .exclude() + .toEqualTypeOf<{ input: string }>() + + expectTypeOf(navigate) + .parameter(0) + .toHaveProperty('search') + .returns.toEqualTypeOf<{ input: string }>() + + expectTypeOf(navigate) + .parameter(0) + .toHaveProperty('search') + .parameter(0) + .toEqualTypeOf<{ page: string }>() +}) diff --git a/packages/solid-router/tests/route.test.tsx b/packages/solid-router/tests/route.test.tsx new file mode 100644 index 00000000000..4684aaed44d --- /dev/null +++ b/packages/solid-router/tests/route.test.tsx @@ -0,0 +1,387 @@ +import { afterEach, describe, expect, it, test, vi } from 'vitest' +import { cleanup, render, screen } from '@solidjs/testing-library' + +import { + RouterProvider, + createRootRoute, + createRoute, + createRouter, + getRouteApi, +} from '../src' + +afterEach(() => { + vi.resetAllMocks() + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +describe('getRouteApi', () => { + it('should have the useMatch hook', () => { + const api = getRouteApi('foo') + expect(api.useMatch).toBeDefined() + }) + + it('should have the useRouteContext hook', () => { + const api = getRouteApi('foo') + expect(api.useRouteContext).toBeDefined() + }) + + it('should have the useSearch hook', () => { + const api = getRouteApi('foo') + expect(api.useSearch).toBeDefined() + }) + + it('should have the useParams hook', () => { + const api = getRouteApi('foo') + expect(api.useParams).toBeDefined() + }) + + it('should have the useLoaderData hook', () => { + const api = getRouteApi('foo') + expect(api.useLoaderData).toBeDefined() + }) + + it('should have the useLoaderDeps hook', () => { + const api = getRouteApi('foo') + expect(api.useLoaderDeps).toBeDefined() + }) + + it('should have the useNavigate hook', () => { + const api = getRouteApi('foo') + expect(api.useNavigate).toBeDefined() + }) +}) + +describe('createRoute has the same hooks as getRouteApi', () => { + const routeApi = getRouteApi('foo') + const hookNames = Object.keys(routeApi).filter((key) => key.startsWith('use')) + const route = createRoute({} as any) + + it.each(hookNames.map((name) => [name]))( + 'should have the "%s" hook defined', + (hookName) => { + expect(route[hookName as keyof typeof route]).toBeDefined() + }, + ) +}) + +/* disabled until HMR bug is fixed +describe('throws invariant exception when trying to access properties before `createRouter` completed', () => { + function setup() { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + <> +

Posts

+ + ) + }, + }) + const initRouter = () => + createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + return { initRouter, rootRoute, indexRoute, postsRoute } + } + + it('to', () => { + const { initRouter, indexRoute, postsRoute } = setup() + + const expectedError = `Invariant failed: trying to access property 'to' on a route which is not initialized yet. Route properties are only available after 'createRouter' completed.` + expect(() => indexRoute.to).toThrowError(expectedError) + expect(() => postsRoute.to).toThrowError(expectedError) + + initRouter() + + expect(indexRoute.to).toBe('/') + expect(postsRoute.to).toBe('/posts') + }) + + it('fullPath', () => { + const { initRouter, indexRoute, postsRoute } = setup() + + const expectedError = `trying to access property 'fullPath' on a route which is not initialized yet. Route properties are only available after 'createRouter' completed.` + expect(() => indexRoute.fullPath).toThrowError(expectedError) + expect(() => postsRoute.fullPath).toThrowError(expectedError) + + initRouter() + + expect(indexRoute.fullPath).toBe('/') + expect(postsRoute.fullPath).toBe('/posts') + }) + + it('id', () => { + const { initRouter, indexRoute, postsRoute } = setup() + + const expectedError = `Invariant failed: trying to access property 'id' on a route which is not initialized yet. Route properties are only available after 'createRouter' completed.` + expect(() => indexRoute.id).toThrowError(expectedError) + expect(() => postsRoute.id).toThrowError(expectedError) + + initRouter() + + expect(indexRoute.to).toBe('/') + expect(postsRoute.to).toBe('/posts') + }) +}) +*/ + +describe('onEnter event', () => { + it('should have router context defined in router.load()', async () => { + const fn = vi.fn() + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return

Index

+ }, + onEnter: ({ context }) => { + fn(context) + }, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, context: { foo: 'bar' } }) + + await router.load() + + expect(fn).toHaveBeenCalledWith({ foo: 'bar' }) + }) + + it('should have router context defined in ', async () => { + const fn = vi.fn() + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return

Index

+ }, + onEnter: ({ context }) => { + fn(context) + }, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, context: { foo: 'bar' } }) + + render(() => ) + + const indexElem = await screen.findByText('Index') + expect(indexElem).toBeInTheDocument() + + expect(fn).toHaveBeenCalledWith({ foo: 'bar' }) + }) +}) + +describe('route.head', () => { + test('meta', async () => { + const rootRoute = createRootRoute({ + head: () => ({ + meta: [ + { title: 'Root' }, + { + charSet: 'utf-8', + }, + ], + }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + head: () => ({ + meta: [{ title: 'Index' }], + }), + component: () =>
Index
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + render(() => ) + const indexElem = await screen.findByText('Index') + expect(indexElem).toBeInTheDocument() + + const metaState = router.state.matches.map((m) => m.meta) + expect(metaState).toEqual([ + [ + { title: 'Root' }, + { + charSet: 'utf-8', + }, + ], + [{ title: 'Index' }], + ]) + }) + + test('meta w/ loader', async () => { + const rootRoute = createRootRoute({ + head: () => ({ + meta: [ + { title: 'Root' }, + { + charSet: 'utf-8', + }, + ], + }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + head: () => ({ + meta: [{ title: 'Index' }], + }), + loader: async () => { + await new Promise((resolve) => setTimeout(resolve, 200)) + }, + component: () =>
Index
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + render(() => ) + const indexElem = await screen.findByText('Index') + expect(indexElem).toBeInTheDocument() + + const metaState = router.state.matches.map((m) => m.meta) + expect(metaState).toEqual([ + [ + { title: 'Root' }, + { + charSet: 'utf-8', + }, + ], + [{ title: 'Index' }], + ]) + }) + + test('scripts', async () => { + const rootRoute = createRootRoute({ + head: () => ({ + scripts: [{ src: 'root.js' }, { src: 'root2.js' }], + }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + head: () => ({ + scripts: [{ src: 'index.js' }], + }), + component: () =>
Index
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + render(() => ) + const indexElem = await screen.findByText('Index') + expect(indexElem).toBeInTheDocument() + + const scriptsState = router.state.matches.map((m) => m.scripts) + expect(scriptsState).toEqual([ + [{ src: 'root.js' }, { src: 'root2.js' }], + [{ src: 'index.js' }], + ]) + }) + + test('scripts w/ loader', async () => { + const rootRoute = createRootRoute({ + head: () => ({ + scripts: [{ src: 'root.js' }, { src: 'root2.js' }], + }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + head: () => ({ + scripts: [{ src: 'index.js' }], + }), + loader: async () => { + await new Promise((resolve) => setTimeout(resolve, 200)) + }, + component: () =>
Index
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + render(() => ) + const indexElem = await screen.findByText('Index') + expect(indexElem).toBeInTheDocument() + + const scriptsState = router.state.matches.map((m) => m.scripts) + expect(scriptsState).toEqual([ + [{ src: 'root.js' }, { src: 'root2.js' }], + [{ src: 'index.js' }], + ]) + }) + + test('links', async () => { + const rootRoute = createRootRoute({ + head: () => ({ + links: [{ href: 'root.css' }, { href: 'root2.css' }], + }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + head: () => ({ + links: [{ href: 'index.css' }], + }), + component: () =>
Index
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + render(() => ) + const indexElem = await screen.findByText('Index') + expect(indexElem).toBeInTheDocument() + + const linksState = router.state.matches.map((m) => m.links) + expect(linksState).toEqual([ + [{ href: 'root.css' }, { href: 'root2.css' }], + [{ href: 'index.css' }], + ]) + }) + + test('links w/loader', async () => { + const rootRoute = createRootRoute({ + head: () => ({ + links: [{ href: 'root.css' }, { href: 'root2.css' }], + }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + head: () => ({ + links: [{ href: 'index.css' }], + }), + loader: async () => { + await new Promise((resolve) => setTimeout(resolve, 200)) + }, + component: () =>
Index
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + render(() => ) + const indexElem = await screen.findByText('Index') + expect(indexElem).toBeInTheDocument() + + const linksState = router.state.matches.map((m) => m.links) + expect(linksState).toEqual([ + [{ href: 'root.css' }, { href: 'root2.css' }], + [{ href: 'index.css' }], + ]) + }) +}) diff --git a/packages/solid-router/tests/routeApi.test-d.tsx b/packages/solid-router/tests/routeApi.test-d.tsx new file mode 100644 index 00000000000..972be17b5e9 --- /dev/null +++ b/packages/solid-router/tests/routeApi.test-d.tsx @@ -0,0 +1,101 @@ +import { describe, expectTypeOf, test } from 'vitest' +import { createRootRoute, createRoute, createRouter, getRouteApi } from '../src' +import type { MakeRouteMatch, UseNavigateResult } from '../src' + +const rootRoute = createRootRoute() + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), + beforeLoad: () => ({ beforeLoadContext: 0 }), + loaderDeps: () => ({ dep: 0 }), + loader: () => ({ data: 0 }), +}) + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +type ExtractDefaultFrom = + T extends UseNavigateResult ? DefaultFrom : never + +describe('getRouteApi', () => { + const invoiceRouteApi = getRouteApi<'/invoices/$invoiceId', DefaultRouter>( + '/invoices/$invoiceId', + ) + describe('useNavigate', () => { + test('has a static `from`', () => { + const navigate = invoiceRouteApi.useNavigate() + navigate + expectTypeOf< + ExtractDefaultFrom + >().toEqualTypeOf<'/invoices/$invoiceId'>() + }) + }) + test('useParams', () => { + expectTypeOf(invoiceRouteApi.useParams()).toEqualTypeOf<{ + invoiceId: string + }>() + }) + test('useContext', () => { + expectTypeOf( + invoiceRouteApi.useRouteContext(), + ).toEqualTypeOf<{ + beforeLoadContext: number + }>() + }) + test('useSearch', () => { + expectTypeOf(invoiceRouteApi.useSearch()).toEqualTypeOf<{ + page: number + }>() + }) + test('useLoaderData', () => { + expectTypeOf(invoiceRouteApi.useLoaderData()).toEqualTypeOf<{ + data: number + }>() + }) + test('useLoaderDeps', () => { + expectTypeOf(invoiceRouteApi.useLoaderDeps()).toEqualTypeOf<{ + dep: number + }>() + }) + test('useMatch', () => { + expectTypeOf(invoiceRouteApi.useMatch()).toEqualTypeOf< + MakeRouteMatch + >() + }) +}) + +describe('createRoute', () => { + describe('useNavigate', () => { + test('has a static `from`', () => { + const navigate = invoiceRoute.useNavigate() + expectTypeOf< + ExtractDefaultFrom + >().toEqualTypeOf<'/invoices/$invoiceId'>() + }) + }) +}) diff --git a/packages/solid-router/tests/routeContext.test.tsx b/packages/solid-router/tests/routeContext.test.tsx new file mode 100644 index 00000000000..adeec0a147e --- /dev/null +++ b/packages/solid-router/tests/routeContext.test.tsx @@ -0,0 +1,3061 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { z } from 'zod' + +import { + Link, + Outlet, + RouterProvider, + createBrowserHistory, + createRootRoute, + createRoute, + createRouter, + redirect, +} from '../src' + +import { sleep } from './utils' +import type { RouterHistory } from '../src' + +let history: RouterHistory + +beforeEach(() => { + history = createBrowserHistory() + expect(window.location.pathname).toBe('/') +}) +afterEach(() => { + history.destroy() + window.history.replaceState(null, 'root', '/') + vi.clearAllMocks() + vi.resetAllMocks() + cleanup() +}) + +const WAIT_TIME = 150 + +describe('context function', () => { + describe('context is executed', () => { + async function findByText(text: string) { + const element = await screen.findByText(text) + expect(element).toBeInTheDocument() + } + + async function clickButton(name: string) { + const button = await screen.findByRole('button', { + name, + }) + expect(button).toBeInTheDocument() + fireEvent.click(button) + } + + test('when the path params change', async () => { + const mockContextFn = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ + +
+ ) + }, + }) + const detailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/detail/$id', + params: { + parse: (p) => z.object({ id: z.coerce.number() }).parse(p), + stringify: (p) => ({ id: `${p.id}` }), + }, + context: (args) => { + mockContextFn(args.params) + }, + component: () => { + const id = detailRoute.useParams({ select: (params) => params.id }) + const navigate = detailRoute.useNavigate() + return ( +
+

Detail page: {id()}

+ +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, detailRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await findByText('Index page') + expect(mockContextFn).not.toHaveBeenCalled() + + await clickButton('detail-1') + + await findByText('Detail page: 1') + console.log(mockContextFn.mock.calls) + expect(mockContextFn).toHaveBeenCalledOnce() + expect(mockContextFn).toHaveBeenCalledWith({ id: 1 }) + mockContextFn.mockClear() + await clickButton('next') + + await findByText('Detail page: 2') + expect(mockContextFn).toHaveBeenCalledOnce() + expect(mockContextFn).toHaveBeenCalledWith({ id: 2 }) + mockContextFn.mockClear() + await clickButton('next') + + await findByText('Detail page: 3') + expect(mockContextFn).toHaveBeenCalledOnce() + expect(mockContextFn).toHaveBeenCalledWith({ id: 3 }) + mockContextFn.mockClear() + }) + + test('when loader deps change', async () => { + const mockContextFn = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + validateSearch: z.object({ + foo: z.string().optional(), + bar: z.string().optional(), + }), + path: '/', + loaderDeps: ({ search }) => ({ foo: search.foo }), + context: ({ deps }) => { + mockContextFn(deps) + }, + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+

search: {JSON.stringify(indexRoute.useSearch()())}

+ + + + + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await findByText('Index page') + await findByText(`search: ${JSON.stringify({})}`) + + expect(mockContextFn).toHaveBeenCalledOnce() + expect(mockContextFn).toHaveBeenCalledWith({}) + mockContextFn.mockClear() + + await clickButton('foo-1') + await findByText(`search: ${JSON.stringify({ foo: 'foo-1' })}`) + expect(mockContextFn).toHaveBeenCalledOnce() + expect(mockContextFn).toHaveBeenCalledWith({ foo: 'foo-1' }) + + mockContextFn.mockClear() + await clickButton('foo-1') + await findByText(`search: ${JSON.stringify({ foo: 'foo-1' })}`) + expect(mockContextFn).not.toHaveBeenCalled() + + await clickButton('bar-1') + await findByText( + `search: ${JSON.stringify({ foo: 'foo-1', bar: 'bar-1' })}`, + ) + expect(mockContextFn).not.toHaveBeenCalled() + + await clickButton('foo-2') + await findByText( + `search: ${JSON.stringify({ foo: 'foo-2', bar: 'bar-1' })}`, + ) + expect(mockContextFn).toHaveBeenCalledWith({ foo: 'foo-2' }) + mockContextFn.mockClear() + + await clickButton('bar-2') + await findByText( + `search: ${JSON.stringify({ foo: 'foo-2', bar: 'bar-2' })}`, + ) + expect(mockContextFn).not.toHaveBeenCalled() + + await clickButton('clear') + await findByText(`search: ${JSON.stringify({})}`) + expect(mockContextFn).toHaveBeenCalledOnce() + expect(mockContextFn).toHaveBeenCalledWith({}) + }) + }) + + describe('accessing values in the context function', () => { + test('receives an empty object', async () => { + const mockIndexContextFn = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: ({ context }) => { + mockIndexContextFn(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const rootElement = await screen.findByText('Index page') + expect(rootElement).toBeInTheDocument() + + expect(mockIndexContextFn).toHaveBeenCalledWith({}) + }) + + test('receives an empty object - with an empty object when creating the router', async () => { + const mockIndexContextFn = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: ({ context }) => { + mockIndexContextFn(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history, context: {} }) + + render(() => ) + + const rootElement = await screen.findByText('Index page') + expect(rootElement).toBeInTheDocument() + + expect(mockIndexContextFn).toHaveBeenCalledWith({}) + }) + + test('receives valid values - with values added when creating the router', async () => { + const mockIndexContextFn = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: ({ context }) => { + mockIndexContextFn(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ + routeTree, + history, + context: { project: 'Router' }, + }) + + render(() => ) + + const rootElement = await screen.findByText('Index page') + expect(rootElement).toBeInTheDocument() + + expect(mockIndexContextFn).toHaveBeenCalledWith({ project: 'Router' }) + }) + + test('receives valid values when updating the values in the parent route to be read in the child route', async () => { + const mockIndexContextFn = vi.fn() + + const rootRoute = createRootRoute({ + context: () => ({ + project: 'Router', + }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: ({ context }) => { + mockIndexContextFn(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const rootElement = await screen.findByText('Index page') + expect(rootElement).toBeInTheDocument() + + expect(mockIndexContextFn).toHaveBeenCalledWith({ project: 'Router' }) + }) + }) + + describe('return values being available in beforeLoad', () => { + test('when returning an empty object in a regular route', async () => { + const mockIndexBeforeLoad = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => ({}), + beforeLoad: ({ context }) => { + mockIndexBeforeLoad(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ + routeTree, + history, + context: { project: 'foo' }, + }) + + render(() => ) + + const rootElement = await screen.findByText('Index page') + expect(rootElement).toBeInTheDocument() + + expect(mockIndexBeforeLoad).toHaveBeenCalledWith({ project: 'foo' }) + expect(mockIndexBeforeLoad).toHaveBeenCalledTimes(1) + }) + + test('when updating the initial router context value in a regular route', async () => { + const mockIndexBeforeLoad = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: ({ context }) => ({ ...context, project: 'Query' }), + beforeLoad: ({ context }) => { + mockIndexBeforeLoad(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ + routeTree, + history, + context: { project: 'foo' }, + }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(mockIndexBeforeLoad).toHaveBeenCalledWith({ project: 'Query' }) + expect(mockIndexBeforeLoad).toHaveBeenCalledTimes(1) + }) + + test('when returning an empty object in the root route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute({ + context: () => ({}), + beforeLoad: ({ context }) => { + mock(context) + }, + component: () =>
Root page
, + }) + const routeTree = rootRoute.addChildren([]) + const router = createRouter({ + routeTree, + history, + context: { project: 'foo' }, + }) + + render(() => ) + + const rootElement = await screen.findByText('Root page') + expect(rootElement).toBeInTheDocument() + + expect(mock).toHaveBeenCalledWith({ project: 'foo' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('when updating the initial router context value in the parent route and in the child route', async () => { + const mockRootBeforeLoad = vi.fn() + const mockIndexBeforeLoad = vi.fn() + + const rootRoute = createRootRoute({ + context: ({ context }) => ({ ...context, project: 'Router' }), + beforeLoad: ({ context }) => { + mockRootBeforeLoad(context) + }, + component: () => ( +
+ Root page +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: ({ context }) => ({ ...context, project: 'Query' }), + beforeLoad: ({ context }) => { + mockIndexBeforeLoad(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ + routeTree, + history, + context: { project: 'bar' }, + }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(mockRootBeforeLoad).toHaveBeenCalledWith({ project: 'Router' }) + expect(mockRootBeforeLoad).toHaveBeenCalledTimes(1) + + expect(mockIndexBeforeLoad).toHaveBeenCalledWith({ project: 'Query' }) + expect(mockIndexBeforeLoad).toHaveBeenCalledTimes(1) + }) + }) + + describe('return values being available in loader', () => { + test('when returning an empty object in a regular route', async () => { + const mockIndexLoader = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => ({}), + loader: ({ context }) => { + mockIndexLoader(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ + routeTree, + history, + context: { project: 'foo' }, + }) + + render(() => ) + + const rootElement = await screen.findByText('Index page') + expect(rootElement).toBeInTheDocument() + + expect(mockIndexLoader).toHaveBeenCalledWith({ project: 'foo' }) + expect(mockIndexLoader).toHaveBeenCalledTimes(1) + }) + + test('when updating the initial router context value in a regular route', async () => { + const mockIndexLoader = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: ({ context }) => ({ ...context, project: 'Query' }), + loader: ({ context }) => { + mockIndexLoader(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ + routeTree, + history, + context: { project: 'foo' }, + }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(mockIndexLoader).toHaveBeenCalledWith({ project: 'Query' }) + expect(mockIndexLoader).toHaveBeenCalledTimes(1) + }) + + test('when returning an empty object in the root route', async () => { + const mockRootLoader = vi.fn() + + const rootRoute = createRootRoute({ + context: () => ({}), + loader: ({ context }) => { + mockRootLoader(context) + }, + component: () =>
Root page
, + }) + const routeTree = rootRoute.addChildren([]) + const router = createRouter({ + routeTree, + history, + context: { project: 'foo' }, + }) + + render(() => ) + + const rootElement = await screen.findByText('Root page') + expect(rootElement).toBeInTheDocument() + + expect(mockRootLoader).toHaveBeenCalledWith({ project: 'foo' }) + expect(mockRootLoader).toHaveBeenCalledTimes(1) + }) + + test('when updating the initial router context value in the root route and in the child route', async () => { + const mockRootLoader = vi.fn() + const mockIndexLoader = vi.fn() + + const rootRoute = createRootRoute({ + context: ({ context }) => ({ ...context, project: 'Router' }), + loader: ({ context }) => { + mockRootLoader(context) + }, + component: () => ( +
+ Root page +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: ({ context }) => ({ ...context, project: 'Query' }), + loader: ({ context }) => { + mockIndexLoader(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ + routeTree, + history, + context: { project: 'bar' }, + }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(mockRootLoader).toHaveBeenCalledWith({ project: 'Router' }) + expect(mockRootLoader).toHaveBeenCalledTimes(1) + + expect(mockIndexLoader).toHaveBeenCalledWith({ project: 'Query' }) + expect(mockIndexLoader).toHaveBeenCalledTimes(1) + }) + }) +}) + +describe('beforeLoad in the route definition', () => { + // Present at the root route + test('route context, present in the root route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute({ + beforeLoad: ({ context }) => { + mock(context) + }, + component: () =>
Root page
, + }) + const routeTree = rootRoute.addChildren([]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const rootElement = await screen.findByText('Root page') + expect(rootElement).toBeInTheDocument() + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('route context (sleep), present in the root route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute({ + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Root page
, + }) + const routeTree = rootRoute.addChildren([]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const rootElement = await screen.findByText('Root page') + expect(rootElement).toBeInTheDocument() + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Present at the index route + test('route context, present in the index route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: ({ context }) => { + mock(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('route context (sleep), present in the index route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context that is updated at the root, is the same in the index route + test('modified route context, present in the index route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute({ + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { + ...context, + foo: 'sean', + } + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(mock).toHaveBeenCalledWith({ foo: 'sean' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context the context is available after a redirect on first-load + test('on first-load, route context is present in the /about route after a redirect is thrown in the beforeLoad of the index route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/about' }) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
About page
, + }) + const routeTree = rootRoute.addChildren([aboutRoute, indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const aboutElement = await screen.findByText('About page') + expect(aboutElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/about') + expect(router.state.location.pathname).toBe('/about') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('on first-load, route context is present in the /about route after a redirect is thrown in the loader of the index route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/about' }) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
About page
, + }) + const routeTree = rootRoute.addChildren([aboutRoute, indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const aboutElement = await screen.findByText('About page') + expect(aboutElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/about') + expect(router.state.location.pathname).toBe('/about') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context the context is available after a redirect on navigate + test('on navigate, route context is present in the /person route after a redirect is thrown in the beforeLoad of the /about route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const personRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/person', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + personRoute, + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/person') + expect(router.state.location.pathname).toBe('/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('on navigate, route context is present in the /person route after a redirect is thrown in the loader of the /about route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const personRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/person', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + personRoute, + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/person') + expect(router.state.location.pathname).toBe('/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context returned by /nested/about, is the same as its parent route /nested on navigate + test('nested destination on navigate, route context in the /nested/about route is correctly inherited from the /nested parent', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'nested' } + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/about', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
About page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([aboutRoute]), + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const aboutElement = await screen.findByText('About page') + expect(aboutElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/about') + expect(window.location.pathname).toBe('/nested/about') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'nested' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context returned by /nested/person route, is the same as its parent route /nested on navigate after a redirect /about + test('nested destination on navigate, when a redirect is thrown in beforeLoad in /about, the /nested/person route context is correctly inherited from its parent /nested', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/person' }) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'nested' } + }, + }) + const personRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/person', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/person') + expect(window.location.pathname).toBe('/nested/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'nested' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('nested destination on navigate, when a redirect is thrown in loader in /about, the /nested/person route context is correctly inherited from parent /nested', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/person' }) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'nested' } + }, + }) + const personRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/person', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/person') + expect(window.location.pathname).toBe('/nested/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'nested' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context returned by a layout route, is the same its child route (index route) on first-load + test('_layout on first-load, route context in the index route is correctly inherited from the layout parent', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([indexRoute]), + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/') + expect(window.location.pathname).toBe('/') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'layout' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context returned by a layout route, is the same its child route (about route) on navigate + test('_layout on navigate, route context in the /about route is correctly inherited from the layout parent', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/about', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
About page
, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([aboutRoute]), + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const aboutElement = await screen.findByText('About page') + expect(aboutElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/about') + expect(window.location.pathname).toBe('/about') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'layout' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context returned by a layout route, is the same its child route (about route) on after a redirect on navigate + test('_layout on navigate, when a redirect is thrown in beforeLoad in /about, the /person route context is correctly inherited from the layout parent', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + }) + const personRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/person', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/person') + expect(window.location.pathname).toBe('/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'layout' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('_layout on navigate, when a redirect is thrown in loader in /about, the /person route context is correctly inherited from the layout parent', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + }) + const personRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/person', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/person') + expect(window.location.pathname).toBe('/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'layout' }) + expect(mock).toHaveBeenCalledTimes(1) + }) +}) + +describe('loader in the route definition', () => { + // Present at the root route + test('route context, present in the root route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute({ + loader: ({ context }) => { + mock(context) + }, + component: () =>
Root page
, + }) + const routeTree = rootRoute.addChildren([]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const rootElement = await screen.findByText('Root page') + expect(rootElement).toBeInTheDocument() + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('route context (sleep), present in the root route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute({ + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Root page
, + }) + const routeTree = rootRoute.addChildren([]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const rootElement = await screen.findByText('Root page') + expect(rootElement).toBeInTheDocument() + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Present at the index route + test('route context, present in the index route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: ({ context }) => { + mock(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('route context (sleep), present in the index route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context that is updated at the root, is the same in the index route + test('modified route context, present in the index route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute({ + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { + ...context, + foo: 'sean', + } + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(mock).toHaveBeenCalledWith({ foo: 'sean' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // disabled test due to flakiness + test.skip("on navigate (with preload using router methods), loader isn't invoked with undefined context if beforeLoad is pending when navigation happens", async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + return { mock } + }, + loader: async ({ context }) => { + await sleep(WAIT_TIME) + expect(context.mock).toBe(mock) + context.mock() + }, + }) + + const routeTree = rootRoute.addChildren([aboutRoute, indexRoute]) + const router = createRouter({ + routeTree, + history, + context: { foo: 'bar' }, + }) + + await router.load() + + // Don't await, simulate user clicking before preload is done + router.preloadRoute(aboutRoute) + + await router.navigate(aboutRoute) + await router.invalidate() + + // Expect only a single call as the one from preload and the one from navigate are deduped + expect(mock).toHaveBeenCalledOnce() + }) + + test("on navigate (with preload), loader isn't invoked with undefined context if beforeLoad is pending when navigation happens", async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ + link to about + +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + return { mock } + }, + loader: async ({ context }) => { + await sleep(WAIT_TIME) + expect(context.mock).toBe(mock) + context.mock() + }, + component: () =>
About page
, + }) + + const routeTree = rootRoute.addChildren([aboutRoute, indexRoute]) + const router = createRouter({ + routeTree, + history, + defaultPreload: 'intent', + context: { foo: 'bar' }, + }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + expect(linkToAbout).toBeInTheDocument() + + // Don't await, simulate user clicking before preload is done + fireEvent.focus(linkToAbout) + fireEvent.click(linkToAbout) + + const aboutElement = await screen.findByText('About page') + expect(aboutElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/about') + + // Expect only a single call as the one from preload and the one from navigate are deduped + expect(mock).toHaveBeenCalledOnce() + }) + + // Check if context the context is available after a redirect on first-load + test('on first-load, route context is present in the /about route after a redirect is thrown in beforeLoad of the index route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/about' }) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
About page
, + }) + const routeTree = rootRoute.addChildren([aboutRoute, indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const aboutElement = await screen.findByText('About page') + expect(aboutElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/about') + expect(router.state.location.pathname).toBe('/about') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('on first-load, route context is present in the /about route after a redirect is thrown in loader of the index route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/about' }) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
About page
, + }) + const routeTree = rootRoute.addChildren([aboutRoute, indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const aboutElement = await screen.findByText('About page') + expect(aboutElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/about') + expect(router.state.location.pathname).toBe('/about') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context the context is available after a redirect on navigate + test('on navigate, route context is present in the /person route after a redirect is thrown in the beforeLoad of the /about route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const personRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/person', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + personRoute, + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/person') + expect(router.state.location.pathname).toBe('/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('on navigate, route context is present in the /person route after a redirect is thrown in the loader of the /about route', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const personRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/person', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + personRoute, + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/person') + expect(router.state.location.pathname).toBe('/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context returned by a /nested/about, is the same as its parent route /nested on navigate + test('nested destination on navigate, route context in the /nested/about route is correctly inherited from the /nested parent', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'nested' } + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/about', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
About page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([aboutRoute]), + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const aboutElement = await screen.findByText('About page') + expect(aboutElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/about') + expect(window.location.pathname).toBe('/nested/about') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'nested' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context returned by a /nested/person route, is the same as its parent route /nested on navigate after a redirect /about + test('nested destination on navigate, when a redirect is thrown in beforeLoad in /about, the /nested/person route context is correctly inherited from its parent /nested', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/person' }) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'nested' } + }, + }) + const personRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/person', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/person') + expect(window.location.pathname).toBe('/nested/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'nested' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('nested destination on navigate, when a redirect is thrown in loader in /about, the /nested/person route context is correctly inherited from parent /nested', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/person' }) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'nested' } + }, + }) + const personRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/person', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/person') + expect(window.location.pathname).toBe('/nested/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'nested' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context returned by a layout route, is the same its child route (index route) on first-load + test('_layout on first-load, route context in the index route is correctly inherited from the layout parent', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Index page
, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([indexRoute]), + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const indexElement = await screen.findByText('Index page') + expect(indexElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/') + expect(window.location.pathname).toBe('/') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'layout' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context returned by a layout route, is the same its child route (about route) on navigate + test('_layout on navigate, route context in the /about route is correctly inherited from the layout parent', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/about', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
About page
, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([aboutRoute]), + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const aboutElement = await screen.findByText('About page') + expect(aboutElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/about') + expect(window.location.pathname).toBe('/about') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'layout' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + // Check if context returned by a layout route, is the same its child route (about route) on after a redirect on navigate + test('_layout on navigate, when a redirect is thrown in beforeLoad in /about, the /person route context is correctly inherited from the layout parent', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + }) + const personRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/person', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/person') + expect(window.location.pathname).toBe('/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'layout' }) + expect(mock).toHaveBeenCalledTimes(1) + }) + + test('_layout on navigate, when a redirect is thrown in loader in /about, the /person route context is correctly inherited from the layout parent', async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + }) + const personRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/person', + loader: async ({ context }) => { + await sleep(WAIT_TIME) + mock(context) + }, + component: () =>
Person page
, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const buttonToAbout = await screen.findByRole('button', { + name: 'button to about', + }) + expect(buttonToAbout).toBeInTheDocument() + fireEvent.click(buttonToAbout) + + const personElement = await screen.findByText('Person page') + expect(personElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/person') + expect(window.location.pathname).toBe('/person') + + expect(mock).toHaveBeenCalledWith({ foo: 'bar', layout: 'layout' }) + expect(mock).toHaveBeenCalledTimes(1) + }) +}) + +describe('useRouteContext in the component', () => { + // Present at the root route + test('route context, present in the root route', async () => { + const rootRoute = createRootRoute({ + component: () => { + const context = rootRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const content = await screen.findByText(JSON.stringify({ foo: 'bar' })) + + expect(content).toBeInTheDocument() + }) + + test('route context (sleep in beforeLoad), present in the root route', async () => { + const rootRoute = createRootRoute({ + beforeLoad: async () => { + await sleep(WAIT_TIME) + }, + component: () => { + const context = rootRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const content = await screen.findByText(JSON.stringify({ foo: 'bar' })) + + expect(content).toBeInTheDocument() + }) + + test('route context (sleep in loader), present root route', async () => { + const rootRoute = createRootRoute({ + loader: async () => { + await sleep(WAIT_TIME) + }, + component: () => { + const context = rootRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const content = await screen.findByText(JSON.stringify({ foo: 'bar' })) + + expect(content).toBeInTheDocument() + }) + + // Present at the index route + test('route context, present in the index route', async () => { + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const context = indexRoute.useRouteContext() + + if (context === undefined) { + throw new Error('context is undefined') + } + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const content = await screen.findByText(JSON.stringify({ foo: 'bar' })) + + expect(content).toBeInTheDocument() + }) + + test('route context (sleep in beforeLoad), present in the index route', async () => { + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: async () => { + await sleep(WAIT_TIME) + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const content = await screen.findByText(JSON.stringify({ foo: 'bar' })) + + expect(content).toBeInTheDocument() + }) + + test('route context (sleep in loader), present in the index route', async () => { + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async () => { + await sleep(WAIT_TIME) + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const content = await screen.findByText(JSON.stringify({ foo: 'bar' })) + + expect(content).toBeInTheDocument() + }) + + // Check if context that is updated at the root, is the same in the root route + test('modified route context, present in the root route', async () => { + const rootRoute = createRootRoute({ + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { + ...context, + foo: 'sean', + } + }, + component: () => { + const context = rootRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const content = await screen.findByText(JSON.stringify({ foo: 'sean' })) + + expect(content).toBeInTheDocument() + }) + + // Check if the context that is updated at the root, is the same in the index route + test('modified route context, present in the index route', async () => { + const rootRoute = createRootRoute({ + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { + ...context, + foo: 'sean', + } + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const content = await screen.findByText(JSON.stringify({ foo: 'sean' })) + + expect(content).toBeInTheDocument() + }) + + // Check if the context that is available after a redirect on first-load + test('on first-load, route context is present in the /about route after a redirect is thrown in the beforeLoad of the index route', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/about' }) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () => { + const context = aboutRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([aboutRoute, indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const content = await screen.findByText(JSON.stringify({ foo: 'bar' })) + + expect(router.state.location.href).toBe('/about') + expect(window.location.pathname).toBe('/about') + + expect(content).toBeInTheDocument() + }) + + test('on first-load, route context is present in the /about route after a redirect is thrown in the loader of the index route', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/about' }) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () => { + const context = aboutRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([aboutRoute, indexRoute]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const content = await screen.findByText(JSON.stringify({ foo: 'bar' })) + + expect(router.state.location.href).toBe('/about') + expect(window.location.pathname).toBe('/about') + + expect(content).toBeInTheDocument() + }) + + // Check if the context that is available after a redirect on navigate + test('on navigate, route context is present in the /person route after a redirect is thrown in the beforeLoad of the /about route', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const personRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/person', + component: () => { + const context = personRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([ + personRoute, + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const content = await screen.findByText(JSON.stringify({ foo: 'bar' })) + expect(content).toBeInTheDocument() + + expect(router.state.location.href).toBe('/person') + expect(window.location.pathname).toBe('/person') + }) + + test('on navigate, route context is present in the /person route after a redirect is thrown in the loader of the /about route', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const personRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/person', + component: () => { + const context = personRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([ + personRoute, + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const content = await screen.findByText(JSON.stringify({ foo: 'bar' })) + expect(content).toBeInTheDocument() + + expect(router.state.location.href).toBe('/person') + expect(window.location.pathname).toBe('/person') + }) + + // Check if context returned by /nested/about, is the same its parent /nested on navigate + test('nested destination on navigate, route context in the /nested/about route is correctly inherited from its parent /nested', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'nested' } + }, + component: () => , + }) + const aboutRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/about', + component: () => { + const context = aboutRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([aboutRoute]), + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const content = await screen.findByText( + JSON.stringify({ foo: 'bar', layout: 'nested' }), + ) + + expect(router.state.location.href).toBe('/nested/about') + expect(window.location.pathname).toBe('/nested/about') + + expect(content).toBeInTheDocument() + }) + + // Check if context returned by a layout route, is the same its child route (about route) on after a redirect on navigate + test('nested destination on navigate, when a redirect is thrown in beforeLoad in /about, the /nested/person route context is correctly inherited from its parent /nested', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/person' }) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'nested' } + }, + component: () => , + }) + const personRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/person', + component: () => { + const context = personRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const content = await screen.findByText( + JSON.stringify({ foo: 'bar', layout: 'nested' }), + ) + + expect(router.state.location.href).toBe('/nested/person') + expect(window.location.pathname).toBe('/nested/person') + + expect(content).toBeInTheDocument() + }) + + test('nested destination on navigate, when a redirect is thrown in loader in /about, the /nested/person route context is correctly inherited from its parent /nested', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/person' }) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'nested' } + }, + component: () => , + }) + const personRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/person', + component: () => { + const context = personRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const content = await screen.findByText( + JSON.stringify({ foo: 'bar', layout: 'nested' }), + ) + + expect(router.state.location.href).toBe('/nested/person') + expect(window.location.pathname).toBe('/nested/person') + + expect(content).toBeInTheDocument() + }) + + // Check if context returned by a layout route, is the same its child route (index route) on first-load + test('_layout on first-load, route context in the index route is correctly inherited from the layout parent', async () => { + const rootRoute = createRootRoute() + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/', + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([indexRoute]), + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const content = await screen.findByText( + JSON.stringify({ foo: 'bar', layout: 'layout' }), + ) + + expect(router.state.location.href).toBe('/') + expect(window.location.pathname).toBe('/') + + expect(content).toBeInTheDocument() + }) + + // Check if context returned by a layout route, is the same its child route (about route) on navigate + test('_layout on navigate, route context in the /about route is correctly inherited from the layout parent', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + component: () => , + }) + const aboutRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/about', + component: () => { + const context = aboutRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([aboutRoute]), + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const content = await screen.findByText( + JSON.stringify({ foo: 'bar', layout: 'layout' }), + ) + + expect(router.state.location.href).toBe('/about') + expect(window.location.pathname).toBe('/about') + + expect(content).toBeInTheDocument() + }) + + // Check if context returned by a layout route, is the same its child route (about route) on after a redirect on navigate + test('_layout on navigate, when a redirect is thrown in beforeLoad in /about, the /person route context is correctly inherited from the layout parent', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + component: () => , + }) + const personRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/person', + component: () => { + const context = personRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const content = await screen.findByText( + JSON.stringify({ foo: 'bar', layout: 'layout' }), + ) + + expect(router.state.location.href).toBe('/person') + expect(window.location.pathname).toBe('/person') + + expect(content).toBeInTheDocument() + }) + + test('_layout on navigate, when a redirect is thrown in loader in /about, the /person route context is correctly inherited from the layout parent', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/person' }) + }, + }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + beforeLoad: async ({ context }) => { + await sleep(WAIT_TIME) + return { ...context, layout: 'layout' } + }, + component: () => , + }) + const personRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/person', + component: () => { + const context = personRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([personRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history, context: { foo: 'bar' } }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const content = await screen.findByText( + JSON.stringify({ foo: 'bar', layout: 'layout' }), + ) + + expect(router.state.location.href).toBe('/person') + expect(window.location.pathname).toBe('/person') + + expect(content).toBeInTheDocument() + }) +}) diff --git a/packages/solid-router/tests/router.test-d.tsx b/packages/solid-router/tests/router.test-d.tsx new file mode 100644 index 00000000000..85582f4555b --- /dev/null +++ b/packages/solid-router/tests/router.test-d.tsx @@ -0,0 +1,255 @@ +import { expectTypeOf, test } from 'vitest' +import { + createMemoryHistory, + createRootRoute, + createRootRouteWithContext, + createRoute, + createRouter, +} from '../src' +import type { RouterHistory } from '../src' + +test('when creating a router without context', () => { + // eslint-disable-next-line unused-imports/no-unused-vars + const rootRoute = createRootRoute() + + type RouteTree = typeof rootRoute + + expectTypeOf(createRouter) + .parameter(0) + .toHaveProperty('routeTree') + .toEqualTypeOf() + + expectTypeOf(createRouter) + .parameter(0) + .toHaveProperty('context') + .toEqualTypeOf<{} | undefined>() + + expectTypeOf(createRouter) + .parameter(0) + .not.toMatchTypeOf<{ + context: {} + }>() +}) + +test('when navigating using router', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + validateSearch: () => ({ + page: 0, + }), + }) + + const routeTree = rootRoute.addChildren([indexRoute, postsRoute]) + + const router = createRouter({ + routeTree, + }) + + expectTypeOf(router.navigate) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf<'/posts' | '/' | '.' | '..' | undefined>() + + expectTypeOf(router.navigate) + .parameter(0) + .toHaveProperty('search') + .exclude<(...args: Array) => any>() + .toEqualTypeOf<{ page: number }>() +}) + +test('when building location using router', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + validateSearch: () => ({ + page: 0, + }), + }) + + const routeTree = rootRoute.addChildren([indexRoute, postsRoute]) + + const router = createRouter({ + routeTree, + }) + + expectTypeOf(router.buildLocation) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf<'/posts' | '/' | '.' | '..' | undefined>() + + expectTypeOf(router.buildLocation) + .parameter(0) + .toHaveProperty('search') + .exclude<(...args: Array) => any>() + .toEqualTypeOf<{ page: number }>() +}) + +test('when creating a router with context', () => { + // eslint-disable-next-line unused-imports/no-unused-vars + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + type RouteTree = typeof rootRoute + + expectTypeOf(createRouter) + .parameter(0) + .toHaveProperty('routeTree') + .toEqualTypeOf() + + expectTypeOf(createRouter) + .parameter(0) + .toHaveProperty('context') + .toEqualTypeOf<{ userId: string }>() + + expectTypeOf(createRouter) + .parameter(0) + .toMatchTypeOf<{ + context: { userId: string } + }>() +}) + +test('when creating a router with context and children', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const routeTree = rootRoute.addChildren([indexRoute]) + + type RouteTree = typeof routeTree + + expectTypeOf(createRouter) + .parameter(0) + .toHaveProperty('routeTree') + .toEqualTypeOf() + + expectTypeOf(createRouter) + .parameter(0) + .toHaveProperty('context') + .toEqualTypeOf<{ userId: string }>() + + expectTypeOf(createRouter) + .parameter(0) + .toMatchTypeOf<{ + context: { userId: string } + }>() +}) + +test('invalidate and clearCache narrowing in filter', () => { + const rootRoute = createRootRoute() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + beforeLoad: () => ({ invoicePermissions: ['view'] as const }), + }) + + const invoiceRoute = createRoute({ + path: '$invoiceId', + getParentRoute: () => invoicesRoute, + }) + + const detailsRoute = createRoute({ + path: 'details', + getParentRoute: () => invoiceRoute, + validateSearch: () => ({ detailPage: 0 }), + beforeLoad: () => ({ detailsPermissions: ['view'] as const }), + }) + + const detailRoute = createRoute({ + path: '$detailId', + getParentRoute: () => detailsRoute, + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([ + invoiceRoute.addChildren([detailsRoute.addChildren([detailRoute])]), + ]), + ]) + + const router = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type Router = typeof router + + router.invalidate({ + filter: (route) => { + expectTypeOf(route.routeId).toEqualTypeOf< + | '__root__' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + >() + + if (route.routeId === '/invoices/$invoiceId/details/$detailId') { + expectTypeOf(route.params).branded.toEqualTypeOf<{ + invoiceId: string + detailId: string + }>() + } + return true + }, + }) + + router.clearCache({ + filter: (route) => { + expectTypeOf(route.routeId).toEqualTypeOf< + | '__root__' + | '/invoices' + | '/invoices/$invoiceId' + | '/invoices/$invoiceId/details' + | '/invoices/$invoiceId/details/$detailId' + >() + + if (route.routeId === '/invoices/$invoiceId/details/$detailId') { + expectTypeOf(route.params).branded.toEqualTypeOf<{ + invoiceId: string + detailId: string + }>() + } + return true + }, + }) +}) + +test('when creating a router with default router history', () => { + const router = createRouter({ routeTree: createRootRoute() }) + + expectTypeOf(router.history).toEqualTypeOf() +}) + +test('when creating a router with custom router history', () => { + const customRouterHistory = { + ...createMemoryHistory(), + _isCustomRouterHistory: true, + } + + const router = createRouter({ + routeTree: createRootRoute(), + history: customRouterHistory, + }) + + expectTypeOf(router.history).toMatchTypeOf() + expectTypeOf(router.history).toEqualTypeOf() +}) diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx new file mode 100644 index 00000000000..c4db6df4965 --- /dev/null +++ b/packages/solid-router/tests/router.test.tsx @@ -0,0 +1,1082 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@solidjs/testing-library' +import { z } from 'zod' +import { + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { AnyRoute, AnyRouter, RouterOptions } from '../src' +import { onMount } from 'solid-js' + +afterEach(() => { + vi.resetAllMocks() + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +const mockFn1 = vi.fn() + +export function validateSearchParams< + TExpected extends Partial>, +>(expected: TExpected, router: AnyRouter) { + const parsedSearch = new URLSearchParams(window.location.search) + expect(parsedSearch.size).toBe(Object.keys(expected).length) + for (const [key, value] of Object.entries(expected)) { + expect(parsedSearch.get(key)).toBe(value) + } + expect(router.state.location.search).toEqual(expected) +} + +function createTestRouter(options?: RouterOptions) { + const rootRoute = createRootRoute({ + validateSearch: z.object({ root: z.string().optional() }), + component: () => { + const search = rootRoute.useSearch() + return ( + <> +
{search().root ?? '$undefined'}
+ + + ) + }, + }) + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/' }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const postIdRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '/$slug', + }) + const topLevelSplatRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '$', + }) + // This is simulates a user creating a `é.tsx` file using file-based routing + const pathSegmentEAccentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/path-segment/é', + }) + // This is simulates a user creating a `🚀.tsx` file using file-based routing + const pathSegmentRocketEmojiRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/path-segment/🚀', + }) + const pathSegmentSoloSplatRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/solo-splat/$', + }) + const pathSegmentLayoutSplatRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/layout-splat', + }) + const pathSegmentLayoutSplatIndexRoute = createRoute({ + getParentRoute: () => pathSegmentLayoutSplatRoute, + path: '/', + }) + const pathSegmentLayoutSplatSplatRoute = createRoute({ + getParentRoute: () => pathSegmentLayoutSplatRoute, + path: '$', + }) + const protectedRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '/_protected', + }).lazy(() => import('./lazy/normal').then((f) => f.Route('/_protected'))) + const protectedLayoutRoute = createRoute({ + getParentRoute: () => protectedRoute, + id: '/_layout', + }).lazy(() => + import('./lazy/normal').then((f) => f.Route('/_protected/_layout')), + ) + const protectedFileBasedLayoutRoute = createRoute({ + getParentRoute: () => protectedRoute, + id: '/_fileBasedLayout', + }).lazy(() => + import('./lazy/normal').then((f) => + f.FileRoute('/_protected/_fileBasedLayout'), + ), + ) + const protectedFileBasedLayoutParentRoute = createRoute({ + getParentRoute: () => protectedFileBasedLayoutRoute, + path: '/fileBasedParent', + }).lazy(() => + import('./lazy/normal').then((f) => + f.FileRoute('/_protected/_fileBasedLayout/fileBasedParent'), + ), + ) + const protectedLayoutParentRoute = createRoute({ + getParentRoute: () => protectedLayoutRoute, + path: '/parent', + }).lazy(() => + import('./lazy/normal').then((f) => f.Route('/_protected/_layout/parent')), + ) + const protectedLayoutParentChildRoute = createRoute({ + getParentRoute: () => protectedLayoutParentRoute, + path: '/child', + }).lazy(() => + import('./lazy/normal').then((f) => + f.Route('/_protected/_layout/parent/child'), + ), + ) + const protectedFileBasedLayoutParentChildRoute = createRoute({ + getParentRoute: () => protectedFileBasedLayoutParentRoute, + path: '/child', + }).lazy(() => + import('./lazy/normal').then((f) => + f.FileRoute('/_protected/_fileBasedLayout/fileBasedParent/child'), + ), + ) + const searchRoute = createRoute({ + validateSearch: z.object({ search: z.string().optional() }), + getParentRoute: () => rootRoute, + path: 'search', + component: () => { + const search = searchRoute.useSearch() + return ( + <> +
+ {search().search ?? '$undefined'} +
+ + ) + }, + }) + const searchWithDefaultRoute = createRoute({ + getParentRoute: () => rootRoute, + + path: 'searchWithDefault', + }) + const searchWithDefaultIndexRoute = createRoute({ + getParentRoute: () => searchWithDefaultRoute, + path: '/', + component: () => { + return ( + <> + + without params + + + with optional param + + + with default param + + + with both params + + + ) + }, + }) + + const searchWithDefaultCheckRoute = createRoute({ + validateSearch: z.object({ + default: z.string().default('d1'), + optional: z.string().optional(), + }), + getParentRoute: () => searchWithDefaultRoute, + path: 'check', + component: () => { + const search = searchWithDefaultCheckRoute.useSearch() + return ( + <> +
{search().default}
+
+ {search().optional ?? '$undefined'} +
+ + ) + }, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postIdRoute]), + pathSegmentEAccentRoute, + pathSegmentRocketEmojiRoute, + pathSegmentSoloSplatRoute, + topLevelSplatRoute, + pathSegmentLayoutSplatRoute.addChildren([ + pathSegmentLayoutSplatIndexRoute, + pathSegmentLayoutSplatSplatRoute, + ]), + protectedRoute.addChildren([ + protectedLayoutRoute.addChildren([ + protectedLayoutParentRoute.addChildren([ + protectedLayoutParentChildRoute, + ]), + ]), + protectedFileBasedLayoutRoute.addChildren([ + protectedFileBasedLayoutParentRoute.addChildren([ + protectedFileBasedLayoutParentChildRoute, + ]), + ]), + ]), + searchRoute, + searchWithDefaultRoute.addChildren([ + searchWithDefaultIndexRoute, + searchWithDefaultCheckRoute, + ]), + ]) + + const router = createRouter({ routeTree, ...options }) + + return { + router, + routes: { + indexRoute, + postsRoute, + postIdRoute, + topLevelSplatRoute, + pathSegmentEAccentRoute, + pathSegmentRocketEmojiRoute, + }, + } +} + +describe('encoding: URL param segment for /posts/$slug', () => { + it('state.location.pathname, should have the params.slug value of "tanner"', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/posts/tanner'] }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/posts/tanner') + }) + + it('state.location.pathname, should have the params.slug value of "🚀"', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/posts/🚀'] }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/posts/🚀') + }) + + it('state.location.pathname, should have the params.slug value of "%F0%9F%9A%80"', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/posts/%F0%9F%9A%80'] }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/posts/%F0%9F%9A%80') + }) + + it('state.location.pathname, should have the params.slug value of "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ + initialEntries: [ + '/posts/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', + ], + }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe( + '/posts/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', + ) + }) + + it('params.slug for the matched route, should be "tanner"', async () => { + const { router, routes } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/posts/tanner'] }), + }) + + await router.load() + + const match = router.state.matches.find( + (r) => r.routeId === routes.postIdRoute.id, + ) + + if (!match) { + throw new Error('No match found') + } + + expect((match.params as unknown as any).slug).toBe('tanner') + }) + + it('params.slug for the matched route, should be "🚀"', async () => { + const { router, routes } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/posts/🚀'] }), + }) + + await router.load() + + const match = router.state.matches.find( + (r) => r.routeId === routes.postIdRoute.id, + ) + + if (!match) { + throw new Error('No match found') + } + + expect((match.params as unknown as any).slug).toBe('🚀') + }) + + it('params.slug for the matched route, should be "🚀" instead of it being "%F0%9F%9A%80"', async () => { + const { router, routes } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/posts/%F0%9F%9A%80'] }), + }) + + await router.load() + + const match = router.state.matches.find( + (r) => r.routeId === routes.postIdRoute.id, + ) + + if (!match) { + throw new Error('No match found') + } + + expect((match.params as unknown as any).slug).toBe('🚀') + }) + + it('params.slug for the matched route, should be "framework/react/guide/file-based-routing tanstack" instead of it being "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { + const { router, routes } = createTestRouter({ + history: createMemoryHistory({ + initialEntries: [ + '/posts/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', + ], + }), + }) + + await router.load() + + const match = router.state.matches.find( + (r) => r.routeId === routes.postIdRoute.id, + ) + + if (!match) { + throw new Error('No match found') + } + + expect((match.params as unknown as any).slug).toBe( + 'framework/react/guide/file-based-routing tanstack', + ) + }) + + it('params.slug should be encoded in the final URL', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + render(() => ) + + await router.navigate({ to: '/posts/$slug', params: { slug: '@jane' } }) + + expect(router.state.location.pathname).toBe('/posts/%40jane') + }) + + it('params.slug should be encoded in the final URL except characters in pathParamsAllowedCharacters', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + pathParamsAllowedCharacters: ['@'], + }) + + await router.load() + render(() => ) + + await router.navigate({ to: '/posts/$slug', params: { slug: '@jane' } }) + + expect(router.state.location.pathname).toBe('/posts/@jane') + }) +}) + +describe('encoding: URL splat segment for /$', () => { + it('state.location.pathname, should have the params._splat value of "tanner"', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/tanner'] }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/tanner') + }) + + it('state.location.pathname, should have the params._splat value of "🚀"', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/🚀'] }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/🚀') + }) + + it('state.location.pathname, should have the params._splat value of "%F0%9F%9A%80"', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/%F0%9F%9A%80'] }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/%F0%9F%9A%80') + }) + + it('state.location.pathname, should have the params._splat value of "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ + initialEntries: [ + '/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', + ], + }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe( + '/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', + ) + }) + + it('state.location.pathname, should have the params._splat value of "framework/react/guide/file-based-routing tanstack"', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ + initialEntries: ['/framework/react/guide/file-based-routing tanstack'], + }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe( + '/framework/react/guide/file-based-routing tanstack', + ) + }) + + it('params._splat for the matched route, should be "tanner"', async () => { + const { router, routes } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/tanner'] }), + }) + + await router.load() + + const match = router.state.matches.find( + (r) => r.routeId === routes.topLevelSplatRoute.id, + ) + + if (!match) { + throw new Error('No match found') + } + + expect((match.params as unknown as any)._splat).toBe('tanner') + }) + + it('params._splat for the matched route, should be "🚀"', async () => { + const { router, routes } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/🚀'] }), + }) + + await router.load() + + const match = router.state.matches.find( + (r) => r.routeId === routes.topLevelSplatRoute.id, + ) + + if (!match) { + throw new Error('No match found') + } + + expect((match.params as unknown as any)._splat).toBe('🚀') + }) + + it('params._splat for the matched route, should be "🚀" instead of it being "%F0%9F%9A%80"', async () => { + const { router, routes } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/%F0%9F%9A%80'] }), + }) + + await router.load() + + const match = router.state.matches.find( + (r) => r.routeId === routes.topLevelSplatRoute.id, + ) + + if (!match) { + throw new Error('No match found') + } + + expect((match.params as unknown as any)._splat).toBe('🚀') + }) + + it('params._splat for the matched route, should be "framework/react/guide/file-based-routing tanstack"', async () => { + const { router, routes } = createTestRouter({ + history: createMemoryHistory({ + initialEntries: ['/framework/react/guide/file-based-routing tanstack'], + }), + }) + + await router.load() + + const match = router.state.matches.find( + (r) => r.routeId === routes.topLevelSplatRoute.id, + ) + + if (!match) { + throw new Error('No match found') + } + + expect((match.params as unknown as any)._splat).toBe( + 'framework/react/guide/file-based-routing tanstack', + ) + }) +}) + +describe('encoding: URL path segment', () => { + it.each([ + { + input: '/path-segment/%C3%A9', + output: '/path-segment/é', + type: 'encoded', + }, + { + input: '/path-segment/é', + output: '/path-segment/é', + type: 'not encoded', + }, + { + input: '/path-segment/%F0%9F%9A%80', + output: '/path-segment/🚀', + type: 'encoded', + }, + { + input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', + output: '/path-segment/🚀to%2Fthe%2Fmoon', + type: 'encoded', + }, + { + input: '/path-segment/🚀', + output: '/path-segment/🚀', + type: 'not encoded', + }, + { + input: '/path-segment/🚀to%2Fthe%2Fmoon', + output: '/path-segment/🚀to%2Fthe%2Fmoon', + type: 'not encoded', + }, + ])( + 'should resolve $input to $output when the path segment is $type', + async ({ input, output }) => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: [input] }), + }) + + render(() => ) + await router.load() + + expect(router.state.location.pathname).toBe(output) + }, + ) +}) + +describe('router emits events during rendering', () => { + it('during initial load, should emit the "onResolved" event', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const unsub = router.subscribe('onResolved', mockFn1) + await router.load() + render(() => ) + + await waitFor(() => expect(mockFn1).toBeCalled()) + unsub() + }) + + it('after a navigation, should have emitted the "onResolved" event twice', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const unsub = router.subscribe('onResolved', mockFn1) + await router.load() + await waitFor(() => render(() => )) + + await router.navigate({ to: '/$', params: { _splat: 'tanner' } }) + + await waitFor(() => expect(mockFn1).toBeCalledTimes(2)) + unsub() + }) + + it('during initial load, should emit the "onBeforeRouteMount" and "onResolved" events in the correct order', async () => { + const mockOnBeforeRouteMount = vi.fn() + const mockOnResolved = vi.fn() + + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + // Subscribe to the events + const unsubBeforeRouteMount = router.subscribe( + 'onBeforeRouteMount', + mockOnBeforeRouteMount, + ) + const unsubResolved = router.subscribe('onResolved', mockOnResolved) + + await router.load() + render(() => ) + + // Ensure the "onBeforeRouteMount" event was called once + await waitFor(() => expect(mockOnBeforeRouteMount).toBeCalledTimes(1)) + + // Ensure the "onResolved" event was also called once + await waitFor(() => expect(mockOnResolved).toBeCalledTimes(1)) + + // Check if the invocation call orders are defined before comparing + const beforeRouteMountOrder = + mockOnBeforeRouteMount.mock.invocationCallOrder[0] + const onResolvedOrder = mockOnResolved.mock.invocationCallOrder[0] + + if (beforeRouteMountOrder !== undefined && onResolvedOrder !== undefined) { + expect(beforeRouteMountOrder).toBeLessThan(onResolvedOrder) + } else { + throw new Error('onBeforeRouteMount should be emitted before onResolved.') + } + + unsubBeforeRouteMount() + unsubResolved() + }) +}) + +describe('router rendering stability', () => { + it('should not remount the page component when navigating to the same route', async () => { + const callerMock = vi.fn() + + const rootRoute = createRootRoute({ + component: () => { + return ( +
+

Root

+
+ + Foo1 + + + Foo2 + +
+ +
+ ) + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return '' + }, + }) + const fooIdRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/foo/$id', + component: FooIdRouteComponent, + }) + function FooIdRouteComponent() { + const id = fooIdRoute.useParams({ select: (s) => s.id }) + + onMount(() => { + callerMock() + }) + + return
Foo page {id()}
+ } + + const routeTree = rootRoute.addChildren([fooIdRoute, indexRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const foo1Link = await screen.findByRole('link', { name: 'Foo1' }) + const foo2Link = await screen.findByRole('link', { name: 'Foo2' }) + + expect(foo1Link).toBeInTheDocument() + expect(foo2Link).toBeInTheDocument() + + fireEvent.click(foo1Link) + + const fooPage1 = await screen.findByText('Foo page 1') + expect(fooPage1).toBeInTheDocument() + + expect(callerMock).toBeCalledTimes(1) + + fireEvent.click(foo2Link) + + const fooPage2 = await screen.findByText('Foo page 2') + expect(fooPage2).toBeInTheDocument() + + expect(callerMock).toBeCalledTimes(1) + }) +}) + +describe('transformer functions are defined', () => { + it('should have default transformer functions', () => { + const rootRoute = createRootRoute({}) + const routeTree = rootRoute.addChildren([]) + const router = createRouter({ routeTree }) + + expect(router.options.transformer.parse).toBeInstanceOf(Function) + expect(router.options.transformer.stringify).toBeInstanceOf(Function) + }) +}) + +describe('router matches URLs to route definitions', () => { + it('solo splat route matches index route', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/solo-splat'] }), + }) + + await router.load() + + expect(router.state.matches.map((d) => d.routeId)).toEqual([ + '__root__', + '/solo-splat/$', + ]) + }) + + it('solo splat route matches with splat', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/solo-splat/test'] }), + }) + + await router.load() + + expect(router.state.matches.map((d) => d.routeId)).toEqual([ + '__root__', + '/solo-splat/$', + ]) + }) + + it('layout splat route matches with splat', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/layout-splat/test'] }), + }) + + await router.load() + + expect(router.state.matches.map((d) => d.routeId)).toEqual([ + '__root__', + '/layout-splat', + '/layout-splat/$', + ]) + }) + + it('layout splat route matches without splat', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/layout-splat'] }), + }) + + await router.load() + + expect(router.state.matches.map((d) => d.routeId)).toEqual([ + '__root__', + '/layout-splat', + '/layout-splat/', + ]) + }) +}) + +describe('invalidate', () => { + it('after router.invalid(), routes should be `valid` again after loading', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + router.state.matches.forEach((match) => { + expect(match.invalid).toBe(false) + }) + + await router.invalidate() + + router.state.matches.forEach((match) => { + expect(match.invalid).toBe(false) + }) + }) +}) + +describe('search params in URL', () => { + const testCases = [ + { route: '/', search: { root: 'world' } }, + { route: '/', search: { root: 'world', unknown: 'asdf' } }, + { route: '/search', search: { search: 'foo' } }, + { route: '/search', search: { root: 'world', search: 'foo' } }, + { + route: '/search', + search: { root: 'world', search: 'foo', unknown: 'asdf' }, + }, + ] + describe.each([undefined, false])( + 'does not modify the search params in the URL when search.strict=%s', + (strict) => { + it.each(testCases)( + 'at $route with search params $search', + async ({ route, search }) => { + const { router } = createTestRouter({ search: { strict } }) + window.history.replaceState( + null, + '', + `${route}?${new URLSearchParams(search as Record).toString()}`, + ) + + render(() => ) + await router.load() + + expect(await screen.findByTestId('search-root')).toHaveTextContent( + search.root ?? '$undefined', + ) + if (route === '/search') { + expect( + await screen.findByTestId('search-search'), + ).toHaveTextContent(search.search ?? '$undefined') + } + validateSearchParams(search, router) + }, + ) + }, + ) + + describe('removes unknown search params in the URL when search.strict=true', () => { + it.each(testCases)('%j', async ({ route, search }) => { + const { router } = createTestRouter({ search: { strict: true } }) + window.history.replaceState( + null, + '', + `${route}?${new URLSearchParams(search as Record).toString()}`, + ) + render(() => ) + await router.load() + await expect(await screen.findByTestId('search-root')).toHaveTextContent( + search.root ?? 'undefined', + ) + if (route === '/search') { + expect(await screen.findByTestId('search-search')).toHaveTextContent( + search.search ?? 'undefined', + ) + } + + expect(window.location.pathname).toEqual(route) + const { unknown: _, ...expectedSearch } = { ...search } + validateSearchParams(expectedSearch, router) + }) + }) + + describe.each([false, true, undefined])('default search params', (strict) => { + let router: AnyRouter + beforeEach(() => { + const result = createTestRouter({ search: { strict } }) + router = result.router + }) + + async function checkSearch(expectedSearch: { + default: string + optional?: string + }) { + expect(await screen.findByTestId('search-default')).toHaveTextContent( + expectedSearch.default, + ) + expect(await screen.findByTestId('search-optional')).toHaveTextContent( + expectedSearch.optional ?? '$undefined', + ) + + validateSearchParams(expectedSearch, router) + } + + it('should add the default search param upon initial load when no search params are present', async () => { + window.history.replaceState(null, '', `/searchWithDefault/check`) + + render(() => ) + await router.load() + + await checkSearch({ default: 'd1' }) + }) + + it('should have the correct `default` search param upon initial load when the `default` param is present', async () => { + window.history.replaceState( + null, + '', + `/searchWithDefault/check?default=d2`, + ) + + render(() => ) + await router.load() + + await checkSearch({ default: 'd2' }) + }) + + it('should add the default search param upon initial load when only the optional search param is present', async () => { + window.history.replaceState( + null, + '', + `/searchWithDefault/check?optional=o1`, + ) + + render(() => ) + await router.load() + + await checkSearch({ default: 'd1', optional: 'o1' }) + }) + + it('should keep the search param upon initial load when both search params are present', async () => { + window.history.replaceState( + null, + '', + `/searchWithDefault/check?default=d2&optional=o1`, + ) + + render(() => ) + await router.load() + + await checkSearch({ default: 'd2', optional: 'o1' }) + }) + + it('should have the default search param when navigating without search params', async () => { + window.history.replaceState(null, '', `/searchWithDefault`) + + render(() => ) + await router.load() + const link = await screen.findByTestId('link-without-params') + + expect(link).toBeInTheDocument() + fireEvent.click(link) + + await checkSearch({ default: 'd1' }) + }) + + it('should have the default search param when navigating with the optional search param', async () => { + window.history.replaceState(null, '', `/searchWithDefault`) + + render(() => ) + await router.load() + const link = await screen.findByTestId('link-with-optional-param') + + expect(link).toBeInTheDocument() + fireEvent.click(link) + + await checkSearch({ default: 'd1', optional: 'o1' }) + }) + + it('should have the correct `default` search param when navigating with the `default` search param', async () => { + window.history.replaceState(null, '', `/searchWithDefault`) + + render(() => ) + await router.load() + const link = await screen.findByTestId('link-with-default-param') + + expect(link).toBeInTheDocument() + fireEvent.click(link) + + await checkSearch({ default: 'd2' }) + }) + + it('should have the correct search params when navigating with both search params', async () => { + window.history.replaceState(null, '', `/searchWithDefault`) + + render(() => ) + await router.load() + const link = await screen.findByTestId('link-with-both-params') + + expect(link).toBeInTheDocument() + fireEvent.click(link) + + await checkSearch({ default: 'd2', optional: 'o1' }) + }) + }) +}) + +describe('route ids should be consistent after rebuilding the route tree', () => { + it('should have the same route ids after rebuilding the route tree', async () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const originalRouteIds = Object.keys(router.routesById) + + await router.navigate({ to: '/parent/child' }) + await router.navigate({ to: '/filBasedParent/child' }) + + router.buildRouteTree() + + const rebuiltRouteIds = Object.keys(router.routesById) + + originalRouteIds.forEach((id) => { + expect(rebuiltRouteIds).toContain(id) + }) + + rebuiltRouteIds.forEach((id) => { + expect(originalRouteIds).toContain(id) + }) + }) +}) + +describe('route id uniqueness', () => { + it('flatRoute should not have routes with duplicated route ids', () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const routeIdSet = new Set() + + router.flatRoutes.forEach((route) => { + expect(routeIdSet.has(route.id)).toBe(false) + routeIdSet.add(route.id) + }) + }) + + it('routesById should not have routes duplicated route ids', () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const routeIdSet = new Set() + + Object.values(router.routesById).forEach((route) => { + expect(routeIdSet.has(route.id)).toBe(false) + routeIdSet.add(route.id) + }) + }) + + it('routesByPath should not have routes duplicated route ids', () => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const routeIdSet = new Set() + + Object.values(router.routesByPath).forEach((route) => { + expect(routeIdSet.has(route.id)).toBe(false) + routeIdSet.add(route.id) + }) + }) +}) diff --git a/packages/solid-router/tests/searchMiddleware.test.tsx b/packages/solid-router/tests/searchMiddleware.test.tsx new file mode 100644 index 00000000000..7b34070efb5 --- /dev/null +++ b/packages/solid-router/tests/searchMiddleware.test.tsx @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' + +import { + Link, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + retainSearchParams, + stripSearchParams, +} from '../src' +import { getSearchParamsFromURI } from './utils' +import type { AnyRouter } from '../src' +import type { SearchMiddleware } from '../src/route' + +afterEach(() => { + vi.resetAllMocks() + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +function setupTest(opts: { + initial: { route: string; search?: { value?: string } } + middlewares: Array> + linkSearch?: { value?: string } +}) { + const rootRoute = createRootRoute({ + validateSearch: (input) => { + if (input.value !== undefined) { + return { value: input.value as string } + } + return {} + }, + search: { + middlewares: opts.middlewares, + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ {/* N.B. this link does not have search params set, but the middleware will add `root` if it is currently present */} + + Posts + + + ) + }, + }) + + const PostsComponent = () => { + const { value } = postsRoute.useSearch() + return ( + <> +

Posts

+
{value ?? '$undefined'}
+ + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + window.history.replaceState( + null, + '', + `${opts.initial.route}?${new URLSearchParams(opts.initial.search).toString()}`, + ) + return router +} + +async function runTest( + router: AnyRouter, + expectedSearch: { root: { value?: string }; posts: { value?: string } }, +) { + render(() => ) + + const postsLink = await screen.findByTestId('posts-link') + expect(postsLink).toHaveAttribute('href') + const href = postsLink.getAttribute('href') + const linkSearchOnRoot = getSearchParamsFromURI(href!) + checkLocationSearch(expectedSearch.root) + expect(router.state.location.search).toEqual(expectedSearch.root) + + fireEvent.click(postsLink) + + const postHeading = await screen.findByTestId('posts-heading') + expect(postHeading).toBeInTheDocument() + expect(window.location.pathname).toBe('/posts') + + expect(await screen.findByTestId('search')).toHaveTextContent( + expectedSearch.posts.value ?? '$undefined', + ) + checkLocationSearch(expectedSearch.posts) + expect(router.state.location.search).toEqual(expectedSearch.posts) + return linkSearchOnRoot +} + +function checkLocationSearch(search: object) { + const parsedSearch = new URLSearchParams(window.location.search) + expect(parsedSearch.size).toBe(Object.keys(search).length) + for (const [key, value] of Object.entries(search)) { + expect(parsedSearch.get(key)).toBe(value) + } +} + +describe('retainSearchParams', () => { + const middlewares = [retainSearchParams(['value'])] + + it('should retain `value` search param', async () => { + const router = setupTest({ + initial: { route: '/', search: { value: 'abc' } }, + middlewares, + }) + const linkSearch = await runTest(router, { + root: { value: 'abc' }, + posts: { value: 'abc' }, + }) + expect(linkSearch.size).toBe(1) + expect(linkSearch.get('value')).toBe('abc') + }) + + it('should do nothing if `value` search param is not set', async () => { + const router = setupTest({ initial: { route: '/' }, middlewares }) + const expectedLocationSearch = {} + const linkSearch = await runTest(router, { root: {}, posts: {} }) + expect(linkSearch.size).toBe(0) + expect(router.state.location.search).toEqual(expectedLocationSearch) + checkLocationSearch(expectedLocationSearch) + }) +}) + +describe('stripSearchParams', () => { + it('by key', async () => { + const middlewares = [stripSearchParams(['value'])] + const router = setupTest({ + initial: { route: '/', search: { value: 'abc' } }, + middlewares, + linkSearch: { value: 'xyz' }, + }) + const linkSearch = await runTest(router, { root: {}, posts: {} }) + expect(linkSearch.size).toBe(0) + }) + + it('true', async () => { + const middlewares = [stripSearchParams(true)] + const router = setupTest({ + initial: { route: '/', search: { value: 'abc' } }, + middlewares, + linkSearch: { value: 'xyz' }, + }) + const linkSearch = await runTest(router, { root: {}, posts: {} }) + expect(linkSearch.size).toBe(0) + }) + + it('by value', async () => { + const middlewares = [stripSearchParams({ value: 'abc' })] + const router = setupTest({ + initial: { route: '/', search: { value: 'abc' } }, + middlewares, + linkSearch: { value: 'xyz' }, + }) + const linkSearch = await runTest(router, { + root: {}, + posts: { value: 'xyz' }, + }) + expect(linkSearch.size).toBe(1) + }) +}) diff --git a/packages/solid-router/tests/setupTests.tsx b/packages/solid-router/tests/setupTests.tsx new file mode 100644 index 00000000000..a9d0dd31aa6 --- /dev/null +++ b/packages/solid-router/tests/setupTests.tsx @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/packages/solid-router/tests/useBlocker.test-d.tsx b/packages/solid-router/tests/useBlocker.test-d.tsx new file mode 100644 index 00000000000..ed3149fe997 --- /dev/null +++ b/packages/solid-router/tests/useBlocker.test-d.tsx @@ -0,0 +1,122 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRoute, createRouter, useBlocker } from '../src' + +test('blocker without resolver', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useBlocker).returns.toBeVoid() +}) + +test('blocker with resolver', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useBlocker).returns.toBeObject() +}) + +test('shouldBlockFn has corrent action', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useBlocker) + .parameter(0) + .toHaveProperty('shouldBlockFn') + .parameter(0) + .toHaveProperty('action') + .toEqualTypeOf<'PUSH' | 'POP' | 'REPLACE' | 'FORWARD' | 'BACK' | 'GO'>() + + expectTypeOf(useBlocker) + .parameter(0) + .toHaveProperty('shouldBlockFn') + .parameter(0) + .toHaveProperty('current') + .toHaveProperty('routeId') + .toEqualTypeOf<'__root__' | '/' | '/invoices' | '/invoices/'>() + + expectTypeOf(useBlocker) + .parameter(0) + .toHaveProperty('shouldBlockFn') + .parameter(0) + .toHaveProperty('next') + .toHaveProperty('routeId') + .toEqualTypeOf<'__root__' | '/' | '/invoices' | '/invoices/'>() +}) diff --git a/packages/solid-router/tests/useBlocker.test.tsx b/packages/solid-router/tests/useBlocker.test.tsx new file mode 100644 index 00000000000..e2a70c0c4bd --- /dev/null +++ b/packages/solid-router/tests/useBlocker.test.tsx @@ -0,0 +1,424 @@ +import '@testing-library/jest-dom/vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' + +import { z } from 'zod' +import { + RouterProvider, + createRootRoute, + createRoute, + createRouter, + useBlocker, + useNavigate, +} from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +describe('useBlocker', () => { + test('does not block navigation when not enabled', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn: () => false }) + + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + <> +

Posts

+ + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Posts' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/posts') + }) + + test('does not block navigation when disabled', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn: () => true, disabled: true }) + + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + <> +

Posts

+ + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Posts' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/posts') + }) + + test('blocks navigation when enabled', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn: () => true }) + + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + <> +

Posts

+ + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Index' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/') + }) + + test('gives correct arguments to shouldBlockFn', async () => { + const rootRoute = createRootRoute() + + const shouldBlockFn = vi.fn().mockReturnValue(true) + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn }) + + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + <> +

Posts

+ + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Index' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/') + + expect(shouldBlockFn).toHaveBeenCalledWith({ + action: 'REPLACE', + current: { + routeId: indexRoute.id, + fullPath: indexRoute.fullPath, + pathname: '/', + params: {}, + search: {}, + }, + next: { + routeId: postsRoute.id, + fullPath: postsRoute.fullPath, + pathname: '/posts', + params: {}, + search: {}, + }, + }) + }) + + test('gives correct arguments to shouldBlockFn with path and search params', async () => { + const rootRoute = createRootRoute() + + const shouldBlockFn = vi.fn().mockReturnValue(true) + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn }) + + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + validateSearch: z.object({ + param1: z.string().default('param1-default'), + param2: z.string().default('param2-default'), + }), + path: '/posts/$postId', + component: () => { + return ( + <> +

Posts

+ + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Index' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/') + + expect(shouldBlockFn).toHaveBeenCalledWith({ + action: 'PUSH', + current: { + routeId: indexRoute.id, + fullPath: indexRoute.fullPath, + pathname: '/', + params: {}, + search: {}, + }, + next: { + routeId: postsRoute.id, + fullPath: postsRoute.fullPath, + pathname: '/posts/10', + params: { postId: '10' }, + search: { param1: 'foo', param2: 'bar' }, + }, + }) + }) + + test('conditionally blocking navigation works', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + + useBlocker({ + shouldBlockFn: ({ next }) => { + if (next.fullPath === '/posts') { + return true + } + return false + }, + }) + + return ( + <> +

Index

+ + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + <> +

Posts

+ + ) + }, + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/invoices', + component: () => { + return ( + <> +

Invoices

+ + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute, invoicesRoute]), + }) + + type Router = typeof router + + render(() => ) + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Index' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/') + + const invoicesButton = await screen.findByRole('button', { + name: 'Invoices', + }) + + fireEvent.click(invoicesButton) + + expect( + await screen.findByRole('heading', { name: 'Invoices' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/invoices') + }) +}) diff --git a/packages/solid-router/tests/useLoaderData.test-d.tsx b/packages/solid-router/tests/useLoaderData.test-d.tsx new file mode 100644 index 00000000000..6d8a16f84a5 --- /dev/null +++ b/packages/solid-router/tests/useLoaderData.test-d.tsx @@ -0,0 +1,431 @@ +import { expectTypeOf, test } from 'vitest' +import { + createRootRoute, + createRoute, + createRouter, + useLoaderData, +} from '../src' + +test('when there is no loaders', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('from') + .toEqualTypeOf<'/invoices' | '__root__' | '/invoices/' | '/'>() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('strict') + .toEqualTypeOf() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .parameter(0) + .toMatchTypeOf<{ + loaderData?: {} + }>() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf(useLoaderData).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useLoaderData({ + strict: false, + }), + ).toEqualTypeOf<{}> +}) + +test('when there is one loader', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + loader: () => ({ data: ['element1', 'element2'] }), + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useLoaderData, + ).returns.toEqualTypeOf<{ data: Array }>() + + expectTypeOf( + useLoaderData, + ).returns.toEqualTypeOf<{ data: Array }>() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf( + useLoaderData, + ).returns.toEqualTypeOf<{ data?: Array }>() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf( + useLoaderData, + ).returns.toEqualTypeOf() +}) + +test('when there is one loader that is async', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + loader: () => Promise.resolve({ data: ['element1', 'element2'] }), + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useLoaderData, + ).returns.toEqualTypeOf<{ data: Array }>() + + expectTypeOf( + useLoaderData, + ).returns.toEqualTypeOf<{ data: Array }>() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf( + useLoaderData, + ).returns.toEqualTypeOf<{ data?: Array }>() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf( + useLoaderData, + ).returns.toEqualTypeOf() +}) + +test('when there are multiple loaders', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + loader: () => ({ data: ['invoice1', 'invoice2'] }) as const, + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + loader: () => ({ data: ['post1', 'post2'] }) as const, + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + postsRoute, + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useLoaderData).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useLoaderData, + ).returns.toEqualTypeOf<{ + readonly data: readonly ['invoice1', 'invoice2'] + }>() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf( + useLoaderData, + ).returns.toEqualTypeOf<{ + data?: + | readonly ['invoice1', 'invoice2'] + | readonly ['post1', 'post2'] + | undefined + }>() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .parameter(0) + .toEqualTypeOf<{ + data?: + | readonly ['invoice1', 'invoice2'] + | readonly ['post1', 'post2'] + | undefined + }>() + + expectTypeOf( + useLoaderData void }>, + ) + .parameter(0) + .exclude() + .toHaveProperty('select') + .exclude() + .returns.toEqualTypeOf<{ func: () => void }>() + + expectTypeOf( + useLoaderData void }>, + ) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf( + useLoaderData void }, true>, + ) + .parameter(0) + .exclude() + .toHaveProperty('select') + .exclude() + .returns.toEqualTypeOf<{ func: 'Function is not serializable' }>() + + expectTypeOf( + useLoaderData void }, true>, + ) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + const routerWithStructuralSharing = createRouter({ + routeTree, + defaultStructuralSharing: true, + }) + + expectTypeOf( + useLoaderData< + typeof routerWithStructuralSharing, + '/invoices', + true, + { func: () => void }, + true + >, + ) + .parameter(0) + .exclude() + .toHaveProperty('select') + .exclude() + .returns.toEqualTypeOf<{ + func: 'Function is not serializable' + }>() + + expectTypeOf( + useLoaderData< + typeof routerWithStructuralSharing, + '/invoices', + true, + { func: () => void }, + true + >, + ) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) + +test('when there are multiple loaders of objects and primtives', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + loader: () => ['invoice1', 'invoice2'] as const, + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + loader: () => ({ invoice: { id: 1 } }) as const, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + loader: () => ['post1', 'post2'] as const, + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + postsRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useLoaderData).returns.toEqualTypeOf<{}>() + + expectTypeOf(useLoaderData).returns.toEqualTypeOf< + readonly ['invoice1', 'invoice2'] + >() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .parameter(0) + .toEqualTypeOf() + + expectTypeOf( + useLoaderData, + ).returns.toEqualTypeOf< + | readonly ['invoice1', 'invoice2'] + | readonly ['post1', 'post2'] + | { + invoice?: + | { + readonly id: 1 + } + | undefined + } + >() + + expectTypeOf(useLoaderData) + .parameter(0) + .toHaveProperty('select') + .parameter(0) + .toEqualTypeOf< + | readonly ['invoice1', 'invoice2'] + | readonly ['post1', 'post2'] + | { + invoice?: + | { + readonly id: 1 + } + | undefined + } + >() +}) diff --git a/packages/solid-router/tests/useLocation.test-d.tsx b/packages/solid-router/tests/useLocation.test-d.tsx new file mode 100644 index 00000000000..5989a492caf --- /dev/null +++ b/packages/solid-router/tests/useLocation.test-d.tsx @@ -0,0 +1,38 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRoute, createRouter, useLocation } from '../src' +import type { ParsedLocation } from '../src' + +const rootRoute = createRootRoute() + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const routeTree = rootRoute.addChildren([invoicesRoute, indexRoute]) + +const defaultRouter = createRouter({ routeTree }) + +type DefaultRouter = typeof defaultRouter + +test('should have the types for a ParsedLocation in useLocation', () => { + const location = useLocation() + + expectTypeOf(location).toEqualTypeOf() + expectTypeOf(location) + .toHaveProperty('pathname') + .toEqualTypeOf() +}) + +test('should have the type of string for selecting the pathname in useLocation', () => { + const pathname = useLocation({ + select: (state) => state.pathname, + }) + + expectTypeOf(pathname).toMatchTypeOf() +}) diff --git a/packages/solid-router/tests/useMatch.test-d.tsx b/packages/solid-router/tests/useMatch.test-d.tsx new file mode 100644 index 00000000000..c282a69d0c4 --- /dev/null +++ b/packages/solid-router/tests/useMatch.test-d.tsx @@ -0,0 +1,79 @@ +import { describe, expectTypeOf, test } from 'vitest' +import { createRootRoute, createRoute, createRouter, useMatch } from '../src' +import type { MakeRouteMatch, MakeRouteMatchUnion } from '../src/Matches' + +const rootRoute = createRootRoute() + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const routeTree = rootRoute.addChildren([invoicesRoute, indexRoute]) + +// eslint-disable-next-line unused-imports/no-unused-vars +const defaultRouter = createRouter({ routeTree }) + +type DefaultRouter = typeof defaultRouter + +type TRouteMatch = MakeRouteMatch + +describe('useMatch', () => { + describe('shouldThrow', () => { + const from = '/invoices' + test('return type is `RouteMatch` when shouldThrow = true', () => { + const shouldThrow = true + const match = useMatch< + DefaultRouter, + typeof from, + true, // TStrict + typeof shouldThrow, + TRouteMatch + >({ from, shouldThrow }) + + expectTypeOf(match).toEqualTypeOf() + }) + + test('return type is `RouteMatch | undefined` when shouldThrow = false', () => { + const shouldThrow = false + const match = useMatch< + DefaultRouter, + typeof from, + true, // TStrict + typeof shouldThrow, + TRouteMatch + >({ from, shouldThrow }) + + expectTypeOf(match).toEqualTypeOf() + }) + }) + + test('return type is union of matches when strict = false', () => { + const strict = false as const + const match = useMatch({ + strict, + }) + + expectTypeOf(match).toEqualTypeOf>() + }) + + test('shouldThrow must be false when strict is false', () => { + const strict = false as const + const shouldThrow = true as const + useMatch< + DefaultRouter, + typeof undefined, + typeof strict, + typeof shouldThrow + >({ + strict, + // @ts-expect-error shouldThrow must be false when strict is false + shouldThrow, + }) + }) +}) diff --git a/packages/solid-router/tests/useMatch.test.tsx b/packages/solid-router/tests/useMatch.test.tsx new file mode 100644 index 00000000000..a5d87e27261 --- /dev/null +++ b/packages/solid-router/tests/useMatch.test.tsx @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useMatch, +} from '../src' +import type { RouteComponent, RouterHistory } from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +describe('useMatch', () => { + function setup({ + RootComponent, + history, + }: { + RootComponent: RouteComponent + history?: RouterHistory + }) { + const rootRoute = createRootRoute({ + component: RootComponent, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

IndexTitle

+ Posts + + ), + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

PostsTitle

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history, + }) + + return render(() => ) + } + + describe('when match is found', () => { + test.each([true, false, undefined])( + 'returns the match if shouldThrow = %s', + async (shouldThrow) => { + function RootComponent() { + const match = useMatch({ from: '/posts', shouldThrow }) + expect(match).toBeDefined() + expect(match!.routeId).toBe('/posts') + return + } + + setup({ + RootComponent, + history: createMemoryHistory({ initialEntries: ['/posts'] }), + }) + const postsTitle = await screen.findByText('PostsTitle') + expect(postsTitle).toBeInTheDocument() + }, + ) + }) + + describe('when match is not found', () => { + test.each([undefined, true])( + 'throws if shouldThrow = %s', + async (shouldThrow) => { + function RootComponent() { + useMatch({ from: '/posts', shouldThrow }) + return + } + setup({ RootComponent }) + const postsError = await screen.findByText( + 'Invariant failed: Could not find an active match from "/posts"', + ) + expect(postsError).toBeInTheDocument() + }, + ) + + describe('returns undefined if shouldThrow = false', () => { + test('without select function', async () => { + function RootComponent() { + const match = useMatch({ from: 'posts', shouldThrow: false }) + expect(match).toBeUndefined() + return + } + setup({ RootComponent }) + expect( + await waitFor(() => screen.findByText('IndexTitle')), + ).toBeInTheDocument() + }) + test('with select function', async () => { + const select = vi.fn() + function RootComponent() { + const match = useMatch({ from: 'posts', shouldThrow: false, select }) + expect(match).toBeUndefined() + return + } + setup({ RootComponent }) + const indexTitle = await screen.findByText('IndexTitle') + expect(indexTitle).toBeInTheDocument() + expect(select).not.toHaveBeenCalled() + }) + }) + }) +}) diff --git a/packages/solid-router/tests/useNavigate.test-d.tsx b/packages/solid-router/tests/useNavigate.test-d.tsx new file mode 100644 index 00000000000..7c19c09887c --- /dev/null +++ b/packages/solid-router/tests/useNavigate.test-d.tsx @@ -0,0 +1,67 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRoute, createRouter, useNavigate } from '../src' + +const rootRoute = createRootRoute() + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +test('when navigating to a route', () => { + const navigate = useNavigate() + + expectTypeOf(navigate) + .parameter(0) + .toHaveProperty('to') + .toEqualTypeOf< + '/' | '/invoices' | '/invoices/$invoiceId' | '.' | '..' | undefined + >() +}) + +test('when setting a default from', () => { + expectTypeOf(useNavigate) + .parameter(0) + .exclude() + .toHaveProperty('from') + .toEqualTypeOf< + '/invoices' | '/' | '/invoices/$invoiceId' | '/invoices/' | undefined + >() +}) + +test('when setting an invalid default from', () => { + expectTypeOf(useNavigate) + .parameter(0) + .exclude() + .toHaveProperty('from') + .toEqualTypeOf< + '/invoices' | '/' | '/invoices/$invoiceId' | '/invoices/' | undefined + >() +}) diff --git a/packages/solid-router/tests/useNavigate.test.tsx b/packages/solid-router/tests/useNavigate.test.tsx new file mode 100644 index 00000000000..f5e51c5e557 --- /dev/null +++ b/packages/solid-router/tests/useNavigate.test.tsx @@ -0,0 +1,1295 @@ +import '@testing-library/jest-dom/vitest' +import { afterEach, expect, test } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' + +import { z } from 'zod' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouteMask, + createRouter, + useNavigate, + useParams, +} from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +test('when navigating to /posts', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + return ( + <> +

Posts

+ + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect( + await screen.findByRole('heading', { name: 'Posts' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/posts') +}) + +test('when navigating from /posts to ./$postId', async () => { + const rootRoute = createRootRoute() + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostsIndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Posts Index

+ + + ) + } + + const postsIndexRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '/', + component: PostsIndexComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + const navigate = useNavigate() + return ( + <> + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postsIndexRoute, postRoute]), + ]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect(await screen.findByText('Posts Index')).toBeInTheDocument() + + const postButton = await screen.findByRole('button', { + name: 'To the first post', + }) + + fireEvent.click(postButton) + + expect(await screen.findByText('Params: id1')).toBeInTheDocument() + + expect(window.location.pathname).toBe('/posts/id1') +}) + +test('when navigating from /posts to ../posts/$postId', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostsIndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Posts Index

+ + + ) + } + + const postsIndexRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '/', + component: PostsIndexComponent, + }) + + const PostComponent = () => { + const navigate = useNavigate() + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postsIndexRoute, postRoute]), + ]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { name: 'Posts' }) + + fireEvent.click(postsButton) + + expect(await screen.findByText('Posts Index')).toBeInTheDocument() + + const postButton = await screen.findByRole('button', { + name: 'To the first post', + }) + + fireEvent.click(postButton) + + expect(await screen.findByText('Params: id1')).toBeInTheDocument() +}) + +test('when navigating from /posts/$postId to /posts/$postId/info and the current route is /posts/$postId/details', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const DetailsComponent = () => { + const navigate = useNavigate() + return ( + <> +

Details!

+ + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return ( + <> +

Information

+ + ) + } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([detailsRoute, informationRoute]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { + name: 'To first post', + }) + + fireEvent.click(postsButton) + + expect(await screen.findByText('Params: id1')).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const informationButton = await screen.findByRole('button', { + name: 'To Information', + }) + + fireEvent.click(informationButton) + + expect(await screen.findByText('Information')).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/info') + + expect(await screen.findByText('Params: id1')) +}) + +test('when navigating from /posts/$postId to ./info and the current route is /posts/$postId/details', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const DetailsComponent = () => { + const navigate = useNavigate() + return ( + <> +

Details!

+ + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return ( + <> +

Information

+ + ) + } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([detailsRoute, informationRoute]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { + name: 'To first post', + }) + + fireEvent.click(postsButton) + + expect(await screen.findByText('Params: id1')).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const informationButton = await screen.findByRole('button', { + name: 'To Information', + }) + + fireEvent.click(informationButton) + + expect(await screen.findByText('Information')).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/info') + + expect(await screen.findByText('Params: id1')) +}) + +test('when navigating from /posts/$postId to ../$postId and the current route is /posts/$postId/details', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const DetailsComponent = () => { + const navigate = useNavigate() + return ( + <> +

Details!

+ + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return ( + <> +

Information

+ + ) + } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([detailsRoute, informationRoute]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { + name: 'To first post', + }) + + fireEvent.click(postsButton) + + expect(await screen.findByText('Params: id1')).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const postButton = await screen.findByRole('button', { + name: 'To Post', + }) + + fireEvent.click(postButton) + + expect(await screen.findByText('Posts')).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1') +}) + +test('when navigating from /posts/$postId with an index to ../$postId and the current route is /posts/$postId/details', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const postIndexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + component: () =>

Post Index

, + }) + + const DetailsComponent = () => { + const navigate = useNavigate() + return ( + <> +

Details!

+ + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return ( + <> +

Information

+ + ) + } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([ + postIndexRoute, + detailsRoute, + informationRoute, + ]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { + name: 'To first post', + }) + + fireEvent.click(postsButton) + + expect(await screen.findByText('Params: id1')).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const postButton = await screen.findByRole('button', { + name: 'To Post', + }) + + fireEvent.click(postButton) + + expect(await screen.findByText('Posts')).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1') +}) + +test('when navigating from /invoices to ./invoiceId and the current route is /posts/$postId/details', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const DetailsComponent = () => { + const navigate = useNavigate() + const [error, setError] = React.useState() + return ( + <> +

Details!

+ + Something went wrong! + + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const InformationComponent = () => { + return ( + <> +

Information

+ + ) + } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + component: () => ( + <> +

Invoices!

+ + + ), + }) + + const InvoiceComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + invoiceId: {params.invoiceId} + + ) + } + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + component: InvoiceComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute]), + postsRoute.addChildren([ + postRoute.addChildren([detailsRoute, informationRoute]), + ]), + ]), + ]), + }) + + render(() => ) + + const postsButton = await screen.findByRole('button', { + name: 'To first post', + }) + + fireEvent.click(postsButton) + + const invoicesButton = await screen.findByRole('button', { + name: 'To Invoices', + }) + + fireEvent.click(invoicesButton) + + expect(await screen.findByText('Something went wrong!')).toBeInTheDocument() +}) + +test('when navigating to /posts/$postId/info which is masked as /posts/$postId', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const InformationComponent = () => { + return ( + <> +

Information

+ + ) + } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute.addChildren([informationRoute])]), + ]) + + const routeMask = createRouteMask({ + routeTree, + from: '/posts/$postId/info', + to: '/posts', + }) + + const router = createRouter({ + routeTree, + routeMasks: [routeMask], + }) + + render(() => ) + + const postButton = await screen.findByRole('button', { + name: 'To first post', + }) + + fireEvent.click(postButton) + + expect(await screen.findByText('Params: id1')) +}) + +test('when navigating to /posts/$postId/info which is imperatively masked as /posts/$postId', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const InformationComponent = () => { + return ( + <> +

Information

+ + ) + } + + const informationRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'info', + component: InformationComponent, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute.addChildren([informationRoute])]), + ]) + + const router = createRouter({ + routeTree, + }) + + render(() => ) + + const postButton = await screen.findByRole('button', { + name: 'To first post', + }) + + fireEvent.click(postButton) + + expect(await screen.findByText('Information')).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1') +}) + +test('when setting search params with 2 parallel navigate calls', async () => { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + const search = indexRoute.useSearch() + return ( + <> +

Index

+
{search.param1}
+
{search.param2}
+ + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().default('param1-default'), + param2: z.string().default('param2-default'), + }), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + }) + + render(() => ) + expect(router.state.location.search).toEqual({ + param1: 'param1-default', + param2: 'param2-default', + }) + + const postsButton = await screen.findByRole('button', { name: 'search' }) + + fireEvent.click(postsButton) + + expect(await screen.findByTestId('param1')).toHaveTextContent('foo') + expect(await screen.findByTestId('param2')).toHaveTextContent('bar') + expect(router.state.location.search).toEqual({ param1: 'foo', param2: 'bar' }) + const search = new URLSearchParams(window.location.search) + expect(search.get('param1')).toEqual('foo') + expect(search.get('param2')).toEqual('bar') +}) diff --git a/packages/solid-router/tests/useParams.test-d.tsx b/packages/solid-router/tests/useParams.test-d.tsx new file mode 100644 index 00000000000..4b4f13162a8 --- /dev/null +++ b/packages/solid-router/tests/useParams.test-d.tsx @@ -0,0 +1,274 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRoute, createRouter, useParams } from '../src' + +test('when there are no params', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useParams) + .parameter(0) + .toHaveProperty('from') + .toEqualTypeOf<'/invoices' | '__root__' | '/invoices/' | '/'>() + + expectTypeOf(useParams) + .parameter(0) + .toHaveProperty('strict') + .toEqualTypeOf() + + expectTypeOf(useParams) + .parameter(0) + .toHaveProperty('select') + .parameter(0) + .toEqualTypeOf<{}>() + + expectTypeOf(useParams) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf(useParams).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useParams({ + strict: false, + }), + ).toEqualTypeOf<{}>() +}) + +test('when there is one param', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useParams).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useParams, + ).returns.toEqualTypeOf<{ invoiceId: string }>() + + expectTypeOf(useParams) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { invoiceId: string }) => unknown) | undefined>() + + expectTypeOf( + useParams, + ).returns.toEqualTypeOf<{ invoiceId?: string }>() + + expectTypeOf(useParams) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { invoiceId?: string }) => unknown) | undefined>() + + expectTypeOf( + useParams, + ).returns.toEqualTypeOf() +}) + +test('when there are multiple params', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + }) + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([ + invoicesIndexRoute, + invoiceRoute, + postsRoute.addChildren([postRoute]), + ]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useParams).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useParams, + ).returns.toEqualTypeOf<{ invoiceId: string }>() + + expectTypeOf(useParams) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { invoiceId: string }) => unknown) | undefined>() + + expectTypeOf( + useParams, + ).returns.toEqualTypeOf<{ invoiceId?: string; postId?: string }>() + + expectTypeOf(useParams) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + ((search: { invoiceId?: string; postId?: string }) => unknown) | undefined + >() + + expectTypeOf( + useParams void }>, + ) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: {}) => { + func: () => void + }) + | undefined + >() + + expectTypeOf( + useParams void }>, + ) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf( + useParams void }, true>, + ) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: {}) => { + func: 'Function is not serializable' + }) + | undefined + >() + + expectTypeOf( + useParams void }, true>, + ) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + const routerWithStructuralSharing = createRouter({ + routeTree, + defaultStructuralSharing: true, + }) + + expectTypeOf( + useParams< + typeof routerWithStructuralSharing, + '/invoices', + true, + { func: () => void }, + true + >, + ) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: {}) => { + func: 'Function is not serializable' + }) + | undefined + >() + + expectTypeOf( + useParams< + typeof routerWithStructuralSharing, + '/invoices', + true, + { func: () => void }, + true + >, + ) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) diff --git a/packages/solid-router/tests/useRouteContext.test-d.tsx b/packages/solid-router/tests/useRouteContext.test-d.tsx new file mode 100644 index 00000000000..7d0a14ef4a8 --- /dev/null +++ b/packages/solid-router/tests/useRouteContext.test-d.tsx @@ -0,0 +1,313 @@ +import { expectTypeOf, test } from 'vitest' +import { + createRootRoute, + createRootRouteWithContext, + createRoute, + createRouter, + useRouteContext, +} from '../src' + +test('when there is no context', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useRouteContext) + .parameter(0) + .toHaveProperty('from') + .toEqualTypeOf<'/invoices' | '__root__' | '/invoices/' | '/'>() + + expectTypeOf(useRouteContext) + .parameter(0) + .toHaveProperty('strict') + .toEqualTypeOf() + + expectTypeOf(useRouteContext) + .parameter(0) + .toHaveProperty('select') + .parameter(0) + .toEqualTypeOf<{}>() + + expectTypeOf(useRouteContext) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf(useRouteContext).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useRouteContext({ + strict: false, + }), + ).toEqualTypeOf<{}>() +}) + +test('when there is the root context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useRouteContext).returns.toEqualTypeOf<{ + userId: string + }>() + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ userId: string }>() + + expectTypeOf(useRouteContext) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { userId: string }) => unknown) | undefined>() + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ userId?: string }>() + + expectTypeOf(useRouteContext) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { userId?: string }) => unknown) | undefined>() + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf() +}) + +test('when there are multiple contexts', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + }) + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + beforeLoad: () => ({ username: 'username' }), + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([ + invoicesIndexRoute, + invoiceRoute, + postsRoute.addChildren([postRoute]), + ]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useRouteContext).returns.toEqualTypeOf<{ + userId: string + }>() + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ userId: string }>() + + expectTypeOf(useRouteContext) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { userId: string }) => unknown) | undefined>() + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ userId?: string; username?: string }>() + + expectTypeOf(useRouteContext) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + ((search: { userId?: string; username?: string }) => unknown) | undefined + >() +}) + +test('when there are overlapping contexts', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: () => ({ page: 0 }), + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + beforeLoad: () => ({ username: 'username2' }) as const, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + }) + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + beforeLoad: () => ({ username: 'username1' }) as const, + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([ + invoicesIndexRoute, + invoiceRoute, + postsRoute.addChildren([postRoute]), + ]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useRouteContext).returns.toEqualTypeOf<{ + userId: string + }> + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId: string + readonly username: 'username2' + }>() + + expectTypeOf(useRouteContext) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: { + userId: string + readonly username: 'username2' + }) => unknown) + | undefined + >() + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId?: string + username?: 'username1' | 'username2' + }>() + + expectTypeOf(useRouteContext) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: { + userId?: string + username?: 'username2' | 'username1' + }) => unknown) + | undefined + >() +}) diff --git a/packages/solid-router/tests/useRouterState.test-d.tsx b/packages/solid-router/tests/useRouterState.test-d.tsx new file mode 100644 index 00000000000..c91a8005a26 --- /dev/null +++ b/packages/solid-router/tests/useRouterState.test-d.tsx @@ -0,0 +1,114 @@ +import { expectTypeOf, test } from 'vitest' +import { + createRootRoute, + createRoute, + createRouter, + useRouterState, +} from '../src' +import type { RouterState } from '../src' + +const rootRoute = createRootRoute({ + validateSearch: () => ({ + page: 0, + }), +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', +}) + +const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', +}) + +const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', +}) + +const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ page: 0 }), +}) + +const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, +]) + +const defaultRouter = createRouter({ + routeTree, +}) + +type DefaultRouter = typeof defaultRouter + +test('can select router state', () => { + expectTypeOf(useRouterState) + .returns.toHaveProperty('location') + .toMatchTypeOf<{ + search: { page?: number | undefined } + }>() + + expectTypeOf(useRouterState void }>) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: RouterState) => { + func: () => void + }) + | undefined + >() + + expectTypeOf(useRouterState void }>) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf(useRouterState void }, true>) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: RouterState) => { + func: 'Function is not serializable' + }) + | undefined + >() + + expectTypeOf(useRouterState void }, true>) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + const routerWithStructuralSharing = createRouter({ + routeTree, + defaultStructuralSharing: true, + }) + + expectTypeOf( + useRouterState void }>, + ) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: RouterState) => { + func: 'Function is not serializable' + }) + | undefined + >() + + expectTypeOf( + useRouterState void }>, + ) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) diff --git a/packages/solid-router/tests/useSearch.test-d.tsx b/packages/solid-router/tests/useSearch.test-d.tsx new file mode 100644 index 00000000000..7908b9aac6a --- /dev/null +++ b/packages/solid-router/tests/useSearch.test-d.tsx @@ -0,0 +1,610 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRoute, createRouter, useSearch } from '../src' +import type { SearchSchemaInput } from '../src' + +test('when there are no search params', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('from') + .toEqualTypeOf< + '/invoices' | '__root__' | '/invoices/$invoiceId' | '/invoices/' | '/' + >() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('strict') + .toEqualTypeOf() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .parameter(0) + .toEqualTypeOf<{}>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .returns.toEqualTypeOf() + + expectTypeOf(useSearch).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useSearch({ + strict: false, + }), + ).toEqualTypeOf<{}>() +}) + +test('when there is one search params', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: () => ({ page: 0 }), + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useSearch).returns.toEqualTypeOf<{}>() + + expectTypeOf(useSearch).returns.toEqualTypeOf<{ + page: number + }>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { page: number }) => unknown) | undefined>() + + expectTypeOf( + useSearch, + ).returns.toEqualTypeOf<{ page?: number }>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { page?: number }) => unknown) | undefined>() + + expectTypeOf( + useSearch, + ).returns.toEqualTypeOf() + + expectTypeOf( + useSearch void }>, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + ((search: { page?: number }) => { func: () => void }) | undefined + >() + + expectTypeOf( + useSearch void }>, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf( + useSearch void }, true>, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: { page?: number }) => { + func: 'Function is not serializable' + }) + | undefined + >() + + expectTypeOf( + useSearch void }, true>, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: { page?: number }) => { + hi: never + }) + | undefined + >() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + const routerWithStructuralSharing = createRouter({ + routeTree, + defaultStructuralSharing: true, + }) + + expectTypeOf( + useSearch< + typeof routerWithStructuralSharing, + '/invoices', + false, + { func: () => void } + >, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: { page?: number }) => { + func: 'Function is not serializable' + }) + | undefined + >() + + expectTypeOf( + useSearch< + typeof routerWithStructuralSharing, + '/invoices', + false, + { date: () => void }, + true + >, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() + + expectTypeOf( + useSearch< + typeof routerWithStructuralSharing, + '/invoices', + false, + { hi: any }, + true + >, + ) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: { page?: number }) => { + hi: never + }) + | undefined + >() + + expectTypeOf( + useSearch< + typeof routerWithStructuralSharing, + '/invoices', + false, + { hi: any }, + true + >, + ) + .parameter(0) + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) + +test('when there are multiple search params', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: () => ({ page: 0 }), + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ detail: 'detail' }), + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useSearch).returns.toEqualTypeOf<{}>() + + expectTypeOf(useSearch).returns.toEqualTypeOf<{ + page: number + }>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { page: number }) => unknown) | undefined>() + + expectTypeOf( + useSearch, + ).returns.toEqualTypeOf<{ page?: number; detail?: string }>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + ((search: { page?: number; detail?: string }) => unknown) | undefined + >() +}) + +test('when there are overlapping search params', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: () => ({ page: 0 }), + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + validateSearch: () => ({ detail: 50 }) as const, + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + validateSearch: () => ({ detail: 'detail' }) as const, + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useSearch).returns.toEqualTypeOf<{}>() + + expectTypeOf(useSearch).returns.toEqualTypeOf<{ + page: number + }>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { page: number }) => unknown) | undefined>() + + expectTypeOf( + useSearch, + ).returns.toEqualTypeOf<{ page?: number; detail?: 'detail' | 50 }>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: { page?: number; detail?: 'detail' | 50 }) => unknown) + | undefined + >() +}) + +test('when the root has no search params but the index route does', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: () => ({ indexPage: 0 }), + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useSearch).returns.toEqualTypeOf<{ + indexPage: number + }>() + + expectTypeOf(useSearch).returns.toEqualTypeOf<{}>() + + expectTypeOf( + useSearch, + ).returns.toEqualTypeOf<{}>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: {}) => unknown) | undefined>() + + expectTypeOf( + useSearch, + ).returns.toEqualTypeOf<{ indexPage?: number }>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { indexPage?: number }) => unknown) | undefined>() +}) + +test('when the root has search params but the index route does not', () => { + const rootRoute = createRootRoute({ + validateSearch: () => ({ rootPage: 0 }), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useSearch).returns.toEqualTypeOf<{ + rootPage: number + }>() + + expectTypeOf(useSearch).returns.toEqualTypeOf<{ + rootPage: number + }>() + + expectTypeOf(useSearch).returns.toEqualTypeOf<{ + rootPage: number + }>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { rootPage: number }) => unknown) | undefined>() + + expectTypeOf( + useSearch, + ).returns.toEqualTypeOf<{ rootPage?: number }>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { rootPage?: number }) => unknown) | undefined>() +}) + +test('when the root has search params but the index does', () => { + const rootRoute = createRootRoute({ + validateSearch: () => ({ rootPage: 0 }), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: () => ({ indexPage: 0 }), + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + }) + + const invoicesIndexRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '/', + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]), + indexRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf(useSearch).returns.toEqualTypeOf<{ + rootPage: number + indexPage: number + }>() + + expectTypeOf(useSearch).returns.toEqualTypeOf<{ + rootPage: number + }>() + + expectTypeOf(useSearch).returns.toEqualTypeOf<{ + rootPage: number + }>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf<((search: { rootPage: number }) => unknown) | undefined>() + + expectTypeOf( + useSearch, + ).returns.toEqualTypeOf<{ indexPage?: number; rootPage?: number }>() + + expectTypeOf(useSearch) + .parameter(0) + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: { indexPage?: number; rootPage?: number }) => unknown) + | undefined + >() +}) + +test('when a route has search params using SearchSchemaInput', () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (input: { page?: number } & SearchSchemaInput) => { + return { page: input.page ?? 0 } + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ routeTree }) + expectTypeOf(useSearch).returns.toEqualTypeOf<{ + page: number + }> +}) + +test('when route has a union of search params', () => { + const rootRoute = createRootRoute() + + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (): { status: 'in' } | { status: 'out' } => { + return { status: 'in' } + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + validateSearch: (): { detail: string } => { + return { detail: 'detail' } + }, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute.addChildren([indexRoute]), + ]) + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ routeTree }) + expectTypeOf(useSearch).returns.toEqualTypeOf< + { status: 'in'; detail: string } | { status: 'out'; detail: string } + > +}) diff --git a/packages/solid-router/tests/utils.ts b/packages/solid-router/tests/utils.ts new file mode 100644 index 00000000000..cdc7ff82bd0 --- /dev/null +++ b/packages/solid-router/tests/utils.ts @@ -0,0 +1,56 @@ +import type { Mock } from 'vitest' + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export function createTimer() { + let time = Date.now() + + return { + start: () => { + time = Date.now() + }, + getTime: () => { + return Date.now() - time + }, + } +} + +export const getIntersectionObserverMock = ({ + observe, + disconnect, +}: { + observe: Mock + disconnect: Mock +}) => { + return class IO implements IntersectionObserver { + root: Document | Element | null + rootMargin: string + thresholds: Array + constructor( + _cb: IntersectionObserverCallback, + options?: IntersectionObserverInit, + ) { + this.root = options?.root ?? null + this.rootMargin = options?.rootMargin ?? '0px' + this.thresholds = options?.threshold ?? ([0] as any) + } + + takeRecords(): Array { + return [] + } + unobserve(): void {} + observe(): void { + observe() + } + disconnect(): void { + disconnect() + } + } +} + +export function getSearchParamsFromURI(uri: string) { + const [, paramString] = uri.split('?') + return new URLSearchParams(paramString) +} diff --git a/packages/solid-router/tsconfig.build.json b/packages/solid-router/tsconfig.build.json new file mode 100644 index 00000000000..dc6b45064ef --- /dev/null +++ b/packages/solid-router/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "moduleResolution": "Bundler", + "module": "preserve", + "rootDir": "src", + "outDir": "dist/source", + "noEmit": false, + "declaration": true, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/packages/solid-router/tsconfig.json b/packages/solid-router/tsconfig.json new file mode 100644 index 00000000000..fde0e36cdaa --- /dev/null +++ b/packages/solid-router/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js" + }, + "include": [ + "src", + "tests", + "vite.config.ts", + "eslint.config.ts", + "../start/src/client/DehydrateRouter.tsx" + ] +} diff --git a/packages/solid-router/tsconfig.legacy.json b/packages/solid-router/tsconfig.legacy.json new file mode 100644 index 00000000000..b90fc83e04c --- /dev/null +++ b/packages/solid-router/tsconfig.legacy.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"] +} diff --git a/packages/solid-router/vite.config.ts b/packages/solid-router/vite.config.ts new file mode 100644 index 00000000000..3a3a4f2edff --- /dev/null +++ b/packages/solid-router/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/config/vite' +import solid from 'vite-plugin-solid' +import packageJson from './package.json' +import type { UserConfig } from 'vitest/config' + +const config = defineConfig({ + plugins: [solid()] as UserConfig['plugins'], + test: { + name: packageJson.name, + dir: './tests', + watch: false, + environment: 'jsdom', + typecheck: { enabled: true }, + setupFiles: ['./tests/setupTests.tsx'], + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: './src/index.tsx', + srcDir: './src', + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9539189e7da..dc01fda6688 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,7 +68,7 @@ importers: version: 25.0.1 nx: specifier: ^20.3.0 - version: 20.3.0(@swc/core@1.10.1(@swc/helpers@0.5.15)) + version: 20.3.0(@swc/core@1.10.1) prettier: specifier: ^3.4.2 version: 3.4.2 @@ -113,7 +113,7 @@ importers: version: 6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1) vitest: specifier: ^2.1.8 - version: 2.1.8(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(msw@2.6.8(@types/node@22.10.2)(typescript@5.7.2))(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1) + version: 2.1.8(@types/node@22.10.2)(@vitest/browser@2.1.8)(@vitest/ui@2.1.8)(jiti@2.4.1)(jsdom@25.0.1)(msw@2.6.8(@types/node@22.10.2)(typescript@5.7.2))(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1) e2e/react-router/basic: dependencies: @@ -2004,10 +2004,10 @@ importers: version: 18.3.1 html-webpack-plugin: specifier: ^5.6.3 - version: 5.6.3(@rspack/core@1.1.8(@swc/helpers@0.5.15))(webpack@5.97.1) + version: 5.6.3(@rspack/core@1.1.8(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)) swc-loader: specifier: ^0.2.6 - version: 0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1) + version: 0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)) typescript: specifier: ^5.7.2 version: 5.7.2 @@ -3234,7 +3234,7 @@ importers: version: 7.0.6 html-webpack-plugin: specifier: ^5.6.0 - version: 5.6.3(@rspack/core@1.1.8(@swc/helpers@0.5.15))(webpack@5.97.1) + version: 5.6.3(@rspack/core@1.1.8(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)) picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -3246,7 +3246,7 @@ importers: version: 18.3.1(react@18.3.1) swc-loader: specifier: ^0.2.6 - version: 0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1) + version: 0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)) tinyglobby: specifier: ^0.2.10 version: 0.2.10 @@ -3477,6 +3477,15 @@ importers: specifier: ^17.0.33 version: 17.0.33 + packages/router-core: + dependencies: + '@tanstack/history': + specifier: workspace:* + version: link:../history + '@tanstack/store': + specifier: ^0.6.0 + version: 0.6.0 + packages/router-devtools: dependencies: '@tanstack/react-router': @@ -3578,7 +3587,7 @@ importers: version: 6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1) webpack: specifier: '>=5.92.0' - version: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2) + version: 5.97.1(@swc/core@1.10.1)(esbuild@0.24.2) zod: specifier: ^3.23.8 version: 3.23.8 @@ -3589,6 +3598,67 @@ importers: specifier: workspace:* version: link:../router-plugin + packages/solid-router: + dependencies: + '@solid-devtools/logger': + specifier: ^0.9.4 + version: 0.9.4(solid-js@1.9.3) + '@solid-primitives/refs': + specifier: ^1.0.8 + version: 1.0.8(solid-js@1.9.3) + '@tanstack/history': + specifier: workspace:* + version: link:../history + '@tanstack/router-core': + specifier: workspace:* + version: link:../router-core + '@tanstack/solid-store': + specifier: ^0.6.0 + version: 0.6.0(solid-js@1.9.3) + '@tanstack/store': + specifier: ^0.6.0 + version: 0.6.0 + jsesc: + specifier: ^3.0.2 + version: 3.0.2 + tiny-invariant: + specifier: ^1.3.3 + version: 1.3.3 + tiny-warning: + specifier: ^1.0.3 + version: 1.0.3 + devDependencies: + '@solidjs/testing-library': + specifier: ^0.8.10 + version: 0.8.10(solid-js@1.9.3) + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.6.3 + '@types/jsesc': + specifier: ^3.0.3 + version: 3.0.3 + '@vitest/browser': + specifier: ^2.1.8 + version: 2.1.8(@types/node@22.10.2)(playwright@1.49.1)(typescript@5.7.2)(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1))(vitest@2.1.8) + '@vitest/ui': + specifier: 2.1.8 + version: 2.1.8(vitest@2.1.8) + combinate: + specifier: ^1.1.11 + version: 1.1.11 + solid-js: + specifier: ^1 + version: 1.9.3 + vite-plugin-solid: + specifier: 2.10.2 + version: 2.10.2(@testing-library/jest-dom@6.6.3)(solid-js@1.9.3)(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1)) + vitest: + specifier: ^2.1.8 + version: 2.1.8(@types/node@22.10.2)(@vitest/browser@2.1.8)(@vitest/ui@2.1.8)(jiti@2.4.1)(jsdom@25.0.1)(msw@2.6.8(@types/node@22.10.2)(typescript@5.7.2))(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1) + zod: + specifier: ^3.23.8 + version: 3.23.8 + packages/start: dependencies: '@tanstack/react-cross-context': @@ -3811,6 +3881,10 @@ packages: resolution: {integrity: sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.25.9': resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} engines: {node: '>=6.9.0'} @@ -5027,6 +5101,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@nothing-but/utils@0.17.0': + resolution: {integrity: sha512-TuCHcHLOqDL0SnaAxACfuRHBNRgNJcNn9X0GiH5H3YSDBVquCr3qEIG3FOQAuMyZCbu9w8nk2CHhOsn7IvhIwQ==} + '@nx/nx-darwin-arm64@20.3.0': resolution: {integrity: sha512-9PqSe1Sh7qNqA4GL0cZH0t3S0EZzb2Xn14XY9au7yf0+eoxyag1oETjjULrxLeUmSoXW2hDxzNtoqKFE9zF07Q==} engines: {node: '>= 10'} @@ -5204,6 +5281,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@polka/url@1.0.0-next.28': + resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + '@prisma/client@5.22.0': resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} engines: {node: '>=16.13'} @@ -5881,6 +5961,101 @@ packages: resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} + '@solid-devtools/debugger@0.24.4': + resolution: {integrity: sha512-tjoPPNqIFeNhDL5cAk8TMWtbZZUEnjcGhSD4Nbj9no0USFAF4z0+u84IiSZyK/oGZpcVh1Aan4zTdK1SLMK/Qg==} + peerDependencies: + solid-js: ^1.9.0 + + '@solid-devtools/logger@0.9.4': + resolution: {integrity: sha512-k8Nt9N2uTR6e1E+u/SuOeLszZE9sHriFdEMLfq8Xjutbu0FjbGQ7vHokVmaOXpbikQD7ro/B5jQUEW4JMuxKag==} + peerDependencies: + solid-js: ^1.9.0 + + '@solid-devtools/shared@0.16.1': + resolution: {integrity: sha512-wuNbhER510E+VFI+7XQ2vkOSDzTbHDt323yGdW5OZHf8qb/jDmtKBeQmbgeVLSn+ASMP4Hkg//JeVXZra2kTsg==} + peerDependencies: + solid-js: ^1.9.0 + + '@solid-primitives/bounds@0.0.122': + resolution: {integrity: sha512-kUq/IprOdFr/rg2upon5lQGOoTnDAmxQS4ASKK2l+VwoKSctdPwgu/4qJxEITZikL+nB0myYZzBZWptySV0cRg==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/cursor@0.0.115': + resolution: {integrity: sha512-8nEmUN/sacXPChwuJOAi6Yi6VnxthW/Jk8VGvvcF38AenjUvOA6FHI6AkJILuFXjQw1PGxia1YbH/Mn77dPiOA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/event-bus@1.0.11': + resolution: {integrity: sha512-bSwVA4aI2aNHomSbEroUnisMSyDDXJbrw4U8kFEvrcYdlLrJX5i6QeCFx+vj/zdQQw62KAllrEIyWP8KMpPVnQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/event-listener@2.3.3': + resolution: {integrity: sha512-DAJbl+F0wrFW2xmcV8dKMBhk9QLVLuBSW+TR4JmIfTaObxd13PuL7nqaXnaYKDWOYa6otB00qcCUIGbuIhSUgQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/keyboard@1.2.8': + resolution: {integrity: sha512-pJtcbkjozS6L1xvTht9rPpyPpX55nAkfBzbFWdf3y0Suwh6qClTibvvObzKOf7uzQ+8aZRDH4LsoGmbTKXtJjQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/media@2.2.9': + resolution: {integrity: sha512-QUmU62D4/d9YWx/4Dvr/UZasIkIpqNXz7wosA5GLmesRW9XlPa3G5M6uOmTw73SByHNTCw0D6x8bSdtvvLgzvQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/platform@0.1.2': + resolution: {integrity: sha512-sSxcZfuUrtxcwV0vdjmGnZQcflACzMfLriVeIIWXKp8hzaS3Or3tO6EFQkTd3L8T5dTq+kTtLvPscXIpL0Wzdg==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/refs@1.0.8': + resolution: {integrity: sha512-+jIsWG8/nYvhaCoG2Vg6CJOLgTmPKFbaCrNQKWfChalgUf9WrVxWw0CdJb3yX15n5lUcQ0jBo6qYtuVVmBLpBw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/resize-observer@2.0.26': + resolution: {integrity: sha512-KbPhwal6ML9OHeUTZszBbt6PYSMj89d4wVCLxlvDYL4U0+p+xlCEaqz6v9dkCwm/0Lb+Wed7W5T1dQZCP3JUUw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/rootless@1.4.5': + resolution: {integrity: sha512-GFJE9GC3ojx0aUKqAUZmQPyU8fOVMtnVNrkdk2yS4kd17WqVSpXpoTmo9CnOwA+PG7FTzdIkogvfLQSLs4lrww==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/scheduled@1.4.4': + resolution: {integrity: sha512-BTGdFP7t+s7RSak+s1u0eTix4lHP23MrbGkgQTFlt1E+4fmnD/bEx3ZfNW7Grylz3GXgKyXrgDKA7jQ/wuWKgA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/static-store@0.0.8': + resolution: {integrity: sha512-ZecE4BqY0oBk0YG00nzaAWO5Mjcny8Fc06CdbXadH9T9lzq/9GefqcSe/5AtdXqjvY/DtJ5C6CkcjPZO0o/eqg==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/styles@0.0.114': + resolution: {integrity: sha512-SFXr16mgr6LvZAIj6L7i59HHg+prAmIF8VP/U3C6jSHz68Eh1G71vaWr9vlJVpy/j6bh1N8QUzu5CgtvIC92OQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.2.3': + resolution: {integrity: sha512-CqAwKb2T5Vi72+rhebSsqNZ9o67buYRdEJrIFzRXz3U59QqezuuxPsyzTSVCacwS5Pf109VRsgCJQoxKRoECZQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solidjs/testing-library@0.8.10': + resolution: {integrity: sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ==} + engines: {node: '>= 14'} + peerDependencies: + '@solidjs/router': '>=0.9.0' + solid-js: '>=1.0.0' + peerDependenciesMeta: + '@solidjs/router': + optional: true + '@stylistic/eslint-plugin-js@2.12.1': resolution: {integrity: sha512-5ybogtEgWIGCR6dMnaabztbWyVdAPDsf/5XOk6jBonWug875Q9/a6gm9QxnU3rhdyDEnckWKX7dduwYJMOWrVA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6032,6 +6207,14 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/solid-store@0.6.0': + resolution: {integrity: sha512-2lkalYD/au4PiMWm7Q26FiwLW3DO1xRACY7cPwKMud8rPFlB4tV7SNPq1j41/wtRayFCkR2MOe1+msW1TmMvYw==} + peerDependencies: + solid-js: ^1.6.0 + + '@tanstack/store@0.6.0': + resolution: {integrity: sha512-+m2OBglsjXcLmmKOX6/9v8BDOCtyxhMmZLsRUDswOOSdIIR9mvv6i0XNKsmTh3AlYU8c1mRcodC8/Vyf+69VlQ==} + '@tanstack/store@0.7.0': resolution: {integrity: sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg==} @@ -6064,6 +6247,12 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.5.2': + resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@trpc/client@11.0.0-rc.660': resolution: {integrity: sha512-bNpkZEfyMGKHynYFxdLpY8nJ1n7E3JHKcd4Pe2cagmpkzOEF9tFT3kzNf+eLI8XMG8196lTRR0J0W2/1Q8/cug==} peerDependencies: @@ -6375,6 +6564,21 @@ packages: peerDependencies: vite: 6.0.3 + '@vitest/browser@2.1.8': + resolution: {integrity: sha512-OWVvEJThRgxlNMYNVLEK/9qVkpRcLvyuKLngIV3Hob01P56NjPHprVBYn+rx4xAJudbM9yrCrywPIEuA3Xyo8A==} + peerDependencies: + playwright: '*' + safaridriver: '*' + vitest: 2.1.8 + webdriverio: '*' + peerDependenciesMeta: + playwright: + optional: true + safaridriver: + optional: true + webdriverio: + optional: true + '@vitest/expect@2.1.8': resolution: {integrity: sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==} @@ -6401,6 +6605,11 @@ packages: '@vitest/spy@2.1.8': resolution: {integrity: sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==} + '@vitest/ui@2.1.8': + resolution: {integrity: sha512-5zPJ1fs0ixSVSs5+5V2XJjXLmNzjugHRyV11RqxYVR+oMcogZ9qTuSfKW+OcTV0JeFNznI83BNylzH6SSNJ1+w==} + peerDependencies: + vitest: 2.1.8 + '@vitest/utils@2.1.8': resolution: {integrity: sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==} @@ -6780,6 +6989,16 @@ packages: babel-plugin-add-module-exports@0.2.1: resolution: {integrity: sha512-3AN/9V/rKuv90NG65m4tTHsI04XrCKsWbztIcW7a8H5iIN7WlvWucRtVV0V/rT4QvtA11n5Vmp20fLwfMWqp6g==} + babel-plugin-jsx-dom-expressions@0.39.3: + resolution: {integrity: sha512-6RzmSu21zYPlV2gNwzjGG9FgODtt9hIWnx7L//OIioIEuRcnpDZoY8Tr+I81Cy1SrH4qoDyKpwHHo6uAMAeyPA==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-preset-solid@1.9.3: + resolution: {integrity: sha512-jvlx5wDp8s+bEF9sGFw/84SInXOA51ttkUEroQziKMbxplXThVKt83qB6bDTa1HuLNatdU9FHpFOiQWs1tLQIg==} + peerDependencies: + '@babel/core': ^7.0.0 + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -8202,6 +8421,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-entities@2.5.2: resolution: {integrity: sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==} @@ -8813,6 +9035,10 @@ packages: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} @@ -8953,6 +9179,10 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -9983,6 +10213,16 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + seroval-plugins@1.1.1: + resolution: {integrity: sha512-qNSy1+nUj7hsCOon7AO4wdAIo9P0jrzAMp18XhiOzA6/uO5TKtP7ScozVJ8T293oRIvi5wyCHSM4TrJo/c/GJA==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.1.1: + resolution: {integrity: sha512-rqEO6FZk8mv7Hyv4UCj3FD3b6Waqft605TLfsCe/BiaylRpyyMC0b+uA5TJKawX3KzMrdi3wsLbCaLplrQmBvQ==} + engines: {node: '>=10'} + serve-index@1.9.1: resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==} engines: {node: '>= 0.8.0'} @@ -10052,6 +10292,10 @@ packages: simple-git@3.27.0: resolution: {integrity: sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==} + sirv@3.0.0: + resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==} + engines: {node: '>=18'} + skin-tone@2.0.0: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} @@ -10077,6 +10321,14 @@ packages: sockjs@0.3.24: resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} + solid-js@1.9.3: + resolution: {integrity: sha512-5ba3taPoZGt9GY3YlsCB24kCg0Lv/rie/HTD4kG6h4daZZz7+yK02xn8Vx8dLYBc9i6Ps5JwAbEiqjmKaLB3Ag==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -10391,6 +10643,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@4.1.4: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} @@ -10749,6 +11005,9 @@ packages: typescript: optional: true + validate-html-nesting@1.2.2: + resolution: {integrity: sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==} + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -10795,6 +11054,16 @@ packages: peerDependencies: vite: 6.0.3 + vite-plugin-solid@2.10.2: + resolution: {integrity: sha512-AOEtwMe2baBSXMXdo+BUwECC8IFHcKS6WQV/1NEd+Q7vHPap5fmIhLcAzr+DUJ04/KHx/1UBU0l1/GWP+rMAPQ==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: 6.0.3 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} peerDependencies: @@ -10843,6 +11112,14 @@ packages: yaml: optional: true + vitefu@0.2.5: + resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + peerDependencies: + vite: 6.0.3 + peerDependenciesMeta: + vite: + optional: true + vitest@2.1.8: resolution: {integrity: sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==} engines: {node: ^18.0.0 || >=20.0.0} @@ -11219,6 +11496,10 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.26.3 + '@babel/helper-module-imports@7.25.9': dependencies: '@babel/traverse': 7.26.4 @@ -12331,6 +12612,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@nothing-but/utils@0.17.0': {} + '@nx/nx-darwin-arm64@20.3.0': optional: true @@ -12451,6 +12734,8 @@ snapshots: dependencies: playwright: 1.49.1 + '@polka/url@1.0.0-next.28': {} + '@prisma/client@5.22.0(prisma@5.22.0)': optionalDependencies: prisma: 5.22.0 @@ -13118,6 +13403,128 @@ snapshots: '@sindresorhus/merge-streams@2.3.0': {} + '@solid-devtools/debugger@0.24.4(solid-js@1.9.3)': + dependencies: + '@nothing-but/utils': 0.17.0 + '@solid-devtools/shared': 0.16.1(solid-js@1.9.3) + '@solid-primitives/bounds': 0.0.122(solid-js@1.9.3) + '@solid-primitives/cursor': 0.0.115(solid-js@1.9.3) + '@solid-primitives/event-bus': 1.0.11(solid-js@1.9.3) + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/keyboard': 1.2.8(solid-js@1.9.3) + '@solid-primitives/platform': 0.1.2(solid-js@1.9.3) + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/scheduled': 1.4.4(solid-js@1.9.3) + '@solid-primitives/static-store': 0.0.8(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-devtools/logger@0.9.4(solid-js@1.9.3)': + dependencies: + '@nothing-but/utils': 0.17.0 + '@solid-devtools/debugger': 0.24.4(solid-js@1.9.3) + '@solid-devtools/shared': 0.16.1(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-devtools/shared@0.16.1(solid-js@1.9.3)': + dependencies: + '@nothing-but/utils': 0.17.0 + '@solid-primitives/event-bus': 1.0.11(solid-js@1.9.3) + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/media': 2.2.9(solid-js@1.9.3) + '@solid-primitives/refs': 1.0.8(solid-js@1.9.3) + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/scheduled': 1.4.4(solid-js@1.9.3) + '@solid-primitives/static-store': 0.0.8(solid-js@1.9.3) + '@solid-primitives/styles': 0.0.114(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/bounds@0.0.122(solid-js@1.9.3)': + dependencies: + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/resize-observer': 2.0.26(solid-js@1.9.3) + '@solid-primitives/static-store': 0.0.8(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/cursor@0.0.115(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/event-bus@1.0.11(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/event-listener@2.3.3(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/keyboard@1.2.8(solid-js@1.9.3)': + dependencies: + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/media@2.2.9(solid-js@1.9.3)': + dependencies: + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/static-store': 0.0.8(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/platform@0.1.2(solid-js@1.9.3)': + dependencies: + solid-js: 1.9.3 + + '@solid-primitives/refs@1.0.8(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/resize-observer@2.0.26(solid-js@1.9.3)': + dependencies: + '@solid-primitives/event-listener': 2.3.3(solid-js@1.9.3) + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/static-store': 0.0.8(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/rootless@1.4.5(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/scheduled@1.4.4(solid-js@1.9.3)': + dependencies: + solid-js: 1.9.3 + + '@solid-primitives/static-store@0.0.8(solid-js@1.9.3)': + dependencies: + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/styles@0.0.114(solid-js@1.9.3)': + dependencies: + '@solid-primitives/rootless': 1.4.5(solid-js@1.9.3) + '@solid-primitives/utils': 6.2.3(solid-js@1.9.3) + solid-js: 1.9.3 + + '@solid-primitives/utils@6.2.3(solid-js@1.9.3)': + dependencies: + solid-js: 1.9.3 + + '@solidjs/testing-library@0.8.10(solid-js@1.9.3)': + dependencies: + '@testing-library/dom': 10.4.0 + solid-js: 1.9.3 + '@stylistic/eslint-plugin-js@2.12.1(eslint@9.17.0(jiti@2.4.1))': dependencies: eslint: 9.17.0(jiti@2.4.1) @@ -13299,6 +13706,13 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@tanstack/solid-store@0.6.0(solid-js@1.9.3)': + dependencies: + '@tanstack/store': 0.6.0 + solid-js: 1.9.3 + + '@tanstack/store@0.6.0': {} + '@tanstack/store@0.7.0': {} '@tanstack/virtual-core@3.10.9': {} @@ -13336,6 +13750,10 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': + dependencies: + '@testing-library/dom': 10.4.0 + '@trpc/client@11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2)': dependencies: '@trpc/server': 11.0.0-rc.660(typescript@5.7.2) @@ -13772,6 +14190,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/browser@2.1.8(@types/node@22.10.2)(playwright@1.49.1)(typescript@5.7.2)(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1))(vitest@2.1.8)': + dependencies: + '@testing-library/dom': 10.4.0 + '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) + '@vitest/mocker': 2.1.8(msw@2.6.8(@types/node@22.10.2)(typescript@5.7.2))(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1)) + '@vitest/utils': 2.1.8 + magic-string: 0.30.14 + msw: 2.6.8(@types/node@22.10.2)(typescript@5.7.2) + sirv: 3.0.0 + tinyrainbow: 1.2.0 + vitest: 2.1.8(@types/node@22.10.2)(@vitest/browser@2.1.8)(@vitest/ui@2.1.8)(jiti@2.4.1)(jsdom@25.0.1)(msw@2.6.8(@types/node@22.10.2)(typescript@5.7.2))(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1) + ws: 8.18.0 + optionalDependencies: + playwright: 1.49.1 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - typescript + - utf-8-validate + - vite + '@vitest/expect@2.1.8': dependencies: '@vitest/spy': 2.1.8 @@ -13807,6 +14246,17 @@ snapshots: dependencies: tinyspy: 3.0.2 + '@vitest/ui@2.1.8(vitest@2.1.8)': + dependencies: + '@vitest/utils': 2.1.8 + fflate: 0.8.2 + flatted: 3.3.2 + pathe: 1.1.2 + sirv: 3.0.0 + tinyglobby: 0.2.10 + tinyrainbow: 1.2.0 + vitest: 2.1.8(@types/node@22.10.2)(@vitest/browser@2.1.8)(@vitest/ui@2.1.8)(jiti@2.4.1)(jsdom@25.0.1)(msw@2.6.8(@types/node@22.10.2)(typescript@5.7.2))(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1) + '@vitest/utils@2.1.8': dependencies: '@vitest/pretty-format': 2.1.8 @@ -13959,17 +14409,17 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 - '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.97.1)': + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4))': dependencies: webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1) - '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.97.1)': + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4))': dependencies: webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1) - '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.1.0)(webpack@5.97.1)': + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack-dev-server@5.1.0(webpack-cli@5.1.4)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4))': dependencies: webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1) @@ -14233,6 +14683,21 @@ snapshots: babel-plugin-add-module-exports@0.2.1: {} + babel-plugin-jsx-dom-expressions@0.39.3(@babel/core@7.26.0): + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.3 + html-entities: 2.3.3 + parse5: 7.2.1 + validate-html-nesting: 1.2.2 + + babel-preset-solid@1.9.3(@babel/core@7.26.0): + dependencies: + '@babel/core': 7.26.0 + babel-plugin-jsx-dom-expressions: 0.39.3(@babel/core@7.26.0) + balanced-match@1.0.2: {} bare-events@2.5.0: @@ -15893,6 +16358,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-entities@2.3.3: {} + html-entities@2.5.2: {} html-minifier-terser@6.1.0: @@ -15905,7 +16372,7 @@ snapshots: relateurl: 0.2.7 terser: 5.36.0 - html-webpack-plugin@5.6.3(@rspack/core@1.1.8(@swc/helpers@0.5.15))(webpack@5.97.1): + html-webpack-plugin@5.6.3(@rspack/core@1.1.8(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -16491,6 +16958,10 @@ snapshots: meow@12.1.1: {} + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + merge-descriptors@1.0.3: {} merge-stream@2.0.0: {} @@ -16601,6 +17072,8 @@ snapshots: mri@1.2.0: {} + mrmime@2.0.0: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -16831,7 +17304,7 @@ snapshots: nwsapi@2.2.16: {} - nx@20.3.0(@swc/core@1.10.1(@swc/helpers@0.5.15)): + nx@20.3.0(@swc/core@1.10.1): dependencies: '@napi-rs/wasm-runtime': 0.2.4 '@yarnpkg/lockfile': 1.1.0 @@ -17759,6 +18232,12 @@ snapshots: dependencies: randombytes: 2.1.0 + seroval-plugins@1.1.1(seroval@1.1.1): + dependencies: + seroval: 1.1.1 + + seroval@1.1.1: {} + serve-index@1.9.1: dependencies: accepts: 1.3.8 @@ -17840,6 +18319,12 @@ snapshots: transitivePeerDependencies: - supports-color + sirv@3.0.0: + dependencies: + '@polka/url': 1.0.0-next.28 + mrmime: 2.0.0 + totalist: 3.0.1 + skin-tone@2.0.0: dependencies: unicode-emoji-modifier-base: 1.0.0 @@ -17867,6 +18352,21 @@ snapshots: uuid: 8.3.2 websocket-driver: 0.7.4 + solid-js@1.9.3: + dependencies: + csstype: 3.1.3 + seroval: 1.1.1 + seroval-plugins: 1.1.1(seroval@1.1.1) + + solid-refresh@0.6.3(solid-js@1.9.3): + dependencies: + '@babel/generator': 7.26.3 + '@babel/helper-module-imports': 7.25.9 + '@babel/types': 7.26.3 + solid-js: 1.9.3 + transitivePeerDependencies: + - supports-color + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -18024,7 +18524,7 @@ snapshots: csso: 5.0.5 picocolors: 1.1.1 - swc-loader@0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1): + swc-loader@0.2.6(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)): dependencies: '@swc/core': 1.10.1(@swc/helpers@0.5.15) '@swc/counter': 0.1.3 @@ -18132,26 +18632,26 @@ snapshots: type-fest: 2.19.0 unique-string: 3.0.0 - terser-webpack-plugin@5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)): + terser-webpack-plugin@5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.36.0 - webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2) + webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4) optionalDependencies: '@swc/core': 1.10.1(@swc/helpers@0.5.15) esbuild: 0.24.2 - terser-webpack-plugin@5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1): + terser-webpack-plugin@5.3.10(@swc/core@1.10.1)(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.1)(esbuild@0.24.2)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.36.0 - webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4) + webpack: 5.97.1(@swc/core@1.10.1)(esbuild@0.24.2) optionalDependencies: '@swc/core': 1.10.1(@swc/helpers@0.5.15) esbuild: 0.24.2 @@ -18227,6 +18727,8 @@ snapshots: toidentifier@1.0.1: {} + totalist@3.0.1: {} + tough-cookie@4.1.4: dependencies: psl: 1.15.0 @@ -18546,6 +19048,8 @@ snapshots: optionalDependencies: typescript: 5.7.2 + validate-html-nesting@1.2.2: {} + validate-npm-package-name@5.0.1: {} validate-npm-package-name@6.0.0: {} @@ -18779,6 +19283,21 @@ snapshots: dependencies: vite: 6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1) + vite-plugin-solid@2.10.2(@testing-library/jest-dom@6.6.3)(solid-js@1.9.3)(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1)): + dependencies: + '@babel/core': 7.26.0 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.3(@babel/core@7.26.0) + merge-anything: 5.1.7 + solid-js: 1.9.3 + solid-refresh: 0.6.3(solid-js@1.9.3) + vite: 6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1) + vitefu: 0.2.5(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1)) + optionalDependencies: + '@testing-library/jest-dom': 6.6.3 + transitivePeerDependencies: + - supports-color + vite-tsconfig-paths@5.1.4(typescript@5.7.2)(vite@6.0.3(@types/node@22.10.1)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1)): dependencies: debug: 4.3.7(supports-color@9.4.0) @@ -18827,7 +19346,11 @@ snapshots: tsx: 4.19.2 yaml: 2.6.1 - vitest@2.1.8(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(msw@2.6.8(@types/node@22.10.2)(typescript@5.7.2))(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1): + vitefu@0.2.5(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1)): + optionalDependencies: + vite: 6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1) + + vitest@2.1.8(@types/node@22.10.2)(@vitest/browser@2.1.8)(@vitest/ui@2.1.8)(jiti@2.4.1)(jsdom@25.0.1)(msw@2.6.8(@types/node@22.10.2)(typescript@5.7.2))(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1): dependencies: '@vitest/expect': 2.1.8 '@vitest/mocker': 2.1.8(msw@2.6.8(@types/node@22.10.2)(typescript@5.7.2))(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1)) @@ -18851,6 +19374,8 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.10.2 + '@vitest/browser': 2.1.8(@types/node@22.10.2)(playwright@1.49.1)(typescript@5.7.2)(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1))(vitest@2.1.8) + '@vitest/ui': 2.1.8(vitest@2.1.8) jsdom: 25.0.1 transitivePeerDependencies: - jiti @@ -18914,9 +19439,9 @@ snapshots: webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.97.1) - '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.97.1) - '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.1.0)(webpack@5.97.1) + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1))(webpack-dev-server@5.1.0(webpack-cli@5.1.4)(webpack@5.97.1))(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)) colorette: 2.0.20 commander: 10.0.1 cross-spawn: 7.0.6 @@ -18930,7 +19455,7 @@ snapshots: optionalDependencies: webpack-dev-server: 5.1.0(webpack-cli@5.1.4)(webpack@5.97.1) - webpack-dev-middleware@7.4.2(webpack@5.97.1): + webpack-dev-middleware@7.4.2(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)): dependencies: colorette: 2.0.20 memfs: 4.14.1 @@ -18969,7 +19494,7 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.2(webpack@5.97.1) + webpack-dev-middleware: 7.4.2(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)) ws: 8.18.0 optionalDependencies: webpack: 5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4) @@ -18990,7 +19515,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2): + webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.6 @@ -19012,15 +19537,17 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)) + terser-webpack-plugin: 5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4)) watchpack: 2.4.2 webpack-sources: 3.2.3 + optionalDependencies: + webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1) transitivePeerDependencies: - '@swc/core' - esbuild - uglify-js - webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@5.1.4): + webpack@5.97.1(@swc/core@1.10.1)(esbuild@0.24.2): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.6 @@ -19042,11 +19569,9 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1) + terser-webpack-plugin: 5.3.10(@swc/core@1.10.1)(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.1)(esbuild@0.24.2)) watchpack: 2.4.2 webpack-sources: 3.2.3 - optionalDependencies: - webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.97.1) transitivePeerDependencies: - '@swc/core' - esbuild