Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/non-extensible-component-cache.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions packages/animated/src/createHost.test.ts
Original file line number Diff line number Diff line change
@@ -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)')
})
})
21 changes: 18 additions & 3 deletions packages/animated/src/createHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object, any>()

export const createHost = (
components: AnimatableComponent[] | { [key: string]: AnimatableComponent },
{
Expand All @@ -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})`
Expand Down
Loading