Skip to content

packages/core/src/lib/hooks/use-animated-value.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 { bindAnimationTarget } from '../animation/definition.js';
2 import { drive } from '../animation/program.js';
3 import { withSignalWriteForbidden } from '../signals/graph.js';
4 import {
5   createAnimationPlaybackController,
6   type AnimationPlaybackController,
7 } from '../animation/playback.js';
8 import type {
9   PibblAnimatedValueOptions,
10 } from '../animation/types.js';
11 import { GLOBAL_STATE } from '../global-state.js';
12 import {
13   isSignal,
14   provisionSignalValueForRender,
15   signal,
16   untracked,
17 } from '../signals/graph.js';
18 import { resolveSignalValue } from '../signals/resolve-signal-value.js';
19 import type {
20   Signal,
21   SignalEquals,
22   SignalValue,
23   WritableSignal,
24 } from '../signals/types.js';
25 import type { PibblComponentRefs, PibblInstance } from '../types.js';
26 import { requireHookContext, useHookSlot, withHooksForbidden } from './hook-slot.js';
27 
28 type AnimatedValueInputs<T> = {
29   readonly [K in keyof PibblAnimatedValueOptions<T>]: SignalValue<PibblAnimatedValueOptions<T>[K]>;
30 };
31 
32 interface PibblTransitionCandidate<T> {
33   readonly options: PibblAnimatedValueOptions<T>;
34   readonly equals: SignalEquals<T>;
35   readonly hasInitial: boolean;
36   readonly initial: T;
37   readonly target: T;
38 }
39 
40 interface PibblTransitionHookSlot<T> {
41   readonly componentRefs: PibblComponentRefs<any>;
42   readonly controller: AnimationPlaybackController;
43   readonly pibblInstance: PibblInstance;
44   readonly output: WritableSignal<T>;
45   readonly readonlyOutput: Signal<T>;
46   renderRevision: number;
47   settled: boolean;
48   target: T;
49 }
50 
51 function normalizeCandidate<T>(
52   target: T,
53   options: PibblAnimatedValueOptions<T>,
54 ): PibblTransitionCandidate<T> {
55   if (typeof options !== 'object' || options === null) {
56     throw new TypeError('useAnimatedValue options must be an object.');
57   }
58   if (options.equals !== undefined && typeof options.equals !== 'function') {
59     throw new TypeError('Pibbl transition equals must be a function.');
60   }
61   const snapshot = Object.freeze({ ...options });
62   const hasInitial = Object.prototype.hasOwnProperty.call(options, 'initial');
63   return Object.freeze({
64     options: snapshot,
65     equals: snapshot.equals ?? Object.is,
66     hasInitial,
67     initial: (hasInitial ? snapshot.initial : target) as T,
68     target,
69   });
70 }
71 
72 function isCurrentSlot<T>(slot: PibblTransitionHookSlot<T>): boolean {
73   return !slot.componentRefs.teardowns.closed &&
74     !slot.pibblInstance.mainTeardowns.closed &&
75     GLOBAL_STATE.pibblInstances.get(slot.pibblInstance.canvas) === slot.pibblInstance &&
76     slot.pibblInstance.refs.get(slot.componentRefs.id) === slot.componentRefs;
77 }
78 
79 /**
80  * Presents a target through an immutable animation definition or a pure animation factory.
81  * @param targetInput - Target value or signal. See {@link SignalValue}.
82  * @param optionsInput - Animation definition, initial value, equality, and diagnostic settings;
83  * fields and the options object may be signals. See {@link PibblAnimatedValueOptions}.
84  * @returns One stable readonly animated signal. See {@link Signal}.
85  */
86 export function useAnimatedValue<T>(
87   targetInput: SignalValue<T>,
88   optionsInput: SignalValue<AnimatedValueInputs<NoInfer<T>>>,
89 ): Signal<T> {
90   const { componentRefs, pibblInstance } = requireHookContext();
91   const target = resolveSignalValue(targetInput);
92   const options = resolveTransitionOptions<T>(optionsInput);
93   const candidate = normalizeCandidate(target, options);
94   const slot = useHookSlot<PibblTransitionHookSlot<T>>('animatedValue', teardowns => {
95     const output = signal(candidate.initial);
96     const program = programFor(output, candidate.initial, candidate);
97     const controller = createAnimationPlaybackController(program, {
98       conflict: 'replace',
99       debugName: candidate.options.debugName,
100     });
101     controller.setDeferredStatusCommandOwner(pibblInstance.mainTeardowns);
102     const value: PibblTransitionHookSlot<T> = {
103       componentRefs,
104       controller,
105       pibblInstance,
106       output,
107       readonlyOutput: output.asReadonly(),
108       renderRevision: 0,
109       settled: false,
110       target: candidate.target,
111     };
112     teardowns.add(() => controller.dispose());
113     return value;
114   }).value;
115 
116   // Prepare user-defined work while Render can still fail transactionally. No factory runs
117   // in onFinish, where a late exception could otherwise adopt only part of the update.
118   const program = programFor(slot.output,
119     slot.settled ? untracked(() => slot.output.get()) : candidate.initial,
120     candidate, slot.settled ? slot.controller.committedSpringSnapshot()?.velocity : undefined);
121 
122   const shouldEnter = !slot.settled && candidate.hasInitial &&
123     !candidate.equals(candidate.initial, candidate.target);
124   const shouldRetarget = slot.settled &&
125     !candidate.equals(slot.target, candidate.target);
126   const previousProvisionalValue = untracked(() => slot.output.get());
127   const hasProvisionalValue = !slot.settled &&
128     !Object.is(previousProvisionalValue, candidate.initial);
129   const transaction = GLOBAL_STATE.renderTransaction;
130   if (!transaction) {
131     throw new Error('useAnimatedValue requires an active Pibbl render transaction.');
132   }
133   if (hasProvisionalValue) {
134     provisionSignalValueForRender(slot.output, candidate.initial, transaction);
135   }
136   const revision = ++slot.renderRevision;
137   transaction.onFinish(success => {
138     if (!success) return;
139     if (revision !== slot.renderRevision || !isCurrentSlot(slot)) return;
140     slot.settled = true;
141     slot.target = candidate.target;
142     if (!shouldEnter && !shouldRetarget) {
143       slot.controller.adopt(
144         program,
145         {
146           conflict: 'replace',
147           debugName: candidate.options.debugName,
148         },
149       );
150       return;
151     }
152     slot.controller.retarget(program, {
153       conflict: 'replace',
154       debugName: candidate.options.debugName,
155     });
156   });
157   return slot.readonlyOutput;
158 }
159 
160 function resolveTransitionOptions<T>(
161   input: SignalValue<AnimatedValueInputs<T>>,
162 ): PibblAnimatedValueOptions<T> {
163   const source = resolveSignalValue(input);
164   let resolved: Record<PropertyKey, unknown> | undefined;
165   for (const key in source) {
166     const value = source[key as keyof typeof source];
167     if (!isSignal(value)) continue;
168     resolved ??= { ...source };
169     resolved[key] = resolveSignalValue(value as SignalValue<unknown>);
170   }
171   return (resolved ?? source) as PibblAnimatedValueOptions<T>;
172 }
173 
174 function programFor<T>(
175   output: WritableSignal<T>, from: T, candidate: PibblTransitionCandidate<T>, velocity?: number,
176 ) {
177   const context = Object.freeze({ from, to: candidate.target, velocity });
178   const animation = candidate.options.animation;
179   const definition = typeof animation === 'function'
180     ? withHooksForbidden('Animation factories cannot call hooks.', () =>
181       withSignalWriteForbidden('Animation factories cannot write to signals.', () => animation(context)))
182     : animation;
183   return drive(output, bindAnimationTarget(definition, context));
184 }
185 

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.