Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions bifrost-fastify/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -287,6 +291,7 @@ export const viteProxyPlugin: FastifyPluginAsync<
bodyInnerHtml,
headInnerHtml,
proxyLayoutInfo,
nestedComponents,
} satisfies WrappedServerOnly,
...customPageContextInit,
};
Expand Down
55 changes: 54 additions & 1 deletion bifrost-fastify/lib/extractDomElements.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,90 @@
import { Parser } from "htmlparser2";

/**
* A `<div data-bifrost-render="name">` 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<string, string>;
nestedComponents: Record<string, NestedComponent>;
} {
let headInnerHtml: string | null = null;
let bodyInnerHtml: string | null = null;
let bodyAttributes: Record<string, string> | null = null;
// Ranges relative to the raw html; rebased onto bodyInnerHtml before returning.
const nestedComponents: Record<string, NestedComponent> = {};

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) {
headStart = parser.endIndex! + 1;
} 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) {
if (name === "head" && headStart >= 0 && headInnerHtml === null) {
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--;
}
},
});

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,
};
}
48 changes: 48 additions & 0 deletions bifrost/NestedComponentPortal.tsx
Original file line number Diff line number Diff line change
@@ -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 (`<div data-bifrost-render="name">`)
* 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<Element | null>(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 {}
}
}
8 changes: 8 additions & 0 deletions bifrost/lib/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ export interface WrappedServerOnly {
proxyLayoutInfo: Vike.ProxyLayoutInfo;
// Marker to verify that render succeeded
renderedBody?: boolean;
// Placeholder divs (`<div data-bifrost-render="name">`) 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 {
Expand Down
8 changes: 8 additions & 0 deletions bifrost/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -24,6 +28,9 @@
"config": [
"./dist/renderer/config.d.ts"
],
"NestedComponentPortal": [
"./dist/NestedComponentPortal.d.ts"
],
".": [
"./dist/index.d.ts"
]
Expand All @@ -44,6 +51,7 @@
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"typescript": ">=4.7",
"vike": ">=0.4.259-commit-2fe6cc7",
"vike-react": ">=0.6.21"
Expand Down
8 changes: 7 additions & 1 deletion bifrost/renderer/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -97,6 +97,12 @@ declare global {
}
interface PageContext {
proxyLayoutInfo?: ProxyLayoutInfo;
/**
* Which nested components (`<div data-bifrost-render="name">`) the backend
* requested in the proxied HTML. Lets userspace fetch data / conditionally
* render the matching `<NestedComponentPortal>` only for requested components.
*/
proxyNestedComponents?: Record<string, boolean>;
}
interface ProxyLayoutInfo {}
}
Expand Down
14 changes: 12 additions & 2 deletions bifrost/renderer/wrapped/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions bifrost/renderer/wrapped/onBeforeRender.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion bifrost/renderer/wrapped/onBeforeRenderClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default instrument("wrappedOnBeforeRenderClient", async function wrappedO
};
Turbolinks._vpsCachePageContext({
proxyLayoutInfo: pageContext.proxyLayoutInfo,
proxyNestedComponents: pageContext.proxyNestedComponents,
});
recordExistingHeadScripts();
return;
Expand All @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion bifrost/renderer/wrapped/onBeforeRenderHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
);
}
}
2 changes: 1 addition & 1 deletion bifrost/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ async function* getFiles(dir: string): AsyncGenerator<string> {
}

export default defineConfig({
entry: ["./index.ts", "./renderer/**/*"],
entry: ["./index.ts", "./NestedComponentPortal.tsx", "./renderer/**/*"],
format: "esm",
clean: true,
sourcemap: true,
Expand Down
77 changes: 77 additions & 0 deletions tests/e2e/specs/nested-component-portal.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <NestedComponentPortal>
// into the claimed ones and Bifrost strips the unclaimed ones from SSR output.
test.describe("nested component portals", () => {
const claimed = `<div data-bifrost-render="myComponent">legacy fallback</div>`;
const unclaimed = `<div data-bifrost-render="unclaimed">orphan markup</div>`;

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");
});
});
Loading