Repository: remix-run/remix
Pull request #3964 (2022-08-08 19:45:10)

chore(remix-react): add `subscribe` method to `transitionManager`
chore: update RemixEntry to use new subscribe method

<!--

👋 Hey, thanks for your interest in contributing to Remix!

If this is a simple docs change, go ahead and delete all this.

**Please ask first before starting work on any significant new features.**

It's never a fun experience to have your pull request declined after investing a
lot of time and effort into a new feature. To avoid this from happening, we
request that contributors create a
[Feature Request discussion](https://github.com/remix-run/remix/discussions/new?category=ideas)
to first discuss any significant new features.

https://github.com/remix-run/remix/blob/main/CONTRIBUTING.md

Please fill in or delete each item below:

-->

Closes: #

- [ ] Docs
- [ ] Tests

Testing Strategy:

<!--
Please explain how you tested this. For example:

> This test covers this code: <link to test>

Or

> I opened up my windows machine and ran this script:
>
> ```
> npx create-remix@0.0.0-experimental-7e420ee3 --template remix my-test
> cd my-test
> npm run dev
> ```
-->


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: d160eb97e16388a71f12341a6e774549983ab2eb

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 16 packages</summary>

  | Name                          | Type  |
| ----------------------------- | ----- |
| @remix-run/react              | Patch |
| create-remix                  | Patch |
| remix                         | Patch |
| @remix-run/dev                | Patch |
| @remix-run/eslint-config      | Patch |
| @remix-run/serve              | Patch |
| @remix-run/server-runtime     | Patch |
| @remix-run/cloudflare         | Patch |
| @remix-run/node               | Patch |
| @remix-run/deno               | Patch |
| @remix-run/cloudflare-pages   | Patch |
| @remix-run/cloudflare-workers | Patch |
| @remix-run/express            | Patch |
| @remix-run/netlify            | Patch |
| @remix-run/vercel             | Patch |
| @remix-run/architect          | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/remix-run/remix/new/react18-transition?filename=.changeset/neat-lions-perform.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Freact%22%3A%20patch%0A---%0A%0Achore%3A%20add%20subscribe%20method%20to%20transition%20manager%0A)


ryanflorence: 👍

Patch:
diff --git a/.changeset/slow-apples-pretend.md b/.changeset/slow-apples-pretend.md
new file mode 100644
index 000000000..7547330d3
--- /dev/null
+++ b/.changeset/slow-apples-pretend.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/react": patch
+---
+
+Add subscribe method to transition manager to allow subscribing and un-subscribing for React 18 strict mode compliance.
diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx
index 3a6bef315..4627a44e0 100644
--- a/packages/remix-react/components.tsx
+++ b/packages/remix-react/components.tsx
@@ -49,7 +49,12 @@ import type { RouteMatch as BaseRouteMatch } from "./routeMatching";
 import { matchClientRoutes } from "./routeMatching";
 import type { RouteModules, HtmlMetaDescriptor } from "./routeModules";
 import { createTransitionManager } from "./transition";
-import type { Transition, Fetcher, Submission } from "./transition";
+import type {
+  Transition,
+  TransitionManagerState,
+  Fetcher,
+  Submission,
+} from "./transition";
 
 ////////////////////////////////////////////////////////////////////////////////
 // RemixEntry
@@ -117,20 +122,25 @@ export function RemixEntry({
       catch: entryComponentDidCatchEmulator.catch,
       catchBoundaryId: entryComponentDidCatchEmulator.catchBoundaryRouteId,
       onRedirect: _navigator.replace,
-      onChange: (state) => {
-        setClientState({
-          catch: state.catch,
-          error: state.error,
-          catchBoundaryRouteId: state.catchBoundaryId,
-          loaderBoundaryRouteId: state.errorBoundaryId,
-          renderBoundaryRouteId: null,
-          trackBoundaries: false,
-          trackCatchBoundaries: false,
-        });
-      },
     });
   });
 
+  React.useEffect(() => {
+    let subscriber = (state: TransitionManagerState) => {
+      setClientState({
+        catch: state.catch,
+        error: state.error,
+        catchBoundaryRouteId: state.catchBoundaryId,
+        loaderBoundaryRouteId: state.errorBoundaryId,
+        renderBoundaryRouteId: null,
+        trackBoundaries: false,
+        trackCatchBoundaries: false,
+      });
+    };
+
+    return transitionManager.subscribe(subscriber);
+  }, [transitionManager]);
+
   // Ensures pushes interrupting pending navigations use replace
   // TODO: Move this to React Router
   let navigator: Navigator = React.useMemo(() => {
diff --git a/packages/remix-react/transition.ts b/packages/remix-react/transition.ts
index 346fd5d76..0249db948 100644
--- a/packages/remix-react/transition.ts
+++ b/packages/remix-react/transition.ts
@@ -97,10 +97,13 @@ export interface TransitionManagerInit {
   error?: Error;
   catchBoundaryId?: null | string;
   errorBoundaryId?: null | string;
-  onChange: (state: TransitionManagerState) => void;
   onRedirect: (to: string, state?: any) => void;
 }
 
+export type TransitionManagerSubscriber = (
+  state: TransitionManagerState
+) => void;
+
 export interface Submission {
   action: string;
   method: string;
@@ -422,6 +425,7 @@ export function createTransitionManager(init: TransitionManagerInit) {
   let navigationLoadId = -1;
   let fetchReloadIds = new Map<string, number>();
   let fetchRedirectIds = new Set<string>();
+  let subscribers = new Set<TransitionManagerSubscriber>();
 
   let matches = matchClientRoutes(routes, init.location);
 
@@ -462,7 +466,10 @@ export function createTransitionManager(init: TransitionManagerInit) {
     }
 
     state = Object.assign({}, state, updates);
-    init.onChange(state);
+
+    for (let subscriber of subscribers.values()) {
+      subscriber(state);
+    }
   }
 
   function getState(): TransitionManagerState {
@@ -1382,7 +1389,15 @@ export function createTransitionManager(init: TransitionManagerInit) {
     markFetchersDone(doneKeys);
   }
 
+  function subscribe(subscriber: TransitionManagerSubscriber) {
+    subscribers.add(subscriber);
+    return () => {
+      subscribers.delete(subscriber);
+    };
+  }
+
   return {
+    subscribe,
     send,
     getState,
     getFetcher,


Test patch:
diff --git a/packages/remix-react/__tests__/transition-test.tsx b/packages/remix-react/__tests__/transition-test.tsx
index c4a37e78b..a0b87ed0e 100644
--- a/packages/remix-react/__tests__/transition-test.tsx
+++ b/packages/remix-react/__tests__/transition-test.tsx
@@ -28,7 +28,6 @@ describe("init", () => {
       actionData: { root: "ACTION DATA" },
       error: new Error("lol"),
       errorBoundaryId: "root",
-      onChange: () => {},
       onRedirect: () => {},
     });
     expect(tm.getState()).toMatchInlineSnapshot(`
@@ -1956,7 +1955,6 @@ function createTestTransitionManager(
     loaderData: { root: "ROOT" },
     location,
     routes: [],
-    onChange() {},
     onRedirect() {},
     ...init,
   });
@@ -2090,11 +2088,11 @@ let setup = ({ url } = { url: "/" }) => {
   ];
 
   let tm = createTestTransitionManager(url, {
-    onChange: handleChange,
     onRedirect: handleRedirect,
     loaderData: { root: "ROOT" },
     routes,
   });
+  tm.subscribe(handleChange);
 
   let navigate_ = (
     location: Location | string,