From 2d1abf51c388090197b0b63a1e314fc3939bffc6 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Sun, 17 Aug 2025 14:40:42 +0200 Subject: [PATCH] fix: handle splat params --- packages/router/src/encoding.ts | 16 +++--- .../matchers/matcher-pattern.spec.ts | 25 ++++++++++ .../matchers/matcher-pattern.ts | 49 ++++++++++++------- 3 files changed, 66 insertions(+), 24 deletions(-) diff --git a/packages/router/src/encoding.ts b/packages/router/src/encoding.ts index e866dcba..fc02a7c0 100644 --- a/packages/router/src/encoding.ts +++ b/packages/router/src/encoding.ts @@ -58,11 +58,13 @@ const ENC_SPACE_RE = /%20/g // } * @param text - string to encode * @returns encoded string */ -export function commonEncode(text: string | number): string { - return encodeURI('' + text) - .replace(ENC_PIPE_RE, '|') - .replace(ENC_BRACKET_OPEN_RE, '[') - .replace(ENC_BRACKET_CLOSE_RE, ']') +export function commonEncode(text: string | number | null | undefined): string { + return text == null + ? '' + : encodeURI('' + text) + .replace(ENC_PIPE_RE, '|') + .replace(ENC_BRACKET_OPEN_RE, '[') + .replace(ENC_BRACKET_CLOSE_RE, ']') } /** @@ -115,7 +117,7 @@ export function encodeQueryKey(text: string | number): string { * @param text - string to encode * @returns encoded string */ -export function encodePath(text: string | number): string { +export function encodePath(text: string | number | null | undefined): string { return commonEncode(text).replace(HASH_RE, '%23').replace(IM_RE, '%3F') } @@ -129,7 +131,7 @@ export function encodePath(text: string | number): string { * @returns encoded string */ export function encodeParam(text: string | number | null | undefined): string { - return text == null ? '' : encodePath(text).replace(SLASH_RE, '%2F') + return encodePath(text).replace(SLASH_RE, '%2F') } /** diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts index 2e4d491f..e445e522 100644 --- a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts @@ -155,8 +155,10 @@ describe('MatcherPatternPathCustom', () => { expect(pattern.match('/teams/123/b')).toEqual({ teamId: '123' }) expect(() => pattern.match('/teams/123/c')).toThrow() expect(() => pattern.match('/teams/123/b/c')).toThrow() + expect(() => pattern.match('/teams//b')).toThrow() expect(pattern.build({ teamId: '123' })).toBe('/teams/123/b') expect(pattern.build({ teamId: null })).toBe('/teams/b') + expect(pattern.build({ teamId: '' })).toBe('/teams/b') }) it('repeatable param', () => { @@ -178,6 +180,29 @@ describe('MatcherPatternPathCustom', () => { expect(pattern.build({ teamId: ['123', '456'] })).toBe('/teams/123/456/b') }) + it('catch all route', () => { + // const pattern = new MatcherPatternPathDynamic( + }) + + it('splat params with prefix', () => { + const pattern = new MatcherPatternPathDynamic( + /^\/teams\/(.*)$/i, + { + pathMatch: {}, + }, + ['teams', 1] + ) + expect(pattern.match('/teams/')).toEqual({ pathMatch: '' }) + expect(pattern.match('/teams/123/b')).toEqual({ pathMatch: '123/b' }) + expect(() => pattern.match('/teams')).toThrow() + expect(() => pattern.match('/teamso/123/c')).toThrow() + + expect(pattern.build({ pathMatch: null })).toBe('/teams/') + expect(pattern.build({ pathMatch: '' })).toBe('/teams/') + expect(pattern.build({ pathMatch: '124' })).toBe('/teams/124') + expect(pattern.build({ pathMatch: '124/b' })).toBe('/teams/124/b') + }) + it('repeatable optional param', () => { const pattern = new MatcherPatternPathDynamic( /^\/teams(?:\/(.+?))?\/b$/i, diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts index bc5e640b..e2dcacda 100644 --- a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts @@ -1,5 +1,5 @@ import { identityFn } from '../../../utils' -import { decode, encodeParam } from '../../../encoding' +import { decode, encodeParam, encodePath } from '../../../encoding' import { warn } from '../../../warning' import { miss } from './errors' import { ParamParser } from './param-parsers/types' @@ -92,7 +92,11 @@ export interface MatcherPatternPathDynamic_ParamOptions< TIn extends string | string[] | null = string | string[] | null, TOut = string | string[] | null, > extends ParamParser { + /** + * Is tha param a repeatable param and should be converted to an array + */ repeat?: boolean + // NOTE: not needed because in the regexp, the value is undefined if // the group is optional and not given // optional?: boolean @@ -133,9 +137,7 @@ export class MatcherPatternPathDynamic< // otherwise, we need to use a factory function: https://github.com/microsoft/TypeScript/issues/40451 readonly params: TParamsOptions & Record>, - // A better version could be using all the parts to join them - // .e.g ['users', 0, 'profile', 1] -> /users/123/profile/456 - // numbers are indexes of the params in the params object keys + // 0 means a regular param, 1 means a splat, the order comes from the keys in params readonly pathParts: Array> ) { this.paramsKeys = Object.keys(this.params) as Array @@ -174,22 +176,30 @@ export class MatcherPatternPathDynamic< build(params: ExtractParamTypeFromOptions): string { let paramIndex = 0 - return ( + let paramName: keyof TParamsOptions + let paramOptions: (TParamsOptions & + Record< + string, + MatcherPatternPathDynamic_ParamOptions + >)[keyof TParamsOptions] + let lastParamPart: number | undefined + let value: ReturnType> | undefined + const path = '/' + this.pathParts .map(part => { if (typeof part === 'string') { return part } else if (typeof part === 'number') { - const paramName = this.paramsKeys[paramIndex++] - const paramOptions = this.params[paramName] - const value: ReturnType> = ( - paramOptions.set || identityFn - )(params[paramName]) + paramName = this.paramsKeys[paramIndex++] + paramOptions = this.params[paramName] + lastParamPart = part + value = (paramOptions.set || identityFn)(params[paramName]) return Array.isArray(value) ? value.map(encodeParam).join('/') - : encodeParam(value) + : // part == 0 means a regular param, 1 means a splat + (part /* part !== 0 */ ? encodePath : encodeParam)(value) } else { return part .map(subPart => { @@ -197,11 +207,9 @@ export class MatcherPatternPathDynamic< return subPart } - const paramName = this.paramsKeys[paramIndex++] - const paramOptions = this.params[paramName] - const value: ReturnType> = ( - paramOptions.set || identityFn - )(params[paramName]) + paramName = this.paramsKeys[paramIndex++] + paramOptions = this.params[paramName] + value = (paramOptions.set || identityFn)(params[paramName]) return Array.isArray(value) ? value.map(encodeParam).join('/') @@ -212,7 +220,14 @@ export class MatcherPatternPathDynamic< }) .filter(identityFn) // filter out empty values .join('/') - ) + + /** + * If the last part of the path is a splat param and its value is empty, it gets + * filteretd out, resulting in a path that doesn't end with a `/` and doesn't even match + * with the original splat path: e.g. /teams/[...pathMatch] does not match /teams, so it makes + * no sense to build a path it cannot match. + */ + return lastParamPart && !value ? path + '/' : path } } -- 2.47.3