Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions packages/router/src/unplugin/core/tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,141 @@ describe('Tree', () => {
} satisfies Partial<TreePathParam>)
})

describe('path override and params extraction', () => {
it('reproduces missing params from custom path override', () => {
const tree = new PrefixTree(RESOLVED_OPTIONS)
const node = tree.insert('users/profile', 'users/profile.vue')

node.setCustomRouteBlock('users/profile.vue', {
path: '/users/:id',
})

expect(node.pathParams.map(param => param.paramName)).toEqual(['id'])
})

it('preserves parser, optional, repeatable, and splat metadata from custom path override', () => {
const tree = new PrefixTree(RESOLVED_OPTIONS)
const node = tree.insert('x/y', 'x/y.vue')
node.setCustomRouteBlock('x/y.vue', {
path: '/docs/:chapters+/:tags*/:path(.*)',
params: {
path: {
chapters: 'int',
},
},
})

expect(node.pathParams).toEqual([
expect.objectContaining({
paramName: 'chapters',
parser: 'int',
modifier: '+',
optional: false,
repeatable: true,
isSplat: false,
}),
expect.objectContaining({
paramName: 'tags',
modifier: '*',
optional: true,
repeatable: true,
isSplat: false,
}),
expect.objectContaining({
paramName: 'path',
modifier: '',
optional: false,
repeatable: false,
isSplat: true,
}),
])
})

it('uses absolute path overrides as param inheritance boundaries', () => {
const tree = new PrefixTree(RESOLVED_OPTIONS)

const parent = tree.insert('org/[orgId]', 'org/[orgId].vue')
parent.setCustomRouteBlock('org/[orgId].vue', {
params: {
query: {
q: {},
},
},
})

const absoluteChild = tree.insert(
'org/[orgId]/dashboard',
'org/[orgId]/dashboard.vue'
)
absoluteChild.setCustomRouteBlock('org/[orgId]/dashboard.vue', {
path: '/dash/:id',
params: {
query: {
tab: {},
},
},
})

const relativeChild = tree.insert(
'org/[orgId]/reports',
'org/[orgId]/reports.vue'
)
relativeChild.setCustomRouteBlock('org/[orgId]/reports.vue', {
path: ':id',
params: {
query: {
tab: {},
},
},
})

const middle = tree.insert(
'org/[orgId]/projects/[projectId]',
'org/[orgId]/projects/[projectId].vue'
)
middle.setCustomRouteBlock('org/[orgId]/projects/[projectId].vue', {
path: '/p/:projectId',
params: {
query: {
page: {},
},
},
})

const leaf = tree.insert(
'org/[orgId]/projects/[projectId]/settings/[section]',
'org/[orgId]/projects/[projectId]/settings/[section].vue'
)

expect(absoluteChild.pathParams.map(param => param.paramName)).toEqual([
'id',
])
expect(absoluteChild.params.map(param => param.paramName)).toEqual([
'id',
'tab',
])
expect(relativeChild.pathParams.map(param => param.paramName)).toEqual([
'orgId',
'id',
])
expect(relativeChild.params.map(param => param.paramName)).toEqual([
'orgId',
'q',
'id',
'tab',
])
expect(leaf.pathParams.map(param => param.paramName)).toEqual([
'projectId',
'section',
])
expect(leaf.params.map(param => param.paramName)).toEqual([
'projectId',
'page',
'section',
])
})
})

it('removes trailing slash from path but not from name', () => {
const tree = new PrefixTree(RESOLVED_OPTIONS)
tree.insert('a/index', 'a/index.vue')
Expand Down
19 changes: 16 additions & 3 deletions packages/router/src/unplugin/core/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CONVENTION_OVERRIDE_NAME,
createTreeNodeValue,
escapeRegex,
isTreePathParam,
type TreeNodeValueOptions,
type TreePathParam,
type TreeQueryParam,
Expand Down Expand Up @@ -370,10 +371,17 @@ export class TreeNode {
*/
get params(): (TreePathParam | TreeQueryParam)[] {
const params = [...this.value.params]
if (this.value.overrides.path?.startsWith('/')) {
return params
}

let node = this.parent
// add all the params from the parents
while (node) {
params.unshift(...node.value.params)
if (node.value.overrides.path?.startsWith('/')) {
break
}
node = node.parent
}

Expand All @@ -384,12 +392,17 @@ export class TreeNode {
* Array of route params coming from the path. It includes all the params from the parents as well.
*/
get pathParams(): TreePathParam[] {
const params = this.value.isParam() ? [...this.value.pathParams] : []
const params = this.value.params.filter(isTreePathParam)
if (this.value.overrides.path?.startsWith('/')) {
return params
}

let node = this.parent
// add all the params from the parents
while (node) {
if (node.value.isParam()) {
params.unshift(...node.value.pathParams)
params.unshift(...node.value.params.filter(isTreePathParam))
if (node.value.overrides.path?.startsWith('/')) {
break
}
node = node.parent
}
Expand Down
31 changes: 30 additions & 1 deletion packages/router/src/unplugin/core/treeNodeValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,36 @@ class _TreeNodeValueBase {
* does not include params from parent nodes.
*/
get params(): (TreePathParam | TreeQueryParam)[] {
return [...(this.isParam() ? this.pathParams : []), ...this.queryParams]
return [...this.getPathParams(), ...this.queryParams]
}

/**
* Gets all path params for the node, including params defined in path overrides.
*/
getPathParams(): TreePathParam[] {
const overridePath = this.overrides.path

if (!overridePath) {
return this.isParam() ? [...this.pathParams] : []
}

const overrideParsers = this.overrides.params?.path ?? {}
const params: TreePathParam[] = []

for (const segment of overridePath.split('/')) {
if (!segment) continue

const [, segmentParams] = parseRawPathSegment(segment)

for (const param of segmentParams) {
params.push({
...param,
parser: overrideParsers[param.paramName] ?? param.parser,
})
}
}

return params
}

toString(): string {
Expand Down