From 21e0a8f337387460b960c94e536a8ca40682abe5 Mon Sep 17 00:00:00 2001 From: Andrei Calazans Date: Tue, 9 Jun 2026 15:03:19 -0300 Subject: [PATCH 1/3] fix(animated): use WeakMap fallback for non-extensible component caching On Hermes (React Native), host components like View, Text, and Image become non-extensible after their first JSX render. The existing cache mechanism writes directly to Component[cacheKey], which throws 'cannot add a new property' in strict mode for these objects. Fix: attempt the direct write first (fast path for extensible components), catch the TypeError, and fall back to a module-level WeakMap for non-extensible components. Reproduces with Metro inlineRequires:true + experimentalImportSupport:true when @react-spring/native is loaded lazily inside a component render. --- packages/animated/src/createHost.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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})` From 0d46af49c9aef2bf0ef80020f85cbf5df83f5261 Mon Sep 17 00:00:00 2001 From: Andrei Calazans Date: Tue, 9 Jun 2026 16:57:10 -0300 Subject: [PATCH 2/3] test(animated): cover non-extensible component caching + changeset --- .changeset/non-extensible-component-cache.md | 9 +++ packages/animated/src/createHost.test.ts | 65 ++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 .changeset/non-extensible-component-cache.md create mode 100644 packages/animated/src/createHost.test.ts 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..86c5de58e6 --- /dev/null +++ b/packages/animated/src/createHost.test.ts @@ -0,0 +1,65 @@ +import { createHost } from './createHost' +import { withAnimated } from './withAnimated' +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() {} + 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() {} + 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() {} + 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() {} + 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() {} + Object.preventExtensions(NamedComponent) + + const { animated } = createHost({ NamedComponent }, hostConfig) + const A = animated(NamedComponent) + + expect(A.displayName).toBe('Animated(NamedComponent)') + }) +}) From c2112639d36c36d0c166b074aa5e8719e2847e93 Mon Sep 17 00:00:00 2001 From: Andrei Calazans Date: Thu, 11 Jun 2026 13:51:47 -0300 Subject: [PATCH 3/3] fix(animated): return null from test component stubs to satisfy AnimatableComponent type --- packages/animated/src/createHost.test.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/animated/src/createHost.test.ts b/packages/animated/src/createHost.test.ts index 86c5de58e6..e38218657a 100644 --- a/packages/animated/src/createHost.test.ts +++ b/packages/animated/src/createHost.test.ts @@ -1,5 +1,4 @@ import { createHost } from './createHost' -import { withAnimated } from './withAnimated' import { AnimatedObject } from './AnimatedObject' const hostConfig = { @@ -10,7 +9,9 @@ const hostConfig = { describe('createHost', () => { it('caches the animated wrapper on the component itself', () => { - function MyComponent() {} + function MyComponent() { + return null + } const { animated } = createHost({ MyComponent }, hostConfig) const A = animated(MyComponent) @@ -21,7 +22,9 @@ describe('createHost', () => { }) it('falls back to a WeakMap when the component is non-extensible', () => { - function MyComponent() {} + function MyComponent() { + return null + } Object.preventExtensions(MyComponent) // Should not throw even though MyComponent is non-extensible @@ -36,7 +39,9 @@ describe('createHost', () => { }) it('handles frozen components without throwing', () => { - function MyComponent() {} + function MyComponent() { + return null + } Object.freeze(MyComponent) const { animated } = createHost({ MyComponent }, hostConfig) @@ -46,7 +51,9 @@ describe('createHost', () => { }) it('sets displayName on the animated wrapper', () => { - function NamedComponent() {} + function NamedComponent() { + return null + } const { animated } = createHost({ NamedComponent }, hostConfig) const A = animated(NamedComponent) @@ -54,7 +61,9 @@ describe('createHost', () => { }) it('sets displayName on wrappers for non-extensible components', () => { - function NamedComponent() {} + function NamedComponent() { + return null + } Object.preventExtensions(NamedComponent) const { animated } = createHost({ NamedComponent }, hostConfig)