Repository: remix-run/remix
Pull request #6287 (2023-05-02 23:49:00)

feat: retry revalidation caused by HDR
Closes: #6286

- [ ] Docs
- [ ] Tests

Testing Strategy:


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 20e7654cd0f461928638fa4b81f9a705828d161a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/remix-run/remix/new/server_down_race?filename=.changeset/dry-crews-pump.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Freact%22%3A%20patch%0A---%0A%0Afeat%3A%20retry%20revalidation%20caused%20by%20HDR%0A)



Patch:
diff --git a/.changeset/hdr-revalidation-retry.md b/.changeset/hdr-revalidation-retry.md
new file mode 100644
index 000000000..949fb91bc
--- /dev/null
+++ b/.changeset/hdr-revalidation-retry.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/react": patch
+---
+
+retry HDR revalidations in development mode to aid in 3rd party server race conditions
diff --git a/.changeset/req-clone-instanceof.md b/.changeset/req-clone-instanceof.md
new file mode 100644
index 000000000..a83a3f1e2
--- /dev/null
+++ b/.changeset/req-clone-instanceof.md
@@ -0,0 +1,6 @@
+---
+"remix": patch
+"@remix-run/node": patch
+---
+
+Fix `request.clone() instanceof Request` returning false.
diff --git a/contributors.yml b/contributors.yml
index efe470f9d..dcac6473e 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -491,6 +491,7 @@
 - tombiju
 - tombyrer
 - TomerAberbach
+- tomer-yechiel
 - toyozaki
 - turkerdev
 - tvanantwerp
diff --git a/packages/remix-node/fetch.ts b/packages/remix-node/fetch.ts
index ce42c4ed8..f586469dd 100644
--- a/packages/remix-node/fetch.ts
+++ b/packages/remix-node/fetch.ts
@@ -41,7 +41,7 @@ class NodeRequest extends WebRequest {
   }
 
   public clone(): NodeRequest {
-    return super.clone() as NodeRequest;
+    return new NodeRequest(this);
   }
 }
 
diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx
index 821731a3d..f56510d6e 100644
--- a/packages/remix-react/browser.tsx
+++ b/packages/remix-react/browser.tsx
@@ -30,6 +30,7 @@ declare global {
   };
   var __remixRouteModules: RouteModules;
   var __remixManifest: EntryContext["manifest"];
+  var __remixRevalidation: number | undefined;
   var $RefreshRuntime$: {
     performReactRefresh: () => void;
   };
@@ -139,6 +140,7 @@ if (import.meta && import.meta.hot) {
           }, 1);
         }
       });
+      window.__remixRevalidation = (window.__remixRevalidation || 0) + 1;
       router.revalidate();
     }
   );
diff --git a/packages/remix-react/data.ts b/packages/remix-react/data.ts
index a065b65c4..675b84137 100644
--- a/packages/remix-react/data.ts
+++ b/packages/remix-react/data.ts
@@ -14,7 +14,7 @@ export function isCatchResponse(response: Response): boolean {
   return response.headers.get("X-Remix-Catch") != null;
 }
 
-export function isErrorResponse(response: Response): boolean {
+export function isErrorResponse(response: any): response is Response {
   return response.headers.get("X-Remix-Error") != null;
 }
 
@@ -28,7 +28,8 @@ export function isDeferredResponse(response: Response): boolean {
 
 export async function fetchData(
   request: Request,
-  routeId: string
+  routeId: string,
+  retry = 0
 ): Promise<Response | Error> {
   let url = new URL(request.url);
   url.searchParams.set("_data", routeId);
@@ -47,7 +48,24 @@ export async function fetchData(
         : await request.formData();
   }
 
-  let response = await fetch(url.href, init);
+  if (retry > 0) {
+    // Retry up to 3 times waiting 50, 250, 1250 ms
+    // between retries for a total of 1550 ms before giving up.
+    await new Promise((resolve) => setTimeout(resolve, 5 ** retry * 10));
+  }
+
+  let revalidation = window.__remixRevalidation;
+  let response = await fetch(url.href, init).catch((error) => {
+    if (
+      typeof revalidation === "number" &&
+      revalidation === window.__remixRevalidation &&
+      error?.name === "TypeError" &&
+      retry < 3
+    ) {
+      return fetchData(request, routeId, retry + 1);
+    }
+    throw error;
+  });
 
   if (isErrorResponse(response)) {
     let data = await response.json();


Test patch:
diff --git a/packages/remix-node/__tests__/fetch-test.ts b/packages/remix-node/__tests__/fetch-test.ts
index 9468160d6..4fd79135e 100644
--- a/packages/remix-node/__tests__/fetch-test.ts
+++ b/packages/remix-node/__tests__/fetch-test.ts
@@ -1,4 +1,5 @@
 import { PassThrough } from "stream";
+import { ReadableStream } from "@remix-run/web-stream";
 
 import { Request } from "../fetch";
 
@@ -70,9 +71,15 @@ let test = {
 
 describe("Request", () => {
   it("clones", async () => {
-    let body = new PassThrough();
-    test.source.forEach((chunk) => body.write(chunk));
-    body.end();
+    let encoder = new TextEncoder();
+    let body = new ReadableStream({
+      start(controller) {
+        test.source.forEach((chunk) => {
+          controller.enqueue(encoder.encode(chunk));
+        });
+        controller.close();
+      },
+    });
 
     let req = new Request("http://test.com", {
       method: "post",
@@ -103,6 +110,8 @@ describe("Request", () => {
     file = clonedFormData.get("upload_file_1") as File;
     expect(file.name).toBe("1k_b.dat");
     expect(file.size).toBe(1023);
+
+    expect(cloned instanceof Request).toBeTruthy();
   });
 });