diff --git a/packages/react/package.json b/packages/react/package.json index 5ce6ac2a293b..f4dfba42f7c2 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -40,8 +40,7 @@ }, "dependencies": { "@sentry/browser": "10.23.0", - "@sentry/core": "10.23.0", - "hoist-non-react-statics": "^3.3.2" + "@sentry/core": "10.23.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" @@ -51,7 +50,6 @@ "@testing-library/react-hooks": "^7.0.2", "@types/history-4": "npm:@types/history@4.7.8", "@types/history-5": "npm:@types/history@4.7.8", - "@types/hoist-non-react-statics": "^3.3.5", "@types/node-fetch": "^2.6.11", "@types/react": "17.0.3", "@types/react-router-4": "npm:@types/react-router@4.0.25", diff --git a/packages/react/src/hoist-non-react-statics.ts b/packages/react/src/hoist-non-react-statics.ts index 970928c80d23..3ab5cb69d257 100644 --- a/packages/react/src/hoist-non-react-statics.ts +++ b/packages/react/src/hoist-non-react-statics.ts @@ -1,5 +1,169 @@ -import * as hoistNonReactStaticsImport from 'hoist-non-react-statics'; +/** + * Inlined implementation of hoist-non-react-statics + * Original library: https://github.com/mridgway/hoist-non-react-statics + * License: BSD-3-Clause + * Copyright 2015, Yahoo! Inc. + * + * This is an inlined version to avoid ESM compatibility issues with the original package. + */ -// Ensure we use the default export from hoist-non-react-statics if available, -// falling back to the module itself. This handles both ESM and CJS usage. -export const hoistNonReactStatics = hoistNonReactStaticsImport.default || hoistNonReactStaticsImport; +import type * as React from 'react'; + +/** + * React statics that should not be hoisted + */ +const REACT_STATICS = { + childContextTypes: true, + contextType: true, + contextTypes: true, + defaultProps: true, + displayName: true, + getDefaultProps: true, + getDerivedStateFromError: true, + getDerivedStateFromProps: true, + mixins: true, + propTypes: true, + type: true, +} as const; + +/** + * Known JavaScript function statics that should not be hoisted + */ +const KNOWN_STATICS = { + name: true, + length: true, + prototype: true, + caller: true, + callee: true, + arguments: true, + arity: true, +} as const; + +/** + * Statics specific to ForwardRef components + */ +const FORWARD_REF_STATICS = { + $$typeof: true, + render: true, + defaultProps: true, + displayName: true, + propTypes: true, +} as const; + +/** + * Statics specific to Memo components + */ +const MEMO_STATICS = { + $$typeof: true, + compare: true, + defaultProps: true, + displayName: true, + propTypes: true, + type: true, +} as const; + +/** + * Inlined react-is utilities + * We only need to detect ForwardRef and Memo types + */ +const ForwardRefType = Symbol.for('react.forward_ref'); +const MemoType = Symbol.for('react.memo'); + +/** + * Check if a component is a Memo component + */ +function isMemo(component: unknown): boolean { + return ( + typeof component === 'object' && component !== null && (component as { $$typeof?: symbol }).$$typeof === MemoType + ); +} + +/** + * Map of React component types to their specific statics + */ +const TYPE_STATICS: Record> = {}; +TYPE_STATICS[ForwardRefType] = FORWARD_REF_STATICS; +TYPE_STATICS[MemoType] = MEMO_STATICS; + +/** + * Get the appropriate statics object for a given component + */ +function getStatics(component: React.ComponentType): Record { + // React v16.11 and below + if (isMemo(component)) { + return MEMO_STATICS; + } + + // React v16.12 and above + const componentType = (component as { $$typeof?: symbol }).$$typeof; + return (componentType && TYPE_STATICS[componentType]) || REACT_STATICS; +} + +const defineProperty = Object.defineProperty.bind(Object); +const getOwnPropertyNames = Object.getOwnPropertyNames.bind(Object); +const getOwnPropertySymbols = Object.getOwnPropertySymbols?.bind(Object); +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor.bind(Object); +const getPrototypeOf = Object.getPrototypeOf.bind(Object); +const objectPrototype = Object.prototype; + +/** + * Copies non-react specific statics from a child component to a parent component. + * Similar to Object.assign, but copies all static properties from source to target, + * excluding React-specific statics and known JavaScript statics. + * + * @param targetComponent - The component to copy statics to + * @param sourceComponent - The component to copy statics from + * @param excludelist - An optional object of keys to exclude from hoisting + * @returns The target component with hoisted statics + */ +export function hoistNonReactStatics< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends React.ComponentType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + S extends React.ComponentType, + C extends Record = Record, +>(targetComponent: T, sourceComponent: S, excludelist?: C): T { + if (typeof sourceComponent !== 'string') { + // Don't hoist over string (html) components + if (objectPrototype) { + const inheritedComponent = getPrototypeOf(sourceComponent); + + if (inheritedComponent && inheritedComponent !== objectPrototype) { + hoistNonReactStatics(targetComponent, inheritedComponent, excludelist); + } + } + + let keys: (string | symbol)[] = getOwnPropertyNames(sourceComponent); + + if (getOwnPropertySymbols) { + keys = keys.concat(getOwnPropertySymbols(sourceComponent)); + } + + const targetStatics = getStatics(targetComponent); + const sourceStatics = getStatics(sourceComponent); + + for (const key of keys) { + const keyStr = String(key); + if ( + !KNOWN_STATICS[keyStr as keyof typeof KNOWN_STATICS] && + !excludelist?.[keyStr] && + !sourceStatics?.[keyStr] && + !targetStatics?.[keyStr] && + !getOwnPropertyDescriptor(targetComponent, key) // Don't overwrite existing properties + ) { + const descriptor = getOwnPropertyDescriptor(sourceComponent, key); + + if (descriptor) { + try { + // Avoid failures from read-only properties + defineProperty(targetComponent, key, descriptor); + } catch (e) { + // Silently ignore errors + } + } + } + } + } + + return targetComponent; +} diff --git a/packages/react/test/hoist-non-react-statics.test.tsx b/packages/react/test/hoist-non-react-statics.test.tsx new file mode 100644 index 000000000000..5c4ecb82126b --- /dev/null +++ b/packages/react/test/hoist-non-react-statics.test.tsx @@ -0,0 +1,293 @@ +import * as React from 'react'; +import { describe, expect, it } from 'vitest'; +import { hoistNonReactStatics } from '../src/hoist-non-react-statics'; + +describe('hoistNonReactStatics', () => { + it('hoists custom static properties', () => { + class Source extends React.Component { + static customStatic = 'customValue'; + static anotherStatic = 42; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('customValue'); + expect((Target as any).anotherStatic).toBe(42); + }); + + it('does not overwrite existing properties on target', () => { + class Source extends React.Component { + static customStatic = 'sourceValue'; + } + class Target extends React.Component { + static customStatic = 'targetValue'; + } + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('targetValue'); + }); + + it('returns the target component', () => { + class Source extends React.Component {} + class Target extends React.Component {} + + const result = hoistNonReactStatics(Target, Source); + + expect(result).toBe(Target); + }); + + it('handles function components', () => { + const Source = () =>
Source
; + (Source as any).customStatic = 'value'; + const Target = () =>
Target
; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('value'); + }); + + it('does not hoist known JavaScript statics', () => { + class Source extends React.Component { + static customStatic = 'customValue'; + } + class Target extends React.Component {} + const originalName = Target.name; + const originalLength = Target.length; + + hoistNonReactStatics(Target, Source); + + expect(Target.name).toBe(originalName); + expect(Target.length).toBe(originalLength); + expect((Target as any).customStatic).toBe('customValue'); + }); + + it('does not hoist React-specific statics', () => { + class Source extends React.Component { + static defaultProps = { foo: 'bar' }; + static customStatic = 'customValue'; + } + class Target extends React.Component { + static defaultProps = { baz: 'qux' }; + } + const originalDefaultProps = Target.defaultProps; + + hoistNonReactStatics(Target, Source); + + expect(Target.defaultProps).toBe(originalDefaultProps); + expect((Target as any).customStatic).toBe('customValue'); + }); + + it('does not hoist displayName', () => { + const Source = () =>
; + (Source as any).displayName = 'SourceComponent'; + (Source as any).customStatic = 'value'; + const Target = () =>
; + (Target as any).displayName = 'TargetComponent'; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).displayName).toBe('TargetComponent'); + expect((Target as any).customStatic).toBe('value'); + }); + + it('respects custom excludelist', () => { + class Source extends React.Component { + static customStatic1 = 'value1'; + static customStatic2 = 'value2'; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source, { customStatic1: true }); + + expect((Target as any).customStatic1).toBeUndefined(); + expect((Target as any).customStatic2).toBe('value2'); + }); + + it('handles ForwardRef components', () => { + const SourceInner = (_props: any, _ref: any) =>
; + const Source = React.forwardRef(SourceInner); + (Source as any).customStatic = 'value'; + const TargetInner = (_props: any, _ref: any) =>
; + const Target = React.forwardRef(TargetInner); + const originalRender = (Target as any).render; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).render).toBe(originalRender); + expect((Target as any).customStatic).toBe('value'); + }); + + it('handles Memo components', () => { + const SourceComponent = () =>
; + const Source = React.memo(SourceComponent); + (Source as any).customStatic = 'value'; + const TargetComponent = () =>
; + const Target = React.memo(TargetComponent); + const originalType = (Target as any).type; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).type).toBe(originalType); + expect((Target as any).customStatic).toBe('value'); + }); + + it('hoists symbol properties', () => { + const customSymbol = Symbol('custom'); + class Source extends React.Component {} + (Source as any)[customSymbol] = 'symbolValue'; + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any)[customSymbol]).toBe('symbolValue'); + }); + + it('preserves property descriptors', () => { + class Source extends React.Component {} + Object.defineProperty(Source, 'customStatic', { + value: 'value', + writable: false, + enumerable: true, + configurable: false, + }); + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + const descriptor = Object.getOwnPropertyDescriptor(Target, 'customStatic'); + expect(descriptor?.value).toBe('value'); + expect(descriptor?.writable).toBe(false); + expect(descriptor?.enumerable).toBe(true); + expect(descriptor?.configurable).toBe(false); + }); + + it('handles getters and setters', () => { + let backingValue = 'initial'; + class Source extends React.Component {} + Object.defineProperty(Source, 'customStatic', { + get: () => backingValue, + set: (value: string) => { + backingValue = value; + }, + }); + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('initial'); + (Target as any).customStatic = 'modified'; + expect((Target as any).customStatic).toBe('modified'); + }); + + it('silently handles read-only property errors', () => { + class Source extends React.Component {} + Object.defineProperty(Source, 'customStatic', { value: 'sourceValue', writable: true }); + class Target extends React.Component {} + Object.defineProperty(Target, 'customStatic', { value: 'targetValue', writable: false }); + + expect(() => hoistNonReactStatics(Target, Source)).not.toThrow(); + expect((Target as any).customStatic).toBe('targetValue'); + }); + + it('hoists statics from the prototype chain', () => { + class GrandParent extends React.Component { + static grandParentStatic = 'grandParent'; + } + class Parent extends GrandParent { + static parentStatic = 'parent'; + } + class Source extends Parent { + static sourceStatic = 'source'; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).sourceStatic).toBe('source'); + expect((Target as any).parentStatic).toBe('parent'); + expect((Target as any).grandParentStatic).toBe('grandParent'); + }); + + it('does not hoist from Object.prototype', () => { + class Source extends React.Component { + static customStatic = 'value'; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('value'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect((Target as any).hasOwnProperty).toBe(Object.prototype.hasOwnProperty); + }); + + it('handles string components', () => { + const Target = () =>
; + (Target as any).existingStatic = 'value'; + + hoistNonReactStatics(Target, 'div' as any); + + expect((Target as any).existingStatic).toBe('value'); + }); + + it('handles falsy static values', () => { + class Source extends React.Component {} + (Source as any).nullStatic = null; + (Source as any).undefinedStatic = undefined; + (Source as any).zeroStatic = 0; + (Source as any).falseStatic = false; + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).nullStatic).toBeNull(); + expect((Target as any).undefinedStatic).toBeUndefined(); + expect((Target as any).zeroStatic).toBe(0); + expect((Target as any).falseStatic).toBe(false); + }); + + it('works with HOC pattern', () => { + class OriginalComponent extends React.Component { + static customMethod() { + return 'custom'; + } + render() { + return
Original
; + } + } + const WrappedComponent: React.FC = () => ; + + hoistNonReactStatics(WrappedComponent, OriginalComponent); + + expect((WrappedComponent as any).customMethod()).toBe('custom'); + }); + + it('preserves target displayName in HOC pattern', () => { + const OriginalComponent = () =>
Original
; + (OriginalComponent as any).displayName = 'Original'; + (OriginalComponent as any).someStaticProp = 'value'; + const WrappedComponent: React.FC = () => ; + (WrappedComponent as any).displayName = 'ErrorBoundary(Original)'; + + hoistNonReactStatics(WrappedComponent, OriginalComponent); + + expect((WrappedComponent as any).displayName).toBe('ErrorBoundary(Original)'); + expect((WrappedComponent as any).someStaticProp).toBe('value'); + }); + + it('works with multiple HOC composition', () => { + class Original extends React.Component { + static originalStatic = 'original'; + } + const Hoc1 = () => ; + (Hoc1 as any).hoc1Static = 'hoc1'; + hoistNonReactStatics(Hoc1, Original); + const Hoc2 = () => ; + hoistNonReactStatics(Hoc2, Hoc1); + + expect((Hoc2 as any).originalStatic).toBe('original'); + expect((Hoc2 as any).hoc1Static).toBe('hoc1'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 0142b73605c8..8225005722d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8638,14 +8638,6 @@ resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" integrity sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA== -"@types/hoist-non-react-statics@^3.3.5": - version "3.3.5" - resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz#dab7867ef789d87e2b4b0003c9d65c49cc44a494" - integrity sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg== - dependencies: - "@types/react" "*" - hoist-non-react-statics "^3.3.0" - "@types/html-minifier-terser@^6.0.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" @@ -18602,7 +18594,7 @@ hoist-non-react-statics@^1.2.0: resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" integrity sha1-qkSM8JhtVcxAdzsXF0t90GbLfPs= -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==