diff --git a/.changeset/angry-papayas-accept.md b/.changeset/angry-papayas-accept.md new file mode 100644 index 000000000..e0e957254 --- /dev/null +++ b/.changeset/angry-papayas-accept.md @@ -0,0 +1,5 @@ +--- +"@preact/signals-react": minor +--- + +Revert react integration to tracking current dispatcher diff --git a/package.json b/package.json index e1b061ba0..7b964446d 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,10 @@ "license": "MIT", "devDependencies": { "@babel/core": "^7.19.1", + "@babel/plugin-transform-typescript": "^7.19.1", "@babel/preset-env": "^7.19.1", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", - "@babel/plugin-transform-typescript": "^7.19.1", "@changesets/changelog-github": "^0.4.6", "@changesets/cli": "^2.24.2", "@types/chai": "^4.3.3", diff --git a/packages/react/package.json b/packages/react/package.json index 85151ff98..24af0aab8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -49,6 +49,7 @@ "@types/react-dom": "^18.0.6", "@types/use-sync-external-store": "^0.0.3", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.9.0" } } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6ca0dfe4e..cc909b919 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -2,14 +2,16 @@ import { useRef, useMemo, useEffect, - Component, - type FunctionComponent, + // @ts-ignore-next-line + // eslint-disable-next-line @typescript-eslint/no-unused-vars + __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED as ReactInternals, type ReactElement, + type useCallback, + type useReducer, } from "react"; import React from "react"; import jsxRuntime from "react/jsx-runtime"; import jsxRuntimeDev from "react/jsx-dev-runtime"; -import { useSyncExternalStore } from "use-sync-external-store/shim/index.js"; import { signal, computed, @@ -18,96 +20,34 @@ import { Signal, type ReadonlySignal, } from "@preact/signals-core"; +import { useSyncExternalStore } from "use-sync-external-store/shim/index"; import type { Effect, JsxRuntimeModule } from "./internal"; export { signal, computed, batch, effect, Signal, type ReadonlySignal }; const Empty = [] as const; const ReactElemType = Symbol.for("react.element"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15 -const ReactMemoType = Symbol.for("react.memo"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L30 -const ReactForwardRefType = Symbol.for("react.forward_ref"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L25 -const ProxyInstance = new WeakMap< - FunctionComponent, - FunctionComponent ->(); - -const SupportsProxy = typeof Proxy === "function"; - -const ProxyHandlers = { - /** - * This is a function call trap for functional components. - * When this is called, we know it means React did run 'Component()', - * that means we can use any hooks here to setup our effect and store. - * - * With the native Proxy, all other calls such as access/setting to/of properties will - * be forwarded to the target Component, so we don't need to copy the Component's - * own or inherited properties. - * - * @see https://github.com/facebook/react/blob/2d80a0cd690bb5650b6c8a6c079a87b5dc42bd15/packages/react-reconciler/src/ReactFiberHooks.old.js#L460 - */ - apply( - Component: FunctionComponent, - thisArg: any, - argumentsList: Parameters> - ) { - const store = useMemo(createEffectStore, Empty); - - useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); - - const stop = store.updater._start(); - - try { - const children = Component.apply(thisArg, argumentsList); - return children; - // eslint-disable-next-line no-useless-catch - } catch (e) { - // Re-throwing promises that'll be handled by suspense - // or an actual error. - throw e; - } finally { - // Stop effects in either case before return or throw, - // Otherwise the effect will leak. - stop(); - } - }, -}; -function ProxyFunctionalComponent(Component: FunctionComponent) { - return ProxyInstance.get(Component) || WrapWithProxy(Component); +interface ReactDispatcher { + useRef: typeof useRef; + useCallback: typeof useCallback; + useReducer: typeof useReducer; + useSyncExternalStore: typeof useSyncExternalStore; } -function WrapWithProxy(Component: FunctionComponent) { - if (SupportsProxy) { - const ProxyComponent = new Proxy(Component, ProxyHandlers); - - ProxyInstance.set(Component, ProxyComponent); - ProxyInstance.set(ProxyComponent, ProxyComponent); - - return ProxyComponent; - } - - /** - * Emulate a Proxy if environment doesn't support it. - * - * @TODO - unlike Proxy, it's not possible to access the type/Component's - * static properties this way. Not sure if we want to copy all statics here. - * Omitting this for now. - * - * @example - works with Proxy, doesn't with wrapped function. - * ``` - * const el = - * el.type.someOwnOrInheritedProperty; - * el.type.defaultProps; - * ``` - */ - const WrappedComponent: FunctionComponent = (...args) => { - return ProxyHandlers.apply(Component, undefined, args); - }; +let finishUpdate: (() => void) | undefined; - ProxyInstance.set(Component, WrappedComponent); - ProxyInstance.set(WrappedComponent, WrappedComponent); +function setCurrentUpdater(updater?: Effect) { + // end tracking for the current update: + if (finishUpdate) finishUpdate(); + // start tracking the new update: + finishUpdate = updater && updater._start(); +} - return WrappedComponent; +interface EffectStore { + updater: Effect; + subscribe(onStoreChange: () => void): () => void; + getSnapshot(): number; } /** @@ -123,7 +63,7 @@ function WrapWithProxy(Component: FunctionComponent) { * @see https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore * @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md */ -function createEffectStore() { +function createEffectStore(): EffectStore { let updater!: Effect; let version = 0; let onChangeNotifyReact: (() => void) | undefined; @@ -138,7 +78,7 @@ function createEffectStore() { return { updater, - subscribe(onStoreChange: () => void) { + subscribe(onStoreChange) { onChangeNotifyReact = onStoreChange; return function () { @@ -163,24 +103,144 @@ function createEffectStore() { }; } -function WrapJsx(jsx: T): T { - if (typeof jsx !== "function") return jsx; +/** + * Custom hook to create the effect to track signals used during render and + * subscribe to changes to rerender the component when the signals change + */ +function usePreactSignalStore(nextDispatcher: ReactDispatcher): EffectStore { + const storeRef = nextDispatcher.useRef(); + if (storeRef.current == null) { + storeRef.current = createEffectStore(); + } - return function (type: any, props: any, ...rest: any[]) { - if (typeof type === "function" && !(type instanceof Component)) { - return jsx.call(jsx, ProxyFunctionalComponent(type), props, ...rest); + const store = storeRef.current; + useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + + return store; +} + +// To track when we are entering and exiting a component render (i.e. before and +// after React renders a component), we track how the dispatcher changes. +// Outside of a component rendering, the dispatcher is set to an instance that +// errors or warns when any hooks are called. This behavior is prevents hooks +// from being used outside of components. Right before React renders a +// component, the dispatcher is set to a valid one. Right after React finishes +// rendering a component, the dispatcher is set to an erroring one again. This +// erroring dispatcher is called the `ContextOnlyDispatcher` in React's source. +// +// So, we watch the getter and setter on `ReactCurrentDispatcher.current` to +// monitor the changes to the current ReactDispatcher. When the dispatcher +// changes from the ContextOnlyDispatcher to a valid dispatcher, we assume we +// are entering a component render. At this point, we setup our +// auto-subscriptions for any signals used in the component. We do this by +// creating an effect and manually starting the effect. We use +// `useSyncExternalStore` to trigger rerenders on the component when any signals +// it uses changes. +// +// When the dispatcher changes from a valid dispatcher back to the +// ContextOnlyDispatcher, we assume we are exiting a component render. At this +// point we stop the effect. +// +// Some edge cases to be aware of: +// - In development, useReducer, useState, and useMemo changes the dispatcher to +// a different erroring dispatcher before invoking the reducer and resets it +// right after. +// +// The useSyncExternalStore shim will use some of these hooks when we invoke +// it while entering a component render. We need to prevent this dispatcher +// change caused by these hooks from re-triggering our entering logic (it +// would cause an infinite loop if we did not). We do this by using a lock to +// prevent the setter from running while we are in the setter. +// +// When a Component's function body invokes useReducer, useState, or useMemo, +// this change in dispatcher should not signal that we are exiting a component +// render. We ignore this change by detecting these dispatchers as different +// from ContextOnlyDispatcher and other valid dispatchers. +// +// - The `use` hook will change the dispatcher to from a valid update dispatcher +// to a valid mount dispatcher in some cases. Similarly to useReducer +// mentioned above, we should not signal that we are exiting a component +// during this change. Because these other valid dispatchers do not pass the +// ContextOnlyDispatcher check, they do not affect our logic. +let lock = false; +let currentDispatcher: ReactDispatcher | null = null; +Object.defineProperty(ReactInternals.ReactCurrentDispatcher, "current", { + get() { + return currentDispatcher; + }, + set(nextDispatcher: ReactDispatcher) { + if (lock) { + currentDispatcher = nextDispatcher; + return; } - if (type && typeof type === "object") { - if (type.$$typeof === ReactMemoType) { - type.type = ProxyFunctionalComponent(type.type); - return jsx.call(jsx, type, props, ...rest); - } else if (type.$$typeof === ReactForwardRefType) { - type.render = ProxyFunctionalComponent(type.render); - return jsx.call(jsx, type, props, ...rest); - } + const currentDispatcherType = getDispatcherType(currentDispatcher); + const nextDispatcherType = getDispatcherType(nextDispatcher); + + // We are entering a component render if the current dispatcher is the + // ContextOnlyDispatcher and the next dispatcher is a valid dispatcher. + const isEnteringComponentRender = + currentDispatcherType === ContextOnlyDispatcherType && + nextDispatcherType === ValidDispatcherType; + + // We are exiting a component render if the current dispatcher is a valid + // dispatcher and the next dispatcher is the ContextOnlyDispatcher. + const isExitingComponentRender = + currentDispatcherType === ValidDispatcherType && + nextDispatcherType === ContextOnlyDispatcherType; + + // Update the current dispatcher now so the hooks inside of the + // useSyncExternalStore shim get the right dispatcher. + currentDispatcher = nextDispatcher; + if (isEnteringComponentRender) { + lock = true; + const store = usePreactSignalStore(nextDispatcher); + lock = false; + + setCurrentUpdater(store.updater); + } else if (isExitingComponentRender) { + setCurrentUpdater(); } + }, +}); + +const ValidDispatcherType = 0; +const ContextOnlyDispatcherType = 1; +const ErroringDispatcherType = 2; + +// We inject a useSyncExternalStore into every function component via +// CurrentDispatcher. This prevents injecting into anything other than a +// function component render. +const dispatcherTypeCache = new Map(); +function getDispatcherType(dispatcher: ReactDispatcher | null): number { + // Treat null the same as the ContextOnlyDispatcher. + if (!dispatcher) return ContextOnlyDispatcherType; + + const cached = dispatcherTypeCache.get(dispatcher); + if (cached !== undefined) return cached; + + // The ContextOnlyDispatcher sets all the hook implementations to a function + // that takes no arguments and throws and error. Check the number of arguments + // for this dispatcher's useCallback implementation to determine if it is a + // ContextOnlyDispatcher. All other dispatchers, erroring or not, define + // functions with arguments and so fail this check. + let type: number; + if (dispatcher.useCallback.length < 2) { + type = ContextOnlyDispatcherType; + } else if (/Invalid/.test(dispatcher.useCallback as any)) { + type = ErroringDispatcherType; + } else { + type = ValidDispatcherType; + } + + dispatcherTypeCache.set(dispatcher, type); + return type; +} +function WrapJsx(jsx: T): T { + if (typeof jsx !== "function") return jsx; + + return function (type: any, props: any, ...rest: any[]) { if (typeof type === "string" && props) { for (let i in props) { let v = props[i]; @@ -228,7 +288,7 @@ function Text({ data }: { data: Signal }) { // Decorate Signals so React renders them as components. Object.defineProperties(Signal.prototype, { $$typeof: { configurable: true, value: ReactElemType }, - type: { configurable: true, value: ProxyFunctionalComponent(Text) }, + type: { configurable: true, value: Text }, props: { configurable: true, get() { diff --git a/packages/react/test/index.test.tsx b/packages/react/test/index.test.tsx index d548e9071..5b4704f08 100644 --- a/packages/react/test/index.test.tsx +++ b/packages/react/test/index.test.tsx @@ -6,65 +6,39 @@ import { computed, useComputed, useSignalEffect, + useSignal, } from "@preact/signals-react"; import { createElement, forwardRef, useMemo, + useReducer, memo, StrictMode, createRef, } from "react"; -import { createRoot, Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; -import { act as realAct } from "react-dom/test-utils"; - -// When testing using react's production build, we can't use act (React -// explicitly throws an error in this situation). So instead we'll fake act by -// just waiting 10ms for React's concurrent rerendering to flush. We'll throw a -// helpful error in afterEach if we detect that act() was called but not -// awaited. -const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); - -let acting = false; -async function prodAct(cb: () => void | Promise): Promise { - acting = true; - await cb(); - await delay(10); - acting = false; -} +import { createRoot, Root, act, checkHangingAct } from "./utils"; describe("@preact/signals-react", () => { let scratch: HTMLDivElement; let root: Root; - let act: typeof realAct; async function render(element: Parameters[0]) { await act(() => root.render(element)); } - before(async () => { - if (process.env.NODE_ENV === "production") { - act = prodAct as typeof realAct; - } else { - act = realAct; - } - }); - - beforeEach(() => { + beforeEach(async () => { scratch = document.createElement("div"); - root = createRoot(scratch); + document.body.appendChild(scratch); + root = await createRoot(scratch); }); afterEach(async () => { - if (acting) { - throw new Error( - "Test finished while still acting. Did you await all act() and render() calls?" - ); - } - + checkHangingAct(); await act(() => root.unmount()); + scratch.remove(); }); describe("Text bindings", () => { @@ -238,7 +212,7 @@ describe("@preact/signals-react", () => { }); it("should consistently rerender in strict mode", async () => { - const sig = signal(null!); + const sig = signal(-1); const Test = () =>

{sig.value}

; const App = () => ( @@ -247,18 +221,19 @@ describe("@preact/signals-react", () => { ); - for (let i = 0; i < 3; i++) { - const value = `${i}`; + await render(); + expect(scratch.textContent).to.equal("-1"); + for (let i = 0; i < 3; i++) { await act(async () => { - sig.value = value; - await render(); + sig.value = i; }); - expect(scratch.textContent).to.equal(value); + expect(scratch.textContent).to.equal("" + i); } }); + it("should consistently rerender in strict mode (with memo)", async () => { - const sig = signal(null!); + const sig = signal(-1); const Test = memo(() =>

{sig.value}

); const App = () => ( @@ -267,16 +242,17 @@ describe("@preact/signals-react", () => { ); - for (let i = 0; i < 3; i++) { - const value = `${i}`; + await render(); + expect(scratch.textContent).to.equal("-1"); + for (let i = 0; i < 3; i++) { await act(async () => { - sig.value = value; - await render(); + sig.value = i; }); - expect(scratch.textContent).to.equal(value); + expect(scratch.textContent).to.equal("" + i); } }); + it("should render static markup of a component", async () => { const count = signal(0); @@ -288,16 +264,87 @@ describe("@preact/signals-react", () => { ); }; + + await render(); + expect(scratch.textContent).to.equal("00"); + for (let i = 0; i < 3; i++) { await act(async () => { count.value += 1; - await render(); }); expect(scratch.textContent).to.equal( `${count.value}${count.value}` ); } }); + + it("should correctly render components that have useReducer()", async () => { + const count = signal(0); + + let increment: () => void; + const Test = () => { + const [state, dispatch] = useReducer( + (state: number, action: number) => { + return state + action; + }, + -2 + ); + + increment = () => dispatch(1); + + const doubled = count.value * 2; + + return ( +
+						{state}
+						{doubled}
+					
+ ); + }; + + await render(); + expect(scratch.innerHTML).to.equal( + "
-20
" + ); + + for (let i = 0; i < 3; i++) { + await act(async () => { + count.value += 1; + }); + expect(scratch.innerHTML).to.equal( + `
-2${count.value * 2}
` + ); + } + + await act(() => { + increment(); + }); + expect(scratch.innerHTML).to.equal( + `
-1${count.value * 2}
` + ); + }); + }); + + describe("useSignal()", () => { + it("should create a signal from a primitive value", async () => { + function App() { + const count = useSignal(1); + return ( +
+ {count} + +
+ ); + } + + await render(); + expect(scratch.textContent).to.equal("1Increment"); + + await act(() => { + scratch.querySelector("button")!.click(); + }); + expect(scratch.textContent).to.equal("2Increment"); + }); }); describe("useSignalEffect()", () => { @@ -417,12 +464,14 @@ describe("@preact/signals-react", () => { const child = scratch.firstElementChild; + expect(scratch.innerHTML).to.equal("

foo

"); expect(cleanup).not.to.have.been.called; expect(spy).to.have.been.calledOnceWith("foo", child); spy.resetHistory(); await render(null); + expect(scratch.innerHTML).to.equal(""); expect(spy).not.to.have.been.called; expect(cleanup).to.have.been.calledOnce; // @note: React cleans up the ref eagerly, so it's already null by the time the callback runs. diff --git a/packages/react/test/react-router.test.tsx b/packages/react/test/react-router.test.tsx new file mode 100644 index 000000000..2ae49de32 --- /dev/null +++ b/packages/react/test/react-router.test.tsx @@ -0,0 +1,49 @@ +// @ts-ignore-next-line +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +import { signal } from "@preact/signals-react"; +import { createElement } from "react"; +import { Route, Routes, MemoryRouter } from "react-router-dom"; + +import { act, checkHangingAct, createRoot, Root } from "./utils"; + +describe("@preact/signals-react", () => { + let scratch: HTMLDivElement; + let root: Root; + async function render(element: Parameters[0]) { + await act(() => root.render(element)); + } + + beforeEach(async () => { + scratch = document.createElement("div"); + document.body.appendChild(scratch); + root = await createRoot(scratch); + }); + + afterEach(async () => { + checkHangingAct(); + await act(() => root.unmount()); + scratch.remove(); + }); + + describe("react-router-dom", () => { + it("Route component should render", async () => { + const name = signal("World")!; + + function App() { + return ( + + + Page 1}> + Hello {name}!}> + + + ); + } + + await render(); + + expect(scratch.innerHTML).to.equal("
Hello World!
"); + }); + }); +}); diff --git a/packages/react/test/utils.ts b/packages/react/test/utils.ts new file mode 100644 index 000000000..d893a498b --- /dev/null +++ b/packages/react/test/utils.ts @@ -0,0 +1,67 @@ +import { act as realAct } from "react-dom/test-utils"; + +export interface Root { + render(element: JSX.Element | null): void; + unmount(): void; +} + +// We need to use createRoot() if it's available, but it's only available in +// React 18. To enable local testing with React 16 & 17, we'll create a fake +// createRoot() that uses render() and unmountComponentAtNode() instead. +let createRootCache: ((container: Element) => Root) | undefined; +export async function createRoot(container: Element): Promise { + if (!createRootCache) { + try { + // @ts-expect-error ESBuild will replace this import with a require() call + // if it resolves react-dom/client. If it doesn't, it will leave the + // import untouched causing a runtime error we'll handle below. + const { createRoot } = await import("react-dom/client"); + createRootCache = createRoot; + } catch (e) { + // @ts-expect-error ESBuild will replace this import with a require() call + // if it resolves react-dom. + const { render, unmountComponentAtNode } = await import("react-dom"); + createRootCache = (container: Element) => ({ + render(element: JSX.Element) { + render(element, container); + }, + unmount() { + unmountComponentAtNode(container); + }, + }); + } + } + + return createRootCache(container); +} + +// When testing using react's production build, we can't use act (React +// explicitly throws an error in this situation). So instead we'll fake act by +// just waiting 10ms for React's concurrent rerendering to flush. We'll make a +// best effort to throw a helpful error in afterEach if we detect that act() was +// called but not awaited. +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); + +let acting = 0; +async function prodActShim(cb: () => void | Promise): Promise { + acting++; + try { + await cb(); + await delay(10); + } finally { + acting--; + } +} + +export function checkHangingAct() { + if (acting > 0) { + throw new Error( + `It appears act() was called but not awaited. This could happen if a test threw an Error or if a test forgot to await a call to act. Make sure to await act() calls in tests.` + ); + } +} + +export const act = + process.env.NODE_ENV === "production" + ? (prodActShim as typeof realAct) + : realAct; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 705a29c99..c253660e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,12 +1,12 @@ lockfileVersion: 5.4 patchedDependencies: - microbundle@0.15.1: - hash: yvstdq4ikeml4yz3a6bi3bgrvu - path: patches/microbundle@0.15.1.patch '@babel/plugin-transform-typescript@7.19.1': hash: tiqrfntt5y3ned567j2lekmz2i path: patches/@babel__plugin-transform-typescript@7.19.1.patch + microbundle@0.15.1: + hash: yvstdq4ikeml4yz3a6bi3bgrvu + path: patches/microbundle@0.15.1.patch importers: @@ -153,6 +153,7 @@ importers: '@types/use-sync-external-store': ^0.0.3 react: ^18.2.0 react-dom: ^18.2.0 + react-router-dom: ^6.9.0 use-sync-external-store: ^1.2.0 dependencies: '@preact/signals-core': link:../core @@ -163,6 +164,7 @@ importers: '@types/use-sync-external-store': 0.0.3 react: 18.2.0 react-dom: 18.2.0_react@18.2.0 + react-router-dom: 6.10.0_biqbaboplfbrettd7655fr4n2y packages: @@ -2061,6 +2063,11 @@ packages: - supports-color dev: true + /@remix-run/router/1.5.0: + resolution: {integrity: sha512-bkUDCp8o1MvFO+qxkODcbhSqRa6P2GXgrGZVpt0dCXNW2HCSCqYI0ZoAqEOSAjRWmmlKcYgFvN4B4S+zo/f8kg==} + engines: {node: '>=14'} + dev: true + /@rollup/plugin-alias/3.1.9_rollup@2.77.2: resolution: {integrity: sha512-QI5fsEvm9bDzt32k39wpOwZhVzRcL5ydcffUHMyLVaVaLeC70I8TJZ17F1z1eMoLu4E/UOcH9BWVkKpIKdrfiw==} engines: {node: '>=8.0.0'} @@ -6232,6 +6239,29 @@ packages: react: 18.2.0 scheduler: 0.23.0 + /react-router-dom/6.10.0_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-E5dfxRPuXKJqzwSe/qGcqdwa18QiWC6f3H3cWXM24qj4N0/beCIf/CWTipop2xm7mR0RCS99NnaqPNjHtrAzCg==} + engines: {node: '>=14'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + dependencies: + '@remix-run/router': 1.5.0 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + react-router: 6.10.0_react@18.2.0 + dev: true + + /react-router/6.10.0_react@18.2.0: + resolution: {integrity: sha512-Nrg0BWpQqrC3ZFFkyewrflCud9dio9ME3ojHCF/WLsprJVzkq3q3UeEhMCAW1dobjeGbWgjNn/PVF6m46ANxXQ==} + engines: {node: '>=14'} + peerDependencies: + react: '>=16.8' + dependencies: + '@remix-run/router': 1.5.0 + react: 18.2.0 + dev: true + /react/18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'}