diff --git a/.changeset/non-extensible-component-cache.md b/.changeset/non-extensible-component-cache.md new file mode 100644 index 0000000000..f8da5f6816 --- /dev/null +++ b/.changeset/non-extensible-component-cache.md @@ -0,0 +1,9 @@ +--- +'@react-spring/animated': patch +--- + +Fix `cannot add a new property` crash when wrapping non-extensible React Native host components. + +`createHost` cached animated wrappers by writing directly to the component object via `Component[Symbol.for('AnimatedComponent')] = ...`. On Hermes (React Native), host components like `View`, `Text`, and `Image` become non-extensible after their first JSX render, causing a `TypeError` in strict mode. + +The fix attempts the direct write first (fast path, no change for extensible components) and falls back to a module-level `WeakMap` when the write is rejected. diff --git a/packages/animated/src/createHost.test.ts b/packages/animated/src/createHost.test.ts new file mode 100644 index 0000000000..e38218657a --- /dev/null +++ b/packages/animated/src/createHost.test.ts @@ -0,0 +1,74 @@ +import { createHost } from './createHost' +import { AnimatedObject } from './AnimatedObject' + +const hostConfig = { + applyAnimatedValues: () => false, + createAnimatedStyle: (style: object) => new AnimatedObject(style), + getComponentProps: (props: object) => props, +} + +describe('createHost', () => { + it('caches the animated wrapper on the component itself', () => { + function MyComponent() { + return null + } + const { animated } = createHost({ MyComponent }, hostConfig) + + const A = animated(MyComponent) + const B = animated(MyComponent) + + // Same wrapper is returned on repeated calls + expect(A).toBe(B) + }) + + it('falls back to a WeakMap when the component is non-extensible', () => { + function MyComponent() { + return null + } + Object.preventExtensions(MyComponent) + + // Should not throw even though MyComponent is non-extensible + const { animated } = createHost({ MyComponent }, hostConfig) + + const A = animated(MyComponent) + expect(A).toBeDefined() + + // Calling again returns the same cached wrapper + const B = animated(MyComponent) + expect(A).toBe(B) + }) + + it('handles frozen components without throwing', () => { + function MyComponent() { + return null + } + Object.freeze(MyComponent) + + const { animated } = createHost({ MyComponent }, hostConfig) + + expect(() => animated(MyComponent)).not.toThrow() + expect(animated(MyComponent)).toBeDefined() + }) + + it('sets displayName on the animated wrapper', () => { + function NamedComponent() { + return null + } + const { animated } = createHost({ NamedComponent }, hostConfig) + + const A = animated(NamedComponent) + expect(A.displayName).toBe('Animated(NamedComponent)') + }) + + it('sets displayName on wrappers for non-extensible components', () => { + function NamedComponent() { + return null + } + Object.preventExtensions(NamedComponent) + + const { animated } = createHost({ NamedComponent }, hostConfig) + const A = animated(NamedComponent) + + expect(A.displayName).toBe('Animated(NamedComponent)') + }) +}) diff --git a/packages/animated/src/createHost.ts b/packages/animated/src/createHost.ts index a45a2382c2..a214ce4921 100644 --- a/packages/animated/src/createHost.ts +++ b/packages/animated/src/createHost.ts @@ -22,6 +22,12 @@ type WithAnimated = { // For storing the animated version on the original component const cacheKey = Symbol.for('AnimatedComponent') +// Fallback cache for component objects that are non-extensible (e.g. React +// Native host components on Hermes after their first JSX render). We try to +// write the cached wrapper on the component itself first; if that throws we +// fall back to this WeakMap so that `animated(View)` keeps working. +const fallbackCache = new WeakMap() + export const createHost = ( components: AnimatableComponent[] | { [key: string]: AnimatableComponent }, { @@ -44,9 +50,18 @@ export const createHost = ( animated[Component] || (animated[Component] = withAnimated(Component, hostConfig)) } else { - Component = - Component[cacheKey] || - (Component[cacheKey] = withAnimated(Component, hostConfig)) + let cached = Component[cacheKey] ?? fallbackCache.get(Component) + if (!cached) { + cached = withAnimated(Component, hostConfig) + try { + Component[cacheKey] = cached + } catch { + // Component is non-extensible (e.g. a Hermes-optimised React Native + // host component). Store in the module-level WeakMap instead. + } + fallbackCache.set(Component, cached) + } + Component = cached } Component.displayName = `Animated(${displayName})`