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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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 34c0c1342538d4f72fe5427631d32ecea1fc64ee Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 8 Jan 2025 10:49:03 +0100 Subject: [PATCH 29/29] chore: wip refactor The idea here was to reuse the experimental router within the actual router. This turns out to be a lot of work without any security of having something working and without breaking changes in the end. So I think it's better to keep two versions of the createRouter function with prefix `EXPERIMENTAL_`. In the end, one code base only uses one of the function so it's fine to keep the code duplicated until v5. This branch is here as a reminder of the failure. --- packages/router/src/experimental/router.ts | 1 + packages/router/src/matcher/pathMatcher.ts | 26 + packages/router/src/router.ts | 1044 +++++--------------- 3 files changed, 266 insertions(+), 805 deletions(-) diff --git a/packages/router/src/experimental/router.ts b/packages/router/src/experimental/router.ts index 9cf885cc1..654bc3b6d 100644 --- a/packages/router/src/experimental/router.ts +++ b/packages/router/src/experimental/router.ts @@ -410,6 +410,7 @@ export interface EXPERIMENTAL_RouteRecordNormalized extends NEW_MatcherRecord { * Arbitrary data attached to the record. */ meta: RouteMeta + parent?: EXPERIMENTAL_RouteRecordNormalized } function normalizeRouteRecord( diff --git a/packages/router/src/matcher/pathMatcher.ts b/packages/router/src/matcher/pathMatcher.ts index aae2b7826..a4e00f7cf 100644 --- a/packages/router/src/matcher/pathMatcher.ts +++ b/packages/router/src/matcher/pathMatcher.ts @@ -16,6 +16,32 @@ export interface RouteRecordMatcher extends PathParser { alias: RouteRecordMatcher[] } +export function NEW_createRouteRecordMatcher( + record: Readonly, + parent: RouteRecordMatcher | undefined, + options?: PathParserOptions +): RouteRecordMatcher { + const parser = tokensToParser(tokenizePath(record.path), options) + + const matcher: RouteRecordMatcher = assign(parser, { + record, + parent, + // these needs to be populated by the parent + children: [], + alias: [], + }) + + if (parent) { + // both are aliases or both are not aliases + // we don't want to mix them because the order is used when + // passing originalRecord in Matcher.addRoute + if (!matcher.record.aliasOf === !parent.record.aliasOf) + parent.children.push(matcher) + } + + return matcher +} + export function createRouteRecordMatcher( record: Readonly, parent: RouteRecordMatcher | undefined, diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 059606db2..d8a121fa9 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -1,77 +1,55 @@ import { RouteRecordRaw, - Lazy, isRouteLocation, isRouteName, - RouteLocationOptions, MatcherLocationRaw, } from './types' import type { - RouteLocation, RouteLocationRaw, RouteParams, - RouteLocationNormalized, RouteLocationNormalizedLoaded, - NavigationGuardWithThis, - NavigationHookAfter, RouteLocationResolved, RouteRecordNameGeneric, } from './typed-routes' -import { HistoryState, NavigationType } from './history/common' -import { - getSavedScrollPosition, - getScrollKey, - saveScrollPosition, - computeScrollPosition, - scrollToPosition, - _ScrollPositionNormalized, -} from './scrollBehavior' -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' +import { _ScrollPositionNormalized } from './scrollBehavior' +import { _ErrorListener } from './errors' +import { applyToParams, assign, mergeOptions } from './utils' import { encodeParam, decode, encodeHash } from './encoding' import { normalizeQuery, - parseQuery as originalParseQuery, stringifyQuery as originalStringifyQuery, LocationQuery, + parseQuery, + stringifyQuery, } from './query' -import { shallowRef, nextTick, App, unref, shallowReactive } from 'vue' import { RouteRecordNormalized } from './matcher/types' -import { - parseURL, - stringifyURL, - isSameRouteLocation, - START_LOCATION_NORMALIZED, -} from './location' -import { - extractChangingRecords, - extractComponentsGuards, - guardToPromiseFn, -} from './navigationGuards' +import { parseURL, stringifyURL } from './location' import { warn } from './warning' -import { RouterLink } from './RouterLink' -import { RouterView } from './RouterView' -import { - routeLocationKey, - routerKey, - routerViewLocationKey, -} from './injectionSymbols' -import { addDevtools } from './devtools' import { _LiteralUnion } from './types/utils' import { + EXPERIMENTAL_RouteRecordNormalized, + EXPERIMENTAL_RouteRecordRaw, EXPERIMENTAL_RouterOptions_Base, EXPERIMENTAL_Router_Base, _OnReadyCallback, + experimental_createRouter, } from './experimental/router' +import { createCompiledMatcher } from './new-route-resolver' +import { + NEW_RouterResolver, + NEW_MatcherRecordRaw, +} from './new-route-resolver/resolver' +import { + checkChildMissingNameWithEmptyPath, + normalizeRecordProps, + normalizeRouteRecord, + PathParserOptions, +} from './matcher' +import { PATH_PARSER_OPTIONS_DEFAULTS } from './matcher/pathParserRanker' +import { + createRouteRecordMatcher, + NEW_createRouteRecordMatcher, +} from './matcher/pathMatcher' /** * Options to initialize a {@link Router} instance. @@ -94,35 +72,229 @@ export interface Router readonly options: RouterOptions } +/* + * Normalizes a RouteRecordRaw. Creates a copy + * + * @param record + * @returns the normalized version + */ +export function NEW_normalizeRouteRecord( + record: RouteRecordRaw & { aliasOf?: RouteRecordNormalized }, + parent?: RouteRecordNormalized +): RouteRecordNormalized { + let { path } = record + // Build up the path for nested routes if the child isn't an absolute + // route. Only add the / delimiter if the child path isn't empty and if the + // parent path doesn't have a trailing slash + if (parent && path[0] !== '/') { + const parentPath = parent.path + const connectingSlash = parentPath[parentPath.length - 1] === '/' ? '' : '/' + path = parentPath + (path && connectingSlash + path) + } + + const normalized: Omit = { + path, + redirect: record.redirect, + name: record.name, + meta: record.meta || {}, + aliasOf: record.aliasOf, + beforeEnter: record.beforeEnter, + props: normalizeRecordProps(record), + // TODO: normalize children here or outside? + children: record.children || [], + instances: {}, + leaveGuards: new Set(), + updateGuards: new Set(), + enterCallbacks: {}, + // must be declared afterwards + // mods: {}, + components: + 'components' in record + ? record.components || null + : record.component && { default: record.component }, + } + + // mods contain modules and shouldn't be copied, + // logged or anything. It's just used for internal + // advanced use cases like data loaders + Object.defineProperty(normalized, 'mods', { + value: {}, + }) + + return normalized as RouteRecordNormalized +} + +export function compileRouteRecord( + record: RouteRecordRaw, + parent?: RouteRecordNormalized, + originalRecord?: EXPERIMENTAL_RouteRecordNormalized +): EXPERIMENTAL_RouteRecordRaw { + // used later on to remove by name + const isRootAdd = !originalRecord + const options: PathParserOptions = mergeOptions( + PATH_PARSER_OPTIONS_DEFAULTS, + record + ) + const mainNormalizedRecord = NEW_normalizeRouteRecord(record, parent) + const recordMatcher = NEW_createRouteRecordMatcher( + mainNormalizedRecord, + // FIXME: is this needed? + // @ts-expect-error: the parent is the record not the matcher + parent, + options + ) + + recordMatcher.record + + if (__DEV__) { + // TODO: + // checkChildMissingNameWithEmptyPath(mainNormalizedRecord, parent) + } + // we might be the child of an alias + // mainNormalizedRecord.aliasOf = originalRecord + // generate an array of records to correctly handle aliases + const normalizedRecords: EXPERIMENTAL_RouteRecordNormalized[] = [ + mainNormalizedRecord, + ] + + if ('alias' in record) { + const aliases = + typeof record.alias === 'string' ? [record.alias] : record.alias! + for (const alias of aliases) { + normalizedRecords.push( + // we need to normalize again to ensure the `mods` property + // being non enumerable + NEW_normalizeRouteRecord( + assign({}, mainNormalizedRecord, { + // this allows us to hold a copy of the `components` option + // so that async components cache is hold on the original record + components: originalRecord + ? originalRecord.record.components + : mainNormalizedRecord.components, + path: alias, + // we might be the child of an alias + aliasOf: originalRecord + ? originalRecord.record + : mainNormalizedRecord, + // the aliases are always of the same kind as the original since they + // are defined on the same record + }) + ) + ) + } + } + + let matcher: RouteRecordMatcher + let originalMatcher: RouteRecordMatcher | undefined + + for (const normalizedRecord of normalizedRecords) { + const { path } = normalizedRecord + // Build up the path for nested routes if the child isn't an absolute + // route. Only add the / delimiter if the child path isn't empty and if the + // parent path doesn't have a trailing slash + if (parent && path[0] !== '/') { + const parentPath = parent.record.path + const connectingSlash = + parentPath[parentPath.length - 1] === '/' ? '' : '/' + normalizedRecord.path = + parent.record.path + (path && connectingSlash + path) + } + + if (__DEV__ && normalizedRecord.path === '*') { + throw new Error( + 'Catch all routes ("*") must now be defined using a param with a custom regexp.\n' + + 'See more at https://router.vuejs.org/guide/migration/#Removed-star-or-catch-all-routes.' + ) + } + + // create the object beforehand, so it can be passed to children + matcher = createRouteRecordMatcher(normalizedRecord, parent, options) + + if (__DEV__ && parent && path[0] === '/') + checkMissingParamsInAbsolutePath(matcher, parent) + + // if we are an alias we must tell the original record that we exist, + // so we can be removed + if (originalRecord) { + originalRecord.alias.push(matcher) + if (__DEV__) { + checkSameParams(originalRecord, matcher) + } + } else { + // otherwise, the first record is the original and others are aliases + originalMatcher = originalMatcher || matcher + if (originalMatcher !== matcher) originalMatcher.alias.push(matcher) + + // remove the route if named and only for the top record (avoid in nested calls) + // this works because the original record is the first one + if (isRootAdd && record.name && !isAliasRecord(matcher)) { + if (__DEV__) { + checkSameNameAsAncestor(record, parent) + } + removeRoute(record.name) + } + } + + // Avoid adding a record that doesn't display anything. This allows passing through records without a component to + // not be reached and pass through the catch all route + if (isMatchable(matcher)) { + insertMatcher(matcher) + } + + if (mainNormalizedRecord.children) { + const children = mainNormalizedRecord.children + for (let i = 0; i < children.length; i++) { + addRoute( + children[i], + matcher, + originalRecord && originalRecord.children[i] + ) + } + } + + // if there was no original record, then the first one was not an alias and all + // other aliases (if any) need to reference this record when adding children + originalRecord = originalRecord || matcher + + // TODO: add normalized records for more flexibility + // if (parent && isAliasRecord(originalRecord)) { + // parent.children.push(originalRecord) + // } + } + + return originalMatcher + ? () => { + // since other matchers are aliases, they should be removed by the original matcher + removeRoute(originalMatcher!) + } + : noop + return { + name: record.name, + children: record.children?.map(child => compileRouteRecord(child, record)), + } +} + /** * Creates a Router instance that can be used by a Vue app. * * @param options - {@link RouterOptions} */ export function createRouter(options: RouterOptions): Router { - const matcher = createRouterMatcher(options.routes, options) - const parseQuery = options.parseQuery || originalParseQuery - const stringifyQuery = options.stringifyQuery || originalStringifyQuery - const routerHistory = options.history - 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 + const matcher = createCompiledMatcher( + options.routes.map(record => compileRouteRecord(record)) ) - 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 router = experimental_createRouter({ + matcher, + ...options, + // avoids adding the routes twice + routes: [], + }) + return router +} + +export function _createRouter(options: RouterOptions): Router { const normalizeParams = applyToParams.bind( null, paramValue => '' + paramValue @@ -165,14 +337,6 @@ export function createRouter(options: RouterOptions): Router { } } - function getRoutes() { - return matcher.getRoutes().map(routeMatcher => routeMatcher.record) - } - - function hasRoute(name: NonNullable): boolean { - return !!matcher.getRecordMatcher(name) - } - function resolve( rawLocation: RouteLocationRaw, currentLocation?: RouteLocationNormalizedLoaded @@ -314,734 +478,4 @@ export function createRouter(options: RouterOptions): Router { } ) } - - 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 } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy