diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 13897ef2a..f915611d7 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -185,7 +185,12 @@ function validateDiscoveredParams( function validatePagesStaticPathsEntry(entry: StaticPathsEntry, pattern: string): StaticPathsEntry { if (typeof entry === "string") { - if (entry.includes("?") || entry.includes("#")) { + if ( + !entry.startsWith("/") || + entry.includes("//") || + entry.includes("?") || + entry.includes("#") + ) { throw new Error( `The provided path \`${entry}\` from getStaticPaths does not match the route pattern \`${pattern}\`.`, ); @@ -381,10 +386,12 @@ async function collectPagesPaths(options: { const validatedItem = validatePagesStaticPathsEntry(item, route.pattern); let itemToNormalize = validatedItem; let locale = options.i18n?.defaultLocale; + let explicitLocalePrefix: string | undefined; if (options.i18n && typeof validatedItem === "string") { const localeInfo = extractPagesStaticPathLocale(validatedItem, options.i18n); itemToNormalize = localeInfo.url; locale = localeInfo.locale; + explicitLocalePrefix = localeInfo.explicitLocalePrefix; } else if ( options.i18n && validatedItem && @@ -404,7 +411,11 @@ async function collectPagesPaths(options: { throw new Error(normalized.error); } const pathname = buildUrlFromParams(route.pattern, normalized.params); - addPath(paths, seen, localizePagesPath(pathname, locale, options.i18n)); + addPath( + paths, + seen, + localizePagesPath(pathname, locale, options.i18n, explicitLocalePrefix), + ); } } catch (error) { throwDiscoveryFailure(route.pattern, error); @@ -418,7 +429,11 @@ function localizePagesPath( pathname: string, locale: string | undefined, i18n: ResolvedNextConfig["i18n"], + explicitLocalePrefix?: string, ): string { + if (explicitLocalePrefix) { + return pathname === "/" ? `/${explicitLocalePrefix}` : `/${explicitLocalePrefix}${pathname}`; + } if (!i18n || !locale || locale === i18n.defaultLocale) return pathname; return pathname === "/" ? `/${locale}` : `/${locale}${pathname}`; } @@ -426,7 +441,7 @@ function localizePagesPath( function extractPagesStaticPathLocale( url: string, i18n: NonNullable, -): { locale: string; url: string } { +): { explicitLocalePrefix?: string; locale: string; url: string } { const queryIndex = url.indexOf("?"); const pathname = queryIndex === -1 ? url : url.slice(0, queryIndex); const query = queryIndex === -1 ? "" : url.slice(queryIndex); @@ -437,7 +452,7 @@ function extractPagesStaticPathLocale( : undefined; if (!locale) return extractLocaleFromUrl(url, i18n); const rest = `/${parts.slice(1).join("/")}`; - return { locale, url: `${rest || "/"}${query}` }; + return { explicitLocalePrefix: parts[0], locale, url: `${rest || "/"}${query}` }; } async function collectAppPaths(options: { @@ -473,13 +488,16 @@ async function collectAppPaths(options: { if (!Array.isArray(value)) { throw new Error(`generateStaticParams must return an array for ${pattern}.`); } - return value.map((entry) => - validateDiscoveredParams( + return value.map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`generateStaticParams must return parameter objects for ${pattern}.`); + } + return validateDiscoveredParams( { ...params, ...(entry as Record) }, pattern, "generateStaticParams", - ), - ); + ); + }); })(); void request.catch(() => staticParamsCache.delete(cacheKey)); staticParamsCache.set(cacheKey, request); diff --git a/packages/vinext/src/server/app-prerender-static-params.ts b/packages/vinext/src/server/app-prerender-static-params.ts index 62e444a20..3cef23b80 100644 --- a/packages/vinext/src/server/app-prerender-static-params.ts +++ b/packages/vinext/src/server/app-prerender-static-params.ts @@ -35,7 +35,9 @@ function isLazyStaticParamsSource(value: unknown): value is LazyStaticParamsSour } function isRootParams(value: unknown): value is RootParams { - return isUnknownRecord(value); + if (!isUnknownRecord(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } /** diff --git a/tests/app-prerender-static-params.test.ts b/tests/app-prerender-static-params.test.ts index 0db8e7976..66be2d5ce 100644 --- a/tests/app-prerender-static-params.test.ts +++ b/tests/app-prerender-static-params.test.ts @@ -82,6 +82,11 @@ describe("createAppPrerenderStaticParamsResolver", () => { await expect(nonObjectEntry!({ params: {} })).rejects.toThrow( "generateStaticParams must return an array of objects", ); + + const nonPlainEntry = createAppPrerenderStaticParamsResolver([() => [new Date(0)]]); + await expect(nonPlainEntry!({ params: {} })).rejects.toThrow( + "generateStaticParams must return an array of objects", + ); } finally { if (previous === undefined) delete process.env.__VINEXT_PRERENDER_PATH_DISCOVERY; else process.env.__VINEXT_PRERENDER_PATH_DISCOVERY = previous; diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index b1bc41e2e..5396313e5 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -699,6 +699,7 @@ describe("prerender path manifest", () => { { params: { slug: "bonjour" }, locale: "fr" }, "/fr/posts/string-fr", "/FR/posts/string-fr-upper", + "/en/posts/string-en-explicit", "/posts/string-en", ], }); @@ -729,7 +730,8 @@ describe("prerender path manifest", () => { "/posts/hello", "/fr/posts/bonjour", "/fr/posts/string-fr", - "/fr/posts/string-fr-upper", + "/FR/posts/string-fr-upper", + "/en/posts/string-en-explicit", "/posts/string-en", ]); expect(manifest?.pagesPaths).toEqual(manifest?.paths); @@ -743,7 +745,8 @@ describe("prerender path manifest", () => { "/docs/posts/hello/", "/docs/fr/posts/bonjour/", "/docs/fr/posts/string-fr/", - "/docs/fr/posts/string-fr-upper/", + "/docs/FR/posts/string-fr-upper/", + "/docs/en/posts/string-en-explicit/", "/docs/posts/string-en/", ]); }); @@ -802,6 +805,8 @@ describe("prerender path manifest", () => { ["an array dynamic param", "/posts/[slug].tsx", { params: { slug: ["a", "b"] } }], ["a scalar catch-all param", "/docs/[...parts].tsx", { params: { parts: "a" } }], ["a query-bearing string path", "/posts/[slug].tsx", "/posts/query?x=1"], + ["a relative string path", "/posts/[slug].tsx", "posts/x"], + ["a double-slash string path", "/posts/[slug].tsx", "/posts//x"], ])("fails path discovery for getStaticPaths entry with %s", async (_name, file, entry) => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n");