Skip to content

packages/core/src/lib/hooks/use-reaction.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

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 built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.