From 7dad495a6a81176c5d092b5a424e07e9c89b7f0b Mon Sep 17 00:00:00 2001
From: Lenz Weber-Tronic
Date: Fri, 14 Jul 2023 16:45:53 +0200
Subject: [PATCH] restart simulated streamed request on stream close
---
integration-test/fixture.ts | 1 -
.../src/app/cc/dynamic/apollo-client.test.ts | 42 -----------
.../src/app/cc/dynamic/dynamic.test.ts | 62 +++++++++++++++++
.../page.tsx | 52 ++++++++++++++
.../src/app/cc/static/apollo-client.test.ts | 12 ----
.../src/app/cc/static/static.test.ts | 32 +++++++++
.../page.tsx | 50 ++++++++++++++
integration-test/src/shared/delayLink.ts | 2 +-
package/src/ssr/NextSSRApolloClient.tsx | 69 +++++++++++++++----
9 files changed, 251 insertions(+), 71 deletions(-)
delete mode 100644 integration-test/src/app/cc/dynamic/apollo-client.test.ts
create mode 100644 integration-test/src/app/cc/dynamic/dynamic.test.ts
create mode 100644 integration-test/src/app/cc/dynamic/useBackgroundQueryWithoutSsrReadQuery/page.tsx
delete mode 100644 integration-test/src/app/cc/static/apollo-client.test.ts
create mode 100644 integration-test/src/app/cc/static/static.test.ts
create mode 100644 integration-test/src/app/cc/static/useBackgroundQueryWithoutSsrReadQuery/page.tsx
diff --git a/integration-test/fixture.ts b/integration-test/fixture.ts
index 62e4a8df..90233272 100644
--- a/integration-test/fixture.ts
+++ b/integration-test/fixture.ts
@@ -23,7 +23,6 @@ expect.extend({
});
export const test = base.extend<{
- withHar: import("@playwright/test").Page;
blockRequest: import("@playwright/test").Page;
}>({
page: async ({ page }, use) => {
diff --git a/integration-test/src/app/cc/dynamic/apollo-client.test.ts b/integration-test/src/app/cc/dynamic/apollo-client.test.ts
deleted file mode 100644
index 9d001c14..00000000
--- a/integration-test/src/app/cc/dynamic/apollo-client.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { expect } from "@playwright/test";
-import { test } from "../../../../fixture";
-
-test.describe("CC dynamic", () => {
- test("useSuspenseQuery (one query)", async ({ page, blockRequest }) => {
- await page.goto("http://localhost:3000/cc/dynamic/useSuspenseQuery", {
- waitUntil: "commit",
- });
-
- await expect(page).toBeInitiallyLoading(false);
- await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
- });
-
- test("useBackgroundQuery (one query)", async ({ page, blockRequest }) => {
- await page.goto("http://localhost:3000/cc/dynamic/useBackgroundQuery", {
- waitUntil: "commit",
- });
-
- await expect(page).toBeInitiallyLoading(true);
- await expect(page.getByText("loading")).not.toBeVisible();
- await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
- });
-
- test("useQuery", async ({ page }) => {
- await page.goto("http://localhost:3000/cc/dynamic/useQuery", {
- waitUntil: "commit",
- });
-
- await expect(page).toBeInitiallyLoading(true);
- await expect(page.getByText("loading")).not.toBeVisible();
- await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
- });
-
- test("useQuery (with cache value)", async ({ page }) => {
- await page.goto("http://localhost:3000/cc/dynamic/useQueryWithCache", {
- waitUntil: "commit",
- });
-
- await expect(page).toBeInitiallyLoading(false);
- await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
- });
-});
diff --git a/integration-test/src/app/cc/dynamic/dynamic.test.ts b/integration-test/src/app/cc/dynamic/dynamic.test.ts
new file mode 100644
index 00000000..a9db931a
--- /dev/null
+++ b/integration-test/src/app/cc/dynamic/dynamic.test.ts
@@ -0,0 +1,62 @@
+import { expect } from "@playwright/test";
+import { test } from "../../../../fixture";
+
+test.describe("CC dynamic", () => {
+ test.describe("useSuspenseQuery", () => {
+ test("one query", async ({ page, blockRequest }) => {
+ await page.goto("http://localhost:3000/cc/dynamic/useSuspenseQuery", {
+ waitUntil: "commit",
+ });
+
+ await expect(page).toBeInitiallyLoading(false);
+ await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
+ });
+ });
+ test.describe("useBackgroundQuery", () => {
+ test("one query", async ({ page, blockRequest }) => {
+ await page.goto("http://localhost:3000/cc/dynamic/useBackgroundQuery", {
+ waitUntil: "commit",
+ });
+
+ await expect(page).toBeInitiallyLoading(true);
+ await expect(page.getByText("loading")).not.toBeVisible();
+ await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
+ });
+
+ // this will close the connection before the final result is received, so it can never be forwarded
+ test("no `useReadQuery` on the server", async ({ page }) => {
+ await page.goto(
+ "http://localhost:3000/cc/dynamic/useBackgroundQueryWithoutSsrReadQuery",
+ {
+ waitUntil: "commit",
+ }
+ );
+
+ await expect(page.getByText("rendered on server")).toBeVisible();
+ await expect(page.getByText("rendered on client")).toBeVisible();
+ await expect(page.getByText("loading")).toBeVisible();
+ await expect(page.getByText("loading")).not.toBeVisible();
+ await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
+ });
+ });
+ test.describe("useQuery", () => {
+ test("without cache value", async ({ page }) => {
+ await page.goto("http://localhost:3000/cc/dynamic/useQuery", {
+ waitUntil: "commit",
+ });
+
+ await expect(page).toBeInitiallyLoading(true);
+ await expect(page.getByText("loading")).not.toBeVisible();
+ await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
+ });
+
+ test("with cache value", async ({ page }) => {
+ await page.goto("http://localhost:3000/cc/dynamic/useQueryWithCache", {
+ waitUntil: "commit",
+ });
+
+ await expect(page).toBeInitiallyLoading(false);
+ await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
+ });
+ });
+});
diff --git a/integration-test/src/app/cc/dynamic/useBackgroundQueryWithoutSsrReadQuery/page.tsx b/integration-test/src/app/cc/dynamic/useBackgroundQueryWithoutSsrReadQuery/page.tsx
new file mode 100644
index 00000000..86956216
--- /dev/null
+++ b/integration-test/src/app/cc/dynamic/useBackgroundQueryWithoutSsrReadQuery/page.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import {
+ useBackgroundQuery,
+ useReadQuery,
+} from "@apollo/experimental-nextjs-app-support/ssr";
+import type { TypedDocumentNode } from "@apollo/client";
+import { gql, QueryReference } from "@apollo/client";
+import { Suspense, useState, useEffect } from "react";
+
+interface Data {
+ products: {
+ id: string;
+ title: string;
+ }[];
+}
+
+const QUERY: TypedDocumentNode = gql`
+ query dynamicProducts {
+ products {
+ id
+ title
+ }
+ }
+`;
+
+export const dynamic = "force-dynamic";
+
+export default function Page() {
+ const [queryRef] = useBackgroundQuery(QUERY, { context: { delay: 2000 } });
+ const [isClient, setIsClient] = useState(false);
+ useEffect(() => setIsClient(true), []);
+ return (
+ <>
+ {isClient ? "rendered on client" : "rendered on server"}
+ loading
}>
+ {isClient ? : null}
+
+ >
+ );
+}
+
+function DisplayData({ queryRef }: { queryRef: QueryReference }) {
+ const { data } = useReadQuery(queryRef);
+ return (
+
+ {data.products.map(({ id, title }) => (
+ - {title}
+ ))}
+
+ );
+}
diff --git a/integration-test/src/app/cc/static/apollo-client.test.ts b/integration-test/src/app/cc/static/apollo-client.test.ts
deleted file mode 100644
index d44d3e96..00000000
--- a/integration-test/src/app/cc/static/apollo-client.test.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { expect } from "@playwright/test";
-import { test } from "../../../../fixture";
-
-test.describe("CC static", () => {
- test("useSuspenseQuery (one query)", async ({ page, blockRequest }) => {
- await page.goto("http://localhost:3000/cc/static/useSuspenseQuery", {
- waitUntil: "commit",
- });
-
- await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
- });
-});
diff --git a/integration-test/src/app/cc/static/static.test.ts b/integration-test/src/app/cc/static/static.test.ts
new file mode 100644
index 00000000..32ccc048
--- /dev/null
+++ b/integration-test/src/app/cc/static/static.test.ts
@@ -0,0 +1,32 @@
+import { expect } from "@playwright/test";
+import { test } from "../../../../fixture";
+
+test.describe("CC static", () => {
+ test.describe("useSuspenseQuery", () => {
+ test("one query", async ({ page, blockRequest }) => {
+ await page.goto("http://localhost:3000/cc/static/useSuspenseQuery", {
+ waitUntil: "commit",
+ });
+
+ await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
+ });
+ });
+
+ test.describe("useBackgroundQuery", () => {
+ // this will close the connection before the final result is received, so it can never be forwarded
+ test("no `useReadQuery` on the server", async ({ page }) => {
+ await page.goto(
+ "http://localhost:3000/cc/static/useBackgroundQueryWithoutSsrReadQuery",
+ {
+ waitUntil: "commit",
+ }
+ );
+
+ await expect(page.getByText("rendered on server")).toBeVisible();
+ await expect(page.getByText("rendered on client")).toBeVisible();
+ await expect(page.getByText("loading")).toBeVisible();
+ await expect(page.getByText("loading")).not.toBeVisible();
+ await expect(page.getByText("Soft Warm Apollo Beanie")).toBeVisible();
+ });
+ });
+});
diff --git a/integration-test/src/app/cc/static/useBackgroundQueryWithoutSsrReadQuery/page.tsx b/integration-test/src/app/cc/static/useBackgroundQueryWithoutSsrReadQuery/page.tsx
new file mode 100644
index 00000000..c8f19568
--- /dev/null
+++ b/integration-test/src/app/cc/static/useBackgroundQueryWithoutSsrReadQuery/page.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import {
+ useBackgroundQuery,
+ useReadQuery,
+} from "@apollo/experimental-nextjs-app-support/ssr";
+import type { TypedDocumentNode } from "@apollo/client";
+import { gql, QueryReference } from "@apollo/client";
+import { Suspense, useState, useEffect } from "react";
+
+interface Data {
+ products: {
+ id: string;
+ title: string;
+ }[];
+}
+
+const QUERY: TypedDocumentNode = gql`
+ query dynamicProducts {
+ products {
+ id
+ title
+ }
+ }
+`;
+
+export default function Page() {
+ const [queryRef] = useBackgroundQuery(QUERY, { context: { delay: 2000 } });
+ const [isClient, setIsClient] = useState(false);
+ useEffect(() => setIsClient(true), []);
+ return (
+ <>
+ {isClient ? "rendered on client" : "rendered on server"}
+ loading}>
+ {isClient ? : null}
+
+ >
+ );
+}
+
+function DisplayData({ queryRef }: { queryRef: QueryReference }) {
+ const { data } = useReadQuery(queryRef);
+ return (
+
+ {data.products.map(({ id, title }) => (
+ - {title}
+ ))}
+
+ );
+}
diff --git a/integration-test/src/shared/delayLink.ts b/integration-test/src/shared/delayLink.ts
index 27985d17..d789b796 100644
--- a/integration-test/src/shared/delayLink.ts
+++ b/integration-test/src/shared/delayLink.ts
@@ -11,7 +11,7 @@ export const delayLink = new ApolloLink((operation, forward) => {
return new Observable((observer) => {
const timeout = setTimeout(() => {
forward(operation).subscribe(observer);
- }, 1500);
+ }, operation.getContext().delay ?? 1500);
return () => clearTimeout(timeout);
});
});
diff --git a/package/src/ssr/NextSSRApolloClient.tsx b/package/src/ssr/NextSSRApolloClient.tsx
index 9e995f0f..3a10b677 100644
--- a/package/src/ssr/NextSSRApolloClient.tsx
+++ b/package/src/ssr/NextSSRApolloClient.tsx
@@ -16,6 +16,7 @@ import {
ApolloBackgroundQueryTransport,
ApolloResultCache,
} from "./ApolloRehydrateSymbols";
+import invariant from "ts-invariant";
function getQueryManager(
client: ApolloClient
@@ -23,6 +24,12 @@ function getQueryManager(
return client["queryManager"];
}
+type SimulatedQueryInfo = {
+ resolve: (result: FetchResult) => void;
+ reject: (reason: any) => void;
+ options: WatchQueryOptions;
+};
+
export class NextSSRApolloClient<
TCacheShape
> extends ApolloClient {
@@ -39,10 +46,7 @@ export class NextSSRApolloClient<
this.registerWindowHook();
}
- private resolveFakeQueries = new Map<
- string,
- [(result: FetchResult) => void, (reason: any) => void]
- >();
+ private simulatedStreamingQueries = new Map();
private identifyUniqueQuery(options: {
query: DocumentNode;
@@ -73,6 +77,9 @@ export class NextSSRApolloClient<
registerLateInitializingQueue(
ApolloBackgroundQueryTransport,
(options) => {
+ // we are not streaming anymore, so we should not simulate "server-side requests"
+ if (document.readyState === "complete") return;
+
const { query, varJson, cacheKey } =
this.identifyUniqueQuery(options);
@@ -90,10 +97,7 @@ export class NextSSRApolloClient<
);
if (!byVariables.has(varJson)) {
- let resolveFakeQuery: [
- (result: FetchResult) => void,
- (reason: any) => void
- ],
+ let simulatedStreamingQuery: SimulatedQueryInfo,
observable: Observable,
fetchCancelFn: (reason: unknown) => void;
@@ -106,14 +110,17 @@ export class NextSSRApolloClient<
if (byVariables.get(varJson) === observable)
byVariables.delete(varJson);
- if (this.resolveFakeQueries.get(cacheKey) === resolveFakeQuery)
- this.resolveFakeQueries.delete(cacheKey);
+ if (
+ this.simulatedStreamingQueries.get(cacheKey) ===
+ simulatedStreamingQuery
+ )
+ this.simulatedStreamingQueries.delete(cacheKey);
};
const promise = new Promise((resolve, reject) => {
- this.resolveFakeQueries.set(
+ this.simulatedStreamingQueries.set(
cacheKey,
- (resolveFakeQuery = [resolve, reject])
+ (simulatedStreamingQuery = { resolve, reject, options })
);
});
@@ -135,8 +142,8 @@ export class NextSSRApolloClient<
queryManager["fetchCancelFns"].set(
cacheKey,
(fetchCancelFn = (reason: unknown) => {
- const [_, reject] =
- this.resolveFakeQueries.get(cacheKey) ?? [];
+ const { reject } =
+ this.simulatedStreamingQueries.get(cacheKey) ?? {};
if (reject) {
reject(reason);
}
@@ -146,12 +153,44 @@ export class NextSSRApolloClient<
}
}
);
+ if (document.readyState !== "complete") {
+ const rerunSimulatedQueries = () => {
+ const queryManager = getQueryManager(this);
+ // streaming finished, so we need to refire all "server-side requests"
+ // that are still not resolved on the browser side to make sure we have all the data
+ for (const [cacheKey, queryInfo] of this
+ .simulatedStreamingQueries) {
+ this.simulatedStreamingQueries.delete(cacheKey);
+ invariant.debug(
+ "streaming connection closed before server query could be fully transported, rerunning:",
+ queryInfo.options
+ );
+ const queryId = queryManager.generateQueryId();
+ queryManager
+ .fetchQuery(queryId, {
+ ...queryInfo.options,
+ context: {
+ ...queryInfo.options.context,
+ queryDeduplication: false,
+ },
+ })
+ .finally(() => queryManager.stopQuery(queryId))
+ .then(queryInfo.resolve, queryInfo.reject);
+ }
+ };
+ // happens simulatenously to `readyState` changing to `"complete"`, see
+ // https://html.spec.whatwg.org/multipage/parsing.html#the-end (step 9.1 and 9.5)
+ window.addEventListener("load", rerunSimulatedQueries, {
+ once: true,
+ });
+ }
}
if (Array.isArray(window[ApolloResultCache] || [])) {
registerLateInitializingQueue(ApolloResultCache, (data) => {
const { cacheKey } = this.identifyUniqueQuery(data);
- const [resolve] = this.resolveFakeQueries.get(cacheKey) ?? [];
+ const { resolve } =
+ this.simulatedStreamingQueries.get(cacheKey) ?? {};
if (resolve) {
resolve({