Skip to content

Commit 27157f5

Browse files
authored
Prevent prior scheduled effects from destroying the execution context (#869)
* Prevent prior scheduled effects from destroying the execution context * Add test for signal-effect as well
1 parent 7b84120 commit 27157f5

File tree

3 files changed

+163
-3
lines changed

3 files changed

+163
-3
lines changed

.changeset/many-bears-call.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@preact/signals": patch
3+
---
4+
5+
Prevent scheduled effects from highjacking the execution-context

packages/preact/src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ let finishUpdate: (() => void) | undefined;
4444

4545
function setCurrentUpdater(updater?: Effect) {
4646
// end tracking for the current update:
47-
if (finishUpdate) finishUpdate();
47+
if (finishUpdate) {
48+
const finish = finishUpdate;
49+
finishUpdate = undefined;
50+
finish();
51+
}
4852
// start tracking the new update:
4953
finishUpdate = updater && updater._start();
5054
}
@@ -151,6 +155,7 @@ hook(OptionsTypes.DIFF, (old, vnode) => {
151155

152156
/** Set up Updater before rendering a component */
153157
hook(OptionsTypes.RENDER, (old, vnode) => {
158+
old(vnode);
154159
setCurrentUpdater();
155160

156161
let updater;
@@ -170,7 +175,6 @@ hook(OptionsTypes.RENDER, (old, vnode) => {
170175

171176
currentComponent = component;
172177
setCurrentUpdater(updater);
173-
old(vnode);
174178
});
175179

176180
/** Finish current updater if a component errors */

packages/preact/test/index.test.tsx

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import type { ReadonlySignal } from "@preact/signals";
1010
import { createElement, createRef, render, createContext } from "preact";
1111
import type { ComponentChildren, FunctionComponent } from "preact";
12-
import { useContext, useRef, useState } from "preact/hooks";
12+
import { useContext, useEffect, 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));
@@ -605,6 +605,157 @@ describe("@preact/signals", () => {
605605
});
606606

607607
describe("hooks mixed with signals", () => {
608+
it("should not have a stale currentComponent when an effect triggers a synchronous render", () => {
609+
const scratch2 = document.createElement("div");
610+
const sig = signal("foo");
611+
let setState: (v: string) => void;
612+
613+
function Inner() {
614+
return <p>{sig.value}</p>;
615+
}
616+
617+
function App() {
618+
const [state, _setState] = useState("initial");
619+
setState = _setState;
620+
621+
useEffect(() => {
622+
render(<Inner />, scratch2);
623+
}, []);
624+
625+
// This causes a crash because currentComponent._updateFlags is gone
626+
useComputed(() => {});
627+
628+
return (
629+
<div>
630+
{state} {sig.value}
631+
</div>
632+
);
633+
}
634+
635+
render(<App />, scratch);
636+
expect(scratch.innerHTML).to.equal("<div>initial foo</div>");
637+
638+
// Update state — triggers a re-render.
639+
// options._render will flush the pending effect which calls
640+
// render(<Inner />, scratch2). This synchronous render overwrites
641+
// currentComponent via the nested RENDER/DIFFED hooks.
642+
act(() => {
643+
setState!("updated");
644+
});
645+
expect(scratch.innerHTML).to.equal("<div>updated foo</div>");
646+
expect(scratch2.innerHTML).to.equal("<p>foo</p>");
647+
648+
act(() => {
649+
sig.value = "bar";
650+
});
651+
expect(scratch.innerHTML).to.equal("<div>updated bar</div>");
652+
});
653+
654+
it("should not have a stale currentComponent when a signal effect triggers a synchronous render", () => {
655+
const scratch2 = document.createElement("div");
656+
const sig = signal("foo");
657+
let setState: (v: string) => void;
658+
659+
function Inner({ state }: { state: string }) {
660+
return <p>{state}</p>;
661+
}
662+
663+
function App() {
664+
const [state, _setState] = useState("initial");
665+
setState = _setState;
666+
667+
// useSignalEffect wraps useEffect, so the outer useEffect
668+
// gets flushed during options._render. When it fires, it
669+
// creates a signal effect() that immediately invokes the
670+
// callback, doing a synchronous render to another root.
671+
useSignalEffect(() => {
672+
render(<Inner state={sig.value} />, scratch2);
673+
});
674+
675+
// This causes a crash because currentComponent._updateFlags is gone
676+
useComputed(() => {});
677+
678+
return (
679+
<div>
680+
{state} {sig.value}
681+
</div>
682+
);
683+
}
684+
685+
render(<App />, scratch);
686+
expect(scratch.innerHTML).to.equal("<div>initial foo</div>");
687+
688+
act(() => {
689+
setState!("updated");
690+
});
691+
expect(scratch.innerHTML).to.equal("<div>updated foo</div>");
692+
expect(scratch2.innerHTML).to.equal("<p>foo</p>");
693+
694+
act(() => {
695+
sig.value = "bar";
696+
});
697+
expect(scratch.innerHTML).to.equal("<div>updated bar</div>");
698+
});
699+
700+
it("should not have a stale currentComponent when an already-setup signal effect fires during render", () => {
701+
const scratch2 = document.createElement("div");
702+
const sig = signal("foo");
703+
const dep = signal(0);
704+
let setState: (v: string) => void;
705+
706+
function Inner() {
707+
return <p>{dep.value}</p>;
708+
}
709+
710+
function App() {
711+
const [state, _setState] = useState("initial");
712+
setState = _setState;
713+
714+
// Signal effect is set up on mount and tracks dep.
715+
// When dep changes synchronously during a render flush,
716+
// this fires immediately, doing a synchronous render.
717+
useSignalEffect(() => {
718+
dep.value;
719+
render(<Inner />, scratch2);
720+
});
721+
722+
// Regular useEffect that bumps dep on mount.
723+
// When flushed during options._render (from a state-driven
724+
// re-render), it changes dep, which fires the signal effect.
725+
useEffect(() => {
726+
dep.value = dep.peek() + 1;
727+
}, []);
728+
729+
useComputed(() => {});
730+
731+
return (
732+
<div>
733+
{state} {sig.value}
734+
</div>
735+
);
736+
}
737+
738+
render(<App />, scratch);
739+
expect(scratch.innerHTML).to.equal("<div>initial foo</div>");
740+
741+
// The useSignalEffect's useEffect and the regular useEffect
742+
// are both pending. When options._render flushes them:
743+
// 1. useSignalEffect's useEffect fires → creates effect() →
744+
// immediately runs, reads dep (0), does sync render
745+
// 2. useEffect fires → dep.value = 1 → signal effect fires
746+
// synchronously → sync render again → corrupts currentComponent
747+
// 3. App renders → useComputed accesses stale currentComponent
748+
act(() => {
749+
setState!("updated");
750+
});
751+
expect(scratch.innerHTML).to.equal("<div>updated foo</div>");
752+
753+
act(() => {
754+
sig.value = "bar";
755+
});
756+
expect(scratch.innerHTML).to.equal("<div>updated bar</div>");
757+
});
758+
608759
it("signals should not stop context from propagating", () => {
609760
const ctx = createContext({ test: "should-not-exist" });
610761
let update: any;

0 commit comments

Comments
 (0)