Skip to content

Commit

Permalink
Update route regex for optional catch-all parameters in named regexes (
Browse files Browse the repository at this point in the history
…#14456)

Noticed while working on #14400 that the optional catch-all handling was missing in `namedRegex`.

This whole file also seemed quite regex heavy so I took a look at the overall logic and changed a few things. It worked by regex escaping the whole route then unescape the dynamic parts. I changed it to only regex escape the static parts, this eliminates unnecessary back and forth escaping. It also makes the dynamic parts handling more readable. The whole logic is less reliant on regexes and just uses simple string manipulation to translate the route into a regex, I didn't measure anything but as an effect this should make it more performant.
  • Loading branch information
Janpot authored Jun 22, 2020
1 parent 7078318 commit a7af013
Show file tree
Hide file tree
Showing 2 changed files with 48 additions and 40 deletions.
86 changes: 47 additions & 39 deletions packages/next/next-server/lib/router/utils/route-regex.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
interface Group {
pos: number
repeat: boolean
optional: boolean
}

// this isn't importing the escape-string-regex module
// to reduce bytes
function escapeRegex(str: string) {
return str.replace(/[|\\{}()[\]^$+*?.-]/g, '\\$&')
}

function parseParameter(param: string) {
const optional = /^\\\[.*\\\]$/.test(param)
const optional = param.startsWith('[') && param.endsWith(']')
if (optional) {
param = param.slice(2, -2)
param = param.slice(1, -1)
}
const repeat = /^(\\\.){3}/.test(param)
const repeat = param.startsWith('...')
if (repeat) {
param = param.slice(6)
param = param.slice(3)
}
// Un-escape key
const key = param.replace(/\\([|\\{}()[\]^$+*?.-])/g, '$1')
return { key, repeat, optional }
return { key: param, repeat, optional }
}

export function getRouteRegex(
Expand All @@ -24,56 +28,60 @@ export function getRouteRegex(
re: RegExp
namedRegex?: string
routeKeys?: { [named: string]: string }
groups: {
[groupName: string]: { pos: number; repeat: boolean; optional: boolean }
}
groups: { [groupName: string]: Group }
} {
// Escape all characters that could be considered RegEx
const escapedRoute = escapeRegex(normalizedRoute.replace(/\/$/, '') || '/')
const segments = (normalizedRoute.replace(/\/$/, '') || '/')
.slice(1)
.split('/')

const groups: {
[groupName: string]: { pos: number; repeat: boolean; optional: boolean }
} = {}
const groups: { [groupName: string]: Group } = {}
let groupIndex = 1

const parameterizedRoute = escapedRoute.replace(
/\/\\\[([^/]+?)\\\](?=\/|$)/g,
(_, $1) => {
const { key, optional, repeat } = parseParameter($1)
groups[key] = { pos: groupIndex++, repeat, optional }
return repeat ? (optional ? '(?:/(.+?))?' : '/(.+?)') : '/([^/]+?)'
}
)

let namedParameterizedRoute: string | undefined
const parameterizedRoute = segments
.map((segment) => {
if (segment.startsWith('[') && segment.endsWith(']')) {
const { key, optional, repeat } = parseParameter(segment.slice(1, -1))
groups[key] = { pos: groupIndex++, repeat, optional }
return repeat ? (optional ? '(?:/(.+?))?' : '/(.+?)') : '/([^/]+?)'
} else {
return `/${escapeRegex(segment)}`
}
})
.join('')

// dead code eliminate for browser since it's only needed
// while generating routes-manifest
if (typeof window === 'undefined') {
const routeKeys: { [named: string]: string } = {}

namedParameterizedRoute = escapedRoute.replace(
/\/\\\[([^/]+?)\\\](?=\/|$)/g,
(_, $1) => {
const { key, repeat } = parseParameter($1)
// replace any non-word characters since they can break
// the named regex
const cleanedKey = key.replace(/\W/g, '')
routeKeys[cleanedKey] = key
return repeat ? `/(?<${cleanedKey}>.+?)` : `/(?<${cleanedKey}>[^/]+?)`
}
)
let namedParameterizedRoute = segments
.map((segment) => {
if (segment.startsWith('[') && segment.endsWith(']')) {
const { key, optional, repeat } = parseParameter(segment.slice(1, -1))
// replace any non-word characters since they can break
// the named regex
const cleanedKey = key.replace(/\W/g, '')
routeKeys[cleanedKey] = key
return repeat
? optional
? `(?:/(?<${cleanedKey}>.+?))?`
: `/(?<${cleanedKey}>.+?)`
: `/(?<${cleanedKey}>[^/]+?)`
} else {
return `/${escapeRegex(segment)}`
}
})
.join('')

return {
re: new RegExp('^' + parameterizedRoute + '(?:/)?$', 'i'),
re: new RegExp(`^${parameterizedRoute}(?:/)?$`, 'i'),
groups,
routeKeys,
namedRegex: `^${namedParameterizedRoute}(?:/)?$`,
}
}

return {
re: new RegExp('^' + parameterizedRoute + '(?:/)?$', 'i'),
re: new RegExp(`^${parameterizedRoute}(?:/)?$`, 'i'),
groups,
}
}
2 changes: 1 addition & 1 deletion test/integration/prerender/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -942,7 +942,7 @@ const runTests = (dev = false, isEmulatedServerless = false) => {
{
namedDataRouteRegex: `^/_next/data/${escapeRegex(
buildId
)}/catchall\\-optional/(?<slug>.+?)\\.json$`,
)}/catchall\\-optional(?:/(?<slug>.+?))?\\.json$`,
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(
buildId
Expand Down

0 comments on commit a7af013

Please sign in to comment.