fix(javascript): test literals for Next page paths in linear time (#7748)

Every source string literal without an established Next root was tested
against /(?:^|\/)app\/(?:[\s\S]*\/)?page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?:[?#].*)?$/u.
The unanchored search restarted at every app/ segment and rescanned to the
end of the literal, and each page? or page# candidate scanned to the next
line terminator before $ failed. A long literal therefore took quadratic
time on the analysis thread: about 6 s for a 256 KB literal of repeated
app/ segments, and 3.5 s for 224 KB of x/page? followed by a newline.

The first app/ segment admits every later page segment, and . cannot cross
a line terminator, so a query or fragment after the page filename starts on
the last line. Test the tail after the first app/ segment one page segment
at a time instead; results are unchanged.
This commit is contained in:
Dragolone
2026-10-28 22:45:59 +11:00
committed by GitHub
parent 09225b08e1
commit fea4f2e397
2 changed files with 42 additions and 5 deletions
+26 -5
View File
@@ -55,6 +55,31 @@ export type NextAppRoutePath =
| { readonly kind: "known"; readonly path: string }
| { readonly kind: "unknown"; readonly reason: string };
/**
* Test /(?:^|\/)app\/(?:[\s\S]*\/)?page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?:[?#].*)?$/u
* in linear time; every source literal reaches this test. The first `app/`
* segment admits every later page segment, and `.` cannot cross a line
* terminator, so a query or fragment after the page filename starts on the
* last line. Retrying from each `app/` or `?` instead is quadratic.
*/
const isPageShapedPath = (value: string): boolean => {
const app = /(?:^|\/)app\//u.exec(value);
if (app === null) return false;
const tail = value.slice(app.index + app[0].length);
const lastLineStart =
Math.max(
tail.lastIndexOf("\n"),
tail.lastIndexOf("\r"),
tail.lastIndexOf("\u2028"),
tail.lastIndexOf("\u2029"),
) + 1;
for (const page of tail.matchAll(
/(?:^|\/)page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?=[?#]|$)/gu,
))
if (page.index + page[0].length >= lastLineStart) return true;
return false;
};
/** Classify a page filename without treating missing routing context as absence. */
export const classifyNextAppRoutePath = (
raw: string,
@@ -104,11 +129,7 @@ export const classifyNextAppRoutePath = (
}
}
if (relative === undefined) {
if (
/(?:^|\/)app\/(?:[\s\S]*\/)?page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?:[?#].*)?$/u.test(
value,
)
)
if (isPageShapedPath(value))
return {
kind: "unknown",
reason:
@@ -798,6 +798,22 @@ it.each(["app/(.)photo/_private/page.tsx", "app/_private/(.)photo/page.tsx"])(
},
);
it("tests long literals for page-shaped Next paths in linear time", () => {
const classify = (value: string) =>
classifyNextAppRoutePath(value, {
kind: "source-literal",
runtime: "unknown",
})?.kind;
const started = performance.now();
expect(classify("app/".repeat(64_000))).toBeUndefined();
expect(classify(`app/${"x/page?".repeat(32_000)}\nend`)).toBeUndefined();
expect(performance.now() - started).toBeLessThan(1_000);
expect(classify("lib/app/a\nb/page.tsx")).toBe("unknown");
expect(classify("app/page-a\nb.js?v=1")).toBe("unknown");
expect(classify("app/x/page?first\nsecond")).toBeUndefined();
expect(classify("app/x/page.tsx/extra")).toBeUndefined();
});
it("keeps the already-covered aliased binary fragment excluded", () => {
const facts = analyze(
"createBrowserRouter([{path:'/users'}]);const target={pathname:'/users/'+id};router.push(target);",