diff --git a/platform/loader-html-hydrate/src/hydrater.ts b/platform/loader-html-hydrate/src/hydrater.ts index c79707212..1839e62cf 100644 --- a/platform/loader-html-hydrate/src/hydrater.ts +++ b/platform/loader-html-hydrate/src/hydrater.ts @@ -29,6 +29,20 @@ export class PlasmicHtmlHydrater { element.getAttribute("data-plasmic-prefetched-query-data") || "{}" ); + // Read page params data attributes for dynamic route support. + // Note: pageRoute is read but not passed to hydrateFromElement because + // the loader-react package doesn't support it in the hydration flow. + // This means $ctx.params and $ctx.query will work, but $ctx.pageRoute + // and $ctx.pagePath won't be available after hydration. + const _pageRoute = + element.getAttribute("data-plasmic-page-route") || undefined; + const pageParams = JSON.parse( + element.getAttribute("data-plasmic-page-params") || "{}" + ); + const pageQuery = JSON.parse( + element.getAttribute("data-plasmic-page-query") || "{}" + ); + const loader = initPlasmicLoader({ projects: [ { @@ -52,7 +66,10 @@ export class PlasmicHtmlHydrater { globalVariants, componentProps, prefetchedQueryData, - } + // Pass page params if present (for dynamic route support) + ...(Object.keys(pageParams).length > 0 && { pageParams }), + ...(Object.keys(pageQuery).length > 0 && { pageQuery }), + } as any // Type assertion needed - npm package types don't include pageParams/pageQuery ); element.setAttribute("data-plasmic-hydrating", "false"); element.setAttribute("data-plasmic-hydrated", "true"); diff --git a/platform/wab/src/wab/server/loader/gen-html-bundle.ts b/platform/wab/src/wab/server/loader/gen-html-bundle.ts index bd3c013e3..a3eb52af3 100644 --- a/platform/wab/src/wab/server/loader/gen-html-bundle.ts +++ b/platform/wab/src/wab/server/loader/gen-html-bundle.ts @@ -11,6 +11,7 @@ import { } from "@plasmicapp/loader-react"; import React from "react"; import ReactDOMServer from "react-dom/server"; +import { PageParams } from "./page-params"; export async function genLoaderHtmlBundle(opts: { projectId: string; @@ -22,6 +23,9 @@ export async function genLoaderHtmlBundle(opts: { prepass?: boolean; componentProps?: any; globalVariants?: GlobalVariantSpec[]; + pageRoute?: string; + pageParams?: PageParams; + pageQuery?: PageParams; }) { const { projectId, @@ -33,6 +37,9 @@ export async function genLoaderHtmlBundle(opts: { componentProps, globalVariants, prepass, + pageRoute, + pageParams, + pageQuery, } = opts; // Set the data host for data source operations during SSR prepass. @@ -65,7 +72,9 @@ export async function genLoaderHtmlBundle(opts: { prefetchedData: data, componentProps, globalVariants, - } + pageParams, + pageQuery, + } as any // Type assertion needed - npm package types don't include pageParams/pageQuery ) : undefined; @@ -77,7 +86,9 @@ export async function genLoaderHtmlBundle(opts: { componentProps, globalVariants, prefetchedQueryData, - } + pageParams, + pageQuery, + } as any // Type assertion needed - npm package types don't include pageParams/pageQuery ); const outerElement = React.createElement( @@ -97,6 +108,13 @@ export async function genLoaderHtmlBundle(opts: { hydrate && embedHydrate && prepass ? JSON.stringify(prefetchedQueryData || {}) : "", + "data-plasmic-page-route": hydrate ? pageRoute || "" : "", + "data-plasmic-page-params": hydrate + ? JSON.stringify(pageParams || {}) + : "", + "data-plasmic-page-query": hydrate + ? JSON.stringify(pageQuery || {}) + : "", dangerouslySetInnerHTML: { __html: innerHtml }, }), hydrate && diff --git a/platform/wab/src/wab/server/loader/page-params.spec.ts b/platform/wab/src/wab/server/loader/page-params.spec.ts new file mode 100644 index 000000000..de8e2072b --- /dev/null +++ b/platform/wab/src/wab/server/loader/page-params.spec.ts @@ -0,0 +1,196 @@ +import { BadRequestError } from "@/wab/shared/ApiErrors/errors"; +import { + getInvalidParamKeys, + isValidParamsObject, + isValidParamValue, + isValidRoutePattern, + parsePageParams, + parsePageQuery, + parsePageRoute, +} from "./page-params"; + +describe("page-params", () => { + describe("isValidRoutePattern", () => { + it("returns true for valid route patterns", () => { + expect(isValidRoutePattern("/")).toBe(true); + expect(isValidRoutePattern("/products")).toBe(true); + expect(isValidRoutePattern("/products/[slug]")).toBe(true); + expect(isValidRoutePattern("/products/[...catchall]")).toBe(true); + expect(isValidRoutePattern("/a/b/c/[id]")).toBe(true); + }); + + it("returns false for invalid route patterns", () => { + expect(isValidRoutePattern("")).toBe(false); + expect(isValidRoutePattern("products")).toBe(false); + expect(isValidRoutePattern("products/[slug]")).toBe(false); + }); + }); + + describe("isValidParamValue", () => { + it("returns true for valid param values", () => { + expect(isValidParamValue("hello")).toBe(true); + expect(isValidParamValue("")).toBe(true); + expect(isValidParamValue([])).toBe(true); + expect(isValidParamValue(["a", "b", "c"])).toBe(true); + }); + + it("returns false for invalid param values", () => { + expect(isValidParamValue(123)).toBe(false); + expect(isValidParamValue(null)).toBe(false); + expect(isValidParamValue(undefined)).toBe(false); + expect(isValidParamValue({})).toBe(false); + expect(isValidParamValue([1, 2, 3])).toBe(false); + expect(isValidParamValue(["a", 1])).toBe(false); + }); + }); + + describe("isValidParamsObject", () => { + it("returns true for valid params objects", () => { + expect(isValidParamsObject({})).toBe(true); + expect(isValidParamsObject({ slug: "hello" })).toBe(true); + expect(isValidParamsObject({ slug: "hello", id: "123" })).toBe(true); + expect(isValidParamsObject({ catchall: ["a", "b"] })).toBe(true); + expect(isValidParamsObject({ slug: "hello", parts: ["a", "b"] })).toBe( + true + ); + }); + + it("returns false for invalid params objects", () => { + expect(isValidParamsObject(null)).toBe(false); + expect(isValidParamsObject(undefined)).toBe(false); + expect(isValidParamsObject("string")).toBe(false); + expect(isValidParamsObject([])).toBe(false); + expect(isValidParamsObject([{ slug: "hello" }])).toBe(false); + expect(isValidParamsObject({ slug: 123 })).toBe(false); + expect(isValidParamsObject({ slug: null })).toBe(false); + }); + }); + + describe("getInvalidParamKeys", () => { + it("returns empty array for valid params", () => { + expect(getInvalidParamKeys({})).toEqual([]); + expect(getInvalidParamKeys({ slug: "hello" })).toEqual([]); + expect(getInvalidParamKeys({ slug: ["a", "b"] })).toEqual([]); + }); + + it("returns keys with invalid values", () => { + expect(getInvalidParamKeys({ slug: 123 })).toEqual(["slug"]); + expect(getInvalidParamKeys({ a: "valid", b: 123, c: null })).toEqual([ + "b", + "c", + ]); + }); + }); + + describe("parsePageRoute", () => { + it("returns undefined for empty/null/undefined input", () => { + expect(parsePageRoute()).toBeUndefined(); + expect(parsePageRoute(undefined)).toBeUndefined(); + expect(parsePageRoute(null)).toBeUndefined(); + expect(parsePageRoute("")).toBeUndefined(); + }); + + it("parses valid route patterns", () => { + expect(parsePageRoute("/")).toBe("/"); + expect(parsePageRoute("/products")).toBe("/products"); + expect(parsePageRoute("/products/[slug]")).toBe("/products/[slug]"); + expect(parsePageRoute("/[...catchall]")).toBe("/[...catchall]"); + }); + + it("throws BadRequestError for non-string input", () => { + expect(() => parsePageRoute(123)).toThrow(BadRequestError); + expect(() => parsePageRoute({})).toThrow(BadRequestError); + expect(() => parsePageRoute([])).toThrow(BadRequestError); + }); + + it("throws BadRequestError for routes not starting with /", () => { + expect(() => parsePageRoute("products")).toThrow(BadRequestError); + expect(() => parsePageRoute("products/[slug]")).toThrow(BadRequestError); + }); + }); + + describe("parsePageParams", () => { + it("returns undefined for empty/null/undefined input", () => { + expect(parsePageParams()).toBeUndefined(); + expect(parsePageParams(undefined)).toBeUndefined(); + expect(parsePageParams(null)).toBeUndefined(); + expect(parsePageParams("")).toBeUndefined(); + }); + + it("parses valid JSON params", () => { + expect(parsePageParams("{}")).toEqual({}); + expect(parsePageParams('{"slug":"hello"}')).toEqual({ slug: "hello" }); + expect(parsePageParams('{"slug":"hello","id":"123"}')).toEqual({ + slug: "hello", + id: "123", + }); + expect(parsePageParams('{"catchall":["a","b","c"]}')).toEqual({ + catchall: ["a", "b", "c"], + }); + }); + + it("throws BadRequestError for non-string input", () => { + expect(() => parsePageParams(123)).toThrow(BadRequestError); + expect(() => parsePageParams({})).toThrow(BadRequestError); + }); + + it("throws BadRequestError for invalid JSON", () => { + expect(() => parsePageParams("{invalid}")).toThrow(BadRequestError); + expect(() => parsePageParams("not json")).toThrow(BadRequestError); + }); + + it("throws BadRequestError for non-object JSON", () => { + expect(() => parsePageParams("[]")).toThrow(BadRequestError); + expect(() => parsePageParams('"string"')).toThrow(BadRequestError); + expect(() => parsePageParams("123")).toThrow(BadRequestError); + expect(() => parsePageParams("null")).toThrow(BadRequestError); + }); + + it("throws BadRequestError for invalid param values", () => { + expect(() => parsePageParams('{"slug":123}')).toThrow(BadRequestError); + expect(() => parsePageParams('{"slug":null}')).toThrow(BadRequestError); + expect(() => parsePageParams('{"slug":[1,2,3]}')).toThrow(BadRequestError); + }); + + it("includes invalid key names in error message", () => { + expect(() => parsePageParams('{"validKey":"ok","badKey":123}')).toThrow( + /badKey/ + ); + }); + }); + + describe("parsePageQuery", () => { + it("returns undefined for empty/null/undefined input", () => { + expect(parsePageQuery()).toBeUndefined(); + expect(parsePageQuery(undefined)).toBeUndefined(); + expect(parsePageQuery(null)).toBeUndefined(); + expect(parsePageQuery("")).toBeUndefined(); + }); + + it("parses valid JSON query params", () => { + expect(parsePageQuery("{}")).toEqual({}); + expect(parsePageQuery('{"q":"search"}')).toEqual({ q: "search" }); + expect(parsePageQuery('{"tags":["a","b"]}')).toEqual({ + tags: ["a", "b"], + }); + }); + + it("throws BadRequestError for non-string input", () => { + expect(() => parsePageQuery(123)).toThrow(BadRequestError); + expect(() => parsePageQuery({})).toThrow(BadRequestError); + }); + + it("throws BadRequestError for invalid JSON", () => { + expect(() => parsePageQuery("{invalid}")).toThrow(BadRequestError); + }); + + it("throws BadRequestError for non-object JSON", () => { + expect(() => parsePageQuery("[]")).toThrow(BadRequestError); + expect(() => parsePageQuery("null")).toThrow(BadRequestError); + }); + + it("throws BadRequestError for invalid query values", () => { + expect(() => parsePageQuery('{"page":123}')).toThrow(BadRequestError); + }); + }); +}); diff --git a/platform/wab/src/wab/server/loader/page-params.ts b/platform/wab/src/wab/server/loader/page-params.ts new file mode 100644 index 000000000..6d3c0ca69 --- /dev/null +++ b/platform/wab/src/wab/server/loader/page-params.ts @@ -0,0 +1,115 @@ +/** + * Page params parsing module for HTML loader. + * + * Provides parsing and validation for dynamic route parameters + * (pageRoute, pageParams, pageQuery) used in server-side rendering. + */ + +import { BadRequestError } from "@/wab/shared/ApiErrors/errors"; + +// --- Types --- + +export type PageParamsValue = string | string[]; +export type PageParams = Record; + +// --- Pure validation functions --- + +export const isValidRoutePattern = (route: string): boolean => { + return typeof route === "string" && route.startsWith("/"); +}; + +export const isValidParamValue = (value: unknown): value is PageParamsValue => { + if (typeof value === "string") { + return true; + } + if (Array.isArray(value)) { + return value.every((v) => typeof v === "string"); + } + return false; +}; + +export const isValidParamsObject = (obj: unknown): obj is PageParams => { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { + return false; + } + return Object.values(obj).every(isValidParamValue); +}; + +export const getInvalidParamKeys = (obj: Record): string[] => { + return Object.entries(obj) + .filter(([_, value]) => !isValidParamValue(value)) + .map(([key]) => key); +}; + +// --- Parsing helpers --- + +const isEmptyInput = (value: unknown): boolean => { + return value === undefined || value === null || value === ""; +}; + +const parseJsonObject = ( + raw: unknown, + fieldName: string +): Record => { + if (typeof raw !== "string") { + throw new BadRequestError(`${fieldName} must be a JSON string`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new BadRequestError(`${fieldName} must be valid JSON`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new BadRequestError(`${fieldName} must be a JSON object`); + } + return parsed as Record; +}; + +const validateParamValues = ( + obj: Record, + fieldName: string +): PageParams => { + const invalidKeys = getInvalidParamKeys(obj); + if (invalidKeys.length > 0) { + throw new BadRequestError( + `${fieldName} contains invalid values for keys: ${invalidKeys.join(", ")}` + ); + } + return obj as PageParams; +}; + +// --- Public parse functions --- + +export const parsePageRoute = (rawPageRoute?: unknown): string | undefined => { + if (isEmptyInput(rawPageRoute)) { + return undefined; + } + if (typeof rawPageRoute !== "string") { + throw new BadRequestError("pageRoute must be a string"); + } + if (!isValidRoutePattern(rawPageRoute)) { + throw new BadRequestError("pageRoute must start with /"); + } + return rawPageRoute; +}; + +export const parsePageParams = ( + rawPageParams?: unknown +): PageParams | undefined => { + if (isEmptyInput(rawPageParams)) { + return undefined; + } + const parsed = parseJsonObject(rawPageParams, "pageParams"); + return validateParamValues(parsed, "pageParams"); +}; + +export const parsePageQuery = ( + rawPageQuery?: unknown +): PageParams | undefined => { + if (isEmptyInput(rawPageQuery)) { + return undefined; + } + const parsed = parseJsonObject(rawPageQuery, "pageQuery"); + return validateParamValues(parsed, "pageQuery"); +}; diff --git a/platform/wab/src/wab/server/routes/loader.ts b/platform/wab/src/wab/server/routes/loader.ts index f7852dab4..186e3f9a5 100644 --- a/platform/wab/src/wab/server/routes/loader.ts +++ b/platform/wab/src/wab/server/routes/loader.ts @@ -12,6 +12,11 @@ import { CodeModule, LoaderBundleOutput, } from "@/wab/server/loader/module-bundler"; +import { + parsePageParams, + parsePageQuery, + parsePageRoute, +} from "@/wab/server/loader/page-params"; import { parseComponentProps, parseGlobalVariants, @@ -672,6 +677,9 @@ export async function buildVersionedLoaderHtml(req: Request, res: Response) { projectToken: props.token, componentProps: parseComponentProps(req.query.componentProps), globalVariants: parseGlobalVariants(req.query.globalVariants), + pageRoute: parsePageRoute(req.query.pageRoute), + pageParams: parsePageParams(req.query.pageParams), + pageQuery: parsePageQuery(req.query.pageQuery), }); await props.mgr.upsertLoaderPublishmentEntities({ @@ -702,6 +710,9 @@ export async function buildLatestLoaderHtml(req: Request, res: Response) { projectToken: props.token, componentProps: parseComponentProps(req.query.componentProps), globalVariants: parseGlobalVariants(req.query.globalVariants), + pageRoute: parsePageRoute(req.query.pageRoute), + pageParams: parsePageParams(req.query.pageParams), + pageQuery: parsePageQuery(req.query.pageQuery), }); res.json(result); });