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({