Skip to content

Commit fea3e8d

Browse files
Raf aligned dom updates (#604)
* Raf aligned dom updates * Test approach leveraging our existing options.debounceRendering This all works fine, however it creates a discrepancy between browser usage and test usage which we want to avoid. The discrepancy is made clear in the failing tests. What we want to happen is for Preact to be first allowed to perform its render cycle, the effects that follow and only then should we clean up stragglers by performing the direct DOM updates. Currently what happens is that the ordering is "random" based on how the signals were registered. * options.requestAnimationFrame solves it * Improve changelog post Co-authored-by: Ryan Christian <33403762+rschristian@users.noreply.github.com> * Update .changeset/ninety-beans-compare.md * Skip changing the SignalValue notifier when it's an object (JSX) --------- Co-authored-by: Ryan Christian <33403762+rschristian@users.noreply.github.com>
1 parent 5b6d891 commit fea3e8d

File tree

3 files changed

+100
-87
lines changed

3 files changed

+100
-87
lines changed

.changeset/ninety-beans-compare.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@preact/signals": major
3+
---
4+
5+
Defer all DOM updates by an animation frame, this should make it so
6+
that any previously synchronous DOM update will be instead delayed by an
7+
animation frame. This allows Preact to first perform its own render
8+
cycle and then our direct DOM updates to occur. These will now
9+
be performed in a batched way which is more performant as the browser
10+
is prepared to handle these during the animation frame.
11+
12+
This does impact how Preact based signals are tested, when
13+
you perform a signal update, you'll need to wrap it in `act`. In a way
14+
this was always the case, as a signal update that resulted in
15+
a Preact state update would require it to be wrapped in `act`, but
16+
now this is the norm.

packages/preact/src/index.ts

Lines changed: 32 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -79,32 +79,21 @@ function SignalValue(this: AugmentedComponent, { data }: { data: Signal }) {
7979
const currentSignal = useSignal(data);
8080
currentSignal.value = data;
8181

82-
const s = useMemo(() => {
83-
// mark the parent component as having computeds so it gets optimized
84-
let v = this.__v;
85-
while ((v = v.__!)) {
86-
if (v.__c) {
87-
v.__c._updateFlags |= HAS_COMPUTEDS;
88-
break;
89-
}
90-
}
91-
92-
this._updater!._callback = () => {
93-
if (isValidElement(s.peek()) || this.base?.nodeType !== 3) {
94-
this._updateFlags |= HAS_PENDING_UPDATE;
95-
this.setState({});
96-
return;
97-
}
82+
const s = useComputed(() => {
83+
let data = currentSignal.value;
84+
let s = data.value;
85+
return s === 0 ? 0 : s === true ? "" : s || "";
86+
});
9887

99-
(this.base as Text).data = s.peek();
100-
};
88+
const isText = useComputed(() => {
89+
return !isValidElement(s.peek()) || this.base?.nodeType === 3;
90+
});
10191

102-
return computed(() => {
103-
let data = currentSignal.value;
104-
let s = data.value;
105-
return s === 0 ? 0 : s === true ? "" : s || "";
106-
});
107-
}, []);
92+
useSignalEffect(() => {
93+
if (isText.value) {
94+
(this.base as Text).data = data.peek();
95+
}
96+
});
10897

10998
return s.value;
11099
}
@@ -238,7 +227,9 @@ function createPropUpdater(
238227
changeSignal.value = newSignal;
239228
props = newProps;
240229
},
241-
_dispose: effect(() => {
230+
_dispose: effect(function (this: Effect) {
231+
if (!oldNotifyEffects) oldNotifyEffects = this._notify;
232+
this._notify = notifyEffects;
242233
const value = changeSignal.value.value;
243234
// If Preact just rendered this value, don't render it again:
244235
if (props[prop] === value) return;
@@ -358,28 +349,26 @@ export function useComputed<T>(compute: () => T) {
358349
return useMemo(() => computed<T>(() => $compute.current()), []);
359350
}
360351

361-
let oldNotify: (this: Effect) => void,
362-
queue: Array<Effect> = [],
363-
isFlushing = false;
352+
let oldNotifyEffects: (this: Effect) => void,
353+
effectsQueue: Array<Effect> = [];
354+
355+
const defer =
356+
typeof requestAnimationFrame === "undefined"
357+
? setTimeout
358+
: requestAnimationFrame;
364359

365-
function flush() {
360+
function flushEffects() {
366361
batch(() => {
367-
let flushing = [...queue];
368-
isFlushing = false;
369-
queue.length = 0;
370-
for (let i = 0; i < flushing.length; i++) {
371-
oldNotify.call(flushing[i]);
362+
let inst: Effect | undefined;
363+
while ((inst = effectsQueue.shift())) {
364+
oldNotifyEffects.call(inst);
372365
}
373366
});
374367
}
375368

376-
function notify(this: Effect) {
377-
queue.push(this);
378-
if (!isFlushing) {
379-
isFlushing = true;
380-
(typeof requestAnimationFrame === "undefined"
381-
? setTimeout
382-
: requestAnimationFrame)(flush);
369+
function notifyEffects(this: Effect) {
370+
if (effectsQueue.push(this) === 1) {
371+
(options.requestAnimationFrame || defer)(flushEffects);
383372
}
384373
}
385374

@@ -389,8 +378,8 @@ export function useSignalEffect(cb: () => void | (() => void)) {
389378

390379
useEffect(() => {
391380
return effect(function (this: Effect) {
392-
if (!oldNotify) oldNotify = this._notify;
393-
this._notify = notify;
381+
if (!oldNotifyEffects) oldNotifyEffects = this._notify;
382+
this._notify = notifyEffects;
394383
return callback.current();
395384
});
396385
}, []);

packages/preact/test/index.test.tsx

Lines changed: 52 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,6 @@ import { useContext, useRef, useState } from "preact/hooks";
1313
import { setupRerender, act } from "preact/test-utils";
1414

1515
const sleep = (ms?: number) => new Promise(r => setTimeout(r, ms));
16-
const defer =
17-
typeof requestAnimationFrame === "undefined"
18-
? setTimeout
19-
: requestAnimationFrame;
20-
const afterFrame = () => {
21-
return new Promise(res => {
22-
defer(res);
23-
});
24-
};
2516

2617
describe("@preact/signals", () => {
2718
let scratch: HTMLDivElement;
@@ -63,14 +54,16 @@ describe("@preact/signals", () => {
6354
expect(text).to.have.property("data", "test");
6455
});
6556

66-
it("should update Signal-based SignalValue (no parent component)", () => {
57+
it("should update Signal-based SignalValue (no parent component)", async () => {
6758
const sig = signal("test");
6859
render(<span>{sig}</span>, scratch);
6960

7061
const text = scratch.firstChild!.firstChild!;
7162
expect(text).to.have.property("data", "test");
7263

73-
sig.value = "changed";
64+
act(() => {
65+
sig.value = "changed";
66+
});
7467

7568
// should not remount/replace SignalValue
7669
expect(scratch.firstChild!.firstChild!).to.equal(text);
@@ -91,7 +84,9 @@ describe("@preact/signals", () => {
9184
const text = scratch.firstChild!.firstChild!;
9285
expect(text).to.have.property("data", "test");
9386

94-
sig.value = "changed";
87+
act(() => {
88+
sig.value = "changed";
89+
});
9590

9691
// should not remount/replace SignalValue
9792
expect(scratch.firstChild!.firstChild!).to.equal(text);
@@ -109,14 +104,19 @@ describe("@preact/signals", () => {
109104
spy();
110105
return <span>{x}</span>;
111106
}
112-
render(<App x={sig} />, scratch);
107+
108+
act(() => {
109+
render(<App x={sig} />, scratch);
110+
});
113111
spy.resetHistory();
114112

115113
const text = scratch.firstChild!.firstChild!;
116114
expect(text).to.have.property("data", "test");
117115

118116
const sig2 = signal("different");
119-
render(<App x={sig2} />, scratch);
117+
act(() => {
118+
render(<App x={sig2} />, scratch);
119+
});
120120
expect(spy).to.have.been.called;
121121
spy.resetHistory();
122122

@@ -128,14 +128,18 @@ describe("@preact/signals", () => {
128128
await sleep();
129129
expect(spy).not.to.have.been.called;
130130

131-
sig.value = "changed old signal";
131+
act(() => {
132+
sig.value = "changed old signal";
133+
});
132134

133135
await sleep();
134136
expect(spy).not.to.have.been.called;
135137
// the text should _not_ have changed:
136138
expect(text).to.have.property("data", "different");
137139

138-
sig2.value = "changed";
140+
act(() => {
141+
sig2.value = "changed";
142+
});
139143

140144
expect(scratch.firstChild!.firstChild!).to.equal(text);
141145
expect(text).to.have.property("data", "changed");
@@ -177,11 +181,10 @@ describe("@preact/signals", () => {
177181
expect(text).to.be.an.instanceOf(HTMLSpanElement);
178182
expect(text).to.have.property("firstChild").that.is.an.instanceOf(Text);
179183

180-
sig.value = <div>a</div>;
181-
184+
act(() => {
185+
sig.value = <div>a</div>;
186+
});
182187
expect(spy).not.to.have.been.calledOnce;
183-
184-
rerender();
185188
scratch.firstChild!.firstChild!.textContent!.should.equal("a");
186189
});
187190

@@ -198,23 +201,30 @@ describe("@preact/signals", () => {
198201
expect(text.textContent).to.equal("test");
199202
expect(text).to.be.an.instanceOf(HTMLSpanElement);
200203
expect(text).to.have.property("firstChild").that.is.an.instanceOf(Text);
201-
sig.value = "a";
202-
rerender();
204+
205+
act(() => {
206+
sig.value = "a";
207+
});
203208
text = scratch.firstChild!.firstChild!;
204209
expect(text.nodeType).to.equal(Node.TEXT_NODE);
205210
expect(text.textContent).to.equal("a");
206211

207-
sig.value = "b";
212+
act(() => {
213+
sig.value = "b";
214+
});
208215
expect(text.textContent).to.equal("b");
209216

210-
sig.value = <div>c</div>;
211-
rerender();
217+
act(() => {
218+
sig.value = <div>c</div>;
219+
});
212220
await sleep();
213221
text = scratch.firstChild!.firstChild!;
214222

215223
expect(text).to.be.an.instanceOf(HTMLDivElement);
216224
expect(text.textContent).to.equal("c");
217-
sig.value = <span>d</span>;
225+
act(() => {
226+
sig.value = <span>d</span>;
227+
});
218228
rerender();
219229
await sleep();
220230

@@ -461,14 +471,16 @@ describe("@preact/signals", () => {
461471
expect(s.value).to.equal(true);
462472
});
463473

464-
it("should update the checked property on change", () => {
474+
it("should update the checked property on change", async () => {
465475
const s = signal(true);
466476
// @ts-ignore
467477
render(<input checked={s} />, scratch);
468478

469479
expect(scratch.firstChild).to.have.property("checked", true);
470480

471-
s.value = false;
481+
act(() => {
482+
s.value = false;
483+
});
472484

473485
expect(scratch.firstChild).to.have.property("checked", false);
474486
});
@@ -486,15 +498,19 @@ describe("@preact/signals", () => {
486498

487499
expect(scratch.firstChild).to.have.property("value", "initial");
488500

489-
s.value = "updated";
501+
act(() => {
502+
s.value = "updated";
503+
});
490504

491505
expect(scratch.firstChild).to.have.property("value", "updated");
492506

493507
// ensure the component was never re-rendered: (even after a tick)
494508
await sleep();
495509
expect(spy).not.to.have.been.called;
496510

497-
s.value = "second update";
511+
act(() => {
512+
s.value = "second update";
513+
});
498514

499515
expect(scratch.firstChild).to.have.property("value", "second update");
500516

@@ -522,7 +538,9 @@ describe("@preact/signals", () => {
522538
await sleep();
523539
expect(spy).not.to.have.been.called;
524540

525-
style.value = "left: 20px;";
541+
act(() => {
542+
style.value = "left: 20px;";
543+
});
526544

527545
expect(div.style).to.have.property("left", "20px");
528546

@@ -679,7 +697,6 @@ describe("@preact/signals", () => {
679697
});
680698
expect(scratch.textContent).to.equal("foo");
681699
// expect(spy).not.to.have.been.called;
682-
await afterFrame();
683700
expect(spy).to.have.been.calledOnceWith(
684701
"foo",
685702
scratch.firstElementChild,
@@ -690,11 +707,9 @@ describe("@preact/signals", () => {
690707

691708
act(() => {
692709
sig.value = "bar";
693-
rerender();
694710
});
695711

696712
expect(scratch.textContent).to.equal("bar");
697-
await afterFrame();
698713

699714
expect(spy).to.have.been.calledOnceWith(
700715
"bar",
@@ -715,7 +730,9 @@ describe("@preact/signals", () => {
715730
const id = ref.current.getAttribute("data-render-id");
716731
const value = sig.value;
717732
spy(value, ref.current, id);
718-
return () => cleanup(value, ref.current, id);
733+
return () => {
734+
cleanup(value, ref.current, id);
735+
};
719736
});
720737
return (
721738
<p ref={ref} data-render-id={count++}>
@@ -728,7 +745,6 @@ describe("@preact/signals", () => {
728745
render(<App />, scratch);
729746
});
730747

731-
await afterFrame();
732748
expect(cleanup).not.to.have.been.called;
733749
expect(spy).to.have.been.calledOnceWith(
734750
"foo",
@@ -739,16 +755,12 @@ describe("@preact/signals", () => {
739755

740756
act(() => {
741757
sig.value = "bar";
742-
rerender();
743758
});
744759

745760
expect(scratch.textContent).to.equal("bar");
746-
await afterFrame();
747761

748762
const child = scratch.firstElementChild;
749-
750763
expect(cleanup).to.have.been.calledOnceWith("foo", child, "0");
751-
752764
expect(spy).to.have.been.calledOnceWith("bar", child, "1");
753765
});
754766

@@ -771,8 +783,6 @@ describe("@preact/signals", () => {
771783
render(<App />, scratch);
772784
});
773785

774-
await afterFrame();
775-
776786
const child = scratch.firstElementChild;
777787

778788
expect(cleanup).not.to.have.been.called;
@@ -783,8 +793,6 @@ describe("@preact/signals", () => {
783793
render(null, scratch);
784794
});
785795

786-
await afterFrame();
787-
788796
expect(spy).not.to.have.been.called;
789797
expect(cleanup).to.have.been.calledOnceWith("foo", child);
790798
});

0 commit comments

Comments
 (0)