plans/012-motion-value-derivation-graph-spike.md
Executor instructions: This plan produces a design document, not code. Follow the steps, read every listed file in full, and write the deliverable described in "Deliverable". You may write throwaway prototype code in a scratch worktree to answer the open questions empirically, but no changes to
packages/may be part of the result. If anything in "STOP conditions" occurs, stop and report. When done, update the status row for this plan inplans/README.md— unless a reviewer dispatched you and told you they maintain the index.Drift check (run first):
git diff --stat 42bfbe3ed..HEAD -- packages/motion-dom/src/value/ packages/motion-dom/src/effects/ packages/motion-dom/src/frameloop/If these changed since this plan was written, read the diffs first — the design must target the code as it is, not as excerpted here. Also rungit branch -a | grep style-effectand check whether the effects/VisualElement unification branch (worktree-style-effect) has merged tomain; if it has, the design substrate is that code, and the "Current state" section below understates how much already runs throughMotionValueState.
worktree-style-effect branch); the design doc must state which substrate it assumes.42bfbe3ed, 2026-06-11MotionValue has two parallel derivation channels that want to be one:
subscribeValue subscribes to inputs' change events and re-runs the transformer on a frame-batched callback, pushing the result through the full MotionValue.set() machinery (velocity bookkeeping, prev-frame tracking, change notification). Derived values recompute on every input change whether or not anything observes their output.MotionValue.dependents + dirty(), whose own comment calls it "a rough start to a proper signal-like dirtying system". dirty() notifies a dependent's change subscribers with its stale current value, without recomputing. It works today only because its sole consumers (placeholder values in MotionValueState) treat the notification as "schedule a re-render" and never read the dependent's value. The moment someone wires addDependent to a real computed value, stale values flow silently downstream.This spike designs the unification: derived values become nodes in a mark-dirty graph that compute lazily (on get() or at the frame boundary), while sources keep their public synchronous push semantics. The wins: unobserved computeds cost zero, intermediate nodes skip set() bookkeeping they don't need, the dirty() footgun disappears before the effects migration spreads addDependent further, and subscribeValue/useCombineMotionValues collapse into one mechanism. The constraint that disqualifies an off-the-shelf signal runtime: this library ships to end users and bundle size is a hard priority (see CLAUDE.md "Prioritise small file size") — the design space is "minimal dirty-flag + frame-boundary flush", not "port Reactively".
Read all of these in full before designing — the doc must cite them:
The value graph:
packages/motion-dom/src/value/index.ts — MotionValue. Key regions:
:148-151 — dependents?: Set<MotionValue>, the "rough start" comment.:332-334 — dirty(): this.events.change?.notify(this.current) — notify-without-recompute, the stale-value footgun.:349-375 — updateAndNotify: timestamp + prev-frame bookkeeping on every set; equality cutoff (current !== prev); sync change notify; then dependent.dirty() loop.:384-390 — get() + collectMotionValues dependency-collection hook.:247-274 — on("change") and the unsubscribe-time frame.read auto-stop check.:406-428 — getVelocity(): depends on updatedAt/prevUpdatedAt/prevFrameValue maintained by updateAndNotify. Any laziness must not corrupt these.packages/motion-dom/src/value/subscribe-value.ts — the eager-push derivation channel (18 lines): inputs' change → frame.preRender(update, false, true) → outputValue.set(getLatest()).packages/motion-dom/src/value/transform-value.ts — creation-time static dependency collection via collectMotionValues; transformer contract "pure with no side-effects or conditional statements".packages/motion-dom/src/value/map-value.ts, follow-value.ts, spring-value.ts — other derived-value factories. Note followValue is not a pure computed (it runs an animation between values) — it stays on the push/passive-effect path; the design must say so explicitly.The consumers:
packages/motion-dom/src/effects/MotionValueState.ts — :28-44: per-key change subscription updates latest[name] and schedules a per-key render; computed && value.addDependent(computed) is the only current addDependent caller. The "computed" here is a notification proxy (e.g. the transform MotionValue created in effects/style/index.ts:34-36 stays "none" forever; the real transform string is built at render time by buildTransform(state) — i.e. the effects render path is already pull-at-render).packages/motion-dom/src/effects/style/index.ts and effects/style/transform.ts — how transform/origin placeholder nodes and render callbacks compose.packages/motion-dom/src/render/VisualElement.ts:538-608 (bindToMotionValue) and :670-688 (scheduleRender/render) — the legacy per-element render path; relies on synchronous change notification to keep latestValues fresh and on the WAAPI accelerate bypass (:543-567).packages/framer-motion/src/value/use-combine-values.ts, use-computed.ts, use-transform.ts — the React adapter layer (see plan 011, which fixes its per-render churn independently of this spike).packages/framer-motion/src/value/use-velocity.ts — polls getVelocity() via self-rescheduling frame.update; a consumer of the velocity bookkeeping invariant.The frame loop:
packages/motion-dom/src/frameloop/render-step.ts and batcher.ts — step ordering (setup, read, resolveKeyframes, preUpdate, update, preRender, render, postRender), Set-based dedup, immediate scheduling into the currently-processing step. Today's derived-value updates run in preRender; animations tick in update. This ordering is why derived values are glitch-free and compute at most once per frame already.These are public API or load-bearing internal contracts, enumerated here so the design doc addresses each one explicitly with a "how it's preserved" line:
mv.set(x) fires on("change") listeners in the same tick. User code and VisualElement.bindToMotionValue rely on it.preRender), not synchronously — laziness does not change observable timing for deriveds, and the doc should state this precisely, because it's what makes the migration tractable.get() always returns the latest value. A dirty computed must recompute synchronously on read, at any point in the frame (e.g. user code reading a useTransform output inside a frame.update callback after the input changed in the same frame).getVelocity() keeps working on deriveds. Velocity derives from updatedAt/prevUpdatedAt/prevFrameValue written in updateAndNotify. If a lazy computed only "updates" when read, two reads in the same frame after an input change must not produce velocity 0/Infinity artifacts. Decide: do computeds keep full velocity bookkeeping (status quo cost), or is velocity computed on demand from input timestamps, or do lazy nodes update bookkeeping at flush time?value/index.ts:258-270) must keep stopping orphaned animations.accelerate metadata propagation (use-transform.ts:220-238, VisualElement.bindToMotionValue:543-567): scroll-timeline WAAPI animations bypass the JS graph entirely; the graph must stay out of their way.updateAndNotify:366) must survive: converged chains go quiet.dirty()'s existing consumer semantics: MotionValueState's placeholder nodes need "input changed → schedule my render callback this frame". Whatever replaces dirty() must serve that use without the stale-value hazard.subscribe-value.ts, parts of use-combine-values.ts subscription management). State the measured delta in the doc.| Purpose | Command | Expected on success |
|---|---|---|
| Build (if prototyping against dist) | yarn build from repo root | exit 0 |
| motion-dom unit tests | npx jest --config packages/motion-dom/jest.config.json | pass (used to sanity-check prototype claims) |
| framer-motion value tests | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="value/" | pass except known pre-existing failures (SSR TextEncoder, use-velocity) |
| Recompute-count evidence | a scratch Jest test instrumenting a transformer counter | n/a — evidence for the doc |
In scope:
plans/design/012-derivation-graph.md (create the plans/design/ directory).Out of scope (hard):
packages/.followValue/springValue (animation-driven, not pure computeds) beyond stating how they coexist.plans/design/.git worktree add ../motion-spike-012), never merged.Run the drift check and the worktree-style-effect branch check from the header. Read git log --oneline -20 main and note any commits touching packages/motion-dom/src/value/ or effects/ since 42bfbe3ed. Record in the doc which substrate the design targets: current main, or the effects-unified VisualElement if that branch has landed/is imminent.
Verify: the doc's "Substrate" section names a commit SHA and a yes/no on whether worktree-style-effect content is included.
Read every file in "Current state". Produce the doc's first section: a diagram (text is fine) of today's two channels — eager-push (subscribeValue) and dirty-notify (dependents) — annotated with where computation, notification, and rendering happen for: (a) a useTransform chain feeding a style, (b) an effects-system transform key, (c) a followValue spring.
Verify: the map names the frame step (update/preRender/render) where each arrow executes.
Write scratch Jest tests (motion-dom config) that count:
transformValue whose output has zero subscribers while its input animates for N frames (expected today: N executions; the design's target: 0 until first read).x → a → b → c) per input change: executions of updateAndNotify machinery vs. pure transformer runs.Record the numbers in the doc. These are the before-figures any future implementation PR cites.
Verify: the doc contains a table of measured counts with the test code inlined in an appendix.
Answer, with chosen option and rationale, at minimum:
MotionValue instances with a compute slot (smallest API churn, keeps isMotionValue checks working) or become a subclass? (Note MotionValue is a public class users instanceof-check and extend; prefer the slot.)preRender keeps today's timing) and what schedules it?get() on a dirty node: recompute synchronously, memoize, clear flag. How does this interact with collectMotionValues (which currently abuses get() for collection)?observers counter).dirty() API disposition: rename/split so "schedule my render" (MotionValueState's need) and "your inputs changed" (graph-internal) are distinct; specify the deprecation path since dirty is on the public class (check whether it's in the published type surface and whether Framer uses it — note in doc).useVelocity(useTransform(...)) behavior.subscribe-value.ts folds into the graph; useCombineMotionValues (post-plan-011) becomes a thin "create computed once, swap transformer ref" adapter. Show before/after responsibilities.main.End the doc with a recommendation: implement (with a sketch of the follow-up plan's steps), implement-partially (e.g. only fix the dirty() footgun + lazy unobserved computeds), or don't (if Step 3's measurements show the win doesn't justify the bytes/risk — that is an acceptable conclusion and should be stated plainly if true).
Verify: plans/design/012-derivation-graph.md exists, addresses all 10 invariants by number, answers all 9 design questions, includes the Step 3 measurements, and ends with an explicit recommendation.
Doc-only plan — no shipped tests. The scratch measurement tests from Step 3 are inlined in the doc's appendix so the implementation plan can resurrect them as regression gates.
plans/design/012-derivation-graph.md exists and contains: substrate statement (Step 1), graph map (Step 2), measured before-figures (Step 3), all invariants addressed by number, all design questions answered, bundle-delta estimate, migration order, go/no-go recommendation.git status shows no modifications under packages/ (scratch worktree excluded).plans/README.md status row for 012 updated.Stop and report back (do not improvise) if:
worktree-style-effect branch has merged AND reshaped MotionValueState/VisualElement beyond what 30 minutes of diff-reading can absorb — report what changed and ask whether the spike should target the new code.addDependent|subscribeValue|attach\( across packages/ first; if something else drives derived values, the inventory above is incomplete and the design would be built on a wrong map).subscribe-value.ts and report.dirty() outright) — flag it as an open question for the maintainer instead of deciding.worktree-style-effect should review it before the implementation plan is written.useConstant-based subscription state is what this design's adapter layer would replace.