Skip to content

Commit 93f55ea

Browse files
authored
Merge pull request #3162 from motiondivision/feature/immediate-on-change
`styleEffect`
2 parents 5cd8eaf + 3aedbf9 commit 93f55ea

File tree

6 files changed

+251
-6
lines changed

6 files changed

+251
-6
lines changed

.cursorrules

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
## Writing tests
2+
3+
- When waiting for the next frame, use an `async` test and await like so:
4+
5+
```javascript
6+
async function nextFrame() {
7+
return new Promise<void>((resolve) => {
8+
frame.postRender(() => resolve())
9+
})
10+
}
11+
```

CHANGELOG.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/).
44

55
Undocumented APIs should be considered internal and may change without warning.
66

7+
## [12.9.0] 2025-04-23
8+
9+
### Added
10+
11+
- `styleEffect`
12+
713
## [12.8.3] 2025-04-24
814

915
### Changed
@@ -20,13 +26,13 @@ Undocumented APIs should be considered internal and may change without warning.
2026

2127
- Unifying `transform` behaviour for SVG and CSS switched from element measurements for `transform-box: fill-box`.
2228

23-
## [12.8.1] 2025-04-22
29+
## [12.8.1] 2025-04-23
2430

2531
### Fixed
2632

2733
- Removing errant `console.trace` on `value.set("none")`.
2834

29-
## [12.8.0] 2025-04-22
35+
## [12.8.0] 2025-04-23
3036

3137
### Added
3238

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { frame } from "../../frameloop"
2+
import { motionValue } from "../../value"
3+
import { styleEffect } from "../style-effect"
4+
5+
async function nextFrame() {
6+
return new Promise<void>((resolve) => {
7+
frame.postRender(() => resolve())
8+
})
9+
}
10+
11+
describe("styleEffect", () => {
12+
it("sets styles after styleEffect is applied", async () => {
13+
const element = document.createElement("div")
14+
15+
// Create motion values
16+
const width = motionValue("100px")
17+
const color = motionValue("red")
18+
19+
// Apply style effect
20+
styleEffect(element, {
21+
width,
22+
color,
23+
})
24+
25+
await nextFrame()
26+
27+
// Verify styles are set
28+
expect(element.style.width).toBe("100px")
29+
expect(element.style.color).toBe("red")
30+
})
31+
32+
it("updates styles when motion values change", async () => {
33+
const element = document.createElement("div")
34+
// Create motion values
35+
const width = motionValue("100px")
36+
const color = motionValue("red")
37+
38+
// Apply style effect
39+
styleEffect(element, {
40+
width,
41+
color,
42+
})
43+
44+
await nextFrame()
45+
46+
// Verify initial styles
47+
expect(element.style.width).toBe("100px")
48+
expect(element.style.color).toBe("red")
49+
50+
// Change motion values
51+
width.set("200px")
52+
color.set("blue")
53+
54+
// Updates should be scheduled for the next frame render
55+
// Styles should not have changed yet
56+
expect(element.style.width).toBe("100px")
57+
expect(element.style.color).toBe("red")
58+
59+
await nextFrame()
60+
61+
// Verify styles are updated
62+
expect(element.style.width).toBe("200px")
63+
expect(element.style.color).toBe("blue")
64+
})
65+
66+
it("handles multiple elements", async () => {
67+
// Create additional elements
68+
const element = document.createElement("div")
69+
const element2 = document.createElement("div")
70+
71+
const margin = motionValue("10px")
72+
const backgroundColor = motionValue("yellow")
73+
74+
styleEffect([element, element2], {
75+
margin,
76+
backgroundColor,
77+
})
78+
79+
await nextFrame()
80+
81+
expect(element.style.margin).toBe("10px")
82+
expect(element.style.backgroundColor).toBe("yellow")
83+
expect(element2.style.margin).toBe("10px")
84+
expect(element2.style.backgroundColor).toBe("yellow")
85+
86+
margin.set("20px")
87+
backgroundColor.set("green")
88+
89+
await nextFrame()
90+
91+
expect(element.style.margin).toBe("20px")
92+
expect(element.style.backgroundColor).toBe("green")
93+
})
94+
95+
it("returns cleanup function that stops updating styles", async () => {
96+
const element = document.createElement("div")
97+
// Create motion values
98+
const padding = motionValue("5px")
99+
const opacity = motionValue("0.5")
100+
101+
// Apply style effect and get cleanup function
102+
const cleanup = styleEffect(element, {
103+
padding,
104+
opacity,
105+
})
106+
107+
await nextFrame()
108+
109+
// Verify initial styles
110+
expect(element.style.padding).toBe("5px")
111+
expect(element.style.opacity).toBe("0.5")
112+
113+
// Change values and verify update on next frame
114+
padding.set("10px")
115+
opacity.set("0.8")
116+
117+
await nextFrame()
118+
119+
// Verify update happened
120+
expect(element.style.padding).toBe("10px")
121+
expect(element.style.opacity).toBe("0.8")
122+
123+
// Call cleanup function
124+
cleanup()
125+
126+
// Change values again
127+
padding.set("15px")
128+
opacity.set("1")
129+
130+
await nextFrame()
131+
132+
// Verify styles didn't change after cleanup
133+
expect(element.style.padding).toBe("10px")
134+
expect(element.style.opacity).toBe("0.8")
135+
})
136+
137+
it("returns cleanup function that stops updating styles that have already been scheduled", async () => {
138+
const element = document.createElement("div")
139+
140+
// Create motion values
141+
const padding = motionValue("5px")
142+
const opacity = motionValue("0.5")
143+
144+
// Apply style effect and get cleanup function
145+
const cleanup = styleEffect(element, {
146+
padding,
147+
opacity,
148+
})
149+
150+
await nextFrame()
151+
152+
// Verify initial styles
153+
expect(element.style.padding).toBe("5px")
154+
expect(element.style.opacity).toBe("0.5")
155+
156+
// Change values and verify update on next frame
157+
padding.set("10px")
158+
opacity.set("0.8")
159+
160+
await nextFrame()
161+
162+
// Verify update happened
163+
expect(element.style.padding).toBe("10px")
164+
expect(element.style.opacity).toBe("0.8")
165+
166+
// Change values again
167+
padding.set("15px")
168+
opacity.set("1")
169+
170+
// Call cleanup function
171+
cleanup()
172+
173+
// Check that values don't update on the next frame
174+
await nextFrame()
175+
176+
// Verify styles didn't change after cleanup
177+
expect(element.style.padding).toBe("10px")
178+
expect(element.style.opacity).toBe("0.8")
179+
})
180+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { cancelFrame, frame } from "../frameloop"
2+
import { ElementOrSelector, resolveElements } from "../utils/resolve-elements"
3+
import { MotionValue } from "../value"
4+
5+
export function styleEffect(
6+
subject: ElementOrSelector,
7+
values: Record<string, MotionValue>
8+
) {
9+
const elements = resolveElements(subject) as HTMLElement[]
10+
const subscriptions: VoidFunction[] = []
11+
12+
for (let i = 0; i < elements.length; i++) {
13+
const element = elements[i]
14+
15+
for (const key in values) {
16+
const value = values[key]
17+
18+
/**
19+
* TODO: Get specific setters for combined props (like x)
20+
* or values with default types (like color)
21+
*
22+
* TODO: CSS variable support
23+
*/
24+
const updateStyle = () => {
25+
element.style[key as any] = value.get()
26+
}
27+
28+
const scheduleUpdate = () => frame.render(updateStyle)
29+
30+
const cancel = value.on("change", scheduleUpdate)
31+
32+
scheduleUpdate()
33+
34+
subscriptions.push(() => {
35+
cancel()
36+
cancelFrame(updateStyle)
37+
})
38+
}
39+
}
40+
41+
return () => {
42+
for (const cancel of subscriptions) {
43+
cancel()
44+
}
45+
}
46+
}

packages/motion-dom/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export * from "./animation/waapi/supports/waapi"
3636
export * from "./animation/waapi/utils/accelerated-values"
3737
export * from "./animation/waapi/utils/linear"
3838

39+
export * from "./effects/style-effect"
40+
3941
export * from "./frameloop"
4042
export * from "./frameloop/batcher"
4143
export * from "./frameloop/microtask"

packages/motion-dom/src/value/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -320,13 +320,13 @@ export class MotionValue<V = any> {
320320
this.setCurrent(v)
321321

322322
// Update update subscribers
323-
if (this.current !== this.prev && this.events.change) {
324-
this.events.change.notify(this.current)
323+
if (this.current !== this.prev) {
324+
this.events.change?.notify(this.current)
325325
}
326326

327327
// Update render subscribers
328-
if (render && this.events.renderRequest) {
329-
this.events.renderRequest.notify(this.current)
328+
if (render) {
329+
this.events.renderRequest?.notify(this.current)
330330
}
331331
}
332332

0 commit comments

Comments
 (0)