Files
cloudflare__vinext/tests/server-inserted-html-context.test.ts
T
MD YUNUS 567f0ddf1d fix: export ServerInsertedHTMLContext from next/navigation shim (#145) (#151)
Wire ServerInsertedHTMLContext into the SSR pipeline so libraries like
Apollo Client, styled-components, and emotion that use
useContext(ServerInsertedHTMLContext) get a working callback registration
function during SSR.

Changes:
- Add ServerInsertedHTMLContext export to navigation.ts (single shared
  context, RSC-safe with null fallback)
- Wrap SSR rendered tree with ServerInsertedHTMLContext.Provider in
  handleSsr() — the provider value is useServerInsertedHTML itself, so
  both direct callers and useContext() callers push to the same
  ALS-backed callback array
- Add TypeScript declaration in next-shims.d.ts
- Add 8 integration tests verifying the actual Apollo Client pattern
  (useContext returns a usable callback, callbacks are flushed correctly)

Addresses review feedback from #151:
- Single context (no dead getServerInsertedHTMLContext)
- Provider wired into SSR pipeline (not disconnected)
- Tests verify real integration behavior, not React internals
2026-03-03 18:56:35 -06:00

202 lines
7.3 KiB
TypeScript

/**
* Tests for ServerInsertedHTMLContext — the React Context that CSS-in-JS libraries
* (Apollo Client, styled-components, emotion) use to register HTML injection
* callbacks during SSR via useContext().
*
* These tests verify the actual integration pattern used by libraries, not just
* structural properties of the context object.
*/
import { describe, it, expect, beforeEach } from "vitest";
import * as React from "react";
import { renderToString } from "react-dom/server";
describe("ServerInsertedHTMLContext", () => {
beforeEach(async () => {
const { clearServerInsertedHTML } = await import(
"../packages/vinext/src/shims/navigation.js"
);
clearServerInsertedHTML();
});
it("is exported from next/navigation shim", async () => {
const mod = await import("../packages/vinext/src/shims/navigation.js");
expect(mod.ServerInsertedHTMLContext).toBeDefined();
});
it("is a valid React.Context with Provider and Consumer", async () => {
const { ServerInsertedHTMLContext } = await import(
"../packages/vinext/src/shims/navigation.js"
);
expect(ServerInsertedHTMLContext).not.toBeNull();
expect(ServerInsertedHTMLContext).toHaveProperty("Provider");
expect(ServerInsertedHTMLContext).toHaveProperty("Consumer");
});
it("has null as default value (no Provider)", async () => {
const { ServerInsertedHTMLContext } = await import(
"../packages/vinext/src/shims/navigation.js"
);
// Without a Provider, useContext returns the default value (null).
// This is correct — Apollo checks for null and throws a clear error
// if used outside the App Router.
expect((ServerInsertedHTMLContext as any)._currentValue).toBeNull();
});
it("provides a callback registration function when wrapped with Provider", async () => {
const { ServerInsertedHTMLContext } = await import(
"../packages/vinext/src/shims/navigation.js"
);
let contextValue: unknown = "not-set";
// Component that reads the context — simulates what Apollo does
function ContextReader() {
contextValue = React.useContext(ServerInsertedHTMLContext!);
return React.createElement("div", null, "test");
}
// Simulate the SSR pipeline: Provider wraps the tree with a registration function
const addCallback = (_cb: () => unknown) => { /* registration function */ };
const tree = React.createElement(
ServerInsertedHTMLContext!.Provider,
{ value: addCallback },
React.createElement(ContextReader),
);
renderToString(tree);
expect(contextValue).toBe(addCallback);
expect(typeof contextValue).toBe("function");
});
it("Apollo Client pattern: useContext returns a usable registration function", async () => {
const { ServerInsertedHTMLContext, useServerInsertedHTML, flushServerInsertedHTML } = await import(
"../packages/vinext/src/shims/navigation.js"
);
// Simulate Apollo's actual usage pattern:
// const insertHtml = useContext(ServerInsertedHTMLContext);
// if (!insertHtml) throw new Error("...");
// insertHtml(() => <style>...</style>);
let apolloError: Error | null = null;
function ApolloSSRComponent() {
const insertHtml = React.useContext(ServerInsertedHTMLContext!);
if (!insertHtml) {
apolloError = new Error(
"The SSR build of ApolloNextAppProvider cannot be used outside of the Next App Router!"
);
return React.createElement("div", null, "error");
}
// Register a style injection callback (what Apollo does for SSR)
insertHtml(() => "<style>.apollo-ssr { color: red; }</style>");
return React.createElement("div", null, "apollo-content");
}
// Wrap with Provider (simulates what handleSsr does)
const tree = React.createElement(
ServerInsertedHTMLContext!.Provider,
{ value: useServerInsertedHTML },
React.createElement(ApolloSSRComponent),
);
const html = renderToString(tree);
// Apollo should NOT throw
expect(apolloError).toBeNull();
expect(html).toContain("apollo-content");
// The callback should have been registered via useServerInsertedHTML
const flushed = flushServerInsertedHTML();
expect(flushed).toHaveLength(1);
expect(flushed[0]).toBe("<style>.apollo-ssr { color: red; }</style>");
});
it("works alongside direct useServerInsertedHTML calls", async () => {
const { ServerInsertedHTMLContext, useServerInsertedHTML, flushServerInsertedHTML } = await import(
"../packages/vinext/src/shims/navigation.js"
);
// Component using direct useServerInsertedHTML (styled-components pattern)
function StyledComponentsRegistry({ children }: { children: React.ReactNode }) {
useServerInsertedHTML(() => "<style>.sc-1 { display: block; }</style>");
return React.createElement(React.Fragment, null, children);
}
// Component using useContext (Apollo pattern)
function ApolloRegistry() {
const insertHtml = React.useContext(ServerInsertedHTMLContext!);
if (insertHtml) {
insertHtml(() => "<style>.apollo { font-weight: bold; }</style>");
}
return React.createElement("div", null, "app");
}
const tree = React.createElement(
ServerInsertedHTMLContext!.Provider,
{ value: useServerInsertedHTML },
React.createElement(
StyledComponentsRegistry,
null,
React.createElement(ApolloRegistry),
),
);
renderToString(tree);
// Both callbacks should be in the same array
const flushed = flushServerInsertedHTML();
expect(flushed).toHaveLength(2);
expect(flushed[0]).toContain(".sc-1");
expect(flushed[1]).toContain(".apollo");
});
it("returns null without Provider (Apollo throws clear error)", async () => {
const { ServerInsertedHTMLContext } = await import(
"../packages/vinext/src/shims/navigation.js"
);
let contextValue: unknown = "not-set";
function ComponentWithoutProvider() {
contextValue = React.useContext(ServerInsertedHTMLContext!);
return React.createElement("div", null, "no-provider");
}
// Render WITHOUT Provider — simulates using outside App Router
renderToString(React.createElement(ComponentWithoutProvider));
// Context value should be null (the default)
expect(contextValue).toBeNull();
});
it("supports multiple callback registrations from context", async () => {
const { ServerInsertedHTMLContext, useServerInsertedHTML, flushServerInsertedHTML } = await import(
"../packages/vinext/src/shims/navigation.js"
);
function MultiCallbackComponent() {
const insertHtml = React.useContext(ServerInsertedHTMLContext!);
if (insertHtml) {
insertHtml(() => "<style>.first {}</style>");
insertHtml(() => "<style>.second {}</style>");
insertHtml(() => "<style>.third {}</style>");
}
return React.createElement("div", null, "multi");
}
const tree = React.createElement(
ServerInsertedHTMLContext!.Provider,
{ value: useServerInsertedHTML },
React.createElement(MultiCallbackComponent),
);
renderToString(tree);
const flushed = flushServerInsertedHTML();
expect(flushed).toHaveLength(3);
expect(flushed[0]).toContain(".first");
expect(flushed[1]).toContain(".second");
expect(flushed[2]).toContain(".third");
});
});