packages/core/src/lib/hooks/use-reaction.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import { GLOBAL_STATE } from '../global-state.js';
2 import { queueReactionCompletion } from '../scheduler/realm-scheduler.js';
3 import {
4 recordReactionCleanup,
5 recordReactionConsumerCreated,
6 recordReactionConsumerReleased,
7 recordReactionEdgeCreated,
8 recordReactionEdgeReleased,
9 recordReactionFailure,
10 recordReactionRun,
11 untracked,
12 withSignalTracking,
13 withSignalWriteForbidden,
14 } from '../signals/graph.js';
15 import {
16 beginReactiveCollection,
17 collectReactiveDependency,
18 disposeReactiveConsumer,
19 finishReactiveCollection,
20 linkReactiveEdgeToSource,
21 unlinkReactiveEdgeFromSource,
22 type ReactiveEdge,
23 type SignalDependencyConsumer,
24 } from '../signals/edge.js';
25 import type { PibblComponentRefs, PibblInstance } from '../types.js';
26 import {
27 requireHookContext,
28 useHookSlot,
29 withHooksForbidden,
30 } from './hook-slot.js';
31
32 /**
33 * Options controlling when a signal reaction runs and how it is named for diagnostics.
34 *
35 * @see {@link useReaction}
36 */
37 export interface PibblReactionOptions {
38 /** Optional label used in diagnostics. See {@link PibblReactionOptions}. */
39 readonly debugName?: string;
40 }
41
42 interface ReactionConsumerState extends SignalDependencyConsumer {
43 readonly kind: 'reaction';
44 readonly ownerComponent: PibblComponentRefs<any>;
45 collecting: boolean;
46 disposed: boolean;
47 }
48
49 interface PibblReactionHookSlot {
50 active: boolean;
51 cancelPending: (() => void) | undefined;
52 cleanup: (() => void) | undefined;
53 readonly componentRefs: PibblComponentRefs<any>;
54 consumer: ReactionConsumerState | undefined;
55 readonly pibblInstance: PibblInstance;
56 dependencies: readonly unknown[] | undefined;
57 readonly debugName: string | undefined;
58 mounted: boolean;
59 pending: boolean;
60 pendingGeneration: number;
61 renderRevision: number;
62 setup: () => void | (() => void);
63 }
64
65 /**
66 * Owns one Complete-phase imperative reaction for a mounted hook slot.
67 *
68 * @param setup - Setup callback; its optional returned cleanup runs when replaced or disposed.
69 * @param dependencies - Values controlling when setup is repeated.
70 * @param options - Optional diagnostic name for this reaction. See {@link PibblReactionOptions}.
71 *
72 * @see {@link PibblReactionOptions}
73 */
74 export function useReaction(
75 setup: () => void | (() => void),
76 dependencies?: readonly unknown[],
77 options: PibblReactionOptions = {},
78 ): void {
79 const { componentRefs, pibblInstance } = requireHookContext();
80 const dependencySnapshot = snapshotDependencies(dependencies);
81 const slot = useHookSlot<PibblReactionHookSlot>('reaction', teardowns => {
82 let value!: PibblReactionHookSlot;
83 value = {
84 active: true,
85 cancelPending: undefined,
86 cleanup: undefined,
87 componentRefs,
88 consumer: undefined,
89 pibblInstance,
90 dependencies: undefined,
91 debugName: options.debugName,
92 mounted: false,
93 pending: false,
94 pendingGeneration: 0,
95 renderRevision: 0,
96 setup,
97 };
98 teardowns.add(() => disposeReaction(value));
99 return value;
100 }).value;
101
102 const transaction = GLOBAL_STATE.renderTransaction;
103 if (!transaction) {
104 throw new Error('useReaction requires an active Pibbl render transaction.');
105 }
106 const dependenciesChanged = !slot.mounted || !dependencySnapshotsEqual(
107 slot.dependencies,
108 dependencySnapshot,
109 );
110 const revision = ++slot.renderRevision;
111 transaction.onFinish(success => {
112 if (!success || revision !== slot.renderRevision || !isCurrentSlot(slot)) {
113 return;
114 }
115 slot.setup = setup;
116 slot.dependencies = dependencySnapshot;
117 if (!slot.mounted) {
118 slot.mounted = true;
119 slot.consumer = createReactionConsumer(() => scheduleReaction(slot), slot.componentRefs);
120 }
121 if (dependenciesChanged) {
122 clearPendingReaction(slot);
123 runReaction(slot);
124 }
125 });
126 }
127
128 function createReactionConsumer(
129 invalidate: () => void,
130 ownerComponent: PibblComponentRefs<any>,
131 ): ReactionConsumerState {
132 const state: ReactionConsumerState = {
133 kind: 'reaction',
134 ownerComponent,
135 dependencyHead: undefined,
136 dependencyTail: undefined,
137 dependencyFreeHead: undefined,
138 dependencyFreeCount: 0,
139 dependencyCount: 0,
140 dependencyHighWater: 0,
141 collectionCursor: undefined,
142 collectionGeneration: 0,
143 collecting: false,
144 disposed: false,
145 addDependency(node): void {
146 if (state.collecting) collectReactiveDependency(state, node);
147 },
148 notify(): void {
149 if (!state.disposed) invalidate();
150 },
151 };
152 recordReactionConsumerCreated();
153 return state;
154 }
155
156 function collectReactionReads<T>(
157 consumer: ReactionConsumerState,
158 callback: () => T,
159 ): T {
160 beginReactiveCollection(consumer);
161 consumer.collecting = true;
162 let result!: T;
163 let failure: unknown;
164 let failed = false;
165 try {
166 result = withSignalTracking(consumer, callback);
167 } catch (error) {
168 failed = true;
169 failure = error;
170 } finally {
171 consumer.collecting = false;
172 finishReactiveCollection(
173 consumer,
174 edge => {
175 if (!edge.sourceLinked) {
176 const source = edge.source!;
177 linkReactiveEdgeToSource(edge, source.observe?.());
178 recordReactionEdgeCreated();
179 }
180 edge.observedVersion = edge.source!.version;
181 edge.collectionKind = undefined;
182 },
183 releaseReactionEdge,
184 );
185 }
186 if (failed) throw failure;
187 return result;
188 }
189
190 function scheduleReaction(slot: PibblReactionHookSlot): void {
191 if (!slot.active || slot.pending) return;
192 slot.pending = true;
193 const generation = ++slot.pendingGeneration;
194 slot.cancelPending = queueReactionCompletion(() => {
195 if (
196 !slot.active ||
197 !slot.pending ||
198 generation !== slot.pendingGeneration
199 ) {
200 return;
201 }
202 slot.pending = false;
203 slot.cancelPending = undefined;
204 runReaction(slot);
205 });
206 }
207
208 function runReaction(slot: PibblReactionHookSlot): void {
209 if (!slot.active || slot.consumer === undefined) return;
210 const failures: unknown[] = [];
211 const cleanup = slot.cleanup;
212 slot.cleanup = undefined;
213 if (cleanup !== undefined) {
214 try {
215 runCleanup(cleanup);
216 } catch (error) {
217 recordReactionFailure();
218 failures.push(error);
219 }
220 }
221
222 recordReactionRun();
223 try {
224 const nextCleanup = collectReactionReads(
225 slot.consumer,
226 () => withSignalWriteForbidden(
227 'Cannot write to a signal during useReaction setup.',
228 () => withHooksForbidden(
229 'Pibbl hooks cannot be called inside useReaction setup.',
230 slot.setup,
231 ),
232 ),
233 );
234 if (typeof nextCleanup === 'function') slot.cleanup = nextCleanup;
235 } catch (error) {
236 recordReactionFailure();
237 failures.push(error);
238 }
239 throwReactionFailures(failures, slot.debugName);
240 }
241
242 function runCleanup(cleanup: () => void): void {
243 recordReactionCleanup();
244 untracked(() => withSignalWriteForbidden(
245 'Cannot write to a signal during useReaction cleanup.',
246 () => withHooksForbidden(
247 'Pibbl hooks cannot be called inside useReaction cleanup.',
248 cleanup,
249 ),
250 ));
251 }
252
253 function disposeReaction(slot: PibblReactionHookSlot): void {
254 if (!slot.active) return;
255 slot.active = false;
256 clearPendingReaction(slot);
257 if (slot.consumer !== undefined) {
258 slot.consumer.disposed = true;
259 disposeReactiveConsumer(slot.consumer, releaseReactionEdge);
260 slot.consumer = undefined;
261 recordReactionConsumerReleased();
262 }
263 const cleanup = slot.cleanup;
264 slot.cleanup = undefined;
265 if (cleanup !== undefined) {
266 try {
267 runCleanup(cleanup);
268 } catch (error) {
269 recordReactionFailure();
270 throw error;
271 }
272 }
273 }
274
275 function clearPendingReaction(slot: PibblReactionHookSlot): void {
276 slot.pending = false;
277 slot.pendingGeneration++;
278 const cancel = slot.cancelPending;
279 slot.cancelPending = undefined;
280 cancel?.();
281 }
282
283 function releaseReactionEdge(edge: ReactiveEdge): void {
284 if (!edge.sourceLinked) return;
285 unlinkReactiveEdgeFromSource(edge);
286 recordReactionEdgeReleased();
287 }
288
289 function snapshotDependencies(
290 dependencies: readonly unknown[] | undefined,
291 ): readonly unknown[] | undefined {
292 return dependencies === undefined ? undefined : [...dependencies];
293 }
294
295 function dependencySnapshotsEqual(
296 previous: readonly unknown[] | undefined,
297 next: readonly unknown[] | undefined,
298 ): boolean {
299 if (previous === undefined || next === undefined) return previous === next;
300 if (previous.length !== next.length) return false;
301 for (let index = 0; index < previous.length; index++) {
302 if (!Object.is(previous[index], next[index])) return false;
303 }
304 return true;
305 }
306
307 function isCurrentSlot(slot: PibblReactionHookSlot): boolean {
308 return slot.active &&
309 !slot.componentRefs.teardowns.closed &&
310 !slot.pibblInstance.mainTeardowns.closed &&
311 GLOBAL_STATE.pibblInstances.get(slot.pibblInstance.canvas) === slot.pibblInstance &&
312 slot.pibblInstance.refs.get(slot.componentRefs.id) === slot.componentRefs;
313 }
314
315 function throwReactionFailures(
316 failures: readonly unknown[],
317 debugName: string | undefined,
318 ): void {
319 if (failures.length === 1) throw failures[0];
320 if (failures.length > 1) {
321 const label = debugName === undefined ? '' : ` "${debugName}"`;
322 throw new AggregateError(
323 failures,
324 `Pibbl reaction${label} setup and cleanup failed.`,
325 );
326 }
327 }
328
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.