Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
86 changes: 86 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,86 @@
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')
})
})
75 changes: 75 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,75 @@
/**
* 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.
*/
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
let start = 0
while (start <= search.length) {
const idx = search.indexOf(target, start)
if (idx < 0) return null

// 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
}

return null
}
Loading