Skip to content

Commit ff9d956

Browse files
authored
Merge pull request storybookjs#35179 from storybookjs/jeppe/docs-useservicedocgen-react16-17
Docs: Restore React 16/17 support in useServiceDocgen
2 parents beab22c + ee9fe20 commit ff9d956

1 file changed

Lines changed: 68 additions & 2 deletions

File tree

code/addons/docs/src/blocks/blocks/useServiceDocgen.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
1+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
22

33
import type { DocgenPayload } from 'storybook/internal/types';
44

@@ -36,5 +36,71 @@ export function useServiceDocgen(id: string | undefined): DocgenPayload | undefi
3636
[service, id]
3737
);
3838

39-
return useSyncExternalStore(subscribe, getSnapshot);
39+
return useSyncExternalStoreShim(subscribe, getSnapshot);
40+
}
41+
42+
/**
43+
* Inlined fallback for React's `useSyncExternalStore`.
44+
*
45+
* `addon-docs` still supports React 16.8 and 17 (see the `react` range in `package.json`), and
46+
* `useSyncExternalStore` only exists from React 18 onwards. This is a faithful port of the official
47+
* `use-sync-external-store/shim` fallback, which is safe on React 16/17 because those versions render
48+
* synchronously and therefore can't tear (the tearing problem the real hook guards against only
49+
* occurs with concurrent rendering, which doesn't exist before React 18).
50+
*
51+
* The snapshot is recomputed on every render and returned directly, so the value is always current
52+
* during render; the `useState` updater is used solely as a force-re-render mechanism when the store
53+
* emits a change. The layout effect re-checks the snapshot to catch a store mutation that happened
54+
* between render and commit.
55+
*
56+
* TODO: Delete this shim and go back to importing `useSyncExternalStore` from `react` once we drop
57+
* support for React < 18.
58+
*/
59+
function useSyncExternalStoreShim<T>(
60+
subscribe: (onStoreChange: () => void) => () => void,
61+
getSnapshot: () => T
62+
): T {
63+
const value = getSnapshot();
64+
// `inst` is a stable mutable container that is never reassigned, so it is intentionally omitted
65+
// from the effect dependency arrays below (matching React's upstream shim). The dependency arrays
66+
// are deliberately `[subscribe, value, getSnapshot]` and `[subscribe]` to preserve the exact
67+
// re-subscription semantics of `useSyncExternalStore`.
68+
const [{ inst }, forceUpdate] = useState({ inst: { value, getSnapshot } });
69+
70+
useLayoutEffect(() => {
71+
inst.value = value;
72+
inst.getSnapshot = getSnapshot;
73+
74+
if (didSnapshotChange(inst)) {
75+
forceUpdate({ inst });
76+
}
77+
// eslint-disable-next-line react-hooks/exhaustive-deps
78+
}, [subscribe, value, getSnapshot]);
79+
80+
useEffect(() => {
81+
// Re-check on subscribe in case the store changed before the subscription was set up.
82+
if (didSnapshotChange(inst)) {
83+
forceUpdate({ inst });
84+
}
85+
86+
return subscribe(() => {
87+
if (didSnapshotChange(inst)) {
88+
forceUpdate({ inst });
89+
}
90+
});
91+
// eslint-disable-next-line react-hooks/exhaustive-deps
92+
}, [subscribe]);
93+
94+
return value;
95+
}
96+
97+
function didSnapshotChange<T>(inst: { value: T; getSnapshot: () => T }): boolean {
98+
const latestGetSnapshot = inst.getSnapshot;
99+
const prevValue = inst.value;
100+
try {
101+
const nextValue = latestGetSnapshot();
102+
return !Object.is(prevValue, nextValue);
103+
} catch {
104+
return true;
105+
}
40106
}

0 commit comments

Comments
 (0)