Skip to content

Commit 344972e

Browse files
fix(web): remove animated attributes when value is undefined (#2439)
Co-authored-by: LoganDark <github@logandark.mozmail.com>
1 parent e84a977 commit 344972e

3 files changed

Lines changed: 115 additions & 8 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@react-spring/web': patch
3+
---
4+
5+
fix(web): remove DOM attributes when their animated value becomes `undefined`
6+
7+
Previously, attributes such as `inert`, `disabled`, `viewBox`, `className`, and `children` were coerced to the string `"undefined"` or left stale when their animated value resolved to `undefined`. Boolean-style attributes like `inert` must be entirely removed to be disabled — setting them to any value (including `"undefined"`) keeps them active. `applyAnimatedValues` now calls `removeAttribute` (or clears the class/textContent) in this case.

targets/web/src/animated.test.tsx

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from 'react'
22
import { forwardRef } from 'react'
33
import { render } from 'vitest-browser-react'
44
import createMockRaf, { MockRaf } from '@react-spring/mock-raf'
5-
import { Globals } from '@react-spring/shared'
5+
import { FluidValue, Globals, callFluidObservers } from '@react-spring/shared'
66
import { SpringValue, Animatable } from '@react-spring/core'
77

88
import { a } from './index'
@@ -224,6 +224,89 @@ describe('animated component', () => {
224224
})
225225
})
226226

227+
describe('animated component attribute removal', () => {
228+
it('removes a boolean-style attribute when its animated value becomes undefined', () => {
229+
const inert = new TestFluid<true | undefined>(true)
230+
const { getByTestId } = render(
231+
<a.div inert={inert as any} data-testid="wrapper" />
232+
)
233+
const el = getByTestId('wrapper').element() as HTMLElement
234+
expect(el.hasAttribute('inert')).toBe(true)
235+
inert.set(undefined)
236+
mockRaf.step()
237+
expect(el.hasAttribute('inert')).toBe(false)
238+
})
239+
it('removes a generic attribute when its animated value becomes undefined', () => {
240+
const value = new TestFluid<string | undefined>('bar')
241+
const { getByTestId } = render(
242+
<a.div data-foo={value as any} data-testid="wrapper" />
243+
)
244+
const el = getByTestId('wrapper').element() as HTMLElement
245+
expect(el.getAttribute('data-foo')).toBe('bar')
246+
value.set(undefined)
247+
mockRaf.step()
248+
expect(el.hasAttribute('data-foo')).toBe(false)
249+
})
250+
it('removes the viewBox attribute when its animated value becomes undefined', () => {
251+
const viewBox = new TestFluid<string | undefined>('0 0 100 100')
252+
const { getByTestId } = render(
253+
<a.svg viewBox={viewBox as any} data-testid="wrapper" />
254+
)
255+
const el = getByTestId('wrapper').element() as unknown as SVGSVGElement
256+
expect(el.getAttribute('viewBox')).toBe('0 0 100 100')
257+
viewBox.set(undefined)
258+
mockRaf.step()
259+
expect(el.hasAttribute('viewBox')).toBe(false)
260+
})
261+
it('removes the class attribute when className becomes undefined', () => {
262+
const className = new TestFluid<string | undefined>('initial')
263+
const { getByTestId } = render(
264+
<a.div className={className as any} data-testid="wrapper" />
265+
)
266+
const el = getByTestId('wrapper').element() as HTMLElement
267+
expect(el.getAttribute('class')).toBe('initial')
268+
className.set(undefined)
269+
mockRaf.step()
270+
expect(el.hasAttribute('class')).toBe(false)
271+
})
272+
it('clears textContent when children becomes undefined', () => {
273+
const children = new TestFluid<string | undefined>('hello')
274+
const { getByTestId } = render(
275+
<a.div data-testid="wrapper">{children as any}</a.div>
276+
)
277+
const el = getByTestId('wrapper').element() as HTMLElement
278+
expect(el.textContent).toBe('hello')
279+
children.set(undefined)
280+
mockRaf.step()
281+
expect(el.textContent).toBe('')
282+
})
283+
it('still applies defined falsy attribute values rather than removing them', () => {
284+
const value = new TestFluid<string>('1')
285+
const { getByTestId } = render(
286+
<a.div tabIndex={value as any} data-testid="wrapper" />
287+
)
288+
const el = getByTestId('wrapper').element() as HTMLElement
289+
expect(el.getAttribute('tabindex')).toBe('1')
290+
value.set('0')
291+
mockRaf.step()
292+
expect(el.getAttribute('tabindex')).toBe('0')
293+
expect(el.hasAttribute('tabindex')).toBe(true)
294+
})
295+
})
296+
227297
function spring<T>(value: T): SpringValue<Animatable<T>> {
228298
return new SpringValue(value!)
229299
}
300+
301+
class TestFluid<T> extends FluidValue<T> {
302+
constructor(private _value: T) {
303+
super()
304+
}
305+
protected get(): T {
306+
return this._value
307+
}
308+
set(next: T): void {
309+
this._value = next
310+
callFluidObservers(this, { type: 'change', parent: this })
311+
}
312+
}

targets/web/src/applyAnimatedValues.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ const attributeCache: Lookup<string> = {}
2222
type Instance = HTMLDivElement & { style?: Lookup }
2323

2424
export function applyAnimatedValues(instance: Instance, props: Lookup) {
25-
if (!instance.nodeType || !instance.setAttribute) {
25+
if (
26+
!instance.nodeType ||
27+
!instance.setAttribute ||
28+
!instance.removeAttribute
29+
) {
2630
return false
2731
}
2832

@@ -52,7 +56,7 @@ export function applyAnimatedValues(instance: Instance, props: Lookup) {
5256
))
5357
)
5458

55-
if (children !== void 0) {
59+
if (props.hasOwnProperty('children')) {
5660
instance.textContent = children
5761
}
5862

@@ -70,20 +74,33 @@ export function applyAnimatedValues(instance: Instance, props: Lookup) {
7074

7175
// Apply DOM attributes
7276
names.forEach((name, i) => {
73-
instance.setAttribute(name, values[i])
77+
const value = values[i]
78+
if (value !== void 0) {
79+
instance.setAttribute(name, value)
80+
} else {
81+
instance.removeAttribute(name)
82+
}
7483
})
7584

76-
if (className !== void 0) {
77-
instance.className = className
85+
if (props.hasOwnProperty('className')) {
86+
if (className !== void 0) {
87+
instance.className = className
88+
} else {
89+
instance.removeAttribute('class')
90+
}
7891
}
7992
if (scrollTop !== void 0) {
8093
instance.scrollTop = scrollTop
8194
}
8295
if (scrollLeft !== void 0) {
8396
instance.scrollLeft = scrollLeft
8497
}
85-
if (viewBox !== void 0) {
86-
instance.setAttribute('viewBox', viewBox)
98+
if (props.hasOwnProperty('viewBox')) {
99+
if (viewBox !== void 0) {
100+
instance.setAttribute('viewBox', viewBox)
101+
} else {
102+
instance.removeAttribute('viewBox')
103+
}
87104
}
88105
}
89106

0 commit comments

Comments
 (0)