diff --git a/src/domain/javascript/javascriptRoutePaths.ts b/src/domain/javascript/javascriptRoutePaths.ts index 5aad7623c8..396207b2d1 100644 --- a/src/domain/javascript/javascriptRoutePaths.ts +++ b/src/domain/javascript/javascriptRoutePaths.ts @@ -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: diff --git a/src/domain/javascript/javascriptRouteScan.test.ts b/src/domain/javascript/javascriptRouteScan.test.ts index ffa11558b4..82c8127c5e 100644 --- a/src/domain/javascript/javascriptRouteScan.test.ts +++ b/src/domain/javascript/javascriptRouteScan.test.ts @@ -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);",