Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix error occurring in useEffect on hmr #308

Open
wants to merge 28 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
441b1df
fix error with hot module replacement
PatrykWalach Nov 12, 2024
10ce0e2
update tests
PatrykWalach Nov 12, 2024
61b16b9
fix cleanup
PatrykWalach Nov 14, 2024
4fbfb36
don't use stale itemCleanupPairRef
PatrykWalach Nov 14, 2024
e9d1845
add comment for guards added in `useEffects`
PatrykWalach Jan 3, 2025
ff7cd3b
Merge branch 'main' into fix-use-effect-error-on-hmr-rebase-2
PatrykWalach Jan 10, 2025
52bef46
fix throwing in `useDisposableState`
PatrykWalach Jan 10, 2025
820a8f7
fix double cleanup in `useUpdatableDisposableState`
PatrykWalach Jan 10, 2025
23fb36a
Merge branch 'fix-use-effect-error-on-hmr-rebase-2' of github.com:Pat…
PatrykWalach Jan 10, 2025
20c773e
detect hmr
PatrykWalach Jan 13, 2025
78c6037
fix errors
PatrykWalach Jan 13, 2025
2e24a7f
set `itemCleanupPairRef` to null on cleanup
PatrykWalach Jan 13, 2025
3f441e0
`revert last`
PatrykWalach Jan 13, 2025
d3de5d4
use ref of type bool to detect hmr
PatrykWalach Jan 13, 2025
ef925c6
fix error with hot module replacement
PatrykWalach Nov 12, 2024
a6af861
update tests
PatrykWalach Nov 12, 2024
b377add
fix cleanup
PatrykWalach Nov 14, 2024
faf696a
don't use stale itemCleanupPairRef
PatrykWalach Nov 14, 2024
8352c72
add comment for guards added in `useEffects`
PatrykWalach Jan 3, 2025
c0083e3
fix throwing in `useDisposableState`
PatrykWalach Jan 10, 2025
bef4639
fix double cleanup in `useUpdatableDisposableState`
PatrykWalach Jan 10, 2025
652be84
detect hmr
PatrykWalach Jan 13, 2025
8b0fdab
fix errors
PatrykWalach Jan 13, 2025
e329219
set `itemCleanupPairRef` to null on cleanup
PatrykWalach Jan 13, 2025
ad22a27
`revert last`
PatrykWalach Jan 13, 2025
50f95ea
use ref of type bool to detect hmr
PatrykWalach Jan 13, 2025
402ac00
Merge branches 'fix-use-effect-error-on-hmr-rebase-2' and 'fix-use-ef…
PatrykWalach Jan 13, 2025
420d291
Merge branch 'main' into fix-use-effect-error-on-hmr-rebase-2
PatrykWalach Jan 21, 2025
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
1 change: 1 addition & 0 deletions libs/isograph-react-disposable-state/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"devDependencies": {
"@types/react": "18.3.1",
"@types/react-test-renderer": "^18.3.0",
"react-test-renderer": "^18.2.0",
"typescript": "5.6.3"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, test, vi, expect, assert } from 'vitest';
import { ParentCache } from './ParentCache';
import { ItemCleanupPair } from '@isograph/disposable-types';
import { useCachedResponsivePrecommitValue } from './useCachedResponsivePrecommitValue';
import React from 'react';
import React, { StrictMode, type ReactElement } from 'react';
import { create } from 'react-test-renderer';
import { CacheItem, CacheItemState } from './CacheItem';

Expand Down Expand Up @@ -51,9 +51,10 @@ function promiseAndResolver() {

// The fact that sometimes we need to render in concurrent mode and sometimes
// not is a bit worrisome.
async function awaitableCreate(Component, isConcurrent) {
async function awaitableCreate(Component: ReactElement, isConcurrent) {
const element = create(
Component,
<StrictMode>{Component}</StrictMode>,
// @ts-expect-error
isConcurrent ? { unstable_isConcurrent: true } : undefined,
);
await shortPromise();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ export function useCachedResponsivePrecommitValue<T>(
const lastCommittedParentCache = useRef<ParentCache<T> | null>(null);

useEffect(() => {
// react reruns all `useEffect` in HMR since it doesn't know if the
Copy link
Collaborator

Choose a reason for hiding this comment

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

    // Since React doesn't know if the code inside of a useEffect has changed,
    // it unmounts and remounts all useEffect calls when doing HMR. This breaks
    // this hook's assumptions! The `onCommit` is called twice.
    //
    // Since this is a library, the user can't change this code, so we are safe
    // to skip this rerun.
    //
    // This also prevents the useEffect from running twice in Strict Mode.

// code inside of useEffect has changed. Since this is a library
// user can't change this code so we are safe to skip this rerun.
// This also prevents `useEffect` from running twice in Strict Mode.
if (lastCommittedParentCache.current === parentCache) {
return;
}

lastCommittedParentCache.current = parentCache;
// On commit, cacheItem may be disposed, because during the render phase,
// we only temporarily retained the item, and the temporary retain could have
Expand Down
24 changes: 16 additions & 8 deletions libs/isograph-react-disposable-state/src/useDisposableState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export function useDisposableState<T = never>(
const preCommitItem = useCachedResponsivePrecommitValue(
parentCache,
(pair) => {
itemCleanupPairRef.current?.[1]();
itemCleanupPairRef.current = pair;
},
);
Expand All @@ -47,14 +46,23 @@ export function useDisposableState<T = never>(
[stateFromDisposableStateHook],
);

useEffect(function cleanupItemCleanupPairRefIfSetStateNotCalled() {
return () => {
if (itemCleanupPairRef.current !== null) {
itemCleanupPairRef.current[1]();
itemCleanupPairRef.current = null;
const lastCommittedParentCache = useRef<ParentCache<T> | null>(null);

useEffect(
function cleanupItemCleanupPairRefIfSetStateNotCalled() {
if (lastCommittedParentCache.current === parentCache) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

    // Since React doesn't know if the code inside of a useEffect has changed,
    // it unmounts and remounts all useEffect calls when doing HMR. This breaks
    // this hook's assumptions! The item in the `itemCleanupPairRef` will be disposed.
    //
    // Since this is a library, the user can't change this code, so we are safe
    // to skip this rerun.
    //
    // This also prevents the useEffect from running twice in Strict Mode.

Do we also need to have this check above, in the other useEffect?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The second one will not throw because it sets the ref to null. However, because of this, we will lose the current state, which is not perfect.

Copy link
Collaborator

Choose a reason for hiding this comment

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

yeah, seems better to not set it to null!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh wait, this should be good, because it will cleanup only when we set with a function and when the ref is still not cleaned up, hmr shouldn't be able to trigger it twice

Copy link
Collaborator

Choose a reason for hiding this comment

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

wdym? Which useEffect are you referring to?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

useEffect(
    function cleanupItemCleanupPairRefAfterSetState() {
      if (stateFromDisposableStateHook !== UNASSIGNED_STATE) {
        if (itemCleanupPairRef.current !== null) {
          itemCleanupPairRef.current[1]();
          itemCleanupPairRef.current = null;
        } else {
          throw new Error(
            'itemCleanupPairRef.current is unexpectedly null. ' +
              'This indicates a bug in react-disposable-state.',
          );
        }
      }
    },
    [stateFromDisposableStateHook],
  );

This one should cleanup only when these two conditions are met, reruning this on hmr has no effect.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I posted this stuff way too quickly. This will throw on hmr but I think just removing the throw should be enough

return;
}
};
}, []);
lastCommittedParentCache.current = parentCache;

return () => {
if (itemCleanupPairRef.current !== null) {
itemCleanupPairRef.current[1]();
}
};
},
[parentCache],
);

// Safety: we can be in one of three states. Pre-commit, in which case
// preCommitItem is assigned, post-commit but before setState has been
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ItemCleanupPair } from '@isograph/disposable-types';
import React, { useEffect, useState } from 'react';
import React, { StrictMode, useEffect, useState } from 'react';
import { create } from 'react-test-renderer';
import { describe, expect, test, vi } from 'vitest';
import { ParentCache } from './ParentCache';
Expand Down Expand Up @@ -55,7 +55,12 @@ describe('useLazyDisposableState', async () => {
return null;
}

const root = create(<TestComponent />, { unstable_isConcurrent: true });
const root = create(
<StrictMode>
<TestComponent />
</StrictMode>,
{ unstable_isConcurrent: true },
);
await committed.promise;
expect(cache1.disposeItem).toHaveBeenCalled();
expect(cache1.cache.factory).toHaveBeenCalledOnce();
Expand Down
14 changes: 11 additions & 3 deletions libs/isograph-react-disposable-state/src/useLazyDisposableState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,24 @@ export function useLazyDisposableState<T>(
state: T;
} {
const itemCleanupPairRef = useRef<ItemCleanupPair<T> | null>(null);

const preCommitItem = useCachedResponsivePrecommitValue(
parentCache,
(pair) => {
itemCleanupPairRef.current?.[1]();
itemCleanupPairRef.current = pair;
},
);

const lastCommittedParentCache = useRef<ParentCache<T> | null>(null);
useEffect(() => {
// react reruns all `useEffect` in HMR since it doesn't know if the
Copy link
Collaborator

Choose a reason for hiding this comment

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

    // Since React doesn't know if the code inside of a useEffect has changed,
    // it unmounts and remounts all useEffect calls when doing HMR. This breaks
    // this hook's assumptions! The cleanupFn is called unexpectedly during HMR,
    // and the item is not recreated. (Even recreating the item would be a bad outcome,
    // as that would reissue network requests.)
    //
    // Since this is a library, the user can't change this code, so we are safe
    // to skip this rerun.
    //
    // This also prevents the useEffect from running twice in Strict Mode.

// code inside of useEffect has changed. Since this is a library
// user can't change this code so we are safe to skip this rerun.
// This also prevents `useEffect` from running twice in Strict Mode.
if (lastCommittedParentCache.current === parentCache) {
return;
}
lastCommittedParentCache.current = parentCache;

return () => {
const cleanupFn = itemCleanupPairRef.current?.[1];
// TODO confirm useEffect is called in order.
Expand All @@ -43,7 +51,7 @@ export function useLazyDisposableState<T>(
}
return cleanupFn();
};
}, []);
}, [parentCache]);

const returnedItem = preCommitItem?.state ?? itemCleanupPairRef.current?.[0];

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, vi, expect } from 'vitest';
import React from 'react';
import React, { StrictMode } from 'react';
import { create } from 'react-test-renderer';
import {
useUpdatableDisposableState,
Expand Down Expand Up @@ -45,7 +45,8 @@ function promiseAndResolver() {
// not is a bit worrisome.
async function awaitableCreate(Component, isConcurrent) {
const element = create(
Component,
<StrictMode>{Component}</StrictMode>,

isConcurrent ? { unstable_isConcurrent: true } : undefined,
);
await shortPromise();
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading