diff --git a/.changeset/yellow-pans-raise.md b/.changeset/yellow-pans-raise.md new file mode 100644 index 0000000..0f54237 --- /dev/null +++ b/.changeset/yellow-pans-raise.md @@ -0,0 +1,20 @@ +--- +"react-fast-hoc": patch +--- + +Fixed wrapping into hoc in case of nested `React.memo` +Resolves: [#17](https://github.com/XantreGodlike/react-fast-hoc/issues/17) + +```tsx +const Component = transformProps( + React.memo( + React.memo((props) => { + console.log(props); // prev: { c: 30 }, now: { a: 10, b: 20, c: 30 } + return null; + }) + ), + (props) => ({ ...props, a: 10, b: 20 }) +); + +root.render(); +``` diff --git a/packages/react-fast-hoc/src/index.test.ts b/packages/react-fast-hoc/src/__tests__/index.test.ts similarity index 86% rename from packages/react-fast-hoc/src/index.test.ts rename to packages/react-fast-hoc/src/__tests__/index.test.ts index 0bbdd45..b5ea18c 100644 --- a/packages/react-fast-hoc/src/index.test.ts +++ b/packages/react-fast-hoc/src/__tests__/index.test.ts @@ -4,7 +4,8 @@ import { Objects } from "hotscript"; import React, { ComponentType, createElement, forwardRef, memo } from "react"; import { Function } from "ts-toolbelt"; import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest"; -import { createTransformProps, transformProps, wrapIntoProxy } from "."; +import { createTransformProps, transformProps, wrapIntoProxy } from ".."; +import { applyHocs, lazyShort, renderComponent } from "./utils"; declare module "react" { export interface ExoticComponent { @@ -102,17 +103,29 @@ describe("transformProps", () => { }); }); - test("works with memo after hoc", () => { - render(createElement(memo(addBebeHoc(Component)))); - standardCheck(); - }); - test("works with memo before hoc", () => { - render(createElement(addBebeHoc(memo(Component)))); + describe("hocs: memo", () => { + test("memo -> hoc", () => { + renderComponent(applyHocs(Component, [React.memo, addBebeHoc])); + standardCheck(); + }); + test("hoc -> memo", () => { + renderComponent(applyHocs(Component, [addBebeHoc, React.memo])); - expect(Component).toHaveBeenCalledOnce(); - expect(addBebeProp).toHaveBeenCalledOnce(); - expect(addBebeProp).toHaveBeenCalledWith({}); - expect(Component).toHaveBeenCalledWith({ bebe: true }, {}); + expect(Component).toHaveBeenCalledOnce(); + expect(addBebeProp).toHaveBeenCalledOnce(); + expect(addBebeProp).toHaveBeenCalledWith({}); + expect(Component).toHaveBeenCalledWith({ bebe: true }, {}); + }); + test("hoc -> memo -> memo", () => { + renderComponent( + applyHocs(Component, [addBebeHoc, React.memo, React.memo]) + ); + + expect(Component).toHaveBeenCalledOnce(); + expect(addBebeProp).toHaveBeenCalledOnce(); + expect(addBebeProp).toHaveBeenCalledWith({}); + expect(Component).toHaveBeenCalledWith({ bebe: true }, {}); + }); }); // this @@ -138,7 +151,7 @@ describe("transformProps", () => { describe("hocs: lazy", () => { test("unloaded lazy", async () => { const Cmp = vi.fn(Component); - const Lazy = React.lazy(() => Promise.resolve({ default: Cmp })); + const Lazy = lazyShort(Cmp); render( createElement(React.Suspense, {}, createElement(addBebeHoc(Lazy))) diff --git a/packages/react-fast-hoc/src/mimicToNewComponent.test.ts b/packages/react-fast-hoc/src/__tests__/mimicToNewComponent.test.ts similarity index 95% rename from packages/react-fast-hoc/src/mimicToNewComponent.test.ts rename to packages/react-fast-hoc/src/__tests__/mimicToNewComponent.test.ts index 10a2027..95a9a73 100644 --- a/packages/react-fast-hoc/src/mimicToNewComponent.test.ts +++ b/packages/react-fast-hoc/src/__tests__/mimicToNewComponent.test.ts @@ -1,5 +1,5 @@ import { assert, beforeEach, describe, expect, test } from "vitest"; -import { transformProps } from "."; +import { transformProps } from ".."; const createComponent = () => () => null; diff --git a/packages/react-fast-hoc/src/react.test.ts b/packages/react-fast-hoc/src/__tests__/react.test.ts similarity index 87% rename from packages/react-fast-hoc/src/react.test.ts rename to packages/react-fast-hoc/src/__tests__/react.test.ts index 9d2ff07..331394f 100644 --- a/packages/react-fast-hoc/src/react.test.ts +++ b/packages/react-fast-hoc/src/__tests__/react.test.ts @@ -1,25 +1,23 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import { range, sleep, tryit } from "radash"; import React, { ComponentType, createElement } from "react"; - -const renderComponent = (Component: React.ComponentType) => - render(createElement(Component)); - -const lazyShort = >(component: T) => - React.lazy(() => - Promise.resolve({ - default: component, - }) - ); - -const applyHocs = >( - Component: T, - hocs: ((...args: any) => any)[] -): T => hocs.reduceRight((acc, cur) => cur(acc), Component); +import { applyHocs, lazyShort, renderComponent } from "./utils"; function noop() {} +afterEach(() => { + cleanup(); +}); + /** * oportunities * memo = 1..9999 memos diff --git a/packages/react-fast-hoc/src/rename.test.ts b/packages/react-fast-hoc/src/__tests__/rename.test.ts similarity index 98% rename from packages/react-fast-hoc/src/rename.test.ts rename to packages/react-fast-hoc/src/__tests__/rename.test.ts index a4967d5..b7fdd3e 100644 --- a/packages/react-fast-hoc/src/rename.test.ts +++ b/packages/react-fast-hoc/src/__tests__/rename.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { transformProps } from "."; +import { transformProps } from ".."; describe("renaming works", () => { const A = () => null; diff --git a/packages/react-fast-hoc/src/handlers/rewriteCall.test.ts b/packages/react-fast-hoc/src/__tests__/rewriteCall.test.ts similarity index 92% rename from packages/react-fast-hoc/src/handlers/rewriteCall.test.ts rename to packages/react-fast-hoc/src/__tests__/rewriteCall.test.ts index 79b5396..7f3da59 100644 --- a/packages/react-fast-hoc/src/handlers/rewriteCall.test.ts +++ b/packages/react-fast-hoc/src/__tests__/rewriteCall.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import { RewriteCall } from "./rewriteCall"; +import { RewriteCall } from "../handlers/rewriteCall"; describe("rewrite call should rewrite it", () => { const f = vi.fn(); diff --git a/packages/react-fast-hoc/src/__tests__/utils.ts b/packages/react-fast-hoc/src/__tests__/utils.ts new file mode 100644 index 0000000..39bf1da --- /dev/null +++ b/packages/react-fast-hoc/src/__tests__/utils.ts @@ -0,0 +1,20 @@ +import { + RenderResult, + render, +} from "@testing-library/react"; +import React, { ComponentType, createElement } from "react"; + +export const renderComponent = (Component: React.ComponentType): RenderResult => + render(createElement(Component)); + +export const lazyShort = >(component: T) => + React.lazy(() => + Promise.resolve({ + default: component, + }) + ); + +export const applyHocs = >( + Component: T, + hocs: ((...args: any) => any)[] +): T => hocs.reduceRight((acc, cur) => cur(acc), Component); diff --git a/packages/react-fast-hoc/src/internals.ts b/packages/react-fast-hoc/src/internals.ts index e1a90c8..518132b 100644 --- a/packages/react-fast-hoc/src/internals.ts +++ b/packages/react-fast-hoc/src/internals.ts @@ -1,4 +1,4 @@ -import { isValidElement, type ReactNode, type Ref } from "react"; +import React, { isValidElement, type ReactNode, type Ref } from "react"; import type { HocTransformer, MimicToNewComponentHandler } from "./handlers"; import { isClassComponent, toFunctional, type Get } from "./toFunctional"; @@ -43,7 +43,7 @@ type RealComponentType = | { $$typeof: typeof REACT_MEMO_TYPE; compare: null | ((a: TProps, b: TProps) => boolean); - type: (props: TProps) => ReactNode; + type: RealComponentType; } | { $$typeof: typeof REACT_LAZY_TYPE; @@ -65,28 +65,34 @@ type ReactFunctionalComponentType< { $$typeof: typeof REACT_FORWARD_REF_TYPE } | React.FC >; -const wrapFunctionalFROrDefault = ( +type ForwardRefComponent = Extract< + ReactFunctionalComponentType, + { $$typeof: typeof REACT_FORWARD_REF_TYPE } +>; +type RegularFunctionComponent = Exclude< + ReactFunctionalComponentType, + ForwardRefComponent +>; +const wrapFCWithForwardRefOrPlain = ( Component: ReactFunctionalComponentType, handler: HocTransformer -) => { - type ForwardRefComponent = Extract< - ReactFunctionalComponentType, - { $$typeof: typeof REACT_FORWARD_REF_TYPE } - >; - type RegularFunctionComponent = Exclude< - ReactFunctionalComponentType, - ForwardRefComponent - >; +): ForwardRefComponent | ReactFunctionalComponentType => { if ( "$$typeof" in Component && Component["$$typeof"] === REACT_FORWARD_REF_TYPE ) { return { $$typeof: REACT_FORWARD_REF_TYPE, - render: new Proxy((Component as ForwardRefComponent).render, handler), - }; + render: new Proxy( + (Component as ForwardRefComponent).render, + handler + ), + } as ForwardRefComponent; } - return new Proxy(Component as RegularFunctionComponent, handler); + return new Proxy( + Component as Function, + handler + ) as RegularFunctionComponent; }; // I don't know why but typescript is not helpful at all @@ -105,13 +111,14 @@ export const wrapComponentIntoHoc = ( handler: HocTransformer, mimicToNewComponentHandler: null | MimicToNewComponentHandler ): unknown => { - if (process.env.NODE_ENV === "development" && !isValidElement(Component)) { - console.warn("react-fast-hoc: passed incorrect component for transform"); - return Component; - } + // should use isValidElementType + // if (process.env.NODE_ENV === "development" && !isValidElement(Component)) { + // console.warn("react-fast-hoc: passed incorrect component for transform"); + // return Component; + // } // this case assumes that it's ClassComponent if (isClassComponent(Component)) { - return wrapFunctionalFROrDefault( + return wrapFCWithForwardRefOrPlain( toFunctional(Component) as React.FC, handler ); @@ -120,8 +127,7 @@ export const wrapComponentIntoHoc = ( if ("$$typeof" in Component && Component["$$typeof"] === REACT_MEMO_TYPE) { return { $$typeof: REACT_MEMO_TYPE, - // @ts-expect-error - type: wrapFunctionalFROrDefault(toFunctional(Component.type), handler), + type: wrapComponentIntoHoc(Component.type, handler, null), compare: Component.compare, }; } @@ -147,7 +153,7 @@ export const wrapComponentIntoHoc = ( result = wrapComponentIntoHoc( initRes, handler, - mimicToNewComponentHandler + null ) as RealComponentType; } return result; diff --git a/packages/react-fast-hoc/src/reactShallowEqual.ts b/packages/react-fast-hoc/src/reactShallowEqual.ts new file mode 100644 index 0000000..19bb1d2 --- /dev/null +++ b/packages/react-fast-hoc/src/reactShallowEqual.ts @@ -0,0 +1,44 @@ +// https://github.com/facebook/react/blob/b9244f79bab133e0e6d2e992b5be4722b4ac7536/packages/shared/shallowEqual.js +const is = Object.is; +const hasOwnProperty = Object.prototype.hasOwnProperty; + +/** + * Performs equality by iterating through keys on an object and returning false + * when any key has values which are not strictly equal between the arguments. + * Returns true when the values of all keys are strictly equal. + */ +export function reactShallowEqual(objA: unknown, objB: unknown): boolean { + if (is(objA, objB)) { + return true; + } + + if ( + typeof objA !== "object" || + objA === null || + typeof objB !== "object" || + objB === null + ) { + return false; + } + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) { + return false; + } + + // Test for A's keys different from B. + for (let i = 0; i < keysA.length; i++) { + const currentKey = keysA[i]; + if ( + !hasOwnProperty.call(objB, currentKey) || + // @ts-expect-error lost refinement of `objB` + !is(objA[currentKey], objB[currentKey]) + ) { + return false; + } + } + + return true; +} diff --git a/packages/react-fast-hoc/src/toFunctional.ts b/packages/react-fast-hoc/src/toFunctional.ts index 1783d1a..1208072 100644 --- a/packages/react-fast-hoc/src/toFunctional.ts +++ b/packages/react-fast-hoc/src/toFunctional.ts @@ -46,7 +46,9 @@ const isPrototypeOf = Function.call.bind(Object.prototype.isPrototypeOf) as ( child: unknown ) => boolean; -export const isClassComponent = ( +export const isClassComponent = isPrototypeOf.bind( + isPrototypeOf, + ReactComponent +) as ( Component: T -): Component is Extract> => - isPrototypeOf(ReactComponent, Component); +) => Component is Extract>;