packages/core/src/lib/hooks/use-animated-value.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.