From 64dc2d5526d14ffd2d7edb328531313dfd3e6e1a Mon Sep 17 00:00:00 2001 From: Da-Jin Chu Date: Mon, 29 Jun 2026 15:08:59 -0400 Subject: [PATCH] Portal --- bifrost-fastify/index.ts | 9 ++- bifrost-fastify/lib/extractDomElements.ts | 55 ++++++++++++- bifrost/NestedComponentPortal.tsx | 48 ++++++++++++ bifrost/lib/type.ts | 8 ++ bifrost/package.json | 8 ++ bifrost/renderer/config.ts | 8 +- bifrost/renderer/wrapped/Page.tsx | 14 +++- .../renderer/wrapped/onBeforeRender.client.ts | 6 ++ .../renderer/wrapped/onBeforeRenderClient.ts | 5 +- .../renderer/wrapped/onBeforeRenderHtml.ts | 7 +- bifrost/tsup.config.ts | 2 +- .../e2e/specs/nested-component-portal.spec.ts | 77 +++++++++++++++++++ tests/vite/bifrost.d.ts | 3 + tests/vite/pages/proxy/wrapped/+Layout.tsx | 11 ++- 14 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 bifrost/NestedComponentPortal.tsx create mode 100644 tests/e2e/specs/nested-component-portal.spec.ts diff --git a/bifrost-fastify/index.ts b/bifrost-fastify/index.ts index 3ba2acf..017501b 100644 --- a/bifrost-fastify/index.ts +++ b/bifrost-fastify/index.ts @@ -258,8 +258,12 @@ export const viteProxyPlugin: FastifyPluginAsync< const html = await text(res.stream); - const { bodyAttributes, bodyInnerHtml, headInnerHtml } = - extractDomElements(html); + const { + bodyAttributes, + bodyInnerHtml, + headInnerHtml, + nestedComponents, + } = extractDomElements(html); if (!bodyInnerHtml || !headInnerHtml) { return reply.send(html); @@ -287,6 +291,7 @@ export const viteProxyPlugin: FastifyPluginAsync< bodyInnerHtml, headInnerHtml, proxyLayoutInfo, + nestedComponents, } satisfies WrappedServerOnly, ...customPageContextInit, }; diff --git a/bifrost-fastify/lib/extractDomElements.ts b/bifrost-fastify/lib/extractDomElements.ts index 7e17b94..cb43812 100644 --- a/bifrost-fastify/lib/extractDomElements.ts +++ b/bifrost-fastify/lib/extractDomElements.ts @@ -1,17 +1,36 @@ import { Parser } from "htmlparser2"; +/** + * A `
` placeholder found in the proxied body: + * its byte range within bodyInnerHtml and whether a NestedComponentPortal claimed it + * during SSR (`rendered`, set later during React render). + */ +export interface NestedComponent { + start: number; + end: number; + rendered?: boolean; +} + export function extractDomElements(html: string): { bodyInnerHtml: string | null; headInnerHtml: string | null; bodyAttributes: Record; + nestedComponents: Record; } { let headInnerHtml: string | null = null; let bodyInnerHtml: string | null = null; let bodyAttributes: Record | null = null; + // Ranges relative to the raw html; rebased onto bodyInnerHtml before returning. + const nestedComponents: Record = {}; let headStart = -1; let bodyStart = -1; + // div nesting depth counter; when a tagged div opens we remember its name+start + // and the depth at which to match its close. + let divDepth = 0; + const openTemplates: { name: string; start: number; depth: number }[] = []; + const parser = new Parser({ onopentag(name, attribs) { if (name === "head" && headStart < 0) { @@ -19,6 +38,16 @@ export function extractDomElements(html: string): { } else if (name === "body" && bodyStart < 0) { bodyStart = parser.endIndex! + 1; bodyAttributes = attribs; + } else if (name === "div") { + divDepth++; + const template = attribs["data-bifrost-render"]; + if (template) { + openTemplates.push({ + name: template, + start: parser.startIndex!, + depth: divDepth, + }); + } } }, onclosetag(name) { @@ -26,6 +55,17 @@ export function extractDomElements(html: string): { headInnerHtml = html.slice(headStart, parser.startIndex!); } else if (name === "body" && bodyStart >= 0 && bodyInnerHtml === null) { bodyInnerHtml = html.slice(bodyStart, parser.startIndex!); + } else if (name === "div") { + const top = openTemplates[openTemplates.length - 1]; + if (top && top.depth === divDepth) { + openTemplates.pop(); + // ponytail: last occurrence wins if a name repeats; single use expected. + nestedComponents[top.name] = { + start: top.start, + end: parser.endIndex! + 1, + }; + } + divDepth--; } }, }); @@ -33,5 +73,18 @@ export function extractDomElements(html: string): { parser.write(html); parser.end(); - return { headInnerHtml, bodyInnerHtml, bodyAttributes: bodyAttributes ?? {} }; + // Rebase ranges from raw-html offsets onto bodyInnerHtml offsets. + if (bodyStart >= 0) { + for (const range of Object.values(nestedComponents)) { + range.start -= bodyStart; + range.end -= bodyStart; + } + } + + return { + headInnerHtml, + bodyInnerHtml, + bodyAttributes: bodyAttributes ?? {}, + nestedComponents, + }; } diff --git a/bifrost/NestedComponentPortal.tsx b/bifrost/NestedComponentPortal.tsx new file mode 100644 index 0000000..2ebd3ec --- /dev/null +++ b/bifrost/NestedComponentPortal.tsx @@ -0,0 +1,48 @@ +import React, { useLayoutEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { usePageContext } from "vike-react/usePageContext"; +import "./renderer/config"; +import "./lib/type"; + +/** + * Renders `children` into a legacy placeholder div (`
`) + * inside the proxied body of a wrapped page. + * + * Server: marks the placeholder as claimed (so Page.tsx keeps it) and renders nothing — + * the backend's server-side shell stays in the SSR HTML. + * Client: portals `children` into the placeholder div, replacing the shell. + * + * Augment `Vike.NestedComponents` in userspace to type the allowed `name`s. + */ +export function NestedComponentPortal({ + name, + children, +}: { + name: keyof Vike.NestedComponents & string; + children: React.ReactNode; +}) { + const pageContext = usePageContext(); + const [target, setTarget] = useState(null); + + useLayoutEffect(() => { + if (!pageContext.isClientSide) return; + setTarget( + document.querySelector(`#proxied-body [data-bifrost-render="${name}"]`) + ); + }, [name, pageContext]); // re-query on client navigation (pageContext changes) + + if (!pageContext.isClientSide) { + // Mark the placeholder claimed so Page.tsx keeps it instead of stripping. + const entry = pageContext._wrappedServerOnly?.nestedComponents?.[name]; + if (entry) entry.rendered = true; + return null; + } + + return target ? createPortal(children, target) : null; +} + +declare global { + namespace Vike { + interface NestedComponents {} + } +} diff --git a/bifrost/lib/type.ts b/bifrost/lib/type.ts index 6306519..800083a 100644 --- a/bifrost/lib/type.ts +++ b/bifrost/lib/type.ts @@ -12,6 +12,14 @@ export interface WrappedServerOnly { proxyLayoutInfo: Vike.ProxyLayoutInfo; // Marker to verify that render succeeded renderedBody?: boolean; + // Placeholder divs (`
`) found in the proxied body: + // their byte range within bodyInnerHtml (set by extractDomElements) and whether a + // NestedComponentPortal claimed each one during React render (`rendered`). Page.tsx + // strips any entry left unrendered. Optional: older bifrost-fastify versions don't send it. + nestedComponents?: Record< + string, + { start: number; end: number; rendered?: boolean } + >; } declare global { diff --git a/bifrost/package.json b/bifrost/package.json index 0d02364..8843cdf 100644 --- a/bifrost/package.json +++ b/bifrost/package.json @@ -9,6 +9,10 @@ "types": "./dist/renderer/config.d.ts", "default": "./dist/renderer/+config.js" }, + "./NestedComponentPortal": { + "types": "./dist/NestedComponentPortal.d.ts", + "default": "./dist/NestedComponentPortal.js" + }, "./__internal/renderer/*": { "types": "./dist/renderer/*.d.ts", "default": "./dist/renderer/*.js" @@ -24,6 +28,9 @@ "config": [ "./dist/renderer/config.d.ts" ], + "NestedComponentPortal": [ + "./dist/NestedComponentPortal.d.ts" + ], ".": [ "./dist/index.d.ts" ] @@ -44,6 +51,7 @@ }, "peerDependencies": { "react": ">=18", + "react-dom": ">=18", "typescript": ">=4.7", "vike": ">=0.4.259-commit-2fe6cc7", "vike-react": ">=0.6.21" diff --git a/bifrost/renderer/config.ts b/bifrost/renderer/config.ts index 85131f9..706a92b 100644 --- a/bifrost/renderer/config.ts +++ b/bifrost/renderer/config.ts @@ -15,7 +15,7 @@ export default { "import:@alignable/bifrost/__internal/renderer/onBeforeRoute:default", Wrapper: "import:@alignable/bifrost/__internal/renderer/Wrapper:default", - passToClient: ["proxyLayoutInfo"], + passToClient: ["proxyLayoutInfo", "proxyNestedComponents"], meta: { bodyAttributes: { @@ -97,6 +97,12 @@ declare global { } interface PageContext { proxyLayoutInfo?: ProxyLayoutInfo; + /** + * Which nested components (`
`) the backend + * requested in the proxied HTML. Lets userspace fetch data / conditionally + * render the matching `` only for requested components. + */ + proxyNestedComponents?: Record; } interface ProxyLayoutInfo {} } diff --git a/bifrost/renderer/wrapped/Page.tsx b/bifrost/renderer/wrapped/Page.tsx index 9d5314c..bd0ec49 100644 --- a/bifrost/renderer/wrapped/Page.tsx +++ b/bifrost/renderer/wrapped/Page.tsx @@ -6,13 +6,23 @@ import "../../lib/type"; export default function Page() { const pageContext = usePageContext(); - const bodyHtml = pageContext.isClientSide + let bodyHtml = pageContext.isClientSide ? pageContext._turbolinksProxy?.body?.innerHTML : pageContext._wrappedServerOnly?.bodyInnerHtml; // Set marker so we can render error if failed to render body due to error in surrounding Layout if (bodyHtml && !pageContext.isClientSide && pageContext._wrappedServerOnly) { - pageContext._wrappedServerOnly.renderedBody = true; + const wso = pageContext._wrappedServerOnly; + wso.renderedBody = true; + + // Strip placeholder divs whose NestedComponentPortal never rendered, back-to-front so + // earlier offsets stay valid. Rendered placeholders are left for the client to portal into. + const stripRanges = Object.values(wso.nestedComponents ?? {}) + .filter((c) => !c.rendered) + .sort((a, b) => b.start - a.start); + for (const { start, end } of stripRanges) { + bodyHtml = bodyHtml!.slice(0, start) + bodyHtml!.slice(end); + } } if (bodyHtml) { diff --git a/bifrost/renderer/wrapped/onBeforeRender.client.ts b/bifrost/renderer/wrapped/onBeforeRender.client.ts index 98378c4..9898ce0 100644 --- a/bifrost/renderer/wrapped/onBeforeRender.client.ts +++ b/bifrost/renderer/wrapped/onBeforeRender.client.ts @@ -109,6 +109,12 @@ export default async function wrappedOnBeforeRender( const bodyEl = parsed.querySelector("body")!; const headEl = parsed.querySelector("head")!; pageContext.proxyLayoutInfo = layoutInfo; + pageContext.proxyNestedComponents = Object.fromEntries( + [...bodyEl.querySelectorAll("[data-bifrost-render]")].map((el) => [ + el.getAttribute("data-bifrost-render")!, + true, + ]) + ); pageContext._turbolinksProxy = { body: bodyEl, head: headEl, diff --git a/bifrost/renderer/wrapped/onBeforeRenderClient.ts b/bifrost/renderer/wrapped/onBeforeRenderClient.ts index 3d1c2ca..24e9752 100644 --- a/bifrost/renderer/wrapped/onBeforeRenderClient.ts +++ b/bifrost/renderer/wrapped/onBeforeRenderClient.ts @@ -35,6 +35,7 @@ export default instrument("wrappedOnBeforeRenderClient", async function wrappedO }; Turbolinks._vpsCachePageContext({ proxyLayoutInfo: pageContext.proxyLayoutInfo, + proxyNestedComponents: pageContext.proxyNestedComponents, }); recordExistingHeadScripts(); return; @@ -46,13 +47,15 @@ export default instrument("wrappedOnBeforeRenderClient", async function wrappedO "restoration visit should never happen on initial render" ); } - const { proxyLayoutInfo } = pageContext._snapshot.pageContext; + const { proxyLayoutInfo, proxyNestedComponents } = + pageContext._snapshot.pageContext; const { bodyEl, headEl } = pageContext._snapshot; const proxyBodyEl = bodyEl.querySelector("#proxied-body")!; if (!proxyBodyEl || !(proxyBodyEl instanceof HTMLElement)) { throw new Error("proxied body not found in cached snapshot"); } pageContext.proxyLayoutInfo = proxyLayoutInfo; + pageContext.proxyNestedComponents = proxyNestedComponents; pageContext._turbolinksProxy = { bodyAttrs: getElementAttributes(bodyEl), body: proxyBodyEl, diff --git a/bifrost/renderer/wrapped/onBeforeRenderHtml.ts b/bifrost/renderer/wrapped/onBeforeRenderHtml.ts index 3d64228..f7d1821 100644 --- a/bifrost/renderer/wrapped/onBeforeRenderHtml.ts +++ b/bifrost/renderer/wrapped/onBeforeRenderHtml.ts @@ -5,12 +5,17 @@ export default function wrappedOnBeforeRenderHtml( pageContext: PageContextServer ) { if (pageContext._wrappedServerOnly) { - const { bodyAttributes, proxyLayoutInfo } = pageContext._wrappedServerOnly; + const { bodyAttributes, proxyLayoutInfo, nestedComponents } = + pageContext._wrappedServerOnly; const config = useConfig(); config({ bodyAttributes }); // Move layout/layoutProps to top-level pageContext so Vike can pass them to client pageContext.proxyLayoutInfo = proxyLayoutInfo; + // Expose requested component names as a boolean map (passToClient-safe) + pageContext.proxyNestedComponents = Object.fromEntries( + Object.keys(nestedComponents ?? {}).map((name) => [name, true]) + ); } } diff --git a/bifrost/tsup.config.ts b/bifrost/tsup.config.ts index e6677a4..0b52f8d 100644 --- a/bifrost/tsup.config.ts +++ b/bifrost/tsup.config.ts @@ -15,7 +15,7 @@ async function* getFiles(dir: string): AsyncGenerator { } export default defineConfig({ - entry: ["./index.ts", "./renderer/**/*"], + entry: ["./index.ts", "./NestedComponentPortal.tsx", "./renderer/**/*"], format: "esm", clean: true, sourcemap: true, diff --git a/tests/e2e/specs/nested-component-portal.spec.ts b/tests/e2e/specs/nested-component-portal.spec.ts new file mode 100644 index 0000000..5adc24a --- /dev/null +++ b/tests/e2e/specs/nested-component-portal.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from "@playwright/test"; +import { CustomProxyPage } from "../helpers/custom-proxy-page"; +import { toPath } from "../../fake-backend/page-builder"; + +// Legacy backend leaves placeholder divs; userspace renders +// into the claimed ones and Bifrost strips the unclaimed ones from SSR output. +test.describe("nested component portals", () => { + const claimed = `
legacy fallback
`; + const unclaimed = `
orphan markup
`; + + test("portals react content into the claimed placeholder", async ({ + page, + }) => { + const proxy = new CustomProxyPage(page, { + title: "portal page", + layout: "main_nav", + content: claimed, + }); + await proxy.goto(); + + const placeholder = page.locator('[data-bifrost-render="myComponent"]'); + await expect(placeholder).toHaveCount(1); + // client portal replaces the legacy fallback with react content + await expect(placeholder).toContainText("portaled react content"); + }); + + test("strips placeholders that no portal claimed (SSR)", async ({ + request, + }) => { + const path = toPath({ + title: "orphan page", + layout: "main_nav", + content: claimed + unclaimed, + }); + const html = await (await request.get(path)).text(); + + // claimed survives for the client to portal into; unclaimed is removed entirely + expect(html).toContain('data-bifrost-render="myComponent"'); + expect(html).not.toContain('data-bifrost-render="unclaimed"'); + expect(html).not.toContain("orphan markup"); + }); + + test("exposes requested component names to userspace", async ({ page }) => { + const proxy = new CustomProxyPage(page, { + title: "requested page", + layout: "main_nav", + content: `${claimed}${unclaimed}`, + }); + await proxy.goto(); + // pageContext.proxyNestedComponents lists every requested template, even + // ones with no matching portal, so userspace can gate data-fetching on it. + await expect(page.getByTestId("requested-components")).toHaveText( + "myComponent,unclaimed" + ); + }); + + test("portals after client-side navigation", async ({ page }) => { + const proxy = new CustomProxyPage(page, { + title: "start page", + layout: "main_nav", + content: "start content", + links: [ + { + title: "portal target", + layout: "main_nav", + content: claimed, + }, + ], + }); + await proxy.goto(); + await proxy.clickLink("portal target"); + + const placeholder = page.locator('[data-bifrost-render="myComponent"]'); + await expect(placeholder).toHaveCount(1); + await expect(placeholder).toContainText("portaled react content"); + }); +}); diff --git a/tests/vite/bifrost.d.ts b/tests/vite/bifrost.d.ts index 5733ec0..17e2bb7 100644 --- a/tests/vite/bifrost.d.ts +++ b/tests/vite/bifrost.d.ts @@ -15,6 +15,9 @@ declare global { visitor?: { currentNav: string }; ssr_error?: { currentNav: string }; } + interface NestedComponents { + myComponent: true; + } } } diff --git a/tests/vite/pages/proxy/wrapped/+Layout.tsx b/tests/vite/pages/proxy/wrapped/+Layout.tsx index 00552fa..91c55db 100644 --- a/tests/vite/pages/proxy/wrapped/+Layout.tsx +++ b/tests/vite/pages/proxy/wrapped/+Layout.tsx @@ -2,10 +2,11 @@ import { usePageContext } from "vike-react/usePageContext"; import { MainNavLayout } from "../../../layouts/MainNavLayout"; import { VisitorLayout } from "../../../layouts/VisitorLayout"; import { SsrErrorLayout } from "../../../layouts/SsrErrorLayout"; +import { NestedComponentPortal } from "@alignable/bifrost/NestedComponentPortal"; import React, { useEffect } from "react"; export function Layout({ children }: { children: React.ReactNode }) { - const { proxyLayoutInfo } = usePageContext(); + const { proxyLayoutInfo, proxyNestedComponents } = usePageContext(); useEffect(() => { const listener = () => { @@ -27,6 +28,14 @@ export function Layout({ children }: { children: React.ReactNode }) { if (main_nav) { return ( + + {Object.keys(proxyNestedComponents ?? {}).sort().join(",")} + + {proxyNestedComponents?.myComponent && ( + + portaled react content + + )} {children} );