fix: scope getStaticProps revalidate parsing to the exported function#592
fix: scope getStaticProps revalidate parsing to the exported function#592Boyeep wants to merge 7 commits intocloudflare:mainfrom
Conversation
|
/bigbonk review |
commit: |
There was a problem hiding this comment.
Good fix — the false-positive from matching revalidate: anywhere in the file was a real issue, and scoping the search to the getStaticProps return object is the right approach. The implementation is solid for a regex-based solution, the findMatchingBrace parser handles strings, comments, and nested braces correctly, and the fallback-to-whole-file behavior preserves backward compatibility for snippets without a getStaticProps declaration.
Two issues worth addressing, one medium-severity bug and one minor nit. The rest is clean.
packages/vinext/src/build/report.ts
Outdated
| const returnObjectStart = | ||
| declaration.search(/\breturn\s*\{/) !== -1 | ||
| ? declaration.search(/\breturn\s*\{/) | ||
| : declaration.search(/=>\s*\(\s*\{/); | ||
| if (returnObjectStart === -1) return declaration; |
There was a problem hiding this comment.
Bug (medium): First return wins, but early-return redirects are common.
declaration.search(/\breturn\s*\{/) matches the first return { in the function. If getStaticProps has an early return before the main props return, this extracts the wrong object:
export async function getStaticProps(ctx) {
if (!ctx.params?.slug) {
return { redirect: { destination: '/', permanent: false } }
}
return { props: { data }, revalidate: 60 }
}Here the code extracts { redirect: { destination: '/', permanent: false } }, finds no revalidate: key, and returns null — missing the actual revalidate: 60 on the second return.
This is a common Next.js pattern (early-return redirect or notFound before the happy-path props return).
A simple fix: instead of taking the first return { match, search all return objects for revalidate: and take the first hit. Something like:
| const returnObjectStart = | |
| declaration.search(/\breturn\s*\{/) !== -1 | |
| ? declaration.search(/\breturn\s*\{/) | |
| : declaration.search(/=>\s*\(\s*\{/); | |
| if (returnObjectStart === -1) return declaration; | |
| const returnPattern = /\breturn\s*\{/g; | |
| let match; | |
| while ((match = returnPattern.exec(declaration)) !== null) { | |
| const braceStart = declaration.indexOf("{", match.index); | |
| if (braceStart === -1) continue; | |
| const braceEnd = findMatchingBrace(declaration, braceStart); | |
| if (braceEnd === -1) continue; | |
| const obj = declaration.slice(braceStart, braceEnd + 1); | |
| if (/\brevalidate\s*:/.test(obj)) return obj; | |
| } | |
| // Try arrow function implicit return: => ({ ... }) | |
| const arrowMatch = declaration.search(/=>\s*\(\s*\{/); | |
| if (arrowMatch !== -1) { | |
| const braceStart = declaration.indexOf("{", arrowMatch); | |
| if (braceStart !== -1) { | |
| const braceEnd = findMatchingBrace(declaration, braceStart); | |
| if (braceEnd !== -1) return declaration.slice(braceStart, braceEnd + 1); | |
| } | |
| } | |
| return declaration; |
This searches all return objects and returns the one that actually contains revalidate:, which handles the early-return-redirect pattern correctly.
A test case for this:
it("finds revalidate in second return when first return is a redirect", () => {
const code = `export async function getStaticProps(ctx) {
if (!ctx.params?.slug) {
return { redirect: { destination: '/', permanent: false } }
}
return { props: { data: 1 }, revalidate: 60 }
}`;
expect(extractGetStaticPropsRevalidate(code)).toBe(60);
});| continue; | ||
| } | ||
|
|
||
| if (char === '"' || char === "'" || char === "`") { |
There was a problem hiding this comment.
Nit: The backtick quote tracking works for simple template literals (`hello ${name}`), but nested template literals (`outer ${`inner`}`) would confuse the parser since it treats the inner backtick as closing the outer one.
Not worth fixing for this use case — getStaticProps return objects are almost always plain data, and this is a build report, not a runtime behavior change. Just noting it for the record.
| return { props: {}, revalidate: 60 }; | ||
| }`; | ||
| expect(extractGetStaticPropsRevalidate(code)).toBe(60); | ||
| }); |
There was a problem hiding this comment.
Nice test cases. Consider also adding a test for the early-return redirect pattern mentioned in the other comment — it's a common enough Next.js pattern that it's worth covering even if the fix comes in a follow-up.
|
Review posted. Here's a summary of the feedback: Overall: Good fix that addresses a real false-positive issue. The approach is sound — scoping the regex search to the Issues raised:
|
|
/bigbonk re-review |
There was a problem hiding this comment.
Good fix for a real false-positive issue. The approach — scoping the regex to getStaticProps return objects with a whole-file fallback when no declaration is found — is the right one. The findMatchingBrace parser is solid, test coverage hits the important cases, and the early-return-redirect pattern is handled correctly.
One scoping bug worth fixing before merge (see inline comment), plus a minor suggestion.
packages/vinext/src/build/report.ts
Outdated
| ); | ||
| if (!declarationMatch) return null; | ||
|
|
||
| const declaration = code.slice(declarationMatch.index); |
There was a problem hiding this comment.
Bug: declaration includes everything after getStaticProps, not just its body.
code.slice(declarationMatch.index) captures from the start of getStaticProps to the end of the file. If another function is defined after getStaticProps and its return object happens to contain revalidate:, it gets picked up as a false positive — the exact class of bug this PR is trying to fix.
Example that would still produce a wrong result:
export function getStaticProps() {
return { props: {} }
}
export function someHelper() {
return { revalidate: 999 }
}This would incorrectly return 999.
The fix is straightforward — you already have findMatchingBrace, so use it to find the closing brace of the function body and scope the search:
| const declaration = code.slice(declarationMatch.index); | |
| const fnBodyStart = code.indexOf("{", declarationMatch.index + declarationMatch[0].length); | |
| if (fnBodyStart === -1) return null; | |
| const fnBodyEnd = findMatchingBrace(code, fnBodyStart); | |
| if (fnBodyEnd === -1) return null; | |
| const declaration = code.slice(fnBodyStart, fnBodyEnd + 1); |
This scopes declaration to just the function body of getStaticProps, so return statements in subsequent functions are excluded.
Note: for arrow functions assigned via const getStaticProps = async () => ({ ... }), the { after => may be a block body or the regex may not match the function keyword pattern. But the arrow function path (lines 158-167) already handles that separately, so this should be fine for the function keyword path.
| return { props: { data: 1 }, revalidate: 60 }; | ||
| }`; | ||
| expect(extractGetStaticPropsRevalidate(code)).toBe(60); | ||
| }); |
There was a problem hiding this comment.
Good test cases. Consider adding a test for the scoping issue mentioned in the other comment — a function defined after getStaticProps that contains return { revalidate: ... }:
it("ignores revalidate in a function defined after getStaticProps", () => {
const code = `export function getStaticProps() {
return { props: {} };
}
export function unrelated() {
return { revalidate: 999 };
}`;
expect(extractGetStaticPropsRevalidate(code)).toBeNull();
});This is the symmetric counterpart to the existing "ignores unrelated revalidate values outside getStaticProps" test (which covers values before the declaration). Having both ensures the scoping works in both directions.
| if (returnObjects.length > 0) return returnObjects; | ||
|
|
||
| const arrowMatch = declaration.search(/=>\s*\(\s*\{/); | ||
| if (arrowMatch === -1) return []; |
There was a problem hiding this comment.
Minor: this empty-array return is a bit subtle. When getStaticProps is declared via the function keyword but has no return { ... } statements, returnObjects is empty so we fall through to the arrow function check. If that also fails, we return [] (truthy), which means the caller skips the whole-file fallback and returns null.
The behavior is correct — if we found a getStaticProps declaration, we should scope to it and not fall back. But the distinction between null ("no declaration found, use fallback") and [] ("declaration found but no return objects") is implicit. A brief comment here would help future readers understand the contract:
| if (arrowMatch === -1) return []; | |
| // getStaticProps was found but contains no return objects — return empty | |
| // (non-null signals the caller to skip the whole-file fallback). | |
| const arrowMatch = declaration.search(/=>\s*\(\s*\{/); | |
| if (arrowMatch === -1) return []; |
|
Review posted successfully. Here's a summary of the feedback: Overall: Good fix that addresses a real false-positive bug. The approach is sound — scoping the regex search to Issues raised:
|
|
/bigbonk re-review |
|
I did another hardening pass and pushed follow-up fixes for a few more realistic In short, this now:
I also added regression tests for each of those cases. Focused validation is still green with:
|
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk re-review |
1 similar comment
|
/bigbonk re-review |
There was a problem hiding this comment.
Good fix for a real false-positive bug — scoping the revalidate search to the getStaticProps body is the right approach, and the test coverage is solid. The findMatchingBrace/findMatchingParen parsers are well-implemented, and handling early-return redirects, re-exports, destructured params, and nested helpers are all good additions.
One bug in the nested-body skipping logic that can produce false positives in a specific (but realistic) pattern. Details inline.
packages/vinext/src/build/report.ts
Outdated
| if (matchesKeywordAt(code, i, "function")) { | ||
| const nestedBody = extractFunctionBody(code, i); | ||
| if (nestedBody !== null) { | ||
| i += nestedBody.length - 1; |
There was a problem hiding this comment.
Bug: nested function body skip undershoots — can produce false positives.
extractFunctionBody(code, i) returns code.slice(bodyStart, bodyEnd + 1) — only the body from { to }, starting at bodyStart. But i is at the f of function, which is earlier. i += nestedBody.length - 1 advances by the body's length, not by the distance from i to bodyEnd. Since bodyStart > i, the skip doesn't reach the end of the nested function.
Concrete example that triggers this:
export function getStaticProps() {
function helper(longParam) {
return { revalidate: 999 };
}
return { props: {} };
}When scanning, i lands on function helper. extractFunctionBody returns the body { return { revalidate: 999 }; } (length ~31). i += 30 advances past function helper but lands back inside (longParam) { return { revalidate: 999 }; }, causing the inner return { revalidate: 999 } to be picked up as a top-level return.
The fix: compute the actual end position relative to i.
| i += nestedBody.length - 1; | |
| if (matchesKeywordAt(code, i, "function")) { | |
| const paramsStart = code.indexOf("(", i + "function".length); | |
| if (paramsStart !== -1) { | |
| const paramsEnd = findMatchingParen(code, paramsStart); | |
| if (paramsEnd !== -1) { | |
| const bodyStart = code.indexOf("{", paramsEnd + 1); | |
| if (bodyStart !== -1) { | |
| const bodyEnd = findMatchingBrace(code, bodyStart); | |
| if (bodyEnd !== -1) { | |
| i = bodyEnd; | |
| continue; | |
| } | |
| } | |
| } | |
| } | |
| continue; | |
| } |
Alternatively, you could change extractFunctionBody to return the end index instead of the string, or return both. The same issue affects the class and arrow function skip paths below (lines 287-288, 295-296), though the arrow function gap is smaller and less likely to cause problems in practice.
packages/vinext/src/build/report.ts
Outdated
| if (matchesKeywordAt(code, i, "class")) { | ||
| const classBody = extractClassBody(code, i); | ||
| if (classBody !== null) { | ||
| i += classBody.length - 1; |
There was a problem hiding this comment.
Same skip-undershoot bug as the function case above. extractClassBody(code, i) returns code.slice(bodyStart, bodyEnd + 1) where bodyStart is the { after the class name/extends clause. i is at class, so i += classBody.length - 1 doesn't reach bodyEnd.
Fix the same way — compute the actual end position:
| i += classBody.length - 1; | |
| if (matchesKeywordAt(code, i, "class")) { | |
| const bodyStart = code.indexOf("{", i + "class".length); | |
| if (bodyStart !== -1) { | |
| const bodyEnd = findMatchingBrace(code, bodyStart); | |
| if (bodyEnd !== -1) { | |
| i = bodyEnd; | |
| continue; | |
| } | |
| } | |
| continue; | |
| } |
packages/vinext/src/build/report.ts
Outdated
| if (char === "=" && next === ">") { | ||
| const nestedBody = extractArrowFunctionBody(code, i); | ||
| if (nestedBody !== null) { | ||
| i += nestedBody.length - 1; |
There was a problem hiding this comment.
Same category of bug, though the impact is smaller. i is at = of =>, extractArrowFunctionBody starts from arrowIndex + 2 (the character after >). The gap is only whitespace between > and {, so in practice this is unlikely to cause issues. But for correctness:
| i += nestedBody.length - 1; | |
| const nestedBody = extractArrowFunctionBody(code, i); | |
| if (nestedBody !== null) { | |
| const bodyStart = findNextNonWhitespaceIndex(code, i + 2); | |
| if (bodyStart !== -1) { | |
| i = bodyStart + nestedBody.length - 1; | |
| } | |
| } |
Or inline the logic like the other two paths.
|
|
||
| return { props: {} }; | ||
| }`; | ||
| expect(extractGetStaticPropsRevalidate(code)).toBeNull(); |
There was a problem hiding this comment.
The test passes because the helper uses an arrow function (small gap between => and {). Consider adding a test with a named nested function with parameters to cover the skip bug described in the other comment:
it("ignores revalidate in a nested named function inside getStaticProps", () => {
const code = `export function getStaticProps() {
function helper(longParam) {
return { revalidate: 999 };
}
return { props: {} };
}`;
expect(extractGetStaticPropsRevalidate(code)).toBeNull();
});This would catch the skip-undershoot bug and guard against regressions.
|
Review posted. Here's a summary of the feedback: Overall: Good fix that addresses a real false-positive issue. The approach — scoping the regex search to the Issue raised (one bug, three locations): Bug: nested body skip undershoots in This is exploitable with a nested named function that has parameters: export function getStaticProps() {
function helper(longParam) {
return { revalidate: 999 };
}
return { props: {} };
}The existing test for nested helpers passes only because it uses an arrow function (minimal gap between |
|
/bigbonk re-review |
There was a problem hiding this comment.
Good fix. The false-positive from matching revalidate: anywhere in the file was a real issue, and scoping the search to the getStaticProps body using structured brace-matching is the right approach.
The refactoring from string-length-based skipping to find*BodyEnd functions that return absolute indices resolves the skip-undershoot concern from the earlier reviews — the nested named function test (line 234 in the test file) passes correctly because findFunctionBodyEnd returns the actual } index in the source, and i = nestedBodyEnd jumps directly to it.
Tests are comprehensive and all 72 pass. A few observations inline — one real (though low-probability) scoping issue in extractGetStaticPropsDeclaration, and some minor nits.
| // positive. Rare in practice, but a proper AST-based approach would be more | ||
| // accurate. | ||
| const re = /\brevalidate\s*:\s*(-?\d+(?:\.\d+)?|Infinity|false)\b/; | ||
| const returnObjects = extractGetStaticPropsReturnObjects(code); |
There was a problem hiding this comment.
Nit: re (line 113) is now only used in the fallback path (lines 124-128), but it's declared before the early-return on line 121. Consider inlining the regex at the use site to avoid constructing it when the scoped path handles everything:
const m = /\brevalidate\s*:\s*(-?\d+(?:\.\d+)?|Infinity|false)\b/.exec(code);Minor clarity improvement — the current code works fine.
| const functionExpressionMatch = /(?:async\s+)?function\b/.exec(declarationTail); | ||
| if (functionExpressionMatch) { | ||
| return extractFunctionBody(declarationTail, functionExpressionMatch.index); | ||
| } |
There was a problem hiding this comment.
Low-probability scoping issue: When declarationMatch is a const/let/var declaration (not a function keyword), declarationTail is everything from the match to end-of-file. The regex /(?:async\s+)?function\b/ on line 285 searches the entire tail.
If the actual assignment isn't a function expression — e.g. const getStaticProps = createGSP(options); — this regex will match the next function keyword anywhere later in the file, potentially scoping to an unrelated function body.
Very unlikely for getStaticProps in practice (it's almost always a function declaration, expression, or arrow). But if you want to tighten it, you could check that the matched function appears before any statement-terminating ; or newline after the = sign.
Not a blocker.
| continue; | ||
| } | ||
|
|
||
| const methodBodyEnd = findObjectMethodBodyEnd(code, i); |
There was a problem hiding this comment.
Minor: findObjectMethodBodyEnd is called on every character that doesn't match a prior condition. It does keyword matching, name scanning, and paren/brace matching internally. For this use case (build report, run once per page file at build time) the cost is negligible, but if this parser is ever reused in a hot path, adding a guard like if (isIdentStart(code.charCodeAt(i)) || code[i] === '*') before the call would help.
| expect(extractGetStaticPropsRevalidate(code)).toBeNull(); | ||
| }); | ||
|
|
||
| it("ignores unrelated revalidate values outside getStaticProps", () => { |
There was a problem hiding this comment.
Good test coverage. The existing tests above this block (lines 148-161 — revalidate: 0, false, Infinity) use bare return statements without a getStaticProps wrapper, so they exercise the whole-file fallback path (lines 124-128). That's fine and intentional, but a brief comment noting they test the fallback would help readers understand why they don't wrap in getStaticProps while the new tests do.
|
Review posted successfully. Here's a summary of the feedback: Overall: Good fix that addresses a real false-positive issue. The approach — scoping the regex search to the Key observation: The skip-undershoot bug flagged in earlier reviews has been resolved in this revision. The code now uses Issues raised:
|
This fixes a false positive in
extractGetStaticPropsRevalidate().Previously, the helper could match
revalidate:anywhere in the file, including unrelated config objects outsidegetStaticProps. That could misclassify Pages Router routes in the build report.This change scopes the search to the exported
getStaticPropsreturn object when that function is present.extractGetStaticPropsRevalidate()togetStaticPropsrevalidatevalues outsidegetStaticPropsgetStaticPropsvalue still wins when both are presentpnpm exec vp test run tests/build-report.test.tspnpm exec vp fmt packages/vinext/src/build/report.ts tests/build-report.test.tspnpm exec vp lint packages/vinext/src/build/report.ts tests/build-report.test.ts#Notes
pnpm exec vp checkreports existing repo-wide formatting issues in this checkout, so validation here was kept targeted to the changed files.