Skip to content

Commit 6ee3093

Browse files
committed
feat(runtime): add synchronization for direct hook assignments in reactive context
- Implemented a test to ensure that direct hook assignments are synchronized with cached callbacks in the reactive runtime. - Updated `effect` and `resource` APIs to use `registerWatcherCleanup` instead of `registerEffectCleanup` for better clarity and consistency. - Introduced new propagation logic in the reactivity system to handle branching and once propagation more efficiently. - Added performance tests for various propagation strategies, including branching and tree structures, to evaluate efficiency. - Refactored propagation utilities and watchers to improve performance and maintainability.
1 parent e68bc96 commit 6ee3093

22 files changed

Lines changed: 1396 additions & 992 deletions

packages/@reflex/runtime/rollup.perf.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@ export default [
3333
createPerfDomain("tests/perf/tracking-connect.jit.mjs", "dist/tracking-connect.jit.js"),
3434
createPerfDomain("tests/perf/tracking-lifecycle.jit.mjs", "dist/tracking-lifecycle.jit.js"),
3535
createPerfDomain("tests/perf/tracking-policies.jit.mjs", "dist/tracking-policies.jit.js"),
36+
createPerfDomain("tests/perf/propagate-stack-compare.jit.mjs", "dist/propagate-stack-compare.jit.js"),
3637
createPerfDomain("tests/perf/walkers.jit.mjs", "dist/walkers.jit.js"),
3738
];

packages/@reflex/runtime/src/api/watcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from "../reactivity/shape";
1212
import { disposeNode } from "../reactivity/shape/methods/connect";
1313
import { executeNodeComputation } from "../reactivity/engine/execute";
14-
import { shouldRecompute } from "../reactivity/walkers/shouldRecompute";
14+
import { shouldRecompute } from "../reactivity";
1515
import { getDefaultContext } from "../reactivity/context";
1616

1717
export function runWatcher(

packages/@reflex/runtime/src/reactivity/context.ts

Lines changed: 78 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@ export interface EngineHooks {
88

99
export type CleanupRegistrar = (cleanup: () => void) => void;
1010

11+
type OnEffectInvalidatedHook = EngineHooks["onEffectInvalidated"];
12+
type OnReactiveSettledHook = EngineHooks["onReactiveSettled"];
13+
14+
const EFFECT_INVALIDATED_HOOK = 1;
15+
const REACTIVE_SETTLED_HOOK = 1 << 1;
16+
17+
function normalizeOwnHook<T extends keyof EngineHooks>(
18+
hooks: EngineHooks,
19+
key: T,
20+
): EngineHooks[T] | undefined {
21+
if (!Object.hasOwn(hooks, key)) return undefined;
22+
23+
const hook = hooks[key];
24+
return typeof hook === "function" ? hook : undefined;
25+
}
26+
1127
/**
1228
* ExecutionContext управляет состоянием вычисления и уведомлениями host'у.
1329
*
@@ -26,30 +42,57 @@ export class ExecutionContext {
2642
propagationDepth = 0;
2743
cleanupRegistrar: CleanupRegistrar | null = null;
2844
readonly hooks: EngineHooks;
45+
onEffectInvalidatedHook: OnEffectInvalidatedHook = undefined;
46+
onReactiveSettledHook: OnReactiveSettledHook = undefined;
47+
private hookMask = 0;
2948

3049
constructor(hooks: EngineHooks = {}) {
3150
this.hooks = {};
51+
// Keep the public hook snapshot and the hot-path caches synchronized.
52+
Object.defineProperties(this.hooks, {
53+
onEffectInvalidated: {
54+
enumerable: true,
55+
get: () => this.onEffectInvalidatedHook,
56+
set: (hook: OnEffectInvalidatedHook) => {
57+
this.setOnEffectInvalidatedHook(hook);
58+
},
59+
},
60+
onReactiveSettled: {
61+
enumerable: true,
62+
get: () => this.onReactiveSettledHook,
63+
set: (hook: OnReactiveSettledHook) => {
64+
this.setOnReactiveSettledHook(hook);
65+
},
66+
},
67+
});
3268
this.setHooks(hooks);
3369
}
3470

3571
dispatchWatcherEvent(node: ReactiveNode): void {
72+
const hook = this.onEffectInvalidatedHook;
73+
3674
if (__DEV__) {
3775
recordDebugEvent(this, "watcher:invalidated", {
3876
node,
3977
});
78+
} else if (hook === undefined) {
79+
return;
4080
}
4181

42-
this.hooks.onEffectInvalidated?.(node);
82+
hook?.(node);
4383
}
4484

4585
maybeNotifySettled(): void {
46-
if (this.propagationDepth === 0 && this.activeComputed === null) {
47-
if (__DEV__) {
48-
recordDebugEvent(this, "context:settled");
49-
}
86+
if (!__DEV__ && (this.hookMask & REACTIVE_SETTLED_HOOK) === 0) return;
87+
if (this.propagationDepth !== 0 || this.activeComputed !== null) return;
5088

51-
this.hooks.onReactiveSettled?.();
89+
const hook = this.onReactiveSettledHook;
90+
91+
if (__DEV__) {
92+
recordDebugEvent(this, "context:settled");
5293
}
94+
95+
hook?.();
5396
}
5497

5598
enterPropagation(): void {
@@ -87,38 +130,23 @@ export class ExecutionContext {
87130
}
88131

89132
setHooks(hooks: EngineHooks = {}): void {
90-
const onEffectInvalidated = Object.hasOwn(hooks, "onEffectInvalidated")
91-
? hooks.onEffectInvalidated
92-
: undefined;
93-
const onReactiveSettled = Object.hasOwn(hooks, "onReactiveSettled")
94-
? hooks.onReactiveSettled
95-
: undefined;
96-
97-
if (typeof onEffectInvalidated === "function") {
98-
this.hooks.onEffectInvalidated = onEffectInvalidated;
99-
} else {
100-
this.hooks.onEffectInvalidated = undefined;
101-
}
133+
const onEffectInvalidated = normalizeOwnHook(hooks, "onEffectInvalidated");
134+
const onReactiveSettled = normalizeOwnHook(hooks, "onReactiveSettled");
102135

103-
if (typeof onReactiveSettled === "function") {
104-
this.hooks.onReactiveSettled = onReactiveSettled;
105-
} else {
106-
this.hooks.onReactiveSettled = undefined;
107-
}
136+
this.hooks.onEffectInvalidated = onEffectInvalidated;
137+
this.hooks.onReactiveSettled = onReactiveSettled;
108138

109139
if (__DEV__) {
110140
recordDebugEvent(this, "context:hooks", {
111141
detail: {
112-
hasOnEffectInvalidated:
113-
typeof this.hooks.onEffectInvalidated === "function",
114-
hasOnReactiveSettled:
115-
typeof this.hooks.onReactiveSettled === "function",
142+
hasOnEffectInvalidated: this.onEffectInvalidatedHook !== undefined,
143+
hasOnReactiveSettled: this.onReactiveSettledHook !== undefined,
116144
},
117145
});
118146
}
119147
}
120148

121-
registerEffectCleanup(cleanup: () => void): void {
149+
registerWatcherCleanup(cleanup: () => void): void {
122150
this.cleanupRegistrar?.(cleanup);
123151
}
124152

@@ -135,6 +163,28 @@ export class ExecutionContext {
135163
this.cleanupRegistrar = previousRegistrar;
136164
}
137165
}
166+
167+
private setOnEffectInvalidatedHook(hook: OnEffectInvalidatedHook): void {
168+
this.onEffectInvalidatedHook =
169+
typeof hook === "function" ? hook : undefined;
170+
this.updateHookMask(
171+
EFFECT_INVALIDATED_HOOK,
172+
this.onEffectInvalidatedHook !== undefined,
173+
);
174+
}
175+
176+
private setOnReactiveSettledHook(hook: OnReactiveSettledHook): void {
177+
this.onReactiveSettledHook =
178+
typeof hook === "function" ? hook : undefined;
179+
this.updateHookMask(
180+
REACTIVE_SETTLED_HOOK,
181+
this.onReactiveSettledHook !== undefined,
182+
);
183+
}
184+
185+
private updateHookMask(bit: number, enabled: boolean): void {
186+
this.hookMask = enabled ? this.hookMask | bit : this.hookMask & ~bit;
187+
}
138188
}
139189

140190
/**

packages/@reflex/runtime/src/reactivity/engine/compute.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
11
import { compare } from "../../api/compare";
22
import type { ReactiveNode } from "../shape";
33
import type { ExecutionContext } from "../context";
4-
import {
5-
devAssertRecomputeAlive,
6-
devRecordRecompute,
7-
} from "../dev";
8-
import {
9-
clearDirtyState,
10-
isDisposedNode,
11-
} from "../shape";
4+
import { devAssertRecomputeAlive, devRecordRecompute } from "../dev";
5+
import { clearDirtyState, isDisposedNode } from "../shape";
126
import { executeNodeComputationRaw } from "./execute";
137
import { getDefaultContext } from "../context";
148

@@ -26,7 +20,9 @@ export function recompute(
2620
const prev = node.payload;
2721
let next: unknown = prev;
2822
let hasChanged = false;
23+
2924
next = executeNodeComputationRaw(node, context);
25+
3026
if (!isDisposedNode(node)) {
3127
const changed = !compare(prev, next);
3228
hasChanged = changed;

packages/@reflex/runtime/src/reactivity/engine/execute.ts

Lines changed: 37 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { ExecutionContext } from "../context";
22
import { recordDebugEvent } from "../../debug";
3-
import type {
4-
ReactiveNode} from "../shape";
3+
import type { ReactiveNode } from "../shape";
54
import {
65
ReactiveNodeState,
76
clearNodeComputing,
@@ -12,6 +11,32 @@ import { getDefaultContext } from "../context";
1211

1312
type CommitComputation<T> = (result: unknown) => T;
1413

14+
function prepareNodeExecution(
15+
node: ReactiveNode,
16+
context: ExecutionContext,
17+
): () => void {
18+
node.depsTail = null;
19+
node.state =
20+
(node.state & ~ReactiveNodeState.Visited) | ReactiveNodeState.Tracking;
21+
markNodeComputing(node);
22+
23+
const prevActive = context.activeComputed;
24+
context.activeComputed = node;
25+
let restored = false;
26+
27+
if (__DEV__) {
28+
recordDebugEvent(context, "compute:start", {
29+
node,
30+
});
31+
}
32+
33+
return () => {
34+
if (restored) return;
35+
restored = true;
36+
context.activeComputed = prevActive;
37+
};
38+
}
39+
1540
export function executeNodeComputationRaw(
1641
node: ReactiveNode,
1742
context: ExecutionContext = getDefaultContext(),
@@ -28,28 +53,12 @@ export function executeNodeComputationRaw(
2853
}
2954

3055
const compute = node.compute!;
31-
node.depsTail = null;
32-
node.state =
33-
(node.state & ~ReactiveNodeState.Visited) | ReactiveNodeState.Tracking;
34-
markNodeComputing(node);
35-
36-
const prevActive = context.activeComputed;
37-
context.activeComputed = node;
56+
const restoreActive = prepareNodeExecution(node, context);
3857
let result: unknown;
3958

40-
if (__DEV__) {
41-
recordDebugEvent(context, "compute:start", {
42-
node,
43-
});
44-
}
45-
4659
try {
47-
try {
48-
result = compute();
49-
} finally {
50-
context.activeComputed = prevActive;
51-
}
52-
60+
result = compute();
61+
restoreActive();
5362
cleanupStaleSources(node, context);
5463

5564
if (__DEV__) {
@@ -63,6 +72,8 @@ export function executeNodeComputationRaw(
6372

6473
return result;
6574
} catch (error) {
75+
restoreActive();
76+
6677
if (__DEV__) {
6778
recordDebugEvent(context, "compute:error", {
6879
node,
@@ -97,28 +108,12 @@ export function executeNodeComputation<T>(
97108
}
98109

99110
const compute = node.compute!;
100-
node.depsTail = null;
101-
node.state =
102-
(node.state & ~ReactiveNodeState.Visited) | ReactiveNodeState.Tracking;
103-
markNodeComputing(node);
104-
105-
const prevActive = context.activeComputed;
106-
context.activeComputed = node;
111+
const restoreActive = prepareNodeExecution(node, context);
107112
let result: unknown;
108113

109-
if (__DEV__) {
110-
recordDebugEvent(context, "compute:start", {
111-
node,
112-
});
113-
}
114-
115114
try {
116-
try {
117-
result = compute();
118-
} finally {
119-
context.activeComputed = prevActive;
120-
}
121-
115+
result = compute();
116+
restoreActive();
122117
cleanupStaleSources(node, context);
123118
const committed = commit(result);
124119

@@ -133,6 +128,8 @@ export function executeNodeComputation<T>(
133128

134129
return committed;
135130
} catch (error) {
131+
restoreActive();
132+
136133
if (__DEV__) {
137134
recordDebugEvent(context, "compute:error", {
138135
node,
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1+
export * from "./propagate.constants";
2+
export * from "./propagate.once";
13
export * from "./propagate";
2-
export * from "./shouldRecompute";
4+
export * from "./recompute";

0 commit comments

Comments
 (0)