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
5 changes: 3 additions & 2 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ import { isReactLargeShellError } from './react-large-shell-error'
import type { GlobalErrorComponent } from '../../client/components/builtin/global-error'
import { normalizeConventionFilePath } from './segment-explorer-path'
import { getRequestMeta } from '../request-meta'
import { getPathnameFromUrl } from '../lib/url-string-utils'
import {
getDynamicParam,
interpolateParallelRouteParams,
Expand Down Expand Up @@ -2117,7 +2118,7 @@ async function renderToHTMLOrFlightImpl(

if (process.env.__NEXT_DEV_SERVER && setIsrStatus && !cacheComponents) {
// Reset the ISR status at start of request.
const { pathname } = new URL(req.url || '/', 'http://n')
const pathname = getPathnameFromUrl(req.url)
setIsrStatus(
pathname,
// Only pages using the Node runtime can use ISR, Edge is always dynamic.
Expand Down Expand Up @@ -2396,7 +2397,7 @@ async function renderToHTMLOrFlightImpl(
isNodeNextRequest(req)
) {
req.originalRequest.on('end', () => {
const { pathname } = new URL(req.url || '/', 'http://n')
const pathname = getPathnameFromUrl(req.url)
const isStatic = !requestStore.usedDynamic && !workStore.forceDynamic
setIsrStatus(pathname, isStatic)
})
Expand Down
16 changes: 9 additions & 7 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ import { shouldServeStreamingMetadata } from './lib/streaming-metadata'
import { decodeQueryPathParameter } from './lib/decode-query-path-parameter'
import { NoFallbackError } from '../shared/lib/no-fallback-error.external'
import { fixMojibake } from './lib/fix-mojibake'
import {
getPathnameFromUrl,
getQueryParamFromUrl,
} from './lib/url-string-utils'
import { computeCacheBustingSearchParam } from '../shared/lib/router/utils/cache-busting-search-param'
import { setCacheBustingSearchParamWithHash } from '../client/components/router-reducer/set-cache-busting-search-param'
import type { CacheControl } from './lib/cache-control'
Expand Down Expand Up @@ -1514,9 +1518,9 @@ export default abstract class Server<
return this.renderError(err, req, res, '/_error', parsedUrl.query)
}

const parsedMatchedPath = new URL(invokePath || '/', 'http://n')
const matchedPathname = getPathnameFromUrl(invokePath)
const invokePathnameInfo = getNextPathnameInfo(
parsedMatchedPath.pathname,
matchedPathname,
{
nextConfig: this.nextConfig,
parseData: false,
Expand All @@ -1527,8 +1531,8 @@ export default abstract class Server<
addRequestMeta(req, 'locale', invokePathnameInfo.locale)
}

if (parsedUrl.pathname !== parsedMatchedPath.pathname) {
parsedUrl.pathname = parsedMatchedPath.pathname
if (parsedUrl.pathname !== matchedPathname) {
parsedUrl.pathname = matchedPathname
addRequestMeta(req, 'rewrittenPathname', invokePathnameInfo.pathname)
}
const normalizeResult = normalizeLocalePath(
Expand Down Expand Up @@ -2090,9 +2094,7 @@ export default abstract class Server<
)
const actualHash =
getRequestMeta(req, 'cacheBustingSearchParam') ??
new URL(req.url || '', 'http://localhost').searchParams.get(
NEXT_RSC_UNION_QUERY
)
getQueryParamFromUrl(req.url, NEXT_RSC_UNION_QUERY)

if (expectedHash !== actualHash) {
// The hash sent by the client does not match the expected value.
Expand Down
106 changes: 106 additions & 0 deletions packages/next/src/server/lib/url-string-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { getPathnameFromUrl, getQueryParamFromUrl } from './url-string-utils'

describe('getPathnameFromUrl', () => {
it('returns pathname from a simple path', () => {
expect(getPathnameFromUrl('/foo/bar')).toBe('/foo/bar')
})

it('strips query string', () => {
expect(getPathnameFromUrl('/foo?a=1&b=2')).toBe('/foo')
})

it('strips hash fragment', () => {
expect(getPathnameFromUrl('/foo#section')).toBe('/foo')
})

it('strips both query and hash', () => {
expect(getPathnameFromUrl('/foo?a=1#section')).toBe('/foo')
})

it('handles hash before query', () => {
expect(getPathnameFromUrl('/foo#section?a=1')).toBe('/foo')
})

it('returns "/" for empty string', () => {
expect(getPathnameFromUrl('')).toBe('/')
})

it('returns "/" for undefined', () => {
expect(getPathnameFromUrl(undefined)).toBe('/')
})

it('returns "/" for root path', () => {
expect(getPathnameFromUrl('/')).toBe('/')
})

it('handles root with query', () => {
expect(getPathnameFromUrl('/?_rsc=abc')).toBe('/')
})
})

describe('getQueryParamFromUrl', () => {
it('returns the value of a query parameter', () => {
expect(getQueryParamFromUrl('/path?_rsc=abc123', '_rsc')).toBe('abc123')
})

it('returns the first occurrence when param appears multiple times', () => {
expect(getQueryParamFromUrl('/path?a=1&a=2', 'a')).toBe('1')
})

it('returns null when param is absent', () => {
expect(getQueryParamFromUrl('/path?other=1', '_rsc')).toBeNull()
})

it('returns null when there is no query string', () => {
expect(getQueryParamFromUrl('/path', '_rsc')).toBeNull()
})

it('returns null for undefined url', () => {
expect(getQueryParamFromUrl(undefined, '_rsc')).toBeNull()
})

it('returns empty string for empty value', () => {
expect(getQueryParamFromUrl('/path?_rsc=', '_rsc')).toBe('')
})

it('handles param at the end of query string', () => {
expect(getQueryParamFromUrl('/path?a=1&_rsc=xyz', '_rsc')).toBe('xyz')
})

it('does not match partial param names', () => {
// "x_rsc=bad" should not match "_rsc"
expect(getQueryParamFromUrl('/path?x_rsc=bad&_rsc=good', '_rsc')).toBe(
'good'
)
})

it('decodes percent-encoded values', () => {
expect(getQueryParamFromUrl('/path?q=hello%20world', 'q')).toBe(
'hello world'
)
})

it('stops at hash fragment', () => {
expect(getQueryParamFromUrl('/path?a=1#hash', 'a')).toBe('1')
})

it('returns empty string for value-less param (no =)', () => {
expect(getQueryParamFromUrl('/path?_rsc', '_rsc')).toBe('')
})

it('returns empty string for value-less param in the middle', () => {
expect(getQueryParamFromUrl('/path?a=1&_rsc&b=2', '_rsc')).toBe('')
})

it('returns empty string for value-less param at the end', () => {
expect(getQueryParamFromUrl('/path?a=1&_rsc', '_rsc')).toBe('')
})

it('returns empty string for value-less param before hash', () => {
expect(getQueryParamFromUrl('/path?_rsc#hash', '_rsc')).toBe('')
})

it('does not match partial param names for value-less params', () => {
expect(getQueryParamFromUrl('/path?x_rsc', '_rsc')).toBeNull()
})
})
99 changes: 99 additions & 0 deletions packages/next/src/server/lib/url-string-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Lightweight URL string utilities that avoid `new URL()` construction.
*
* In hot server paths, `new URL(req.url, 'http://n')` shows up as significant
* self-time because it must parse a full spec-compliant URL. When we only need
* the pathname or a single query-parameter from a *relative* URL such as
* `/path?query=1`, simple string operations are 5-10x cheaper.
*
* Reference: https://tanstack.com/blog/tanstack-start-5x-faster-ssr
*/

/**
* Extract the pathname from a relative URL string (e.g. `/path?q=1` -> `/path`).
* Returns `'/'` for empty/undefined input.
*/
export function getPathnameFromUrl(url: string | undefined): string {
if (!url) return '/'
const qIdx = url.indexOf('?')
const hIdx = url.indexOf('#')
const end =
qIdx >= 0
? hIdx >= 0
? Math.min(qIdx, hIdx)
: qIdx
: hIdx >= 0
? hIdx
: url.length
return url.substring(0, end) || '/'
}

/**
* Get the value of a single query parameter from a URL string without
* constructing a URL or URLSearchParams object.
*
* Only returns the *first* occurrence. Returns `null` when the param is absent.
* Value-less parameters (e.g. `?_rsc` without `=`) return `''`, matching
* URLSearchParams behavior.
*/
export function getQueryParamFromUrl(
url: string | undefined,
param: string
): string | null {
if (!url) return null
const qIdx = url.indexOf('?')
if (qIdx < 0) return null

const search = url.substring(qIdx + 1)
const target = param + '='

// Walk through the search string looking for the param with a value (param=value)
let start = 0
while (start <= search.length) {
const idx = search.indexOf(target, start)
if (idx < 0) break

// Make sure we matched at a parameter boundary (start of string or after '&')
if (idx === 0 || search.charCodeAt(idx - 1) === 38 /* '&' */) {
const valueStart = idx + target.length
const ampIdx = search.indexOf('&', valueStart)
const hashIdx = search.indexOf('#', valueStart)
const end =
ampIdx >= 0
? hashIdx >= 0
? Math.min(ampIdx, hashIdx)
: ampIdx
: hashIdx >= 0
? hashIdx
: search.length
return decodeURIComponent(search.substring(valueStart, end))
}

// Not at a boundary, keep searching
start = idx + 1
}

// Check for value-less parameter (e.g. `?_rsc` or `?a&_rsc&b`)
// This matches URLSearchParams behavior which returns '' for value-less params
start = 0
while (start <= search.length) {
const idx = search.indexOf(param, start)
if (idx < 0) return null

if (idx === 0 || search.charCodeAt(idx - 1) === 38 /* '&' */) {
const afterParam = idx + param.length
// Must be at end of search, followed by '&', or followed by '#'
if (
afterParam === search.length ||
search.charCodeAt(afterParam) === 38 /* '&' */ ||
search.charCodeAt(afterParam) === 35 /* '#' */
) {
return ''
}
}

start = idx + 1
}

return null
}
Loading