`) 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}
);