Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
3 changes: 2 additions & 1 deletion integration-test/playwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"test": "yarn playwright test",
"test:nextjs": "TEST_PROJECT_DIR=../nextjs yarn playwright test --grep=@nextjs",
"test:tanstack": "TEST_PROJECT_DIR=../tanstack-start yarn playwright test --grep=@tanstack",
"test:react-router": "TEST_PROJECT_DIR=../react-router yarn playwright test --grep=@react-router"
"test:react-router": "TEST_PROJECT_DIR=../react-router yarn playwright test --grep=@react-router",
"test:react-router-cloudflare": "TEST_PROJECT_DIR=../react-router-cloudflare yarn playwright test --grep=@react-router-cloudflare"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
Expand Down
42 changes: 25 additions & 17 deletions integration-test/playwright/src/cc-dynamic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,21 +187,29 @@ test.describe("CC dynamic", () => {
);
});

test("async loader", { tag: ["@react-router"] }, async ({ page }) => {
await page.goto(`${base}/asyncLoader`, {
waitUntil: "commit",
});

// main data already there
await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
expect(await page.getByText("Queried in SSR environment").count()).toBe(1);
// deferred chunks still loading
expect(await page.getByText("loading...").count()).toBe(6);
// deferred chunk came in
await expect(page.getByText("cuteness overload")).toBeVisible();
await new Promise((resolve) => setTimeout(resolve, 500));

expect(await page.getByText("Queried in SSR environment").count()).toBe(7);
expect(await page.getByText("loading...").count()).toBe(0);
});
test(
"async loader",
{ tag: ["@react-router", "@react-router-cloudflare"] },
async ({ page }) => {
await page.goto(`${base}/asyncLoader`, {
waitUntil: "commit",
});

// main data already there
await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
expect(await page.getByText("Queried in SSR environment").count()).toBe(
1
);
// deferred chunks still loading
expect(await page.getByText("loading...").count()).toBe(6);
// deferred chunk came in
await expect(page.getByText("cuteness overload")).toBeVisible();
await new Promise((resolve) => setTimeout(resolve, 500));

expect(await page.getByText("Queried in SSR environment").count()).toBe(
7
);
expect(await page.getByText("loading...").count()).toBe(0);
}
);
});
9 changes: 9 additions & 0 deletions integration-test/react-router-cloudflare/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.DS_Store
/node_modules/

# React Router
/.react-router/
/build/

/worker-configuration.d.ts
/.wrangler
3 changes: 3 additions & 0 deletions integration-test/react-router-cloudflare/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# React Router Cloudflare integration test

Useful to check if we run well on runtimes that don't support Node APIs such as `renderToPipeableStream`.
25 changes: 25 additions & 0 deletions integration-test/react-router-cloudflare/app/apollo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ApolloLink, HttpLink, InMemoryCache } from "@apollo/client/index.js";
import {
createApolloLoaderHandler,
ApolloClient,
} from "@apollo/client-integration-react-router";
import { IncrementalSchemaLink } from "@integration-test/shared/IncrementalSchemaLink";
import { schema } from "@integration-test/shared/schema";
import { delayLink } from "@integration-test/shared/delayLink";
import { errorLink } from "@integration-test/shared/errorLink";

const link = ApolloLink.from([
delayLink,
errorLink,
typeof window === "undefined"
? (new IncrementalSchemaLink({ schema }) as any as ApolloLink)
: new HttpLink({ uri: "/graphql" }),
]);

export const makeClient = (request?: Request) => {
return new ApolloClient({
cache: new InMemoryCache(),
link,
});
};
export const apolloLoader = createApolloLoaderHandler(makeClient);
17 changes: 17 additions & 0 deletions integration-test/react-router-cloudflare/app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import { makeClient } from "./apollo";
import { ApolloProvider } from "@apollo/client/react/index.js";

startTransition(() => {
const client = makeClient();
hydrateRoot(
document,
<StrictMode>
<ApolloProvider client={client}>
<HydratedRouter />
</ApolloProvider>
</StrictMode>
);
});
73 changes: 73 additions & 0 deletions integration-test/react-router-cloudflare/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { AppLoadContext, EntryContext } from "react-router";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type {
RenderToPipeableStreamOptions,
RenderToReadableStreamOptions,
} from "react-dom/server";
import { renderToReadableStream } from "react-dom/server";
import { makeClient } from "./apollo";
import { ApolloProvider } from "@apollo/client/react/index.js";

export const streamTimeout = 5_000;
export type RenderOptions = {
[K in keyof RenderToReadableStreamOptions &
keyof RenderToPipeableStreamOptions]?: RenderToReadableStreamOptions[K];
};

// Based on this template https://github.com/cloudflare/templates/blob/staging/react-router-starter-template/app/entry.server.tsx
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
// vercel-specific options, originating from `@vercel/react-router/entry.server.js`
options?: RenderOptions
) {
let shellRendered = false;
const userAgent = request.headers.get("user-agent");
const client = makeClient(request);

const abortController = new AbortController();
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setTimeout(() => {
abortController.abort(`Rendering exceed timeout of ${streamTimeout}ms`);
}, streamTimeout + 1000);

responseHeaders.set("Content-Type", "text/html");

const stream = await renderToReadableStream(
<ApolloProvider client={client}>
<ServerRouter
context={routerContext}
url={request.url}
nonce={options?.nonce}
/>
</ApolloProvider>,
{
...options,
signal: abortController.signal,
onError(error: unknown) {
responseStatusCode = 500;

if (shellRendered) {
console.error(error);
}
},
}
);
shellRendered = true;

// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToReadableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const isCrawler = (userAgent && isbot(userAgent)) || routerContext.isSpaMode;

if (isCrawler) {
await stream.allReady;
}

return new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
}
14 changes: 14 additions & 0 deletions integration-test/react-router-cloudflare/app/entry.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createRequestHandler } from "react-router";

const requestHandler = createRequestHandler(
() => import("virtual:react-router/server-build"),
import.meta.env.MODE
);

export default {
fetch(request, env, ctx) {
return requestHandler(request, {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Server entrypoint.

cloudflare: { env, ctx },
});
},
} satisfies ExportedHandler<Env>;
62 changes: 62 additions & 0 deletions integration-test/react-router-cloudflare/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";

import type { Route } from "./+types/root";
import { ApolloHydrationHelper } from "@apollo/client-integration-react-router";

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<ApolloHydrationHelper>{children}</ApolloHydrationHelper>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function App() {
return <Outlet />;
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;

if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}

return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
3 changes: 3 additions & 0 deletions integration-test/react-router-cloudflare/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { flatRoutes } from "@react-router/fs-routes";

export default flatRoutes();
70 changes: 70 additions & 0 deletions integration-test/react-router-cloudflare/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useLoaderData } from "react-router";
import type { Route } from "./+types/_index";
import {
useApolloClient,
useQueryRefHandlers,
useReadQuery,
} from "@apollo/client/react/index.js";
import { apolloLoader } from "~/apollo";
import { DEFERRED_QUERY } from "@integration-test/shared/queries";
import { useTransition } from "react";

export const loader = apolloLoader<Route.LoaderArgs>()(({ preloadQuery }) => {
const queryRef = preloadQuery(DEFERRED_QUERY, {
variables: { delayDeferred: 1000 },
});
return {
queryRef,
};
});

export default function Home() {
const { queryRef } = useLoaderData<typeof loader>();

const { refetch } = useQueryRefHandlers(queryRef);
const [refetching, startTransition] = useTransition();
const { data } = useReadQuery(queryRef);
const client = useApolloClient();

return (
<>
<ul>
{data.products.map(({ id, title, rating }) => (
<li key={id}>
{title}
<br />
Rating:{" "}
<div style={{ display: "inline-block", verticalAlign: "text-top" }}>
{rating?.value || ""}
<br />
{rating ? `Queried in ${rating.env} environment` : "loading..."}
</div>
</li>
))}
</ul>
<p>Queried in {data.env} environment</p>
<button
disabled={refetching}
onClick={() => {
client.cache.batch({
update(cache) {
for (const product of data.products) {
cache.modify({
id: cache.identify(product),
fields: {
rating: () => null,
},
});
}
},
});
startTransition(() => {
refetch();
});
}}
>
{refetching ? "refetching..." : "refetch"}
</button>
</>
);
}
Loading