From 2ee69154983e32bcf763a6cae9fb7a151ba93952 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 25 Jun 2024 15:56:55 +0200 Subject: [PATCH 01/37] feat: wip new matcher --- packages/router/src/encoding.ts | 4 +- packages/router/src/matcher/index.ts | 12 +- packages/router/src/new-matcher/index.ts | 1 + .../src/new-matcher/matcher-location.ts | 32 ++ .../router/src/new-matcher/matcher-pattern.ts | 144 +++++++++ .../router/src/new-matcher/matcher.spec.ts | 105 +++++++ .../router/src/new-matcher/matcher.test-d.ts | 16 + packages/router/src/new-matcher/matcher.ts | 292 ++++++++++++++++++ packages/router/src/utils/index.ts | 5 +- 9 files changed, 602 insertions(+), 9 deletions(-) create mode 100644 packages/router/src/new-matcher/index.ts create mode 100644 packages/router/src/new-matcher/matcher-location.ts create mode 100644 packages/router/src/new-matcher/matcher-pattern.ts create mode 100644 packages/router/src/new-matcher/matcher.spec.ts create mode 100644 packages/router/src/new-matcher/matcher.test-d.ts create mode 100644 packages/router/src/new-matcher/matcher.ts diff --git a/packages/router/src/encoding.ts b/packages/router/src/encoding.ts index 69b338a65..74d304928 100644 --- a/packages/router/src/encoding.ts +++ b/packages/router/src/encoding.ts @@ -22,7 +22,7 @@ import { warn } from './warning' const HASH_RE = /#/g // %23 const AMPERSAND_RE = /&/g // %26 -const SLASH_RE = /\//g // %2F +export const SLASH_RE = /\//g // %2F const EQUAL_RE = /=/g // %3D const IM_RE = /\?/g // %3F export const PLUS_RE = /\+/g // %2B @@ -58,7 +58,7 @@ const ENC_SPACE_RE = /%20/g // } * @param text - string to encode * @returns encoded string */ -function commonEncode(text: string | number): string { +export function commonEncode(text: string | number): string { return encodeURI('' + text) .replace(ENC_PIPE_RE, '|') .replace(ENC_BRACKET_OPEN_RE, '[') diff --git a/packages/router/src/matcher/index.ts b/packages/router/src/matcher/index.ts index 9d787ddbc..fe951f7ad 100644 --- a/packages/router/src/matcher/index.ts +++ b/packages/router/src/matcher/index.ts @@ -271,7 +271,7 @@ export function createRouterMatcher( name = matcher.record.name params = assign( // paramsFromLocation is a new object - paramsFromLocation( + pickParams( currentLocation.params, // only keep params that exist in the resolved location // only keep optional params coming from a parent record @@ -285,7 +285,7 @@ export function createRouterMatcher( // discard any existing params in the current location that do not exist here // #1497 this ensures better active/exact matching location.params && - paramsFromLocation( + pickParams( location.params, matcher.keys.map(k => k.name) ) @@ -365,7 +365,13 @@ export function createRouterMatcher( } } -function paramsFromLocation( +/** + * Picks an object param to contain only specified keys. + * + * @param params - params object to pick from + * @param keys - keys to pick + */ +function pickParams( params: MatcherLocation['params'], keys: string[] ): MatcherLocation['params'] { diff --git a/packages/router/src/new-matcher/index.ts b/packages/router/src/new-matcher/index.ts new file mode 100644 index 000000000..17910f62f --- /dev/null +++ b/packages/router/src/new-matcher/index.ts @@ -0,0 +1 @@ +export { createCompiledMatcher } from './matcher' diff --git a/packages/router/src/new-matcher/matcher-location.ts b/packages/router/src/new-matcher/matcher-location.ts new file mode 100644 index 000000000..bb44326b2 --- /dev/null +++ b/packages/router/src/new-matcher/matcher-location.ts @@ -0,0 +1,32 @@ +import type { LocationQueryRaw } from '../query' +import type { MatcherName } from './matcher' + +// the matcher can serialize and deserialize params +export type MatcherParamsFormatted = Record + +export interface MatcherLocationAsName { + name: MatcherName + params: MatcherParamsFormatted + query?: LocationQueryRaw + hash?: string + + path?: undefined +} + +export interface MatcherLocationAsPath { + path: string + query?: LocationQueryRaw + hash?: string + + name?: undefined + params?: undefined +} + +export interface MatcherLocationAsRelative { + params?: MatcherParamsFormatted + query?: LocationQueryRaw + hash?: string + + name?: undefined + path?: undefined +} diff --git a/packages/router/src/new-matcher/matcher-pattern.ts b/packages/router/src/new-matcher/matcher-pattern.ts new file mode 100644 index 000000000..021b975c0 --- /dev/null +++ b/packages/router/src/new-matcher/matcher-pattern.ts @@ -0,0 +1,144 @@ +import type { + MatcherName, + MatcherPathParams, + MatcherQueryParams, + MatcherQueryParamsValue, +} from './matcher' +import type { MatcherParamsFormatted } from './matcher-location' + +export interface MatcherPattern { + /** + * Name of the matcher. Unique across all matchers. + */ + name: MatcherName + + /** + * Extracts from a formatted, unencoded params object the ones belonging to the path, query, and hash. If any of them is missing, returns `null`. TODO: throw instead? + * @param params - Params to extract from. + */ + unformatParams( + params: MatcherParamsFormatted + ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string | null] + + /** + * Extracts the defined params from an encoded path, query, and hash parsed from a URL. Does not apply formatting or + * decoding. If the URL does not match the pattern, returns `null`. + * + * @example + * ```ts + * const pattern = createPattern('/foo', { + * path: {}, // nothing is used from the path + * query: { used: String }, // we require a `used` query param + * }) + * // /?used=2 + * pattern.parseLocation({ path: '/', query: { used: '' }, hash: '' }) // null + * // /foo?used=2¬Used¬Used=2#hello + * pattern.parseLocation({ path: '/foo', query: { used: '2', notUsed: [null, '2']}, hash: '#hello' }) + * // { used: '2' } // we extract the required params + * // /foo?used=2#hello + * pattern.parseLocation({ path: '/foo', query: {}, hash: '#hello' }) + * // null // the query param is missing + * ``` + */ + matchLocation(location: { + path: string + query: MatcherQueryParams + hash: string + }): [path: MatcherPathParams, query: MatcherQueryParams, hash: string | null] + + /** + * Takes encoded params object to form the `path`, + * @param path - encoded path params + */ + buildPath(path: MatcherPathParams): string + + /** + * Runs the decoded params through the formatting functions if any. + * @param params - Params to format. + */ + formatParams( + path: MatcherPathParams, + query: MatcherQueryParams, + hash: string | null + ): MatcherParamsFormatted +} + +interface PatternParamOptions_Base { + get: (value: MatcherQueryParamsValue) => T + set?: (value: T) => MatcherQueryParamsValue + default?: T | (() => T) +} + +export interface PatternParamOptions extends PatternParamOptions_Base {} + +export interface PatternQueryParamOptions + extends PatternParamOptions_Base { + get: (value: MatcherQueryParamsValue) => T + set?: (value: T) => MatcherQueryParamsValue +} + +// TODO: allow more than strings +export interface PatternHashParamOptions + extends PatternParamOptions_Base {} + +export interface MatcherPatternPath { + match(path: string): MatcherPathParams + format(params: MatcherPathParams): MatcherParamsFormatted +} + +export interface MatcherPatternQuery { + match(query: MatcherQueryParams): MatcherQueryParams + format(params: MatcherQueryParams): MatcherParamsFormatted +} + +export interface MatcherPatternHash { + /** + * Check if the hash matches a pattern and returns it, still encoded with its leading `#`. + * @param hash - encoded hash + */ + match(hash: string): string + format(hash: string): MatcherParamsFormatted +} + +export class MatcherPatternImpl implements MatcherPattern { + constructor( + public name: MatcherName, + private path: MatcherPatternPath, + private query?: MatcherPatternQuery, + private hash?: MatcherPatternHash + ) {} + + matchLocation(location: { + path: string + query: MatcherQueryParams + hash: string + }): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] { + return [ + this.path.match(location.path), + this.query?.match(location.query) ?? {}, + this.hash?.match(location.hash) ?? '', + ] + } + + formatParams( + path: MatcherPathParams, + query: MatcherQueryParams, + hash: string + ): MatcherParamsFormatted { + return { + ...this.path.format(path), + ...this.query?.format(query), + ...this.hash?.format(hash), + } + } + + buildPath(path: MatcherPathParams): string { + return '' + } + + unformatParams( + params: MatcherParamsFormatted + ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string | null] { + throw new Error('Method not implemented.') + } +} diff --git a/packages/router/src/new-matcher/matcher.spec.ts b/packages/router/src/new-matcher/matcher.spec.ts new file mode 100644 index 000000000..2660abdda --- /dev/null +++ b/packages/router/src/new-matcher/matcher.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { MatcherPatternImpl, MatcherPatternPath } from './matcher-pattern' +import { createCompiledMatcher } from './matcher' + +function createMatcherPattern( + ...args: ConstructorParameters +) { + return new MatcherPatternImpl(...args) +} + +const EMPTY_PATH_PATTERN_MATCHER = { + match: (path: string) => ({}), + format: (params: {}) => ({}), +} satisfies MatcherPatternPath + +describe('Matcher', () => { + describe('resolve()', () => { + it('resolves string locations with no params', () => { + const matcher = createCompiledMatcher() + matcher.addRoute( + createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER) + ) + + expect(matcher.resolve('/foo?a=a&b=b#h')).toMatchObject({ + path: '/foo', + params: {}, + query: { a: 'a', b: 'b' }, + hash: '#h', + }) + }) + + it('resolves string locations with params', () => { + const matcher = createCompiledMatcher() + matcher.addRoute( + // /users/:id + createMatcherPattern(Symbol('foo'), { + match: (path: string) => { + const match = path.match(/^\/foo\/([^/]+?)$/) + if (!match) throw new Error('no match') + return { id: match[1] } + }, + format: (params: { id: string }) => ({ id: Number(params.id) }), + }) + ) + + expect(matcher.resolve('/foo/1')).toMatchObject({ + path: '/foo/1', + params: { id: 1 }, + query: {}, + hash: '', + }) + expect(matcher.resolve('/foo/54')).toMatchObject({ + path: '/foo/54', + params: { id: 54 }, + query: {}, + hash: '', + }) + }) + + it('resolve string locations with query', () => { + const matcher = createCompiledMatcher() + matcher.addRoute( + createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER, { + match: query => ({ + id: Array.isArray(query.id) ? query.id[0] : query.id, + }), + format: (params: { id: string }) => ({ id: Number(params.id) }), + }) + ) + + expect(matcher.resolve('/foo?id=100')).toMatchObject({ + hash: '', + params: { + id: 100, + }, + path: '/foo', + query: { + id: '100', + }, + }) + }) + + it('resolves string locations with hash', () => { + const matcher = createCompiledMatcher() + matcher.addRoute( + createMatcherPattern( + Symbol('foo'), + EMPTY_PATH_PATTERN_MATCHER, + undefined, + { + match: hash => hash, + format: hash => ({ a: hash.slice(1) }), + } + ) + ) + + expect(matcher.resolve('/foo#bar')).toMatchObject({ + hash: '#bar', + params: { a: 'bar' }, + path: '/foo', + query: {}, + }) + }) + }) +}) diff --git a/packages/router/src/new-matcher/matcher.test-d.ts b/packages/router/src/new-matcher/matcher.test-d.ts new file mode 100644 index 000000000..fbf150e2e --- /dev/null +++ b/packages/router/src/new-matcher/matcher.test-d.ts @@ -0,0 +1,16 @@ +import { describe, it } from 'vitest' +import { NEW_MatcherLocationResolved, createCompiledMatcher } from './matcher' + +describe('Matcher', () => { + it('resolves locations', () => { + const matcher = createCompiledMatcher() + matcher.resolve('/foo') + // @ts-expect-error: needs currentLocation + matcher.resolve('foo') + matcher.resolve('foo', {} as NEW_MatcherLocationResolved) + matcher.resolve({ name: 'foo', params: {} }) + // @ts-expect-error: needs currentLocation + matcher.resolve({ params: { id: 1 } }) + matcher.resolve({ params: { id: 1 } }, {} as NEW_MatcherLocationResolved) + }) +}) diff --git a/packages/router/src/new-matcher/matcher.ts b/packages/router/src/new-matcher/matcher.ts new file mode 100644 index 000000000..bd48a1246 --- /dev/null +++ b/packages/router/src/new-matcher/matcher.ts @@ -0,0 +1,292 @@ +import { type LocationQuery, parseQuery, normalizeQuery } from '../query' +import type { MatcherPattern } from './matcher-pattern' +import { warn } from '../warning' +import { + SLASH_RE, + encodePath, + encodeQueryValue as _encodeQueryValue, +} from '../encoding' +import { parseURL } from '../location' +import type { + MatcherLocationAsName, + MatcherLocationAsRelative, + MatcherParamsFormatted, +} from './matcher-location' + +export type MatcherName = string | symbol + +/** + * Matcher capable of resolving route locations. + */ +export interface NEW_Matcher_Resolve { + /** + * Resolves an absolute location (like `/path/to/somewhere`). + */ + resolve(absoluteLocation: `/${string}`): NEW_MatcherLocationResolved + + /** + * Resolves a string location relative to another location. A relative location can be `./same-folder`, + * `../parent-folder`, or even `same-folder`. + */ + resolve( + relativeLocation: string, + currentLocation: NEW_MatcherLocationResolved + ): NEW_MatcherLocationResolved + + /** + * Resolves a location by its name. Any required params or query must be passed in the `options` argument. + */ + resolve(location: MatcherLocationAsName): NEW_MatcherLocationResolved + + /** + * Resolves a location by its path. Any required query must be passed. + * @param location - The location to resolve. + */ + // resolve(location: MatcherLocationAsPath): NEW_MatcherLocationResolved + // NOTE: in practice, this overload can cause bugs. It's better to use named locations + + /** + * Resolves a location relative to another location. It reuses existing properties in the `currentLocation` like + * `params`, `query`, and `hash`. + */ + resolve( + relativeLocation: MatcherLocationAsRelative, + currentLocation: NEW_MatcherLocationResolved + ): NEW_MatcherLocationResolved + + addRoute(matcher: MatcherPattern, parent?: MatcherPattern): void + removeRoute(matcher: MatcherPattern): void + clearRoutes(): void +} + +type MatcherResolveArgs = + | [absoluteLocation: `/${string}`] + | [relativeLocation: string, currentLocation: NEW_MatcherLocationResolved] + | [location: MatcherLocationAsName] + | [ + relativeLocation: MatcherLocationAsRelative, + currentLocation: NEW_MatcherLocationResolved + ] + +/** + * Matcher capable of adding and removing routes at runtime. + */ +export interface NEW_Matcher_Dynamic { + addRoute(record: TODO, parent?: TODO): () => void + + removeRoute(record: TODO): void + removeRoute(name: MatcherName): void + + clearRoutes(): void +} + +type TODO = any + +export interface NEW_MatcherLocationResolved { + name: MatcherName + path: string + // TODO: generics? + params: MatcherParamsFormatted + query: LocationQuery + hash: string + + matched: TODO[] +} + +export type MatcherPathParamsValue = string | null | string[] +/** + * Params in a string format so they can be encoded/decoded and put into a URL. + */ +export type MatcherPathParams = Record + +export type MatcherQueryParamsValue = string | null | Array +export type MatcherQueryParams = Record + +export function applyToParams( + fn: (v: string | number | null | undefined) => R, + params: MatcherPathParams | LocationQuery | undefined +): Record { + const newParams: Record = {} + + for (const key in params) { + const value = params[key] + newParams[key] = Array.isArray(value) ? value.map(fn) : fn(value) + } + + return newParams +} + +/** + * Decode text using `decodeURIComponent`. Returns the original text if it + * fails. + * + * @param text - string to decode + * @returns decoded string + */ +export function decode(text: string | number): string +export function decode(text: null | undefined): null +export function decode(text: string | number | null | undefined): string | null +export function decode( + text: string | number | null | undefined +): string | null { + if (text == null) return null + try { + return decodeURIComponent('' + text) + } catch (err) { + __DEV__ && warn(`Error decoding "${text}". Using original value`) + } + return '' + text +} + +interface FnStableNull { + (value: null | undefined): null + (value: string | number): string + // needed for the general case and must be last + (value: string | number | null | undefined): string | null +} + +function encodeParam(text: null | undefined, encodeSlash?: boolean): null +function encodeParam(text: string | number, encodeSlash?: boolean): string +function encodeParam( + text: string | number | null | undefined, + encodeSlash?: boolean +): string | null +function encodeParam( + text: string | number | null | undefined, + encodeSlash = true +): string | null { + if (text == null) return null + text = encodePath(text) + return encodeSlash ? text.replace(SLASH_RE, '%2F') : text +} + +// @ts-expect-error: overload are not correctly identified +const encodeQueryValue: FnStableNull = + // for ts + value => (value == null ? null : _encodeQueryValue(value)) + +// // @ts-expect-error: overload are not correctly identified +// const encodeQueryKey: FnStableNull = +// // for ts +// value => (value == null ? null : _encodeQueryKey(value)) + +function transformObject( + fnKey: (value: string | number) => string, + fnValue: FnStableNull, + query: T +): T { + const encoded: any = {} + + for (const key in query) { + const value = query[key] + encoded[fnKey(key)] = Array.isArray(value) + ? value.map(fnValue) + : fnValue(value as string | number | null | undefined) + } + + return encoded +} + +export function createCompiledMatcher(): NEW_Matcher_Resolve { + const matchers = new Map() + + // TODO: allow custom encode/decode functions + // const encodeParams = applyToParams.bind(null, encodeParam) + // const decodeParams = transformObject.bind(null, String, decode) + // const encodeQuery = transformObject.bind( + // null, + // _encodeQueryKey, + // encodeQueryValue + // ) + // const decodeQuery = transformObject.bind(null, decode, decode) + + function resolve(...args: MatcherResolveArgs): NEW_MatcherLocationResolved { + const [location, currentLocation] = args + if (typeof location === 'string') { + // string location, e.g. '/foo', '../bar', 'baz' + const url = parseURL(parseQuery, location, currentLocation?.path) + + let matcher: MatcherPattern | undefined + let parsedParams: MatcherParamsFormatted | null | undefined + + for (matcher of matchers.values()) { + const params = matcher.matchLocation(url) + if (params) { + parsedParams = matcher.formatParams( + transformObject(String, decode, params[0]), + transformObject(decode, decode, params[1]), + decode(params[2]) + ) + if (parsedParams) break + } + } + if (!parsedParams || !matcher) { + throw new Error(`No matcher found for location "${location}"`) + } + // TODO: build fullPath + return { + name: matcher.name, + path: url.path, + params: parsedParams, + query: transformObject(decode, decode, url.query), + hash: decode(url.hash), + matched: [], + } + } else { + // relative location or by name + const name = location.name ?? currentLocation!.name + const matcher = matchers.get(name) + if (!matcher) { + throw new Error(`Matcher "${String(location.name)}" not found`) + } + + // unencoded params in a formatted form that the user came up with + const params = location.params ?? currentLocation!.params + const mixedUnencodedParams = matcher.unformatParams(params) + + // TODO: they could just throw? + if (!mixedUnencodedParams) { + throw new Error(`Missing params for matcher "${String(name)}"`) + } + + const path = matcher.buildPath( + // encode the values before building the path + transformObject(String, encodeParam, mixedUnencodedParams[0]) + ) + + return { + name, + path, + params, + hash: mixedUnencodedParams[2] ?? location.hash ?? '', + // TODO: should pick query from the params but also from the location and merge them + query: { + ...normalizeQuery(location.query), + // ...matcher.extractQuery(mixedUnencodedParams[1]) + }, + matched: [], + } + } + } + + function addRoute(matcher: MatcherPattern, parent?: MatcherPattern) { + matchers.set(matcher.name, matcher) + } + + function removeRoute(matcher: MatcherPattern) { + matchers.delete(matcher.name) + // TODO: delete children and aliases + } + + function clearRoutes() { + matchers.clear() + } + + return { + resolve, + + addRoute, + removeRoute, + clearRoutes, + } +} diff --git a/packages/router/src/utils/index.ts b/packages/router/src/utils/index.ts index b63f9dbb3..a7c42f4cf 100644 --- a/packages/router/src/utils/index.ts +++ b/packages/router/src/utils/index.ts @@ -2,7 +2,6 @@ import { RouteParamsGeneric, RouteComponent, RouteParamsRawGeneric, - RouteParamValueRaw, RawRouteComponent, } from '../types' @@ -45,9 +44,7 @@ export function applyToParams( for (const key in params) { const value = params[key] - newParams[key] = isArray(value) - ? value.map(fn) - : fn(value as Exclude) + newParams[key] = isArray(value) ? value.map(fn) : fn(value) } return newParams From 534bb1b3c752f71019d60a0a98025f7439819c15 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 25 Jun 2024 15:59:05 +0200 Subject: [PATCH 02/37] test: check parsed urls --- .../router/src/new-matcher/matcher.spec.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/router/src/new-matcher/matcher.spec.ts b/packages/router/src/new-matcher/matcher.spec.ts index 2660abdda..29f6a40a3 100644 --- a/packages/router/src/new-matcher/matcher.spec.ts +++ b/packages/router/src/new-matcher/matcher.spec.ts @@ -43,17 +43,17 @@ describe('Matcher', () => { }) ) - expect(matcher.resolve('/foo/1')).toMatchObject({ + expect(matcher.resolve('/foo/1?a=a&b=b#h')).toMatchObject({ path: '/foo/1', params: { id: 1 }, - query: {}, - hash: '', + query: { a: 'a', b: 'b' }, + hash: '#h', }) - expect(matcher.resolve('/foo/54')).toMatchObject({ + expect(matcher.resolve('/foo/54?a=a&b=b#h')).toMatchObject({ path: '/foo/54', params: { id: 54 }, - query: {}, - hash: '', + query: { a: 'a', b: 'b' }, + hash: '#h', }) }) @@ -68,15 +68,14 @@ describe('Matcher', () => { }) ) - expect(matcher.resolve('/foo?id=100')).toMatchObject({ - hash: '', - params: { - id: 100, - }, + expect(matcher.resolve('/foo?id=100&b=b#h')).toMatchObject({ + params: { id: 100 }, path: '/foo', query: { id: '100', + b: 'b', }, + hash: '#h', }) }) @@ -94,11 +93,11 @@ describe('Matcher', () => { ) ) - expect(matcher.resolve('/foo#bar')).toMatchObject({ + expect(matcher.resolve('/foo?a=a&b=b#bar')).toMatchObject({ hash: '#bar', params: { a: 'bar' }, path: '/foo', - query: {}, + query: { a: 'a', b: 'b' }, }) }) }) From b908aa75af7b08e3076f41bccc243fc280a37935 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 25 Jun 2024 17:22:01 +0200 Subject: [PATCH 03/37] chore: build location --- .../router/src/new-matcher/matcher-pattern.ts | 22 +- .../router/src/new-matcher/matcher.spec.ts | 255 +++++++++++++----- packages/router/src/new-matcher/matcher.ts | 23 +- 3 files changed, 218 insertions(+), 82 deletions(-) diff --git a/packages/router/src/new-matcher/matcher-pattern.ts b/packages/router/src/new-matcher/matcher-pattern.ts index 021b975c0..bb993658c 100644 --- a/packages/router/src/new-matcher/matcher-pattern.ts +++ b/packages/router/src/new-matcher/matcher-pattern.ts @@ -13,12 +13,12 @@ export interface MatcherPattern { name: MatcherName /** - * Extracts from a formatted, unencoded params object the ones belonging to the path, query, and hash. If any of them is missing, returns `null`. TODO: throw instead? + * Extracts from a formatted, unencoded params object the ones belonging to the path, query, and hash. * @param params - Params to extract from. */ unformatParams( params: MatcherParamsFormatted - ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string | null] + ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] /** * Extracts the defined params from an encoded path, query, and hash parsed from a URL. Does not apply formatting or @@ -44,7 +44,7 @@ export interface MatcherPattern { path: string query: MatcherQueryParams hash: string - }): [path: MatcherPathParams, query: MatcherQueryParams, hash: string | null] + }): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] /** * Takes encoded params object to form the `path`, @@ -59,7 +59,7 @@ export interface MatcherPattern { formatParams( path: MatcherPathParams, query: MatcherQueryParams, - hash: string | null + hash: string ): MatcherParamsFormatted } @@ -82,13 +82,16 @@ export interface PatternHashParamOptions extends PatternParamOptions_Base {} export interface MatcherPatternPath { + build(path: MatcherPathParams): string match(path: string): MatcherPathParams format(params: MatcherPathParams): MatcherParamsFormatted + unformat(params: MatcherParamsFormatted): MatcherPathParams } export interface MatcherPatternQuery { match(query: MatcherQueryParams): MatcherQueryParams format(params: MatcherQueryParams): MatcherParamsFormatted + unformat(params: MatcherParamsFormatted): MatcherQueryParams } export interface MatcherPatternHash { @@ -98,6 +101,7 @@ export interface MatcherPatternHash { */ match(hash: string): string format(hash: string): MatcherParamsFormatted + unformat(params: MatcherParamsFormatted): string } export class MatcherPatternImpl implements MatcherPattern { @@ -133,12 +137,16 @@ export class MatcherPatternImpl implements MatcherPattern { } buildPath(path: MatcherPathParams): string { - return '' + return this.path.build(path) } unformatParams( params: MatcherParamsFormatted - ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string | null] { - throw new Error('Method not implemented.') + ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] { + return [ + this.path.unformat(params), + this.query?.unformat(params) ?? {}, + this.hash?.unformat(params) ?? '', + ] } } diff --git a/packages/router/src/new-matcher/matcher.spec.ts b/packages/router/src/new-matcher/matcher.spec.ts index 29f6a40a3..9c6ccb2fe 100644 --- a/packages/router/src/new-matcher/matcher.spec.ts +++ b/packages/router/src/new-matcher/matcher.spec.ts @@ -11,93 +11,212 @@ function createMatcherPattern( const EMPTY_PATH_PATTERN_MATCHER = { match: (path: string) => ({}), format: (params: {}) => ({}), + unformat: (params: {}) => ({}), + build: () => '/', } satisfies MatcherPatternPath describe('Matcher', () => { describe('resolve()', () => { - it('resolves string locations with no params', () => { - const matcher = createCompiledMatcher() - matcher.addRoute( - createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER) - ) - - expect(matcher.resolve('/foo?a=a&b=b#h')).toMatchObject({ - path: '/foo', - params: {}, - query: { a: 'a', b: 'b' }, - hash: '#h', + describe('absolute locationss as strings', () => { + it('resolves string locations with no params', () => { + const matcher = createCompiledMatcher() + matcher.addRoute( + createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER) + ) + + expect(matcher.resolve('/foo?a=a&b=b#h')).toMatchObject({ + path: '/foo', + params: {}, + query: { a: 'a', b: 'b' }, + hash: '#h', + }) }) - }) - it('resolves string locations with params', () => { - const matcher = createCompiledMatcher() - matcher.addRoute( - // /users/:id - createMatcherPattern(Symbol('foo'), { - match: (path: string) => { - const match = path.match(/^\/foo\/([^/]+?)$/) - if (!match) throw new Error('no match') - return { id: match[1] } + it('resolves string locations with params', () => { + const matcher = createCompiledMatcher() + matcher.addRoute( + // /users/:id + createMatcherPattern(Symbol('foo'), { + match: (path: string) => { + const match = path.match(/^\/foo\/([^/]+?)$/) + if (!match) throw new Error('no match') + return { id: match[1] } + }, + format: (params: { id: string }) => ({ id: Number(params.id) }), + unformat: (params: { id: number }) => ({ id: String(params.id) }), + build: params => `/foo/${params.id}`, + }) + ) + + expect(matcher.resolve('/foo/1?a=a&b=b#h')).toMatchObject({ + path: '/foo/1', + params: { id: 1 }, + query: { a: 'a', b: 'b' }, + hash: '#h', + }) + expect(matcher.resolve('/foo/54?a=a&b=b#h')).toMatchObject({ + path: '/foo/54', + params: { id: 54 }, + query: { a: 'a', b: 'b' }, + hash: '#h', + }) + }) + + it('resolve string locations with query', () => { + const matcher = createCompiledMatcher() + matcher.addRoute( + createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER, { + match: query => ({ + id: Array.isArray(query.id) ? query.id[0] : query.id, + }), + format: (params: { id: string }) => ({ id: Number(params.id) }), + unformat: (params: { id: number }) => ({ id: String(params.id) }), + }) + ) + + expect(matcher.resolve('/foo?id=100&b=b#h')).toMatchObject({ + params: { id: 100 }, + path: '/foo', + query: { + id: '100', + b: 'b', }, - format: (params: { id: string }) => ({ id: Number(params.id) }), + hash: '#h', + }) + }) + + it('resolves string locations with hash', () => { + const matcher = createCompiledMatcher() + matcher.addRoute( + createMatcherPattern( + Symbol('foo'), + EMPTY_PATH_PATTERN_MATCHER, + undefined, + { + match: hash => hash, + format: hash => ({ a: hash.slice(1) }), + unformat: ({ a }) => '#a', + } + ) + ) + + expect(matcher.resolve('/foo?a=a&b=b#bar')).toMatchObject({ + hash: '#bar', + params: { a: 'bar' }, + path: '/foo', + query: { a: 'a', b: 'b' }, }) - ) + }) - expect(matcher.resolve('/foo/1?a=a&b=b#h')).toMatchObject({ - path: '/foo/1', - params: { id: 1 }, - query: { a: 'a', b: 'b' }, - hash: '#h', + it('returns a valid location with an empty `matched` array if no match', () => { + const matcher = createCompiledMatcher() + expect(matcher.resolve('/bar')).toMatchInlineSnapshot( + { + hash: '', + matched: [], + params: {}, + path: '/bar', + query: {}, + }, + ` + { + "fullPath": "/bar", + "hash": "", + "matched": [], + "name": Symbol(no-match), + "params": {}, + "path": "/bar", + "query": {}, + } + ` + ) }) - expect(matcher.resolve('/foo/54?a=a&b=b#h')).toMatchObject({ - path: '/foo/54', - params: { id: 54 }, - query: { a: 'a', b: 'b' }, - hash: '#h', + + it('resolves string locations with all', () => { + const matcher = createCompiledMatcher() + matcher.addRoute( + createMatcherPattern( + Symbol('foo'), + { + build: params => `/foo/${params.id}`, + match: path => { + const match = path.match(/^\/foo\/([^/]+?)$/) + if (!match) throw new Error('no match') + return { id: match[1] } + }, + format: params => ({ id: Number(params.id) }), + unformat: params => ({ id: String(params.id) }), + }, + { + match: query => ({ + id: Array.isArray(query.id) ? query.id[0] : query.id, + }), + format: params => ({ q: Number(params.id) }), + unformat: params => ({ id: String(params.q) }), + }, + { + match: hash => hash, + format: hash => ({ a: hash.slice(1) }), + unformat: ({ a }) => '#a', + } + ) + ) + + expect(matcher.resolve('/foo/1?id=100#bar')).toMatchObject({ + hash: '#bar', + params: { id: 1, q: 100, a: 'bar' }, + }) }) }) - it('resolve string locations with query', () => { - const matcher = createCompiledMatcher() - matcher.addRoute( - createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER, { - match: query => ({ - id: Array.isArray(query.id) ? query.id[0] : query.id, - }), - format: (params: { id: string }) => ({ id: Number(params.id) }), + describe('relative locations as strings', () => { + it('resolves a simple relative location', () => { + const matcher = createCompiledMatcher() + matcher.addRoute( + createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER) + ) + + expect( + matcher.resolve('foo', matcher.resolve('/nested/')) + ).toMatchObject({ + params: {}, + path: '/nested/foo', + query: {}, + hash: '', + }) + expect( + matcher.resolve('../foo', matcher.resolve('/nested/')) + ).toMatchObject({ + params: {}, + path: '/foo', + query: {}, + hash: '', + }) + expect( + matcher.resolve('./foo', matcher.resolve('/nested/')) + ).toMatchObject({ + params: {}, + path: '/nested/foo', + query: {}, + hash: '', }) - ) - - expect(matcher.resolve('/foo?id=100&b=b#h')).toMatchObject({ - params: { id: 100 }, - path: '/foo', - query: { - id: '100', - b: 'b', - }, - hash: '#h', }) }) - it('resolves string locations with hash', () => { - const matcher = createCompiledMatcher() - matcher.addRoute( - createMatcherPattern( - Symbol('foo'), - EMPTY_PATH_PATTERN_MATCHER, - undefined, - { - match: hash => hash, - format: hash => ({ a: hash.slice(1) }), - } + describe('named locations', () => { + it('resolves named locations with no params', () => { + const matcher = createCompiledMatcher() + matcher.addRoute( + createMatcherPattern('home', EMPTY_PATH_PATTERN_MATCHER) ) - ) - expect(matcher.resolve('/foo?a=a&b=b#bar')).toMatchObject({ - hash: '#bar', - params: { a: 'bar' }, - path: '/foo', - query: { a: 'a', b: 'b' }, + expect(matcher.resolve({ name: 'home', params: {} })).toMatchObject({ + name: 'home', + path: '/', + params: {}, + query: {}, + hash: '', + }) }) }) }) diff --git a/packages/router/src/new-matcher/matcher.ts b/packages/router/src/new-matcher/matcher.ts index bd48a1246..5d204f7bc 100644 --- a/packages/router/src/new-matcher/matcher.ts +++ b/packages/router/src/new-matcher/matcher.ts @@ -187,6 +187,12 @@ function transformObject( return encoded } +export const NO_MATCH_LOCATION = { + name: Symbol('no-match'), + params: {}, + matched: [], +} satisfies Omit + export function createCompiledMatcher(): NEW_Matcher_Resolve { const matchers = new Map() @@ -220,13 +226,21 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { if (parsedParams) break } } + + // No match location if (!parsedParams || !matcher) { - throw new Error(`No matcher found for location "${location}"`) + return { + ...url, + ...NO_MATCH_LOCATION, + query: transformObject(decode, decode, url.query), + hash: decode(url.hash), + } } + // TODO: build fullPath return { + ...url, name: matcher.name, - path: url.path, params: parsedParams, query: transformObject(decode, decode, url.query), hash: decode(url.hash), @@ -244,11 +258,6 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { const params = location.params ?? currentLocation!.params const mixedUnencodedParams = matcher.unformatParams(params) - // TODO: they could just throw? - if (!mixedUnencodedParams) { - throw new Error(`Missing params for matcher "${String(name)}"`) - } - const path = matcher.buildPath( // encode the values before building the path transformObject(String, encodeParam, mixedUnencodedParams[0]) From bf52c6e31c50c05c6ccd91a3f159fa7e5ea8c83a Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 26 Jun 2024 10:49:57 +0200 Subject: [PATCH 04/37] perf: parseURL minor improvements --- packages/router/__tests__/location.spec.ts | 68 +++++++++++++++++++++- packages/router/__tests__/router.spec.ts | 4 +- packages/router/src/location.ts | 43 ++++++++------ packages/router/src/query.ts | 3 +- 4 files changed, 93 insertions(+), 25 deletions(-) diff --git a/packages/router/__tests__/location.spec.ts b/packages/router/__tests__/location.spec.ts index 8ccd8a425..98dadf1e8 100644 --- a/packages/router/__tests__/location.spec.ts +++ b/packages/router/__tests__/location.spec.ts @@ -134,7 +134,7 @@ describe('parseURL', () => { }) }) - it('parses ? after the hash', () => { + it('avoids ? after the hash', () => { expect(parseURL('/foo#?a=one')).toEqual({ fullPath: '/foo#?a=one', path: '/foo', @@ -149,11 +149,75 @@ describe('parseURL', () => { }) }) + it('works with empty query', () => { + expect(parseURL('/foo?#hash')).toEqual({ + fullPath: '/foo?#hash', + path: '/foo', + hash: '#hash', + query: {}, + }) + expect(parseURL('/foo?')).toEqual({ + fullPath: '/foo?', + path: '/foo', + hash: '', + query: {}, + }) + }) + + it('works with empty hash', () => { + expect(parseURL('/foo#')).toEqual({ + fullPath: '/foo#', + path: '/foo', + hash: '#', + query: {}, + }) + expect(parseURL('/foo?#')).toEqual({ + fullPath: '/foo?#', + path: '/foo', + hash: '#', + query: {}, + }) + }) + + it('works with a relative paths', () => { + expect(parseURL('foo', '/parent/bar')).toEqual({ + fullPath: '/parent/foo', + path: '/parent/foo', + hash: '', + query: {}, + }) + expect(parseURL('./foo', '/parent/bar')).toEqual({ + fullPath: '/parent/foo', + path: '/parent/foo', + hash: '', + query: {}, + }) + expect(parseURL('../foo', '/parent/bar')).toEqual({ + fullPath: '/foo', + path: '/foo', + hash: '', + query: {}, + }) + + expect(parseURL('#foo', '/parent/bar')).toEqual({ + fullPath: '/parent/bar#foo', + path: '/parent/bar', + hash: '#foo', + query: {}, + }) + expect(parseURL('?o=o', '/parent/bar')).toEqual({ + fullPath: '/parent/bar?o=o', + path: '/parent/bar', + hash: '', + query: { o: 'o' }, + }) + }) + it('calls parseQuery', () => { const parseQuery = vi.fn() originalParseURL(parseQuery, '/?é=é&é=a') expect(parseQuery).toHaveBeenCalledTimes(1) - expect(parseQuery).toHaveBeenCalledWith('é=é&é=a') + expect(parseQuery).toHaveBeenCalledWith('?é=é&é=a') }) }) diff --git a/packages/router/__tests__/router.spec.ts b/packages/router/__tests__/router.spec.ts index bf11f31ba..f835f41e3 100644 --- a/packages/router/__tests__/router.spec.ts +++ b/packages/router/__tests__/router.spec.ts @@ -14,8 +14,6 @@ import { START_LOCATION_NORMALIZED } from '../src/location' import { vi, describe, expect, it, beforeAll } from 'vitest' import { mockWarn } from './vitest-mock-warn' -declare var __DEV__: boolean - const routes: RouteRecordRaw[] = [ { path: '/', component: components.Home, name: 'home' }, { path: '/home', redirect: '/' }, @@ -173,7 +171,7 @@ describe('Router', () => { const parseQuery = vi.fn(_ => ({})) const { router } = await newRouter({ parseQuery }) const to = router.resolve('/foo?bar=baz') - expect(parseQuery).toHaveBeenCalledWith('bar=baz') + expect(parseQuery).toHaveBeenCalledWith('?bar=baz') expect(to.query).toEqual({}) }) diff --git a/packages/router/src/location.ts b/packages/router/src/location.ts index 08c2b744b..e0aa54052 100644 --- a/packages/router/src/location.ts +++ b/packages/router/src/location.ts @@ -50,37 +50,43 @@ export function parseURL( searchString = '', hash = '' - // Could use URL and URLSearchParams but IE 11 doesn't support it - // TODO: move to new URL() + // NOTE: we could use URL and URLSearchParams but they are 2 to 5 times slower than this method const hashPos = location.indexOf('#') - let searchPos = location.indexOf('?') - // the hash appears before the search, so it's not part of the search string - if (hashPos < searchPos && hashPos >= 0) { - searchPos = -1 - } + // let searchPos = location.indexOf('?') + let searchPos = + hashPos >= 0 + ? // find the query string before the hash to avoid including a ? in the hash + // e.g. /foo#hash?query -> has no query + location.lastIndexOf('?', hashPos) + : location.indexOf('?') - if (searchPos > -1) { + if (searchPos >= 0) { path = location.slice(0, searchPos) - searchString = location.slice( - searchPos + 1, - hashPos > -1 ? hashPos : location.length - ) + searchString = + '?' + + location.slice(searchPos + 1, hashPos > 0 ? hashPos : location.length) query = parseQuery(searchString) } - if (hashPos > -1) { + if (hashPos >= 0) { + // TODO(major): path ||= path = path || location.slice(0, hashPos) // keep the # character hash = location.slice(hashPos, location.length) } - // no search and no query - path = resolveRelativePath(path != null ? path : location, currentLocation) - // empty path means a relative query or hash `?foo=f`, `#thing` + // TODO(major): path ?? location + path = resolveRelativePath( + path != null + ? path + : // empty path means a relative query or hash `?foo=f`, `#thing` + location, + currentLocation + ) return { - fullPath: path + (searchString && '?') + searchString + hash, + fullPath: path + searchString + hash, path, query, hash: decode(hash), @@ -207,11 +213,12 @@ export function resolveRelativePath(to: string, from: string): string { return to } + // resolve '' with '/anything' -> '/anything' if (!to) return from const fromSegments = from.split('/') const toSegments = to.split('/') - const lastToSegment = toSegments[toSegments.length - 1] + const lastToSegment: string | undefined = toSegments[toSegments.length - 1] // make . and ./ the same (../ === .., ../../ === ../..) // this is the same behavior as new URL() diff --git a/packages/router/src/query.ts b/packages/router/src/query.ts index 94d914618..75a8cc70b 100644 --- a/packages/router/src/query.ts +++ b/packages/router/src/query.ts @@ -56,8 +56,7 @@ export function parseQuery(search: string): LocationQuery { // avoid creating an object with an empty key and empty value // because of split('&') if (search === '' || search === '?') return query - const hasLeadingIM = search[0] === '?' - const searchParams = (hasLeadingIM ? search.slice(1) : search).split('&') + const searchParams = (search[0] === '?' ? search.slice(1) : search).split('&') for (let i = 0; i < searchParams.length; ++i) { // pre decode the + into space const searchParam = searchParams[i].replace(PLUS_RE, ' ') From a195cc8d7cdbb12c8bfbdb1938432a692a3c1757 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 26 Jun 2024 11:13:06 +0200 Subject: [PATCH 05/37] refactor: avoid double decoding --- .../router/src/new-matcher/matcher-pattern.ts | 2 +- packages/router/src/new-matcher/matcher.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/router/src/new-matcher/matcher-pattern.ts b/packages/router/src/new-matcher/matcher-pattern.ts index bb993658c..f368a04f8 100644 --- a/packages/router/src/new-matcher/matcher-pattern.ts +++ b/packages/router/src/new-matcher/matcher-pattern.ts @@ -44,7 +44,7 @@ export interface MatcherPattern { path: string query: MatcherQueryParams hash: string - }): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] + }): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] | null /** * Takes encoded params object to form the `path`, diff --git a/packages/router/src/new-matcher/matcher.ts b/packages/router/src/new-matcher/matcher.ts index 5d204f7bc..ec3b8d6cf 100644 --- a/packages/router/src/new-matcher/matcher.ts +++ b/packages/router/src/new-matcher/matcher.ts @@ -220,8 +220,9 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { if (params) { parsedParams = matcher.formatParams( transformObject(String, decode, params[0]), - transformObject(decode, decode, params[1]), - decode(params[2]) + // already decoded + params[1], + params[2] ) if (parsedParams) break } @@ -232,8 +233,9 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { return { ...url, ...NO_MATCH_LOCATION, - query: transformObject(decode, decode, url.query), - hash: decode(url.hash), + // already decoded + query: url.query, + hash: url.hash, } } @@ -242,8 +244,9 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { ...url, name: matcher.name, params: parsedParams, - query: transformObject(decode, decode, url.query), - hash: decode(url.hash), + // already decoded + query: url.query, + hash: url.hash, matched: [], } } else { From eda52f6e26dcffeeba64bf59a45f4152bacbccad Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 26 Jun 2024 11:13:37 +0200 Subject: [PATCH 06/37] refactor: add fullPath --- packages/router/src/new-matcher/matcher.ts | 33 +++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/router/src/new-matcher/matcher.ts b/packages/router/src/new-matcher/matcher.ts index ec3b8d6cf..9e39f44e6 100644 --- a/packages/router/src/new-matcher/matcher.ts +++ b/packages/router/src/new-matcher/matcher.ts @@ -1,4 +1,9 @@ -import { type LocationQuery, parseQuery, normalizeQuery } from '../query' +import { + type LocationQuery, + parseQuery, + normalizeQuery, + stringifyQuery, +} from '../query' import type { MatcherPattern } from './matcher-pattern' import { warn } from '../warning' import { @@ -6,7 +11,7 @@ import { encodePath, encodeQueryValue as _encodeQueryValue, } from '../encoding' -import { parseURL } from '../location' +import { parseURL, stringifyURL } from '../location' import type { MatcherLocationAsName, MatcherLocationAsRelative, @@ -84,6 +89,7 @@ type TODO = any export interface NEW_MatcherLocationResolved { name: MatcherName + fullPath: string path: string // TODO: generics? params: MatcherParamsFormatted @@ -137,6 +143,7 @@ export function decode( } return '' + text } +// TODO: just add the null check to the original function in encoding.ts interface FnStableNull { (value: null | undefined): null @@ -191,7 +198,10 @@ export const NO_MATCH_LOCATION = { name: Symbol('no-match'), params: {}, matched: [], -} satisfies Omit +} satisfies Omit< + NEW_MatcherLocationResolved, + 'path' | 'hash' | 'query' | 'fullPath' +> export function createCompiledMatcher(): NEW_Matcher_Resolve { const matchers = new Map() @@ -239,7 +249,6 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { } } - // TODO: build fullPath return { ...url, name: matcher.name, @@ -266,16 +275,20 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { transformObject(String, encodeParam, mixedUnencodedParams[0]) ) + // TODO: should pick query from the params but also from the location and merge them + const query = { + ...normalizeQuery(location.query), + // ...matcher.extractQuery(mixedUnencodedParams[1]) + } + const hash = mixedUnencodedParams[2] ?? location.hash ?? '' + return { name, + fullPath: stringifyURL(stringifyQuery, { path, query: {}, hash }), path, params, - hash: mixedUnencodedParams[2] ?? location.hash ?? '', - // TODO: should pick query from the params but also from the location and merge them - query: { - ...normalizeQuery(location.query), - // ...matcher.extractQuery(mixedUnencodedParams[1]) - }, + hash, + query, matched: [], } } From 6f2da87eb8e4b39df2710144733d06e24edd1da9 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 26 Jun 2024 15:52:51 +0200 Subject: [PATCH 07/37] chore: static path matcher --- .../index.ts | 0 .../matcher-location.ts | 0 .../matcher-pattern.ts | 109 ++++++++++++------ .../matcher.spec.ts | 34 +++--- .../matcher.test-d.ts | 6 +- .../matcher.ts | 45 +++++--- .../new-route-resolver/matchers/path-param.ts | 48 ++++++++ .../matchers/path-static.ts | 15 +++ 8 files changed, 181 insertions(+), 76 deletions(-) rename packages/router/src/{new-matcher => new-route-resolver}/index.ts (100%) rename packages/router/src/{new-matcher => new-route-resolver}/matcher-location.ts (100%) rename packages/router/src/{new-matcher => new-route-resolver}/matcher-pattern.ts (53%) rename packages/router/src/{new-matcher => new-route-resolver}/matcher.spec.ts (85%) rename packages/router/src/{new-matcher => new-route-resolver}/matcher.test-d.ts (64%) rename packages/router/src/{new-matcher => new-route-resolver}/matcher.ts (87%) create mode 100644 packages/router/src/new-route-resolver/matchers/path-param.ts create mode 100644 packages/router/src/new-route-resolver/matchers/path-static.ts diff --git a/packages/router/src/new-matcher/index.ts b/packages/router/src/new-route-resolver/index.ts similarity index 100% rename from packages/router/src/new-matcher/index.ts rename to packages/router/src/new-route-resolver/index.ts diff --git a/packages/router/src/new-matcher/matcher-location.ts b/packages/router/src/new-route-resolver/matcher-location.ts similarity index 100% rename from packages/router/src/new-matcher/matcher-location.ts rename to packages/router/src/new-route-resolver/matcher-location.ts diff --git a/packages/router/src/new-matcher/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts similarity index 53% rename from packages/router/src/new-matcher/matcher-pattern.ts rename to packages/router/src/new-route-resolver/matcher-pattern.ts index f368a04f8..a9f8f5e83 100644 --- a/packages/router/src/new-matcher/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -6,19 +6,34 @@ import type { } from './matcher' import type { MatcherParamsFormatted } from './matcher-location' +/** + * Allows to match, extract, parse and build a path. Tailored to iterate through route records and check if a location + * matches. When it cannot match, it returns `null` instead of throwing to not force a try/catch block around each + * iteration in for loops. + */ export interface MatcherPattern { /** * Name of the matcher. Unique across all matchers. */ name: MatcherName + // TODO: add route record to be able to build the matched + /** - * Extracts from a formatted, unencoded params object the ones belonging to the path, query, and hash. - * @param params - Params to extract from. + * Extracts from an unencoded, parsed params object the ones belonging to the path, query, and hash in their + * serialized format but still unencoded. e.g. `{ id: 2 }` -> `{ id: '2' }`. If any params are missing, return `null`. + * + * @param params - Params to extract from. If any params are missing, throws */ - unformatParams( + matchParams( params: MatcherParamsFormatted - ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] + ): + | readonly [ + pathParams: MatcherPathParams, + queryParams: MatcherQueryParams, + hashParam: string + ] + | null /** * Extracts the defined params from an encoded path, query, and hash parsed from a URL. Does not apply formatting or @@ -44,23 +59,34 @@ export interface MatcherPattern { path: string query: MatcherQueryParams hash: string - }): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] | null + }): + | readonly [ + pathParams: MatcherPathParams, + queryParams: MatcherQueryParams, + hashParam: string + ] + | null /** * Takes encoded params object to form the `path`, - * @param path - encoded path params + * + * @param pathParams - encoded path params */ - buildPath(path: MatcherPathParams): string + buildPath(pathParams: MatcherPathParams): string /** - * Runs the decoded params through the formatting functions if any. - * @param params - Params to format. + * Runs the decoded params through the parsing functions if any, allowing them to be in be of a type other than a + * string. + * + * @param pathParams - decoded path params + * @param queryParams - decoded query params + * @param hashParam - decoded hash param */ - formatParams( - path: MatcherPathParams, - query: MatcherQueryParams, - hash: string - ): MatcherParamsFormatted + parseParams( + pathParams: MatcherPathParams, + queryParams: MatcherQueryParams, + hashParam: string + ): MatcherParamsFormatted | null } interface PatternParamOptions_Base { @@ -69,7 +95,11 @@ interface PatternParamOptions_Base { default?: T | (() => T) } -export interface PatternParamOptions extends PatternParamOptions_Base {} +export interface PatternPathParamOptions + extends PatternParamOptions_Base { + re: RegExp + keys: string[] +} export interface PatternQueryParamOptions extends PatternParamOptions_Base { @@ -82,16 +112,16 @@ export interface PatternHashParamOptions extends PatternParamOptions_Base {} export interface MatcherPatternPath { - build(path: MatcherPathParams): string + buildPath(path: MatcherPathParams): string match(path: string): MatcherPathParams - format(params: MatcherPathParams): MatcherParamsFormatted - unformat(params: MatcherParamsFormatted): MatcherPathParams + parse?(params: MatcherPathParams): MatcherParamsFormatted + serialize?(params: MatcherParamsFormatted): MatcherPathParams } export interface MatcherPatternQuery { match(query: MatcherQueryParams): MatcherQueryParams - format(params: MatcherQueryParams): MatcherParamsFormatted - unformat(params: MatcherParamsFormatted): MatcherQueryParams + parse(params: MatcherQueryParams): MatcherParamsFormatted + serialize(params: MatcherParamsFormatted): MatcherQueryParams } export interface MatcherPatternHash { @@ -100,8 +130,8 @@ export interface MatcherPatternHash { * @param hash - encoded hash */ match(hash: string): string - format(hash: string): MatcherParamsFormatted - unformat(params: MatcherParamsFormatted): string + parse(hash: string): MatcherParamsFormatted + serialize(params: MatcherParamsFormatted): string } export class MatcherPatternImpl implements MatcherPattern { @@ -116,37 +146,42 @@ export class MatcherPatternImpl implements MatcherPattern { path: string query: MatcherQueryParams hash: string - }): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] { - return [ - this.path.match(location.path), - this.query?.match(location.query) ?? {}, - this.hash?.match(location.hash) ?? '', - ] + }) { + // TODO: is this performant? Compare to a check with `null + try { + return [ + this.path.match(location.path), + this.query?.match(location.query) ?? {}, + this.hash?.match(location.hash) ?? '', + ] as const + } catch { + return null + } } - formatParams( + parseParams( path: MatcherPathParams, query: MatcherQueryParams, hash: string ): MatcherParamsFormatted { return { - ...this.path.format(path), - ...this.query?.format(query), - ...this.hash?.format(hash), + ...this.path.parse?.(path), + ...this.query?.parse(query), + ...this.hash?.parse(hash), } } buildPath(path: MatcherPathParams): string { - return this.path.build(path) + return this.path.buildPath(path) } - unformatParams( + matchParams( params: MatcherParamsFormatted ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] { return [ - this.path.unformat(params), - this.query?.unformat(params) ?? {}, - this.hash?.unformat(params) ?? '', + this.path.serialize?.(params) ?? {}, + this.query?.serialize(params) ?? {}, + this.hash?.serialize(params) ?? '', ] } } diff --git a/packages/router/src/new-matcher/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts similarity index 85% rename from packages/router/src/new-matcher/matcher.spec.ts rename to packages/router/src/new-route-resolver/matcher.spec.ts index 9c6ccb2fe..52d8b208a 100644 --- a/packages/router/src/new-matcher/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -10,9 +10,9 @@ function createMatcherPattern( const EMPTY_PATH_PATTERN_MATCHER = { match: (path: string) => ({}), - format: (params: {}) => ({}), - unformat: (params: {}) => ({}), - build: () => '/', + parse: (params: {}) => ({}), + serialize: (params: {}) => ({}), + buildPath: () => '/', } satisfies MatcherPatternPath describe('Matcher', () => { @@ -42,9 +42,9 @@ describe('Matcher', () => { if (!match) throw new Error('no match') return { id: match[1] } }, - format: (params: { id: string }) => ({ id: Number(params.id) }), - unformat: (params: { id: number }) => ({ id: String(params.id) }), - build: params => `/foo/${params.id}`, + parse: (params: { id: string }) => ({ id: Number(params.id) }), + serialize: (params: { id: number }) => ({ id: String(params.id) }), + buildPath: params => `/foo/${params.id}`, }) ) @@ -69,8 +69,8 @@ describe('Matcher', () => { match: query => ({ id: Array.isArray(query.id) ? query.id[0] : query.id, }), - format: (params: { id: string }) => ({ id: Number(params.id) }), - unformat: (params: { id: number }) => ({ id: String(params.id) }), + parse: (params: { id: string }) => ({ id: Number(params.id) }), + serialize: (params: { id: number }) => ({ id: String(params.id) }), }) ) @@ -94,8 +94,8 @@ describe('Matcher', () => { undefined, { match: hash => hash, - format: hash => ({ a: hash.slice(1) }), - unformat: ({ a }) => '#a', + parse: hash => ({ a: hash.slice(1) }), + serialize: ({ a }) => '#a', } ) ) @@ -138,26 +138,26 @@ describe('Matcher', () => { createMatcherPattern( Symbol('foo'), { - build: params => `/foo/${params.id}`, + buildPath: params => `/foo/${params.id}`, match: path => { const match = path.match(/^\/foo\/([^/]+?)$/) if (!match) throw new Error('no match') return { id: match[1] } }, - format: params => ({ id: Number(params.id) }), - unformat: params => ({ id: String(params.id) }), + parse: params => ({ id: Number(params.id) }), + serialize: params => ({ id: String(params.id) }), }, { match: query => ({ id: Array.isArray(query.id) ? query.id[0] : query.id, }), - format: params => ({ q: Number(params.id) }), - unformat: params => ({ id: String(params.q) }), + parse: params => ({ q: Number(params.id) }), + serialize: params => ({ id: String(params.q) }), }, { match: hash => hash, - format: hash => ({ a: hash.slice(1) }), - unformat: ({ a }) => '#a', + parse: hash => ({ a: hash.slice(1) }), + serialize: ({ a }) => '#a', } ) ) diff --git a/packages/router/src/new-matcher/matcher.test-d.ts b/packages/router/src/new-route-resolver/matcher.test-d.ts similarity index 64% rename from packages/router/src/new-matcher/matcher.test-d.ts rename to packages/router/src/new-route-resolver/matcher.test-d.ts index fbf150e2e..412cb0719 100644 --- a/packages/router/src/new-matcher/matcher.test-d.ts +++ b/packages/router/src/new-route-resolver/matcher.test-d.ts @@ -1,5 +1,5 @@ import { describe, it } from 'vitest' -import { NEW_MatcherLocationResolved, createCompiledMatcher } from './matcher' +import { NEW_LocationResolved, createCompiledMatcher } from './matcher' describe('Matcher', () => { it('resolves locations', () => { @@ -7,10 +7,10 @@ describe('Matcher', () => { matcher.resolve('/foo') // @ts-expect-error: needs currentLocation matcher.resolve('foo') - matcher.resolve('foo', {} as NEW_MatcherLocationResolved) + matcher.resolve('foo', {} as NEW_LocationResolved) matcher.resolve({ name: 'foo', params: {} }) // @ts-expect-error: needs currentLocation matcher.resolve({ params: { id: 1 } }) - matcher.resolve({ params: { id: 1 } }, {} as NEW_MatcherLocationResolved) + matcher.resolve({ params: { id: 1 } }, {} as NEW_LocationResolved) }) }) diff --git a/packages/router/src/new-matcher/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts similarity index 87% rename from packages/router/src/new-matcher/matcher.ts rename to packages/router/src/new-route-resolver/matcher.ts index 9e39f44e6..4aa742e93 100644 --- a/packages/router/src/new-matcher/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -21,13 +21,13 @@ import type { export type MatcherName = string | symbol /** - * Matcher capable of resolving route locations. + * Manage and resolve routes. Also handles the encoding, decoding, parsing and serialization of params, query, and hash. */ -export interface NEW_Matcher_Resolve { +export interface RouteResolver { /** * Resolves an absolute location (like `/path/to/somewhere`). */ - resolve(absoluteLocation: `/${string}`): NEW_MatcherLocationResolved + resolve(absoluteLocation: `/${string}`): NEW_LocationResolved /** * Resolves a string location relative to another location. A relative location can be `./same-folder`, @@ -35,13 +35,13 @@ export interface NEW_Matcher_Resolve { */ resolve( relativeLocation: string, - currentLocation: NEW_MatcherLocationResolved - ): NEW_MatcherLocationResolved + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved /** * Resolves a location by its name. Any required params or query must be passed in the `options` argument. */ - resolve(location: MatcherLocationAsName): NEW_MatcherLocationResolved + resolve(location: MatcherLocationAsName): NEW_LocationResolved /** * Resolves a location by its path. Any required query must be passed. @@ -56,8 +56,8 @@ export interface NEW_Matcher_Resolve { */ resolve( relativeLocation: MatcherLocationAsRelative, - currentLocation: NEW_MatcherLocationResolved - ): NEW_MatcherLocationResolved + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved addRoute(matcher: MatcherPattern, parent?: MatcherPattern): void removeRoute(matcher: MatcherPattern): void @@ -66,11 +66,11 @@ export interface NEW_Matcher_Resolve { type MatcherResolveArgs = | [absoluteLocation: `/${string}`] - | [relativeLocation: string, currentLocation: NEW_MatcherLocationResolved] + | [relativeLocation: string, currentLocation: NEW_LocationResolved] | [location: MatcherLocationAsName] | [ relativeLocation: MatcherLocationAsRelative, - currentLocation: NEW_MatcherLocationResolved + currentLocation: NEW_LocationResolved ] /** @@ -87,7 +87,7 @@ export interface NEW_Matcher_Dynamic { type TODO = any -export interface NEW_MatcherLocationResolved { +export interface NEW_LocationResolved { name: MatcherName fullPath: string path: string @@ -198,12 +198,9 @@ export const NO_MATCH_LOCATION = { name: Symbol('no-match'), params: {}, matched: [], -} satisfies Omit< - NEW_MatcherLocationResolved, - 'path' | 'hash' | 'query' | 'fullPath' -> +} satisfies Omit -export function createCompiledMatcher(): NEW_Matcher_Resolve { +export function createCompiledMatcher(): RouteResolver { const matchers = new Map() // TODO: allow custom encode/decode functions @@ -216,7 +213,7 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { // ) // const decodeQuery = transformObject.bind(null, decode, decode) - function resolve(...args: MatcherResolveArgs): NEW_MatcherLocationResolved { + function resolve(...args: MatcherResolveArgs): NEW_LocationResolved { const [location, currentLocation] = args if (typeof location === 'string') { // string location, e.g. '/foo', '../bar', 'baz' @@ -228,7 +225,7 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { for (matcher of matchers.values()) { const params = matcher.matchLocation(url) if (params) { - parsedParams = matcher.formatParams( + parsedParams = matcher.parseParams( transformObject(String, decode, params[0]), // already decoded params[1], @@ -268,7 +265,17 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { // unencoded params in a formatted form that the user came up with const params = location.params ?? currentLocation!.params - const mixedUnencodedParams = matcher.unformatParams(params) + const mixedUnencodedParams = matcher.matchParams(params) + + if (!mixedUnencodedParams) { + throw new Error( + `Invalid params for matcher "${String(name)}":\n${JSON.stringify( + params, + null, + 2 + )}` + ) + } const path = matcher.buildPath( // encode the values before building the path diff --git a/packages/router/src/new-route-resolver/matchers/path-param.ts b/packages/router/src/new-route-resolver/matchers/path-param.ts new file mode 100644 index 000000000..e17e78068 --- /dev/null +++ b/packages/router/src/new-route-resolver/matchers/path-param.ts @@ -0,0 +1,48 @@ +import type { MatcherPathParams } from '../matcher' +import { MatcherParamsFormatted } from '../matcher-location' +import type { + MatcherPatternPath, + PatternPathParamOptions, +} from '../matcher-pattern' + +export class PatterParamPath implements MatcherPatternPath { + options: Required, 'default'>> & { + default: undefined | (() => T) | T + } + + constructor(options: PatternPathParamOptions) { + this.options = { + set: String, + default: undefined, + ...options, + } + } + + match(path: string): MatcherPathParams { + const match = this.options.re.exec(path)?.groups ?? {} + if (!match) { + throw new Error( + `Path "${path}" does not match the pattern "${String( + this.options.re + )}"}` + ) + } + const params: MatcherPathParams = {} + for (let i = 0; i < this.options.keys.length; i++) { + params[this.options.keys[i]] = match[i + 1] ?? null + } + return params + } + + buildPath(path: MatcherPathParams): string { + throw new Error('Method not implemented.') + } + + parse(params: MatcherPathParams): MatcherParamsFormatted { + throw new Error('Method not implemented.') + } + + serialize(params: MatcherParamsFormatted): MatcherPathParams { + throw new Error('Method not implemented.') + } +} diff --git a/packages/router/src/new-route-resolver/matchers/path-static.ts b/packages/router/src/new-route-resolver/matchers/path-static.ts new file mode 100644 index 000000000..0d6ebd3fe --- /dev/null +++ b/packages/router/src/new-route-resolver/matchers/path-static.ts @@ -0,0 +1,15 @@ +import type { MatcherPatternPath } from '../matcher-pattern' + +export class PathMatcherStatic implements MatcherPatternPath { + constructor(private path: string) {} + + match(path: string) { + if (this.path === path) return {} + throw new Error() + // return this.path === path ? {} : null + } + + buildPath() { + return this.path + } +} From ef57c3effcc1318a19b7cd2f4a2a7474f67a878d Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 26 Jun 2024 16:12:40 +0200 Subject: [PATCH 08/37] chore: error matches --- .../src/new-route-resolver/matcher-pattern.ts | 12 +++++++++--- .../src/new-route-resolver/matchers/errors.ts | 13 +++++++++++++ .../src/new-route-resolver/matchers/path-static.ts | 6 +++--- packages/router/vue-router-auto.d.ts | 5 +---- 4 files changed, 26 insertions(+), 10 deletions(-) create mode 100644 packages/router/src/new-route-resolver/matchers/errors.ts diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts index a9f8f5e83..2e066bf87 100644 --- a/packages/router/src/new-route-resolver/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -9,7 +9,8 @@ import type { MatcherParamsFormatted } from './matcher-location' /** * Allows to match, extract, parse and build a path. Tailored to iterate through route records and check if a location * matches. When it cannot match, it returns `null` instead of throwing to not force a try/catch block around each - * iteration in for loops. + * iteration in for loops. Not meant to handle encoding/decoding. It expects different parts of the URL to be either + * encoded or decoded depending on the method. */ export interface MatcherPattern { /** @@ -36,8 +37,8 @@ export interface MatcherPattern { | null /** - * Extracts the defined params from an encoded path, query, and hash parsed from a URL. Does not apply formatting or - * decoding. If the URL does not match the pattern, returns `null`. + * Extracts the defined params from an encoded path, decoded query, and decoded hash parsed from a URL. Does not apply + * formatting or decoding. If the URL does not match the pattern, returns `null`. * * @example * ```ts @@ -54,6 +55,11 @@ export interface MatcherPattern { * pattern.parseLocation({ path: '/foo', query: {}, hash: '#hello' }) * // null // the query param is missing * ``` + * + * @param location - URL parts to extract from + * @param location.path - encoded path + * @param location.query - decoded query + * @param location.hash - decoded hash */ matchLocation(location: { path: string diff --git a/packages/router/src/new-route-resolver/matchers/errors.ts b/packages/router/src/new-route-resolver/matchers/errors.ts new file mode 100644 index 000000000..51c5574a8 --- /dev/null +++ b/packages/router/src/new-route-resolver/matchers/errors.ts @@ -0,0 +1,13 @@ +export class MatchMiss extends Error { + name = 'MatchMiss' +} + +export const miss = () => new MatchMiss() + +export class ParamInvalid extends Error { + name = 'ParamInvalid' + constructor(public param: string) { + super() + } +} +export const invalid = (param: string) => new ParamInvalid(param) diff --git a/packages/router/src/new-route-resolver/matchers/path-static.ts b/packages/router/src/new-route-resolver/matchers/path-static.ts index 0d6ebd3fe..7d5e968ff 100644 --- a/packages/router/src/new-route-resolver/matchers/path-static.ts +++ b/packages/router/src/new-route-resolver/matchers/path-static.ts @@ -1,12 +1,12 @@ import type { MatcherPatternPath } from '../matcher-pattern' +import { miss } from './errors' -export class PathMatcherStatic implements MatcherPatternPath { +export class MatcherPathStatic implements MatcherPatternPath { constructor(private path: string) {} match(path: string) { if (this.path === path) return {} - throw new Error() - // return this.path === path ? {} : null + throw miss() } buildPath() { diff --git a/packages/router/vue-router-auto.d.ts b/packages/router/vue-router-auto.d.ts index 56e8a0979..797a70599 100644 --- a/packages/router/vue-router-auto.d.ts +++ b/packages/router/vue-router-auto.d.ts @@ -1,4 +1 @@ -/** - * Extended by unplugin-vue-router to create typed routes. - */ -export interface RouteNamedMap {} +// augmented by unplugin-vue-router From 7ed433b07b0e4a1f4e8e4f5ee7d57eeb2a8a2bbb Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 26 Jun 2024 16:22:24 +0200 Subject: [PATCH 09/37] test: static matcher --- .../src/new-route-resolver/matcher-pattern.ts | 2 +- .../matchers/path-static.spec.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 packages/router/src/new-route-resolver/matchers/path-static.spec.ts diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts index 2e066bf87..3b2bbdbfd 100644 --- a/packages/router/src/new-route-resolver/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -153,7 +153,7 @@ export class MatcherPatternImpl implements MatcherPattern { query: MatcherQueryParams hash: string }) { - // TODO: is this performant? Compare to a check with `null + // TODO: is this performant? bench compare to a check with `null try { return [ this.path.match(location.path), diff --git a/packages/router/src/new-route-resolver/matchers/path-static.spec.ts b/packages/router/src/new-route-resolver/matchers/path-static.spec.ts new file mode 100644 index 000000000..aae50551c --- /dev/null +++ b/packages/router/src/new-route-resolver/matchers/path-static.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { MatcherPathStatic } from './path-static' + +describe('PathStaticMatcher', () => { + it('matches', () => { + expect(new MatcherPathStatic('/').match('/')).toEqual({}) + expect(() => new MatcherPathStatic('/').match('/no')).toThrowError() + expect(new MatcherPathStatic('/ok/ok').match('/ok/ok')).toEqual({}) + expect(() => new MatcherPathStatic('/ok/ok').match('/ok/no')).toThrowError() + }) + + it('builds path', () => { + expect(new MatcherPathStatic('/').buildPath()).toBe('/') + expect(new MatcherPathStatic('/ok').buildPath()).toBe('/ok') + expect(new MatcherPathStatic('/ok/ok').buildPath()).toEqual('/ok/ok') + }) +}) From 8e650e66a72714da436e1be30e74353bc6817c38 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 9 Jul 2024 09:54:37 +0200 Subject: [PATCH 10/37] refactor: unused code --- packages/router/src/typed-routes/route-location.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/router/src/typed-routes/route-location.ts b/packages/router/src/typed-routes/route-location.ts index 3be525760..c277fd268 100644 --- a/packages/router/src/typed-routes/route-location.ts +++ b/packages/router/src/typed-routes/route-location.ts @@ -2,7 +2,6 @@ import type { RouteLocationOptions, RouteQueryAndHash, _RouteLocationBase, - RouteParamsGeneric, RouteLocationMatched, RouteParamsRawGeneric, } from '../types' @@ -50,7 +49,6 @@ export type RouteLocationTypedList< */ export interface RouteLocationNormalizedGeneric extends _RouteLocationBase { name: RouteRecordNameGeneric - params: RouteParamsGeneric /** * Array of {@link RouteRecordNormalized} */ From 49eb85014fc61f7077d12bade2be7d42a5b4e744 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 4 Dec 2024 16:51:03 +0100 Subject: [PATCH 11/37] chore: ignore temp tsconfig --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index faa347817..9053a1101 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ local.log _selenium-server.log packages/*/LICENSE tracing_output +tsconfig.vitest-temp.json From 731c9ee319c7a75712f2b56a20e95771d1fd8d2c Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Thu, 5 Dec 2024 11:16:12 +0100 Subject: [PATCH 12/37] test: better IM after hash --- packages/router/__tests__/location.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/router/__tests__/location.spec.ts b/packages/router/__tests__/location.spec.ts index 98dadf1e8..0511ad89f 100644 --- a/packages/router/__tests__/location.spec.ts +++ b/packages/router/__tests__/location.spec.ts @@ -134,18 +134,18 @@ describe('parseURL', () => { }) }) - it('avoids ? after the hash', () => { + it('correctly parses a ? after the hash', () => { expect(parseURL('/foo#?a=one')).toEqual({ fullPath: '/foo#?a=one', path: '/foo', hash: '#?a=one', query: {}, }) - expect(parseURL('/foo/#?a=one')).toEqual({ - fullPath: '/foo/#?a=one', + expect(parseURL('/foo/?a=two#?a=one')).toEqual({ + fullPath: '/foo/?a=two#?a=one', path: '/foo/', hash: '#?a=one', - query: {}, + query: { a: 'two' }, }) }) From d1dba9c6da88da9e24b2895827a0adf3fc14d991 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Thu, 5 Dec 2024 11:24:40 +0100 Subject: [PATCH 13/37] test: url parsing --- packages/router/__tests__/location.spec.ts | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/router/__tests__/location.spec.ts b/packages/router/__tests__/location.spec.ts index 0511ad89f..7b7497687 100644 --- a/packages/router/__tests__/location.spec.ts +++ b/packages/router/__tests__/location.spec.ts @@ -156,12 +156,24 @@ describe('parseURL', () => { hash: '#hash', query: {}, }) + expect(parseURL('/foo#hash')).toEqual({ + fullPath: '/foo#hash', + path: '/foo', + hash: '#hash', + query: {}, + }) expect(parseURL('/foo?')).toEqual({ fullPath: '/foo?', path: '/foo', hash: '', query: {}, }) + expect(parseURL('/foo')).toEqual({ + fullPath: '/foo', + path: '/foo', + hash: '', + query: {}, + }) }) it('works with empty hash', () => { @@ -177,6 +189,12 @@ describe('parseURL', () => { hash: '#', query: {}, }) + expect(parseURL('/foo')).toEqual({ + fullPath: '/foo', + path: '/foo', + hash: '', + query: {}, + }) }) it('works with a relative paths', () => { @@ -198,7 +216,20 @@ describe('parseURL', () => { hash: '', query: {}, }) + // cannot go below root + expect(parseURL('../../foo', '/parent/bar')).toEqual({ + fullPath: '/foo', + path: '/foo', + hash: '', + query: {}, + }) + expect(parseURL('', '/parent/bar')).toEqual({ + fullPath: '/parent/bar', + path: '/parent/bar', + hash: '', + query: {}, + }) expect(parseURL('#foo', '/parent/bar')).toEqual({ fullPath: '/parent/bar#foo', path: '/parent/bar', From 9dcfd423156577a66b50b575781738599c37513b Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 6 Dec 2024 09:40:16 +0100 Subject: [PATCH 14/37] refactor: simplify parseURL --- packages/router/src/location.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/router/src/location.ts b/packages/router/src/location.ts index e0aa54052..0811c35c2 100644 --- a/packages/router/src/location.ts +++ b/packages/router/src/location.ts @@ -52,19 +52,19 @@ export function parseURL( // NOTE: we could use URL and URLSearchParams but they are 2 to 5 times slower than this method const hashPos = location.indexOf('#') - // let searchPos = location.indexOf('?') - let searchPos = - hashPos >= 0 - ? // find the query string before the hash to avoid including a ? in the hash - // e.g. /foo#hash?query -> has no query - location.lastIndexOf('?', hashPos) - : location.indexOf('?') + let searchPos = location.indexOf('?') + + // This ensures that the ? is not part of the hash + // e.g. /foo#hash?query -> has no query + searchPos = hashPos >= 0 && searchPos > hashPos ? -1 : searchPos if (searchPos >= 0) { path = location.slice(0, searchPos) - searchString = - '?' + - location.slice(searchPos + 1, hashPos > 0 ? hashPos : location.length) + // keep the ? char + searchString = location.slice( + searchPos, + hashPos > 0 ? hashPos : location.length + ) query = parseQuery(searchString) } @@ -213,7 +213,7 @@ export function resolveRelativePath(to: string, from: string): string { return to } - // resolve '' with '/anything' -> '/anything' + // resolve to: '' with from: '/anything' -> '/anything' if (!to) return from const fromSegments = from.split('/') From e76d25669b0b9c67848a04a1e3951aadc8536c62 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 6 Dec 2024 09:41:57 +0100 Subject: [PATCH 15/37] chore: comment [skip ci] --- packages/router/src/location.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/router/src/location.ts b/packages/router/src/location.ts index 0811c35c2..0ca40799c 100644 --- a/packages/router/src/location.ts +++ b/packages/router/src/location.ts @@ -63,6 +63,7 @@ export function parseURL( // keep the ? char searchString = location.slice( searchPos, + // hashPos cannot be 0 because there is a search section in the location hashPos > 0 ? hashPos : location.length ) From b57cb21f2d9d8637ef88638fb11a7a9d577509ee Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 6 Dec 2024 10:03:39 +0100 Subject: [PATCH 16/37] chore: comments --- .../router/src/new-route-resolver/matcher-location.ts | 7 ++++++- .../router/src/new-route-resolver/matchers/errors.ts | 11 +++++++++++ packages/router/tsconfig.json | 11 +++-------- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/router/src/new-route-resolver/matcher-location.ts b/packages/router/src/new-route-resolver/matcher-location.ts index bb44326b2..1bfcd9a16 100644 --- a/packages/router/src/new-route-resolver/matcher-location.ts +++ b/packages/router/src/new-route-resolver/matcher-location.ts @@ -1,7 +1,9 @@ import type { LocationQueryRaw } from '../query' import type { MatcherName } from './matcher' -// the matcher can serialize and deserialize params +/** + * Generic object of params that can be passed to a matcher. + */ export type MatcherParamsFormatted = Record export interface MatcherLocationAsName { @@ -10,6 +12,9 @@ export interface MatcherLocationAsName { query?: LocationQueryRaw hash?: string + /** + * A path is ignored if `name` is provided. + */ path?: undefined } diff --git a/packages/router/src/new-route-resolver/matchers/errors.ts b/packages/router/src/new-route-resolver/matchers/errors.ts index 51c5574a8..4ad69cc4c 100644 --- a/packages/router/src/new-route-resolver/matchers/errors.ts +++ b/packages/router/src/new-route-resolver/matchers/errors.ts @@ -1,9 +1,20 @@ +/** + * NOTE: for these classes to keep the same code we need to tell TS with `"useDefineForClassFields": true` in the `tsconfig.json` + */ + +/** + * Error throw when a matcher miss + */ export class MatchMiss extends Error { name = 'MatchMiss' } +// NOTE: not sure about having a helper. Using `new MatchMiss(description?)` is good enough export const miss = () => new MatchMiss() +/** + * Error throw when a param is invalid when parsing params from path, query, or hash. + */ export class ParamInvalid extends Error { name = 'ParamInvalid' constructor(public param: string) { diff --git a/packages/router/tsconfig.json b/packages/router/tsconfig.json index 41fc6c378..318f5c658 100644 --- a/packages/router/tsconfig.json +++ b/packages/router/tsconfig.json @@ -22,19 +22,14 @@ "noImplicitReturns": true, "strict": true, "skipLibCheck": true, + "useDefineForClassFields": true, // "noUncheckedIndexedAccess": true, "experimentalDecorators": true, "resolveJsonModule": true, "esModuleInterop": true, "removeComments": false, "jsx": "preserve", - "lib": [ - "esnext", - "dom" - ], - "types": [ - "node", - "vite/client" - ] + "lib": ["esnext", "dom"], + "types": ["node", "vite/client"] } } From d46275810fb4d10677b75fee1e11cbbc7d1e7ffb Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Fri, 6 Dec 2024 22:01:44 +0100 Subject: [PATCH 17/37] refactor: renames and minor changes --- .../new-route-resolver/matcher-location.ts | 2 +- .../src/new-route-resolver/matcher-pattern.ts | 9 ++-- .../src/new-route-resolver/matcher.test-d.ts | 48 ++++++++++++++----- .../router/src/new-route-resolver/matcher.ts | 37 ++++++++++---- 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/packages/router/src/new-route-resolver/matcher-location.ts b/packages/router/src/new-route-resolver/matcher-location.ts index 1bfcd9a16..c205a4564 100644 --- a/packages/router/src/new-route-resolver/matcher-location.ts +++ b/packages/router/src/new-route-resolver/matcher-location.ts @@ -6,7 +6,7 @@ import type { MatcherName } from './matcher' */ export type MatcherParamsFormatted = Record -export interface MatcherLocationAsName { +export interface MatcherLocationAsNamed { name: MatcherName params: MatcherParamsFormatted query?: LocationQueryRaw diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts index 3b2bbdbfd..049109e11 100644 --- a/packages/router/src/new-route-resolver/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -47,11 +47,11 @@ export interface MatcherPattern { * query: { used: String }, // we require a `used` query param * }) * // /?used=2 - * pattern.parseLocation({ path: '/', query: { used: '' }, hash: '' }) // null + * pattern.parseLocation({ path: '/', query: { used: '' }, hash: '' }) // null becauso no /foo * // /foo?used=2¬Used¬Used=2#hello * pattern.parseLocation({ path: '/foo', query: { used: '2', notUsed: [null, '2']}, hash: '#hello' }) - * // { used: '2' } // we extract the required params - * // /foo?used=2#hello + * // [{}, { used: '2' }, {}]// we extract the required params + * // /foo?other=2#hello * pattern.parseLocation({ path: '/foo', query: {}, hash: '#hello' }) * // null // the query param is missing * ``` @@ -109,6 +109,7 @@ export interface PatternPathParamOptions export interface PatternQueryParamOptions extends PatternParamOptions_Base { + // FIXME: can be removed? seems to be the same as above get: (value: MatcherQueryParamsValue) => T set?: (value: T) => MatcherQueryParamsValue } @@ -153,7 +154,7 @@ export class MatcherPatternImpl implements MatcherPattern { query: MatcherQueryParams hash: string }) { - // TODO: is this performant? bench compare to a check with `null + // TODO: is this performant? bench compare to a check with `null` try { return [ this.path.match(location.path), diff --git a/packages/router/src/new-route-resolver/matcher.test-d.ts b/packages/router/src/new-route-resolver/matcher.test-d.ts index 412cb0719..bb45c5129 100644 --- a/packages/router/src/new-route-resolver/matcher.test-d.ts +++ b/packages/router/src/new-route-resolver/matcher.test-d.ts @@ -1,16 +1,42 @@ -import { describe, it } from 'vitest' +import { describe, expectTypeOf, it } from 'vitest' import { NEW_LocationResolved, createCompiledMatcher } from './matcher' describe('Matcher', () => { - it('resolves locations', () => { - const matcher = createCompiledMatcher() - matcher.resolve('/foo') - // @ts-expect-error: needs currentLocation - matcher.resolve('foo') - matcher.resolve('foo', {} as NEW_LocationResolved) - matcher.resolve({ name: 'foo', params: {} }) - // @ts-expect-error: needs currentLocation - matcher.resolve({ params: { id: 1 } }) - matcher.resolve({ params: { id: 1 } }, {} as NEW_LocationResolved) + const matcher = createCompiledMatcher() + + describe('matcher.resolve()', () => { + it('resolves absolute string locations', () => { + expectTypeOf( + matcher.resolve('/foo') + ).toEqualTypeOf() + }) + + it('fails on non absolute location without a currentLocation', () => { + // @ts-expect-error: needs currentLocation + matcher.resolve('foo') + }) + + it('resolves relative locations', () => { + expectTypeOf( + matcher.resolve('foo', {} as NEW_LocationResolved) + ).toEqualTypeOf() + }) + + it('resolved named locations', () => { + expectTypeOf( + matcher.resolve({ name: 'foo', params: {} }) + ).toEqualTypeOf() + }) + + it('fails on object relative location without a currentLocation', () => { + // @ts-expect-error: needs currentLocation + matcher.resolve({ params: { id: 1 } }) + }) + + it('resolves object relative locations with a currentLocation', () => { + expectTypeOf( + matcher.resolve({ params: { id: 1 } }, {} as NEW_LocationResolved) + ).toEqualTypeOf() + }) }) }) diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts index 4aa742e93..31b1d0319 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -13,7 +13,7 @@ import { } from '../encoding' import { parseURL, stringifyURL } from '../location' import type { - MatcherLocationAsName, + MatcherLocationAsNamed, MatcherLocationAsRelative, MatcherParamsFormatted, } from './matcher-location' @@ -31,7 +31,7 @@ export interface RouteResolver { /** * Resolves a string location relative to another location. A relative location can be `./same-folder`, - * `../parent-folder`, or even `same-folder`. + * `../parent-folder`, `same-folder`, or even `?page=2`. */ resolve( relativeLocation: string, @@ -41,7 +41,7 @@ export interface RouteResolver { /** * Resolves a location by its name. Any required params or query must be passed in the `options` argument. */ - resolve(location: MatcherLocationAsName): NEW_LocationResolved + resolve(location: MatcherLocationAsNamed): NEW_LocationResolved /** * Resolves a location by its path. Any required query must be passed. @@ -67,7 +67,7 @@ export interface RouteResolver { type MatcherResolveArgs = | [absoluteLocation: `/${string}`] | [relativeLocation: string, currentLocation: NEW_LocationResolved] - | [location: MatcherLocationAsName] + | [location: MatcherLocationAsNamed] | [ relativeLocation: MatcherLocationAsRelative, currentLocation: NEW_LocationResolved @@ -108,7 +108,11 @@ export type MatcherPathParams = Record export type MatcherQueryParamsValue = string | null | Array export type MatcherQueryParams = Record -export function applyToParams( +/** + * Apply a function to all properties in an object. It's used to encode/decode params and queries. + * @internal + */ +export function applyFnToObject( fn: (v: string | number | null | undefined) => R, params: MatcherPathParams | LocationQuery | undefined ): Record { @@ -195,7 +199,7 @@ function transformObject( } export const NO_MATCH_LOCATION = { - name: Symbol('no-match'), + name: __DEV__ ? Symbol('no-match') : Symbol(), params: {}, matched: [], } satisfies Omit @@ -215,8 +219,9 @@ export function createCompiledMatcher(): RouteResolver { function resolve(...args: MatcherResolveArgs): NEW_LocationResolved { const [location, currentLocation] = args + + // string location, e.g. '/foo', '../bar', 'baz', '?page=1' if (typeof location === 'string') { - // string location, e.g. '/foo', '../bar', 'baz' const url = parseURL(parseQuery, location, currentLocation?.path) let matcher: MatcherPattern | undefined @@ -257,6 +262,21 @@ export function createCompiledMatcher(): RouteResolver { } } else { // relative location or by name + if (__DEV__ && location.name == null && currentLocation == null) { + console.warn( + `Cannot resolve an unnamed relative location without a current location. This will throw in production.`, + location + ) + return { + ...NO_MATCH_LOCATION, + fullPath: '/', + path: '/', + query: {}, + hash: '', + } + } + + // either one of them must be defined and is catched by the dev only warn above const name = location.name ?? currentLocation!.name const matcher = matchers.get(name) if (!matcher) { @@ -264,7 +284,8 @@ export function createCompiledMatcher(): RouteResolver { } // unencoded params in a formatted form that the user came up with - const params = location.params ?? currentLocation!.params + const params: MatcherParamsFormatted = + location.params ?? currentLocation!.params const mixedUnencodedParams = matcher.matchParams(params) if (!mixedUnencodedParams) { From 1ca2a13808b5d80020aac065529bdfe5c94fdded Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 9 Dec 2024 21:57:13 +0100 Subject: [PATCH 18/37] refactor: simplify matcher interfaces --- .../new-route-resolver/matcher-location.ts | 6 + .../src/new-route-resolver/matcher.spec.ts | 250 +++++++++--------- .../src/new-route-resolver/matcher.test-d.ts | 4 +- .../router/src/new-route-resolver/matcher.ts | 143 +++++++--- .../new-route-resolver/new-matcher-pattern.ts | 197 ++++++++++++++ packages/router/src/query.ts | 3 +- 6 files changed, 438 insertions(+), 165 deletions(-) create mode 100644 packages/router/src/new-route-resolver/new-matcher-pattern.ts diff --git a/packages/router/src/new-route-resolver/matcher-location.ts b/packages/router/src/new-route-resolver/matcher-location.ts index c205a4564..3744e8cec 100644 --- a/packages/router/src/new-route-resolver/matcher-location.ts +++ b/packages/router/src/new-route-resolver/matcher-location.ts @@ -6,8 +6,14 @@ import type { MatcherName } from './matcher' */ export type MatcherParamsFormatted = Record +/** + * Empty object in TS. + */ +export type EmptyParams = Record + export interface MatcherLocationAsNamed { name: MatcherName + // FIXME: should this be optional? params: MatcherParamsFormatted query?: LocationQueryRaw hash?: string diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index 52d8b208a..3cb67af19 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest' -import { MatcherPatternImpl, MatcherPatternPath } from './matcher-pattern' -import { createCompiledMatcher } from './matcher' +import { MatcherPatternImpl } from './matcher-pattern' +import { createCompiledMatcher, NO_MATCH_LOCATION } from './matcher' +import { + MatcherPatternParams_Base, + MatcherPattern, + MatcherPatternPath, + MatcherPatternQuery, +} from './new-matcher-pattern' +import { miss } from './matchers/errors' +import { EmptyParams } from './matcher-location' function createMatcherPattern( ...args: ConstructorParameters @@ -8,54 +16,121 @@ function createMatcherPattern( return new MatcherPatternImpl(...args) } -const EMPTY_PATH_PATTERN_MATCHER = { - match: (path: string) => ({}), - parse: (params: {}) => ({}), - serialize: (params: {}) => ({}), - buildPath: () => '/', -} satisfies MatcherPatternPath +const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{ pathMatch: string }> = { + match(path) { + return { pathMatch: path } + }, + build({ pathMatch }) { + return pathMatch + }, +} + +const EMPTY_PATH_PATTERN_MATCHER: MatcherPatternPath = { + match: path => { + if (path !== '/') { + throw miss() + } + return {} + }, + build: () => '/', +} + +const USER_ID_PATH_PATTERN_MATCHER: MatcherPatternPath<{ id: number }> = { + match(value) { + const match = value.match(/^\/users\/(\d+)$/) + if (!match?.[1]) { + throw miss() + } + const id = Number(match[1]) + if (Number.isNaN(id)) { + throw miss() + } + return { id } + }, + build({ id }) { + return `/users/${id}` + }, +} + +const PAGE_QUERY_PATTERN_MATCHER: MatcherPatternQuery<{ page: number }> = { + match: query => { + const page = Number(query.page) + return { + page: Number.isNaN(page) ? 1 : page, + } + }, + build: params => ({ page: String(params.page) }), +} satisfies MatcherPatternQuery<{ page: number }> + +const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base< + string, + { hash: string | null } +> = { + match: hash => ({ hash: hash ? hash.slice(1) : null }), + build: ({ hash }) => (hash ? `#${hash}` : ''), +} + +const EMPTY_PATH_ROUTE = { + name: 'no params', + path: EMPTY_PATH_PATTERN_MATCHER, +} satisfies MatcherPattern + +const USER_ID_ROUTE = { + name: 'user-id', + path: USER_ID_PATH_PATTERN_MATCHER, +} satisfies MatcherPattern describe('Matcher', () => { + describe('adding and removing', () => { + it('add static path', () => { + const matcher = createCompiledMatcher() + matcher.addRoute(EMPTY_PATH_ROUTE) + }) + + it('adds dynamic path', () => { + const matcher = createCompiledMatcher() + matcher.addRoute(USER_ID_ROUTE) + }) + }) + describe('resolve()', () => { describe('absolute locationss as strings', () => { it('resolves string locations with no params', () => { const matcher = createCompiledMatcher() - matcher.addRoute( - createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER) - ) + matcher.addRoute(EMPTY_PATH_ROUTE) - expect(matcher.resolve('/foo?a=a&b=b#h')).toMatchObject({ - path: '/foo', + expect(matcher.resolve('/?a=a&b=b#h')).toMatchObject({ + path: '/', params: {}, query: { a: 'a', b: 'b' }, hash: '#h', }) }) + it('resolves a not found string', () => { + const matcher = createCompiledMatcher() + expect(matcher.resolve('/bar?q=1#hash')).toEqual({ + ...NO_MATCH_LOCATION, + fullPath: '/bar?q=1#hash', + path: '/bar', + query: { q: '1' }, + hash: '#hash', + matched: [], + }) + }) + it('resolves string locations with params', () => { const matcher = createCompiledMatcher() - matcher.addRoute( - // /users/:id - createMatcherPattern(Symbol('foo'), { - match: (path: string) => { - const match = path.match(/^\/foo\/([^/]+?)$/) - if (!match) throw new Error('no match') - return { id: match[1] } - }, - parse: (params: { id: string }) => ({ id: Number(params.id) }), - serialize: (params: { id: number }) => ({ id: String(params.id) }), - buildPath: params => `/foo/${params.id}`, - }) - ) - - expect(matcher.resolve('/foo/1?a=a&b=b#h')).toMatchObject({ - path: '/foo/1', + matcher.addRoute(USER_ID_ROUTE) + + expect(matcher.resolve('/users/1?a=a&b=b#h')).toMatchObject({ + path: '/users/1', params: { id: 1 }, query: { a: 'a', b: 'b' }, hash: '#h', }) - expect(matcher.resolve('/foo/54?a=a&b=b#h')).toMatchObject({ - path: '/foo/54', + expect(matcher.resolve('/users/54?a=a&b=b#h')).toMatchObject({ + path: '/users/54', params: { id: 54 }, query: { a: 'a', b: 'b' }, hash: '#h', @@ -64,21 +139,16 @@ describe('Matcher', () => { it('resolve string locations with query', () => { const matcher = createCompiledMatcher() - matcher.addRoute( - createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER, { - match: query => ({ - id: Array.isArray(query.id) ? query.id[0] : query.id, - }), - parse: (params: { id: string }) => ({ id: Number(params.id) }), - serialize: (params: { id: number }) => ({ id: String(params.id) }), - }) - ) - - expect(matcher.resolve('/foo?id=100&b=b#h')).toMatchObject({ - params: { id: 100 }, + matcher.addRoute({ + path: ANY_PATH_PATTERN_MATCHER, + query: PAGE_QUERY_PATTERN_MATCHER, + }) + + expect(matcher.resolve('/foo?page=100&b=b#h')).toMatchObject({ + params: { page: 100 }, path: '/foo', query: { - id: '100', + page: '100', b: 'b', }, hash: '#h', @@ -87,84 +157,29 @@ describe('Matcher', () => { it('resolves string locations with hash', () => { const matcher = createCompiledMatcher() - matcher.addRoute( - createMatcherPattern( - Symbol('foo'), - EMPTY_PATH_PATTERN_MATCHER, - undefined, - { - match: hash => hash, - parse: hash => ({ a: hash.slice(1) }), - serialize: ({ a }) => '#a', - } - ) - ) + matcher.addRoute({ + path: ANY_PATH_PATTERN_MATCHER, + hash: ANY_HASH_PATTERN_MATCHER, + }) expect(matcher.resolve('/foo?a=a&b=b#bar')).toMatchObject({ hash: '#bar', - params: { a: 'bar' }, + params: { hash: 'bar' }, path: '/foo', query: { a: 'a', b: 'b' }, }) }) - it('returns a valid location with an empty `matched` array if no match', () => { + it('combines path, query and hash params', () => { const matcher = createCompiledMatcher() - expect(matcher.resolve('/bar')).toMatchInlineSnapshot( - { - hash: '', - matched: [], - params: {}, - path: '/bar', - query: {}, - }, - ` - { - "fullPath": "/bar", - "hash": "", - "matched": [], - "name": Symbol(no-match), - "params": {}, - "path": "/bar", - "query": {}, - } - ` - ) - }) + matcher.addRoute({ + path: USER_ID_PATH_PATTERN_MATCHER, + query: PAGE_QUERY_PATTERN_MATCHER, + hash: ANY_HASH_PATTERN_MATCHER, + }) - it('resolves string locations with all', () => { - const matcher = createCompiledMatcher() - matcher.addRoute( - createMatcherPattern( - Symbol('foo'), - { - buildPath: params => `/foo/${params.id}`, - match: path => { - const match = path.match(/^\/foo\/([^/]+?)$/) - if (!match) throw new Error('no match') - return { id: match[1] } - }, - parse: params => ({ id: Number(params.id) }), - serialize: params => ({ id: String(params.id) }), - }, - { - match: query => ({ - id: Array.isArray(query.id) ? query.id[0] : query.id, - }), - parse: params => ({ q: Number(params.id) }), - serialize: params => ({ id: String(params.q) }), - }, - { - match: hash => hash, - parse: hash => ({ a: hash.slice(1) }), - serialize: ({ a }) => '#a', - } - ) - ) - - expect(matcher.resolve('/foo/1?id=100#bar')).toMatchObject({ - hash: '#bar', - params: { id: 1, q: 100, a: 'bar' }, + expect(matcher.resolve('/users/24?page=100#bar')).toMatchObject({ + params: { id: 24, page: 100, hash: 'bar' }, }) }) }) @@ -172,9 +187,7 @@ describe('Matcher', () => { describe('relative locations as strings', () => { it('resolves a simple relative location', () => { const matcher = createCompiledMatcher() - matcher.addRoute( - createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER) - ) + matcher.addRoute({ path: ANY_PATH_PATTERN_MATCHER }) expect( matcher.resolve('foo', matcher.resolve('/nested/')) @@ -206,9 +219,10 @@ describe('Matcher', () => { describe('named locations', () => { it('resolves named locations with no params', () => { const matcher = createCompiledMatcher() - matcher.addRoute( - createMatcherPattern('home', EMPTY_PATH_PATTERN_MATCHER) - ) + matcher.addRoute({ + name: 'home', + path: EMPTY_PATH_PATTERN_MATCHER, + }) expect(matcher.resolve({ name: 'home', params: {} })).toMatchObject({ name: 'home', diff --git a/packages/router/src/new-route-resolver/matcher.test-d.ts b/packages/router/src/new-route-resolver/matcher.test-d.ts index bb45c5129..c50731a1e 100644 --- a/packages/router/src/new-route-resolver/matcher.test-d.ts +++ b/packages/router/src/new-route-resolver/matcher.test-d.ts @@ -1,8 +1,8 @@ import { describe, expectTypeOf, it } from 'vitest' -import { NEW_LocationResolved, createCompiledMatcher } from './matcher' +import { NEW_LocationResolved, RouteResolver } from './matcher' describe('Matcher', () => { - const matcher = createCompiledMatcher() + const matcher: RouteResolver = {} as any describe('matcher.resolve()', () => { it('resolves absolute string locations', () => { diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts index 31b1d0319..c6af61e98 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -4,7 +4,12 @@ import { normalizeQuery, stringifyQuery, } from '../query' -import type { MatcherPattern } from './matcher-pattern' +import type { + MatcherPattern, + MatcherPatternHash, + MatcherPatternPath, + MatcherPatternQuery, +} from './new-matcher-pattern' import { warn } from '../warning' import { SLASH_RE, @@ -17,13 +22,17 @@ import type { MatcherLocationAsRelative, MatcherParamsFormatted, } from './matcher-location' +import { RouteRecordRaw } from 'test-dts' +/** + * Allowed types for a matcher name. + */ export type MatcherName = string | symbol /** * Manage and resolve routes. Also handles the encoding, decoding, parsing and serialization of params, query, and hash. */ -export interface RouteResolver { +export interface RouteResolver { /** * Resolves an absolute location (like `/path/to/somewhere`). */ @@ -59,8 +68,8 @@ export interface RouteResolver { currentLocation: NEW_LocationResolved ): NEW_LocationResolved - addRoute(matcher: MatcherPattern, parent?: MatcherPattern): void - removeRoute(matcher: MatcherPattern): void + addRoute(matcher: Matcher, parent?: MatcherNormalized): MatcherNormalized + removeRoute(matcher: MatcherNormalized): void clearRoutes(): void } @@ -204,7 +213,39 @@ export const NO_MATCH_LOCATION = { matched: [], } satisfies Omit -export function createCompiledMatcher(): RouteResolver { +// FIXME: later on, the MatcherRecord should be compatible with RouteRecordRaw (which can miss a path, have children, etc) + +export interface MatcherRecordRaw { + name?: MatcherName + + path: MatcherPatternPath + + query?: MatcherPatternQuery + + hash?: MatcherPatternHash + + children?: MatcherRecordRaw[] +} + +// const a: RouteRecordRaw = {} as any + +/** + * Build the `matched` array of a record that includes all parent records from the root to the current one. + */ +function buildMatched(record: MatcherPattern): MatcherPattern[] { + const matched: MatcherPattern[] = [] + let node: MatcherPattern | undefined = record + while (node) { + matched.unshift(node) + node = node.parent + } + return matched +} + +export function createCompiledMatcher(): RouteResolver< + MatcherRecordRaw, + MatcherPattern +> { const matchers = new Map() // TODO: allow custom encode/decode functions @@ -225,23 +266,39 @@ export function createCompiledMatcher(): RouteResolver { const url = parseURL(parseQuery, location, currentLocation?.path) let matcher: MatcherPattern | undefined + let matched: NEW_LocationResolved['matched'] | undefined let parsedParams: MatcherParamsFormatted | null | undefined for (matcher of matchers.values()) { - const params = matcher.matchLocation(url) - if (params) { - parsedParams = matcher.parseParams( - transformObject(String, decode, params[0]), - // already decoded - params[1], - params[2] + // match the path because the path matcher only needs to be matched here + // match the hash because only the deepest child matters + // End up by building up the matched array, (reversed so it goes from + // root to child) and then match and merge all queries + try { + const pathParams = matcher.path.match(url.path) + const hashParams = matcher.hash?.match(url.hash) + matched = buildMatched(matcher) + const queryParams: MatcherQueryParams = Object.assign( + {}, + ...matched.map(matcher => matcher.query?.match(url.query)) ) + // TODO: test performance + // for (const matcher of matched) { + // Object.assign(queryParams, matcher.query?.match(url.query)) + // } + + parsedParams = { ...pathParams, ...queryParams, ...hashParams } + // console.log('parsedParams', parsedParams) + if (parsedParams) break + } catch (e) { + // for debugging tests + // console.log('❌ ERROR matching', e) } } // No match location - if (!parsedParams || !matcher) { + if (!parsedParams || !matched) { return { ...url, ...NO_MATCH_LOCATION, @@ -253,12 +310,13 @@ export function createCompiledMatcher(): RouteResolver { return { ...url, - name: matcher.name, + // matcher exists if matched exists + name: matcher!.name, params: parsedParams, // already decoded query: url.query, hash: url.hash, - matched: [], + matched, } } else { // relative location or by name @@ -284,46 +342,43 @@ export function createCompiledMatcher(): RouteResolver { } // unencoded params in a formatted form that the user came up with - const params: MatcherParamsFormatted = - location.params ?? currentLocation!.params - const mixedUnencodedParams = matcher.matchParams(params) - - if (!mixedUnencodedParams) { - throw new Error( - `Invalid params for matcher "${String(name)}":\n${JSON.stringify( - params, - null, - 2 - )}` - ) + const params: MatcherParamsFormatted = { + ...currentLocation?.params, + ...location.params, } - - const path = matcher.buildPath( - // encode the values before building the path - transformObject(String, encodeParam, mixedUnencodedParams[0]) + const path = matcher.path.build(params) + const hash = matcher.hash?.build(params) ?? '' + const matched = buildMatched(matcher) + const query = Object.assign( + { + ...currentLocation?.query, + ...normalizeQuery(location.query), + }, + ...matched.map(matcher => matcher.query?.build(params)) ) - // TODO: should pick query from the params but also from the location and merge them - const query = { - ...normalizeQuery(location.query), - // ...matcher.extractQuery(mixedUnencodedParams[1]) - } - const hash = mixedUnencodedParams[2] ?? location.hash ?? '' - return { name, - fullPath: stringifyURL(stringifyQuery, { path, query: {}, hash }), + fullPath: stringifyURL(stringifyQuery, { path, query, hash }), path, - params, - hash, query, - matched: [], + hash, + params, + matched, } } } - function addRoute(matcher: MatcherPattern, parent?: MatcherPattern) { - matchers.set(matcher.name, matcher) + function addRoute(record: MatcherRecordRaw, parent?: MatcherPattern) { + const name = record.name ?? (__DEV__ ? Symbol('unnamed-route') : Symbol()) + // FIXME: proper normalization of the record + const normalizedRecord: MatcherPattern = { + ...record, + name, + parent, + } + matchers.set(name, normalizedRecord) + return normalizedRecord } function removeRoute(matcher: MatcherPattern) { diff --git a/packages/router/src/new-route-resolver/new-matcher-pattern.ts b/packages/router/src/new-route-resolver/new-matcher-pattern.ts new file mode 100644 index 000000000..f231490cd --- /dev/null +++ b/packages/router/src/new-route-resolver/new-matcher-pattern.ts @@ -0,0 +1,197 @@ +import { MatcherName, MatcherQueryParams } from './matcher' +import { EmptyParams, MatcherParamsFormatted } from './matcher-location' +import { MatchMiss, miss } from './matchers/errors' + +export interface MatcherLocation { + /** + * Encoded path + */ + path: string + + /** + * Decoded query. + */ + query: MatcherQueryParams + + /** + * Decoded hash. + */ + hash: string +} + +export interface OLD_MatcherPattern { + /** + * Name of the matcher. Unique across all matchers. + */ + name: MatcherName + + match(location: MatcherLocation): TParams | null + + toLocation(params: TParams): MatcherLocation +} + +export interface MatcherPattern { + /** + * Name of the matcher. Unique across all matchers. + */ + name: MatcherName + + path: MatcherPatternPath + query?: MatcherPatternQuery + hash?: MatcherPatternHash + + parent?: MatcherPattern +} + +export interface MatcherPatternParams_Base< + TIn = string, + TOut extends MatcherParamsFormatted = MatcherParamsFormatted +> { + match(value: TIn): TOut + + build(params: TOut): TIn + // get: (value: MatcherQueryParamsValue) => T + // set?: (value: T) => MatcherQueryParamsValue + // default?: T | (() => T) +} + +export interface MatcherPatternPath< + TParams extends MatcherParamsFormatted = MatcherParamsFormatted +> extends MatcherPatternParams_Base {} + +export class MatcherPatternPathStatic + implements MatcherPatternPath +{ + constructor(private path: string) {} + + match(path: string): EmptyParams { + if (path !== this.path) { + throw miss() + } + return {} + } + + build(): string { + return this.path + } +} +// example of a static matcher built at runtime +// new MatcherPatternPathStatic('/') + +// example of a generated matcher at build time +const HomePathMatcher = { + match: path => { + if (path !== '/') { + throw miss() + } + return {} + }, + build: () => '/', +} satisfies MatcherPatternPath + +export interface MatcherPatternQuery< + TParams extends MatcherParamsFormatted = MatcherParamsFormatted +> extends MatcherPatternParams_Base {} + +const PaginationQueryMatcher = { + match: query => { + const page = Number(query.page) + return { + page: Number.isNaN(page) ? 1 : page, + } + }, + build: params => ({ page: String(params.page) }), +} satisfies MatcherPatternQuery<{ page: number }> + +export interface MatcherPatternHash< + TParams extends MatcherParamsFormatted = MatcherParamsFormatted +> extends MatcherPatternParams_Base {} + +const HeaderHashMatcher = { + match: hash => + hash.startsWith('#') + ? { + header: hash.slice(1), + } + : {}, // null also works + build: ({ header }) => (header ? `#${header}` : ''), +} satisfies MatcherPatternHash<{ header?: string }> + +export class MatcherPatternImpl< + PathParams extends MatcherParamsFormatted, + QueryParams extends MatcherParamsFormatted = EmptyParams, + HashParams extends MatcherParamsFormatted = EmptyParams +> implements OLD_MatcherPattern +{ + parent: MatcherPatternImpl | null = null + children: MatcherPatternImpl[] = [] + + constructor( + public name: MatcherName, + private path: MatcherPatternPath, + private query?: MatcherPatternQuery, + private hash?: MatcherPatternHash + ) {} + + /** + * Matches a parsed query against the matcher and all of the parents. + * @param query - query to match + * @returns matched + * @throws {MatchMiss} if the query does not match + */ + queryMatch(query: MatcherQueryParams): QParams { + // const queryParams: QParams = {} as QParams + const queryParams: QParams[] = [] + let current: MatcherPatternImpl< + MatcherParamsFormatted, + MatcherParamsFormatted, + MatcherParamsFormatted + > | null = this + + while (current) { + queryParams.push(current.query?.match(query) as QParams) + current = current.parent + } + // we give the later matchers precedence + return Object.assign({}, ...queryParams.reverse()) + } + + queryBuild(params: QParams): MatcherQueryParams { + const query: MatcherQueryParams = {} + let current: MatcherPatternImpl< + MatcherParamsFormatted, + MatcherParamsFormatted, + MatcherParamsFormatted + > | null = this + while (current) { + Object.assign(query, current.query?.build(params)) + current = current.parent + } + return query + } + + match( + location: MatcherLocation + ): (PathParams & QParams & HashParams) | null { + try { + const pathParams = this.path.match(location.path) + const queryParams = this.queryMatch(location.query) + const hashParams = this.hash?.match(location.hash) ?? ({} as HashParams) + + return { ...pathParams, ...queryParams, ...hashParams } + } catch (err) {} + + return null + } + + toLocation(params: PathParams & QueryParams & HashParams): MatcherLocation { + return { + path: this.path.build(params), + query: this.query?.build(params) ?? {}, + hash: this.hash?.build(params) ?? '', + } + } +} + +// const matcher = new MatcherPatternImpl('name', HomePathMatcher, PaginationQueryMatcher, HeaderHashMatcher) +// matcher.match({ path: '/', query: {}, hash: '' })!.page diff --git a/packages/router/src/query.ts b/packages/router/src/query.ts index 75a8cc70b..55e77c714 100644 --- a/packages/router/src/query.ts +++ b/packages/router/src/query.ts @@ -89,9 +89,10 @@ export function parseQuery(search: string): LocationQuery { * @param query - query object to stringify * @returns string version of the query without the leading `?` */ -export function stringifyQuery(query: LocationQueryRaw): string { +export function stringifyQuery(query: LocationQueryRaw | undefined): string { let search = '' for (let key in query) { + // FIXME: we could do search ||= '?' so that the returned value already has the leading ? const value = query[key] key = encodeQueryKey(key) if (value == null) { From 82da018901b6086dfb0a31c736981c28220ce09b Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 9 Dec 2024 22:12:37 +0100 Subject: [PATCH 19/37] refactor: remove unused code --- .../src/new-route-resolver/matcher-pattern.ts | 194 ------------------ .../src/new-route-resolver/matcher.spec.ts | 7 - .../router/src/new-route-resolver/matcher.ts | 52 ++--- .../new-route-resolver/matchers/path-param.ts | 48 ----- .../matchers/path-static.spec.ts | 17 -- .../matchers/path-static.ts | 15 -- .../new-route-resolver/new-matcher-pattern.ts | 148 +------------ 7 files changed, 19 insertions(+), 462 deletions(-) delete mode 100644 packages/router/src/new-route-resolver/matcher-pattern.ts delete mode 100644 packages/router/src/new-route-resolver/matchers/path-param.ts delete mode 100644 packages/router/src/new-route-resolver/matchers/path-static.spec.ts delete mode 100644 packages/router/src/new-route-resolver/matchers/path-static.ts diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts deleted file mode 100644 index 049109e11..000000000 --- a/packages/router/src/new-route-resolver/matcher-pattern.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type { - MatcherName, - MatcherPathParams, - MatcherQueryParams, - MatcherQueryParamsValue, -} from './matcher' -import type { MatcherParamsFormatted } from './matcher-location' - -/** - * Allows to match, extract, parse and build a path. Tailored to iterate through route records and check if a location - * matches. When it cannot match, it returns `null` instead of throwing to not force a try/catch block around each - * iteration in for loops. Not meant to handle encoding/decoding. It expects different parts of the URL to be either - * encoded or decoded depending on the method. - */ -export interface MatcherPattern { - /** - * Name of the matcher. Unique across all matchers. - */ - name: MatcherName - - // TODO: add route record to be able to build the matched - - /** - * Extracts from an unencoded, parsed params object the ones belonging to the path, query, and hash in their - * serialized format but still unencoded. e.g. `{ id: 2 }` -> `{ id: '2' }`. If any params are missing, return `null`. - * - * @param params - Params to extract from. If any params are missing, throws - */ - matchParams( - params: MatcherParamsFormatted - ): - | readonly [ - pathParams: MatcherPathParams, - queryParams: MatcherQueryParams, - hashParam: string - ] - | null - - /** - * Extracts the defined params from an encoded path, decoded query, and decoded hash parsed from a URL. Does not apply - * formatting or decoding. If the URL does not match the pattern, returns `null`. - * - * @example - * ```ts - * const pattern = createPattern('/foo', { - * path: {}, // nothing is used from the path - * query: { used: String }, // we require a `used` query param - * }) - * // /?used=2 - * pattern.parseLocation({ path: '/', query: { used: '' }, hash: '' }) // null becauso no /foo - * // /foo?used=2¬Used¬Used=2#hello - * pattern.parseLocation({ path: '/foo', query: { used: '2', notUsed: [null, '2']}, hash: '#hello' }) - * // [{}, { used: '2' }, {}]// we extract the required params - * // /foo?other=2#hello - * pattern.parseLocation({ path: '/foo', query: {}, hash: '#hello' }) - * // null // the query param is missing - * ``` - * - * @param location - URL parts to extract from - * @param location.path - encoded path - * @param location.query - decoded query - * @param location.hash - decoded hash - */ - matchLocation(location: { - path: string - query: MatcherQueryParams - hash: string - }): - | readonly [ - pathParams: MatcherPathParams, - queryParams: MatcherQueryParams, - hashParam: string - ] - | null - - /** - * Takes encoded params object to form the `path`, - * - * @param pathParams - encoded path params - */ - buildPath(pathParams: MatcherPathParams): string - - /** - * Runs the decoded params through the parsing functions if any, allowing them to be in be of a type other than a - * string. - * - * @param pathParams - decoded path params - * @param queryParams - decoded query params - * @param hashParam - decoded hash param - */ - parseParams( - pathParams: MatcherPathParams, - queryParams: MatcherQueryParams, - hashParam: string - ): MatcherParamsFormatted | null -} - -interface PatternParamOptions_Base { - get: (value: MatcherQueryParamsValue) => T - set?: (value: T) => MatcherQueryParamsValue - default?: T | (() => T) -} - -export interface PatternPathParamOptions - extends PatternParamOptions_Base { - re: RegExp - keys: string[] -} - -export interface PatternQueryParamOptions - extends PatternParamOptions_Base { - // FIXME: can be removed? seems to be the same as above - get: (value: MatcherQueryParamsValue) => T - set?: (value: T) => MatcherQueryParamsValue -} - -// TODO: allow more than strings -export interface PatternHashParamOptions - extends PatternParamOptions_Base {} - -export interface MatcherPatternPath { - buildPath(path: MatcherPathParams): string - match(path: string): MatcherPathParams - parse?(params: MatcherPathParams): MatcherParamsFormatted - serialize?(params: MatcherParamsFormatted): MatcherPathParams -} - -export interface MatcherPatternQuery { - match(query: MatcherQueryParams): MatcherQueryParams - parse(params: MatcherQueryParams): MatcherParamsFormatted - serialize(params: MatcherParamsFormatted): MatcherQueryParams -} - -export interface MatcherPatternHash { - /** - * Check if the hash matches a pattern and returns it, still encoded with its leading `#`. - * @param hash - encoded hash - */ - match(hash: string): string - parse(hash: string): MatcherParamsFormatted - serialize(params: MatcherParamsFormatted): string -} - -export class MatcherPatternImpl implements MatcherPattern { - constructor( - public name: MatcherName, - private path: MatcherPatternPath, - private query?: MatcherPatternQuery, - private hash?: MatcherPatternHash - ) {} - - matchLocation(location: { - path: string - query: MatcherQueryParams - hash: string - }) { - // TODO: is this performant? bench compare to a check with `null` - try { - return [ - this.path.match(location.path), - this.query?.match(location.query) ?? {}, - this.hash?.match(location.hash) ?? '', - ] as const - } catch { - return null - } - } - - parseParams( - path: MatcherPathParams, - query: MatcherQueryParams, - hash: string - ): MatcherParamsFormatted { - return { - ...this.path.parse?.(path), - ...this.query?.parse(query), - ...this.hash?.parse(hash), - } - } - - buildPath(path: MatcherPathParams): string { - return this.path.buildPath(path) - } - - matchParams( - params: MatcherParamsFormatted - ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] { - return [ - this.path.serialize?.(params) ?? {}, - this.query?.serialize(params) ?? {}, - this.hash?.serialize(params) ?? '', - ] - } -} diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index 3cb67af19..f368647c8 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { MatcherPatternImpl } from './matcher-pattern' import { createCompiledMatcher, NO_MATCH_LOCATION } from './matcher' import { MatcherPatternParams_Base, @@ -10,12 +9,6 @@ import { import { miss } from './matchers/errors' import { EmptyParams } from './matcher-location' -function createMatcherPattern( - ...args: ConstructorParameters -) { - return new MatcherPatternImpl(...args) -} - const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{ pathMatch: string }> = { match(path) { return { pathMatch: path } diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts index c6af61e98..11cf6b098 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -11,18 +11,13 @@ import type { MatcherPatternQuery, } from './new-matcher-pattern' import { warn } from '../warning' -import { - SLASH_RE, - encodePath, - encodeQueryValue as _encodeQueryValue, -} from '../encoding' +import { encodeQueryValue as _encodeQueryValue } from '../encoding' import { parseURL, stringifyURL } from '../location' import type { MatcherLocationAsNamed, MatcherLocationAsRelative, MatcherParamsFormatted, } from './matcher-location' -import { RouteRecordRaw } from 'test-dts' /** * Allowed types for a matcher name. @@ -165,20 +160,20 @@ interface FnStableNull { (value: string | number | null | undefined): string | null } -function encodeParam(text: null | undefined, encodeSlash?: boolean): null -function encodeParam(text: string | number, encodeSlash?: boolean): string -function encodeParam( - text: string | number | null | undefined, - encodeSlash?: boolean -): string | null -function encodeParam( - text: string | number | null | undefined, - encodeSlash = true -): string | null { - if (text == null) return null - text = encodePath(text) - return encodeSlash ? text.replace(SLASH_RE, '%2F') : text -} +// function encodeParam(text: null | undefined, encodeSlash?: boolean): null +// function encodeParam(text: string | number, encodeSlash?: boolean): string +// function encodeParam( +// text: string | number | null | undefined, +// encodeSlash?: boolean +// ): string | null +// function encodeParam( +// text: string | number | null | undefined, +// encodeSlash = true +// ): string | null { +// if (text == null) return null +// text = encodePath(text) +// return encodeSlash ? text.replace(SLASH_RE, '%2F') : text +// } // @ts-expect-error: overload are not correctly identified const encodeQueryValue: FnStableNull = @@ -190,23 +185,6 @@ const encodeQueryValue: FnStableNull = // // for ts // value => (value == null ? null : _encodeQueryKey(value)) -function transformObject( - fnKey: (value: string | number) => string, - fnValue: FnStableNull, - query: T -): T { - const encoded: any = {} - - for (const key in query) { - const value = query[key] - encoded[fnKey(key)] = Array.isArray(value) - ? value.map(fnValue) - : fnValue(value as string | number | null | undefined) - } - - return encoded -} - export const NO_MATCH_LOCATION = { name: __DEV__ ? Symbol('no-match') : Symbol(), params: {}, diff --git a/packages/router/src/new-route-resolver/matchers/path-param.ts b/packages/router/src/new-route-resolver/matchers/path-param.ts deleted file mode 100644 index e17e78068..000000000 --- a/packages/router/src/new-route-resolver/matchers/path-param.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { MatcherPathParams } from '../matcher' -import { MatcherParamsFormatted } from '../matcher-location' -import type { - MatcherPatternPath, - PatternPathParamOptions, -} from '../matcher-pattern' - -export class PatterParamPath implements MatcherPatternPath { - options: Required, 'default'>> & { - default: undefined | (() => T) | T - } - - constructor(options: PatternPathParamOptions) { - this.options = { - set: String, - default: undefined, - ...options, - } - } - - match(path: string): MatcherPathParams { - const match = this.options.re.exec(path)?.groups ?? {} - if (!match) { - throw new Error( - `Path "${path}" does not match the pattern "${String( - this.options.re - )}"}` - ) - } - const params: MatcherPathParams = {} - for (let i = 0; i < this.options.keys.length; i++) { - params[this.options.keys[i]] = match[i + 1] ?? null - } - return params - } - - buildPath(path: MatcherPathParams): string { - throw new Error('Method not implemented.') - } - - parse(params: MatcherPathParams): MatcherParamsFormatted { - throw new Error('Method not implemented.') - } - - serialize(params: MatcherParamsFormatted): MatcherPathParams { - throw new Error('Method not implemented.') - } -} diff --git a/packages/router/src/new-route-resolver/matchers/path-static.spec.ts b/packages/router/src/new-route-resolver/matchers/path-static.spec.ts deleted file mode 100644 index aae50551c..000000000 --- a/packages/router/src/new-route-resolver/matchers/path-static.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MatcherPathStatic } from './path-static' - -describe('PathStaticMatcher', () => { - it('matches', () => { - expect(new MatcherPathStatic('/').match('/')).toEqual({}) - expect(() => new MatcherPathStatic('/').match('/no')).toThrowError() - expect(new MatcherPathStatic('/ok/ok').match('/ok/ok')).toEqual({}) - expect(() => new MatcherPathStatic('/ok/ok').match('/ok/no')).toThrowError() - }) - - it('builds path', () => { - expect(new MatcherPathStatic('/').buildPath()).toBe('/') - expect(new MatcherPathStatic('/ok').buildPath()).toBe('/ok') - expect(new MatcherPathStatic('/ok/ok').buildPath()).toEqual('/ok/ok') - }) -}) diff --git a/packages/router/src/new-route-resolver/matchers/path-static.ts b/packages/router/src/new-route-resolver/matchers/path-static.ts deleted file mode 100644 index 7d5e968ff..000000000 --- a/packages/router/src/new-route-resolver/matchers/path-static.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { MatcherPatternPath } from '../matcher-pattern' -import { miss } from './errors' - -export class MatcherPathStatic implements MatcherPatternPath { - constructor(private path: string) {} - - match(path: string) { - if (this.path === path) return {} - throw miss() - } - - buildPath() { - return this.path - } -} diff --git a/packages/router/src/new-route-resolver/new-matcher-pattern.ts b/packages/router/src/new-route-resolver/new-matcher-pattern.ts index f231490cd..25d7c22ec 100644 --- a/packages/router/src/new-route-resolver/new-matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/new-matcher-pattern.ts @@ -1,34 +1,6 @@ import { MatcherName, MatcherQueryParams } from './matcher' import { EmptyParams, MatcherParamsFormatted } from './matcher-location' -import { MatchMiss, miss } from './matchers/errors' - -export interface MatcherLocation { - /** - * Encoded path - */ - path: string - - /** - * Decoded query. - */ - query: MatcherQueryParams - - /** - * Decoded hash. - */ - hash: string -} - -export interface OLD_MatcherPattern { - /** - * Name of the matcher. Unique across all matchers. - */ - name: MatcherName - - match(location: MatcherLocation): TParams | null - - toLocation(params: TParams): MatcherLocation -} +import { miss } from './matchers/errors' export interface MatcherPattern { /** @@ -48,15 +20,13 @@ export interface MatcherPatternParams_Base< TOut extends MatcherParamsFormatted = MatcherParamsFormatted > { match(value: TIn): TOut - build(params: TOut): TIn - // get: (value: MatcherQueryParamsValue) => T - // set?: (value: T) => MatcherQueryParamsValue - // default?: T | (() => T) } export interface MatcherPatternPath< - TParams extends MatcherParamsFormatted = MatcherParamsFormatted + TParams extends MatcherParamsFormatted = // | undefined // | void // so it might be a bit more convenient // TODO: should we allow to not return anything? It's valid to spread null and undefined + // | null + MatcherParamsFormatted > extends MatcherPatternParams_Base {} export class MatcherPatternPathStatic @@ -78,120 +48,10 @@ export class MatcherPatternPathStatic // example of a static matcher built at runtime // new MatcherPatternPathStatic('/') -// example of a generated matcher at build time -const HomePathMatcher = { - match: path => { - if (path !== '/') { - throw miss() - } - return {} - }, - build: () => '/', -} satisfies MatcherPatternPath - export interface MatcherPatternQuery< TParams extends MatcherParamsFormatted = MatcherParamsFormatted > extends MatcherPatternParams_Base {} -const PaginationQueryMatcher = { - match: query => { - const page = Number(query.page) - return { - page: Number.isNaN(page) ? 1 : page, - } - }, - build: params => ({ page: String(params.page) }), -} satisfies MatcherPatternQuery<{ page: number }> - export interface MatcherPatternHash< TParams extends MatcherParamsFormatted = MatcherParamsFormatted > extends MatcherPatternParams_Base {} - -const HeaderHashMatcher = { - match: hash => - hash.startsWith('#') - ? { - header: hash.slice(1), - } - : {}, // null also works - build: ({ header }) => (header ? `#${header}` : ''), -} satisfies MatcherPatternHash<{ header?: string }> - -export class MatcherPatternImpl< - PathParams extends MatcherParamsFormatted, - QueryParams extends MatcherParamsFormatted = EmptyParams, - HashParams extends MatcherParamsFormatted = EmptyParams -> implements OLD_MatcherPattern -{ - parent: MatcherPatternImpl | null = null - children: MatcherPatternImpl[] = [] - - constructor( - public name: MatcherName, - private path: MatcherPatternPath, - private query?: MatcherPatternQuery, - private hash?: MatcherPatternHash - ) {} - - /** - * Matches a parsed query against the matcher and all of the parents. - * @param query - query to match - * @returns matched - * @throws {MatchMiss} if the query does not match - */ - queryMatch(query: MatcherQueryParams): QParams { - // const queryParams: QParams = {} as QParams - const queryParams: QParams[] = [] - let current: MatcherPatternImpl< - MatcherParamsFormatted, - MatcherParamsFormatted, - MatcherParamsFormatted - > | null = this - - while (current) { - queryParams.push(current.query?.match(query) as QParams) - current = current.parent - } - // we give the later matchers precedence - return Object.assign({}, ...queryParams.reverse()) - } - - queryBuild(params: QParams): MatcherQueryParams { - const query: MatcherQueryParams = {} - let current: MatcherPatternImpl< - MatcherParamsFormatted, - MatcherParamsFormatted, - MatcherParamsFormatted - > | null = this - while (current) { - Object.assign(query, current.query?.build(params)) - current = current.parent - } - return query - } - - match( - location: MatcherLocation - ): (PathParams & QParams & HashParams) | null { - try { - const pathParams = this.path.match(location.path) - const queryParams = this.queryMatch(location.query) - const hashParams = this.hash?.match(location.hash) ?? ({} as HashParams) - - return { ...pathParams, ...queryParams, ...hashParams } - } catch (err) {} - - return null - } - - toLocation(params: PathParams & QueryParams & HashParams): MatcherLocation { - return { - path: this.path.build(params), - query: this.query?.build(params) ?? {}, - hash: this.hash?.build(params) ?? '', - } - } -} - -// const matcher = new MatcherPatternImpl('name', HomePathMatcher, PaginationQueryMatcher, HeaderHashMatcher) -// matcher.match({ path: '/', query: {}, hash: '' })!.page From b8eba1a2dbbeb03cf54bdc72bc6a7f4bce015abc Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 9 Dec 2024 22:20:06 +0100 Subject: [PATCH 20/37] refactor: rename matcher-pattern --- .../{new-matcher-pattern.ts => matcher-pattern.ts} | 0 packages/router/src/new-route-resolver/matcher.spec.ts | 2 +- packages/router/src/new-route-resolver/matcher.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/router/src/new-route-resolver/{new-matcher-pattern.ts => matcher-pattern.ts} (100%) diff --git a/packages/router/src/new-route-resolver/new-matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts similarity index 100% rename from packages/router/src/new-route-resolver/new-matcher-pattern.ts rename to packages/router/src/new-route-resolver/matcher-pattern.ts diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index f368647c8..15ca09f83 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -5,7 +5,7 @@ import { MatcherPattern, MatcherPatternPath, MatcherPatternQuery, -} from './new-matcher-pattern' +} from './matcher-pattern' import { miss } from './matchers/errors' import { EmptyParams } from './matcher-location' diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts index 11cf6b098..26805ddaf 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -9,7 +9,7 @@ import type { MatcherPatternHash, MatcherPatternPath, MatcherPatternQuery, -} from './new-matcher-pattern' +} from './matcher-pattern' import { warn } from '../warning' import { encodeQueryValue as _encodeQueryValue } from '../encoding' import { parseURL, stringifyURL } from '../location' From 65a094080f5c2627fd5c8c16d91a885becd5043f Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 10 Dec 2024 14:41:31 +0100 Subject: [PATCH 21/37] refactor: add methods needed by router --- .../new-route-resolver/matcher-location.ts | 20 ++++++++- .../src/new-route-resolver/matcher.spec.ts | 14 ++++++ .../src/new-route-resolver/matcher.test-d.ts | 12 ++++++ .../router/src/new-route-resolver/matcher.ts | 43 +++++++++++++++++-- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/packages/router/src/new-route-resolver/matcher-location.ts b/packages/router/src/new-route-resolver/matcher-location.ts index 3744e8cec..b9ca1ab0c 100644 --- a/packages/router/src/new-route-resolver/matcher-location.ts +++ b/packages/router/src/new-route-resolver/matcher-location.ts @@ -19,25 +19,41 @@ export interface MatcherLocationAsNamed { hash?: string /** - * A path is ignored if `name` is provided. + * @deprecated This is ignored when `name` is provided */ path?: undefined } -export interface MatcherLocationAsPath { +export interface MatcherLocationAsPathRelative { path: string query?: LocationQueryRaw hash?: string + /** + * @deprecated This is ignored when `path` is provided + */ name?: undefined + /** + * @deprecated This is ignored when `path` (instead of `name`) is provided + */ params?: undefined } +export interface MatcherLocationAsPathAbsolute + extends MatcherLocationAsPathRelative { + path: `/${string}` +} export interface MatcherLocationAsRelative { params?: MatcherParamsFormatted query?: LocationQueryRaw hash?: string + /** + * @deprecated This location is relative to the next parameter. This `name` will be ignored. + */ name?: undefined + /** + * @deprecated This location is relative to the next parameter. This `path` will be ignored. + */ path?: undefined } diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index 15ca09f83..c15561f53 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -209,6 +209,20 @@ describe('Matcher', () => { }) }) + describe('absolute locations as objects', () => { + it('resolves an object location', () => { + const matcher = createCompiledMatcher() + matcher.addRoute(EMPTY_PATH_ROUTE) + expect(matcher.resolve({ path: '/' })).toMatchObject({ + fullPath: '/', + path: '/', + params: {}, + query: {}, + hash: '', + }) + }) + }) + describe('named locations', () => { it('resolves named locations with no params', () => { const matcher = createCompiledMatcher() diff --git a/packages/router/src/new-route-resolver/matcher.test-d.ts b/packages/router/src/new-route-resolver/matcher.test-d.ts index c50731a1e..a60874518 100644 --- a/packages/router/src/new-route-resolver/matcher.test-d.ts +++ b/packages/router/src/new-route-resolver/matcher.test-d.ts @@ -39,4 +39,16 @@ describe('Matcher', () => { ).toEqualTypeOf() }) }) + + it('does not allow a name + path', () => { + matcher.resolve({ + // ...({} as NEW_LocationResolved), + name: 'foo', + params: {}, + // @ts-expect-error: name + path + path: '/e', + }) + // @ts-expect-error: name + currentLocation + matcher.resolve({ name: 'a', params: {} }, {} as NEW_LocationResolved) + }) }) diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts index 26805ddaf..f9fa1c6f8 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -15,6 +15,8 @@ import { encodeQueryValue as _encodeQueryValue } from '../encoding' import { parseURL, stringifyURL } from '../location' import type { MatcherLocationAsNamed, + MatcherLocationAsPathAbsolute, + MatcherLocationAsPathRelative, MatcherLocationAsRelative, MatcherParamsFormatted, } from './matcher-location' @@ -48,10 +50,16 @@ export interface RouteResolver { resolve(location: MatcherLocationAsNamed): NEW_LocationResolved /** - * Resolves a location by its path. Any required query must be passed. + * Resolves a location by its absolute path (starts with `/`). Any required query must be passed. * @param location - The location to resolve. */ - // resolve(location: MatcherLocationAsPath): NEW_MatcherLocationResolved + resolve(location: MatcherLocationAsPathAbsolute): NEW_LocationResolved + + resolve( + location: MatcherLocationAsPathRelative, + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved + // NOTE: in practice, this overload can cause bugs. It's better to use named locations /** @@ -66,11 +74,28 @@ export interface RouteResolver { addRoute(matcher: Matcher, parent?: MatcherNormalized): MatcherNormalized removeRoute(matcher: MatcherNormalized): void clearRoutes(): void + + /** + * Get a list of all matchers. + * Previously named `getRoutes()` + */ + getMatchers(): MatcherNormalized[] + + /** + * Get a matcher by its name. + * Previously named `getRecordMatcher()` + */ + getMatcher(name: MatcherName): MatcherNormalized | undefined } type MatcherResolveArgs = | [absoluteLocation: `/${string}`] | [relativeLocation: string, currentLocation: NEW_LocationResolved] + | [absoluteLocation: MatcherLocationAsPathAbsolute] + | [ + relativeLocation: MatcherLocationAsPathRelative, + currentLocation: NEW_LocationResolved + ] | [location: MatcherLocationAsNamed] | [ relativeLocation: MatcherLocationAsRelative, @@ -224,6 +249,7 @@ export function createCompiledMatcher(): RouteResolver< MatcherRecordRaw, MatcherPattern > { + // TODO: we also need an array that has the correct order const matchers = new Map() // TODO: allow custom encode/decode functions @@ -241,6 +267,7 @@ export function createCompiledMatcher(): RouteResolver< // string location, e.g. '/foo', '../bar', 'baz', '?page=1' if (typeof location === 'string') { + // parseURL handles relative paths const url = parseURL(parseQuery, location, currentLocation?.path) let matcher: MatcherPattern | undefined @@ -266,7 +293,6 @@ export function createCompiledMatcher(): RouteResolver< // } parsedParams = { ...pathParams, ...queryParams, ...hashParams } - // console.log('parsedParams', parsedParams) if (parsedParams) break } catch (e) { @@ -296,6 +322,7 @@ export function createCompiledMatcher(): RouteResolver< hash: url.hash, matched, } + // TODO: handle object location { path, query, hash } } else { // relative location or by name if (__DEV__ && location.name == null && currentLocation == null) { @@ -368,11 +395,21 @@ export function createCompiledMatcher(): RouteResolver< matchers.clear() } + function getMatchers() { + return Array.from(matchers.values()) + } + + function getMatcher(name: MatcherName) { + return matchers.get(name) + } + return { resolve, addRoute, removeRoute, clearRoutes, + getMatcher, + getMatchers, } } From 2ab9c3225750bd8a91e653223e7571b2b9ad6cef Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 16 Dec 2024 15:35:49 +0100 Subject: [PATCH 22/37] feat: new dynamic path matcher --- .../src/new-route-resolver/matcher-pattern.ts | 159 +- .../matcher-resolve.spec.ts | 1492 +++++++++++++++++ .../src/new-route-resolver/matcher.spec.ts | 118 +- .../router/src/new-route-resolver/matcher.ts | 46 +- .../new-route-resolver/matchers/test-utils.ts | 76 + packages/router/src/types/utils.ts | 10 + 6 files changed, 1860 insertions(+), 41 deletions(-) create mode 100644 packages/router/src/new-route-resolver/matcher-resolve.spec.ts create mode 100644 packages/router/src/new-route-resolver/matchers/test-utils.ts diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts index 25d7c22ec..ad582bb8d 100644 --- a/packages/router/src/new-route-resolver/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -1,4 +1,4 @@ -import { MatcherName, MatcherQueryParams } from './matcher' +import { decode, MatcherName, MatcherQueryParams } from './matcher' import { EmptyParams, MatcherParamsFormatted } from './matcher-location' import { miss } from './matchers/errors' @@ -19,14 +19,28 @@ export interface MatcherPatternParams_Base< TIn = string, TOut extends MatcherParamsFormatted = MatcherParamsFormatted > { + /** + * Matches a serialized params value against the pattern. + * + * @param value - params value to parse + * @throws {MatchMiss} if the value doesn't match + * @returns parsed params + */ match(value: TIn): TOut + + /** + * Build a serializable value from parsed params. Should apply encoding if the + * returned value is a string (e.g path and hash should be encoded but query + * shouldn't). + * + * @param value - params value to parse + */ build(params: TOut): TIn } export interface MatcherPatternPath< - TParams extends MatcherParamsFormatted = // | undefined // | void // so it might be a bit more convenient // TODO: should we allow to not return anything? It's valid to spread null and undefined - // | null - MatcherParamsFormatted + // TODO: should we allow to not return anything? It's valid to spread null and undefined + TParams extends MatcherParamsFormatted = MatcherParamsFormatted // | null // | undefined // | void // so it might be a bit more convenient > extends MatcherPatternParams_Base {} export class MatcherPatternPathStatic @@ -48,6 +62,143 @@ export class MatcherPatternPathStatic // example of a static matcher built at runtime // new MatcherPatternPathStatic('/') +export interface Param_GetSet< + TIn extends string | string[] = string | string[], + TOut = TIn +> { + get?: (value: NoInfer) => TOut + set?: (value: NoInfer) => TIn +} + +export type ParamParser_Generic = + | Param_GetSet + | Param_GetSet +// TODO: these are possible values for optional params +// | null | undefined + +/** + * Type safe helper to define a param parser. + * + * @param parser - the parser to define. Will be returned as is. + */ +/*! #__NO_SIDE_EFFECTS__ */ +export function defineParamParser(parser: { + get?: (value: TIn) => TOut + set?: (value: TOut) => TIn +}): Param_GetSet { + return parser +} + +const PATH_PARAM_DEFAULT_GET = (value: string | string[]) => value +const PATH_PARAM_DEFAULT_SET = (value: unknown) => + value && Array.isArray(value) ? value.map(String) : String(value) +// TODO: `(value an null | undefined)` for types + +/** + * NOTE: I tried to make this generic and infer the types from the params but failed. This is what I tried: + * ```ts + * export type ParamsFromParsers

> = { + * [K in keyof P]: P[K] extends Param_GetSet + * ? unknown extends TOut // if any or unknown, use the value of TIn, which defaults to string | string[] + * ? TIn + * : TOut + * : never + * } + * + * export class MatcherPatternPathDynamic< + * ParamsParser extends Record + * > implements MatcherPatternPath> + * { + * private params: Record> = {} + * constructor( + * private re: RegExp, + * params: ParamsParser, + * public build: (params: ParamsFromParsers) => string + * ) {} + * ``` + * It ended up not working in one place or another. It could probably be fixed by + */ + +export type ParamsFromParsers

> = { + [K in keyof P]: P[K] extends Param_GetSet + ? unknown extends TOut // if any or unknown, use the value of TIn, which defaults to string | string[] + ? TIn + : TOut + : never +} + +export class MatcherPatternPathDynamic< + TParams extends MatcherParamsFormatted = MatcherParamsFormatted +> implements MatcherPatternPath +{ + private params: Record> = {} + constructor( + private re: RegExp, + params: Record, + public build: (params: TParams) => string, + private opts: { repeat?: boolean; optional?: boolean } = {} + ) { + for (const paramName in params) { + const param = params[paramName] + this.params[paramName] = { + get: param.get || PATH_PARAM_DEFAULT_GET, + // @ts-expect-error FIXME: should work + set: param.set || PATH_PARAM_DEFAULT_SET, + } + } + } + + /** + * Match path against the pattern and return + * + * @param path - path to match + * @throws if the patch doesn't match + * @returns matched decoded params + */ + match(path: string): TParams { + const match = path.match(this.re) + if (!match) { + throw miss() + } + let i = 1 // index in match array + const params = {} as TParams + for (const paramName in this.params) { + const currentParam = this.params[paramName] + const currentMatch = match[i++] + let value: string | null | string[] = + this.opts.optional && currentMatch == null ? null : currentMatch + value = this.opts.repeat && value ? value.split('/') : value + + params[paramName as keyof typeof params] = currentParam.get( + // @ts-expect-error: FIXME: the type of currentParam['get'] is wrong + value && (Array.isArray(value) ? value.map(decode) : decode(value)) + ) as (typeof params)[keyof typeof params] + } + + if (__DEV__ && i !== match.length) { + console.warn( + `Regexp matched ${match.length} params, but ${i} params are defined` + ) + } + return params + } + + // build(params: TParams): string { + // let path = this.re.source + // for (const param of this.params) { + // const value = params[param.name as keyof TParams] + // if (value == null) { + // throw new Error(`Matcher build: missing param ${param.name}`) + // } + // path = path.replace( + // /([^\\]|^)\([^?]*\)/, + // `$1${encodeParam(param.set(value))}` + // ) + // } + // return path + // } +} + export interface MatcherPatternQuery< TParams extends MatcherParamsFormatted = MatcherParamsFormatted > extends MatcherPatternParams_Base {} diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts new file mode 100644 index 000000000..b4799bbec --- /dev/null +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -0,0 +1,1492 @@ +import { createRouterMatcher, normalizeRouteRecord } from '../matcher' +import { RouteComponent, RouteRecordRaw, MatcherLocation } from '../types' +import { MatcherLocationNormalizedLoose } from '../../__tests__/utils' +import { defineComponent } from 'vue' +import { START_LOCATION_NORMALIZED } from '../location' +import { describe, expect, it } from 'vitest' +import { mockWarn } from '../../__tests__/vitest-mock-warn' +import { + createCompiledMatcher, + MatcherLocationRaw, + MatcherRecordRaw, + NEW_LocationResolved, +} from './matcher' +import { PathParams, tokensToParser } from '../matcher/pathParserRanker' +import { tokenizePath } from '../matcher/pathTokenizer' +import { miss } from './matchers/errors' +import { MatcherPatternPath } from './matcher-pattern' + +// for raw route record +const component: RouteComponent = defineComponent({}) +// for normalized route records +const components = { default: component } + +function compileRouteRecord( + record: RouteRecordRaw, + parentRecord?: RouteRecordRaw +): MatcherRecordRaw { + // we adapt the path to ensure they are absolute + // TODO: aliases? they could be handled directly in the path matcher + const path = record.path.startsWith('/') + ? record.path + : (parentRecord?.path || '') + record.path + record.path = path + const parser = tokensToParser(tokenizePath(record.path), { + // start: true, + end: record.end, + sensitive: record.sensitive, + strict: record.strict, + }) + + return { + name: record.name, + + path: { + match(value) { + const params = parser.parse(value) + if (params) { + return params + } + throw miss() + }, + build(params) { + // TODO: normalize params? + return parser.stringify(params) + }, + } satisfies MatcherPatternPath, + + children: record.children?.map(childRecord => + compileRouteRecord(childRecord, record) + ), + } +} + +describe('RouterMatcher.resolve', () => { + mockWarn() + type Matcher = ReturnType + type MatcherResolvedLocation = ReturnType + + const START_LOCATION: NEW_LocationResolved = { + name: Symbol('START'), + fullPath: '/', + path: '/', + params: {}, + query: {}, + hash: '', + matched: [], + } + + function isMatcherLocationResolved( + location: unknown + ): location is NEW_LocationResolved { + return !!( + location && + typeof location === 'object' && + 'matched' in location && + 'fullPath' in location && + Array.isArray(location.matched) + ) + } + + // TODO: rework with object param for clarity + + function assertRecordMatch( + record: RouteRecordRaw | RouteRecordRaw[], + toLocation: MatcherLocationRaw, + expectedLocation: Partial, + fromLocation: + | NEW_LocationResolved + | Exclude + | `/${string}` = START_LOCATION + ) { + const records = (Array.isArray(record) ? record : [record]).map( + (record): MatcherRecordRaw => compileRouteRecord(record) + ) + const matcher = createCompiledMatcher() + for (const record of records) { + matcher.addRoute(record) + } + + const resolved: MatcherResolvedLocation = { + // FIXME: to add later + // meta: records[0].meta || {}, + path: + typeof toLocation === 'string' ? toLocation : toLocation.path || '/', + name: expect.any(Symbol) as symbol, + matched: [], // FIXME: build up + params: (typeof toLocation === 'object' && toLocation.params) || {}, + ...expectedLocation, + } + + Object.defineProperty(resolved, 'matched', { + writable: true, + configurable: true, + enumerable: false, + value: [], + }) + + fromLocation = isMatcherLocationResolved(fromLocation) + ? fromLocation + : matcher.resolve(fromLocation) + + expect(matcher.resolve(toLocation, fromLocation)).toMatchObject({ + // avoid undesired properties + query: {}, + hash: '', + ...resolved, + }) + } + + function _assertRecordMatch( + record: RouteRecordRaw | RouteRecordRaw[], + location: MatcherLocationRaw, + resolved: Partial, + start: MatcherLocation = START_LOCATION_NORMALIZED + ) { + record = Array.isArray(record) ? record : [record] + const matcher = createRouterMatcher(record, {}) + + if (!('meta' in resolved)) { + resolved.meta = record[0].meta || {} + } + + if (!('name' in resolved)) { + resolved.name = undefined + } + + // add location if provided as it should be the same value + if ('path' in location && !('path' in resolved)) { + resolved.path = location.path + } + + if ('redirect' in record) { + throw new Error('not handled') + } else { + // use one single record + if (!resolved.matched) resolved.matched = record.map(normalizeRouteRecord) + // allow passing an expect.any(Array) + else if (Array.isArray(resolved.matched)) + resolved.matched = resolved.matched.map(m => ({ + ...normalizeRouteRecord(m as any), + aliasOf: m.aliasOf, + })) + } + + // allows not passing params + resolved.params = + resolved.params || ('params' in location ? location.params : {}) + + const startCopy: MatcherLocation = { + ...start, + matched: start.matched.map(m => ({ + ...normalizeRouteRecord(m), + aliasOf: m.aliasOf, + })) as MatcherLocation['matched'], + } + + // make matched non enumerable + Object.defineProperty(startCopy, 'matched', { enumerable: false }) + + const result = matcher.resolve(location, startCopy) + expect(result).toEqual(resolved) + } + + /** + * + * @param record - Record or records we are testing the matcher against + * @param location - location we want to resolve against + * @param [start] Optional currentLocation used when resolving + * @returns error + */ + function assertErrorMatch( + record: RouteRecordRaw | RouteRecordRaw[], + location: MatcherLocationRaw, + start: MatcherLocation = START_LOCATION_NORMALIZED + ) { + assertRecordMatch(record, location, {}, start) + } + + describe.skip('LocationAsPath', () => { + it('resolves a normal path', () => { + assertRecordMatch({ path: '/', name: 'Home', components }, '/', { + name: 'Home', + path: '/', + params: {}, + }) + }) + + it('resolves a normal path without name', () => { + assertRecordMatch( + { path: '/', components }, + { path: '/' }, + { name: undefined, path: '/', params: {} } + ) + }) + + it('resolves a path with params', () => { + assertRecordMatch( + { path: '/users/:id', name: 'User', components }, + { path: '/users/posva' }, + { name: 'User', params: { id: 'posva' } } + ) + }) + + it('resolves an array of params for a repeatable params', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { name: 'a', params: { p: ['b', 'c', 'd'] } }, + { name: 'a', path: '/a/b/c/d', params: { p: ['b', 'c', 'd'] } } + ) + }) + + it('resolves single params for a repeatable params', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { name: 'a', params: { p: 'b' } }, + { name: 'a', path: '/a/b', params: { p: 'b' } } + ) + }) + + it('keeps repeated params as a single one when provided through path', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { path: '/a/b/c' }, + { name: 'a', params: { p: ['b', 'c'] } } + ) + }) + + it('resolves a path with multiple params', () => { + assertRecordMatch( + { path: '/users/:id/:other', name: 'User', components }, + { path: '/users/posva/hey' }, + { name: 'User', params: { id: 'posva', other: 'hey' } } + ) + }) + + it('resolves a path with multiple params but no name', () => { + assertRecordMatch( + { path: '/users/:id/:other', components }, + { path: '/users/posva/hey' }, + { name: undefined, params: { id: 'posva', other: 'hey' } } + ) + }) + + it('returns an empty match when the path does not exist', () => { + assertRecordMatch( + { path: '/', components }, + { path: '/foo' }, + { name: undefined, params: {}, path: '/foo', matched: [] } + ) + }) + + it('allows an optional trailing slash', () => { + assertRecordMatch( + { path: '/home/', name: 'Home', components }, + { path: '/home/' }, + { name: 'Home', path: '/home/', matched: expect.any(Array) } + ) + }) + + it('allows an optional trailing slash with optional param', () => { + assertRecordMatch( + { path: '/:a', components, name: 'a' }, + { path: '/a/' }, + { path: '/a/', params: { a: 'a' }, name: 'a' } + ) + assertRecordMatch( + { path: '/a/:a', components, name: 'a' }, + { path: '/a/a/' }, + { path: '/a/a/', params: { a: 'a' }, name: 'a' } + ) + }) + + it('allows an optional trailing slash with missing optional param', () => { + assertRecordMatch( + { path: '/:a?', components, name: 'a' }, + { path: '/' }, + { path: '/', params: { a: '' }, name: 'a' } + ) + assertRecordMatch( + { path: '/a/:a?', components, name: 'a' }, + { path: '/a/' }, + { path: '/a/', params: { a: '' }, name: 'a' } + ) + }) + + it('keeps required trailing slash (strict: true)', () => { + const record = { + path: '/home/', + name: 'Home', + components, + options: { strict: true }, + } + assertErrorMatch(record, { path: '/home' }) + assertRecordMatch( + record, + { path: '/home/' }, + { name: 'Home', path: '/home/', matched: expect.any(Array) } + ) + }) + + it('rejects a trailing slash when strict', () => { + const record = { + path: '/home', + name: 'Home', + components, + options: { strict: true }, + } + assertRecordMatch( + record, + { path: '/home' }, + { name: 'Home', path: '/home', matched: expect.any(Array) } + ) + assertErrorMatch(record, { path: '/home/' }) + }) + }) + + describe('LocationAsName', () => { + it('matches a name', () => { + assertRecordMatch( + { path: '/home', name: 'Home', components }, + // TODO: allow a name only without the params? + { name: 'Home', params: {} }, + { name: 'Home', path: '/home' } + ) + }) + + it('matches a name and fill params', () => { + assertRecordMatch( + { path: '/users/:id/m/:role', name: 'UserEdit', components }, + { name: 'UserEdit', params: { id: 'posva', role: 'admin' } }, + { + name: 'UserEdit', + path: '/users/posva/m/admin', + params: { id: 'posva', role: 'admin' }, + } + ) + }) + + it('throws if the named route does not exists', () => { + expect(() => + assertErrorMatch( + { path: '/', components }, + { name: 'Home', params: {} } + ) + ).toThrowError('Matcher "Home" not found') + }) + + it('merges params', () => { + assertRecordMatch( + { path: '/:a/:b', name: 'p', components }, + { params: { b: 'b' } }, + { name: 'p', path: '/A/b', params: { a: 'A', b: 'b' } }, + '/A/B' + ) + }) + + // TODO: new matcher no longer allows implicit param merging + it.todo('only keep existing params', () => { + assertRecordMatch( + { path: '/:a/:b', name: 'p', components }, + { name: 'p', params: { b: 'b' } }, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + '/a/c' + ) + }) + + // TODO: implement parent children + it.todo('keep optional params from parent record', () => { + const Child_A = { path: 'a', name: 'child_a', components } + const Child_B = { path: 'b', name: 'child_b', components } + const Parent = { + path: '/:optional?/parent', + name: 'parent', + components, + children: [Child_A, Child_B], + } + assertRecordMatch( + Parent, + { name: 'child_b' }, + { + name: 'child_b', + path: '/foo/parent/b', + params: { optional: 'foo' }, + matched: [ + Parent as any, + { + ...Child_B, + path: `${Parent.path}/${Child_B.path}`, + }, + ], + }, + { + params: { optional: 'foo' }, + path: '/foo/parent/a', + matched: [], + meta: {}, + name: undefined, + } + ) + }) + + // TODO: check if needed by the active matching, if not just test that the param is dropped + it.todo('discards non existent params', () => { + assertRecordMatch( + { path: '/', name: 'home', components }, + { name: 'home', params: { a: 'a', b: 'b' } }, + { name: 'home', path: '/', params: {} } + ) + expect('invalid param(s) "a", "b" ').toHaveBeenWarned() + assertRecordMatch( + { path: '/:b', name: 'a', components }, + { name: 'a', params: { a: 'a', b: 'b' } }, + { name: 'a', path: '/b', params: { b: 'b' } } + ) + expect('invalid param(s) "a"').toHaveBeenWarned() + }) + + it('drops optional params in absolute location', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { name: 'p', params: { a: 'b' } }, + { name: 'p', path: '/b', params: { a: 'b' } } + ) + }) + + it('keeps optional params passed as empty strings', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { name: 'p', params: { a: 'b', b: '' } }, + { name: 'p', path: '/b', params: { a: 'b', b: '' } } + ) + }) + + it('resolves root path with optional params', () => { + assertRecordMatch( + { path: '/:tab?', name: 'h', components }, + { name: 'h', params: {} }, + { name: 'h', path: '/', params: {} } + ) + assertRecordMatch( + { path: '/:tab?/:other?', name: 'h', components }, + { name: 'h', params: {} }, + { name: 'h', path: '/', params: {} } + ) + }) + }) + + describe.skip('LocationAsRelative', () => { + it('warns if a path isn not absolute', () => { + const record = { + path: '/parent', + components, + } + const matcher = createRouterMatcher([record], {}) + matcher.resolve( + { path: 'two' }, + { + path: '/parent/one', + name: undefined, + params: {}, + matched: [] as any, + meta: {}, + } + ) + expect('received "two"').toHaveBeenWarned() + }) + + it('matches with nothing', () => { + const record = { path: '/home', name: 'Home', components } + assertRecordMatch( + record, + {}, + { name: 'Home', path: '/home' }, + { + name: 'Home', + params: {}, + path: '/home', + matched: [record] as any, + meta: {}, + } + ) + }) + + it('replace params even with no name', () => { + const record = { path: '/users/:id/m/:role', components } + assertRecordMatch( + record, + { params: { id: 'posva', role: 'admin' } }, + { name: undefined, path: '/users/posva/m/admin' }, + { + path: '/users/ed/m/user', + name: undefined, + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('replace params', () => { + const record = { + path: '/users/:id/m/:role', + name: 'UserEdit', + components, + } + assertRecordMatch( + record, + { params: { id: 'posva', role: 'admin' } }, + { name: 'UserEdit', path: '/users/posva/m/admin' }, + { + path: '/users/ed/m/user', + name: 'UserEdit', + params: { id: 'ed', role: 'user' }, + matched: [], + meta: {}, + } + ) + }) + + it('keep params if not provided', () => { + const record = { + path: '/users/:id/m/:role', + name: 'UserEdit', + components, + } + assertRecordMatch( + record, + {}, + { + name: 'UserEdit', + path: '/users/ed/m/user', + params: { id: 'ed', role: 'user' }, + }, + { + path: '/users/ed/m/user', + name: 'UserEdit', + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('keep params if not provided even with no name', () => { + const record = { path: '/users/:id/m/:role', components } + assertRecordMatch( + record, + {}, + { + name: undefined, + path: '/users/ed/m/user', + params: { id: 'ed', role: 'user' }, + }, + { + path: '/users/ed/m/user', + name: undefined, + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('merges params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { params: { b: 'b' } }, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + { + name: 'p', + params: { a: 'a' }, + path: '/a', + matched: [], + meta: {}, + } + ) + }) + + it('keep optional params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + {}, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + { + name: 'p', + params: { a: 'a', b: 'b' }, + path: '/a/b', + matched: [], + meta: {}, + } + ) + }) + + it('merges optional params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { params: { a: 'c' } }, + { name: 'p', path: '/c/b', params: { a: 'c', b: 'b' } }, + { + name: 'p', + params: { a: 'a', b: 'b' }, + path: '/a/b', + matched: [], + meta: {}, + } + ) + }) + + it('throws if the current named route does not exists', () => { + const record = { path: '/', components } + const start = { + name: 'home', + params: {}, + path: '/', + matched: [record], + } + // the property should be non enumerable + Object.defineProperty(start, 'matched', { enumerable: false }) + expect( + assertErrorMatch( + record, + { params: { a: 'foo' } }, + { + ...start, + matched: start.matched.map(normalizeRouteRecord), + meta: {}, + } + ) + ).toMatchSnapshot() + }) + + it('avoids records with children without a component nor name', () => { + assertErrorMatch( + { + path: '/articles', + children: [{ path: ':id', components }], + }, + { path: '/articles' } + ) + }) + + it('avoid deeply nested records with children without a component nor name', () => { + assertErrorMatch( + { + path: '/app', + components, + children: [ + { + path: '/articles', + children: [{ path: ':id', components }], + }, + ], + }, + { path: '/articles' } + ) + }) + + it('can reach a named route with children and no component if named', () => { + assertRecordMatch( + { + path: '/articles', + name: 'ArticlesParent', + children: [{ path: ':id', components }], + }, + { name: 'ArticlesParent' }, + { name: 'ArticlesParent', path: '/articles' } + ) + }) + }) + + describe.skip('alias', () => { + it('resolves an alias', () => { + assertRecordMatch( + { + path: '/', + alias: '/home', + name: 'Home', + components, + meta: { foo: true }, + }, + { path: '/home' }, + { + name: 'Home', + path: '/home', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/home', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + }) + + it('multiple aliases', () => { + const record = { + path: '/', + alias: ['/home', '/start'], + name: 'Home', + components, + meta: { foo: true }, + } + + assertRecordMatch( + record, + { path: '/' }, + { + name: 'Home', + path: '/', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/', + name: 'Home', + components, + aliasOf: undefined, + meta: { foo: true }, + }, + ], + } + ) + assertRecordMatch( + record, + { path: '/home' }, + { + name: 'Home', + path: '/home', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/home', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + assertRecordMatch( + record, + { path: '/start' }, + { + name: 'Home', + path: '/start', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/start', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + }) + + it('resolves the original record by name', () => { + assertRecordMatch( + { + path: '/', + alias: '/home', + name: 'Home', + components, + meta: { foo: true }, + }, + { name: 'Home' }, + { + name: 'Home', + path: '/', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/', + name: 'Home', + components, + aliasOf: undefined, + meta: { foo: true }, + }, + ], + } + ) + }) + + it('resolves an alias with children to the alias when using the path', () => { + const children = [{ path: 'one', component, name: 'nested' }] + assertRecordMatch( + { + path: '/parent', + alias: '/p', + component, + children, + }, + { path: '/p/one' }, + { + path: '/p/one', + name: 'nested', + params: {}, + matched: [ + { + path: '/p', + children, + components, + aliasOf: expect.objectContaining({ path: '/parent' }), + }, + { + path: '/p/one', + name: 'nested', + components, + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }, + ], + } + ) + }) + + describe('nested aliases', () => { + const children = [ + { + path: 'one', + component, + name: 'nested', + alias: 'o', + children: [ + { path: 'two', alias: 't', name: 'nestednested', component }, + ], + }, + { + path: 'other', + alias: 'otherAlias', + component, + name: 'other', + }, + ] + const record = { + path: '/parent', + name: 'parent', + alias: '/p', + component, + children, + } + + it('resolves the parent as an alias', () => { + assertRecordMatch( + record, + { path: '/p' }, + expect.objectContaining({ + path: '/p', + name: 'parent', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + ], + }) + ) + }) + + describe('multiple children', () => { + // tests concerning the /parent/other path and its aliases + + it('resolves the alias parent', () => { + assertRecordMatch( + record, + { path: '/p/other' }, + expect.objectContaining({ + path: '/p/other', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/other', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves the alias child', () => { + assertRecordMatch( + record, + { path: '/parent/otherAlias' }, + expect.objectContaining({ + path: '/parent/otherAlias', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/otherAlias', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves the alias parent and child', () => { + assertRecordMatch( + record, + { path: '/p/otherAlias' }, + expect.objectContaining({ + path: '/p/otherAlias', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/otherAlias', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + }) + + it('resolves the original one with no aliases', () => { + assertRecordMatch( + record, + { path: '/parent/one/two' }, + expect.objectContaining({ + path: '/parent/one/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one/two', + aliasOf: undefined, + }), + ], + }) + ) + }) + + it.todo('resolves when parent is an alias and child has an absolute path') + + it('resolves when parent is an alias', () => { + assertRecordMatch( + record, + { path: '/p/one/two' }, + expect.objectContaining({ + path: '/p/one/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/one', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/one/two', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves a different child when parent is an alias', () => { + assertRecordMatch( + record, + { path: '/p/other' }, + expect.objectContaining({ + path: '/p/other', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/other', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves when the first child is an alias', () => { + assertRecordMatch( + record, + { path: '/parent/o/two' }, + expect.objectContaining({ + path: '/parent/o/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/parent/o/two', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when the second child is an alias', () => { + assertRecordMatch( + record, + { path: '/parent/one/t' }, + expect.objectContaining({ + path: '/parent/one/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when the two last children are aliases', () => { + assertRecordMatch( + record, + { path: '/parent/o/t' }, + expect.objectContaining({ + path: '/parent/o/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/parent/o/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when all are aliases', () => { + assertRecordMatch( + record, + { path: '/p/o/t' }, + expect.objectContaining({ + path: '/p/o/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/o/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when first and last are aliases', () => { + assertRecordMatch( + record, + { path: '/p/one/t' }, + expect.objectContaining({ + path: '/p/one/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/one', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/one/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + }) + + it('resolves the original path of the named children of a route with an alias', () => { + const children = [{ path: 'one', component, name: 'nested' }] + assertRecordMatch( + { + path: '/parent', + alias: '/p', + component, + children, + }, + { name: 'nested' }, + { + path: '/parent/one', + name: 'nested', + params: {}, + matched: [ + { + path: '/parent', + children, + components, + aliasOf: undefined, + }, + { path: '/parent/one', name: 'nested', components }, + ], + } + ) + }) + }) + + describe.skip('children', () => { + const ChildA = { path: 'a', name: 'child-a', components } + const ChildB = { path: 'b', name: 'child-b', components } + const ChildC = { path: 'c', name: 'child-c', components } + const ChildD = { path: '/absolute', name: 'absolute', components } + const ChildWithParam = { path: ':p', name: 'child-params', components } + const NestedChildWithParam = { + ...ChildWithParam, + name: 'nested-child-params', + } + const NestedChildA = { ...ChildA, name: 'nested-child-a' } + const NestedChildB = { ...ChildB, name: 'nested-child-b' } + const NestedChildC = { ...ChildC, name: 'nested-child-c' } + const Nested = { + path: 'nested', + name: 'nested', + components, + children: [NestedChildA, NestedChildB, NestedChildC], + } + const NestedWithParam = { + path: 'nested/:n', + name: 'nested', + components, + children: [NestedChildWithParam], + } + + it('resolves children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [ChildA, ChildB, ChildC], + } + assertRecordMatch( + Foo, + { path: '/foo/b' }, + { + name: 'child-b', + path: '/foo/b', + params: {}, + matched: [Foo, { ...ChildB, path: `${Foo.path}/${ChildB.path}` }], + } + ) + }) + + it('resolves children with empty paths', () => { + const Nested = { path: '', name: 'nested', components } + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo' }, + { + name: 'nested', + path: '/foo', + params: {}, + matched: [Foo as any, { ...Nested, path: `${Foo.path}` }], + } + ) + }) + + it('resolves nested children with empty paths', () => { + const NestedNested = { path: '', name: 'nested', components } + const Nested = { + path: '', + name: 'nested-nested', + components, + children: [NestedNested], + } + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo' }, + { + name: 'nested', + path: '/foo', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}` }, + { ...NestedNested, path: `${Foo.path}` }, + ], + } + ) + }) + + it('resolves nested children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo/nested/a' }, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with named location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { name: 'nested-child-a' }, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with relative location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + {}, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + }, + { + name: 'nested-child-a', + matched: [], + params: {}, + path: '/foo/nested/a', + meta: {}, + } + ) + }) + + it('resolves nested children with params', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [NestedWithParam], + } + assertRecordMatch( + Foo, + { path: '/foo/nested/a/b' }, + { + name: 'nested-child-params', + path: '/foo/nested/a/b', + params: { p: 'b', n: 'a' }, + matched: [ + Foo as any, + { + ...NestedWithParam, + path: `${Foo.path}/${NestedWithParam.path}`, + }, + { + ...NestedChildWithParam, + path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with params with named location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [NestedWithParam], + } + assertRecordMatch( + Foo, + { name: 'nested-child-params', params: { p: 'a', n: 'b' } }, + { + name: 'nested-child-params', + path: '/foo/nested/b/a', + params: { p: 'a', n: 'b' }, + matched: [ + Foo as any, + { + ...NestedWithParam, + path: `${Foo.path}/${NestedWithParam.path}`, + }, + { + ...NestedChildWithParam, + path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`, + }, + ], + } + ) + }) + + it('resolves absolute path children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [ChildA, ChildD], + } + assertRecordMatch( + Foo, + { path: '/absolute' }, + { + name: 'absolute', + path: '/absolute', + params: {}, + matched: [Foo, ChildD], + } + ) + }) + + it('resolves children with root as the parent', () => { + const Nested = { path: 'nested', name: 'nested', components } + const Parent = { + path: '/', + name: 'parent', + components, + children: [Nested], + } + assertRecordMatch( + Parent, + { path: '/nested' }, + { + name: 'nested', + path: '/nested', + params: {}, + matched: [Parent as any, { ...Nested, path: `/nested` }], + } + ) + }) + + it('resolves children with parent with trailing slash', () => { + const Nested = { path: 'nested', name: 'nested', components } + const Parent = { + path: '/parent/', + name: 'parent', + components, + children: [Nested], + } + assertRecordMatch( + Parent, + { path: '/parent/nested' }, + { + name: 'nested', + path: '/parent/nested', + params: {}, + matched: [Parent as any, { ...Nested, path: `/parent/nested` }], + } + ) + }) + }) +}) diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index c15561f53..f508fe113 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -1,10 +1,17 @@ import { describe, expect, it } from 'vitest' -import { createCompiledMatcher, NO_MATCH_LOCATION } from './matcher' +import { + createCompiledMatcher, + NO_MATCH_LOCATION, + pathEncoded, +} from './matcher' import { MatcherPatternParams_Base, MatcherPattern, MatcherPatternPath, MatcherPatternQuery, + MatcherPatternPathStatic, + MatcherPatternPathDynamic, + defineParamParser, } from './matcher-pattern' import { miss } from './matchers/errors' import { EmptyParams } from './matcher-location' @@ -73,7 +80,52 @@ const USER_ID_ROUTE = { path: USER_ID_PATH_PATTERN_MATCHER, } satisfies MatcherPattern -describe('Matcher', () => { +describe('RouterMatcher', () => { + describe('new matchers', () => { + it('static path', () => { + const matcher = createCompiledMatcher([ + { path: new MatcherPatternPathStatic('/') }, + { path: new MatcherPatternPathStatic('/users') }, + ]) + + expect(matcher.resolve('/')).toMatchObject({ + fullPath: '/', + path: '/', + params: {}, + query: {}, + hash: '', + }) + + expect(matcher.resolve('/users')).toMatchObject({ + fullPath: '/users', + path: '/users', + params: {}, + query: {}, + hash: '', + }) + }) + + it('dynamic path', () => { + const matcher = createCompiledMatcher([ + { + path: new MatcherPatternPathDynamic<{ id: string }>( + /^\/users\/([^\/]+)$/, + { + id: {}, + }, + ({ id }) => pathEncoded`/users/${id}` + ), + }, + ]) + + expect(matcher.resolve('/users/1')).toMatchObject({ + fullPath: '/users/1', + path: '/users/1', + params: { id: '1' }, + }) + }) + }) + describe('adding and removing', () => { it('add static path', () => { const matcher = createCompiledMatcher() @@ -87,10 +139,9 @@ describe('Matcher', () => { }) describe('resolve()', () => { - describe('absolute locationss as strings', () => { + describe('absolute locations as strings', () => { it('resolves string locations with no params', () => { - const matcher = createCompiledMatcher() - matcher.addRoute(EMPTY_PATH_ROUTE) + const matcher = createCompiledMatcher([EMPTY_PATH_ROUTE]) expect(matcher.resolve('/?a=a&b=b#h')).toMatchObject({ path: '/', @@ -113,8 +164,7 @@ describe('Matcher', () => { }) it('resolves string locations with params', () => { - const matcher = createCompiledMatcher() - matcher.addRoute(USER_ID_ROUTE) + const matcher = createCompiledMatcher([USER_ID_ROUTE]) expect(matcher.resolve('/users/1?a=a&b=b#h')).toMatchObject({ path: '/users/1', @@ -131,11 +181,12 @@ describe('Matcher', () => { }) it('resolve string locations with query', () => { - const matcher = createCompiledMatcher() - matcher.addRoute({ - path: ANY_PATH_PATTERN_MATCHER, - query: PAGE_QUERY_PATTERN_MATCHER, - }) + const matcher = createCompiledMatcher([ + { + path: ANY_PATH_PATTERN_MATCHER, + query: PAGE_QUERY_PATTERN_MATCHER, + }, + ]) expect(matcher.resolve('/foo?page=100&b=b#h')).toMatchObject({ params: { page: 100 }, @@ -149,11 +200,12 @@ describe('Matcher', () => { }) it('resolves string locations with hash', () => { - const matcher = createCompiledMatcher() - matcher.addRoute({ - path: ANY_PATH_PATTERN_MATCHER, - hash: ANY_HASH_PATTERN_MATCHER, - }) + const matcher = createCompiledMatcher([ + { + path: ANY_PATH_PATTERN_MATCHER, + hash: ANY_HASH_PATTERN_MATCHER, + }, + ]) expect(matcher.resolve('/foo?a=a&b=b#bar')).toMatchObject({ hash: '#bar', @@ -164,12 +216,13 @@ describe('Matcher', () => { }) it('combines path, query and hash params', () => { - const matcher = createCompiledMatcher() - matcher.addRoute({ - path: USER_ID_PATH_PATTERN_MATCHER, - query: PAGE_QUERY_PATTERN_MATCHER, - hash: ANY_HASH_PATTERN_MATCHER, - }) + const matcher = createCompiledMatcher([ + { + path: USER_ID_PATH_PATTERN_MATCHER, + query: PAGE_QUERY_PATTERN_MATCHER, + hash: ANY_HASH_PATTERN_MATCHER, + }, + ]) expect(matcher.resolve('/users/24?page=100#bar')).toMatchObject({ params: { id: 24, page: 100, hash: 'bar' }, @@ -179,8 +232,9 @@ describe('Matcher', () => { describe('relative locations as strings', () => { it('resolves a simple relative location', () => { - const matcher = createCompiledMatcher() - matcher.addRoute({ path: ANY_PATH_PATTERN_MATCHER }) + const matcher = createCompiledMatcher([ + { path: ANY_PATH_PATTERN_MATCHER }, + ]) expect( matcher.resolve('foo', matcher.resolve('/nested/')) @@ -211,8 +265,7 @@ describe('Matcher', () => { describe('absolute locations as objects', () => { it('resolves an object location', () => { - const matcher = createCompiledMatcher() - matcher.addRoute(EMPTY_PATH_ROUTE) + const matcher = createCompiledMatcher([EMPTY_PATH_ROUTE]) expect(matcher.resolve({ path: '/' })).toMatchObject({ fullPath: '/', path: '/', @@ -225,11 +278,12 @@ describe('Matcher', () => { describe('named locations', () => { it('resolves named locations with no params', () => { - const matcher = createCompiledMatcher() - matcher.addRoute({ - name: 'home', - path: EMPTY_PATH_PATTERN_MATCHER, - }) + const matcher = createCompiledMatcher([ + { + name: 'home', + path: EMPTY_PATH_PATTERN_MATCHER, + }, + ]) expect(matcher.resolve({ name: 'home', params: {} })).toMatchObject({ name: 'home', diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts index f9fa1c6f8..cabb296ef 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -11,7 +11,7 @@ import type { MatcherPatternQuery, } from './matcher-pattern' import { warn } from '../warning' -import { encodeQueryValue as _encodeQueryValue } from '../encoding' +import { encodeQueryValue as _encodeQueryValue, encodeParam } from '../encoding' import { parseURL, stringifyURL } from '../location' import type { MatcherLocationAsNamed, @@ -102,6 +102,17 @@ type MatcherResolveArgs = currentLocation: NEW_LocationResolved ] +/** + * Allowed location objects to be passed to {@link RouteResolver['resolve']} + */ +export type MatcherLocationRaw = + | `/${string}` + | string + | MatcherLocationAsNamed + | MatcherLocationAsPathAbsolute + | MatcherLocationAsPathRelative + | MatcherLocationAsRelative + /** * Matcher capable of adding and removing routes at runtime. */ @@ -230,6 +241,28 @@ export interface MatcherRecordRaw { children?: MatcherRecordRaw[] } +/** + * Tagged template helper to encode params into a path. Doesn't work with null + */ +export function pathEncoded( + parts: TemplateStringsArray, + ...params: Array +): string { + return parts.reduce((result, part, i) => { + return ( + result + + part + + (Array.isArray(params[i]) + ? params[i].map(encodeParam).join('/') + : encodeParam(params[i])) + ) + }) +} + +// pathEncoded`/users/${1}` +// TODO: +// pathEncoded`/users/${null}/end` + // const a: RouteRecordRaw = {} as any /** @@ -245,10 +278,9 @@ function buildMatched(record: MatcherPattern): MatcherPattern[] { return matched } -export function createCompiledMatcher(): RouteResolver< - MatcherRecordRaw, - MatcherPattern -> { +export function createCompiledMatcher( + records: MatcherRecordRaw[] = [] +): RouteResolver { // TODO: we also need an array that has the correct order const matchers = new Map() @@ -386,6 +418,10 @@ export function createCompiledMatcher(): RouteResolver< return normalizedRecord } + for (const record of records) { + addRoute(record) + } + function removeRoute(matcher: MatcherPattern) { matchers.delete(matcher.name) // TODO: delete children and aliases diff --git a/packages/router/src/new-route-resolver/matchers/test-utils.ts b/packages/router/src/new-route-resolver/matchers/test-utils.ts new file mode 100644 index 000000000..f40ce00a5 --- /dev/null +++ b/packages/router/src/new-route-resolver/matchers/test-utils.ts @@ -0,0 +1,76 @@ +import { EmptyParams } from '../matcher-location' +import { + MatcherPatternPath, + MatcherPatternQuery, + MatcherPatternParams_Base, + MatcherPattern, +} from '../matcher-pattern' +import { miss } from './errors' + +export const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{ + pathMatch: string +}> = { + match(path) { + return { pathMatch: path } + }, + build({ pathMatch }) { + return pathMatch + }, +} + +export const EMPTY_PATH_PATTERN_MATCHER: MatcherPatternPath = { + match: path => { + if (path !== '/') { + throw miss() + } + return {} + }, + build: () => '/', +} + +export const USER_ID_PATH_PATTERN_MATCHER: MatcherPatternPath<{ id: number }> = + { + match(value) { + const match = value.match(/^\/users\/(\d+)$/) + if (!match?.[1]) { + throw miss() + } + const id = Number(match[1]) + if (Number.isNaN(id)) { + throw miss() + } + return { id } + }, + build({ id }) { + return `/users/${id}` + }, + } + +export const PAGE_QUERY_PATTERN_MATCHER: MatcherPatternQuery<{ page: number }> = + { + match: query => { + const page = Number(query.page) + return { + page: Number.isNaN(page) ? 1 : page, + } + }, + build: params => ({ page: String(params.page) }), + } satisfies MatcherPatternQuery<{ page: number }> + +export const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base< + string, + { hash: string | null } +> = { + match: hash => ({ hash: hash ? hash.slice(1) : null }), + build: ({ hash }) => (hash ? `#${hash}` : ''), +} + +export const EMPTY_PATH_ROUTE = { + name: 'no params', + path: EMPTY_PATH_PATTERN_MATCHER, +} satisfies MatcherPattern + +export const USER_ID_ROUTE = { + name: 'user-id', + path: USER_ID_PATH_PATTERN_MATCHER, +} satisfies MatcherPattern diff --git a/packages/router/src/types/utils.ts b/packages/router/src/types/utils.ts index e7d163184..2d443f69e 100644 --- a/packages/router/src/types/utils.ts +++ b/packages/router/src/types/utils.ts @@ -6,6 +6,16 @@ export type _LiteralUnion = | LiteralType | (BaseType & Record) +export type IsNull = + // avoid distributive conditional types + [T] extends [null] ? true : false + +export type IsUnknown = unknown extends T // `T` can be `unknown` or `any` + ? IsNull extends false // `any` can be `null`, but `unknown` can't be + ? true + : false + : false + /** * Maybe a promise maybe not * @internal From a515a2177af6a7be41ecd853ffd90937e93f7e30 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 17 Dec 2024 15:32:50 +0100 Subject: [PATCH 23/37] refactor: reorganize types and add initial experimental router --- packages/router/src/errors.ts | 22 +- packages/router/src/experimental/router.ts | 1351 +++++++++++++++++ packages/router/src/index.ts | 3 +- packages/router/src/navigationGuards.ts | 40 + .../src/new-route-resolver/matcher.spec.ts | 1 - packages/router/src/router.ts | 349 +---- packages/router/src/scrollBehavior.ts | 16 + packages/router/src/types/utils.ts | 2 + 8 files changed, 1451 insertions(+), 333 deletions(-) create mode 100644 packages/router/src/experimental/router.ts diff --git a/packages/router/src/errors.ts b/packages/router/src/errors.ts index 877a0de21..63abf5f8a 100644 --- a/packages/router/src/errors.ts +++ b/packages/router/src/errors.ts @@ -1,5 +1,9 @@ import type { MatcherLocationRaw, MatcherLocation } from './types' -import type { RouteLocationRaw, RouteLocationNormalized } from './typed-routes' +import type { + RouteLocationRaw, + RouteLocationNormalized, + RouteLocationNormalizedLoaded, +} from './typed-routes' import { assign } from './utils' /** @@ -199,3 +203,19 @@ function stringifyRoute(to: RouteLocationRaw): string { } return JSON.stringify(location, null, 2) } +/** + * Internal type to define an ErrorHandler + * + * @param error - error thrown + * @param to - location we were navigating to when the error happened + * @param from - location we were navigating from when the error happened + * @internal + */ + +export interface _ErrorListener { + ( + error: any, + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded + ): any +} diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts new file mode 100644 index 000000000..ac44da155 --- /dev/null +++ b/packages/router/src/experimental/router.ts @@ -0,0 +1,1351 @@ +import { + createRouterError, + ErrorTypes, + isNavigationFailure, + NavigationRedirectError, + type _ErrorListener, + type NavigationFailure, +} from '../errors' +import { + nextTick, + shallowReactive, + shallowRef, + unref, + warn, + type App, + type Ref, +} from 'vue' +import { RouterLink } from '../RouterLink' +import { RouterView } from '../RouterView' +import { + NavigationType, + type HistoryState, + type RouterHistory, +} from '../history/common' +import type { PathParserOptions } from '../matcher' +import type { RouteResolver } from '../new-route-resolver/matcher' +import { + LocationQuery, + normalizeQuery, + parseQuery as originalParseQuery, + stringifyQuery as originalStringifyQuery, +} from '../query' +import type { Router } from '../router' +import { + _ScrollPositionNormalized, + computeScrollPosition, + getSavedScrollPosition, + getScrollKey, + saveScrollPosition, + scrollToPosition, + type RouterScrollBehavior, +} from '../scrollBehavior' +import type { + NavigationGuardWithThis, + NavigationHookAfter, + RouteLocation, + RouteLocationAsPath, + RouteLocationAsRelative, + RouteLocationAsRelativeTyped, + RouteLocationAsString, + RouteLocationNormalized, + RouteLocationNormalizedLoaded, + RouteLocationRaw, + RouteLocationResolved, + RouteMap, + RouteParams, + RouteRecordNameGeneric, +} from '../typed-routes' +import { + isRouteLocation, + isRouteName, + Lazy, + MatcherLocationRaw, + RouteLocationOptions, + type RouteRecordRaw, +} from '../types' +import { useCallbacks } from '../utils/callbacks' +import { + isSameRouteLocation, + parseURL, + START_LOCATION_NORMALIZED, + stringifyURL, +} from '../location' +import { applyToParams, assign, isArray, isBrowser, noop } from '../utils' +import { decode, encodeHash, encodeParam } from '../encoding' +import { + extractChangingRecords, + extractComponentsGuards, + guardToPromiseFn, +} from '../navigationGuards' +import { addDevtools } from '../devtools' +import { + routeLocationKey, + routerKey, + routerViewLocationKey, +} from '../injectionSymbols' + +/** + * resolve, reject arguments of Promise constructor + * @internal + */ +export type _OnReadyCallback = [() => void, (reason?: any) => void] + +/** + * Options to initialize a {@link Router} instance. + */ +export interface EXPERIMENTAL_RouterOptions_Base extends PathParserOptions { + /** + * History implementation used by the router. Most web applications should use + * `createWebHistory` but it requires the server to be properly configured. + * You can also use a _hash_ based history with `createWebHashHistory` that + * does not require any configuration on the server but isn't handled at all + * by search engines and does poorly on SEO. + * + * @example + * ```js + * createRouter({ + * history: createWebHistory(), + * // other options... + * }) + * ``` + */ + history: RouterHistory + + /** + * Function to control scrolling when navigating between pages. Can return a + * Promise to delay scrolling. + * + * @see {@link RouterScrollBehavior}. + * + * @example + * ```js + * function scrollBehavior(to, from, savedPosition) { + * // `to` and `from` are both route locations + * // `savedPosition` can be null if there isn't one + * } + * ``` + */ + + scrollBehavior?: RouterScrollBehavior + /** + * Custom implementation to parse a query. See its counterpart, + * {@link EXPERIMENTAL_RouterOptions_Base.stringifyQuery}. + * + * @example + * Let's say you want to use the [qs package](https://github.com/ljharb/qs) + * to parse queries, you can provide both `parseQuery` and `stringifyQuery`: + * ```js + * import qs from 'qs' + * + * createRouter({ + * // other options... + * parseQuery: qs.parse, + * stringifyQuery: qs.stringify, + * }) + * ``` + */ + + parseQuery?: typeof originalParseQuery + /** + * Custom implementation to stringify a query object. Should not prepend a leading `?`. + * {@link EXPERIMENTAL_RouterOptions_Base.parseQuery | parseQuery} counterpart to handle query parsing. + */ + + stringifyQuery?: typeof originalStringifyQuery + /** + * Default class applied to active {@link RouterLink}. If none is provided, + * `router-link-active` will be applied. + */ + + linkActiveClass?: string + /** + * Default class applied to exact active {@link RouterLink}. If none is provided, + * `router-link-exact-active` will be applied. + */ + + linkExactActiveClass?: string + /** + * Default class applied to non-active {@link RouterLink}. If none is provided, + * `router-link-inactive` will be applied. + */ + // linkInactiveClass?: string +} + +/** + * Options to initialize an experimental {@link EXPERIMENTAL_Router} instance. + * @experimental + */ +export interface EXPERIMENTAL_RouterOptions + extends EXPERIMENTAL_RouterOptions_Base { + /** + * Initial list of routes that should be added to the router. + */ + routes?: Readonly + + /** + * Matcher to use to resolve routes. + * @experimental + */ + matcher: RouteResolver +} + +/** + * Router instance. + * @experimental This version is not stable, it's meant to replace {@link Router} in the future. + */ +export interface EXPERIMENTAL_Router_Base { + /** + * Current {@link RouteLocationNormalized} + */ + readonly currentRoute: Ref + + /** + * Allows turning off the listening of history events. This is a low level api for micro-frontend. + */ + listening: boolean + + /** + * Add a new {@link RouteRecordRaw | route record} as the child of an existing route. + * + * @param parentName - Parent Route Record where `route` should be appended at + * @param route - Route Record to add + */ + addRoute( + // NOTE: it could be `keyof RouteMap` but the point of dynamic routes is not knowing the routes at build + parentName: NonNullable, + route: RouteRecordRaw + ): () => void + /** + * Add a new {@link RouteRecordRaw | route record} to the router. + * + * @param route - Route Record to add + */ + addRoute(route: TRouteRecordRaw): () => void + + /** + * Remove an existing route by its name. + * + * @param name - Name of the route to remove + */ + removeRoute(name: NonNullable): void + + /** + * Checks if a route with a given name exists + * + * @param name - Name of the route to check + */ + hasRoute(name: NonNullable): boolean + + /** + * Get a full list of all the {@link RouteRecord | route records}. + */ + getRoutes(): TRouteRecord[] + + /** + * Delete all routes from the router matcher. + */ + clearRoutes(): void + + /** + * Returns the {@link RouteLocation | normalized version} of a + * {@link RouteLocationRaw | route location}. Also includes an `href` property + * that includes any existing `base`. By default, the `currentLocation` used is + * `router.currentRoute` and should only be overridden in advanced use cases. + * + * @param to - Raw route location to resolve + * @param currentLocation - Optional current location to resolve against + */ + resolve( + to: RouteLocationAsRelativeTyped, + // NOTE: This version doesn't work probably because it infers the type too early + // | RouteLocationAsRelative + currentLocation?: RouteLocationNormalizedLoaded + ): RouteLocationResolved + resolve( + // not having the overload produces errors in RouterLink calls to router.resolve() + to: RouteLocationAsString | RouteLocationAsRelative | RouteLocationAsPath, + currentLocation?: RouteLocationNormalizedLoaded + ): RouteLocationResolved + + /** + * Programmatically navigate to a new URL by pushing an entry in the history + * stack. + * + * @param to - Route location to navigate to + */ + push(to: RouteLocationRaw): Promise + + /** + * Programmatically navigate to a new URL by replacing the current entry in + * the history stack. + * + * @param to - Route location to navigate to + */ + replace(to: RouteLocationRaw): Promise + + /** + * Go back in history if possible by calling `history.back()`. Equivalent to + * `router.go(-1)`. + */ + back(): void + + /** + * Go forward in history if possible by calling `history.forward()`. + * Equivalent to `router.go(1)`. + */ + forward(): void + + /** + * Allows you to move forward or backward through the history. Calls + * `history.go()`. + * + * @param delta - The position in the history to which you want to move, + * relative to the current page + */ + go(delta: number): void + + /** + * Add a navigation guard that executes before any navigation. Returns a + * function that removes the registered guard. + * + * @param guard - navigation guard to add + */ + beforeEach(guard: NavigationGuardWithThis): () => void + + /** + * Add a navigation guard that executes before navigation is about to be + * resolved. At this state all component have been fetched and other + * navigation guards have been successful. Returns a function that removes the + * registered guard. + * + * @param guard - navigation guard to add + * @returns a function that removes the registered guard + * + * @example + * ```js + * router.beforeResolve(to => { + * if (to.meta.requiresAuth && !isAuthenticated) return false + * }) + * ``` + * + */ + beforeResolve(guard: NavigationGuardWithThis): () => void + + /** + * Add a navigation hook that is executed after every navigation. Returns a + * function that removes the registered hook. + * + * @param guard - navigation hook to add + * @returns a function that removes the registered hook + * + * @example + * ```js + * router.afterEach((to, from, failure) => { + * if (isNavigationFailure(failure)) { + * console.log('failed navigation', failure) + * } + * }) + * ``` + */ + afterEach(guard: NavigationHookAfter): () => void + + /** + * Adds an error handler that is called every time a non caught error happens + * during navigation. This includes errors thrown synchronously and + * asynchronously, errors returned or passed to `next` in any navigation + * guard, and errors occurred when trying to resolve an async component that + * is required to render a route. + * + * @param handler - error handler to register + */ + onError(handler: _ErrorListener): () => void + + /** + * Returns a Promise that resolves when the router has completed the initial + * navigation, which means it has resolved all async enter hooks and async + * components that are associated with the initial route. If the initial + * navigation already happened, the promise resolves immediately. + * + * This is useful in server-side rendering to ensure consistent output on both + * the server and the client. Note that on server side, you need to manually + * push the initial location while on client side, the router automatically + * picks it up from the URL. + */ + isReady(): Promise + + /** + * Called automatically by `app.use(router)`. Should not be called manually by + * the user. This will trigger the initial navigation when on client side. + * + * @internal + * @param app - Application that uses the router + */ + install(app: App): void +} + +export interface EXPERIMENTAL_Router + extends EXPERIMENTAL_Router_Base { + /** + * Original options object passed to create the Router + */ + readonly options: EXPERIMENTAL_RouterOptions +} + +interface EXPERIMENTAL_RouteRecordRaw {} +interface EXPERIMENTAL_RouteRecord {} + +export function experimental_createRouter( + options: EXPERIMENTAL_RouterOptions< + EXPERIMENTAL_RouteRecordRaw, + EXPERIMENTAL_RouteRecord + > +): EXPERIMENTAL_Router { + const { + matcher, + parseQuery = originalParseQuery, + stringifyQuery = originalStringifyQuery, + history: routerHistory, + } = options + + if (__DEV__ && !routerHistory) + throw new Error( + 'Provide the "history" option when calling "createRouter()":' + + ' https://router.vuejs.org/api/interfaces/RouterOptions.html#history' + ) + + const beforeGuards = useCallbacks>() + const beforeResolveGuards = useCallbacks>() + const afterGuards = useCallbacks() + const currentRoute = shallowRef( + START_LOCATION_NORMALIZED + ) + let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED + + // leave the scrollRestoration if no scrollBehavior is provided + if (isBrowser && options.scrollBehavior && 'scrollRestoration' in history) { + history.scrollRestoration = 'manual' + } + + const normalizeParams = applyToParams.bind( + null, + paramValue => '' + paramValue + ) + const encodeParams = applyToParams.bind(null, encodeParam) + const decodeParams: (params: RouteParams | undefined) => RouteParams = + // @ts-expect-error: intentionally avoid the type check + applyToParams.bind(null, decode) + + function addRoute( + parentOrRoute: NonNullable | RouteRecordRaw, + route?: RouteRecordRaw + ) { + let parent: Parameters<(typeof matcher)['addRoute']>[1] | undefined + let record: RouteRecordRaw + if (isRouteName(parentOrRoute)) { + parent = matcher.getMatcher(parentOrRoute) + if (__DEV__ && !parent) { + warn( + `Parent route "${String( + parentOrRoute + )}" not found when adding child route`, + route + ) + } + record = route! + } else { + record = parentOrRoute + } + + return matcher.addRoute(record, parent) + } + + function removeRoute(name: NonNullable) { + const recordMatcher = matcher.getMatcher(name) + if (recordMatcher) { + matcher.removeRoute(recordMatcher) + } else if (__DEV__) { + warn(`Cannot remove non-existent route "${String(name)}"`) + } + } + + function getRoutes() { + return matcher.getMatchers().map(routeMatcher => routeMatcher.record) + } + + function hasRoute(name: NonNullable): boolean { + return !!matcher.getMatcher(name) + } + + function resolve( + rawLocation: RouteLocationRaw, + currentLocation?: RouteLocationNormalizedLoaded + ): RouteLocationResolved { + // const resolve: Router['resolve'] = (rawLocation: RouteLocationRaw, currentLocation) => { + // const objectLocation = routerLocationAsObject(rawLocation) + // we create a copy to modify it later + currentLocation = assign({}, currentLocation || currentRoute.value) + if (typeof rawLocation === 'string') { + const locationNormalized = parseURL( + parseQuery, + rawLocation, + currentLocation.path + ) + const matchedRoute = matcher.resolve( + { path: locationNormalized.path }, + currentLocation + ) + + const href = routerHistory.createHref(locationNormalized.fullPath) + if (__DEV__) { + if (href.startsWith('//')) + warn( + `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.` + ) + else if (!matchedRoute.matched.length) { + warn(`No match found for location with path "${rawLocation}"`) + } + } + + // locationNormalized is always a new object + return assign(locationNormalized, matchedRoute, { + params: decodeParams(matchedRoute.params), + hash: decode(locationNormalized.hash), + redirectedFrom: undefined, + href, + }) + } + + if (__DEV__ && !isRouteLocation(rawLocation)) { + warn( + `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`, + rawLocation + ) + return resolve({}) + } + + let matcherLocation: MatcherLocationRaw + + // path could be relative in object as well + if (rawLocation.path != null) { + if ( + __DEV__ && + 'params' in rawLocation && + !('name' in rawLocation) && + // @ts-expect-error: the type is never + Object.keys(rawLocation.params).length + ) { + warn( + `Path "${rawLocation.path}" was passed with params but they will be ignored. Use a named route alongside params instead.` + ) + } + matcherLocation = assign({}, rawLocation, { + path: parseURL(parseQuery, rawLocation.path, currentLocation.path).path, + }) + } else { + // remove any nullish param + const targetParams = assign({}, rawLocation.params) + for (const key in targetParams) { + if (targetParams[key] == null) { + delete targetParams[key] + } + } + // pass encoded values to the matcher, so it can produce encoded path and fullPath + matcherLocation = assign({}, rawLocation, { + params: encodeParams(targetParams), + }) + // current location params are decoded, we need to encode them in case the + // matcher merges the params + currentLocation.params = encodeParams(currentLocation.params) + } + + const matchedRoute = matcher.resolve(matcherLocation, currentLocation) + const hash = rawLocation.hash || '' + + if (__DEV__ && hash && !hash.startsWith('#')) { + warn( + `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".` + ) + } + + // the matcher might have merged current location params, so + // we need to run the decoding again + matchedRoute.params = normalizeParams(decodeParams(matchedRoute.params)) + + const fullPath = stringifyURL( + stringifyQuery, + assign({}, rawLocation, { + hash: encodeHash(hash), + path: matchedRoute.path, + }) + ) + + const href = routerHistory.createHref(fullPath) + if (__DEV__) { + if (href.startsWith('//')) { + warn( + `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.` + ) + } else if (!matchedRoute.matched.length) { + warn( + `No match found for location with path "${ + rawLocation.path != null ? rawLocation.path : rawLocation + }"` + ) + } + } + + return assign( + { + fullPath, + // keep the hash encoded so fullPath is effectively path + encodedQuery + + // hash + hash, + query: + // if the user is using a custom query lib like qs, we might have + // nested objects, so we keep the query as is, meaning it can contain + // numbers at `$route.query`, but at the point, the user will have to + // use their own type anyway. + // https://github.com/vuejs/router/issues/328#issuecomment-649481567 + stringifyQuery === originalStringifyQuery + ? normalizeQuery(rawLocation.query) + : ((rawLocation.query || {}) as LocationQuery), + }, + matchedRoute, + { + redirectedFrom: undefined, + href, + } + ) + } + + function locationAsObject( + to: RouteLocationRaw | RouteLocationNormalized + ): Exclude | RouteLocationNormalized { + return typeof to === 'string' + ? parseURL(parseQuery, to, currentRoute.value.path) + : assign({}, to) + } + + function checkCanceledNavigation( + to: RouteLocationNormalized, + from: RouteLocationNormalized + ): NavigationFailure | void { + if (pendingLocation !== to) { + return createRouterError( + ErrorTypes.NAVIGATION_CANCELLED, + { + from, + to, + } + ) + } + } + + function push(to: RouteLocationRaw) { + return pushWithRedirect(to) + } + + function replace(to: RouteLocationRaw) { + return push(assign(locationAsObject(to), { replace: true })) + } + + function handleRedirectRecord(to: RouteLocation): RouteLocationRaw | void { + const lastMatched = to.matched[to.matched.length - 1] + if (lastMatched && lastMatched.redirect) { + const { redirect } = lastMatched + let newTargetLocation = + typeof redirect === 'function' ? redirect(to) : redirect + + if (typeof newTargetLocation === 'string') { + newTargetLocation = + newTargetLocation.includes('?') || newTargetLocation.includes('#') + ? (newTargetLocation = locationAsObject(newTargetLocation)) + : // force empty params + { path: newTargetLocation } + // @ts-expect-error: force empty params when a string is passed to let + // the router parse them again + newTargetLocation.params = {} + } + + if ( + __DEV__ && + newTargetLocation.path == null && + !('name' in newTargetLocation) + ) { + warn( + `Invalid redirect found:\n${JSON.stringify( + newTargetLocation, + null, + 2 + )}\n when navigating to "${ + to.fullPath + }". A redirect must contain a name or path. This will break in production.` + ) + throw new Error('Invalid redirect') + } + + return assign( + { + query: to.query, + hash: to.hash, + // avoid transferring params if the redirect has a path + params: newTargetLocation.path != null ? {} : to.params, + }, + newTargetLocation + ) + } + } + + function pushWithRedirect( + to: RouteLocationRaw | RouteLocation, + redirectedFrom?: RouteLocation + ): Promise { + const targetLocation: RouteLocation = (pendingLocation = resolve(to)) + const from = currentRoute.value + const data: HistoryState | undefined = (to as RouteLocationOptions).state + const force: boolean | undefined = (to as RouteLocationOptions).force + // to could be a string where `replace` is a function + const replace = (to as RouteLocationOptions).replace === true + + const shouldRedirect = handleRedirectRecord(targetLocation) + + if (shouldRedirect) + return pushWithRedirect( + assign(locationAsObject(shouldRedirect), { + state: + typeof shouldRedirect === 'object' + ? assign({}, data, shouldRedirect.state) + : data, + force, + replace, + }), + // keep original redirectedFrom if it exists + redirectedFrom || targetLocation + ) + + // if it was a redirect we already called `pushWithRedirect` above + const toLocation = targetLocation as RouteLocationNormalized + + toLocation.redirectedFrom = redirectedFrom + let failure: NavigationFailure | void | undefined + + if (!force && isSameRouteLocation(stringifyQuery, from, targetLocation)) { + failure = createRouterError( + ErrorTypes.NAVIGATION_DUPLICATED, + { to: toLocation, from } + ) + // trigger scroll to allow scrolling to the same anchor + handleScroll( + from, + from, + // this is a push, the only way for it to be triggered from a + // history.listen is with a redirect, which makes it become a push + true, + // This cannot be the first navigation because the initial location + // cannot be manually navigated to + false + ) + } + + return (failure ? Promise.resolve(failure) : navigate(toLocation, from)) + .catch((error: NavigationFailure | NavigationRedirectError) => + isNavigationFailure(error) + ? // navigation redirects still mark the router as ready + isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT) + ? error + : markAsReady(error) // also returns the error + : // reject any unknown error + triggerError(error, toLocation, from) + ) + .then((failure: NavigationFailure | NavigationRedirectError | void) => { + if (failure) { + if ( + isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT) + ) { + if ( + __DEV__ && + // we are redirecting to the same location we were already at + isSameRouteLocation( + stringifyQuery, + resolve(failure.to), + toLocation + ) && + // and we have done it a couple of times + redirectedFrom && + // @ts-expect-error: added only in dev + (redirectedFrom._count = redirectedFrom._count + ? // @ts-expect-error + redirectedFrom._count + 1 + : 1) > 30 + ) { + warn( + `Detected a possibly infinite redirection in a navigation guard when going from "${from.fullPath}" to "${toLocation.fullPath}". Aborting to avoid a Stack Overflow.\n Are you always returning a new location within a navigation guard? That would lead to this error. Only return when redirecting or aborting, that should fix this. This might break in production if not fixed.` + ) + return Promise.reject( + new Error('Infinite redirect in navigation guard') + ) + } + + return pushWithRedirect( + // keep options + assign( + { + // preserve an existing replacement but allow the redirect to override it + replace, + }, + locationAsObject(failure.to), + { + state: + typeof failure.to === 'object' + ? assign({}, data, failure.to.state) + : data, + force, + } + ), + // preserve the original redirectedFrom if any + redirectedFrom || toLocation + ) + } + } else { + // if we fail we don't finalize the navigation + failure = finalizeNavigation( + toLocation as RouteLocationNormalizedLoaded, + from, + true, + replace, + data + ) + } + triggerAfterEach( + toLocation as RouteLocationNormalizedLoaded, + from, + failure + ) + return failure + }) + } + + /** + * Helper to reject and skip all navigation guards if a new navigation happened + * @param to + * @param from + */ + function checkCanceledNavigationAndReject( + to: RouteLocationNormalized, + from: RouteLocationNormalized + ): Promise { + const error = checkCanceledNavigation(to, from) + return error ? Promise.reject(error) : Promise.resolve() + } + + function runWithContext(fn: () => T): T { + const app: App | undefined = installedApps.values().next().value + // support Vue < 3.3 + return app && typeof app.runWithContext === 'function' + ? app.runWithContext(fn) + : fn() + } + + // TODO: refactor the whole before guards by internally using router.beforeEach + + function navigate( + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded + ): Promise { + let guards: Lazy[] + + const [leavingRecords, updatingRecords, enteringRecords] = + extractChangingRecords(to, from) + + // all components here have been resolved once because we are leaving + guards = extractComponentsGuards( + leavingRecords.reverse(), + 'beforeRouteLeave', + to, + from + ) + + // leavingRecords is already reversed + for (const record of leavingRecords) { + record.leaveGuards.forEach(guard => { + guards.push(guardToPromiseFn(guard, to, from)) + }) + } + + const canceledNavigationCheck = checkCanceledNavigationAndReject.bind( + null, + to, + from + ) + + guards.push(canceledNavigationCheck) + + // run the queue of per route beforeRouteLeave guards + return ( + runGuardQueue(guards) + .then(() => { + // check global guards beforeEach + guards = [] + for (const guard of beforeGuards.list()) { + guards.push(guardToPromiseFn(guard, to, from)) + } + guards.push(canceledNavigationCheck) + + return runGuardQueue(guards) + }) + .then(() => { + // check in components beforeRouteUpdate + guards = extractComponentsGuards( + updatingRecords, + 'beforeRouteUpdate', + to, + from + ) + + for (const record of updatingRecords) { + record.updateGuards.forEach(guard => { + guards.push(guardToPromiseFn(guard, to, from)) + }) + } + guards.push(canceledNavigationCheck) + + // run the queue of per route beforeEnter guards + return runGuardQueue(guards) + }) + .then(() => { + // check the route beforeEnter + guards = [] + for (const record of enteringRecords) { + // do not trigger beforeEnter on reused views + if (record.beforeEnter) { + if (isArray(record.beforeEnter)) { + for (const beforeEnter of record.beforeEnter) + guards.push(guardToPromiseFn(beforeEnter, to, from)) + } else { + guards.push(guardToPromiseFn(record.beforeEnter, to, from)) + } + } + } + guards.push(canceledNavigationCheck) + + // run the queue of per route beforeEnter guards + return runGuardQueue(guards) + }) + .then(() => { + // NOTE: at this point to.matched is normalized and does not contain any () => Promise + + // clear existing enterCallbacks, these are added by extractComponentsGuards + to.matched.forEach(record => (record.enterCallbacks = {})) + + // check in-component beforeRouteEnter + guards = extractComponentsGuards( + enteringRecords, + 'beforeRouteEnter', + to, + from, + runWithContext + ) + guards.push(canceledNavigationCheck) + + // run the queue of per route beforeEnter guards + return runGuardQueue(guards) + }) + .then(() => { + // check global guards beforeResolve + guards = [] + for (const guard of beforeResolveGuards.list()) { + guards.push(guardToPromiseFn(guard, to, from)) + } + guards.push(canceledNavigationCheck) + + return runGuardQueue(guards) + }) + // catch any navigation canceled + .catch(err => + isNavigationFailure(err, ErrorTypes.NAVIGATION_CANCELLED) + ? err + : Promise.reject(err) + ) + ) + } + + function triggerAfterEach( + to: RouteLocationNormalizedLoaded, + from: RouteLocationNormalizedLoaded, + failure?: NavigationFailure | void + ): void { + // navigation is confirmed, call afterGuards + // TODO: wrap with error handlers + afterGuards + .list() + .forEach(guard => runWithContext(() => guard(to, from, failure))) + } + + /** + * - Cleans up any navigation guards + * - Changes the url if necessary + * - Calls the scrollBehavior + */ + function finalizeNavigation( + toLocation: RouteLocationNormalizedLoaded, + from: RouteLocationNormalizedLoaded, + isPush: boolean, + replace?: boolean, + data?: HistoryState + ): NavigationFailure | void { + // a more recent navigation took place + const error = checkCanceledNavigation(toLocation, from) + if (error) return error + + // only consider as push if it's not the first navigation + const isFirstNavigation = from === START_LOCATION_NORMALIZED + const state: Partial | null = !isBrowser ? {} : history.state + + // change URL only if the user did a push/replace and if it's not the initial navigation because + // it's just reflecting the url + if (isPush) { + // on the initial navigation, we want to reuse the scroll position from + // history state if it exists + if (replace || isFirstNavigation) + routerHistory.replace( + toLocation.fullPath, + assign( + { + scroll: isFirstNavigation && state && state.scroll, + }, + data + ) + ) + else routerHistory.push(toLocation.fullPath, data) + } + + // accept current navigation + currentRoute.value = toLocation + handleScroll(toLocation, from, isPush, isFirstNavigation) + + markAsReady() + } + + let removeHistoryListener: undefined | null | (() => void) + // attach listener to history to trigger navigations + function setupListeners() { + // avoid setting up listeners twice due to an invalid first navigation + if (removeHistoryListener) return + removeHistoryListener = routerHistory.listen((to, _from, info) => { + if (!router.listening) return + // cannot be a redirect route because it was in history + const toLocation = resolve(to) as RouteLocationNormalized + + // due to dynamic routing, and to hash history with manual navigation + // (manually changing the url or calling history.hash = '#/somewhere'), + // there could be a redirect record in history + const shouldRedirect = handleRedirectRecord(toLocation) + if (shouldRedirect) { + pushWithRedirect( + assign(shouldRedirect, { replace: true, force: true }), + toLocation + ).catch(noop) + return + } + + pendingLocation = toLocation + const from = currentRoute.value + + // TODO: should be moved to web history? + if (isBrowser) { + saveScrollPosition( + getScrollKey(from.fullPath, info.delta), + computeScrollPosition() + ) + } + + navigate(toLocation, from) + .catch((error: NavigationFailure | NavigationRedirectError) => { + if ( + isNavigationFailure( + error, + ErrorTypes.NAVIGATION_ABORTED | ErrorTypes.NAVIGATION_CANCELLED + ) + ) { + return error + } + if ( + isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT) + ) { + // Here we could call if (info.delta) routerHistory.go(-info.delta, + // false) but this is bug prone as we have no way to wait the + // navigation to be finished before calling pushWithRedirect. Using + // a setTimeout of 16ms seems to work but there is no guarantee for + // it to work on every browser. So instead we do not restore the + // history entry and trigger a new navigation as requested by the + // navigation guard. + + // the error is already handled by router.push we just want to avoid + // logging the error + pushWithRedirect( + assign(locationAsObject((error as NavigationRedirectError).to), { + force: true, + }), + toLocation + // avoid an uncaught rejection, let push call triggerError + ) + .then(failure => { + // manual change in hash history #916 ending up in the URL not + // changing, but it was changed by the manual url change, so we + // need to manually change it ourselves + if ( + isNavigationFailure( + failure, + ErrorTypes.NAVIGATION_ABORTED | + ErrorTypes.NAVIGATION_DUPLICATED + ) && + !info.delta && + info.type === NavigationType.pop + ) { + routerHistory.go(-1, false) + } + }) + .catch(noop) + // avoid the then branch + return Promise.reject() + } + // do not restore history on unknown direction + if (info.delta) { + routerHistory.go(-info.delta, false) + } + // unrecognized error, transfer to the global handler + return triggerError(error, toLocation, from) + }) + .then((failure: NavigationFailure | void) => { + failure = + failure || + finalizeNavigation( + // after navigation, all matched components are resolved + toLocation as RouteLocationNormalizedLoaded, + from, + false + ) + + // revert the navigation + if (failure) { + if ( + info.delta && + // a new navigation has been triggered, so we do not want to revert, that will change the current history + // entry while a different route is displayed + !isNavigationFailure(failure, ErrorTypes.NAVIGATION_CANCELLED) + ) { + routerHistory.go(-info.delta, false) + } else if ( + info.type === NavigationType.pop && + isNavigationFailure( + failure, + ErrorTypes.NAVIGATION_ABORTED | ErrorTypes.NAVIGATION_DUPLICATED + ) + ) { + // manual change in hash history #916 + // it's like a push but lacks the information of the direction + routerHistory.go(-1, false) + } + } + + triggerAfterEach( + toLocation as RouteLocationNormalizedLoaded, + from, + failure + ) + }) + // avoid warnings in the console about uncaught rejections, they are logged by triggerErrors + .catch(noop) + }) + } + + // Initialization and Errors + + let readyHandlers = useCallbacks<_OnReadyCallback>() + let errorListeners = useCallbacks<_ErrorListener>() + let ready: boolean + + /** + * Trigger errorListeners added via onError and throws the error as well + * + * @param error - error to throw + * @param to - location we were navigating to when the error happened + * @param from - location we were navigating from when the error happened + * @returns the error as a rejected promise + */ + function triggerError( + error: any, + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded + ): Promise { + markAsReady(error) + const list = errorListeners.list() + if (list.length) { + list.forEach(handler => handler(error, to, from)) + } else { + if (__DEV__) { + warn('uncaught error during route navigation:') + } + console.error(error) + } + // reject the error no matter there were error listeners or not + return Promise.reject(error) + } + + function isReady(): Promise { + if (ready && currentRoute.value !== START_LOCATION_NORMALIZED) + return Promise.resolve() + return new Promise((resolve, reject) => { + readyHandlers.add([resolve, reject]) + }) + } + + /** + * Mark the router as ready, resolving the promised returned by isReady(). Can + * only be called once, otherwise does nothing. + * @param err - optional error + */ + function markAsReady(err: E): E + function markAsReady(): void + function markAsReady(err?: E): E | void { + if (!ready) { + // still not ready if an error happened + ready = !err + setupListeners() + readyHandlers + .list() + .forEach(([resolve, reject]) => (err ? reject(err) : resolve())) + readyHandlers.reset() + } + return err + } + + // Scroll behavior + function handleScroll( + to: RouteLocationNormalizedLoaded, + from: RouteLocationNormalizedLoaded, + isPush: boolean, + isFirstNavigation: boolean + ): // the return is not meant to be used + Promise { + const { scrollBehavior } = options + if (!isBrowser || !scrollBehavior) return Promise.resolve() + + const scrollPosition: _ScrollPositionNormalized | null = + (!isPush && getSavedScrollPosition(getScrollKey(to.fullPath, 0))) || + ((isFirstNavigation || !isPush) && + (history.state as HistoryState) && + history.state.scroll) || + null + + return nextTick() + .then(() => scrollBehavior(to, from, scrollPosition)) + .then(position => position && scrollToPosition(position)) + .catch(err => triggerError(err, to, from)) + } + + const go = (delta: number) => routerHistory.go(delta) + + let started: boolean | undefined + const installedApps = new Set() + + const router: Router = { + currentRoute, + listening: true, + + addRoute, + removeRoute, + clearRoutes: matcher.clearRoutes, + hasRoute, + getRoutes, + resolve, + options, + + push, + replace, + go, + back: () => go(-1), + forward: () => go(1), + + beforeEach: beforeGuards.add, + beforeResolve: beforeResolveGuards.add, + afterEach: afterGuards.add, + + onError: errorListeners.add, + isReady, + + install(app: App) { + const router = this + app.component('RouterLink', RouterLink) + app.component('RouterView', RouterView) + + app.config.globalProperties.$router = router + Object.defineProperty(app.config.globalProperties, '$route', { + enumerable: true, + get: () => unref(currentRoute), + }) + + // this initial navigation is only necessary on client, on server it doesn't + // make sense because it will create an extra unnecessary navigation and could + // lead to problems + if ( + isBrowser && + // used for the initial navigation client side to avoid pushing + // multiple times when the router is used in multiple apps + !started && + currentRoute.value === START_LOCATION_NORMALIZED + ) { + // see above + started = true + push(routerHistory.location).catch(err => { + if (__DEV__) warn('Unexpected error when starting the router:', err) + }) + } + + const reactiveRoute = {} as RouteLocationNormalizedLoaded + for (const key in START_LOCATION_NORMALIZED) { + Object.defineProperty(reactiveRoute, key, { + get: () => currentRoute.value[key as keyof RouteLocationNormalized], + enumerable: true, + }) + } + + app.provide(routerKey, router) + app.provide(routeLocationKey, shallowReactive(reactiveRoute)) + app.provide(routerViewLocationKey, currentRoute) + + const unmountApp = app.unmount + installedApps.add(app) + app.unmount = function () { + installedApps.delete(app) + // the router is not attached to an app anymore + if (installedApps.size < 1) { + // invalidate the current navigation + pendingLocation = START_LOCATION_NORMALIZED + removeHistoryListener && removeHistoryListener() + removeHistoryListener = null + currentRoute.value = START_LOCATION_NORMALIZED + started = false + ready = false + } + unmountApp() + } + + // TODO: this probably needs to be updated so it can be used by vue-termui + if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && isBrowser) { + addDevtools(app, router, matcher) + } + }, + } + + // TODO: type this as NavigationGuardReturn or similar instead of any + function runGuardQueue(guards: Lazy[]): Promise { + return guards.reduce( + (promise, guard) => promise.then(() => runWithContext(guard)), + Promise.resolve() + ) + } + + return router +} diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 2a62ad156..88e9ce732 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -137,7 +137,8 @@ export type { } from './typed-routes' export { createRouter } from './router' -export type { Router, RouterOptions, RouterScrollBehavior } from './router' +export type { Router, RouterOptions } from './router' +export type { RouterScrollBehavior } from './scrollBehavior' export { NavigationFailureType, isNavigationFailure } from './errors' export type { diff --git a/packages/router/src/navigationGuards.ts b/packages/router/src/navigationGuards.ts index 90c079f70..2f314ba67 100644 --- a/packages/router/src/navigationGuards.ts +++ b/packages/router/src/navigationGuards.ts @@ -22,6 +22,7 @@ import { matchedRouteKey } from './injectionSymbols' import { RouteRecordNormalized } from './matcher/types' import { isESModule, isRouteComponent } from './utils' import { warn } from './warning' +import { isSameRouteRecord } from './location' function registerGuard( record: RouteRecordNormalized, @@ -393,3 +394,42 @@ export function loadRouteLocation( ) ).then(() => route as RouteLocationNormalizedLoaded) } + +/** + * Split the leaving, updating, and entering records. + * @internal + * + * @param to - Location we are navigating to + * @param from - Location we are navigating from + */ +export function extractChangingRecords( + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded +): [ + leavingRecords: RouteRecordNormalized[], + updatingRecords: RouteRecordNormalized[], + enteringRecords: RouteRecordNormalized[] +] { + const leavingRecords: RouteRecordNormalized[] = [] + const updatingRecords: RouteRecordNormalized[] = [] + const enteringRecords: RouteRecordNormalized[] = [] + + const len = Math.max(from.matched.length, to.matched.length) + for (let i = 0; i < len; i++) { + const recordFrom = from.matched[i] + if (recordFrom) { + if (to.matched.find(record => isSameRouteRecord(record, recordFrom))) + updatingRecords.push(recordFrom) + else leavingRecords.push(recordFrom) + } + const recordTo = to.matched[i] + if (recordTo) { + // the type doesn't matter because we are comparing per reference + if (!from.matched.find(record => isSameRouteRecord(record, recordTo))) { + enteringRecords.push(recordTo) + } + } + } + + return [leavingRecords, updatingRecords, enteringRecords] +} diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index f508fe113..22fb3e511 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -11,7 +11,6 @@ import { MatcherPatternQuery, MatcherPatternPathStatic, MatcherPatternPathDynamic, - defineParamParser, } from './matcher-pattern' import { miss } from './matchers/errors' import { EmptyParams } from './matcher-location' diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 748a06a32..059606db2 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -15,14 +15,10 @@ import type { NavigationGuardWithThis, NavigationHookAfter, RouteLocationResolved, - RouteLocationAsRelative, - RouteLocationAsPath, - RouteLocationAsString, RouteRecordNameGeneric, } from './typed-routes' -import { RouterHistory, HistoryState, NavigationType } from './history/common' +import { HistoryState, NavigationType } from './history/common' import { - ScrollPosition, getSavedScrollPosition, getScrollKey, saveScrollPosition, @@ -30,13 +26,14 @@ import { scrollToPosition, _ScrollPositionNormalized, } from './scrollBehavior' -import { createRouterMatcher, PathParserOptions } from './matcher' +import { createRouterMatcher } from './matcher' import { createRouterError, ErrorTypes, NavigationFailure, NavigationRedirectError, isNavigationFailure, + _ErrorListener, } from './errors' import { applyToParams, isBrowser, assign, noop, isArray } from './utils' import { useCallbacks } from './utils/callbacks' @@ -47,16 +44,19 @@ import { stringifyQuery as originalStringifyQuery, LocationQuery, } from './query' -import { shallowRef, Ref, nextTick, App, unref, shallowReactive } from 'vue' -import { RouteRecord, RouteRecordNormalized } from './matcher/types' +import { shallowRef, nextTick, App, unref, shallowReactive } from 'vue' +import { RouteRecordNormalized } from './matcher/types' import { parseURL, stringifyURL, isSameRouteLocation, - isSameRouteRecord, START_LOCATION_NORMALIZED, } from './location' -import { extractComponentsGuards, guardToPromiseFn } from './navigationGuards' +import { + extractChangingRecords, + extractComponentsGuards, + guardToPromiseFn, +} from './navigationGuards' import { warn } from './warning' import { RouterLink } from './RouterLink' import { RouterView } from './RouterView' @@ -67,314 +67,31 @@ import { } from './injectionSymbols' import { addDevtools } from './devtools' import { _LiteralUnion } from './types/utils' -import { RouteLocationAsRelativeTyped } from './typed-routes/route-location' -import { RouteMap } from './typed-routes/route-map' - -/** - * Internal type to define an ErrorHandler - * - * @param error - error thrown - * @param to - location we were navigating to when the error happened - * @param from - location we were navigating from when the error happened - * @internal - */ -export interface _ErrorListener { - ( - error: any, - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded - ): any -} -// resolve, reject arguments of Promise constructor -type OnReadyCallback = [() => void, (reason?: any) => void] - -type Awaitable = T | Promise - -/** - * Type of the `scrollBehavior` option that can be passed to `createRouter`. - */ -export interface RouterScrollBehavior { - /** - * @param to - Route location where we are navigating to - * @param from - Route location where we are navigating from - * @param savedPosition - saved position if it exists, `null` otherwise - */ - ( - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded, - savedPosition: _ScrollPositionNormalized | null - ): Awaitable -} +import { + EXPERIMENTAL_RouterOptions_Base, + EXPERIMENTAL_Router_Base, + _OnReadyCallback, +} from './experimental/router' /** * Options to initialize a {@link Router} instance. */ -export interface RouterOptions extends PathParserOptions { - /** - * History implementation used by the router. Most web applications should use - * `createWebHistory` but it requires the server to be properly configured. - * You can also use a _hash_ based history with `createWebHashHistory` that - * does not require any configuration on the server but isn't handled at all - * by search engines and does poorly on SEO. - * - * @example - * ```js - * createRouter({ - * history: createWebHistory(), - * // other options... - * }) - * ``` - */ - history: RouterHistory +export interface RouterOptions extends EXPERIMENTAL_RouterOptions_Base { /** * Initial list of routes that should be added to the router. */ routes: Readonly - /** - * Function to control scrolling when navigating between pages. Can return a - * Promise to delay scrolling. Check {@link ScrollBehavior}. - * - * @example - * ```js - * function scrollBehavior(to, from, savedPosition) { - * // `to` and `from` are both route locations - * // `savedPosition` can be null if there isn't one - * } - * ``` - */ - scrollBehavior?: RouterScrollBehavior - /** - * Custom implementation to parse a query. See its counterpart, - * {@link RouterOptions.stringifyQuery}. - * - * @example - * Let's say you want to use the [qs package](https://github.com/ljharb/qs) - * to parse queries, you can provide both `parseQuery` and `stringifyQuery`: - * ```js - * import qs from 'qs' - * - * createRouter({ - * // other options... - * parseQuery: qs.parse, - * stringifyQuery: qs.stringify, - * }) - * ``` - */ - parseQuery?: typeof originalParseQuery - /** - * Custom implementation to stringify a query object. Should not prepend a leading `?`. - * {@link RouterOptions.parseQuery | parseQuery} counterpart to handle query parsing. - */ - stringifyQuery?: typeof originalStringifyQuery - /** - * Default class applied to active {@link RouterLink}. If none is provided, - * `router-link-active` will be applied. - */ - linkActiveClass?: string - /** - * Default class applied to exact active {@link RouterLink}. If none is provided, - * `router-link-exact-active` will be applied. - */ - linkExactActiveClass?: string - /** - * Default class applied to non-active {@link RouterLink}. If none is provided, - * `router-link-inactive` will be applied. - */ - // linkInactiveClass?: string } /** * Router instance. */ -export interface Router { - /** - * @internal - */ - // readonly history: RouterHistory - /** - * Current {@link RouteLocationNormalized} - */ - readonly currentRoute: Ref +export interface Router + extends EXPERIMENTAL_Router_Base { /** * Original options object passed to create the Router */ readonly options: RouterOptions - - /** - * Allows turning off the listening of history events. This is a low level api for micro-frontend. - */ - listening: boolean - - /** - * Add a new {@link RouteRecordRaw | route record} as the child of an existing route. - * - * @param parentName - Parent Route Record where `route` should be appended at - * @param route - Route Record to add - */ - addRoute( - // NOTE: it could be `keyof RouteMap` but the point of dynamic routes is not knowing the routes at build - parentName: NonNullable, - route: RouteRecordRaw - ): () => void - /** - * Add a new {@link RouteRecordRaw | route record} to the router. - * - * @param route - Route Record to add - */ - addRoute(route: RouteRecordRaw): () => void - /** - * Remove an existing route by its name. - * - * @param name - Name of the route to remove - */ - removeRoute(name: NonNullable): void - /** - * Checks if a route with a given name exists - * - * @param name - Name of the route to check - */ - hasRoute(name: NonNullable): boolean - /** - * Get a full list of all the {@link RouteRecord | route records}. - */ - getRoutes(): RouteRecord[] - - /** - * Delete all routes from the router matcher. - */ - clearRoutes(): void - - /** - * Returns the {@link RouteLocation | normalized version} of a - * {@link RouteLocationRaw | route location}. Also includes an `href` property - * that includes any existing `base`. By default, the `currentLocation` used is - * `router.currentRoute` and should only be overridden in advanced use cases. - * - * @param to - Raw route location to resolve - * @param currentLocation - Optional current location to resolve against - */ - resolve( - to: RouteLocationAsRelativeTyped, - // NOTE: This version doesn't work probably because it infers the type too early - // | RouteLocationAsRelative - currentLocation?: RouteLocationNormalizedLoaded - ): RouteLocationResolved - resolve( - // not having the overload produces errors in RouterLink calls to router.resolve() - to: RouteLocationAsString | RouteLocationAsRelative | RouteLocationAsPath, - currentLocation?: RouteLocationNormalizedLoaded - ): RouteLocationResolved - - /** - * Programmatically navigate to a new URL by pushing an entry in the history - * stack. - * - * @param to - Route location to navigate to - */ - push(to: RouteLocationRaw): Promise - - /** - * Programmatically navigate to a new URL by replacing the current entry in - * the history stack. - * - * @param to - Route location to navigate to - */ - replace(to: RouteLocationRaw): Promise - - /** - * Go back in history if possible by calling `history.back()`. Equivalent to - * `router.go(-1)`. - */ - back(): ReturnType - /** - * Go forward in history if possible by calling `history.forward()`. - * Equivalent to `router.go(1)`. - */ - forward(): ReturnType - /** - * Allows you to move forward or backward through the history. Calls - * `history.go()`. - * - * @param delta - The position in the history to which you want to move, - * relative to the current page - */ - go(delta: number): void - - /** - * Add a navigation guard that executes before any navigation. Returns a - * function that removes the registered guard. - * - * @param guard - navigation guard to add - */ - beforeEach(guard: NavigationGuardWithThis): () => void - /** - * Add a navigation guard that executes before navigation is about to be - * resolved. At this state all component have been fetched and other - * navigation guards have been successful. Returns a function that removes the - * registered guard. - * - * @param guard - navigation guard to add - * @returns a function that removes the registered guard - * - * @example - * ```js - * router.beforeResolve(to => { - * if (to.meta.requiresAuth && !isAuthenticated) return false - * }) - * ``` - * - */ - beforeResolve(guard: NavigationGuardWithThis): () => void - - /** - * Add a navigation hook that is executed after every navigation. Returns a - * function that removes the registered hook. - * - * @param guard - navigation hook to add - * @returns a function that removes the registered hook - * - * @example - * ```js - * router.afterEach((to, from, failure) => { - * if (isNavigationFailure(failure)) { - * console.log('failed navigation', failure) - * } - * }) - * ``` - */ - afterEach(guard: NavigationHookAfter): () => void - - /** - * Adds an error handler that is called every time a non caught error happens - * during navigation. This includes errors thrown synchronously and - * asynchronously, errors returned or passed to `next` in any navigation - * guard, and errors occurred when trying to resolve an async component that - * is required to render a route. - * - * @param handler - error handler to register - */ - onError(handler: _ErrorListener): () => void - /** - * Returns a Promise that resolves when the router has completed the initial - * navigation, which means it has resolved all async enter hooks and async - * components that are associated with the initial route. If the initial - * navigation already happened, the promise resolves immediately. - * - * This is useful in server-side rendering to ensure consistent output on both - * the server and the client. Note that on server side, you need to manually - * push the initial location while on client side, the router automatically - * picks it up from the URL. - */ - isReady(): Promise - - /** - * Called automatically by `app.use(router)`. Should not be called manually by - * the user. This will trigger the initial navigation when on client side. - * - * @internal - * @param app - Application that uses the router - */ - install(app: App): void } /** @@ -1141,7 +858,7 @@ export function createRouter(options: RouterOptions): Router { // Initialization and Errors - let readyHandlers = useCallbacks() + let readyHandlers = useCallbacks<_OnReadyCallback>() let errorListeners = useCallbacks<_ErrorListener>() let ready: boolean @@ -1328,31 +1045,3 @@ export function createRouter(options: RouterOptions): Router { return router } - -function extractChangingRecords( - to: RouteLocationNormalized, - from: RouteLocationNormalizedLoaded -) { - const leavingRecords: RouteRecordNormalized[] = [] - const updatingRecords: RouteRecordNormalized[] = [] - const enteringRecords: RouteRecordNormalized[] = [] - - const len = Math.max(from.matched.length, to.matched.length) - for (let i = 0; i < len; i++) { - const recordFrom = from.matched[i] - if (recordFrom) { - if (to.matched.find(record => isSameRouteRecord(record, recordFrom))) - updatingRecords.push(recordFrom) - else leavingRecords.push(recordFrom) - } - const recordTo = to.matched[i] - if (recordTo) { - // the type doesn't matter because we are comparing per reference - if (!from.matched.find(record => isSameRouteRecord(record, recordTo))) { - enteringRecords.push(recordTo) - } - } - } - - return [leavingRecords, updatingRecords, enteringRecords] -} diff --git a/packages/router/src/scrollBehavior.ts b/packages/router/src/scrollBehavior.ts index 642556452..8124a9fb0 100644 --- a/packages/router/src/scrollBehavior.ts +++ b/packages/router/src/scrollBehavior.ts @@ -29,6 +29,22 @@ export type _ScrollPositionNormalized = { top: number } +/** + * Type of the `scrollBehavior` option that can be passed to `createRouter`. + */ +export interface RouterScrollBehavior { + /** + * @param to - Route location where we are navigating to + * @param from - Route location where we are navigating from + * @param savedPosition - saved position if it exists, `null` otherwise + */ + ( + to: RouteLocationNormalized, + from: RouteLocationNormalizedLoaded, + savedPosition: _ScrollPositionNormalized | null + ): Awaitable +} + export interface ScrollPositionElement extends ScrollToOptions { /** * A valid CSS selector. Note some characters must be escaped in id selectors (https://mathiasbynens.be/notes/css-escapes). diff --git a/packages/router/src/types/utils.ts b/packages/router/src/types/utils.ts index 2d443f69e..34881aca5 100644 --- a/packages/router/src/types/utils.ts +++ b/packages/router/src/types/utils.ts @@ -94,3 +94,5 @@ export type _AlphaNumeric = | '8' | '9' | '_' + +export type Awaitable = T | Promise From 42c984993a25189bc1cdc7abdf7f49970f8f0fa0 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 23 Dec 2024 11:44:01 +0100 Subject: [PATCH 24/37] chore: comments --- packages/router/src/experimental/router.ts | 20 +++++++++---------- .../router/src/new-route-resolver/matcher.ts | 2 -- packages/router/src/types/index.ts | 6 ++++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index ac44da155..cc73bc9c3 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -126,8 +126,8 @@ export interface EXPERIMENTAL_RouterOptions_Base extends PathParserOptions { * } * ``` */ - scrollBehavior?: RouterScrollBehavior + /** * Custom implementation to parse a query. See its counterpart, * {@link EXPERIMENTAL_RouterOptions_Base.stringifyQuery}. @@ -145,26 +145,27 @@ export interface EXPERIMENTAL_RouterOptions_Base extends PathParserOptions { * }) * ``` */ - parseQuery?: typeof originalParseQuery + /** * Custom implementation to stringify a query object. Should not prepend a leading `?`. - * {@link EXPERIMENTAL_RouterOptions_Base.parseQuery | parseQuery} counterpart to handle query parsing. + * {@link parseQuery} counterpart to handle query parsing. */ stringifyQuery?: typeof originalStringifyQuery + /** * Default class applied to active {@link RouterLink}. If none is provided, * `router-link-active` will be applied. */ - linkActiveClass?: string + /** * Default class applied to exact active {@link RouterLink}. If none is provided, * `router-link-exact-active` will be applied. */ - linkExactActiveClass?: string + /** * Default class applied to non-active {@link RouterLink}. If none is provided, * `router-link-inactive` will be applied. @@ -191,7 +192,7 @@ export interface EXPERIMENTAL_RouterOptions } /** - * Router instance. + * Router base instance. * @experimental This version is not stable, it's meant to replace {@link Router} in the future. */ export interface EXPERIMENTAL_Router_Base { @@ -1161,7 +1162,6 @@ export function experimental_createRouter( } // Initialization and Errors - let readyHandlers = useCallbacks<_OnReadyCallback>() let errorListeners = useCallbacks<_ErrorListener>() let ready: boolean @@ -1206,9 +1206,9 @@ export function experimental_createRouter( * only be called once, otherwise does nothing. * @param err - optional error */ - function markAsReady(err: E): E - function markAsReady(): void - function markAsReady(err?: E): E | void { + function markAsReady(err: E): E + function markAsReady(): void + function markAsReady(err?: E): E | void { if (!ready) { // still not ready if an error happened ready = !err diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts index cabb296ef..54ea4cba1 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -325,8 +325,6 @@ export function createCompiledMatcher( // } parsedParams = { ...pathParams, ...queryParams, ...hashParams } - - if (parsedParams) break } catch (e) { // for debugging tests // console.log('❌ ERROR matching', e) diff --git a/packages/router/src/types/index.ts b/packages/router/src/types/index.ts index c06643956..b2f221d18 100644 --- a/packages/router/src/types/index.ts +++ b/packages/router/src/types/index.ts @@ -185,7 +185,7 @@ export type RouteComponent = Component | DefineComponent */ export type RawRouteComponent = RouteComponent | Lazy -// TODO: could this be moved to matcher? +// TODO: could this be moved to matcher? YES, it's on the way /** * Internal type for common properties among all kind of {@link RouteRecordRaw}. */ @@ -278,7 +278,9 @@ export interface RouteRecordSingleView extends _RouteRecordBase { } /** - * Route Record defining one single component with a nested view. + * Route Record defining one single component with a nested view. Differently + * from {@link RouteRecordSingleView}, this record has children and allows a + * `redirect` option. */ export interface RouteRecordSingleViewWithChildren extends _RouteRecordBase { /** From 4d2c23bc1075f800031eb6fb2e9ac6d1c43ca341 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 23 Dec 2024 15:28:35 +0100 Subject: [PATCH 25/37] refactor: simplify router resolve --- packages/router/src/experimental/router.ts | 309 ++++++++---------- .../src/new-route-resolver/matcher-pattern.ts | 15 +- .../matcher-resolve.spec.ts | 6 +- .../src/new-route-resolver/matcher.spec.ts | 57 +++- .../src/new-route-resolver/matcher.test-d.ts | 36 +- .../router/src/new-route-resolver/matcher.ts | 185 +++++++---- .../new-route-resolver/matchers/test-utils.ts | 6 +- packages/router/src/types/typeGuards.ts | 4 +- 8 files changed, 359 insertions(+), 259 deletions(-) diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index cc73bc9c3..3c79d2c69 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -9,11 +9,11 @@ import { import { nextTick, shallowReactive, + ShallowRef, shallowRef, unref, warn, type App, - type Ref, } from 'vue' import { RouterLink } from '../RouterLink' import { RouterView } from '../RouterView' @@ -23,10 +23,13 @@ import { type RouterHistory, } from '../history/common' import type { PathParserOptions } from '../matcher' -import type { RouteResolver } from '../new-route-resolver/matcher' +import type { + NEW_LocationResolved, + NEW_MatcherRecord, + NEW_MatcherRecordRaw, + NEW_RouterMatcher, +} from '../new-route-resolver/matcher' import { - LocationQuery, - normalizeQuery, parseQuery as originalParseQuery, stringifyQuery as originalStringifyQuery, } from '../query' @@ -48,6 +51,7 @@ import type { RouteLocationAsRelative, RouteLocationAsRelativeTyped, RouteLocationAsString, + RouteLocationGeneric, RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteLocationRaw, @@ -60,19 +64,17 @@ import { isRouteLocation, isRouteName, Lazy, - MatcherLocationRaw, RouteLocationOptions, - type RouteRecordRaw, + RouteMeta, } from '../types' import { useCallbacks } from '../utils/callbacks' import { isSameRouteLocation, parseURL, START_LOCATION_NORMALIZED, - stringifyURL, } from '../location' import { applyToParams, assign, isArray, isBrowser, noop } from '../utils' -import { decode, encodeHash, encodeParam } from '../encoding' +import { decode, encodeParam } from '../encoding' import { extractChangingRecords, extractComponentsGuards, @@ -177,18 +179,19 @@ export interface EXPERIMENTAL_RouterOptions_Base extends PathParserOptions { * Options to initialize an experimental {@link EXPERIMENTAL_Router} instance. * @experimental */ -export interface EXPERIMENTAL_RouterOptions - extends EXPERIMENTAL_RouterOptions_Base { +export interface EXPERIMENTAL_RouterOptions< + TMatcherRecord extends NEW_MatcherRecord +> extends EXPERIMENTAL_RouterOptions_Base { /** * Initial list of routes that should be added to the router. */ - routes?: Readonly + routes?: Readonly /** * Matcher to use to resolve routes. * @experimental */ - matcher: RouteResolver + matcher: NEW_RouterMatcher } /** @@ -199,7 +202,7 @@ export interface EXPERIMENTAL_Router_Base { /** * Current {@link RouteLocationNormalized} */ - readonly currentRoute: Ref + readonly currentRoute: ShallowRef /** * Allows turning off the listening of history events. This is a low level api for micro-frontend. @@ -207,7 +210,7 @@ export interface EXPERIMENTAL_Router_Base { listening: boolean /** - * Add a new {@link RouteRecordRaw | route record} as the child of an existing route. + * Add a new {@link EXPERIMENTAL_RouteRecordRaw | route record} as the child of an existing route. * * @param parentName - Parent Route Record where `route` should be appended at * @param route - Route Record to add @@ -215,10 +218,10 @@ export interface EXPERIMENTAL_Router_Base { addRoute( // NOTE: it could be `keyof RouteMap` but the point of dynamic routes is not knowing the routes at build parentName: NonNullable, - route: RouteRecordRaw + route: TRouteRecordRaw ): () => void /** - * Add a new {@link RouteRecordRaw | route record} to the router. + * Add a new {@link EXPERIMENTAL_RouteRecordRaw | route record} to the router. * * @param route - Route Record to add */ @@ -385,23 +388,45 @@ export interface EXPERIMENTAL_Router_Base { install(app: App): void } -export interface EXPERIMENTAL_Router - extends EXPERIMENTAL_Router_Base { +export interface EXPERIMENTAL_Router< + TRouteRecordRaw, // extends NEW_MatcherRecordRaw, + TRouteRecord extends NEW_MatcherRecord +> extends EXPERIMENTAL_Router_Base { /** * Original options object passed to create the Router */ - readonly options: EXPERIMENTAL_RouterOptions + readonly options: EXPERIMENTAL_RouterOptions +} + +export interface EXPERIMENTAL_RouteRecordRaw extends NEW_MatcherRecordRaw { + /** + * Arbitrary data attached to the record. + */ + meta?: RouteMeta +} + +// TODO: is it worth to have 2 types for the undefined values? +export interface EXPERIMENTAL_RouteRecordNormalized extends NEW_MatcherRecord { + meta: RouteMeta } -interface EXPERIMENTAL_RouteRecordRaw {} -interface EXPERIMENTAL_RouteRecord {} +function normalizeRouteRecord( + record: EXPERIMENTAL_RouteRecordRaw +): EXPERIMENTAL_RouteRecordNormalized { + // FIXME: implementation + return { + name: __DEV__ ? Symbol('anonymous route record') : Symbol(), + meta: {}, + ...record, + } +} export function experimental_createRouter( - options: EXPERIMENTAL_RouterOptions< - EXPERIMENTAL_RouteRecordRaw, - EXPERIMENTAL_RouteRecord - > -): EXPERIMENTAL_Router { + options: EXPERIMENTAL_RouterOptions +): EXPERIMENTAL_Router< + EXPERIMENTAL_RouteRecordRaw, + EXPERIMENTAL_RouteRecordNormalized +> { const { matcher, parseQuery = originalParseQuery, @@ -438,11 +463,14 @@ export function experimental_createRouter( applyToParams.bind(null, decode) function addRoute( - parentOrRoute: NonNullable | RouteRecordRaw, - route?: RouteRecordRaw + parentOrRoute: + | NonNullable + | EXPERIMENTAL_RouteRecordRaw, + route?: EXPERIMENTAL_RouteRecordRaw ) { let parent: Parameters<(typeof matcher)['addRoute']>[1] | undefined - let record: RouteRecordRaw + let rawRecord: EXPERIMENTAL_RouteRecordRaw + if (isRouteName(parentOrRoute)) { parent = matcher.getMatcher(parentOrRoute) if (__DEV__ && !parent) { @@ -453,12 +481,19 @@ export function experimental_createRouter( route ) } - record = route! + rawRecord = route! } else { - record = parentOrRoute + rawRecord = parentOrRoute } - return matcher.addRoute(record, parent) + const addedRecord = matcher.addRoute( + normalizeRouteRecord(rawRecord), + parent + ) + + return () => { + matcher.removeRoute(addedRecord) + } } function removeRoute(name: NonNullable) { @@ -471,7 +506,7 @@ export function experimental_createRouter( } function getRoutes() { - return matcher.getMatchers().map(routeMatcher => routeMatcher.record) + return matcher.getMatchers() } function hasRoute(name: NonNullable): boolean { @@ -485,139 +520,66 @@ export function experimental_createRouter( // const resolve: Router['resolve'] = (rawLocation: RouteLocationRaw, currentLocation) => { // const objectLocation = routerLocationAsObject(rawLocation) // we create a copy to modify it later - currentLocation = assign({}, currentLocation || currentRoute.value) - if (typeof rawLocation === 'string') { - const locationNormalized = parseURL( - parseQuery, - rawLocation, - currentLocation.path - ) - const matchedRoute = matcher.resolve( - { path: locationNormalized.path }, - currentLocation - ) + // TODO: in the experimental version, allow configuring this + currentLocation = + currentLocation && assign({}, currentLocation || currentRoute.value) + // currentLocation = assign({}, currentLocation || currentRoute.value) - const href = routerHistory.createHref(locationNormalized.fullPath) - if (__DEV__) { - if (href.startsWith('//')) - warn( - `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.` - ) - else if (!matchedRoute.matched.length) { - warn(`No match found for location with path "${rawLocation}"`) - } + if (__DEV__) { + if (!isRouteLocation(rawLocation)) { + warn( + `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`, + rawLocation + ) + return resolve({}) } - // locationNormalized is always a new object - return assign(locationNormalized, matchedRoute, { - params: decodeParams(matchedRoute.params), - hash: decode(locationNormalized.hash), - redirectedFrom: undefined, - href, - }) - } - - if (__DEV__ && !isRouteLocation(rawLocation)) { - warn( - `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`, - rawLocation - ) - return resolve({}) - } - - let matcherLocation: MatcherLocationRaw - - // path could be relative in object as well - if (rawLocation.path != null) { if ( - __DEV__ && - 'params' in rawLocation && - !('name' in rawLocation) && - // @ts-expect-error: the type is never - Object.keys(rawLocation.params).length + typeof rawLocation === 'object' && + rawLocation.hash?.startsWith('#') ) { warn( - `Path "${rawLocation.path}" was passed with params but they will be ignored. Use a named route alongside params instead.` + `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".` ) } - matcherLocation = assign({}, rawLocation, { - path: parseURL(parseQuery, rawLocation.path, currentLocation.path).path, - }) - } else { - // remove any nullish param - const targetParams = assign({}, rawLocation.params) - for (const key in targetParams) { - if (targetParams[key] == null) { - delete targetParams[key] - } - } - // pass encoded values to the matcher, so it can produce encoded path and fullPath - matcherLocation = assign({}, rawLocation, { - params: encodeParams(targetParams), - }) - // current location params are decoded, we need to encode them in case the - // matcher merges the params - currentLocation.params = encodeParams(currentLocation.params) } - const matchedRoute = matcher.resolve(matcherLocation, currentLocation) - const hash = rawLocation.hash || '' - - if (__DEV__ && hash && !hash.startsWith('#')) { - warn( - `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".` - ) - } - - // the matcher might have merged current location params, so - // we need to run the decoding again - matchedRoute.params = normalizeParams(decodeParams(matchedRoute.params)) - - const fullPath = stringifyURL( - stringifyQuery, - assign({}, rawLocation, { - hash: encodeHash(hash), - path: matchedRoute.path, - }) + // FIXME: is this achieved by matchers? + // remove any nullish param + // if ('params' in rawLocation) { + // const targetParams = assign({}, rawLocation.params) + // for (const key in targetParams) { + // if (targetParams[key] == null) { + // delete targetParams[key] + // } + // } + // rawLocation.params = targetParams + // } + + const matchedRoute = matcher.resolve( + rawLocation, + currentLocation satisfies NEW_LocationResolved ) + const href = routerHistory.createHref(matchedRoute.fullPath) - const href = routerHistory.createHref(fullPath) if (__DEV__) { if (href.startsWith('//')) { warn( `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.` ) - } else if (!matchedRoute.matched.length) { - warn( - `No match found for location with path "${ - rawLocation.path != null ? rawLocation.path : rawLocation - }"` - ) + } + if (!matchedRoute.matched.length) { + warn(`No match found for location with path "${rawLocation}"`) } } - return assign( - { - fullPath, - // keep the hash encoded so fullPath is effectively path + encodedQuery + - // hash - hash, - query: - // if the user is using a custom query lib like qs, we might have - // nested objects, so we keep the query as is, meaning it can contain - // numbers at `$route.query`, but at the point, the user will have to - // use their own type anyway. - // https://github.com/vuejs/router/issues/328#issuecomment-649481567 - stringifyQuery === originalStringifyQuery - ? normalizeQuery(rawLocation.query) - : ((rawLocation.query || {}) as LocationQuery), - }, - matchedRoute, - { - redirectedFrom: undefined, - href, - } - ) + // TODO: can this be refactored at the very end + // matchedRoute is always a new object + return assign(matchedRoute, { + redirectedFrom: undefined, + href, + meta: mergeMetaFields(matchedRoute.matched), + }) } function locationAsObject( @@ -648,7 +610,7 @@ export function experimental_createRouter( } function replace(to: RouteLocationRaw) { - return push(assign(locationAsObject(to), { replace: true })) + return pushWithRedirect(to, true) } function handleRedirectRecord(to: RouteLocation): RouteLocationRaw | void { @@ -700,14 +662,14 @@ export function experimental_createRouter( function pushWithRedirect( to: RouteLocationRaw | RouteLocation, + _replace?: boolean, redirectedFrom?: RouteLocation ): Promise { const targetLocation: RouteLocation = (pendingLocation = resolve(to)) const from = currentRoute.value const data: HistoryState | undefined = (to as RouteLocationOptions).state const force: boolean | undefined = (to as RouteLocationOptions).force - // to could be a string where `replace` is a function - const replace = (to as RouteLocationOptions).replace === true + const replace = (to as RouteLocationOptions).replace ?? _replace const shouldRedirect = handleRedirectRecord(targetLocation) @@ -719,8 +681,8 @@ export function experimental_createRouter( ? assign({}, data, shouldRedirect.state) : data, force, - replace, }), + replace, // keep original redirectedFrom if it exists redirectedFrom || targetLocation ) @@ -790,20 +752,15 @@ export function experimental_createRouter( return pushWithRedirect( // keep options - assign( - { - // preserve an existing replacement but allow the redirect to override it - replace, - }, - locationAsObject(failure.to), - { - state: - typeof failure.to === 'object' - ? assign({}, data, failure.to.state) - : data, - force, - } - ), + assign(locationAsObject(failure.to), { + state: + typeof failure.to === 'object' + ? assign({}, data, failure.to.state) + : data, + force, + }), + // preserve an existing replacement but allow the redirect to override it + replace, // preserve the original redirectedFrom if any redirectedFrom || toLocation ) @@ -842,6 +799,7 @@ export function experimental_createRouter( function runWithContext(fn: () => T): T { const app: App | undefined = installedApps.values().next().value + // TODO: remove safeguard and bump required minimum version of Vue // support Vue < 3.3 return app && typeof app.runWithContext === 'function' ? app.runWithContext(fn) @@ -1044,7 +1002,8 @@ export function experimental_createRouter( const shouldRedirect = handleRedirectRecord(toLocation) if (shouldRedirect) { pushWithRedirect( - assign(shouldRedirect, { replace: true, force: true }), + assign(shouldRedirect, { force: true }), + true, toLocation ).catch(noop) return @@ -1088,6 +1047,7 @@ export function experimental_createRouter( assign(locationAsObject((error as NavigationRedirectError).to), { force: true, }), + undefined, toLocation // avoid an uncaught rejection, let push call triggerError ) @@ -1250,7 +1210,10 @@ export function experimental_createRouter( let started: boolean | undefined const installedApps = new Set() - const router: Router = { + const router: EXPERIMENTAL_Router< + EXPERIMENTAL_RouteRecordRaw, + EXPERIMENTAL_RouteRecordNormalized + > = { currentRoute, listening: true, @@ -1280,6 +1243,7 @@ export function experimental_createRouter( app.component('RouterLink', RouterLink) app.component('RouterView', RouterView) + // @ts-expect-error: FIXME: refactor with new types once it's possible app.config.globalProperties.$router = router Object.defineProperty(app.config.globalProperties, '$route', { enumerable: true, @@ -1311,6 +1275,7 @@ export function experimental_createRouter( }) } + // @ts-expect-error: FIXME: refactor with new types once it's possible app.provide(routerKey, router) app.provide(routeLocationKey, shallowReactive(reactiveRoute)) app.provide(routerViewLocationKey, currentRoute) @@ -1334,6 +1299,7 @@ export function experimental_createRouter( // TODO: this probably needs to be updated so it can be used by vue-termui if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && isBrowser) { + // @ts-expect-error: FIXME: refactor with new types once it's possible addDevtools(app, router, matcher) } }, @@ -1349,3 +1315,14 @@ export function experimental_createRouter( return router } + +/** + * Merge meta fields of an array of records + * + * @param matched - array of matched records + */ +function mergeMetaFields( + matched: NEW_LocationResolved['matched'] +): RouteMeta { + return assign({} as RouteMeta, ...matched.map(r => r.meta)) +} diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts index ad582bb8d..c627c3bff 100644 --- a/packages/router/src/new-route-resolver/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -1,20 +1,7 @@ -import { decode, MatcherName, MatcherQueryParams } from './matcher' +import { decode, MatcherQueryParams } from './matcher' import { EmptyParams, MatcherParamsFormatted } from './matcher-location' import { miss } from './matchers/errors' -export interface MatcherPattern { - /** - * Name of the matcher. Unique across all matchers. - */ - name: MatcherName - - path: MatcherPatternPath - query?: MatcherPatternQuery - hash?: MatcherPatternHash - - parent?: MatcherPattern -} - export interface MatcherPatternParams_Base< TIn = string, TOut extends MatcherParamsFormatted = MatcherParamsFormatted diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts index b4799bbec..91fb8fb24 100644 --- a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -8,7 +8,7 @@ import { mockWarn } from '../../__tests__/vitest-mock-warn' import { createCompiledMatcher, MatcherLocationRaw, - MatcherRecordRaw, + NEW_MatcherRecordRaw, NEW_LocationResolved, } from './matcher' import { PathParams, tokensToParser } from '../matcher/pathParserRanker' @@ -24,7 +24,7 @@ const components = { default: component } function compileRouteRecord( record: RouteRecordRaw, parentRecord?: RouteRecordRaw -): MatcherRecordRaw { +): NEW_MatcherRecordRaw { // we adapt the path to ensure they are absolute // TODO: aliases? they could be handled directly in the path matcher const path = record.path.startsWith('/') @@ -100,7 +100,7 @@ describe('RouterMatcher.resolve', () => { | `/${string}` = START_LOCATION ) { const records = (Array.isArray(record) ? record : [record]).map( - (record): MatcherRecordRaw => compileRouteRecord(record) + (record): NEW_MatcherRecordRaw => compileRouteRecord(record) ) const matcher = createCompiledMatcher() for (const record of records) { diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index 22fb3e511..07695b598 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -6,12 +6,12 @@ import { } from './matcher' import { MatcherPatternParams_Base, - MatcherPattern, MatcherPatternPath, MatcherPatternQuery, MatcherPatternPathStatic, MatcherPatternPathDynamic, } from './matcher-pattern' +import { NEW_MatcherRecord } from './matcher' import { miss } from './matchers/errors' import { EmptyParams } from './matcher-location' @@ -72,12 +72,17 @@ const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base< const EMPTY_PATH_ROUTE = { name: 'no params', path: EMPTY_PATH_PATTERN_MATCHER, -} satisfies MatcherPattern +} satisfies NEW_MatcherRecord + +const ANY_PATH_ROUTE = { + name: 'any path', + path: ANY_PATH_PATTERN_MATCHER, +} satisfies NEW_MatcherRecord const USER_ID_ROUTE = { name: 'user-id', path: USER_ID_PATH_PATTERN_MATCHER, -} satisfies MatcherPattern +} satisfies NEW_MatcherRecord describe('RouterMatcher', () => { describe('new matchers', () => { @@ -135,6 +140,20 @@ describe('RouterMatcher', () => { const matcher = createCompiledMatcher() matcher.addRoute(USER_ID_ROUTE) }) + + it('removes static path', () => { + const matcher = createCompiledMatcher() + matcher.addRoute(EMPTY_PATH_ROUTE) + matcher.removeRoute(EMPTY_PATH_ROUTE) + // Add assertions to verify the route was removed + }) + + it('removes dynamic path', () => { + const matcher = createCompiledMatcher() + matcher.addRoute(USER_ID_ROUTE) + matcher.removeRoute(USER_ID_ROUTE) + // Add assertions to verify the route was removed + }) }) describe('resolve()', () => { @@ -293,5 +312,37 @@ describe('RouterMatcher', () => { }) }) }) + + describe('encoding', () => { + it('handles encoded string path', () => { + const matcher = createCompiledMatcher([ANY_PATH_ROUTE]) + console.log(matcher.resolve('/%23%2F%3F')) + expect(matcher.resolve('/%23%2F%3F')).toMatchObject({ + fullPath: '/%23%2F%3F', + path: '/%23%2F%3F', + query: {}, + params: {}, + hash: '', + }) + }) + + it('decodes query from a string', () => { + const matcher = createCompiledMatcher([ANY_PATH_ROUTE]) + expect(matcher.resolve('/foo?foo=%23%2F%3F')).toMatchObject({ + path: '/foo', + fullPath: '/foo?foo=%23%2F%3F', + query: { foo: '#/?' }, + }) + }) + + it('decodes hash from a string', () => { + const matcher = createCompiledMatcher([ANY_PATH_ROUTE]) + expect(matcher.resolve('/foo#h-%23%2F%3F')).toMatchObject({ + path: '/foo', + fullPath: '/foo#h-%23%2F%3F', + hash: '#h-#/?', + }) + }) + }) }) }) diff --git a/packages/router/src/new-route-resolver/matcher.test-d.ts b/packages/router/src/new-route-resolver/matcher.test-d.ts index a60874518..8ea5b771d 100644 --- a/packages/router/src/new-route-resolver/matcher.test-d.ts +++ b/packages/router/src/new-route-resolver/matcher.test-d.ts @@ -1,14 +1,23 @@ import { describe, expectTypeOf, it } from 'vitest' -import { NEW_LocationResolved, RouteResolver } from './matcher' +import { + NEW_LocationResolved, + NEW_MatcherRecordRaw, + NEW_RouterMatcher, +} from './matcher' +import { EXPERIMENTAL_RouteRecordNormalized } from '../experimental/router' describe('Matcher', () => { - const matcher: RouteResolver = {} as any + type TMatcherRecordRaw = NEW_MatcherRecordRaw + type TMatcherRecord = EXPERIMENTAL_RouteRecordNormalized + + const matcher: NEW_RouterMatcher = + {} as any describe('matcher.resolve()', () => { it('resolves absolute string locations', () => { - expectTypeOf( - matcher.resolve('/foo') - ).toEqualTypeOf() + expectTypeOf(matcher.resolve('/foo')).toEqualTypeOf< + NEW_LocationResolved + >() }) it('fails on non absolute location without a currentLocation', () => { @@ -18,14 +27,14 @@ describe('Matcher', () => { it('resolves relative locations', () => { expectTypeOf( - matcher.resolve('foo', {} as NEW_LocationResolved) - ).toEqualTypeOf() + matcher.resolve('foo', {} as NEW_LocationResolved) + ).toEqualTypeOf>() }) it('resolved named locations', () => { - expectTypeOf( - matcher.resolve({ name: 'foo', params: {} }) - ).toEqualTypeOf() + expectTypeOf(matcher.resolve({ name: 'foo', params: {} })).toEqualTypeOf< + NEW_LocationResolved + >() }) it('fails on object relative location without a currentLocation', () => { @@ -35,8 +44,11 @@ describe('Matcher', () => { it('resolves object relative locations with a currentLocation', () => { expectTypeOf( - matcher.resolve({ params: { id: 1 } }, {} as NEW_LocationResolved) - ).toEqualTypeOf() + matcher.resolve( + { params: { id: 1 } }, + {} as NEW_LocationResolved + ) + ).toEqualTypeOf>() }) }) diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts index 54ea4cba1..69ddc5540 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -5,7 +5,6 @@ import { stringifyQuery, } from '../query' import type { - MatcherPattern, MatcherPatternHash, MatcherPatternPath, MatcherPatternQuery, @@ -20,6 +19,7 @@ import type { MatcherLocationAsRelative, MatcherParamsFormatted, } from './matcher-location' +import { _RouteRecordProps } from '../typed-routes' /** * Allowed types for a matcher name. @@ -28,12 +28,17 @@ export type MatcherName = string | symbol /** * Manage and resolve routes. Also handles the encoding, decoding, parsing and serialization of params, query, and hash. + * `TMatcherRecordRaw` represents the raw record type passed to {@link addRoute}. + * `TMatcherRecord` represents the normalized record type. */ -export interface RouteResolver { +export interface NEW_RouterMatcher { /** * Resolves an absolute location (like `/path/to/somewhere`). */ - resolve(absoluteLocation: `/${string}`): NEW_LocationResolved + resolve( + absoluteLocation: `/${string}`, + currentLocation?: undefined | NEW_LocationResolved + ): NEW_LocationResolved /** * Resolves a string location relative to another location. A relative location can be `./same-folder`, @@ -41,24 +46,28 @@ export interface RouteResolver { */ resolve( relativeLocation: string, - currentLocation: NEW_LocationResolved - ): NEW_LocationResolved + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved /** * Resolves a location by its name. Any required params or query must be passed in the `options` argument. */ - resolve(location: MatcherLocationAsNamed): NEW_LocationResolved + resolve( + location: MatcherLocationAsNamed + ): NEW_LocationResolved /** * Resolves a location by its absolute path (starts with `/`). Any required query must be passed. * @param location - The location to resolve. */ - resolve(location: MatcherLocationAsPathAbsolute): NEW_LocationResolved + resolve( + location: MatcherLocationAsPathAbsolute + ): NEW_LocationResolved resolve( location: MatcherLocationAsPathRelative, - currentLocation: NEW_LocationResolved - ): NEW_LocationResolved + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved // NOTE: in practice, this overload can cause bugs. It's better to use named locations @@ -68,42 +77,28 @@ export interface RouteResolver { */ resolve( relativeLocation: MatcherLocationAsRelative, - currentLocation: NEW_LocationResolved - ): NEW_LocationResolved + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved - addRoute(matcher: Matcher, parent?: MatcherNormalized): MatcherNormalized - removeRoute(matcher: MatcherNormalized): void + addRoute(matcher: TMatcherRecordRaw, parent?: TMatcherRecord): TMatcherRecord + removeRoute(matcher: TMatcherRecord): void clearRoutes(): void /** * Get a list of all matchers. * Previously named `getRoutes()` */ - getMatchers(): MatcherNormalized[] + getMatchers(): TMatcherRecord[] /** * Get a matcher by its name. * Previously named `getRecordMatcher()` */ - getMatcher(name: MatcherName): MatcherNormalized | undefined + getMatcher(name: MatcherName): TMatcherRecord | undefined } -type MatcherResolveArgs = - | [absoluteLocation: `/${string}`] - | [relativeLocation: string, currentLocation: NEW_LocationResolved] - | [absoluteLocation: MatcherLocationAsPathAbsolute] - | [ - relativeLocation: MatcherLocationAsPathRelative, - currentLocation: NEW_LocationResolved - ] - | [location: MatcherLocationAsNamed] - | [ - relativeLocation: MatcherLocationAsRelative, - currentLocation: NEW_LocationResolved - ] - /** - * Allowed location objects to be passed to {@link RouteResolver['resolve']} + * Allowed location objects to be passed to {@link NEW_RouterMatcher['resolve']} */ export type MatcherLocationRaw = | `/${string}` @@ -127,16 +122,18 @@ export interface NEW_Matcher_Dynamic { type TODO = any -export interface NEW_LocationResolved { - name: MatcherName - fullPath: string - path: string +export interface NEW_LocationResolved { + // FIXME: remove `undefined` + name: MatcherName | undefined // TODO: generics? params: MatcherParamsFormatted + + fullPath: string + path: string query: LocationQuery hash: string - matched: TODO[] + matched: TMatched[] } export type MatcherPathParamsValue = string | null | string[] @@ -221,24 +218,69 @@ const encodeQueryValue: FnStableNull = // // for ts // value => (value == null ? null : _encodeQueryKey(value)) +/** + * Common properties for a location that couldn't be matched. This ensures + * having the same name while having a `path`, `query` and `hash` that change. + */ export const NO_MATCH_LOCATION = { name: __DEV__ ? Symbol('no-match') : Symbol(), params: {}, matched: [], -} satisfies Omit +} satisfies Omit< + NEW_LocationResolved, + 'path' | 'hash' | 'query' | 'fullPath' +> // FIXME: later on, the MatcherRecord should be compatible with RouteRecordRaw (which can miss a path, have children, etc) -export interface MatcherRecordRaw { +/** + * Experiment new matcher record base type. + * + * @experimental + */ +export interface NEW_MatcherRecordRaw { + path: MatcherPatternPath + query?: MatcherPatternQuery + hash?: MatcherPatternHash + + // NOTE: matchers do not handle `redirect` the redirect option, the router + // does. They can still match the correct record but they will let the router + // retrigger a whole navigation to the new location. + + // TODO: probably as `aliasOf`. Maybe a different format with the path, query and has matchers? + /** + * Aliases for the record. Allows defining extra paths that will behave like a + * copy of the record. Allows having paths shorthands like `/users/:id` and + * `/u/:id`. All `alias` and `path` values must share the same params. + */ + // alias?: string | string[] + + /** + * Name for the route record. Must be unique. Will be set to `Symbol()` if + * not set. + */ name?: MatcherName - path: MatcherPatternPath + /** + * Array of nested routes. + */ + children?: NEW_MatcherRecordRaw[] +} - query?: MatcherPatternQuery +/** + * Normalized version of a {@link NEW_MatcherRecordRaw} record. + */ +export interface NEW_MatcherRecord { + /** + * Name of the matcher. Unique across all matchers. + */ + name: MatcherName + path: MatcherPatternPath + query?: MatcherPatternQuery hash?: MatcherPatternHash - children?: MatcherRecordRaw[] + parent?: NEW_MatcherRecord } /** @@ -268,9 +310,9 @@ export function pathEncoded( /** * Build the `matched` array of a record that includes all parent records from the root to the current one. */ -function buildMatched(record: MatcherPattern): MatcherPattern[] { - const matched: MatcherPattern[] = [] - let node: MatcherPattern | undefined = record +function buildMatched(record: NEW_MatcherRecord): NEW_MatcherRecord[] { + const matched: NEW_MatcherRecord[] = [] + let node: NEW_MatcherRecord | undefined = record while (node) { matched.unshift(node) node = node.parent @@ -279,10 +321,10 @@ function buildMatched(record: MatcherPattern): MatcherPattern[] { } export function createCompiledMatcher( - records: MatcherRecordRaw[] = [] -): RouteResolver { + records: NEW_MatcherRecordRaw[] = [] +): NEW_RouterMatcher { // TODO: we also need an array that has the correct order - const matchers = new Map() + const matchers = new Map() // TODO: allow custom encode/decode functions // const encodeParams = applyToParams.bind(null, encodeParam) @@ -294,7 +336,30 @@ export function createCompiledMatcher( // ) // const decodeQuery = transformObject.bind(null, decode, decode) - function resolve(...args: MatcherResolveArgs): NEW_LocationResolved { + // NOTE: because of the overloads, we need to manually type the arguments + type MatcherResolveArgs = + | [ + absoluteLocation: `/${string}`, + currentLocation?: undefined | NEW_LocationResolved + ] + | [ + relativeLocation: string, + currentLocation: NEW_LocationResolved + ] + | [absoluteLocation: MatcherLocationAsPathAbsolute] + | [ + relativeLocation: MatcherLocationAsPathRelative, + currentLocation: NEW_LocationResolved + ] + | [location: MatcherLocationAsNamed] + | [ + relativeLocation: MatcherLocationAsRelative, + currentLocation: NEW_LocationResolved + ] + + function resolve( + ...args: MatcherResolveArgs + ): NEW_LocationResolved { const [location, currentLocation] = args // string location, e.g. '/foo', '../bar', 'baz', '?page=1' @@ -302,8 +367,10 @@ export function createCompiledMatcher( // parseURL handles relative paths const url = parseURL(parseQuery, location, currentLocation?.path) - let matcher: MatcherPattern | undefined - let matched: NEW_LocationResolved['matched'] | undefined + let matcher: NEW_MatcherRecord | undefined + let matched: + | NEW_LocationResolved['matched'] + | undefined let parsedParams: MatcherParamsFormatted | null | undefined for (matcher of matchers.values()) { @@ -360,18 +427,22 @@ export function createCompiledMatcher( `Cannot resolve an unnamed relative location without a current location. This will throw in production.`, location ) + const query = normalizeQuery(location.query) + const hash = location.hash ?? '' + const path = location.path ?? '/' return { ...NO_MATCH_LOCATION, - fullPath: '/', - path: '/', - query: {}, - hash: '', + fullPath: stringifyURL(stringifyQuery, { path, query, hash }), + path, + query, + hash, } } // either one of them must be defined and is catched by the dev only warn above const name = location.name ?? currentLocation!.name - const matcher = matchers.get(name) + // FIXME: remove once name cannot be null + const matcher = name != null && matchers.get(name) if (!matcher) { throw new Error(`Matcher "${String(location.name)}" not found`) } @@ -404,10 +475,10 @@ export function createCompiledMatcher( } } - function addRoute(record: MatcherRecordRaw, parent?: MatcherPattern) { + function addRoute(record: NEW_MatcherRecordRaw, parent?: NEW_MatcherRecord) { const name = record.name ?? (__DEV__ ? Symbol('unnamed-route') : Symbol()) // FIXME: proper normalization of the record - const normalizedRecord: MatcherPattern = { + const normalizedRecord: NEW_MatcherRecord = { ...record, name, parent, @@ -420,7 +491,7 @@ export function createCompiledMatcher( addRoute(record) } - function removeRoute(matcher: MatcherPattern) { + function removeRoute(matcher: NEW_MatcherRecord) { matchers.delete(matcher.name) // TODO: delete children and aliases } diff --git a/packages/router/src/new-route-resolver/matchers/test-utils.ts b/packages/router/src/new-route-resolver/matchers/test-utils.ts index f40ce00a5..e922e7217 100644 --- a/packages/router/src/new-route-resolver/matchers/test-utils.ts +++ b/packages/router/src/new-route-resolver/matchers/test-utils.ts @@ -3,8 +3,8 @@ import { MatcherPatternPath, MatcherPatternQuery, MatcherPatternParams_Base, - MatcherPattern, } from '../matcher-pattern' +import { NEW_MatcherRecord } from '../matcher' import { miss } from './errors' export const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{ @@ -68,9 +68,9 @@ export const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base< export const EMPTY_PATH_ROUTE = { name: 'no params', path: EMPTY_PATH_PATTERN_MATCHER, -} satisfies MatcherPattern +} satisfies NEW_MatcherRecord export const USER_ID_ROUTE = { name: 'user-id', path: USER_ID_PATH_PATTERN_MATCHER, -} satisfies MatcherPattern +} satisfies NEW_MatcherRecord diff --git a/packages/router/src/types/typeGuards.ts b/packages/router/src/types/typeGuards.ts index ba30bd9b6..9ecbf3a3c 100644 --- a/packages/router/src/types/typeGuards.ts +++ b/packages/router/src/types/typeGuards.ts @@ -4,6 +4,8 @@ export function isRouteLocation(route: any): route is RouteLocationRaw { return typeof route === 'string' || (route && typeof route === 'object') } -export function isRouteName(name: any): name is RouteRecordNameGeneric { +export function isRouteName( + name: unknown +): name is NonNullable { return typeof name === 'string' || typeof name === 'symbol' } From 2d138b9254eb9f12be66afd30dc9fb71f57cfda2 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 24 Dec 2024 10:45:50 +0100 Subject: [PATCH 26/37] chore: wip encoding --- packages/router/src/location.ts | 21 ++++++- .../src/new-route-resolver/matcher.spec.ts | 61 ++++++++++++------- .../router/src/new-route-resolver/matcher.ts | 6 +- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/packages/router/src/location.ts b/packages/router/src/location.ts index 0ca40799c..163bf6f8d 100644 --- a/packages/router/src/location.ts +++ b/packages/router/src/location.ts @@ -3,7 +3,7 @@ import { RouteParamValue, RouteParamsGeneric } from './types' import { RouteRecord } from './matcher/types' import { warn } from './warning' import { isArray } from './utils' -import { decode } from './encoding' +import { decode, encodeHash } from './encoding' import { RouteLocation, RouteLocationNormalizedLoaded } from './typed-routes' /** @@ -94,6 +94,25 @@ export function parseURL( } } +/** + * Creates a `fullPath` property from the `path`, `query` and `hash` properties + * + * @param stringifyQuery - custom function to stringify the query object. It should handle encoding values + * @param path - An encdoded path + * @param query - A decoded query object + * @param hash - A decoded hash + * @returns a valid `fullPath` + */ +export function NEW_stringifyURL( + stringifyQuery: (query?: LocationQueryRaw) => string, + path: LocationPartial['path'], + query?: LocationPartial['query'], + hash: LocationPartial['hash'] = '' +): string { + const searchText = stringifyQuery(query) + return path + (searchText && '?') + searchText + encodeHash(hash) +} + /** * Stringifies a URL object * diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index 07695b598..a0c59e6d9 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -314,33 +314,50 @@ describe('RouterMatcher', () => { }) describe('encoding', () => { - it('handles encoded string path', () => { - const matcher = createCompiledMatcher([ANY_PATH_ROUTE]) - console.log(matcher.resolve('/%23%2F%3F')) - expect(matcher.resolve('/%23%2F%3F')).toMatchObject({ - fullPath: '/%23%2F%3F', - path: '/%23%2F%3F', - query: {}, - params: {}, - hash: '', + const matcher = createCompiledMatcher([ANY_PATH_ROUTE]) + describe('decodes', () => { + it('handles encoded string path', () => { + expect(matcher.resolve('/%23%2F%3F')).toMatchObject({ + fullPath: '/%23%2F%3F', + path: '/%23%2F%3F', + query: {}, + params: {}, + hash: '', + }) }) - }) - it('decodes query from a string', () => { - const matcher = createCompiledMatcher([ANY_PATH_ROUTE]) - expect(matcher.resolve('/foo?foo=%23%2F%3F')).toMatchObject({ - path: '/foo', - fullPath: '/foo?foo=%23%2F%3F', - query: { foo: '#/?' }, + it('decodes query from a string', () => { + expect(matcher.resolve('/foo?foo=%23%2F%3F')).toMatchObject({ + path: '/foo', + fullPath: '/foo?foo=%23%2F%3F', + query: { foo: '#/?' }, + }) + }) + + it('decodes hash from a string', () => { + expect(matcher.resolve('/foo#%22')).toMatchObject({ + path: '/foo', + fullPath: '/foo#%22', + hash: '#"', + }) }) }) - it('decodes hash from a string', () => { - const matcher = createCompiledMatcher([ANY_PATH_ROUTE]) - expect(matcher.resolve('/foo#h-%23%2F%3F')).toMatchObject({ - path: '/foo', - fullPath: '/foo#h-%23%2F%3F', - hash: '#h-#/?', + describe('encodes', () => { + it('encodes the query', () => { + expect( + matcher.resolve({ path: '/foo', query: { foo: '"' } }) + ).toMatchObject({ + fullPath: '/foo?foo=%22', + query: { foo: '"' }, + }) + }) + + it('encodes the hash', () => { + expect(matcher.resolve({ path: '/foo', hash: '#"' })).toMatchObject({ + fullPath: '/foo#%22', + hash: '#"', + }) }) }) }) diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts index 69ddc5540..c0ba504ce 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -11,7 +11,7 @@ import type { } from './matcher-pattern' import { warn } from '../warning' import { encodeQueryValue as _encodeQueryValue, encodeParam } from '../encoding' -import { parseURL, stringifyURL } from '../location' +import { parseURL, NEW_stringifyURL } from '../location' import type { MatcherLocationAsNamed, MatcherLocationAsPathAbsolute, @@ -432,7 +432,7 @@ export function createCompiledMatcher( const path = location.path ?? '/' return { ...NO_MATCH_LOCATION, - fullPath: stringifyURL(stringifyQuery, { path, query, hash }), + fullPath: NEW_stringifyURL(stringifyQuery, path, query, hash), path, query, hash, @@ -465,7 +465,7 @@ export function createCompiledMatcher( return { name, - fullPath: stringifyURL(stringifyQuery, { path, query, hash }), + fullPath: NEW_stringifyURL(stringifyQuery, path, query, hash), path, query, hash, From 94a7e253ae592d1204d5beb3052c08b2401511e7 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Tue, 7 Jan 2025 14:22:51 +0100 Subject: [PATCH 27/37] chore: small fix --- packages/router/src/experimental/router.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index 3c79d2c69..95762e899 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -536,10 +536,10 @@ export function experimental_createRouter( if ( typeof rawLocation === 'object' && - rawLocation.hash?.startsWith('#') + !rawLocation.hash?.startsWith('#') ) { warn( - `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".` + `A \`hash\` should always start with the character "#". Replace "${rawLocation.hash}" with "#${rawLocation.hash}".` ) } } From 794fdd57fba7d3f0458e519d417d3376a95c16db Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 8 Jan 2025 10:48:35 +0100 Subject: [PATCH 28/37] refactor: rename matcher to resolver --- packages/router/src/experimental/router.ts | 20 ++-- packages/router/src/matcher/index.ts | 27 ++--- .../router/src/matcher/pathParserRanker.ts | 5 + .../router/src/new-route-resolver/index.ts | 2 +- .../new-route-resolver/matcher-location.ts | 2 +- .../src/new-route-resolver/matcher-pattern.ts | 2 +- .../matcher-resolve.spec.ts | 75 ++---------- .../src/new-route-resolver/matcher.spec.ts | 16 +-- .../src/new-route-resolver/matcher.test-d.ts | 6 +- .../new-route-resolver/matchers/test-utils.ts | 2 +- .../{matcher.ts => resolver.ts} | 107 ++++++++++-------- packages/router/src/utils/index.ts | 12 ++ 12 files changed, 121 insertions(+), 155 deletions(-) rename packages/router/src/new-route-resolver/{matcher.ts => resolver.ts} (85%) diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index 95762e899..9cf885cc1 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -27,8 +27,8 @@ import type { NEW_LocationResolved, NEW_MatcherRecord, NEW_MatcherRecordRaw, - NEW_RouterMatcher, -} from '../new-route-resolver/matcher' + NEW_RouterResolver, +} from '../new-route-resolver/resolver' import { parseQuery as originalParseQuery, stringifyQuery as originalStringifyQuery, @@ -51,7 +51,6 @@ import type { RouteLocationAsRelative, RouteLocationAsRelativeTyped, RouteLocationAsString, - RouteLocationGeneric, RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteLocationRaw, @@ -191,7 +190,7 @@ export interface EXPERIMENTAL_RouterOptions< * Matcher to use to resolve routes. * @experimental */ - matcher: NEW_RouterMatcher + matcher: NEW_RouterResolver } /** @@ -407,6 +406,9 @@ export interface EXPERIMENTAL_RouteRecordRaw extends NEW_MatcherRecordRaw { // TODO: is it worth to have 2 types for the undefined values? export interface EXPERIMENTAL_RouteRecordNormalized extends NEW_MatcherRecord { + /** + * Arbitrary data attached to the record. + */ meta: RouteMeta } @@ -468,7 +470,7 @@ export function experimental_createRouter( | EXPERIMENTAL_RouteRecordRaw, route?: EXPERIMENTAL_RouteRecordRaw ) { - let parent: Parameters<(typeof matcher)['addRoute']>[1] | undefined + let parent: Parameters<(typeof matcher)['addMatcher']>[1] | undefined let rawRecord: EXPERIMENTAL_RouteRecordRaw if (isRouteName(parentOrRoute)) { @@ -486,20 +488,20 @@ export function experimental_createRouter( rawRecord = parentOrRoute } - const addedRecord = matcher.addRoute( + const addedRecord = matcher.addMatcher( normalizeRouteRecord(rawRecord), parent ) return () => { - matcher.removeRoute(addedRecord) + matcher.removeMatcher(addedRecord) } } function removeRoute(name: NonNullable) { const recordMatcher = matcher.getMatcher(name) if (recordMatcher) { - matcher.removeRoute(recordMatcher) + matcher.removeMatcher(recordMatcher) } else if (__DEV__) { warn(`Cannot remove non-existent route "${String(name)}"`) } @@ -1219,7 +1221,7 @@ export function experimental_createRouter( addRoute, removeRoute, - clearRoutes: matcher.clearRoutes, + clearRoutes: matcher.clearMatchers, hasRoute, getRoutes, resolve, diff --git a/packages/router/src/matcher/index.ts b/packages/router/src/matcher/index.ts index fe951f7ad..1a2541d72 100644 --- a/packages/router/src/matcher/index.ts +++ b/packages/router/src/matcher/index.ts @@ -14,10 +14,13 @@ import type { _PathParserOptions, } from './pathParserRanker' -import { comparePathParserScore } from './pathParserRanker' +import { + comparePathParserScore, + PATH_PARSER_OPTIONS_DEFAULTS, +} from './pathParserRanker' import { warn } from '../warning' -import { assign, noop } from '../utils' +import { assign, mergeOptions, noop } from '../utils' import type { RouteRecordNameGeneric, _RouteRecordProps } from '../typed-routes' /** @@ -64,8 +67,8 @@ export function createRouterMatcher( NonNullable, RouteRecordMatcher >() - globalOptions = mergeOptions( - { strict: false, end: true, sensitive: false } as PathParserOptions, + globalOptions = mergeOptions( + PATH_PARSER_OPTIONS_DEFAULTS, globalOptions ) @@ -429,7 +432,7 @@ export function normalizeRouteRecord( * components. Also accept a boolean for components. * @param record */ -function normalizeRecordProps( +export function normalizeRecordProps( record: RouteRecordRaw ): Record { const propsObject = {} as Record @@ -472,18 +475,6 @@ function mergeMetaFields(matched: MatcherLocation['matched']) { ) } -function mergeOptions( - defaults: T, - partialOptions: Partial -): T { - const options = {} as T - for (const key in defaults) { - options[key] = key in partialOptions ? partialOptions[key]! : defaults[key] - } - - return options -} - type ParamKey = RouteRecordMatcher['keys'][number] function isSameParam(a: ParamKey, b: ParamKey): boolean { @@ -521,7 +512,7 @@ function checkSameParams(a: RouteRecordMatcher, b: RouteRecordMatcher) { * @param mainNormalizedRecord - RouteRecordNormalized * @param parent - RouteRecordMatcher */ -function checkChildMissingNameWithEmptyPath( +export function checkChildMissingNameWithEmptyPath( mainNormalizedRecord: RouteRecordNormalized, parent?: RouteRecordMatcher ) { diff --git a/packages/router/src/matcher/pathParserRanker.ts b/packages/router/src/matcher/pathParserRanker.ts index 81b077642..b2c0b40a0 100644 --- a/packages/router/src/matcher/pathParserRanker.ts +++ b/packages/router/src/matcher/pathParserRanker.ts @@ -367,3 +367,8 @@ function isLastScoreNegative(score: PathParser['score']): boolean { const last = score[score.length - 1] return score.length > 0 && last[last.length - 1] < 0 } +export const PATH_PARSER_OPTIONS_DEFAULTS: PathParserOptions = { + strict: false, + end: true, + sensitive: false, +} diff --git a/packages/router/src/new-route-resolver/index.ts b/packages/router/src/new-route-resolver/index.ts index 17910f62f..4c07b32cc 100644 --- a/packages/router/src/new-route-resolver/index.ts +++ b/packages/router/src/new-route-resolver/index.ts @@ -1 +1 @@ -export { createCompiledMatcher } from './matcher' +export { createCompiledMatcher } from './resolver' diff --git a/packages/router/src/new-route-resolver/matcher-location.ts b/packages/router/src/new-route-resolver/matcher-location.ts index b9ca1ab0c..f597df07f 100644 --- a/packages/router/src/new-route-resolver/matcher-location.ts +++ b/packages/router/src/new-route-resolver/matcher-location.ts @@ -1,5 +1,5 @@ import type { LocationQueryRaw } from '../query' -import type { MatcherName } from './matcher' +import type { MatcherName } from './resolver' /** * Generic object of params that can be passed to a matcher. diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts index c627c3bff..0f7d8c192 100644 --- a/packages/router/src/new-route-resolver/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -1,4 +1,4 @@ -import { decode, MatcherQueryParams } from './matcher' +import { decode, MatcherQueryParams } from './resolver' import { EmptyParams, MatcherParamsFormatted } from './matcher-location' import { miss } from './matchers/errors' diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts index 91fb8fb24..6f9914af2 100644 --- a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -1,6 +1,5 @@ import { createRouterMatcher, normalizeRouteRecord } from '../matcher' import { RouteComponent, RouteRecordRaw, MatcherLocation } from '../types' -import { MatcherLocationNormalizedLoose } from '../../__tests__/utils' import { defineComponent } from 'vue' import { START_LOCATION_NORMALIZED } from '../location' import { describe, expect, it } from 'vitest' @@ -10,7 +9,8 @@ import { MatcherLocationRaw, NEW_MatcherRecordRaw, NEW_LocationResolved, -} from './matcher' + NEW_MatcherRecord, +} from './resolver' import { PathParams, tokensToParser } from '../matcher/pathParserRanker' import { tokenizePath } from '../matcher/pathTokenizer' import { miss } from './matchers/errors' @@ -63,22 +63,23 @@ function compileRouteRecord( describe('RouterMatcher.resolve', () => { mockWarn() - type Matcher = ReturnType + type Matcher = ReturnType type MatcherResolvedLocation = ReturnType - const START_LOCATION: NEW_LocationResolved = { + const START_LOCATION: MatcherResolvedLocation = { name: Symbol('START'), - fullPath: '/', - path: '/', params: {}, + path: '/', + fullPath: '/', query: {}, hash: '', matched: [], + // meta: {}, } function isMatcherLocationResolved( location: unknown - ): location is NEW_LocationResolved { + ): location is NEW_LocationResolved { return !!( location && typeof location === 'object' && @@ -95,16 +96,16 @@ describe('RouterMatcher.resolve', () => { toLocation: MatcherLocationRaw, expectedLocation: Partial, fromLocation: - | NEW_LocationResolved + | NEW_LocationResolved | Exclude | `/${string}` = START_LOCATION ) { const records = (Array.isArray(record) ? record : [record]).map( (record): NEW_MatcherRecordRaw => compileRouteRecord(record) ) - const matcher = createCompiledMatcher() + const matcher = createCompiledMatcher() for (const record of records) { - matcher.addRoute(record) + matcher.addMatcher(record) } const resolved: MatcherResolvedLocation = { @@ -137,60 +138,6 @@ describe('RouterMatcher.resolve', () => { }) } - function _assertRecordMatch( - record: RouteRecordRaw | RouteRecordRaw[], - location: MatcherLocationRaw, - resolved: Partial, - start: MatcherLocation = START_LOCATION_NORMALIZED - ) { - record = Array.isArray(record) ? record : [record] - const matcher = createRouterMatcher(record, {}) - - if (!('meta' in resolved)) { - resolved.meta = record[0].meta || {} - } - - if (!('name' in resolved)) { - resolved.name = undefined - } - - // add location if provided as it should be the same value - if ('path' in location && !('path' in resolved)) { - resolved.path = location.path - } - - if ('redirect' in record) { - throw new Error('not handled') - } else { - // use one single record - if (!resolved.matched) resolved.matched = record.map(normalizeRouteRecord) - // allow passing an expect.any(Array) - else if (Array.isArray(resolved.matched)) - resolved.matched = resolved.matched.map(m => ({ - ...normalizeRouteRecord(m as any), - aliasOf: m.aliasOf, - })) - } - - // allows not passing params - resolved.params = - resolved.params || ('params' in location ? location.params : {}) - - const startCopy: MatcherLocation = { - ...start, - matched: start.matched.map(m => ({ - ...normalizeRouteRecord(m), - aliasOf: m.aliasOf, - })) as MatcherLocation['matched'], - } - - // make matched non enumerable - Object.defineProperty(startCopy, 'matched', { enumerable: false }) - - const result = matcher.resolve(location, startCopy) - expect(result).toEqual(resolved) - } - /** * * @param record - Record or records we are testing the matcher against diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index a0c59e6d9..335ddb83d 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -3,7 +3,7 @@ import { createCompiledMatcher, NO_MATCH_LOCATION, pathEncoded, -} from './matcher' +} from './resolver' import { MatcherPatternParams_Base, MatcherPatternPath, @@ -11,7 +11,7 @@ import { MatcherPatternPathStatic, MatcherPatternPathDynamic, } from './matcher-pattern' -import { NEW_MatcherRecord } from './matcher' +import { NEW_MatcherRecord } from './resolver' import { miss } from './matchers/errors' import { EmptyParams } from './matcher-location' @@ -133,25 +133,25 @@ describe('RouterMatcher', () => { describe('adding and removing', () => { it('add static path', () => { const matcher = createCompiledMatcher() - matcher.addRoute(EMPTY_PATH_ROUTE) + matcher.addMatcher(EMPTY_PATH_ROUTE) }) it('adds dynamic path', () => { const matcher = createCompiledMatcher() - matcher.addRoute(USER_ID_ROUTE) + matcher.addMatcher(USER_ID_ROUTE) }) it('removes static path', () => { const matcher = createCompiledMatcher() - matcher.addRoute(EMPTY_PATH_ROUTE) - matcher.removeRoute(EMPTY_PATH_ROUTE) + matcher.addMatcher(EMPTY_PATH_ROUTE) + matcher.removeMatcher(EMPTY_PATH_ROUTE) // Add assertions to verify the route was removed }) it('removes dynamic path', () => { const matcher = createCompiledMatcher() - matcher.addRoute(USER_ID_ROUTE) - matcher.removeRoute(USER_ID_ROUTE) + matcher.addMatcher(USER_ID_ROUTE) + matcher.removeMatcher(USER_ID_ROUTE) // Add assertions to verify the route was removed }) }) diff --git a/packages/router/src/new-route-resolver/matcher.test-d.ts b/packages/router/src/new-route-resolver/matcher.test-d.ts index 8ea5b771d..26060c3a6 100644 --- a/packages/router/src/new-route-resolver/matcher.test-d.ts +++ b/packages/router/src/new-route-resolver/matcher.test-d.ts @@ -2,15 +2,15 @@ import { describe, expectTypeOf, it } from 'vitest' import { NEW_LocationResolved, NEW_MatcherRecordRaw, - NEW_RouterMatcher, -} from './matcher' + NEW_RouterResolver, +} from './resolver' import { EXPERIMENTAL_RouteRecordNormalized } from '../experimental/router' describe('Matcher', () => { type TMatcherRecordRaw = NEW_MatcherRecordRaw type TMatcherRecord = EXPERIMENTAL_RouteRecordNormalized - const matcher: NEW_RouterMatcher = + const matcher: NEW_RouterResolver = {} as any describe('matcher.resolve()', () => { diff --git a/packages/router/src/new-route-resolver/matchers/test-utils.ts b/packages/router/src/new-route-resolver/matchers/test-utils.ts index e922e7217..250efafd9 100644 --- a/packages/router/src/new-route-resolver/matchers/test-utils.ts +++ b/packages/router/src/new-route-resolver/matchers/test-utils.ts @@ -4,7 +4,7 @@ import { MatcherPatternQuery, MatcherPatternParams_Base, } from '../matcher-pattern' -import { NEW_MatcherRecord } from '../matcher' +import { NEW_MatcherRecord } from '../resolver' import { miss } from './errors' export const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{ diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/resolver.ts similarity index 85% rename from packages/router/src/new-route-resolver/matcher.ts rename to packages/router/src/new-route-resolver/resolver.ts index c0ba504ce..17ceecf02 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/resolver.ts @@ -27,11 +27,13 @@ import { _RouteRecordProps } from '../typed-routes' export type MatcherName = string | symbol /** - * Manage and resolve routes. Also handles the encoding, decoding, parsing and serialization of params, query, and hash. - * `TMatcherRecordRaw` represents the raw record type passed to {@link addRoute}. - * `TMatcherRecord` represents the normalized record type. + * Manage and resolve routes. Also handles the encoding, decoding, parsing and + * serialization of params, query, and hash. + * + * - `TMatcherRecordRaw` represents the raw record type passed to {@link addMatcher}. + * - `TMatcherRecord` represents the normalized record type returned by {@link getMatchers}. */ -export interface NEW_RouterMatcher { +export interface NEW_RouterResolver { /** * Resolves an absolute location (like `/path/to/somewhere`). */ @@ -80,9 +82,26 @@ export interface NEW_RouterMatcher { currentLocation: NEW_LocationResolved ): NEW_LocationResolved - addRoute(matcher: TMatcherRecordRaw, parent?: TMatcherRecord): TMatcherRecord - removeRoute(matcher: TMatcherRecord): void - clearRoutes(): void + /** + * Add a matcher record. Previously named `addRoute()`. + * @param matcher - The matcher record to add. + * @param parent - The parent matcher record if this is a child. + */ + addMatcher( + matcher: TMatcherRecordRaw, + parent?: TMatcherRecord + ): TMatcherRecord + + /** + * Remove a matcher by its name. Previously named `removeRoute()`. + * @param matcher - The matcher (returned by {@link addMatcher}) to remove. + */ + removeMatcher(matcher: TMatcherRecord): void + + /** + * Remove all matcher records. Prevoisly named `clearRoutes()`. + */ + clearMatchers(): void /** * Get a list of all matchers. @@ -98,7 +117,7 @@ export interface NEW_RouterMatcher { } /** - * Allowed location objects to be passed to {@link NEW_RouterMatcher['resolve']} + * Allowed location objects to be passed to {@link NEW_RouterResolver['resolve']} */ export type MatcherLocationRaw = | `/${string}` @@ -108,20 +127,6 @@ export type MatcherLocationRaw = | MatcherLocationAsPathRelative | MatcherLocationAsRelative -/** - * Matcher capable of adding and removing routes at runtime. - */ -export interface NEW_Matcher_Dynamic { - addRoute(record: TODO, parent?: TODO): () => void - - removeRoute(record: TODO): void - removeRoute(name: MatcherName): void - - clearRoutes(): void -} - -type TODO = any - export interface NEW_LocationResolved { // FIXME: remove `undefined` name: MatcherName | undefined @@ -234,7 +239,7 @@ export const NO_MATCH_LOCATION = { // FIXME: later on, the MatcherRecord should be compatible with RouteRecordRaw (which can miss a path, have children, etc) /** - * Experiment new matcher record base type. + * Experimental new matcher record base type. * * @experimental */ @@ -267,10 +272,7 @@ export interface NEW_MatcherRecordRaw { children?: NEW_MatcherRecordRaw[] } -/** - * Normalized version of a {@link NEW_MatcherRecordRaw} record. - */ -export interface NEW_MatcherRecord { +export interface NEW_MatcherRecordBase { /** * Name of the matcher. Unique across all matchers. */ @@ -280,9 +282,15 @@ export interface NEW_MatcherRecord { query?: MatcherPatternQuery hash?: MatcherPatternHash - parent?: NEW_MatcherRecord + parent?: T } +/** + * Normalized version of a {@link NEW_MatcherRecordRaw} record. + */ +export interface NEW_MatcherRecord + extends NEW_MatcherRecordBase {} + /** * Tagged template helper to encode params into a path. Doesn't work with null */ @@ -310,9 +318,9 @@ export function pathEncoded( /** * Build the `matched` array of a record that includes all parent records from the root to the current one. */ -function buildMatched(record: NEW_MatcherRecord): NEW_MatcherRecord[] { - const matched: NEW_MatcherRecord[] = [] - let node: NEW_MatcherRecord | undefined = record +function buildMatched>(record: T): T[] { + const matched: T[] = [] + let node: T | undefined = record while (node) { matched.unshift(node) node = node.parent @@ -320,11 +328,13 @@ function buildMatched(record: NEW_MatcherRecord): NEW_MatcherRecord[] { return matched } -export function createCompiledMatcher( +export function createCompiledMatcher< + TMatcherRecord extends NEW_MatcherRecordBase +>( records: NEW_MatcherRecordRaw[] = [] -): NEW_RouterMatcher { +): NEW_RouterResolver { // TODO: we also need an array that has the correct order - const matchers = new Map() + const matchers = new Map() // TODO: allow custom encode/decode functions // const encodeParams = applyToParams.bind(null, encodeParam) @@ -340,26 +350,26 @@ export function createCompiledMatcher( type MatcherResolveArgs = | [ absoluteLocation: `/${string}`, - currentLocation?: undefined | NEW_LocationResolved + currentLocation?: undefined | NEW_LocationResolved ] | [ relativeLocation: string, - currentLocation: NEW_LocationResolved + currentLocation: NEW_LocationResolved ] | [absoluteLocation: MatcherLocationAsPathAbsolute] | [ relativeLocation: MatcherLocationAsPathRelative, - currentLocation: NEW_LocationResolved + currentLocation: NEW_LocationResolved ] | [location: MatcherLocationAsNamed] | [ relativeLocation: MatcherLocationAsRelative, - currentLocation: NEW_LocationResolved + currentLocation: NEW_LocationResolved ] function resolve( ...args: MatcherResolveArgs - ): NEW_LocationResolved { + ): NEW_LocationResolved { const [location, currentLocation] = args // string location, e.g. '/foo', '../bar', 'baz', '?page=1' @@ -367,10 +377,8 @@ export function createCompiledMatcher( // parseURL handles relative paths const url = parseURL(parseQuery, location, currentLocation?.path) - let matcher: NEW_MatcherRecord | undefined - let matched: - | NEW_LocationResolved['matched'] - | undefined + let matcher: TMatcherRecord | undefined + let matched: NEW_LocationResolved['matched'] | undefined let parsedParams: MatcherParamsFormatted | null | undefined for (matcher of matchers.values()) { @@ -475,10 +483,11 @@ export function createCompiledMatcher( } } - function addRoute(record: NEW_MatcherRecordRaw, parent?: NEW_MatcherRecord) { + function addRoute(record: NEW_MatcherRecordRaw, parent?: TMatcherRecord) { const name = record.name ?? (__DEV__ ? Symbol('unnamed-route') : Symbol()) // FIXME: proper normalization of the record - const normalizedRecord: NEW_MatcherRecord = { + // @ts-expect-error: we are not properly normalizing the record yet + const normalizedRecord: TMatcherRecord = { ...record, name, parent, @@ -491,7 +500,7 @@ export function createCompiledMatcher( addRoute(record) } - function removeRoute(matcher: NEW_MatcherRecord) { + function removeRoute(matcher: TMatcherRecord) { matchers.delete(matcher.name) // TODO: delete children and aliases } @@ -511,9 +520,9 @@ export function createCompiledMatcher( return { resolve, - addRoute, - removeRoute, - clearRoutes, + addMatcher: addRoute, + removeMatcher: removeRoute, + clearMatchers: clearRoutes, getMatcher, getMatchers, } diff --git a/packages/router/src/utils/index.ts b/packages/router/src/utils/index.ts index a7c42f4cf..c6d622095 100644 --- a/packages/router/src/utils/index.ts +++ b/packages/router/src/utils/index.ts @@ -58,3 +58,15 @@ export const noop = () => {} */ export const isArray: (arg: ArrayLike | any) => arg is ReadonlyArray = Array.isArray + +export function mergeOptions( + defaults: T, + partialOptions: Partial +): T { + const options = {} as T + for (const key in defaults) { + options[key] = key in partialOptions ? partialOptions[key]! : defaults[key] + } + + return options +} From 6b2a6598971b95975c5099f70b58c34d1b025ca5 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 8 Jan 2025 10:54:03 +0100 Subject: [PATCH 29/37] chore: remove unused --- packages/router/src/experimental/router.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index 9cf885cc1..09e7a46af 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -56,7 +56,6 @@ import type { RouteLocationRaw, RouteLocationResolved, RouteMap, - RouteParams, RouteRecordNameGeneric, } from '../typed-routes' import { @@ -72,8 +71,7 @@ import { parseURL, START_LOCATION_NORMALIZED, } from '../location' -import { applyToParams, assign, isArray, isBrowser, noop } from '../utils' -import { decode, encodeParam } from '../encoding' +import { assign, isArray, isBrowser, noop } from '../utils' import { extractChangingRecords, extractComponentsGuards, @@ -455,15 +453,6 @@ export function experimental_createRouter( history.scrollRestoration = 'manual' } - const normalizeParams = applyToParams.bind( - null, - paramValue => '' + paramValue - ) - const encodeParams = applyToParams.bind(null, encodeParam) - const decodeParams: (params: RouteParams | undefined) => RouteParams = - // @ts-expect-error: intentionally avoid the type check - applyToParams.bind(null, decode) - function addRoute( parentOrRoute: | NonNullable From a02cab6e134317f62c9ec84ab2353f34f9f6fb41 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 8 Jan 2025 11:53:53 +0100 Subject: [PATCH 30/37] test: fix ts errors --- .../matcher-resolve.spec.ts | 291 ++++++++++-------- 1 file changed, 162 insertions(+), 129 deletions(-) diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts index 6f9914af2..4ea1a00cd 100644 --- a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -1,7 +1,7 @@ -import { createRouterMatcher, normalizeRouteRecord } from '../matcher' -import { RouteComponent, RouteRecordRaw, MatcherLocation } from '../types' +import { createRouterMatcher } from '../matcher' +import { RouteComponent, RouteRecordRaw } from '../types' import { defineComponent } from 'vue' -import { START_LOCATION_NORMALIZED } from '../location' +import { stringifyURL } from '../location' import { describe, expect, it } from 'vitest' import { mockWarn } from '../../__tests__/vitest-mock-warn' import { @@ -15,6 +15,12 @@ import { PathParams, tokensToParser } from '../matcher/pathParserRanker' import { tokenizePath } from '../matcher/pathTokenizer' import { miss } from './matchers/errors' import { MatcherPatternPath } from './matcher-pattern' +import { EXPERIMENTAL_RouteRecordRaw } from '../experimental/router' +import { stringifyQuery } from '../query' +import { + MatcherLocationAsNamed, + MatcherLocationAsPathAbsolute, +} from './matcher-location' // for raw route record const component: RouteComponent = defineComponent({}) @@ -89,33 +95,58 @@ describe('RouterMatcher.resolve', () => { ) } + function isExperimentalRouteRecordRaw( + record: Record + ): record is EXPERIMENTAL_RouteRecordRaw { + return typeof record.path !== 'string' + } + // TODO: rework with object param for clarity function assertRecordMatch( - record: RouteRecordRaw | RouteRecordRaw[], - toLocation: MatcherLocationRaw, + record: + | EXPERIMENTAL_RouteRecordRaw + | EXPERIMENTAL_RouteRecordRaw[] + | RouteRecordRaw + | RouteRecordRaw[], + toLocation: Exclude | `/${string}`, expectedLocation: Partial, fromLocation: | NEW_LocationResolved - | Exclude - | `/${string}` = START_LOCATION + // absolute locations only that can be resolved for convenience + | `/${string}` + | MatcherLocationAsNamed + | MatcherLocationAsPathAbsolute = START_LOCATION ) { const records = (Array.isArray(record) ? record : [record]).map( - (record): NEW_MatcherRecordRaw => compileRouteRecord(record) + (record): EXPERIMENTAL_RouteRecordRaw => + isExperimentalRouteRecordRaw(record) + ? record + : compileRouteRecord(record) ) const matcher = createCompiledMatcher() for (const record of records) { matcher.addMatcher(record) } - const resolved: MatcherResolvedLocation = { + const path = + typeof toLocation === 'string' ? toLocation : toLocation.path || '/' + + const resolved: Omit = { // FIXME: to add later // meta: records[0].meta || {}, - path: - typeof toLocation === 'string' ? toLocation : toLocation.path || '/', + path, + query: {}, + hash: '', name: expect.any(Symbol) as symbol, - matched: [], // FIXME: build up + // must non enumerable + // matched: [], params: (typeof toLocation === 'object' && toLocation.params) || {}, + fullPath: stringifyURL(stringifyQuery, { + path: expectedLocation.path || '/', + query: expectedLocation.query, + hash: expectedLocation.hash, + }), ...expectedLocation, } @@ -123,17 +154,24 @@ describe('RouterMatcher.resolve', () => { writable: true, configurable: true, enumerable: false, + // FIXME: build it value: [], }) - fromLocation = isMatcherLocationResolved(fromLocation) + const resolvedFrom = isMatcherLocationResolved(fromLocation) ? fromLocation - : matcher.resolve(fromLocation) + : // FIXME: is this a ts bug? + // @ts-expect-error + matcher.resolve(fromLocation) - expect(matcher.resolve(toLocation, fromLocation)).toMatchObject({ - // avoid undesired properties - query: {}, - hash: '', + expect( + matcher.resolve( + // FIXME: WTF? + // @ts-expect-error + toLocation, + resolvedFrom + ) + ).toMatchObject({ ...resolved, }) } @@ -147,10 +185,15 @@ describe('RouterMatcher.resolve', () => { */ function assertErrorMatch( record: RouteRecordRaw | RouteRecordRaw[], - location: MatcherLocationRaw, - start: MatcherLocation = START_LOCATION_NORMALIZED + toLocation: Exclude | `/${string}`, + fromLocation: + | NEW_LocationResolved + // absolute locations only + | `/${string}` + | MatcherLocationAsNamed + | MatcherLocationAsPathAbsolute = START_LOCATION ) { - assertRecordMatch(record, location, {}, start) + assertRecordMatch(record, toLocation, {}, fromLocation) } describe.skip('LocationAsPath', () => { @@ -353,7 +396,7 @@ describe('RouterMatcher.resolve', () => { } assertRecordMatch( Parent, - { name: 'child_b' }, + {}, { name: 'child_b', path: '/foo/parent/b', @@ -368,15 +411,13 @@ describe('RouterMatcher.resolve', () => { }, { params: { optional: 'foo' }, - path: '/foo/parent/a', - matched: [], - meta: {}, - name: undefined, + // matched: [], + name: 'child_a', } ) }) - // TODO: check if needed by the active matching, if not just test that the param is dropped + it.todo('discards non existent params', () => { assertRecordMatch( { path: '/', name: 'home', components }, @@ -451,9 +492,6 @@ describe('RouterMatcher.resolve', () => { { name: 'Home', params: {}, - path: '/home', - matched: [record] as any, - meta: {}, } ) }) @@ -466,10 +504,8 @@ describe('RouterMatcher.resolve', () => { { name: undefined, path: '/users/posva/m/admin' }, { path: '/users/ed/m/user', - name: undefined, - params: { id: 'ed', role: 'user' }, - matched: [record] as any, - meta: {}, + // params: { id: 'ed', role: 'user' }, + // matched: [record] as any, } ) }) @@ -485,11 +521,10 @@ describe('RouterMatcher.resolve', () => { { params: { id: 'posva', role: 'admin' } }, { name: 'UserEdit', path: '/users/posva/m/admin' }, { - path: '/users/ed/m/user', + // path: '/users/ed/m/user', name: 'UserEdit', params: { id: 'ed', role: 'user' }, - matched: [], - meta: {}, + // matched: [], } ) }) @@ -509,11 +544,10 @@ describe('RouterMatcher.resolve', () => { params: { id: 'ed', role: 'user' }, }, { - path: '/users/ed/m/user', + // path: '/users/ed/m/user', name: 'UserEdit', params: { id: 'ed', role: 'user' }, matched: [record] as any, - meta: {}, } ) }) @@ -530,10 +564,9 @@ describe('RouterMatcher.resolve', () => { }, { path: '/users/ed/m/user', - name: undefined, - params: { id: 'ed', role: 'user' }, - matched: [record] as any, - meta: {}, + // name: undefined, + // params: { id: 'ed', role: 'user' }, + // matched: [record] as any, } ) }) @@ -546,9 +579,8 @@ describe('RouterMatcher.resolve', () => { { name: 'p', params: { a: 'a' }, - path: '/a', - matched: [], - meta: {}, + // path: '/a', + // matched: [], } ) }) @@ -561,9 +593,8 @@ describe('RouterMatcher.resolve', () => { { name: 'p', params: { a: 'a', b: 'b' }, - path: '/a/b', + // path: '/a/b', matched: [], - meta: {}, } ) }) @@ -576,9 +607,8 @@ describe('RouterMatcher.resolve', () => { { name: 'p', params: { a: 'a', b: 'b' }, - path: '/a/b', + // path: '/a/b', matched: [], - meta: {}, } ) }) @@ -598,9 +628,10 @@ describe('RouterMatcher.resolve', () => { record, { params: { a: 'foo' } }, { - ...start, - matched: start.matched.map(normalizeRouteRecord), - meta: {}, + name: 'home', + params: {}, + // matched: start.matched.map(normalizeRouteRecord), + // meta: {}, } ) ).toMatchSnapshot() @@ -639,7 +670,7 @@ describe('RouterMatcher.resolve', () => { name: 'ArticlesParent', children: [{ path: ':id', components }], }, - { name: 'ArticlesParent' }, + { name: 'ArticlesParent', params: {} }, { name: 'ArticlesParent', path: '/articles' } ) }) @@ -660,15 +691,15 @@ describe('RouterMatcher.resolve', () => { name: 'Home', path: '/home', params: {}, - meta: { foo: true }, matched: [ - { - path: '/home', - name: 'Home', - components, - aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), - meta: { foo: true }, - }, + // TODO: + // { + // path: '/home', + // name: 'Home', + // components, + // aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + // meta: { foo: true }, + // }, ], } ) @@ -690,15 +721,15 @@ describe('RouterMatcher.resolve', () => { name: 'Home', path: '/', params: {}, - meta: { foo: true }, matched: [ - { - path: '/', - name: 'Home', - components, - aliasOf: undefined, - meta: { foo: true }, - }, + // TODO: + // { + // path: '/', + // name: 'Home', + // components, + // aliasOf: undefined, + // meta: { foo: true }, + // }, ], } ) @@ -709,15 +740,15 @@ describe('RouterMatcher.resolve', () => { name: 'Home', path: '/home', params: {}, - meta: { foo: true }, matched: [ - { - path: '/home', - name: 'Home', - components, - aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), - meta: { foo: true }, - }, + // TODO: + // { + // path: '/home', + // name: 'Home', + // components, + // aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + // meta: { foo: true }, + // }, ], } ) @@ -728,15 +759,15 @@ describe('RouterMatcher.resolve', () => { name: 'Home', path: '/start', params: {}, - meta: { foo: true }, matched: [ - { - path: '/start', - name: 'Home', - components, - aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), - meta: { foo: true }, - }, + // TODO: + // { + // path: '/start', + // name: 'Home', + // components, + // aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + // meta: { foo: true }, + // }, ], } ) @@ -751,20 +782,20 @@ describe('RouterMatcher.resolve', () => { components, meta: { foo: true }, }, - { name: 'Home' }, + { name: 'Home', params: {} }, { name: 'Home', path: '/', params: {}, - meta: { foo: true }, matched: [ - { - path: '/', - name: 'Home', - components, - aliasOf: undefined, - meta: { foo: true }, - }, + // TODO: + // { + // path: '/', + // name: 'Home', + // components, + // aliasOf: undefined, + // meta: { foo: true }, + // }, ], } ) @@ -785,18 +816,19 @@ describe('RouterMatcher.resolve', () => { name: 'nested', params: {}, matched: [ - { - path: '/p', - children, - components, - aliasOf: expect.objectContaining({ path: '/parent' }), - }, - { - path: '/p/one', - name: 'nested', - components, - aliasOf: expect.objectContaining({ path: '/parent/one' }), - }, + // TODO: + // { + // path: '/p', + // children, + // components, + // aliasOf: expect.objectContaining({ path: '/parent' }), + // }, + // { + // path: '/p/one', + // name: 'nested', + // components, + // aliasOf: expect.objectContaining({ path: '/parent/one' }), + // }, ], } ) @@ -1120,19 +1152,20 @@ describe('RouterMatcher.resolve', () => { component, children, }, - { name: 'nested' }, + { name: 'nested', params: {} }, { path: '/parent/one', name: 'nested', params: {}, matched: [ - { - path: '/parent', - children, - components, - aliasOf: undefined, - }, - { path: '/parent/one', name: 'nested', components }, + // TODO: + // { + // path: '/parent', + // children, + // components, + // aliasOf: undefined, + // }, + // { path: '/parent/one', name: 'nested', components }, ], } ) @@ -1179,7 +1212,8 @@ describe('RouterMatcher.resolve', () => { name: 'child-b', path: '/foo/b', params: {}, - matched: [Foo, { ...ChildB, path: `${Foo.path}/${ChildB.path}` }], + // TODO: + // matched: [Foo, { ...ChildB, path: `${Foo.path}/${ChildB.path}` }], } ) }) @@ -1269,19 +1303,20 @@ describe('RouterMatcher.resolve', () => { } assertRecordMatch( Foo, - { name: 'nested-child-a' }, + { name: 'nested-child-a', params: {} }, { name: 'nested-child-a', path: '/foo/nested/a', params: {}, - matched: [ - Foo as any, - { ...Nested, path: `${Foo.path}/${Nested.path}` }, - { - ...NestedChildA, - path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, - }, - ], + // TODO: + // matched: [ + // Foo as any, + // { ...Nested, path: `${Foo.path}/${Nested.path}` }, + // { + // ...NestedChildA, + // path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + // }, + // ], } ) }) @@ -1311,10 +1346,7 @@ describe('RouterMatcher.resolve', () => { }, { name: 'nested-child-a', - matched: [], params: {}, - path: '/foo/nested/a', - meta: {}, } ) }) @@ -1391,7 +1423,8 @@ describe('RouterMatcher.resolve', () => { name: 'absolute', path: '/absolute', params: {}, - matched: [Foo, ChildD], + // TODO: + // matched: [Foo, ChildD], } ) }) From 49ee966babff33576ae4449090a713e57e5b29bc Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 8 Jan 2025 12:00:15 +0100 Subject: [PATCH 31/37] test: remove old matcher refs --- .../matcher-resolve.spec.ts | 47 ++++++++----------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts index 4ea1a00cd..6ad889394 100644 --- a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -1,26 +1,27 @@ -import { createRouterMatcher } from '../matcher' -import { RouteComponent, RouteRecordRaw } from '../types' +import { describe, expect, it } from 'vitest' import { defineComponent } from 'vue' +import { RouteComponent, RouteRecordRaw } from '../types' import { stringifyURL } from '../location' -import { describe, expect, it } from 'vitest' import { mockWarn } from '../../__tests__/vitest-mock-warn' import { createCompiledMatcher, - MatcherLocationRaw, - NEW_MatcherRecordRaw, - NEW_LocationResolved, - NEW_MatcherRecord, + type MatcherLocationRaw, + type NEW_MatcherRecordRaw, + type NEW_LocationResolved, + type NEW_MatcherRecord, } from './resolver' -import { PathParams, tokensToParser } from '../matcher/pathParserRanker' -import { tokenizePath } from '../matcher/pathTokenizer' import { miss } from './matchers/errors' -import { MatcherPatternPath } from './matcher-pattern' -import { EXPERIMENTAL_RouteRecordRaw } from '../experimental/router' +import { MatcherPatternPath, MatcherPatternPathStatic } from './matcher-pattern' +import { type EXPERIMENTAL_RouteRecordRaw } from '../experimental/router' import { stringifyQuery } from '../query' -import { +import type { MatcherLocationAsNamed, MatcherLocationAsPathAbsolute, } from './matcher-location' +// TODO: should be moved to a different test file +// used to check backward compatible paths +import { PathParams, tokensToParser } from '../matcher/pathParserRanker' +import { tokenizePath } from '../matcher/pathTokenizer' // for raw route record const component: RouteComponent = defineComponent({}) @@ -464,22 +465,12 @@ describe('RouterMatcher.resolve', () => { }) describe.skip('LocationAsRelative', () => { - it('warns if a path isn not absolute', () => { - const record = { - path: '/parent', - components, - } - const matcher = createRouterMatcher([record], {}) - matcher.resolve( - { path: 'two' }, - { - path: '/parent/one', - name: undefined, - params: {}, - matched: [] as any, - meta: {}, - } - ) + // TODO: not sure where this warning should appear now + it.todo('warns if a path isn not absolute', () => { + const matcher = createCompiledMatcher([ + { path: new MatcherPatternPathStatic('/') }, + ]) + matcher.resolve('two', matcher.resolve('/')) expect('received "two"').toHaveBeenWarned() }) From 1f717fbe24af33319f10a36990ecf094d67637c5 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 8 Jan 2025 12:34:34 +0100 Subject: [PATCH 32/37] chore: remove last ts errors --- packages/router/src/experimental/router.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index 09e7a46af..32f8a0f0f 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -90,6 +90,12 @@ import { */ export type _OnReadyCallback = [() => void, (reason?: any) => void] +// NOTE: we could override each type with the new matched array but this would +// interface RouteLocationResolved +// extends Omit<_RouteLocationResolved, 'matched'> { +// matched: EXPERIMENTAL_RouteRecordNormalized[] +// } + /** * Options to initialize a {@link Router} instance. */ @@ -548,8 +554,10 @@ export function experimental_createRouter( // } const matchedRoute = matcher.resolve( + // FIXME: should be ok + // @ts-expect-error: too many overlads rawLocation, - currentLocation satisfies NEW_LocationResolved + currentLocation ) const href = routerHistory.createHref(matchedRoute.fullPath) @@ -564,8 +572,8 @@ export function experimental_createRouter( } } - // TODO: can this be refactored at the very end // matchedRoute is always a new object + // @ts-expect-error: the `matched` property is different return assign(matchedRoute, { redirectedFrom: undefined, href, From b879e541c27a76c3da1c14b40f5678b47d115a56 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 8 Jan 2025 17:50:23 +0100 Subject: [PATCH 33/37] feat: support partial locations --- packages/router/src/experimental/router.ts | 47 ++-- packages/router/src/location.ts | 2 +- .../new-route-resolver/matcher-location.ts | 3 + .../matcher-resolve.spec.ts | 171 +++++++------- .../src/new-route-resolver/matcher.spec.ts | 48 ++-- .../src/new-route-resolver/matcher.test-d.ts | 7 +- .../router/src/new-route-resolver/resolver.ts | 215 ++++++++++-------- packages/router/src/query.ts | 2 +- 8 files changed, 276 insertions(+), 219 deletions(-) diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index 32f8a0f0f..1b252336e 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -83,6 +83,7 @@ import { routerKey, routerViewLocationKey, } from '../injectionSymbols' +import { MatcherLocationAsPathAbsolute } from '../new-route-resolver/matcher-location' /** * resolve, reject arguments of Promise constructor @@ -406,6 +407,11 @@ export interface EXPERIMENTAL_RouteRecordRaw extends NEW_MatcherRecordRaw { * Arbitrary data attached to the record. */ meta?: RouteMeta + + components?: Record + component?: unknown + + redirect?: unknown } // TODO: is it worth to have 2 types for the undefined values? @@ -510,6 +516,15 @@ export function experimental_createRouter( return !!matcher.getMatcher(name) } + function locationAsObject( + to: RouteLocationRaw | RouteLocationNormalized, + currentLocation: string = currentRoute.value.path + ): Exclude | RouteLocationNormalized { + return typeof to === 'string' + ? parseURL(parseQuery, to, currentLocation) + : to + } + function resolve( rawLocation: RouteLocationRaw, currentLocation?: RouteLocationNormalizedLoaded @@ -522,6 +537,11 @@ export function experimental_createRouter( currentLocation && assign({}, currentLocation || currentRoute.value) // currentLocation = assign({}, currentLocation || currentRoute.value) + const locationObject = locationAsObject( + rawLocation, + currentRoute.value.path + ) + if (__DEV__) { if (!isRouteLocation(rawLocation)) { warn( @@ -531,12 +551,9 @@ export function experimental_createRouter( return resolve({}) } - if ( - typeof rawLocation === 'object' && - !rawLocation.hash?.startsWith('#') - ) { + if (!locationObject.hash?.startsWith('#')) { warn( - `A \`hash\` should always start with the character "#". Replace "${rawLocation.hash}" with "#${rawLocation.hash}".` + `A \`hash\` should always start with the character "#". Replace "${locationObject.hash}" with "#${locationObject.hash}".` ) } } @@ -555,16 +572,20 @@ export function experimental_createRouter( const matchedRoute = matcher.resolve( // FIXME: should be ok - // @ts-expect-error: too many overlads - rawLocation, - currentLocation + // locationObject as MatcherLocationAsPathRelative, + // locationObject as MatcherLocationAsRelative, + // locationObject as MatcherLocationAsName, // TODO: this one doesn't allow an undefined currentLocation, the other ones work + locationObject as MatcherLocationAsPathAbsolute, + currentLocation as unknown as NEW_LocationResolved ) const href = routerHistory.createHref(matchedRoute.fullPath) if (__DEV__) { if (href.startsWith('//')) { warn( - `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.` + `Location ${JSON.stringify( + rawLocation + )} resolved to "${href}". A resolved location cannot start with multiple slashes.` ) } if (!matchedRoute.matched.length) { @@ -581,14 +602,6 @@ export function experimental_createRouter( }) } - function locationAsObject( - to: RouteLocationRaw | RouteLocationNormalized - ): Exclude | RouteLocationNormalized { - return typeof to === 'string' - ? parseURL(parseQuery, to, currentRoute.value.path) - : assign({}, to) - } - function checkCanceledNavigation( to: RouteLocationNormalized, from: RouteLocationNormalized diff --git a/packages/router/src/location.ts b/packages/router/src/location.ts index 163bf6f8d..8ab2a1815 100644 --- a/packages/router/src/location.ts +++ b/packages/router/src/location.ts @@ -77,8 +77,8 @@ export function parseURL( hash = location.slice(hashPos, location.length) } - // TODO(major): path ?? location path = resolveRelativePath( + // TODO(major): path ?? location path != null ? path : // empty path means a relative query or hash `?foo=f`, `#thing` diff --git a/packages/router/src/new-route-resolver/matcher-location.ts b/packages/router/src/new-route-resolver/matcher-location.ts index f597df07f..e05fdf7b3 100644 --- a/packages/router/src/new-route-resolver/matcher-location.ts +++ b/packages/router/src/new-route-resolver/matcher-location.ts @@ -38,6 +38,9 @@ export interface MatcherLocationAsPathRelative { */ params?: undefined } + +// TODO: does it make sense to support absolute paths objects? + export interface MatcherLocationAsPathAbsolute extends MatcherLocationAsPathRelative { path: `/${string}` diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts index 6ad889394..a73a05841 100644 --- a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { defineComponent } from 'vue' import { RouteComponent, RouteRecordRaw } from '../types' -import { stringifyURL } from '../location' +import { NEW_stringifyURL } from '../location' import { mockWarn } from '../../__tests__/vitest-mock-warn' import { createCompiledMatcher, @@ -9,6 +9,7 @@ import { type NEW_MatcherRecordRaw, type NEW_LocationResolved, type NEW_MatcherRecord, + NO_MATCH_LOCATION, } from './resolver' import { miss } from './matchers/errors' import { MatcherPatternPath, MatcherPatternPathStatic } from './matcher-pattern' @@ -20,14 +21,27 @@ import type { } from './matcher-location' // TODO: should be moved to a different test file // used to check backward compatible paths -import { PathParams, tokensToParser } from '../matcher/pathParserRanker' +import { + PATH_PARSER_OPTIONS_DEFAULTS, + PathParams, + tokensToParser, +} from '../matcher/pathParserRanker' import { tokenizePath } from '../matcher/pathTokenizer' +import { mergeOptions } from '../utils' // for raw route record const component: RouteComponent = defineComponent({}) // for normalized route records const components = { default: component } +function isMatchable(record: RouteRecordRaw): boolean { + return !!( + record.name || + (record.components && Object.keys(record.components).length) || + record.redirect + ) +} + function compileRouteRecord( record: RouteRecordRaw, parentRecord?: RouteRecordRaw @@ -38,14 +52,15 @@ function compileRouteRecord( ? record.path : (parentRecord?.path || '') + record.path record.path = path - const parser = tokensToParser(tokenizePath(record.path), { - // start: true, - end: record.end, - sensitive: record.sensitive, - strict: record.strict, - }) + const parser = tokensToParser( + tokenizePath(record.path), + mergeOptions(PATH_PARSER_OPTIONS_DEFAULTS, record) + ) + + // console.log({ record, parser }) return { + group: !isMatchable(record), name: record.name, path: { @@ -122,7 +137,7 @@ describe('RouterMatcher.resolve', () => { const records = (Array.isArray(record) ? record : [record]).map( (record): EXPERIMENTAL_RouteRecordRaw => isExperimentalRouteRecordRaw(record) - ? record + ? { components, ...record } : compileRouteRecord(record) ) const matcher = createCompiledMatcher() @@ -139,15 +154,17 @@ describe('RouterMatcher.resolve', () => { path, query: {}, hash: '', + // by default we have a symbol on every route name: expect.any(Symbol) as symbol, // must non enumerable // matched: [], params: (typeof toLocation === 'object' && toLocation.params) || {}, - fullPath: stringifyURL(stringifyQuery, { - path: expectedLocation.path || '/', - query: expectedLocation.query, - hash: expectedLocation.hash, - }), + fullPath: NEW_stringifyURL( + stringifyQuery, + expectedLocation.path || path || '/', + expectedLocation.query, + expectedLocation.hash + ), ...expectedLocation, } @@ -161,43 +178,29 @@ describe('RouterMatcher.resolve', () => { const resolvedFrom = isMatcherLocationResolved(fromLocation) ? fromLocation - : // FIXME: is this a ts bug? - // @ts-expect-error - matcher.resolve(fromLocation) + : matcher.resolve( + // FIXME: is this a ts bug? + // @ts-expect-error + typeof fromLocation === 'string' + ? { path: fromLocation } + : fromLocation + ) + + // console.log({ toLocation, resolved, expectedLocation, resolvedFrom }) expect( matcher.resolve( - // FIXME: WTF? + // FIXME: should work now // @ts-expect-error - toLocation, - resolvedFrom + typeof toLocation === 'string' ? { path: toLocation } : toLocation, + resolvedFrom === START_LOCATION ? undefined : resolvedFrom ) ).toMatchObject({ ...resolved, }) } - /** - * - * @param record - Record or records we are testing the matcher against - * @param location - location we want to resolve against - * @param [start] Optional currentLocation used when resolving - * @returns error - */ - function assertErrorMatch( - record: RouteRecordRaw | RouteRecordRaw[], - toLocation: Exclude | `/${string}`, - fromLocation: - | NEW_LocationResolved - // absolute locations only - | `/${string}` - | MatcherLocationAsNamed - | MatcherLocationAsPathAbsolute = START_LOCATION - ) { - assertRecordMatch(record, toLocation, {}, fromLocation) - } - - describe.skip('LocationAsPath', () => { + describe('LocationAsPath', () => { it('resolves a normal path', () => { assertRecordMatch({ path: '/', name: 'Home', components }, '/', { name: 'Home', @@ -207,10 +210,14 @@ describe('RouterMatcher.resolve', () => { }) it('resolves a normal path without name', () => { + assertRecordMatch({ path: '/', components }, '/', { + path: '/', + params: {}, + }) assertRecordMatch( { path: '/', components }, { path: '/' }, - { name: undefined, path: '/', params: {} } + { path: '/', params: {} } ) }) @@ -258,7 +265,7 @@ describe('RouterMatcher.resolve', () => { assertRecordMatch( { path: '/users/:id/:other', components }, { path: '/users/posva/hey' }, - { name: undefined, params: { id: 'posva', other: 'hey' } } + { name: expect.any(Symbol), params: { id: 'posva', other: 'hey' } } ) }) @@ -266,7 +273,7 @@ describe('RouterMatcher.resolve', () => { assertRecordMatch( { path: '/', components }, { path: '/foo' }, - { name: undefined, params: {}, path: '/foo', matched: [] } + { params: {}, path: '/foo', matched: [] } ) }) @@ -274,7 +281,7 @@ describe('RouterMatcher.resolve', () => { assertRecordMatch( { path: '/home/', name: 'Home', components }, { path: '/home/' }, - { name: 'Home', path: '/home/', matched: expect.any(Array) } + { name: 'Home', path: '/home/' } ) }) @@ -309,13 +316,13 @@ describe('RouterMatcher.resolve', () => { path: '/home/', name: 'Home', components, - options: { strict: true }, + strict: true, } - assertErrorMatch(record, { path: '/home' }) + assertRecordMatch(record, { path: '/home' }, NO_MATCH_LOCATION) assertRecordMatch( record, { path: '/home/' }, - { name: 'Home', path: '/home/', matched: expect.any(Array) } + { name: 'Home', path: '/home/' } ) }) @@ -324,14 +331,14 @@ describe('RouterMatcher.resolve', () => { path: '/home', name: 'Home', components, - options: { strict: true }, + strict: true, } assertRecordMatch( record, { path: '/home' }, - { name: 'Home', path: '/home', matched: expect.any(Array) } + { name: 'Home', path: '/home' } ) - assertErrorMatch(record, { path: '/home/' }) + assertRecordMatch(record, { path: '/home/' }, NO_MATCH_LOCATION) }) }) @@ -358,12 +365,10 @@ describe('RouterMatcher.resolve', () => { }) it('throws if the named route does not exists', () => { - expect(() => - assertErrorMatch( - { path: '/', components }, - { name: 'Home', params: {} } - ) - ).toThrowError('Matcher "Home" not found') + const matcher = createCompiledMatcher([]) + expect(() => matcher.resolve({ name: 'Home', params: {} })).toThrowError( + 'Matcher "Home" not found' + ) }) it('merges params', () => { @@ -375,8 +380,9 @@ describe('RouterMatcher.resolve', () => { ) }) - // TODO: new matcher no longer allows implicit param merging - it.todo('only keep existing params', () => { + // TODO: this test doesn't seem useful, it's the same as the test above + // maybe remove it? + it('only keep existing params', () => { assertRecordMatch( { path: '/:a/:b', name: 'p', components }, { name: 'p', params: { b: 'b' } }, @@ -464,13 +470,13 @@ describe('RouterMatcher.resolve', () => { }) }) - describe.skip('LocationAsRelative', () => { + describe('LocationAsRelative', () => { // TODO: not sure where this warning should appear now it.todo('warns if a path isn not absolute', () => { const matcher = createCompiledMatcher([ { path: new MatcherPatternPathStatic('/') }, ]) - matcher.resolve('two', matcher.resolve('/')) + matcher.resolve({ path: 'two' }, matcher.resolve({ path: '/' })) expect('received "two"').toHaveBeenWarned() }) @@ -492,7 +498,7 @@ describe('RouterMatcher.resolve', () => { assertRecordMatch( record, { params: { id: 'posva', role: 'admin' } }, - { name: undefined, path: '/users/posva/m/admin' }, + { path: '/users/posva/m/admin' }, { path: '/users/ed/m/user', // params: { id: 'ed', role: 'user' }, @@ -549,7 +555,6 @@ describe('RouterMatcher.resolve', () => { record, {}, { - name: undefined, path: '/users/ed/m/user', params: { id: 'ed', role: 'user' }, }, @@ -605,41 +610,36 @@ describe('RouterMatcher.resolve', () => { }) it('throws if the current named route does not exists', () => { - const record = { path: '/', components } - const start = { - name: 'home', - params: {}, - path: '/', - matched: [record], - } - // the property should be non enumerable - Object.defineProperty(start, 'matched', { enumerable: false }) - expect( - assertErrorMatch( - record, - { params: { a: 'foo' } }, + const matcher = createCompiledMatcher([]) + expect(() => + matcher.resolve( + {}, { - name: 'home', + name: 'ko', params: {}, - // matched: start.matched.map(normalizeRouteRecord), - // meta: {}, + fullPath: '/', + hash: '', + matched: [], + path: '/', + query: {}, } ) - ).toMatchSnapshot() + ).toThrowError('Matcher "ko" not found') }) it('avoids records with children without a component nor name', () => { - assertErrorMatch( + assertRecordMatch( { path: '/articles', children: [{ path: ':id', components }], }, - { path: '/articles' } + { path: '/articles' }, + NO_MATCH_LOCATION ) }) - it('avoid deeply nested records with children without a component nor name', () => { - assertErrorMatch( + it('avoids deeply nested records with children without a component nor name', () => { + assertRecordMatch( { path: '/app', components, @@ -650,7 +650,8 @@ describe('RouterMatcher.resolve', () => { }, ], }, - { path: '/articles' } + { path: '/articles' }, + NO_MATCH_LOCATION ) }) diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index 335ddb83d..ecc2d5e39 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -92,7 +92,7 @@ describe('RouterMatcher', () => { { path: new MatcherPatternPathStatic('/users') }, ]) - expect(matcher.resolve('/')).toMatchObject({ + expect(matcher.resolve({ path: '/' })).toMatchObject({ fullPath: '/', path: '/', params: {}, @@ -100,7 +100,7 @@ describe('RouterMatcher', () => { hash: '', }) - expect(matcher.resolve('/users')).toMatchObject({ + expect(matcher.resolve({ path: '/users' })).toMatchObject({ fullPath: '/users', path: '/users', params: {}, @@ -122,7 +122,7 @@ describe('RouterMatcher', () => { }, ]) - expect(matcher.resolve('/users/1')).toMatchObject({ + expect(matcher.resolve({ path: '/users/1' })).toMatchObject({ fullPath: '/users/1', path: '/users/1', params: { id: '1' }, @@ -157,11 +157,11 @@ describe('RouterMatcher', () => { }) describe('resolve()', () => { - describe('absolute locations as strings', () => { + describe.todo('absolute locations as strings', () => { it('resolves string locations with no params', () => { const matcher = createCompiledMatcher([EMPTY_PATH_ROUTE]) - expect(matcher.resolve('/?a=a&b=b#h')).toMatchObject({ + expect(matcher.resolve({ path: '/?a=a&b=b#h' })).toMatchObject({ path: '/', params: {}, query: { a: 'a', b: 'b' }, @@ -171,7 +171,7 @@ describe('RouterMatcher', () => { it('resolves a not found string', () => { const matcher = createCompiledMatcher() - expect(matcher.resolve('/bar?q=1#hash')).toEqual({ + expect(matcher.resolve({ path: '/bar?q=1#hash' })).toEqual({ ...NO_MATCH_LOCATION, fullPath: '/bar?q=1#hash', path: '/bar', @@ -184,13 +184,13 @@ describe('RouterMatcher', () => { it('resolves string locations with params', () => { const matcher = createCompiledMatcher([USER_ID_ROUTE]) - expect(matcher.resolve('/users/1?a=a&b=b#h')).toMatchObject({ + expect(matcher.resolve({ path: '/users/1?a=a&b=b#h' })).toMatchObject({ path: '/users/1', params: { id: 1 }, query: { a: 'a', b: 'b' }, hash: '#h', }) - expect(matcher.resolve('/users/54?a=a&b=b#h')).toMatchObject({ + expect(matcher.resolve({ path: '/users/54?a=a&b=b#h' })).toMatchObject({ path: '/users/54', params: { id: 54 }, query: { a: 'a', b: 'b' }, @@ -206,7 +206,7 @@ describe('RouterMatcher', () => { }, ]) - expect(matcher.resolve('/foo?page=100&b=b#h')).toMatchObject({ + expect(matcher.resolve({ path: '/foo?page=100&b=b#h' })).toMatchObject({ params: { page: 100 }, path: '/foo', query: { @@ -225,7 +225,7 @@ describe('RouterMatcher', () => { }, ]) - expect(matcher.resolve('/foo?a=a&b=b#bar')).toMatchObject({ + expect(matcher.resolve({ path: '/foo?a=a&b=b#bar' })).toMatchObject({ hash: '#bar', params: { hash: 'bar' }, path: '/foo', @@ -242,7 +242,9 @@ describe('RouterMatcher', () => { }, ]) - expect(matcher.resolve('/users/24?page=100#bar')).toMatchObject({ + expect( + matcher.resolve({ path: '/users/24?page=100#bar' }) + ).toMatchObject({ params: { id: 24, page: 100, hash: 'bar' }, }) }) @@ -255,7 +257,10 @@ describe('RouterMatcher', () => { ]) expect( - matcher.resolve('foo', matcher.resolve('/nested/')) + matcher.resolve( + { path: 'foo' }, + matcher.resolve({ path: '/nested/' }) + ) ).toMatchObject({ params: {}, path: '/nested/foo', @@ -263,7 +268,10 @@ describe('RouterMatcher', () => { hash: '', }) expect( - matcher.resolve('../foo', matcher.resolve('/nested/')) + matcher.resolve( + { path: '../foo' }, + matcher.resolve({ path: '/nested/' }) + ) ).toMatchObject({ params: {}, path: '/foo', @@ -271,7 +279,10 @@ describe('RouterMatcher', () => { hash: '', }) expect( - matcher.resolve('./foo', matcher.resolve('/nested/')) + matcher.resolve( + { path: './foo' }, + matcher.resolve({ path: '/nested/' }) + ) ).toMatchObject({ params: {}, path: '/nested/foo', @@ -317,7 +328,7 @@ describe('RouterMatcher', () => { const matcher = createCompiledMatcher([ANY_PATH_ROUTE]) describe('decodes', () => { it('handles encoded string path', () => { - expect(matcher.resolve('/%23%2F%3F')).toMatchObject({ + expect(matcher.resolve({ path: '/%23%2F%3F' })).toMatchObject({ fullPath: '/%23%2F%3F', path: '/%23%2F%3F', query: {}, @@ -326,7 +337,9 @@ describe('RouterMatcher', () => { }) }) - it('decodes query from a string', () => { + // TODO: move to the router as the matcher dosen't handle a plain string + it.todo('decodes query from a string', () => { + // @ts-expect-error: does not suppor fullPath expect(matcher.resolve('/foo?foo=%23%2F%3F')).toMatchObject({ path: '/foo', fullPath: '/foo?foo=%23%2F%3F', @@ -334,7 +347,8 @@ describe('RouterMatcher', () => { }) }) - it('decodes hash from a string', () => { + it.todo('decodes hash from a string', () => { + // @ts-expect-error: does not suppor fullPath expect(matcher.resolve('/foo#%22')).toMatchObject({ path: '/foo', fullPath: '/foo#%22', diff --git a/packages/router/src/new-route-resolver/matcher.test-d.ts b/packages/router/src/new-route-resolver/matcher.test-d.ts index 26060c3a6..c04dfad31 100644 --- a/packages/router/src/new-route-resolver/matcher.test-d.ts +++ b/packages/router/src/new-route-resolver/matcher.test-d.ts @@ -15,7 +15,7 @@ describe('Matcher', () => { describe('matcher.resolve()', () => { it('resolves absolute string locations', () => { - expectTypeOf(matcher.resolve('/foo')).toEqualTypeOf< + expectTypeOf(matcher.resolve({ path: '/foo' })).toEqualTypeOf< NEW_LocationResolved >() }) @@ -27,7 +27,10 @@ describe('Matcher', () => { it('resolves relative locations', () => { expectTypeOf( - matcher.resolve('foo', {} as NEW_LocationResolved) + matcher.resolve( + { path: 'foo' }, + {} as NEW_LocationResolved + ) ).toEqualTypeOf>() }) diff --git a/packages/router/src/new-route-resolver/resolver.ts b/packages/router/src/new-route-resolver/resolver.ts index 17ceecf02..93b235c79 100644 --- a/packages/router/src/new-route-resolver/resolver.ts +++ b/packages/router/src/new-route-resolver/resolver.ts @@ -1,9 +1,4 @@ -import { - type LocationQuery, - parseQuery, - normalizeQuery, - stringifyQuery, -} from '../query' +import { type LocationQuery, normalizeQuery, stringifyQuery } from '../query' import type { MatcherPatternHash, MatcherPatternPath, @@ -11,7 +6,7 @@ import type { } from './matcher-pattern' import { warn } from '../warning' import { encodeQueryValue as _encodeQueryValue, encodeParam } from '../encoding' -import { parseURL, NEW_stringifyURL } from '../location' +import { NEW_stringifyURL, resolveRelativePath } from '../location' import type { MatcherLocationAsNamed, MatcherLocationAsPathAbsolute, @@ -37,25 +32,27 @@ export interface NEW_RouterResolver { /** * Resolves an absolute location (like `/path/to/somewhere`). */ - resolve( - absoluteLocation: `/${string}`, - currentLocation?: undefined | NEW_LocationResolved - ): NEW_LocationResolved + // resolve( + // absoluteLocation: `/${string}`, + // currentLocation?: undefined | NEW_LocationResolved + // ): NEW_LocationResolved /** * Resolves a string location relative to another location. A relative location can be `./same-folder`, * `../parent-folder`, `same-folder`, or even `?page=2`. */ - resolve( - relativeLocation: string, - currentLocation: NEW_LocationResolved - ): NEW_LocationResolved + // resolve( + // relativeLocation: string, + // currentLocation: NEW_LocationResolved + // ): NEW_LocationResolved /** * Resolves a location by its name. Any required params or query must be passed in the `options` argument. */ resolve( - location: MatcherLocationAsNamed + location: MatcherLocationAsNamed, + // TODO: is this useful? + currentLocation?: undefined ): NEW_LocationResolved /** @@ -63,7 +60,10 @@ export interface NEW_RouterResolver { * @param location - The location to resolve. */ resolve( - location: MatcherLocationAsPathAbsolute + location: MatcherLocationAsPathAbsolute, + // TODO: is this useful? + currentLocation?: undefined + // currentLocation?: NEW_LocationResolved ): NEW_LocationResolved resolve( @@ -120,8 +120,8 @@ export interface NEW_RouterResolver { * Allowed location objects to be passed to {@link NEW_RouterResolver['resolve']} */ export type MatcherLocationRaw = - | `/${string}` - | string + // | `/${string}` + // | string | MatcherLocationAsNamed | MatcherLocationAsPathAbsolute | MatcherLocationAsPathRelative @@ -270,6 +270,11 @@ export interface NEW_MatcherRecordRaw { * Array of nested routes. */ children?: NEW_MatcherRecordRaw[] + + /** + * Is this a record that groups children. Cannot be matched + */ + group?: boolean } export interface NEW_MatcherRecordBase { @@ -282,6 +287,8 @@ export interface NEW_MatcherRecordBase { query?: MatcherPatternQuery hash?: MatcherPatternHash + group?: boolean + parent?: T } @@ -348,20 +355,23 @@ export function createCompiledMatcher< // NOTE: because of the overloads, we need to manually type the arguments type MatcherResolveArgs = + // | [ + // absoluteLocation: `/${string}`, + // currentLocation?: undefined | NEW_LocationResolved + // ] + // | [ + // relativeLocation: string, + // currentLocation: NEW_LocationResolved + // ] | [ - absoluteLocation: `/${string}`, - currentLocation?: undefined | NEW_LocationResolved - ] - | [ - relativeLocation: string, - currentLocation: NEW_LocationResolved + absoluteLocation: MatcherLocationAsPathAbsolute, + currentLocation?: undefined ] - | [absoluteLocation: MatcherLocationAsPathAbsolute] | [ relativeLocation: MatcherLocationAsPathRelative, currentLocation: NEW_LocationResolved ] - | [location: MatcherLocationAsNamed] + | [location: MatcherLocationAsNamed, currentLocation?: undefined] | [ relativeLocation: MatcherLocationAsRelative, currentLocation: NEW_LocationResolved @@ -370,12 +380,76 @@ export function createCompiledMatcher< function resolve( ...args: MatcherResolveArgs ): NEW_LocationResolved { - const [location, currentLocation] = args + const [to, currentLocation] = args + + if (to.name || to.path == null) { + // relative location or by name + if (__DEV__ && to.name == null && currentLocation == null) { + console.warn( + `Cannot resolve an unnamed relative location without a current location. This will throw in production.`, + to + ) + // NOTE: normally there is no query, hash or path but this helps debug + // what kind of object location was passed + // @ts-expect-error: to is never + const query = normalizeQuery(to.query) + // @ts-expect-error: to is never + const hash = to.hash ?? '' + // @ts-expect-error: to is never + const path = to.path ?? '/' + return { + ...NO_MATCH_LOCATION, + fullPath: NEW_stringifyURL(stringifyQuery, path, query, hash), + path, + query, + hash, + } + } - // string location, e.g. '/foo', '../bar', 'baz', '?page=1' - if (typeof location === 'string') { + // either one of them must be defined and is catched by the dev only warn above + const name = to.name ?? currentLocation?.name + // FIXME: remove once name cannot be null + const matcher = name != null && matchers.get(name) + if (!matcher) { + throw new Error(`Matcher "${String(name)}" not found`) + } + + // unencoded params in a formatted form that the user came up with + const params: MatcherParamsFormatted = { + ...currentLocation?.params, + ...to.params, + } + const path = matcher.path.build(params) + const hash = matcher.hash?.build(params) ?? '' + const matched = buildMatched(matcher) + const query = Object.assign( + { + ...currentLocation?.query, + ...normalizeQuery(to.query), + }, + ...matched.map(matcher => matcher.query?.build(params)) + ) + + return { + name, + fullPath: NEW_stringifyURL(stringifyQuery, path, query, hash), + path, + query, + hash, + params, + matched, + } + // string location, e.g. '/foo', '../bar', 'baz', '?page=1' + } else { // parseURL handles relative paths - const url = parseURL(parseQuery, location, currentLocation?.path) + // parseURL(to.path, currentLocation?.path) + const query = normalizeQuery(to.query) + const url = { + fullPath: NEW_stringifyURL(stringifyQuery, to.path, query, to.hash), + path: resolveRelativePath(to.path, currentLocation?.path || '/'), + query, + hash: to.hash || '', + } let matcher: TMatcherRecord | undefined let matched: NEW_LocationResolved['matched'] | undefined @@ -412,8 +486,8 @@ export function createCompiledMatcher< ...url, ...NO_MATCH_LOCATION, // already decoded - query: url.query, - hash: url.hash, + // query: url.query, + // hash: url.hash, } } @@ -422,68 +496,13 @@ export function createCompiledMatcher< // matcher exists if matched exists name: matcher!.name, params: parsedParams, - // already decoded - query: url.query, - hash: url.hash, matched, } // TODO: handle object location { path, query, hash } - } else { - // relative location or by name - if (__DEV__ && location.name == null && currentLocation == null) { - console.warn( - `Cannot resolve an unnamed relative location without a current location. This will throw in production.`, - location - ) - const query = normalizeQuery(location.query) - const hash = location.hash ?? '' - const path = location.path ?? '/' - return { - ...NO_MATCH_LOCATION, - fullPath: NEW_stringifyURL(stringifyQuery, path, query, hash), - path, - query, - hash, - } - } - - // either one of them must be defined and is catched by the dev only warn above - const name = location.name ?? currentLocation!.name - // FIXME: remove once name cannot be null - const matcher = name != null && matchers.get(name) - if (!matcher) { - throw new Error(`Matcher "${String(location.name)}" not found`) - } - - // unencoded params in a formatted form that the user came up with - const params: MatcherParamsFormatted = { - ...currentLocation?.params, - ...location.params, - } - const path = matcher.path.build(params) - const hash = matcher.hash?.build(params) ?? '' - const matched = buildMatched(matcher) - const query = Object.assign( - { - ...currentLocation?.query, - ...normalizeQuery(location.query), - }, - ...matched.map(matcher => matcher.query?.build(params)) - ) - - return { - name, - fullPath: NEW_stringifyURL(stringifyQuery, path, query, hash), - path, - query, - hash, - params, - matched, - } } } - function addRoute(record: NEW_MatcherRecordRaw, parent?: TMatcherRecord) { + function addMatcher(record: NEW_MatcherRecordRaw, parent?: TMatcherRecord) { const name = record.name ?? (__DEV__ ? Symbol('unnamed-route') : Symbol()) // FIXME: proper normalization of the record // @ts-expect-error: we are not properly normalizing the record yet @@ -492,20 +511,24 @@ export function createCompiledMatcher< name, parent, } - matchers.set(name, normalizedRecord) + // TODO: + // record.children + if (!normalizedRecord.group) { + matchers.set(name, normalizedRecord) + } return normalizedRecord } for (const record of records) { - addRoute(record) + addMatcher(record) } - function removeRoute(matcher: TMatcherRecord) { + function removeMatcher(matcher: TMatcherRecord) { matchers.delete(matcher.name) // TODO: delete children and aliases } - function clearRoutes() { + function clearMatchers() { matchers.clear() } @@ -520,9 +543,9 @@ export function createCompiledMatcher< return { resolve, - addMatcher: addRoute, - removeMatcher: removeRoute, - clearMatchers: clearRoutes, + addMatcher, + removeMatcher, + clearMatchers, getMatcher, getMatchers, } diff --git a/packages/router/src/query.ts b/packages/router/src/query.ts index 55e77c714..79feb6e43 100644 --- a/packages/router/src/query.ts +++ b/packages/router/src/query.ts @@ -16,7 +16,7 @@ import { isArray } from './utils' */ export type LocationQueryValue = string | null /** - * Possible values when defining a query. + * Possible values when defining a query. `undefined` allows to remove a value. * * @internal */ From 3416bd09a3e67270b1f5bef6148b6c1643450ea6 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Thu, 9 Jan 2025 11:18:40 +0100 Subject: [PATCH 34/37] chore: rename --- .../src/new-route-resolver/{matcher.spec.ts => resolver.spec.ts} | 0 .../new-route-resolver/{matcher.test-d.ts => resolver.test-d.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/router/src/new-route-resolver/{matcher.spec.ts => resolver.spec.ts} (100%) rename packages/router/src/new-route-resolver/{matcher.test-d.ts => resolver.test-d.ts} (100%) diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/resolver.spec.ts similarity index 100% rename from packages/router/src/new-route-resolver/matcher.spec.ts rename to packages/router/src/new-route-resolver/resolver.spec.ts diff --git a/packages/router/src/new-route-resolver/matcher.test-d.ts b/packages/router/src/new-route-resolver/resolver.test-d.ts similarity index 100% rename from packages/router/src/new-route-resolver/matcher.test-d.ts rename to packages/router/src/new-route-resolver/resolver.test-d.ts From f20dbf1c634c5a9e0d8c4c88ce3112b514f622fd Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Thu, 9 Jan 2025 11:54:20 +0100 Subject: [PATCH 35/37] feat: allow string in matcher resolve --- packages/router/src/experimental/router.ts | 23 +++--- packages/router/src/location.ts | 2 +- .../src/new-route-resolver/resolver.spec.ts | 7 +- .../src/new-route-resolver/resolver.test-d.ts | 22 +++++- .../router/src/new-route-resolver/resolver.ts | 77 ++++++++++++------- 5 files changed, 78 insertions(+), 53 deletions(-) diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index 1b252336e..a6c70afff 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -83,7 +83,6 @@ import { routerKey, routerViewLocationKey, } from '../injectionSymbols' -import { MatcherLocationAsPathAbsolute } from '../new-route-resolver/matcher-location' /** * resolve, reject arguments of Promise constructor @@ -537,11 +536,6 @@ export function experimental_createRouter( currentLocation && assign({}, currentLocation || currentRoute.value) // currentLocation = assign({}, currentLocation || currentRoute.value) - const locationObject = locationAsObject( - rawLocation, - currentRoute.value.path - ) - if (__DEV__) { if (!isRouteLocation(rawLocation)) { warn( @@ -551,9 +545,12 @@ export function experimental_createRouter( return resolve({}) } - if (!locationObject.hash?.startsWith('#')) { + if ( + typeof rawLocation === 'object' && + rawLocation.hash?.startsWith('#') + ) { warn( - `A \`hash\` should always start with the character "#". Replace "${locationObject.hash}" with "#${locationObject.hash}".` + `A \`hash\` should always start with the character "#". Replace "${rawLocation.hash}" with "#${rawLocation.hash}".` ) } } @@ -571,12 +568,10 @@ export function experimental_createRouter( // } const matchedRoute = matcher.resolve( - // FIXME: should be ok - // locationObject as MatcherLocationAsPathRelative, - // locationObject as MatcherLocationAsRelative, - // locationObject as MatcherLocationAsName, // TODO: this one doesn't allow an undefined currentLocation, the other ones work - locationObject as MatcherLocationAsPathAbsolute, - currentLocation as unknown as NEW_LocationResolved + // incompatible types + rawLocation as any, + // incompatible `matched` requires casting + currentLocation as any ) const href = routerHistory.createHref(matchedRoute.fullPath) diff --git a/packages/router/src/location.ts b/packages/router/src/location.ts index 8ab2a1815..57d4e589d 100644 --- a/packages/router/src/location.ts +++ b/packages/router/src/location.ts @@ -10,7 +10,7 @@ import { RouteLocation, RouteLocationNormalizedLoaded } from './typed-routes' * Location object returned by {@link `parseURL`}. * @internal */ -interface LocationNormalized { +export interface LocationNormalized { path: string fullPath: string hash: string diff --git a/packages/router/src/new-route-resolver/resolver.spec.ts b/packages/router/src/new-route-resolver/resolver.spec.ts index ecc2d5e39..436349a04 100644 --- a/packages/router/src/new-route-resolver/resolver.spec.ts +++ b/packages/router/src/new-route-resolver/resolver.spec.ts @@ -337,9 +337,7 @@ describe('RouterMatcher', () => { }) }) - // TODO: move to the router as the matcher dosen't handle a plain string - it.todo('decodes query from a string', () => { - // @ts-expect-error: does not suppor fullPath + it('decodes query from a string', () => { expect(matcher.resolve('/foo?foo=%23%2F%3F')).toMatchObject({ path: '/foo', fullPath: '/foo?foo=%23%2F%3F', @@ -347,8 +345,7 @@ describe('RouterMatcher', () => { }) }) - it.todo('decodes hash from a string', () => { - // @ts-expect-error: does not suppor fullPath + it('decodes hash from a string', () => { expect(matcher.resolve('/foo#%22')).toMatchObject({ path: '/foo', fullPath: '/foo#%22', diff --git a/packages/router/src/new-route-resolver/resolver.test-d.ts b/packages/router/src/new-route-resolver/resolver.test-d.ts index c04dfad31..6da64da51 100644 --- a/packages/router/src/new-route-resolver/resolver.test-d.ts +++ b/packages/router/src/new-route-resolver/resolver.test-d.ts @@ -18,11 +18,16 @@ describe('Matcher', () => { expectTypeOf(matcher.resolve({ path: '/foo' })).toEqualTypeOf< NEW_LocationResolved >() + expectTypeOf(matcher.resolve('/foo')).toEqualTypeOf< + NEW_LocationResolved + >() }) it('fails on non absolute location without a currentLocation', () => { // @ts-expect-error: needs currentLocation matcher.resolve('foo') + // @ts-expect-error: needs currentLocation + matcher.resolve({ path: 'foo' }) }) it('resolves relative locations', () => { @@ -32,6 +37,9 @@ describe('Matcher', () => { {} as NEW_LocationResolved ) ).toEqualTypeOf>() + expectTypeOf( + matcher.resolve('foo', {} as NEW_LocationResolved) + ).toEqualTypeOf>() }) it('resolved named locations', () => { @@ -42,7 +50,9 @@ describe('Matcher', () => { it('fails on object relative location without a currentLocation', () => { // @ts-expect-error: needs currentLocation - matcher.resolve({ params: { id: 1 } }) + matcher.resolve({ params: { id: '1' } }) + // @ts-expect-error: needs currentLocation + matcher.resolve({ query: { id: '1' } }) }) it('resolves object relative locations with a currentLocation', () => { @@ -57,13 +67,17 @@ describe('Matcher', () => { it('does not allow a name + path', () => { matcher.resolve({ - // ...({} as NEW_LocationResolved), + // ...({} as NEW_LocationResolved), name: 'foo', params: {}, // @ts-expect-error: name + path path: '/e', }) - // @ts-expect-error: name + currentLocation - matcher.resolve({ name: 'a', params: {} }, {} as NEW_LocationResolved) + matcher.resolve( + // @ts-expect-error: name + currentLocation + { name: 'a', params: {} }, + // + {} as NEW_LocationResolved + ) }) }) diff --git a/packages/router/src/new-route-resolver/resolver.ts b/packages/router/src/new-route-resolver/resolver.ts index 93b235c79..060aee34c 100644 --- a/packages/router/src/new-route-resolver/resolver.ts +++ b/packages/router/src/new-route-resolver/resolver.ts @@ -1,4 +1,9 @@ -import { type LocationQuery, normalizeQuery, stringifyQuery } from '../query' +import { + type LocationQuery, + normalizeQuery, + parseQuery, + stringifyQuery, +} from '../query' import type { MatcherPatternHash, MatcherPatternPath, @@ -6,7 +11,12 @@ import type { } from './matcher-pattern' import { warn } from '../warning' import { encodeQueryValue as _encodeQueryValue, encodeParam } from '../encoding' -import { NEW_stringifyURL, resolveRelativePath } from '../location' +import { + LocationNormalized, + NEW_stringifyURL, + parseURL, + resolveRelativePath, +} from '../location' import type { MatcherLocationAsNamed, MatcherLocationAsPathAbsolute, @@ -32,19 +42,19 @@ export interface NEW_RouterResolver { /** * Resolves an absolute location (like `/path/to/somewhere`). */ - // resolve( - // absoluteLocation: `/${string}`, - // currentLocation?: undefined | NEW_LocationResolved - // ): NEW_LocationResolved + resolve( + absoluteLocation: `/${string}`, + currentLocation?: undefined + ): NEW_LocationResolved /** * Resolves a string location relative to another location. A relative location can be `./same-folder`, * `../parent-folder`, `same-folder`, or even `?page=2`. */ - // resolve( - // relativeLocation: string, - // currentLocation: NEW_LocationResolved - // ): NEW_LocationResolved + resolve( + relativeLocation: string, + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved /** * Resolves a location by its name. Any required params or query must be passed in the `options` argument. @@ -53,6 +63,7 @@ export interface NEW_RouterResolver { location: MatcherLocationAsNamed, // TODO: is this useful? currentLocation?: undefined + // currentLocation?: undefined | NEW_LocationResolved ): NEW_LocationResolved /** @@ -63,7 +74,7 @@ export interface NEW_RouterResolver { location: MatcherLocationAsPathAbsolute, // TODO: is this useful? currentLocation?: undefined - // currentLocation?: NEW_LocationResolved + // currentLocation?: NEW_LocationResolved | undefined ): NEW_LocationResolved resolve( @@ -121,7 +132,7 @@ export interface NEW_RouterResolver { */ export type MatcherLocationRaw = // | `/${string}` - // | string + | string | MatcherLocationAsNamed | MatcherLocationAsPathAbsolute | MatcherLocationAsPathRelative @@ -355,23 +366,27 @@ export function createCompiledMatcher< // NOTE: because of the overloads, we need to manually type the arguments type MatcherResolveArgs = - // | [ - // absoluteLocation: `/${string}`, - // currentLocation?: undefined | NEW_LocationResolved - // ] - // | [ - // relativeLocation: string, - // currentLocation: NEW_LocationResolved - // ] + | [absoluteLocation: `/${string}`, currentLocation?: undefined] + | [ + relativeLocation: string, + currentLocation: NEW_LocationResolved + ] | [ absoluteLocation: MatcherLocationAsPathAbsolute, + // Same as above + // currentLocation?: NEW_LocationResolved | undefined currentLocation?: undefined ] | [ relativeLocation: MatcherLocationAsPathRelative, currentLocation: NEW_LocationResolved ] - | [location: MatcherLocationAsNamed, currentLocation?: undefined] + | [ + location: MatcherLocationAsNamed, + // Same as above + // currentLocation?: NEW_LocationResolved | undefined + currentLocation?: undefined + ] | [ relativeLocation: MatcherLocationAsRelative, currentLocation: NEW_LocationResolved @@ -382,7 +397,7 @@ export function createCompiledMatcher< ): NEW_LocationResolved { const [to, currentLocation] = args - if (to.name || to.path == null) { + if (typeof to === 'object' && (to.name || to.path == null)) { // relative location or by name if (__DEV__ && to.name == null && currentLocation == null) { console.warn( @@ -442,13 +457,17 @@ export function createCompiledMatcher< // string location, e.g. '/foo', '../bar', 'baz', '?page=1' } else { // parseURL handles relative paths - // parseURL(to.path, currentLocation?.path) - const query = normalizeQuery(to.query) - const url = { - fullPath: NEW_stringifyURL(stringifyQuery, to.path, query, to.hash), - path: resolveRelativePath(to.path, currentLocation?.path || '/'), - query, - hash: to.hash || '', + let url: LocationNormalized + if (typeof to === 'string') { + url = parseURL(parseQuery, to, currentLocation?.path) + } else { + const query = normalizeQuery(to.query) + url = { + fullPath: NEW_stringifyURL(stringifyQuery, to.path, query, to.hash), + path: resolveRelativePath(to.path, currentLocation?.path || '/'), + query, + hash: to.hash || '', + } } let matcher: TMatcherRecord | undefined From 60564fe54f05b154bc7ffb99df4f725d22db7076 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Thu, 9 Jan 2025 12:04:28 +0100 Subject: [PATCH 36/37] test: stricter no match test --- .../matcher-resolve.spec.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts index a73a05841..66ca21a89 100644 --- a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -188,16 +188,21 @@ describe('RouterMatcher.resolve', () => { // console.log({ toLocation, resolved, expectedLocation, resolvedFrom }) - expect( - matcher.resolve( - // FIXME: should work now - // @ts-expect-error - typeof toLocation === 'string' ? { path: toLocation } : toLocation, - resolvedFrom === START_LOCATION ? undefined : resolvedFrom - ) - ).toMatchObject({ - ...resolved, - }) + const result = matcher.resolve( + // FIXME: should work now + // @ts-expect-error + typeof toLocation === 'string' ? { path: toLocation } : toLocation, + resolvedFrom === START_LOCATION ? undefined : resolvedFrom + ) + + if ( + expectedLocation.name === undefined || + expectedLocation.name !== NO_MATCH_LOCATION.name + ) { + expect(result.name).not.toBe(NO_MATCH_LOCATION.name) + } + + expect(result).toMatchObject(resolved) } describe('LocationAsPath', () => { @@ -273,7 +278,7 @@ describe('RouterMatcher.resolve', () => { assertRecordMatch( { path: '/', components }, { path: '/foo' }, - { params: {}, path: '/foo', matched: [] } + NO_MATCH_LOCATION ) }) From db5a85ccb4d83ed16461a01d2ea04ba0473adceb Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Thu, 9 Jan 2025 15:47:33 +0100 Subject: [PATCH 37/37] feat: handle children --- .../__tests__/matcher/pathRanking.spec.ts | 10 -- packages/router/src/experimental/router.ts | 44 ++++--- .../router/src/matcher/pathParserRanker.ts | 5 +- .../matcher-resolve.spec.ts | 63 ++++++--- .../new-route-resolver/matchers/test-utils.ts | 14 ++ .../src/new-route-resolver/resolver.spec.ts | 32 ++--- .../router/src/new-route-resolver/resolver.ts | 122 ++++++++++++++++-- 7 files changed, 208 insertions(+), 82 deletions(-) diff --git a/packages/router/__tests__/matcher/pathRanking.spec.ts b/packages/router/__tests__/matcher/pathRanking.spec.ts index 230c3a182..417d3229e 100644 --- a/packages/router/__tests__/matcher/pathRanking.spec.ts +++ b/packages/router/__tests__/matcher/pathRanking.spec.ts @@ -13,19 +13,9 @@ describe('Path ranking', () => { return comparePathParserScore( { score: a, - re: /a/, - // @ts-expect-error - stringify: v => v, - // @ts-expect-error - parse: v => v, - keys: [], }, { score: b, - re: /a/, - stringify: v => v, - parse: v => v, - keys: [], } ) } diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index a6c70afff..2347a5f9b 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -23,11 +23,12 @@ import { type RouterHistory, } from '../history/common' import type { PathParserOptions } from '../matcher' -import type { - NEW_LocationResolved, - NEW_MatcherRecord, - NEW_MatcherRecordRaw, - NEW_RouterResolver, +import { + type NEW_MatcherRecordBase, + type NEW_LocationResolved, + type NEW_MatcherRecord, + type NEW_MatcherRecordRaw, + type NEW_RouterResolver, } from '../new-route-resolver/resolver' import { parseQuery as originalParseQuery, @@ -194,7 +195,7 @@ export interface EXPERIMENTAL_RouterOptions< * Matcher to use to resolve routes. * @experimental */ - matcher: NEW_RouterResolver + resolver: NEW_RouterResolver } /** @@ -411,14 +412,18 @@ export interface EXPERIMENTAL_RouteRecordRaw extends NEW_MatcherRecordRaw { component?: unknown redirect?: unknown + score: Array } // TODO: is it worth to have 2 types for the undefined values? -export interface EXPERIMENTAL_RouteRecordNormalized extends NEW_MatcherRecord { +export interface EXPERIMENTAL_RouteRecordNormalized + extends NEW_MatcherRecordBase { /** * Arbitrary data attached to the record. */ meta: RouteMeta + group?: boolean + score: Array } function normalizeRouteRecord( @@ -429,6 +434,7 @@ function normalizeRouteRecord( name: __DEV__ ? Symbol('anonymous route record') : Symbol(), meta: {}, ...record, + children: (record.children || []).map(normalizeRouteRecord), } } @@ -439,7 +445,7 @@ export function experimental_createRouter( EXPERIMENTAL_RouteRecordNormalized > { const { - matcher, + resolver, parseQuery = originalParseQuery, stringifyQuery = originalStringifyQuery, history: routerHistory, @@ -470,11 +476,11 @@ export function experimental_createRouter( | EXPERIMENTAL_RouteRecordRaw, route?: EXPERIMENTAL_RouteRecordRaw ) { - let parent: Parameters<(typeof matcher)['addMatcher']>[1] | undefined + let parent: Parameters<(typeof resolver)['addMatcher']>[1] | undefined let rawRecord: EXPERIMENTAL_RouteRecordRaw if (isRouteName(parentOrRoute)) { - parent = matcher.getMatcher(parentOrRoute) + parent = resolver.getMatcher(parentOrRoute) if (__DEV__ && !parent) { warn( `Parent route "${String( @@ -488,31 +494,31 @@ export function experimental_createRouter( rawRecord = parentOrRoute } - const addedRecord = matcher.addMatcher( + const addedRecord = resolver.addMatcher( normalizeRouteRecord(rawRecord), parent ) return () => { - matcher.removeMatcher(addedRecord) + resolver.removeMatcher(addedRecord) } } function removeRoute(name: NonNullable) { - const recordMatcher = matcher.getMatcher(name) + const recordMatcher = resolver.getMatcher(name) if (recordMatcher) { - matcher.removeMatcher(recordMatcher) + resolver.removeMatcher(recordMatcher) } else if (__DEV__) { warn(`Cannot remove non-existent route "${String(name)}"`) } } function getRoutes() { - return matcher.getMatchers() + return resolver.getMatchers() } function hasRoute(name: NonNullable): boolean { - return !!matcher.getMatcher(name) + return !!resolver.getMatcher(name) } function locationAsObject( @@ -567,7 +573,7 @@ export function experimental_createRouter( // rawLocation.params = targetParams // } - const matchedRoute = matcher.resolve( + const matchedRoute = resolver.resolve( // incompatible types rawLocation as any, // incompatible `matched` requires casting @@ -1226,7 +1232,7 @@ export function experimental_createRouter( addRoute, removeRoute, - clearRoutes: matcher.clearMatchers, + clearRoutes: resolver.clearMatchers, hasRoute, getRoutes, resolve, @@ -1307,7 +1313,7 @@ export function experimental_createRouter( // TODO: this probably needs to be updated so it can be used by vue-termui if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && isBrowser) { // @ts-expect-error: FIXME: refactor with new types once it's possible - addDevtools(app, router, matcher) + addDevtools(app, router, resolver) } }, } diff --git a/packages/router/src/matcher/pathParserRanker.ts b/packages/router/src/matcher/pathParserRanker.ts index b2c0b40a0..df9bf172e 100644 --- a/packages/router/src/matcher/pathParserRanker.ts +++ b/packages/router/src/matcher/pathParserRanker.ts @@ -331,7 +331,10 @@ function compareScoreArray(a: number[], b: number[]): number { * @param b - second PathParser * @returns 0 if both are equal, < 0 if a should be sorted first, > 0 if b */ -export function comparePathParserScore(a: PathParser, b: PathParser): number { +export function comparePathParserScore( + a: Pick, + b: Pick +): number { let i = 0 const aScore = a.score const bScore = b.score diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts index 66ca21a89..77b37489d 100644 --- a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -42,15 +42,25 @@ function isMatchable(record: RouteRecordRaw): boolean { ) } +function joinPaths(a: string | undefined, b: string) { + if (a?.endsWith('/')) { + return a + b + } + return a + '/' + b +} + function compileRouteRecord( record: RouteRecordRaw, parentRecord?: RouteRecordRaw ): NEW_MatcherRecordRaw { // we adapt the path to ensure they are absolute // TODO: aliases? they could be handled directly in the path matcher + if (!parentRecord && !record.path.startsWith('/')) { + throw new Error(`Record without parent must have an absolute path`) + } const path = record.path.startsWith('/') ? record.path - : (parentRecord?.path || '') + record.path + : joinPaths(parentRecord?.path, record.path) record.path = path const parser = tokensToParser( tokenizePath(record.path), @@ -62,10 +72,12 @@ function compileRouteRecord( return { group: !isMatchable(record), name: record.name, + score: parser.score, path: { match(value) { const params = parser.parse(value) + // console.log('🌟', parser.re, value, params) if (params) { return params } @@ -181,20 +193,21 @@ describe('RouterMatcher.resolve', () => { : matcher.resolve( // FIXME: is this a ts bug? // @ts-expect-error - typeof fromLocation === 'string' - ? { path: fromLocation } - : fromLocation + fromLocation ) + // console.log(matcher.getMatchers()) // console.log({ toLocation, resolved, expectedLocation, resolvedFrom }) const result = matcher.resolve( // FIXME: should work now // @ts-expect-error - typeof toLocation === 'string' ? { path: toLocation } : toLocation, + toLocation, resolvedFrom === START_LOCATION ? undefined : resolvedFrom ) + // console.log(result) + if ( expectedLocation.name === undefined || expectedLocation.name !== NO_MATCH_LOCATION.name @@ -479,7 +492,7 @@ describe('RouterMatcher.resolve', () => { // TODO: not sure where this warning should appear now it.todo('warns if a path isn not absolute', () => { const matcher = createCompiledMatcher([ - { path: new MatcherPatternPathStatic('/') }, + { path: new MatcherPatternPathStatic('/'), score: [[80]] }, ]) matcher.resolve({ path: 'two' }, matcher.resolve({ path: '/' })) expect('received "two"').toHaveBeenWarned() @@ -1169,26 +1182,34 @@ describe('RouterMatcher.resolve', () => { }) }) - describe.skip('children', () => { - const ChildA = { path: 'a', name: 'child-a', components } - const ChildB = { path: 'b', name: 'child-b', components } - const ChildC = { path: 'c', name: 'child-c', components } - const ChildD = { path: '/absolute', name: 'absolute', components } - const ChildWithParam = { path: ':p', name: 'child-params', components } - const NestedChildWithParam = { + describe('children', () => { + const ChildA: RouteRecordRaw = { path: 'a', name: 'child-a', components } + const ChildB: RouteRecordRaw = { path: 'b', name: 'child-b', components } + const ChildC: RouteRecordRaw = { path: 'c', name: 'child-c', components } + const ChildD: RouteRecordRaw = { + path: '/absolute', + name: 'absolute', + components, + } + const ChildWithParam: RouteRecordRaw = { + path: ':p', + name: 'child-params', + components, + } + const NestedChildWithParam: RouteRecordRaw = { ...ChildWithParam, name: 'nested-child-params', } - const NestedChildA = { ...ChildA, name: 'nested-child-a' } - const NestedChildB = { ...ChildB, name: 'nested-child-b' } - const NestedChildC = { ...ChildC, name: 'nested-child-c' } - const Nested = { + const NestedChildA: RouteRecordRaw = { ...ChildA, name: 'nested-child-a' } + const NestedChildB: RouteRecordRaw = { ...ChildB, name: 'nested-child-b' } + const NestedChildC: RouteRecordRaw = { ...ChildC, name: 'nested-child-c' } + const Nested: RouteRecordRaw = { path: 'nested', name: 'nested', components, children: [NestedChildA, NestedChildB, NestedChildC], } - const NestedWithParam = { + const NestedWithParam: RouteRecordRaw = { path: 'nested/:n', name: 'nested', components, @@ -1196,7 +1217,7 @@ describe('RouterMatcher.resolve', () => { } it('resolves children', () => { - const Foo = { + const Foo: RouteRecordRaw = { path: '/foo', name: 'Foo', components, @@ -1216,8 +1237,8 @@ describe('RouterMatcher.resolve', () => { }) it('resolves children with empty paths', () => { - const Nested = { path: '', name: 'nested', components } - const Foo = { + const Nested: RouteRecordRaw = { path: '', name: 'nested', components } + const Foo: RouteRecordRaw = { path: '/foo', name: 'Foo', components, diff --git a/packages/router/src/new-route-resolver/matchers/test-utils.ts b/packages/router/src/new-route-resolver/matchers/test-utils.ts index 250efafd9..4c72d8331 100644 --- a/packages/router/src/new-route-resolver/matchers/test-utils.ts +++ b/packages/router/src/new-route-resolver/matchers/test-utils.ts @@ -68,9 +68,23 @@ export const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base< export const EMPTY_PATH_ROUTE = { name: 'no params', path: EMPTY_PATH_PATTERN_MATCHER, + score: [[80]], + children: [], + parent: undefined, +} satisfies NEW_MatcherRecord + +export const ANY_PATH_ROUTE = { + name: 'any path', + path: ANY_PATH_PATTERN_MATCHER, + score: [[-10]], + children: [], + parent: undefined, } satisfies NEW_MatcherRecord export const USER_ID_ROUTE = { name: 'user-id', path: USER_ID_PATH_PATTERN_MATCHER, + score: [[80], [70]], + children: [], + parent: undefined, } satisfies NEW_MatcherRecord diff --git a/packages/router/src/new-route-resolver/resolver.spec.ts b/packages/router/src/new-route-resolver/resolver.spec.ts index 436349a04..da7b388e7 100644 --- a/packages/router/src/new-route-resolver/resolver.spec.ts +++ b/packages/router/src/new-route-resolver/resolver.spec.ts @@ -11,9 +11,13 @@ import { MatcherPatternPathStatic, MatcherPatternPathDynamic, } from './matcher-pattern' -import { NEW_MatcherRecord } from './resolver' import { miss } from './matchers/errors' import { EmptyParams } from './matcher-location' +import { + EMPTY_PATH_ROUTE, + USER_ID_ROUTE, + ANY_PATH_ROUTE, +} from './matchers/test-utils' const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{ pathMatch: string }> = { match(path) { @@ -69,27 +73,12 @@ const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base< build: ({ hash }) => (hash ? `#${hash}` : ''), } -const EMPTY_PATH_ROUTE = { - name: 'no params', - path: EMPTY_PATH_PATTERN_MATCHER, -} satisfies NEW_MatcherRecord - -const ANY_PATH_ROUTE = { - name: 'any path', - path: ANY_PATH_PATTERN_MATCHER, -} satisfies NEW_MatcherRecord - -const USER_ID_ROUTE = { - name: 'user-id', - path: USER_ID_PATH_PATTERN_MATCHER, -} satisfies NEW_MatcherRecord - describe('RouterMatcher', () => { describe('new matchers', () => { it('static path', () => { const matcher = createCompiledMatcher([ - { path: new MatcherPatternPathStatic('/') }, - { path: new MatcherPatternPathStatic('/users') }, + { path: new MatcherPatternPathStatic('/'), score: [[80]] }, + { path: new MatcherPatternPathStatic('/users'), score: [[80]] }, ]) expect(matcher.resolve({ path: '/' })).toMatchObject({ @@ -112,6 +101,7 @@ describe('RouterMatcher', () => { it('dynamic path', () => { const matcher = createCompiledMatcher([ { + score: [[80], [70]], path: new MatcherPatternPathDynamic<{ id: string }>( /^\/users\/([^\/]+)$/, { @@ -202,6 +192,7 @@ describe('RouterMatcher', () => { const matcher = createCompiledMatcher([ { path: ANY_PATH_PATTERN_MATCHER, + score: [[100, -10]], query: PAGE_QUERY_PATTERN_MATCHER, }, ]) @@ -220,6 +211,7 @@ describe('RouterMatcher', () => { it('resolves string locations with hash', () => { const matcher = createCompiledMatcher([ { + score: [[100, -10]], path: ANY_PATH_PATTERN_MATCHER, hash: ANY_HASH_PATTERN_MATCHER, }, @@ -236,6 +228,7 @@ describe('RouterMatcher', () => { it('combines path, query and hash params', () => { const matcher = createCompiledMatcher([ { + score: [[200, 80], [72]], path: USER_ID_PATH_PATTERN_MATCHER, query: PAGE_QUERY_PATTERN_MATCHER, hash: ANY_HASH_PATTERN_MATCHER, @@ -253,7 +246,7 @@ describe('RouterMatcher', () => { describe('relative locations as strings', () => { it('resolves a simple relative location', () => { const matcher = createCompiledMatcher([ - { path: ANY_PATH_PATTERN_MATCHER }, + { path: ANY_PATH_PATTERN_MATCHER, score: [[-10]] }, ]) expect( @@ -311,6 +304,7 @@ describe('RouterMatcher', () => { { name: 'home', path: EMPTY_PATH_PATTERN_MATCHER, + score: [[80]], }, ]) diff --git a/packages/router/src/new-route-resolver/resolver.ts b/packages/router/src/new-route-resolver/resolver.ts index 060aee34c..e9d198b0d 100644 --- a/packages/router/src/new-route-resolver/resolver.ts +++ b/packages/router/src/new-route-resolver/resolver.ts @@ -25,6 +25,7 @@ import type { MatcherParamsFormatted, } from './matcher-location' import { _RouteRecordProps } from '../typed-routes' +import { comparePathParserScore } from '../matcher/pathParserRanker' /** * Allowed types for a matcher name. @@ -286,6 +287,8 @@ export interface NEW_MatcherRecordRaw { * Is this a record that groups children. Cannot be matched */ group?: boolean + + score: Array } export interface NEW_MatcherRecordBase { @@ -298,9 +301,12 @@ export interface NEW_MatcherRecordBase { query?: MatcherPatternQuery hash?: MatcherPatternHash - group?: boolean - parent?: T + children: T[] + + group?: boolean + aliasOf?: NEW_MatcherRecord + score: Array } /** @@ -352,7 +358,8 @@ export function createCompiledMatcher< records: NEW_MatcherRecordRaw[] = [] ): NEW_RouterResolver { // TODO: we also need an array that has the correct order - const matchers = new Map() + const matcherMap = new Map() + const matchers: TMatcherRecord[] = [] // TODO: allow custom encode/decode functions // const encodeParams = applyToParams.bind(null, encodeParam) @@ -424,7 +431,7 @@ export function createCompiledMatcher< // either one of them must be defined and is catched by the dev only warn above const name = to.name ?? currentLocation?.name // FIXME: remove once name cannot be null - const matcher = name != null && matchers.get(name) + const matcher = name != null && matcherMap.get(name) if (!matcher) { throw new Error(`Matcher "${String(name)}" not found`) } @@ -474,7 +481,7 @@ export function createCompiledMatcher< let matched: NEW_LocationResolved['matched'] | undefined let parsedParams: MatcherParamsFormatted | null | undefined - for (matcher of matchers.values()) { + for (matcher of matchers) { // match the path because the path matcher only needs to be matched here // match the hash because only the deepest child matters // End up by building up the matched array, (reversed so it goes from @@ -493,6 +500,8 @@ export function createCompiledMatcher< // } parsedParams = { ...pathParams, ...queryParams, ...hashParams } + // we found our match! + break } catch (e) { // for debugging tests // console.log('❌ ERROR matching', e) @@ -529,12 +538,23 @@ export function createCompiledMatcher< ...record, name, parent, + children: [], } - // TODO: - // record.children + + // insert the matcher if it's matchable if (!normalizedRecord.group) { - matchers.set(name, normalizedRecord) + const index = findInsertionIndex(normalizedRecord, matchers) + matchers.splice(index, 0, normalizedRecord) + // only add the original record to the name map + if (normalizedRecord.name && !isAliasRecord(normalizedRecord)) + matcherMap.set(normalizedRecord.name, normalizedRecord) + // matchers.set(name, normalizedRecord) } + + record.children?.forEach(childRecord => + normalizedRecord.children.push(addMatcher(childRecord, normalizedRecord)) + ) + return normalizedRecord } @@ -543,20 +563,25 @@ export function createCompiledMatcher< } function removeMatcher(matcher: TMatcherRecord) { - matchers.delete(matcher.name) + matcherMap.delete(matcher.name) + for (const child of matcher.children) { + removeMatcher(child) + } + // TODO: delete from matchers // TODO: delete children and aliases } function clearMatchers() { - matchers.clear() + matchers.splice(0, matchers.length) + matcherMap.clear() } function getMatchers() { - return Array.from(matchers.values()) + return matchers } function getMatcher(name: MatcherName) { - return matchers.get(name) + return matcherMap.get(name) } return { @@ -569,3 +594,76 @@ export function createCompiledMatcher< getMatchers, } } + +/** + * Performs a binary search to find the correct insertion index for a new matcher. + * + * Matchers are primarily sorted by their score. If scores are tied then we also consider parent/child relationships, + * with descendants coming before ancestors. If there's still a tie, new routes are inserted after existing routes. + * + * @param matcher - new matcher to be inserted + * @param matchers - existing matchers + */ +function findInsertionIndex>( + matcher: T, + matchers: T[] +) { + // First phase: binary search based on score + let lower = 0 + let upper = matchers.length + + while (lower !== upper) { + const mid = (lower + upper) >> 1 + const sortOrder = comparePathParserScore(matcher, matchers[mid]) + + if (sortOrder < 0) { + upper = mid + } else { + lower = mid + 1 + } + } + + // Second phase: check for an ancestor with the same score + const insertionAncestor = getInsertionAncestor(matcher) + + if (insertionAncestor) { + upper = matchers.lastIndexOf(insertionAncestor, upper - 1) + + if (__DEV__ && upper < 0) { + // This should never happen + warn( + // TODO: fix stringifying new matchers + `Finding ancestor route "${insertionAncestor.path}" failed for "${matcher.path}"` + ) + } + } + + return upper +} + +function getInsertionAncestor>(matcher: T) { + let ancestor: T | undefined = matcher + + while ((ancestor = ancestor.parent)) { + if (!ancestor.group && comparePathParserScore(matcher, ancestor) === 0) { + return ancestor + } + } + + return +} + +/** + * Checks if a record or any of its parent is an alias + * @param record + */ +function isAliasRecord>( + record: T | undefined +): boolean { + while (record) { + if (record.aliasOf) return true + record = record.parent + } + + return false +}