@octanejs/motion 0.1.33 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/motion",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -39,18 +39,32 @@
39
39
  "./react-m": "./src/react-m.ts"
40
40
  },
41
41
  "dependencies": {
42
- "motion": "^12.42.2"
42
+ "motion": "^12.42.2",
43
+ "motion-dom": "^12.0.0"
43
44
  },
44
45
  "peerDependencies": {
45
- "octane": "0.1.34"
46
+ "octane": "0.1.36"
46
47
  },
47
48
  "devDependencies": {
49
+ "@swc/core": "1.15.41",
50
+ "@swc/jest": "0.2.39",
51
+ "@testing-library/dom": "^10.4.1",
52
+ "@testing-library/jest-dom": "^6.9.1",
53
+ "@testing-library/react": "16.3.2",
54
+ "@tsrx/react": "^0.2.56",
48
55
  "esbuild": "^0.28.1",
56
+ "jest": "30.4.2",
57
+ "jest-environment-jsdom": "30.4.1",
58
+ "jest-fixed-jsdom": "0.0.10",
59
+ "react": "^19.2.7",
60
+ "react-dom": "^19.2.7",
49
61
  "vitest": "^4.1.10",
50
- "octane": "0.1.34"
62
+ "octane": "0.1.36"
51
63
  },
52
64
  "scripts": {
53
65
  "test": "vitest run --root ../.. --project motion",
54
- "typecheck": "tsgo --noEmit -p tsconfig.json"
66
+ "typecheck": "tsgo --noEmit -p tsconfig.json",
67
+ "test:upstream": "jest --config tests/upstream-jest.config.cjs --rootDir upstream --runInBand --no-watchman",
68
+ "upstream:verify": "node ./scripts/verify-upstream.mjs"
55
69
  }
56
70
  }
package/src/index.ts CHANGED
@@ -27,7 +27,12 @@ import {
27
27
  resolveVariant,
28
28
  splitVariant,
29
29
  } from './context';
30
- import { isMotionValue, isTransformKey, applyStyleValue } from './useMotionValue';
30
+ import {
31
+ isMotionValue,
32
+ isTransformKey,
33
+ applyStyleValue,
34
+ removeTransformFn,
35
+ } from './useMotionValue';
31
36
  import { useReducedMotionAtSlot } from './useReducedMotion';
32
37
  import {
33
38
  layoutAnimationMode,
@@ -58,6 +63,10 @@ const LAYOUT_GROUP_STATE = Symbol.for('octane-motion:layout-group-state');
58
63
  const LAZY_STATE = Symbol.for('octane-motion:lazy-state');
59
64
  const LAZY_EFFECT = Symbol.for('octane-motion:lazy-effect');
60
65
 
66
+ // Tracks MotionValue / transform shorthand keys last applied to a host so a
67
+ // rebind can clear stale inline styles without wiping them on unmount (exit
68
+ // clones still need the live visual state).
69
+ const motionStyleKeys = new WeakMap<HTMLElement, string[]>();
61
70
  const boxOf = (n: HTMLElement): LayoutBox => {
62
71
  const r = n.getBoundingClientRect();
63
72
  return { left: r.left, top: r.top, width: r.width, height: r.height };
@@ -303,6 +312,7 @@ function createMotionComponent(tag: string, preloadedFeatures: boolean): MotionC
303
312
  latest.layoutMode = layoutAnimationMode(props.layout);
304
313
 
305
314
  // `initial`: apply instantly on mount (before the animate effect runs).
315
+ // OCTANE DIVERGENCE[motion-initial-only-no-style-materialization][conformance:initial-only-no-style-materialization]
306
316
  useLayoutEffect(
307
317
  () => {
308
318
  if (animationEnabled && resolvedInitial) {
@@ -359,25 +369,64 @@ function createMotionComponent(tag: string, preloadedFeatures: boolean): MotionC
359
369
 
360
370
  // Motion values + static transform shorthands in `style`. MotionValues are
361
371
  // subscribed (and update the element without a re-render); shorthands apply once.
372
+ // Re-bind when the style bag's MotionValue identities or shorthand values change
373
+ // so callers can swap `style={{ x }}` across updates (upstream useMotionValue).
374
+ const styleMvDeps: any[] = [];
375
+ if (props.style && typeof props.style === 'object') {
376
+ for (const key in props.style) {
377
+ const v = props.style[key];
378
+ if (isMotionValue(v) || isTransformKey(key)) styleMvDeps.push(key, v);
379
+ }
380
+ }
362
381
  useLayoutEffect(
363
382
  () => {
383
+ // Clear only keys this effect previously managed that are absent from the
384
+ // new style bag. Patch transform functions in place so animate/layout/drag
385
+ // values on `style.transform` survive a MotionValue rebind.
386
+ // Do not clear in cleanup: on unmount the exit effect clones this node
387
+ // after layout-effect cleanups, and still needs the live transforms.
364
388
  const style = props.style;
389
+ const prevKeys = motionStyleKeys.get(node);
390
+ if (prevKeys) {
391
+ for (const key of prevKeys) {
392
+ const next = style && typeof style === 'object' ? style[key] : undefined;
393
+ const stillManaged = next !== undefined && (isMotionValue(next) || isTransformKey(key));
394
+ if (stillManaged) continue;
395
+ // Plain static values stay in the style bag for host/domProps — do not blank
396
+ // them after a MotionValue→static switch (domProps already applied them).
397
+ if (next !== undefined) continue;
398
+ if (isTransformKey(key)) removeTransformFn(node, key);
399
+ else (node.style as any)[key] = '';
400
+ }
401
+ motionStyleKeys.delete(node);
402
+ }
403
+
365
404
  if (!style || typeof style !== 'object') return;
366
- const transformState: Record<string, any> = {};
405
+ const appliedKeys: string[] = [];
367
406
  const cleanups: Array<() => void> = [];
407
+ // Shared bag of currently bound transform shorthands so a compound
408
+ // FLIP patch can keep sibling axes owned by other style MotionValues.
409
+ const transformState: Record<string, any> = {};
368
410
  for (const key in style) {
369
411
  const v = style[key];
370
412
  if (isMotionValue(v)) {
371
- const apply = (val: any) => applyStyleValue(node, key, val, transformState);
413
+ function apply(val: any) {
414
+ applyStyleValue(node, key, val, transformState);
415
+ }
372
416
  apply(v.get());
417
+ appliedKeys.push(key);
373
418
  cleanups.push(v.on('change', apply));
374
419
  } else if (isTransformKey(key)) {
375
420
  applyStyleValue(node, key, v, transformState);
421
+ appliedKeys.push(key);
376
422
  }
377
423
  }
378
- return () => cleanups.forEach((c) => c());
424
+ if (appliedKeys.length) motionStyleKeys.set(node, appliedKeys);
425
+ return function unsubscribeMotionStyles() {
426
+ for (const cleanup of cleanups) cleanup();
427
+ };
379
428
  },
380
- [],
429
+ styleMvDeps,
381
430
  MV,
382
431
  );
383
432
 
@@ -518,6 +567,7 @@ function createMotionComponent(tag: string, preloadedFeatures: boolean): MotionC
518
567
  // moved/resized vs the previous commit, apply the inverse transform instantly
519
568
  // then animate it back to identity. (A single-element FLIP; the full projection
520
569
  // tree — nested/shared layout, scale correction — is out of scope.)
570
+ // OCTANE DIVERGENCE[motion-bounded-layout-flip][conformance:bounded-layout-flip]
521
571
  useLayoutEffect(
522
572
  () => {
523
573
  if (!layoutEnabled || !props.layout) {
@@ -552,6 +602,7 @@ function createMotionComponent(tag: string, preloadedFeatures: boolean): MotionC
552
602
 
553
603
  // `layoutId`: shared-element crossfade. On mount, if a same-id element recently
554
604
  // unmounted, FLIP from its recorded box to ours.
605
+ // OCTANE DIVERGENCE[motion-bounded-layoutId][conformance:bounded-layoutId]
555
606
  useLayoutEffect(
556
607
  () => {
557
608
  if (!layoutEnabled) return;
@@ -580,6 +631,7 @@ function createMotionComponent(tag: string, preloadedFeatures: boolean): MotionC
580
631
  // Unmount: this cleanup runs while the node is still in the DOM. Record shared
581
632
  // layout state here—not in the keyed claim effect, whose cleanup also runs on
582
633
  // dependency changes—then clone any exiting node outside the block's range.
634
+ // OCTANE DIVERGENCE[motion-exit-cleanup-before-detach][conformance:exit-cleanup-before-detach]
583
635
  useLayoutEffect(
584
636
  () => () => {
585
637
  const n: HTMLElement | null = latest.node;
@@ -47,29 +47,235 @@ const PX_KEYS = new Set(['x', 'y', 'z']);
47
47
  const DEG_KEYS = new Set(['rotate', 'rotateX', 'rotateY', 'rotateZ', 'skewX', 'skewY']);
48
48
  const NO_UNIT = new Set(['opacity', 'zIndex', 'scale', 'scaleX', 'scaleY']);
49
49
 
50
+ // Motion-style CSS transform function order: translate before scale/rotate so
51
+ // offsets are not scaled when both are present.
52
+ const TRANSFORM_ORDER = [
53
+ 'translateX',
54
+ 'translateY',
55
+ 'translateZ',
56
+ 'scale',
57
+ 'scaleX',
58
+ 'scaleY',
59
+ 'rotate',
60
+ 'rotateX',
61
+ 'rotateY',
62
+ 'rotateZ',
63
+ 'skewX',
64
+ 'skewY',
65
+ ];
66
+
50
67
  export function isTransformKey(k: string): boolean {
51
68
  return k in TRANSFORM_FN;
52
69
  }
53
70
 
54
- // Apply one style/transform value to the element, rebuilding the transform string
55
- // from the accumulated transform-key state.
56
- export function applyStyleValue(
71
+ function unitizeTransformValue(key: string, val: any): string {
72
+ if (typeof val !== 'number') return String(val);
73
+ if (PX_KEYS.has(key)) return `${val}px`;
74
+ if (DEG_KEYS.has(key)) return `${val}deg`;
75
+ return `${val}`;
76
+ }
77
+
78
+ /** Locate `fn(...)` with balanced parentheses so nested `calc(...)` survives. */
79
+ function findTransformFnRange(
80
+ transform: string,
81
+ fn: string,
82
+ ): { start: number; end: number } | null {
83
+ const needle = fn + '(';
84
+ let from = 0;
85
+ while (from < transform.length) {
86
+ const idx = transform.indexOf(needle, from);
87
+ if (idx === -1) return null;
88
+ // Prefer a token boundary so `scale(` does not match inside `scaleX(`.
89
+ if (idx > 0 && /\S/.test(transform.charAt(idx - 1))) {
90
+ from = idx + 1;
91
+ continue;
92
+ }
93
+ let depth = 0;
94
+ for (let i = idx + fn.length; i < transform.length; i++) {
95
+ const ch = transform.charAt(i);
96
+ if (ch === '(') depth++;
97
+ else if (ch === ')') {
98
+ depth--;
99
+ if (depth === 0) return { start: idx, end: i + 1 };
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+ return null;
105
+ }
106
+
107
+ function insertTransformFn(current: string, next: string, fn: string): string {
108
+ const orderIdx = TRANSFORM_ORDER.indexOf(fn);
109
+ if (orderIdx === -1) return `${current} ${next}`.trim();
110
+ for (let i = orderIdx + 1; i < TRANSFORM_ORDER.length; i++) {
111
+ const later = findTransformFnRange(current, TRANSFORM_ORDER[i]);
112
+ if (later) {
113
+ const before = current.slice(0, later.start).trimEnd();
114
+ const after = current.slice(later.start).trimStart();
115
+ return before ? `${before} ${next} ${after}` : `${next} ${after}`;
116
+ }
117
+ }
118
+ return `${current} ${next}`.trim();
119
+ }
120
+
121
+ /** Split `fn(a, b)` args at depth-0 commas so nested `calc(...)` survives. */
122
+ function splitTransformArgs(inner: string): string[] {
123
+ const parts: string[] = [];
124
+ let start = 0;
125
+ let depth = 0;
126
+ for (let i = 0; i < inner.length; i++) {
127
+ const ch = inner.charAt(i);
128
+ if (ch === '(') depth++;
129
+ else if (ch === ')') depth--;
130
+ else if (ch === ',' && depth === 0) {
131
+ const part = inner.slice(start, i).trim();
132
+ if (part) parts.push(part);
133
+ start = i + 1;
134
+ }
135
+ }
136
+ const tail = inner.slice(start).trim();
137
+ if (tail) parts.push(tail);
138
+ return parts;
139
+ }
140
+
141
+ function replaceTransformRange(
142
+ current: string,
143
+ range: { start: number; end: number },
144
+ next: string,
145
+ ): string {
146
+ return (current.slice(0, range.start) + next + current.slice(range.end)).trim();
147
+ }
148
+
149
+ /**
150
+ * Layout FLIP writes compound `translate(x, y)` / `scale(sx, sy)`. Decompose those
151
+ * into shorthands when a style MotionValue binds so unbind can remove only that
152
+ * shorthand without corrupting or orphaning the layout contribution.
153
+ *
154
+ * When `transformState` already holds a sibling axis (another bound style
155
+ * MotionValue/static shorthand), prefer that over the FLIP compound value so a
156
+ * single-axis patch does not clobber the sibling until it emits again.
157
+ */
158
+ function patchCompoundTransform(
159
+ current: string,
160
+ key: string,
161
+ val: any,
162
+ transformState?: Record<string, any>,
163
+ ): string | null {
164
+ const unitized = unitizeTransformValue(key, val);
165
+ if (key === 'x' || key === 'y') {
166
+ const range = findTransformFnRange(current, 'translate');
167
+ if (!range) return null;
168
+ const inner = current.slice(range.start + 'translate('.length, range.end - 1);
169
+ const args = splitTransformArgs(inner);
170
+ let x =
171
+ transformState && transformState.x !== undefined
172
+ ? unitizeTransformValue('x', transformState.x)
173
+ : args[0] || '0px';
174
+ let y =
175
+ transformState && transformState.y !== undefined
176
+ ? unitizeTransformValue('y', transformState.y)
177
+ : args[1] || '0px';
178
+ if (key === 'x') x = unitized;
179
+ else y = unitized;
180
+ return replaceTransformRange(current, range, `translateX(${x}) translateY(${y})`);
181
+ }
182
+ if (key === 'scaleX' || key === 'scaleY') {
183
+ const range = findTransformFnRange(current, 'scale');
184
+ if (!range) return null;
185
+ const inner = current.slice(range.start + 'scale('.length, range.end - 1);
186
+ const args = splitTransformArgs(inner);
187
+ let sx =
188
+ transformState && transformState.scaleX !== undefined
189
+ ? unitizeTransformValue('scaleX', transformState.scaleX)
190
+ : args[0] || '1';
191
+ let sy =
192
+ transformState && transformState.scaleY !== undefined
193
+ ? unitizeTransformValue('scaleY', transformState.scaleY)
194
+ : args[1] !== undefined
195
+ ? args[1]
196
+ : args[0] || '1';
197
+ if (key === 'scaleX') sx = unitized;
198
+ else sy = unitized;
199
+ return replaceTransformRange(current, range, `scaleX(${sx}) scaleY(${sy})`);
200
+ }
201
+ return null;
202
+ }
203
+
204
+ /** Patch one transform function into the live CSS string without wiping others. */
205
+ export function patchTransformFn(
57
206
  node: HTMLElement,
58
207
  key: string,
59
208
  val: any,
60
- transformState: Record<string, any>,
209
+ transformState?: Record<string, any>,
61
210
  ): void {
62
211
  const fn = TRANSFORM_FN[key];
63
- if (fn) {
64
- transformState[key] = val;
65
- let t = '';
66
- for (const k in transformState) {
67
- let v = transformState[k];
68
- if (typeof v === 'number')
69
- v = PX_KEYS.has(k) ? `${v}px` : DEG_KEYS.has(k) ? `${v}deg` : `${v}`;
70
- t += `${TRANSFORM_FN[k]}(${v}) `;
212
+ if (!fn) return;
213
+ const next = `${fn}(${unitizeTransformValue(key, val)})`;
214
+ const current = node.style.transform || '';
215
+ if (!current || current === 'none') {
216
+ node.style.transform = next;
217
+ return;
218
+ }
219
+ const range = findTransformFnRange(current, fn);
220
+ if (range) {
221
+ // Layout FLIP writes asymmetric `scale(sx, sy)`. A style `scale` MotionValue
222
+ // shares the `scale(` token, but replacing that compound with uniform
223
+ // `scale(n)` drops an axis mid-FLIP. Leave asymmetric compounds alone —
224
+ // `scaleX`/`scaleY` still decompose via patchCompoundTransform below.
225
+ if (key === 'scale') {
226
+ const inner = current.slice(range.start + 'scale('.length, range.end - 1);
227
+ const args = splitTransformArgs(inner);
228
+ if (args.length >= 2 && args[0] !== args[1]) {
229
+ return;
230
+ }
71
231
  }
72
- node.style.transform = t.trim();
232
+ node.style.transform = replaceTransformRange(current, range, next);
233
+ return;
234
+ }
235
+ const compound = patchCompoundTransform(current, key, val, transformState);
236
+ if (compound !== null) {
237
+ node.style.transform = compound;
238
+ return;
239
+ }
240
+ node.style.transform = insertTransformFn(current, next, fn);
241
+ }
242
+
243
+ /**
244
+ * Remove one shorthand transform function from the live CSS string.
245
+ * Do not mutate compound layout FLIP `translate(...)` / `scale(...)` forms —
246
+ * those are owned by layout, not by style MotionValue unbind.
247
+ */
248
+ export function removeTransformFn(node: HTMLElement, key: string): void {
249
+ const fn = TRANSFORM_FN[key];
250
+ if (!fn) return;
251
+ const current = node.style.transform || '';
252
+ if (!current || current === 'none') return;
253
+ const range = findTransformFnRange(current, fn);
254
+ if (!range) return;
255
+ // Style `scale` shares the `scale(` token with layout FLIP's compound
256
+ // `scale(sx, sy)`. Only remove single-arg style-owned forms; leave compounds
257
+ // for layout (mirrors patchTransformFn's asymmetric-scale guard on bind).
258
+ if (key === 'scale') {
259
+ const inner = current.slice(range.start + 'scale('.length, range.end - 1);
260
+ const args = splitTransformArgs(inner);
261
+ if (args.length >= 2) return;
262
+ }
263
+ const before = current.slice(0, range.start).trimEnd();
264
+ const after = current.slice(range.end).trimStart();
265
+ node.style.transform = before && after ? `${before} ${after}` : before || after;
266
+ }
267
+
268
+ // Apply one style/transform value to the element. Transform shorthands patch the
269
+ // live `transform` string in place so animate/layout/drag values survive rebinds.
270
+ export function applyStyleValue(
271
+ node: HTMLElement,
272
+ key: string,
273
+ val: any,
274
+ transformState?: Record<string, any>,
275
+ ): void {
276
+ if (TRANSFORM_FN[key]) {
277
+ if (transformState) transformState[key] = val;
278
+ patchTransformFn(node, key, val, transformState);
73
279
  } else {
74
280
  (node.style as any)[key] = typeof val === 'number' && !NO_UNIT.has(key) ? `${val}px` : val;
75
281
  }