]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
fix: handle splat params
authorEduardo San Martin Morote <posva13@gmail.com>
Sun, 17 Aug 2025 12:40:42 +0000 (14:40 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Sun, 17 Aug 2025 12:40:42 +0000 (14:40 +0200)
packages/router/src/encoding.ts
packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts
packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts

index e866dcba6a7c3f052658620e6cfc52c22c909800..fc02a7c05103e13d5c83b061a83755979eabb583 100644 (file)
@@ -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')
 }
 
 /**
index 2e4d491f3beac695f6c06efe80ea06727214f5d2..e445e522e031320958fa1ad2b4cd717701846c09 100644 (file)
@@ -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,
index bc5e640b53ad83a3dc7935b96f5c0a559ffc36d7..e2dcacda0472dd868bfa70bbc3763781ffae1b39 100644 (file)
@@ -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<TOut, TIn> {
+  /**
+   * 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<string, MatcherPatternPathDynamic_ParamOptions<any, any>>,
-    // 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<string | number | Array<string | number>>
   ) {
     this.paramsKeys = Object.keys(this.params) as Array<keyof TParamsOptions>
@@ -174,22 +176,30 @@ export class MatcherPatternPathDynamic<
 
   build(params: ExtractParamTypeFromOptions<TParamsOptions>): string {
     let paramIndex = 0
-    return (
+    let paramName: keyof TParamsOptions
+    let paramOptions: (TParamsOptions &
+      Record<
+        string,
+        MatcherPatternPathDynamic_ParamOptions<any, any>
+      >)[keyof TParamsOptions]
+    let lastParamPart: number | undefined
+    let value: ReturnType<NonNullable<ParamParser['set']>> | 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<NonNullable<ParamParser['set']>> = (
-              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<NonNullable<ParamParser['set']>> = (
-                  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
   }
 }