framer-motion 12.40.0 → 12.41.0

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.
@@ -158,7 +158,7 @@
158
158
  }
159
159
 
160
160
  /*#__NO_SIDE_EFFECTS__*/
161
- const noop$1 = (any) => any;
161
+ const noop = (any) => any;
162
162
 
163
163
  /**
164
164
  * Pipe
@@ -295,7 +295,7 @@
295
295
  function cubicBezier(mX1, mY1, mX2, mY2) {
296
296
  // If this is a linear gradient, return linear easing
297
297
  if (mX1 === mY1 && mX2 === mY2)
298
- return noop$1;
298
+ return noop;
299
299
  const getTForX = (aX) => binarySubdivide(aX, 0, 1, mX1, mX2);
300
300
  // If animation is at start/end, return t without easing
301
301
  return (t) => t === 0 || t === 1 ? t : calcBezier(getTForX(t), mY1, mY2);
@@ -356,7 +356,7 @@
356
356
  const isBezierDefinition = (easing) => Array.isArray(easing) && typeof easing[0] === "number";
357
357
 
358
358
  const easingLookup = {
359
- linear: noop$1,
359
+ linear: noop,
360
360
  easeIn,
361
361
  easeInOut,
362
362
  easeOut,
@@ -397,12 +397,7 @@
397
397
  "postRender", // Compute
398
398
  ];
399
399
 
400
- const statsBuffer = {
401
- value: null,
402
- addProjectionMetrics: null,
403
- };
404
-
405
- function createRenderStep(runNextFrame, stepName) {
400
+ function createRenderStep(runNextFrame) {
406
401
  /**
407
402
  * We create and reuse two queues, one to queue jobs for the current frame
408
403
  * and one for the next. We reuse to avoid triggering GC after x frames.
@@ -424,13 +419,11 @@
424
419
  timestamp: 0.0,
425
420
  isProcessing: false,
426
421
  };
427
- let numCalls = 0;
428
422
  function triggerCallback(callback) {
429
423
  if (toKeepAlive.has(callback)) {
430
424
  step.schedule(callback);
431
425
  runNextFrame();
432
426
  }
433
- numCalls++;
434
427
  callback(latestFrameData);
435
428
  }
436
429
  const step = {
@@ -473,13 +466,6 @@
473
466
  nextFrame = prevFrame;
474
467
  // Execute this frame
475
468
  thisFrame.forEach(triggerCallback);
476
- /**
477
- * If we're recording stats then
478
- */
479
- if (stepName && statsBuffer.value) {
480
- statsBuffer.value.frameloop[stepName].push(numCalls);
481
- }
482
- numCalls = 0;
483
469
  // Clear the frame so no callbacks remain. This is to avoid
484
470
  // memory leaks should this render step not run for a while.
485
471
  thisFrame.clear();
@@ -504,7 +490,7 @@
504
490
  };
505
491
  const flagRunNextFrame = () => (runNextFrame = true);
506
492
  const steps = stepsOrder.reduce((acc, key) => {
507
- acc[key] = createRenderStep(flagRunNextFrame, allowKeepAlive ? key : undefined);
493
+ acc[key] = createRenderStep(flagRunNextFrame);
508
494
  return acc;
509
495
  }, {});
510
496
  const { setup, read, resolveKeyframes, preUpdate, update, preRender, render, postRender, } = steps;
@@ -560,7 +546,7 @@
560
546
  return { schedule, cancel, state, steps };
561
547
  }
562
548
 
563
- const { schedule: frame, cancel: cancelFrame, state: frameData, steps: frameSteps, } = /* @__PURE__ */ createRenderBatcher(typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : noop$1, true);
549
+ const { schedule: frame, cancel: cancelFrame, state: frameData, steps: frameSteps, } = /* @__PURE__ */ createRenderBatcher(typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : noop, true);
564
550
 
565
551
  let now;
566
552
  function clearTime() {
@@ -589,12 +575,6 @@
589
575
  },
590
576
  };
591
577
 
592
- const activeAnimations = {
593
- layout: 0,
594
- mainThread: 0,
595
- waapi: 0,
596
- };
597
-
598
578
  const checkStringStartsWith = (token) => (key) => typeof key === "string" && key.startsWith(token);
599
579
  const isCSSVariableName =
600
580
  /*@__PURE__*/ checkStringStartsWith("--");
@@ -1582,7 +1562,7 @@
1582
1562
  for (let i = 0; i < numMixers; i++) {
1583
1563
  let mixer = mixerFactory(output[i], output[i + 1]);
1584
1564
  if (ease) {
1585
- const easingFunction = Array.isArray(ease) ? ease[i] || noop$1 : ease;
1565
+ const easingFunction = Array.isArray(ease) ? ease[i] || noop : ease;
1586
1566
  mixer = pipe(easingFunction, mixer);
1587
1567
  }
1588
1568
  mixers.push(mixer);
@@ -1796,7 +1776,6 @@
1796
1776
  this.teardown();
1797
1777
  this.options.onStop?.();
1798
1778
  };
1799
- activeAnimations.mainThread++;
1800
1779
  this.options = options;
1801
1780
  this.initAnimation();
1802
1781
  this.play();
@@ -2101,7 +2080,6 @@
2101
2080
  this.state = "idle";
2102
2081
  this.stopDriver();
2103
2082
  this.startTime = this.holdTime = null;
2104
- activeAnimations.mainThread--;
2105
2083
  }
2106
2084
  stopDriver() {
2107
2085
  if (!this.driver)
@@ -2513,9 +2491,6 @@
2513
2491
  */
2514
2492
  if (Array.isArray(easing))
2515
2493
  keyframeOptions.easing = easing;
2516
- if (statsBuffer.value) {
2517
- activeAnimations.waapi++;
2518
- }
2519
2494
  const options = {
2520
2495
  delay,
2521
2496
  duration,
@@ -2526,13 +2501,7 @@
2526
2501
  };
2527
2502
  if (pseudoElement)
2528
2503
  options.pseudoElement = pseudoElement;
2529
- const animation = element.animate(keyframeOptions, options);
2530
- if (statsBuffer.value) {
2531
- animation.finished.finally(() => {
2532
- activeAnimations.waapi--;
2533
- });
2534
- }
2535
- return animation;
2504
+ return element.animate(keyframeOptions, options);
2536
2505
  }
2537
2506
 
2538
2507
  function isGenerator(type) {
@@ -2711,7 +2680,7 @@
2711
2680
  this.animation.rangeStart = rangeStart;
2712
2681
  if (rangeEnd)
2713
2682
  this.animation.rangeEnd = rangeEnd;
2714
- return noop$1;
2683
+ return noop;
2715
2684
  }
2716
2685
  else {
2717
2686
  return observe(this);
@@ -3064,7 +3033,7 @@
3064
3033
  }
3065
3034
  animation.finished.then(() => {
3066
3035
  this.notifyFinished();
3067
- }).catch(noop$1);
3036
+ }).catch(noop);
3068
3037
  if (this.pendingTimeline) {
3069
3038
  this.stopTimeline = animation.attachTimeline(this.pendingTimeline);
3070
3039
  this.pendingTimeline = undefined;
@@ -5242,9 +5211,16 @@
5242
5211
  claimedPointerDownEvents.add(startEvent);
5243
5212
  }
5244
5213
  const onPressEnd = onPressStart(target, startEvent);
5214
+ /**
5215
+ * End listeners run in the capture phase so a descendant calling
5216
+ * stopPropagation() in its own pointerup handler can't prevent the
5217
+ * press gesture from ending. This also keeps the gesture-end
5218
+ * ordering consistent with the drag gesture. See #2794.
5219
+ */
5220
+ const endEventOptions = { ...eventOptions, capture: true };
5245
5221
  const onPointerEnd = (endEvent, success) => {
5246
- window.removeEventListener("pointerup", onPointerUp);
5247
- window.removeEventListener("pointercancel", onPointerCancel);
5222
+ window.removeEventListener("pointerup", onPointerUp, endEventOptions);
5223
+ window.removeEventListener("pointercancel", onPointerCancel, endEventOptions);
5248
5224
  if (isPressing.has(target)) {
5249
5225
  isPressing.delete(target);
5250
5226
  }
@@ -5264,8 +5240,8 @@
5264
5240
  const onPointerCancel = (cancelEvent) => {
5265
5241
  onPointerEnd(cancelEvent, false);
5266
5242
  };
5267
- window.addEventListener("pointerup", onPointerUp, eventOptions);
5268
- window.addEventListener("pointercancel", onPointerCancel, eventOptions);
5243
+ window.addEventListener("pointerup", onPointerUp, endEventOptions);
5244
+ window.addEventListener("pointercancel", onPointerCancel, endEventOptions);
5269
5245
  };
5270
5246
  targets.forEach((target) => {
5271
5247
  const pointerDownTarget = options.useGlobalTarget ? window : target;
@@ -5404,116 +5380,34 @@
5404
5380
  return () => cancelFrame(onFrame);
5405
5381
  }
5406
5382
 
5407
- function record() {
5408
- const { value } = statsBuffer;
5409
- if (value === null) {
5410
- cancelFrame(record);
5411
- return;
5412
- }
5413
- value.frameloop.rate.push(frameData.delta);
5414
- value.animations.mainThread.push(activeAnimations.mainThread);
5415
- value.animations.waapi.push(activeAnimations.waapi);
5416
- value.animations.layout.push(activeAnimations.layout);
5417
- }
5418
- function mean(values) {
5419
- return values.reduce((acc, value) => acc + value, 0) / values.length;
5420
- }
5421
- function summarise(values, calcAverage = mean) {
5422
- if (values.length === 0) {
5423
- return {
5424
- min: 0,
5425
- max: 0,
5426
- avg: 0,
5427
- };
5428
- }
5429
- return {
5430
- min: Math.min(...values),
5431
- max: Math.max(...values),
5432
- avg: calcAverage(values),
5433
- };
5434
- }
5435
- const msToFps = (ms) => Math.round(1000 / ms);
5383
+ const statsBuffer = {
5384
+ value: null,
5385
+ addProjectionMetrics: null,
5386
+ };
5387
+
5436
5388
  function clearStatsBuffer() {
5437
5389
  statsBuffer.value = null;
5438
5390
  statsBuffer.addProjectionMetrics = null;
5439
5391
  }
5440
- function reportStats() {
5441
- const { value } = statsBuffer;
5442
- if (!value) {
5443
- throw new Error("Stats are not being measured");
5444
- }
5445
- clearStatsBuffer();
5446
- cancelFrame(record);
5447
- const summary = {
5448
- frameloop: {
5449
- setup: summarise(value.frameloop.setup),
5450
- rate: summarise(value.frameloop.rate),
5451
- read: summarise(value.frameloop.read),
5452
- resolveKeyframes: summarise(value.frameloop.resolveKeyframes),
5453
- preUpdate: summarise(value.frameloop.preUpdate),
5454
- update: summarise(value.frameloop.update),
5455
- preRender: summarise(value.frameloop.preRender),
5456
- render: summarise(value.frameloop.render),
5457
- postRender: summarise(value.frameloop.postRender),
5458
- },
5459
- animations: {
5460
- mainThread: summarise(value.animations.mainThread),
5461
- waapi: summarise(value.animations.waapi),
5462
- layout: summarise(value.animations.layout),
5463
- },
5464
- layoutProjection: {
5465
- nodes: summarise(value.layoutProjection.nodes),
5466
- calculatedTargetDeltas: summarise(value.layoutProjection.calculatedTargetDeltas),
5467
- calculatedProjections: summarise(value.layoutProjection.calculatedProjections),
5468
- },
5469
- };
5470
- /**
5471
- * Convert the rate to FPS
5472
- */
5473
- const { rate } = summary.frameloop;
5474
- rate.min = msToFps(rate.min);
5475
- rate.max = msToFps(rate.max);
5476
- rate.avg = msToFps(rate.avg);
5477
- [rate.min, rate.max] = [rate.max, rate.min];
5478
- return summary;
5479
- }
5480
5392
  function recordStats() {
5481
5393
  if (statsBuffer.value) {
5482
5394
  clearStatsBuffer();
5483
5395
  throw new Error("Stats are already being measured");
5484
5396
  }
5485
- const newStatsBuffer = statsBuffer;
5486
- newStatsBuffer.value = {
5487
- frameloop: {
5488
- setup: [],
5489
- rate: [],
5490
- read: [],
5491
- resolveKeyframes: [],
5492
- preUpdate: [],
5493
- update: [],
5494
- preRender: [],
5495
- render: [],
5496
- postRender: [],
5497
- },
5498
- animations: {
5499
- mainThread: [],
5500
- waapi: [],
5501
- layout: [],
5502
- },
5397
+ const buffer = statsBuffer;
5398
+ buffer.value = {
5503
5399
  layoutProjection: {
5504
5400
  nodes: [],
5505
5401
  calculatedTargetDeltas: [],
5506
5402
  calculatedProjections: [],
5507
5403
  },
5508
5404
  };
5509
- newStatsBuffer.addProjectionMetrics = (metrics) => {
5510
- const { layoutProjection } = newStatsBuffer.value;
5405
+ buffer.addProjectionMetrics = (metrics) => {
5406
+ const { layoutProjection } = buffer.value;
5511
5407
  layoutProjection.nodes.push(metrics.nodes);
5512
5408
  layoutProjection.calculatedTargetDeltas.push(metrics.calculatedTargetDeltas);
5513
5409
  layoutProjection.calculatedProjections.push(metrics.calculatedProjections);
5514
5410
  };
5515
- frame.postRender(record, true);
5516
- return reportStats;
5517
5411
  }
5518
5412
 
5519
5413
  /**
@@ -5799,14 +5693,126 @@
5799
5693
  */
5800
5694
  const findValueType = (v) => valueTypes.find(testValueType(v));
5801
5695
 
5696
+ let nameCount = 0;
5697
+ /**
5698
+ * Generated names live in their own namespace so we can tell a name we own
5699
+ * (and must clean up) from an author-defined one - and so a stale generated
5700
+ * name left behind by an interrupted transition is re-owned, not mistaken for
5701
+ * the author's and leaked.
5702
+ */
5703
+ const generatedName = () => `motion-view-${nameCount++}`;
5704
+ const isGeneratedName = (name) => name.startsWith("motion-view-");
5705
+ /**
5706
+ * Tag a captured element with a `view-transition-class` so authors can target
5707
+ * its generated layer from CSS (e.g. `::view-transition-group(.hero)`) without
5708
+ * the opaque generated name. Tracked in `classed` - separate from the generated
5709
+ * names in `assigned` - so cleanup removes the class without ever stripping an
5710
+ * author's own inline `view-transition-name`.
5711
+ */
5712
+ function tagClass(element, className, classed) {
5713
+ if (!className)
5714
+ return;
5715
+ element.style?.setProperty("view-transition-class", className);
5716
+ classed.push(element);
5717
+ }
5718
+ /**
5719
+ * Resolve a selector/Element to elements and ensure each one carries a
5720
+ * `view-transition-name` we can target from script.
5721
+ *
5722
+ * Author-defined names are reused as-is. Elements that are unnamed (or use
5723
+ * the browser's `auto`/`match-element`, whose generated name is not exposed
5724
+ * to script) are given a unique generated name, set inline so it's captured,
5725
+ * and tracked in `assigned` for later cleanup.
5726
+ *
5727
+ * `registry` maps each Element to its name so the same element keeps its name
5728
+ * across both captures (before and after the update), which is what allows a
5729
+ * persistent element to animate as a single `group` layer.
5730
+ */
5731
+ function assignViewTransitionNames(definition, registry, assigned, forcedNames, className, classed = []) {
5732
+ const elements = resolveElements(definition);
5733
+ /**
5734
+ * The new end of a paired morph: give each element the matching name from
5735
+ * the old end (by index) so the two share one layer and morph. If the new
5736
+ * end resolves to *more* elements than the old end named, the extras have no
5737
+ * counterpart - give them a fresh name so they animate as newcomers rather
5738
+ * than being silently left unnamed. We return the names actually assigned
5739
+ * (sized to the resolved elements), not the raw `forcedNames`, so stagger
5740
+ * totals and the layer set stay in step with what's on the page.
5741
+ */
5742
+ if (forcedNames) {
5743
+ return elements.map((element, i) => {
5744
+ const existing = registry.get(element);
5745
+ if (existing)
5746
+ return existing;
5747
+ const name = forcedNames[i] ?? generatedName();
5748
+ element.style?.setProperty("view-transition-name", name);
5749
+ assigned.push(element);
5750
+ registry.set(element, name);
5751
+ tagClass(element, className, classed);
5752
+ return name;
5753
+ });
5754
+ }
5755
+ /**
5756
+ * Read every current name up front, before assigning any. Interleaving the
5757
+ * reads with the inline `setProperty` writes below would dirty styles
5758
+ * between reads and force a style recalc per element; batching the reads
5759
+ * keeps it to one. Elements already in the registry keep their name and
5760
+ * need no read.
5761
+ */
5762
+ const currentNames = elements.map((element) => registry.has(element)
5763
+ ? undefined
5764
+ : getComputedStyle(element).getPropertyValue("view-transition-name"));
5765
+ return elements.map((element, i) => {
5766
+ const existing = registry.get(element);
5767
+ if (existing)
5768
+ return existing;
5769
+ const current = currentNames[i];
5770
+ let name;
5771
+ if (current &&
5772
+ current !== "none" &&
5773
+ current !== "auto" &&
5774
+ current !== "match-element" &&
5775
+ !isGeneratedName(current)) {
5776
+ /**
5777
+ * The author already named this layer - target it as-is and leave
5778
+ * it to them to clean up. `auto`/`match-element` are overridden
5779
+ * because their generated name is not exposed to script, and a
5780
+ * stale `motion-view-*` (e.g. left by an interrupted transition) is
5781
+ * re-owned rather than adopted as an author name and leaked.
5782
+ */
5783
+ name = current;
5784
+ }
5785
+ else {
5786
+ name = generatedName();
5787
+ element.style?.setProperty("view-transition-name", name);
5788
+ assigned.push(element);
5789
+ }
5790
+ registry.set(element, name);
5791
+ tagClass(element, className, classed);
5792
+ return name;
5793
+ });
5794
+ }
5795
+ /**
5796
+ * Remove the `view-transition-name`s we generated and the
5797
+ * `view-transition-class`es we applied. Author-defined names are never touched
5798
+ * (they're not in `assigned`). Safe to call more than once (e.g. on both a
5799
+ * finished and an interrupted transition).
5800
+ */
5801
+ function releaseViewTransitionNames(assigned, classed = []) {
5802
+ for (const element of assigned) {
5803
+ element.style?.removeProperty("view-transition-name");
5804
+ }
5805
+ for (const element of classed) {
5806
+ element.style?.removeProperty("view-transition-class");
5807
+ }
5808
+ }
5809
+
5802
5810
  function chooseLayerType(valueName) {
5803
5811
  if (valueName === "layout")
5804
5812
  return "group";
5805
5813
  if (valueName === "enter" || valueName === "new")
5806
5814
  return "new";
5807
- if (valueName === "exit" || valueName === "old")
5808
- return "old";
5809
- return "group";
5815
+ return "old";
5810
5816
  }
5811
5817
 
5812
5818
  let pendingRules = {};
@@ -5841,21 +5847,21 @@
5841
5847
  };
5842
5848
 
5843
5849
  function getViewAnimationLayerInfo(pseudoElement) {
5844
- const match = pseudoElement.match(/::view-transition-(old|new|group|image-pair)\((.*?)\)/);
5850
+ const match = pseudoElement.match(
5851
+ // `group-children` (nested transitions) before `group` so it wins.
5852
+ /::view-transition-(old|new|group-children|group|image-pair)\((.*?)\)/);
5845
5853
  if (!match)
5846
5854
  return null;
5847
5855
  return { layer: match[2], type: match[1] };
5848
5856
  }
5849
5857
 
5850
- function filterViewAnimations(animation) {
5851
- const { effect } = animation;
5852
- if (!effect)
5853
- return false;
5854
- return (effect.target === document.documentElement &&
5855
- effect.pseudoElement?.startsWith("::view-transition"));
5856
- }
5857
5858
  function getViewAnimations() {
5858
- return document.getAnimations().filter(filterViewAnimations);
5859
+ return document.getAnimations().filter((animation) => {
5860
+ const { effect } = animation;
5861
+ return (!!effect &&
5862
+ effect.target === document.documentElement &&
5863
+ effect.pseudoElement?.startsWith("::view-transition"));
5864
+ });
5859
5865
  }
5860
5866
 
5861
5867
  function hasTarget(target, targets) {
@@ -5863,87 +5869,370 @@
5863
5869
  }
5864
5870
 
5865
5871
  const definitionNames = ["layout", "enter", "exit", "new", "old"];
5872
+ /**
5873
+ * The `ViewTransitionTarget` buckets driving each generated layer type, in
5874
+ * priority order - the inverse of `chooseLayerType`. The new view is driven by
5875
+ * `new`/`enter`, the old by `old`/`exit`. `group-children`/`image-pair` have no
5876
+ * bucket; they follow the default layout timing.
5877
+ */
5878
+ const typeBuckets = {
5879
+ group: ["layout"],
5880
+ new: ["new", "enter"],
5881
+ old: ["old", "exit"],
5882
+ };
5883
+ /**
5884
+ * Default "absent" origin for a single-value keyframe, by pseudo type, so e.g.
5885
+ * `enter({ scale: 1 })` grows in from 0.85 and `exit({ opacity: 0 })` fades
5886
+ * from 1. `enter` prefers the matching `exit` value over these (see below).
5887
+ */
5888
+ const ORIGIN_DEFAULTS = {
5889
+ new: { opacity: 0, scale: 0.85 },
5890
+ old: { opacity: 1, scale: 1 },
5891
+ };
5892
+ const cornerProps = [
5893
+ "borderTopLeftRadius",
5894
+ "borderTopRightRadius",
5895
+ "borderBottomRightRadius",
5896
+ "borderBottomLeftRadius",
5897
+ ];
5866
5898
  function startViewAnimation(builder) {
5867
- const { update, targets, options: defaultOptions } = builder;
5899
+ const { update, targets, resolveDefs, noCrop, pairs, classNames, options: defaultOptions, } = builder;
5868
5900
  if (!document.startViewTransition) {
5869
- return new Promise(async (resolve) => {
5901
+ // An async IIFE (not `new Promise(async )`) so a throwing/rejecting
5902
+ // update rejects this promise rather than leaving it unsettled.
5903
+ return (async () => {
5870
5904
  await update();
5871
- resolve(new GroupAnimation([]));
5872
- });
5905
+ return new GroupAnimation([]);
5906
+ })();
5873
5907
  }
5874
- // TODO: Go over existing targets and ensure they all have ids
5875
5908
  /**
5876
- * If we don't have any animations defined for the root target,
5877
- * remove it from being captured.
5909
+ * Resolve any selector/Element targets to layer names, assigning a
5910
+ * `view-transition-name` to each element as we go. We run this before the
5911
+ * update (so the elements are captured in the old snapshot) and again
5912
+ * after it (for the new snapshot). An element present in both keeps the
5913
+ * same name and animates as a single `group` layer.
5914
+ */
5915
+ const nameRegistry = new Map();
5916
+ const assigned = [];
5917
+ /**
5918
+ * Elements we tagged with a `view-transition-class` (via `.class()`),
5919
+ * tracked separately from `assigned` so cleanup removes the class without
5920
+ * ever stripping an author's own inline `view-transition-name`.
5921
+ */
5922
+ const classed = [];
5923
+ const layerTargets = new Map();
5924
+ const croppedNames = new Set();
5925
+ /**
5926
+ * Each layer's stagger position (index + total) within its subject, per
5927
+ * snapshot. Resolving against the snapshot the layer belongs to keeps
5928
+ * stagger correct when `update()` replaces the matched elements, and lets
5929
+ * us skip a layer that's absent from a snapshot (e.g. an exited element
5930
+ * has no `new` pseudo-element).
5878
5931
  */
5879
- if (!hasTarget("root", targets)) {
5880
- css.set(":root", {
5881
- "view-transition-name": "none",
5932
+ const layerStagger = new Map();
5933
+ /**
5934
+ * Names allocated for a paired subject in the old snapshot, replayed onto
5935
+ * its new-snapshot target so both ends share a layer and morph.
5936
+ */
5937
+ const pairNames = new Map();
5938
+ /**
5939
+ * The old (`from`) elements of each paired subject, so their names can be
5940
+ * transferred off before the new (`to`) elements inherit them.
5941
+ */
5942
+ const pairFrom = new Map();
5943
+ const resolveLayers = (phase) => {
5944
+ targets.forEach((target, definition) => {
5945
+ const className = classNames.get(definition);
5946
+ let names;
5947
+ if (definition === "root" || !resolveDefs.has(definition)) {
5948
+ names = [definition];
5949
+ }
5950
+ else if (pairs.has(definition)) {
5951
+ /**
5952
+ * Paired morph: name the old target in the old snapshot, then
5953
+ * force the same name(s) onto the new target in the new one, so
5954
+ * two different elements morph as a single layer.
5955
+ */
5956
+ if (phase === "old") {
5957
+ pairFrom.set(definition, resolveElements(definition));
5958
+ names = assignViewTransitionNames(definition, nameRegistry, assigned, undefined, className, classed);
5959
+ pairNames.set(definition, names);
5960
+ }
5961
+ else {
5962
+ /**
5963
+ * Transfer the name(s) off the `from` elements before the
5964
+ * `to` elements inherit them. A `from` that survives into
5965
+ * the new snapshot (e.g. hidden with `visibility: hidden`
5966
+ * rather than removed) would otherwise keep the name and
5967
+ * collide - "duplicate view-transition-name".
5968
+ */
5969
+ for (const el of pairFrom.get(definition) ?? []) {
5970
+ el.style?.removeProperty("view-transition-name");
5971
+ /**
5972
+ * Drop the old end from the registry too, so the new
5973
+ * end alone supplies this name's `new` crop radii - we
5974
+ * neither re-measure nor get ordered by a stale element.
5975
+ */
5976
+ nameRegistry.delete(el);
5977
+ }
5978
+ names = assignViewTransitionNames(pairs.get(definition), nameRegistry, assigned, pairNames.get(definition), className, classed);
5979
+ }
5980
+ }
5981
+ else {
5982
+ names = assignViewTransitionNames(definition, nameRegistry, assigned, undefined, className, classed);
5983
+ }
5984
+ const cropped = definition !== "root" && !noCrop.has(definition);
5985
+ names.forEach((name, index) => {
5986
+ /**
5987
+ * If two subjects resolve to the same element, merge their
5988
+ * definitions so neither subject's animations are dropped.
5989
+ */
5990
+ const existing = layerTargets.get(name);
5991
+ layerTargets.set(name, existing && existing !== target
5992
+ ? { ...existing, ...target }
5993
+ : target);
5994
+ if (cropped)
5995
+ croppedNames.add(name);
5996
+ const stagger = layerStagger.get(name) ?? {};
5997
+ stagger[phase] = [index, names.length];
5998
+ layerStagger.set(name, stagger);
5999
+ });
5882
6000
  });
5883
- }
6001
+ };
5884
6002
  /**
5885
- * Set the timing curve to linear for all view transition layers.
5886
- * This gets baked into the keyframes, which can't be changed
5887
- * without breaking the generated animation.
6003
+ * The stagger index/total for a layer, resolved against the snapshot it
6004
+ * belongs to. Returns index -1 when the layer is absent from that snapshot
6005
+ * so the caller can skip a pseudo-element that doesn't exist.
6006
+ */
6007
+ const staggerPosition = (name, type) => {
6008
+ const stagger = layerStagger.get(name);
6009
+ const position = type === "old"
6010
+ ? stagger?.old
6011
+ : type === "new"
6012
+ ? stagger?.new
6013
+ : // group / group-children / image-pair persist across both.
6014
+ stagger?.new ?? stagger?.old;
6015
+ return position ?? [-1, 1];
6016
+ };
6017
+ /**
6018
+ * Merge default + per-layer transition options for a generated layer and
6019
+ * resolve any stagger/delay function against this element's position. Used
6020
+ * by both the morph-retiming and crop corner-radius passes.
6021
+ */
6022
+ const resolveLayerTransition = (target, type, transitionName, index, total) => {
6023
+ const transition = mergeTransition(getValueTransition$1(defaultOptions, transitionName), getValueTransition$1((layerOptions(target, type) ?? {}), transitionName));
6024
+ if (typeof transition.delay === "function") {
6025
+ transition.delay = transition.delay(index, total);
6026
+ }
6027
+ return transition;
6028
+ };
6029
+ /**
6030
+ * Measured corner radii per cropped layer, so the clip can animate each
6031
+ * corner between the old and new elements. Per-corner (rather than the
6032
+ * shorthand) so mismatched/individual radii interpolate cleanly.
6033
+ */
6034
+ const cropRadii = new Map();
6035
+ const recordRadii = (style, name, phase) => {
6036
+ const corners = {};
6037
+ for (const corner of cornerProps)
6038
+ corners[corner] = style[corner];
6039
+ const entry = cropRadii.get(name) ?? {};
6040
+ entry[phase] = corners;
6041
+ cropRadii.set(name, entry);
6042
+ };
6043
+ /**
6044
+ * Cropped layers all come from `.add()`, so their elements are in the
6045
+ * registry - read each one's corner radii directly. For a paired morph both
6046
+ * ends share a name; the new-snapshot element is registered last, so it
6047
+ * wins the `new` reading (and the old end the `old` reading).
6048
+ */
6049
+ const measureCrop = (phase) => {
6050
+ if (!croppedNames.size)
6051
+ return;
6052
+ nameRegistry.forEach((name, element) => {
6053
+ if (croppedNames.has(name)) {
6054
+ recordRadii(getComputedStyle(element), name, phase);
6055
+ }
6056
+ });
6057
+ };
6058
+ /**
6059
+ * Write the persistent view-transition CSS: suppress root capture when the
6060
+ * root has no animations of its own; force linear timing (baked into the
6061
+ * keyframes, so we can retime later via updateTiming); and clip +
6062
+ * object-fit: cover every cropped morph (the UA default overflows on
6063
+ * aspect-ratio change), with an animated border-radius added below.
5888
6064
  *
5889
- * This allows us to set easing via updateTiming - which can be changed.
6065
+ * `css.commit` replaces rather than appends, so we re-set the full rule set
6066
+ * each call - the second call (in the update callback) then picks up cropped
6067
+ * layers that only exist in the new snapshot.
5890
6068
  */
5891
- css.set("::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*)", { "animation-timing-function": "linear !important" });
5892
- css.commit(); // Write
5893
- const transition = document.startViewTransition(async () => {
5894
- await update();
5895
- // TODO: Go over new targets and ensure they all have ids
5896
- });
5897
- transition.finished.finally(() => {
6069
+ const commitViewCSS = () => {
6070
+ if (!hasTarget("root", targets)) {
6071
+ css.set(":root", { "view-transition-name": "none" });
6072
+ }
6073
+ css.set("::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*)", { "animation-timing-function": "linear !important" });
6074
+ croppedNames.forEach((name) => {
6075
+ css.set(`::view-transition-group(${name})`, { overflow: "clip" });
6076
+ css.set(`::view-transition-old(${name}), ::view-transition-new(${name})`, { width: "100%", height: "100%", "object-fit": "cover" });
6077
+ });
6078
+ css.commit(); // Write
6079
+ };
6080
+ const cleanup = () => {
6081
+ releaseViewTransitionNames(assigned, classed);
5898
6082
  css.remove(); // Write
5899
- });
5900
- return new Promise((resolve) => {
5901
- transition.ready.then(() => {
6083
+ };
6084
+ const callback = async () => {
6085
+ await update();
6086
+ /**
6087
+ * Re-resolve so elements created by the update are named for the new
6088
+ * snapshot, then measure the cropped layers' new border-radius.
6089
+ */
6090
+ const croppedBefore = croppedNames.size;
6091
+ resolveLayers("new");
6092
+ measureCrop("new");
6093
+ /**
6094
+ * Re-commit the crop CSS only if the new snapshot introduced cropped
6095
+ * layers, so a layer that exists only in the new snapshot is clipped
6096
+ * too - without forcing a redundant style write on the common path.
6097
+ */
6098
+ if (croppedNames.size > croppedBefore)
6099
+ commitViewCSS();
6100
+ };
6101
+ let transition;
6102
+ try {
6103
+ resolveLayers("old");
6104
+ measureCrop("old");
6105
+ commitViewCSS();
6106
+ transition = document.startViewTransition(callback);
6107
+ }
6108
+ catch (error) {
6109
+ /**
6110
+ * The prelude writes inline names before the transition exists. If it
6111
+ * throws (e.g. startViewTransition rejects in a bad UA state), release
6112
+ * them so we neither leak DOM state nor stall the queue on a promise
6113
+ * that never settles - hand back a rejection it can advance past.
6114
+ */
6115
+ cleanup();
6116
+ return Promise.reject(error);
6117
+ }
6118
+ transition.finished.finally(cleanup);
6119
+ return new Promise((resolve, reject) => {
6120
+ transition.ready
6121
+ .then(() => {
5902
6122
  const generatedViewAnimations = getViewAnimations();
5903
6123
  const animations = [];
5904
6124
  /**
5905
6125
  * Create animations for each of our explicitly-defined subjects.
6126
+ * `opacityAnimated` additionally tracks which `${name}:${type}`
6127
+ * we faded, so we can keep the UA `plus-lighter` blend only for a
6128
+ * genuine opacity crossfade (both sides fading) and drop it for a
6129
+ * slide/transform, where additive compositing would flash bright.
5906
6130
  */
5907
- targets.forEach((definition, target) => {
5908
- // TODO: If target is not "root", resolve elements
5909
- // and iterate over each
6131
+ const explicitlyAnimated = new Set();
6132
+ const opacityAnimated = new Set();
6133
+ layerTargets.forEach((target, name) => {
6134
+ const stagger = layerStagger.get(name);
6135
+ /**
6136
+ * Presence: `enter` only fires for a pure newcomer (a new
6137
+ * view with no old), `exit` only for a pure leaver. A
6138
+ * survivor (both) gets neither - it just morphs.
6139
+ */
6140
+ const enterApplies = !!stagger?.new && !stagger?.old;
6141
+ const exitApplies = !!stagger?.old && !stagger?.new;
5910
6142
  for (const key of definitionNames) {
5911
- if (!definition[key])
6143
+ if (!target[key])
5912
6144
  continue;
5913
- const { keyframes, options } = definition[key];
6145
+ if (key === "enter" && !enterApplies)
6146
+ continue;
6147
+ if (key === "exit" && !exitApplies)
6148
+ continue;
6149
+ const type = chooseLayerType(key);
6150
+ const [index, total] = staggerPosition(name, type);
6151
+ // Skip a layer absent from its snapshot.
6152
+ if (index === -1)
6153
+ continue;
6154
+ const { keyframes, options } = target[key];
5914
6155
  for (let [valueName, valueKeyframes] of Object.entries(keyframes)) {
5915
- if (!valueKeyframes)
6156
+ // Skip only missing values - `0` (e.g. opacity: 0)
6157
+ // is valid and must reach the from-value inference.
6158
+ if (valueKeyframes == null)
5916
6159
  continue;
5917
- const valueOptions = {
5918
- ...getValueTransition$1(defaultOptions, valueName),
5919
- ...getValueTransition$1(options, valueName),
5920
- };
5921
- const type = chooseLayerType(key);
5922
6160
  /**
5923
- * If this is an opacity animation, and keyframes are not an array,
5924
- * we need to convert them into an array and set an initial value.
6161
+ * The view path hands keyframes straight to WAAPI,
6162
+ * so Motion's `x`/`y` shorthands (compiled to
6163
+ * `transform` only via the value pipeline) have no
6164
+ * effect. Warn and skip - use `transform`/`translate`.
6165
+ */
6166
+ if (valueName === "x" || valueName === "y") {
6167
+ warnOnce(false, `animateView() animates view-transition layers with CSS properties; the "${valueName}" shorthand has no effect - use transform, e.g. { transform: "translateX(40px)" }.`);
6168
+ continue;
6169
+ }
6170
+ /**
6171
+ * enter/exit win over new/old on a shared property -
6172
+ * skip it here when the gated bucket also defines it.
6173
+ */
6174
+ if (key === "new" &&
6175
+ enterApplies &&
6176
+ target.enter?.keyframes[valueName] != null) {
6177
+ continue;
6178
+ }
6179
+ if (key === "old" &&
6180
+ exitApplies &&
6181
+ target.exit?.keyframes[valueName] != null) {
6182
+ continue;
6183
+ }
6184
+ const valueOptions = mergeTransition(getValueTransition$1(defaultOptions, valueName), getValueTransition$1(options, valueName));
6185
+ /**
6186
+ * Infer an origin for a single-value keyframe. An
6187
+ * `enter` mirrors the matching `exit` value (a
6188
+ * defined exit reverses into the enter for free);
6189
+ * otherwise the per-type default (opacity 0/1, scale
6190
+ * 0.85). No default -> left as-is (animates from the
6191
+ * live value).
6192
+ *
6193
+ * `new`/`old` fire for survivors too, where only the
6194
+ * opacity crossfade default applies - a transform
6195
+ * default like scale 0.85 would pop a persisting
6196
+ * element, so gate it on the layer actually
6197
+ * entering/leaving.
5925
6198
  */
5926
- if (valueName === "opacity" &&
5927
- !Array.isArray(valueKeyframes)) {
5928
- const initialValue = type === "new" ? 0 : 1;
5929
- valueKeyframes = [initialValue, valueKeyframes];
6199
+ if (!Array.isArray(valueKeyframes)) {
6200
+ const exitValue = key === "enter"
6201
+ ? target.exit?.keyframes[valueName]
6202
+ : undefined;
6203
+ const allowDefault = valueName === "opacity" ||
6204
+ (type === "new" ? enterApplies : exitApplies);
6205
+ const from = exitValue != null
6206
+ ? Array.isArray(exitValue)
6207
+ ? exitValue[exitValue.length - 1]
6208
+ : exitValue
6209
+ : allowDefault
6210
+ ? ORIGIN_DEFAULTS[type]?.[valueName]
6211
+ : undefined;
6212
+ if (from !== undefined) {
6213
+ valueKeyframes = [from, valueKeyframes];
6214
+ }
5930
6215
  }
5931
6216
  /**
5932
- * Resolve stagger function if provided.
6217
+ * Resolve stagger function if provided, per element
6218
+ * across this subject's resolved layers.
5933
6219
  */
5934
6220
  if (typeof valueOptions.delay === "function") {
5935
- valueOptions.delay = valueOptions.delay(0, 1);
6221
+ valueOptions.delay = valueOptions.delay(index, total);
5936
6222
  }
5937
6223
  valueOptions.duration && (valueOptions.duration = secondsToMilliseconds(valueOptions.duration));
5938
6224
  valueOptions.delay && (valueOptions.delay = secondsToMilliseconds(valueOptions.delay));
5939
- const animation = new NativeAnimation({
6225
+ animations.push(new NativeAnimation({
5940
6226
  ...valueOptions,
5941
6227
  element: document.documentElement,
5942
6228
  name: valueName,
5943
- pseudoElement: `::view-transition-${type}(${target})`,
6229
+ pseudoElement: `::view-transition-${type}(${name})`,
5944
6230
  keyframes: valueKeyframes,
5945
- });
5946
- animations.push(animation);
6231
+ }));
6232
+ explicitlyAnimated.add(`${name}:${type}`);
6233
+ if (valueName === "opacity") {
6234
+ opacityAnimated.add(`${name}:${type}`);
6235
+ }
5947
6236
  }
5948
6237
  }
5949
6238
  });
@@ -5962,45 +6251,161 @@
5962
6251
  const name = getViewAnimationLayerInfo(pseudoElement);
5963
6252
  if (!name)
5964
6253
  continue;
5965
- const targetDefinition = targets.get(name.layer);
5966
- if (!targetDefinition) {
5967
- /**
5968
- * If transition name is group then update the timing of the animation
5969
- * whereas if it's old or new then we could possibly replace it using
5970
- * the above method.
5971
- */
5972
- const transitionName = name.type === "group" ? "layout" : "";
5973
- let animationTransition = {
5974
- ...getValueTransition$1(defaultOptions, transitionName),
5975
- };
5976
- animationTransition.duration && (animationTransition.duration = secondsToMilliseconds(animationTransition.duration));
5977
- animationTransition =
5978
- applyGeneratorOptions(animationTransition);
5979
- const easing = mapEasingToNativeEasing(animationTransition.ease, animationTransition.duration);
5980
- effect.updateTiming({
5981
- delay: secondsToMilliseconds(animationTransition.delay ?? 0),
5982
- duration: animationTransition.duration,
5983
- easing,
5984
- });
5985
- animations.push(new NativeAnimationWrapper(animation));
5986
- }
5987
- else if (hasOpacity(targetDefinition, "enter") &&
5988
- hasOpacity(targetDefinition, "exit") &&
5989
- effect
5990
- .getKeyframes()
5991
- .some((keyframe) => keyframe.mixBlendMode)) {
5992
- animations.push(new NativeAnimationWrapper(animation));
5993
- }
5994
- else {
5995
- animation.cancel();
6254
+ const targetDefinition = layerTargets.get(name.layer);
6255
+ /**
6256
+ * We built our own animation for this layer, so drop the
6257
+ * browser-generated fade we're replacing. The UA
6258
+ * `plus-lighter` blend is a *separate* generated animation on
6259
+ * the same pseudo (it sets `mix-blend-mode` in its keyframes):
6260
+ * keep it *only* for a true opacity crossfade - both sides
6261
+ * fading - so a symmetric crossfade composites without
6262
+ * darkening, but a slide/transform (where both layers stay
6263
+ * opaque and overlap) doesn't flash bright from the addition.
6264
+ */
6265
+ if (explicitlyAnimated.has(`${name.layer}:${name.type}`)) {
6266
+ const isCrossfade = opacityAnimated.has(`${name.layer}:new`) &&
6267
+ opacityAnimated.has(`${name.layer}:old`);
6268
+ if (isCrossfade &&
6269
+ effect
6270
+ .getKeyframes()
6271
+ .some((keyframe) => keyframe.mixBlendMode)) {
6272
+ animations.push(new NativeAnimationWrapper(animation));
6273
+ }
6274
+ else {
6275
+ animation.cancel();
6276
+ }
6277
+ continue;
5996
6278
  }
6279
+ /**
6280
+ * Otherwise retime the browser-generated animation to
6281
+ * Motion's timing. This auto-enables the layout (group)
6282
+ * morph for any resolved/named target, and applies the
6283
+ * default timing to old/new layers we haven't explicitly
6284
+ * overridden.
6285
+ *
6286
+ * group + group-children both follow the layout timing so
6287
+ * the nesting container stays in sync with the morph.
6288
+ */
6289
+ /**
6290
+ * A survivor's old + new are the two halves of one
6291
+ * `plus-lighter` crossfade. They must share identical timing
6292
+ * (so their opacities stay mirrored and sum to 1 - else the
6293
+ * additive blend flashes bright wherever both are partly
6294
+ * visible) and fade linearly (the bounce belongs on the
6295
+ * group's geometry, not the opacity). So time them as the
6296
+ * group, rather than via their own - potentially staggered,
6297
+ * or enter/exit-derived - old/new options.
6298
+ */
6299
+ const stagger = layerStagger.get(name.layer);
6300
+ const isMorphCrossfade = (name.type === "old" || name.type === "new") &&
6301
+ !!stagger?.old &&
6302
+ !!stagger?.new;
6303
+ const timingType = name.type.startsWith("group") || isMorphCrossfade
6304
+ ? "group"
6305
+ : name.type;
6306
+ const [index, total] = staggerPosition(name.layer, timingType);
6307
+ const transitionName = timingType === "group" ? "layout" : "";
6308
+ let animationTransition = resolveLayerTransition(targetDefinition, timingType, transitionName, index === -1 ? 0 : index, total);
6309
+ /**
6310
+ * The crossfade should resolve at the spring's *perceptual*
6311
+ * (visual) duration - the geometry can keep bouncing, but the
6312
+ * opacity shouldn't drag through the settle. So capture
6313
+ * `visualDuration` before `applyGeneratorOptions` replaces it
6314
+ * with the full overshoot duration, and use it for the fade.
6315
+ */
6316
+ const visualDuration = animationTransition.visualDuration;
6317
+ animationTransition.duration && (animationTransition.duration = secondsToMilliseconds(animationTransition.duration));
6318
+ animationTransition =
6319
+ applyGeneratorOptions(animationTransition);
6320
+ const duration = isMorphCrossfade && visualDuration !== undefined
6321
+ ? secondsToMilliseconds(visualDuration)
6322
+ : animationTransition.duration;
6323
+ const easing = isMorphCrossfade
6324
+ ? "linear"
6325
+ : mapEasingToNativeEasing(animationTransition.ease, animationTransition.duration);
6326
+ effect.updateTiming({
6327
+ delay: secondsToMilliseconds(animationTransition.delay ?? 0),
6328
+ duration,
6329
+ easing,
6330
+ });
6331
+ animations.push(new NativeAnimationWrapper(animation));
5997
6332
  }
6333
+ /**
6334
+ * Animate each cropped layer's clip corners between the old and
6335
+ * new elements, so a cropped morph keeps rounded corners
6336
+ * (handling individual per-corner radii).
6337
+ */
6338
+ cropRadii.forEach((radii, name) => {
6339
+ if (!radii.old && !radii.new)
6340
+ return;
6341
+ const target = layerTargets.get(name);
6342
+ const [index, total] = staggerPosition(name, "group");
6343
+ const radiusOptions = resolveLayerTransition(target, "group", "layout", index === -1 ? 0 : index, total);
6344
+ radiusOptions.duration && (radiusOptions.duration = secondsToMilliseconds(radiusOptions.duration));
6345
+ radiusOptions.delay && (radiusOptions.delay = secondsToMilliseconds(radiusOptions.delay));
6346
+ for (const corner of cornerProps) {
6347
+ // `||` (not `??`) so an empty measurement (e.g. an
6348
+ // un-rendered element) falls back rather than producing
6349
+ // an invalid keyframe.
6350
+ const from = radii.old?.[corner] || radii.new?.[corner] || "0px";
6351
+ const to = radii.new?.[corner] || radii.old?.[corner] || "0px";
6352
+ // Skip square corners - nothing to round.
6353
+ if (parseFloat(from) === 0 && parseFloat(to) === 0) {
6354
+ continue;
6355
+ }
6356
+ animations.push(new NativeAnimation({
6357
+ ...radiusOptions,
6358
+ element: document.documentElement,
6359
+ name: corner,
6360
+ pseudoElement: `::view-transition-group(${name})`,
6361
+ keyframes: [from, to],
6362
+ }));
6363
+ }
6364
+ });
5998
6365
  resolve(new GroupAnimation(animations));
5999
- });
6366
+ })
6367
+ .catch(() =>
6368
+ /**
6369
+ * `ready` rejects when the transition is skipped - no visual
6370
+ * change, or superseded by an interrupting transition. The DOM
6371
+ * update still applied, so settle with no animations rather than
6372
+ * surfacing it as an error to an awaiting caller. A genuine
6373
+ * failure in `update()` rejects `updateCallbackDone` (already
6374
+ * settled by now), so propagate that instead.
6375
+ */
6376
+ transition.updateCallbackDone.then(() => resolve(new GroupAnimation([])), reject));
6000
6377
  });
6001
6378
  }
6002
- function hasOpacity(target, key) {
6003
- return target?.[key]?.keyframes.opacity;
6379
+ /**
6380
+ * The options that should time a given generated layer type, so a retimed
6381
+ * group/old/new picks up any per-target transition the user provided. Checks
6382
+ * the type's buckets in priority order (e.g. `new` before `enter`).
6383
+ */
6384
+ function layerOptions(target, type) {
6385
+ for (const bucket of typeBuckets[type] ?? []) {
6386
+ const options = target?.[bucket]?.options;
6387
+ if (options)
6388
+ return options;
6389
+ }
6390
+ }
6391
+ /**
6392
+ * Merge a base transition (e.g. the default `options`) with a per-layer/value
6393
+ * override. An explicit `duration` on the override must win over an inherited
6394
+ * generator's own timing: a spring prefers `visualDuration`, and
6395
+ * `spring.applyToOptions` overwrites `duration` with the computed settle time -
6396
+ * so without this the override is silently discarded. Dropping the inherited
6397
+ * `type`/`visualDuration` makes the layer a plain tween of that duration, unless
6398
+ * it asked for its own generator `type`/`visualDuration`.
6399
+ */
6400
+ function mergeTransition(base, override) {
6401
+ const merged = { ...base, ...override };
6402
+ if (override.duration !== undefined) {
6403
+ if (override.visualDuration === undefined)
6404
+ delete merged.visualDuration;
6405
+ if (override.type === undefined)
6406
+ delete merged.type;
6407
+ }
6408
+ return merged;
6004
6409
  }
6005
6410
 
6006
6411
  let builders = [];
@@ -6014,10 +6419,16 @@
6014
6419
  function start(builder) {
6015
6420
  removeItem(builders, builder);
6016
6421
  current = builder;
6017
- startViewAnimation(builder).then((animation) => {
6422
+ startViewAnimation(builder)
6423
+ .then((animation) => {
6018
6424
  builder.notifyReady(animation);
6019
- animation.finished.finally(next);
6020
- });
6425
+ return animation.finished;
6426
+ })
6427
+ // A genuinely failed transition (a throwing update) rejects the
6428
+ // builder; a skipped/interrupted one resolves with no animations (see
6429
+ // start.ts). Either way, advance the queue - else later transitions hang.
6430
+ .catch((error) => builder.notifyReject(error))
6431
+ .finally(next);
6021
6432
  }
6022
6433
  function processQueue() {
6023
6434
  /**
@@ -6054,31 +6465,96 @@
6054
6465
  constructor(update, options = {}) {
6055
6466
  this.currentSubject = "root";
6056
6467
  this.targets = new Map();
6057
- this.notifyReady = noop$1;
6058
- this.readyPromise = new Promise((resolve) => {
6468
+ /**
6469
+ * Definitions that must be resolved to elements (and assigned a
6470
+ * `view-transition-name`) rather than treated as pre-named layers.
6471
+ */
6472
+ this.resolveDefs = new Set();
6473
+ /**
6474
+ * Subjects opted out of the default crop (clip + object-fit: cover +
6475
+ * animated corner radii) via `.crop(false)`.
6476
+ */
6477
+ this.noCrop = new Set();
6478
+ /**
6479
+ * Subjects paired with a different new-snapshot target (the second `.add()`
6480
+ * argument), so two distinct elements share one name and morph into each
6481
+ * other - a shared-element transition.
6482
+ */
6483
+ this.pairs = new Map();
6484
+ /**
6485
+ * A `view-transition-class` to apply to each subject's resolved elements,
6486
+ * so authors can target the generated layers from CSS by class rather than
6487
+ * the opaque generated name.
6488
+ */
6489
+ this.classNames = new Map();
6490
+ this.notifyReady = noop;
6491
+ this.notifyReject = noop;
6492
+ this.readyPromise = new Promise((resolve, reject) => {
6059
6493
  this.notifyReady = resolve;
6494
+ this.notifyReject = reject;
6060
6495
  });
6061
6496
  this.update = update;
6062
6497
  this.options = {
6063
6498
  interrupt: "wait",
6064
6499
  ...options,
6065
6500
  };
6501
+ // Avoid an unhandled rejection when a failed transition has no
6502
+ // `.then(_, reject)` handler attached (e.g. fire-and-forget).
6503
+ this.readyPromise.catch(noop);
6066
6504
  addToQueue(this);
6067
6505
  }
6068
- get(subject) {
6506
+ /**
6507
+ * Target elements resolved from a selector or Element, each assigned a
6508
+ * `view-transition-name` automatically.
6509
+ *
6510
+ * Passing a second target pairs them: the first is resolved in the old
6511
+ * snapshot and the second in the new, sharing one name so two *different*
6512
+ * elements morph into each other (e.g. `.add(card, ".modal")`). Symmetric -
6513
+ * pass them the other way round to morph back.
6514
+ */
6515
+ add(subject, newSubject) {
6069
6516
  this.currentSubject = subject;
6517
+ this.resolveDefs.add(subject);
6518
+ if (newSubject !== undefined)
6519
+ this.pairs.set(subject, newSubject);
6520
+ // Register the subject so it participates (and gets an automatic
6521
+ // layout/morph animation) even without an explicit enter/exit/layout.
6522
+ if (!this.targets.has(subject))
6523
+ this.targets.set(subject, {});
6070
6524
  return this;
6071
6525
  }
6072
- layout(keyframes, options) {
6073
- this.updateTarget("layout", keyframes, options);
6526
+ /**
6527
+ * Morphs are clipped + `object-fit: cover` (and their corners animate)
6528
+ * by default. Call `.crop(false)` to opt this subject out and fall back
6529
+ * to the browser default (which overflows on aspect-ratio change).
6530
+ */
6531
+ crop(enabled = true) {
6532
+ enabled
6533
+ ? this.noCrop.delete(this.currentSubject)
6534
+ : this.noCrop.add(this.currentSubject);
6074
6535
  return this;
6075
6536
  }
6076
- new(keyframes, options) {
6077
- this.updateTarget("new", keyframes, options);
6537
+ /**
6538
+ * Tag this subject's generated layers with a `view-transition-class`, so
6539
+ * they can be targeted from CSS - `::view-transition-group(.name)`,
6540
+ * `::view-transition-old/new(.name)`, `::view-transition-image-pair(.name)`
6541
+ * - without the opaque generated `view-transition-name`. Because `.add()`
6542
+ * can match many elements, a shared class targets them all at once (and,
6543
+ * for a pair, both ends). The escape hatch for z-index / custom keyframes
6544
+ * on a morph layer.
6545
+ */
6546
+ class(name) {
6547
+ this.classNames.set(this.currentSubject, name);
6078
6548
  return this;
6079
6549
  }
6080
- old(keyframes, options) {
6081
- this.updateTarget("old", keyframes, options);
6550
+ /**
6551
+ * Set the transition for this subject's morph. The morph is enabled
6552
+ * automatically by `.add()`; this just customises its timing (duration,
6553
+ * easing, a `delay`/`stagger`, …). On the implicit `root` subject it also
6554
+ * opts the page into the transition (the root crossfade).
6555
+ */
6556
+ layout(options = {}) {
6557
+ this.updateTarget("layout", {}, options);
6082
6558
  return this;
6083
6559
  }
6084
6560
  enter(keyframes, options) {
@@ -6089,9 +6565,21 @@
6089
6565
  this.updateTarget("exit", keyframes, options);
6090
6566
  return this;
6091
6567
  }
6092
- crossfade(options) {
6093
- this.updateTarget("enter", { opacity: 1 }, options);
6094
- this.updateTarget("exit", { opacity: 0 }, options);
6568
+ /**
6569
+ * Animate the new view directly, whether the element is appearing or
6570
+ * persisting (unlike `.enter()`, which only fires for a pure newcomer).
6571
+ * Pair with `.old()` for a crossfade or slide-through.
6572
+ */
6573
+ new(keyframes, options) {
6574
+ this.updateTarget("new", keyframes, options);
6575
+ return this;
6576
+ }
6577
+ /**
6578
+ * Animate the old view directly, whether the element is leaving or
6579
+ * persisting (unlike `.exit()`, which only fires for a pure leaver).
6580
+ */
6581
+ old(keyframes, options) {
6582
+ this.updateTarget("old", keyframes, options);
6095
6583
  return this;
6096
6584
  }
6097
6585
  updateTarget(target, keyframes, options = {}) {
@@ -6106,8 +6594,8 @@
6106
6594
  return this.readyPromise.then(resolve, reject);
6107
6595
  }
6108
6596
  }
6109
- function animateView(update, defaultOptions = {}) {
6110
- return new ViewTransitionBuilder(update, defaultOptions);
6597
+ function animateView(update, options = {}) {
6598
+ return new ViewTransitionBuilder(update, options);
6111
6599
  }
6112
6600
 
6113
6601
  const createAxisDelta = () => ({
@@ -8207,7 +8695,7 @@
8207
8695
  : values.borderRadius;
8208
8696
  }
8209
8697
  const easeCrossfadeIn = /*@__PURE__*/ compress(0, 0.5, circOut);
8210
- const easeCrossfadeOut = /*@__PURE__*/ compress(0.5, 0.95, noop$1);
8698
+ const easeCrossfadeOut = /*@__PURE__*/ compress(0.5, 0.95, noop);
8211
8699
  function compress(min, max, easing) {
8212
8700
  return (p) => {
8213
8701
  // Could replace ifs with clamp
@@ -8227,7 +8715,7 @@
8227
8715
 
8228
8716
  function addDomEvent(target, eventName, handler, options = { passive: true }) {
8229
8717
  target.addEventListener(eventName, handler, options);
8230
- return () => target.removeEventListener(eventName, handler);
8718
+ return () => target.removeEventListener(eventName, handler, options);
8231
8719
  }
8232
8720
 
8233
8721
  const compareByDepth = (a, b) => a.depth - b.depth;
@@ -9497,7 +9985,6 @@
9497
9985
  */
9498
9986
  this.pendingAnimation = frame.update(() => {
9499
9987
  globalProjectionState.hasAnimatedSinceResize = true;
9500
- activeAnimations.layout++;
9501
9988
  this.motionValue || (this.motionValue = motionValue(0));
9502
9989
  this.motionValue.jump(0, false);
9503
9990
  this.currentAnimation = animateSingleValue(this.motionValue, [0, 1000], {
@@ -9508,11 +9995,7 @@
9508
9995
  this.mixTargetDelta(latest);
9509
9996
  options.onUpdate && options.onUpdate(latest);
9510
9997
  },
9511
- onStop: () => {
9512
- activeAnimations.layout--;
9513
- },
9514
9998
  onComplete: () => {
9515
- activeAnimations.layout--;
9516
9999
  options.onComplete && options.onComplete();
9517
10000
  this.completeAnimation();
9518
10001
  },
@@ -10027,7 +10510,7 @@
10027
10510
  */
10028
10511
  const roundPoint = userAgentContains("applewebkit/") && !userAgentContains("chrome/")
10029
10512
  ? Math.round
10030
- : noop$1;
10513
+ : noop;
10031
10514
  function roundAxis(axis) {
10032
10515
  // Round to the nearest .5 pixels to support subpixel layouts
10033
10516
  axis.min = roundPoint(axis.min);
@@ -10101,39 +10584,221 @@
10101
10584
  checkIsScrollRoot: (instance) => Boolean(window.getComputedStyle(instance).position === "fixed"),
10102
10585
  });
10103
10586
 
10104
- const layoutSelector = "[data-layout], [data-layout-id]";
10105
- const noop = () => { };
10106
- function snapshotFromTarget(projection) {
10107
- const target = projection.targetWithTransforms || projection.target;
10108
- if (!target)
10109
- return undefined;
10110
- const measuredBox = createBox();
10111
- const layoutBox = createBox();
10112
- copyBoxInto(measuredBox, target);
10113
- copyBoxInto(layoutBox, target);
10587
+ const layoutSelector = "[data-layout],[data-layout-id]";
10588
+ /**
10589
+ * All imperatively-created projection nodes live in one persistent tree,
10590
+ * shared across animateLayout() calls (and with any React-created nodes,
10591
+ * via the singleton document root). Keyed by element for reuse.
10592
+ */
10593
+ const layoutNodes = new WeakMap();
10594
+ /**
10595
+ * Builders created within the same synchronous tick are flushed together
10596
+ * as a single "commit": every node is snapshotted before any updateDom
10597
+ * runs, mirroring React batching renders from different parts of the tree.
10598
+ */
10599
+ let pendingBuilders;
10600
+ function collectLayoutElements(scope) {
10601
+ const elements = [];
10602
+ if (scope instanceof HTMLElement && scope.matches(layoutSelector)) {
10603
+ elements.push(scope);
10604
+ }
10605
+ scope.querySelectorAll(layoutSelector).forEach((element) => {
10606
+ if (element instanceof HTMLElement)
10607
+ elements.push(element);
10608
+ });
10609
+ return elements;
10610
+ }
10611
+ /**
10612
+ * Process any work scheduled on the frameloop now. A previous animation
10613
+ * may have been seeked while paused (controls.time = x) without a frame
10614
+ * having rendered it - we must materialise that state into the DOM
10615
+ * before taking snapshots.
10616
+ */
10617
+ function flushPendingFrame() {
10618
+ if (frameData.isProcessing)
10619
+ return;
10620
+ const now = time.now();
10621
+ frameData.delta = clamp(0, 1000 / 60, now - frameData.timestamp);
10622
+ frameData.timestamp = now;
10623
+ frameData.isProcessing = true;
10624
+ frameSteps.update.process(frameData);
10625
+ frameSteps.preRender.process(frameData);
10626
+ frameSteps.render.process(frameData);
10627
+ frameData.isProcessing = false;
10628
+ }
10629
+ function getProjectionParent(element) {
10630
+ let ancestor = element.parentElement;
10631
+ while (ancestor) {
10632
+ const node = layoutNodes.get(ancestor);
10633
+ if (node && node.instance)
10634
+ return node;
10635
+ ancestor = ancestor.parentElement;
10636
+ }
10637
+ return undefined;
10638
+ }
10639
+ function createVisualElement() {
10640
+ return new HTMLVisualElement({
10641
+ props: {},
10642
+ presenceContext: null,
10643
+ visualState: {
10644
+ latestValues: {},
10645
+ renderState: {
10646
+ transform: {},
10647
+ transformOrigin: {},
10648
+ style: {},
10649
+ vars: {},
10650
+ },
10651
+ },
10652
+ }, { allowProjection: true });
10653
+ }
10654
+ function readNodeOptions(element, transition) {
10655
+ const layoutAttr = element.getAttribute("data-layout");
10656
+ const layoutId = element.getAttribute("data-layout-id") ?? undefined;
10114
10657
  return {
10115
- animationId: projection.root?.animationId ?? 0,
10116
- measuredBox,
10117
- layoutBox,
10118
- latestValues: projection.animationValues || projection.latestValues || {},
10119
- source: projection.id,
10658
+ layoutId,
10659
+ layout: layoutAttr !== null ? true : undefined,
10660
+ animationType: (!layoutAttr || layoutAttr === "true"
10661
+ ? "both"
10662
+ : layoutAttr),
10663
+ transition,
10664
+ };
10665
+ }
10666
+ function prepareNode(element, transition) {
10667
+ let node = layoutNodes.get(element);
10668
+ if (!node) {
10669
+ let visualElement = visualElementStore.get(element);
10670
+ if (!visualElement)
10671
+ visualElement = createVisualElement();
10672
+ /**
10673
+ * A first-time element may carry a projection transform in its
10674
+ * inline style (e.g. it was cloned from an element mid-animation).
10675
+ * That transform isn't tracked in latestValues so the engine can't
10676
+ * reset it before measuring - clear it now so the first layout
10677
+ * measurement isn't inflated.
10678
+ */
10679
+ if (element.style.transform &&
10680
+ !hasTransform(visualElement.latestValues)) {
10681
+ element.style.transform = "";
10682
+ }
10683
+ node = new HTMLProjectionNode(visualElement.latestValues, getProjectionParent(element));
10684
+ visualElement.projection = node;
10685
+ node.setOptions({
10686
+ ...readNodeOptions(element, transition),
10687
+ visualElement,
10688
+ });
10689
+ node.mount(element);
10690
+ layoutNodes.set(element, node);
10691
+ }
10692
+ else {
10693
+ node.setOptions(readNodeOptions(element, transition));
10694
+ }
10695
+ node.isPresent = true;
10696
+ if (node.options.onExitComplete) {
10697
+ node.setOptions({ onExitComplete: undefined });
10698
+ }
10699
+ return node;
10700
+ }
10701
+ function sortDocumentOrder(elements) {
10702
+ return [...elements].sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
10703
+ }
10704
+ function dropNode(element, node) {
10705
+ node.setOptions({ onExitComplete: undefined });
10706
+ /**
10707
+ * Stop any lingering animation so it can't leak into future updates.
10708
+ * A follow node can share its currentAnimation with a surviving lead
10709
+ * (via resumingFrom), in which case it isn't ours to stop.
10710
+ */
10711
+ const stack = node.getStack();
10712
+ if (!stack || node.isLead())
10713
+ node.currentAnimation?.stop();
10714
+ node.unmount();
10715
+ layoutNodes.delete(element);
10716
+ }
10717
+ function flushPendingBuilders() {
10718
+ const builders = pendingBuilders;
10719
+ pendingBuilders = undefined;
10720
+ flushPendingFrame();
10721
+ /**
10722
+ * Discover and mount every node across all builders before snapshotting
10723
+ * any of them. Mounting during an active update flags isLayoutDirty,
10724
+ * which would make that node's own willUpdate skip its snapshot.
10725
+ * Document order guarantees ancestors mount before descendants, even
10726
+ * when they're discovered by different builders.
10727
+ */
10728
+ const targets = new Map();
10729
+ for (const builder of builders) {
10730
+ for (const element of builder.collectTargets()) {
10731
+ const owners = targets.get(element);
10732
+ owners ? owners.push(builder) : targets.set(element, [builder]);
10733
+ }
10734
+ }
10735
+ const union = new Map();
10736
+ for (const element of sortDocumentOrder(targets.keys())) {
10737
+ const owners = targets.get(element);
10738
+ const node = prepareNode(element, owners[owners.length - 1].transitionFor(element));
10739
+ for (const owner of owners)
10740
+ owner.adopt(element, node);
10741
+ union.set(element, node);
10742
+ }
10743
+ union.forEach((node) => {
10744
+ node.isLayoutDirty = false;
10745
+ node.willUpdate();
10746
+ });
10747
+ const updatePromises = [];
10748
+ for (const builder of builders) {
10749
+ const result = builder.runUpdate();
10750
+ if (result)
10751
+ updatePromises.push(result);
10752
+ }
10753
+ const commit = () => {
10754
+ /**
10755
+ * Process all additions before any removals so that, even across
10756
+ * builders, a removed member knows whether a replacement with the
10757
+ * same layoutId was added in this commit.
10758
+ */
10759
+ const newMemberIds = new Set();
10760
+ for (const builder of builders) {
10761
+ builder.reconcileAdditions(newMemberIds);
10762
+ }
10763
+ for (const builder of builders) {
10764
+ builder.reconcileRemovals(newMemberIds);
10765
+ }
10766
+ let root;
10767
+ union.forEach((node) => (root || (root = node.root)));
10768
+ for (const builder of builders)
10769
+ root || (root = builder.getRoot());
10770
+ root?.didUpdate();
10771
+ /**
10772
+ * The root flushes the update on a microtask, synchronously
10773
+ * processing the frame that creates the layout animations. Collect
10774
+ * them in a later microtask step of the same pass.
10775
+ */
10776
+ microtask.render(() => {
10777
+ for (const builder of builders)
10778
+ builder.finalize();
10779
+ });
10120
10780
  };
10781
+ updatePromises.length ? Promise.all(updatePromises).then(commit) : commit();
10121
10782
  }
10122
10783
  class LayoutAnimationBuilder {
10123
10784
  constructor(scope, updateDom, defaultOptions) {
10124
- this.sharedTransitions = new Map();
10125
- this.notifyReady = noop;
10126
- this.rejectReady = noop;
10127
10785
  this.scope = scope;
10128
10786
  this.updateDom = updateDom;
10129
10787
  this.defaultOptions = defaultOptions;
10788
+ this.sharedTransitions = new Map();
10789
+ this.notifyReady = () => { };
10790
+ this.rejectReady = () => { };
10791
+ this.tracked = new Map();
10792
+ this.restorePoints = new Map();
10130
10793
  this.readyPromise = new Promise((resolve, reject) => {
10131
10794
  this.notifyReady = resolve;
10132
10795
  this.rejectReady = reject;
10133
10796
  });
10134
- frame.postRender(() => {
10135
- this.start().then(this.notifyReady).catch(this.rejectReady);
10136
- });
10797
+ if (!pendingBuilders) {
10798
+ pendingBuilders = [];
10799
+ queueMicrotask(flushPendingBuilders);
10800
+ }
10801
+ pendingBuilders.push(this);
10137
10802
  }
10138
10803
  shared(id, transition) {
10139
10804
  this.sharedTransitions.set(id, transition);
@@ -10142,114 +10807,107 @@
10142
10807
  then(resolve, reject) {
10143
10808
  return this.readyPromise.then(resolve, reject);
10144
10809
  }
10145
- async start() {
10146
- const beforeElements = collectLayoutElements(this.scope);
10147
- const beforeRecords = this.buildRecords(beforeElements);
10148
- beforeRecords.forEach(({ projection }) => {
10149
- const hasCurrentAnimation = Boolean(projection.currentAnimation);
10150
- const isSharedLayout = Boolean(projection.options.layoutId);
10151
- if (hasCurrentAnimation && isSharedLayout) {
10152
- const snapshot = snapshotFromTarget(projection);
10153
- if (snapshot) {
10154
- projection.snapshot = snapshot;
10155
- }
10156
- else if (projection.snapshot) {
10157
- projection.snapshot = undefined;
10158
- }
10159
- }
10160
- else if (projection.snapshot &&
10161
- (projection.currentAnimation || projection.isProjecting())) {
10162
- projection.snapshot = undefined;
10163
- }
10164
- projection.isPresent = true;
10165
- projection.willUpdate();
10810
+ transitionFor(element) {
10811
+ const layoutId = element.getAttribute("data-layout-id");
10812
+ return ((layoutId && this.sharedTransitions.get(layoutId)) ||
10813
+ this.defaultOptions);
10814
+ }
10815
+ adopt(element, node) {
10816
+ this.tracked.set(element, node);
10817
+ this.restorePoints.set(element, {
10818
+ parent: element.parentElement,
10819
+ next: element.nextSibling,
10166
10820
  });
10167
- await this.updateDom();
10168
- const afterElements = collectLayoutElements(this.scope);
10169
- const afterRecords = this.buildRecords(afterElements);
10170
- this.handleExitingElements(beforeRecords, afterRecords);
10171
- afterRecords.forEach(({ projection }) => {
10172
- const instance = projection.instance;
10173
- const resumeFromInstance = projection.resumeFrom
10174
- ?.instance;
10175
- if (!instance || !resumeFromInstance)
10176
- return;
10177
- if (!("style" in instance))
10178
- return;
10179
- const currentTransform = instance.style.transform;
10180
- const resumeFromTransform = resumeFromInstance.style.transform;
10181
- if (currentTransform &&
10182
- resumeFromTransform &&
10183
- currentTransform === resumeFromTransform) {
10184
- instance.style.transform = "";
10185
- instance.style.transformOrigin = "";
10821
+ }
10822
+ collectTargets() {
10823
+ return collectLayoutElements(this.scope);
10824
+ }
10825
+ runUpdate() {
10826
+ try {
10827
+ const result = this.updateDom();
10828
+ if (result && typeof result.then === "function") {
10829
+ return result.then(undefined, (error) => {
10830
+ this.updateError = error;
10831
+ });
10186
10832
  }
10187
- });
10188
- afterRecords.forEach(({ projection }) => {
10189
- projection.isPresent = true;
10190
- });
10191
- const root = getProjectionRoot(afterRecords, beforeRecords);
10192
- root?.didUpdate();
10193
- await new Promise((resolve) => {
10194
- frame.postRender(() => resolve());
10195
- });
10196
- const animations = collectAnimations(afterRecords);
10197
- const animation = new GroupAnimation(animations);
10198
- return animation;
10833
+ }
10834
+ catch (error) {
10835
+ this.updateError = error;
10836
+ }
10837
+ return undefined;
10199
10838
  }
10200
- buildRecords(elements) {
10201
- const records = [];
10202
- const recordMap = new Map();
10203
- for (const element of elements) {
10204
- const parentRecord = findParentRecord(element, recordMap, this.scope);
10205
- const { layout, layoutId } = readLayoutAttributes(element);
10206
- const override = layoutId
10207
- ? this.sharedTransitions.get(layoutId)
10208
- : undefined;
10209
- const transition = override || this.defaultOptions;
10210
- const record = getOrCreateRecord(element, parentRecord?.projection, {
10211
- layout,
10212
- layoutId,
10213
- animationType: typeof layout === "string" ? layout : "both",
10214
- transition: transition,
10215
- });
10216
- recordMap.set(element, record);
10217
- records.push(record);
10839
+ reconcileAdditions(newMemberIds) {
10840
+ for (const element of collectLayoutElements(this.scope)) {
10841
+ if (this.tracked.has(element))
10842
+ continue;
10843
+ const node = prepareNode(element, this.transitionFor(element));
10844
+ this.adopt(element, node);
10845
+ node.options.layoutId && newMemberIds.add(node.options.layoutId);
10218
10846
  }
10219
- return records;
10220
10847
  }
10221
- handleExitingElements(beforeRecords, afterRecords) {
10222
- const afterElementsSet = new Set(afterRecords.map((record) => record.element));
10223
- beforeRecords.forEach((record) => {
10224
- if (afterElementsSet.has(record.element))
10848
+ reconcileRemovals(newMemberIds) {
10849
+ this.tracked.forEach((node, element) => {
10850
+ if (element.isConnected)
10225
10851
  return;
10226
- // For shared layout elements, relegate to set up resumeFrom
10227
- // so the remaining element animates from this position
10228
- if (record.projection.options.layoutId) {
10229
- record.projection.isPresent = false;
10230
- record.projection.relegate();
10852
+ const restore = this.restorePoints.get(element);
10853
+ this.restorePoints.delete(element);
10854
+ const { layoutId } = node.options;
10855
+ const stack = node.getStack();
10856
+ const hasSurvivor = stack &&
10857
+ stack.members.some((member) => member !== node &&
10858
+ member.instance
10859
+ ?.isConnected);
10860
+ /**
10861
+ * A removed lead with a surviving stack member - and no
10862
+ * replacement member added this commit - runs an exit
10863
+ * crossfade: restore the element to its old position in the
10864
+ * DOM, relegate it and let the survivor take over. It's
10865
+ * removed again once the animation completes.
10866
+ */
10867
+ if (layoutId &&
10868
+ node.isLead() &&
10869
+ hasSurvivor &&
10870
+ !newMemberIds.has(layoutId)) {
10871
+ if (restore && restore.parent.isConnected) {
10872
+ restore.parent.insertBefore(element, restore.next && restore.next.parentNode === restore.parent
10873
+ ? restore.next
10874
+ : null);
10875
+ node.isPresent = false;
10876
+ node.setOptions({
10877
+ onExitComplete: () => {
10878
+ element.remove();
10879
+ dropNode(element, node);
10880
+ },
10881
+ });
10882
+ if (node.relegate())
10883
+ return;
10884
+ element.remove();
10885
+ }
10231
10886
  }
10232
- record.visualElement.unmount();
10233
- visualElementStore.delete(record.element);
10887
+ dropNode(element, node);
10888
+ this.tracked.delete(element);
10234
10889
  });
10235
- // Clear resumeFrom on EXISTING nodes that point to unmounted projections
10236
- // This prevents crossfade animation when the source element was removed entirely
10237
- // But preserve resumeFrom for NEW nodes so they can animate from the old position
10238
- // Also preserve resumeFrom for lead nodes that were just promoted via relegate
10239
- const beforeElementsSet = new Set(beforeRecords.map((record) => record.element));
10240
- afterRecords.forEach(({ element, projection }) => {
10241
- if (beforeElementsSet.has(element) &&
10242
- projection.resumeFrom &&
10243
- !projection.resumeFrom.instance &&
10244
- !projection.isLead()) {
10245
- projection.resumeFrom = undefined;
10246
- projection.snapshot = undefined;
10890
+ }
10891
+ getRoot() {
10892
+ let root;
10893
+ this.tracked.forEach((node) => (root || (root = node.root)));
10894
+ return root;
10895
+ }
10896
+ finalize() {
10897
+ if (this.updateError) {
10898
+ this.rejectReady(this.updateError);
10899
+ return;
10900
+ }
10901
+ const animations = new Set();
10902
+ this.tracked.forEach((node) => {
10903
+ if (node.instance && node.currentAnimation) {
10904
+ animations.add(node.currentAnimation);
10247
10905
  }
10248
10906
  });
10907
+ this.notifyReady(new GroupAnimation([...animations]));
10249
10908
  }
10250
10909
  }
10251
10910
  function parseAnimateLayoutArgs(scopeOrUpdateDom, updateDomOrOptions, options) {
10252
- // animateLayout(updateDom)
10253
10911
  if (typeof scopeOrUpdateDom === "function") {
10254
10912
  return {
10255
10913
  scope: document,
@@ -10257,107 +10915,15 @@
10257
10915
  defaultOptions: updateDomOrOptions,
10258
10916
  };
10259
10917
  }
10260
- // animateLayout(scope, updateDom, options?)
10261
- const elements = resolveElements(scopeOrUpdateDom);
10262
- const scope = elements[0] || document;
10918
+ const scope = scopeOrUpdateDom instanceof Document
10919
+ ? scopeOrUpdateDom
10920
+ : resolveElements(scopeOrUpdateDom)[0] ?? document;
10263
10921
  return {
10264
10922
  scope,
10265
10923
  updateDom: updateDomOrOptions,
10266
10924
  defaultOptions: options,
10267
10925
  };
10268
10926
  }
10269
- function collectLayoutElements(scope) {
10270
- const elements = Array.from(scope.querySelectorAll(layoutSelector));
10271
- if (scope instanceof Element && scope.matches(layoutSelector)) {
10272
- if (!elements.includes(scope)) {
10273
- elements.unshift(scope);
10274
- }
10275
- }
10276
- return elements;
10277
- }
10278
- function readLayoutAttributes(element) {
10279
- const layoutId = element.getAttribute("data-layout-id") || undefined;
10280
- const rawLayout = element.getAttribute("data-layout");
10281
- let layout;
10282
- if (rawLayout === "" || rawLayout === "true") {
10283
- layout = true;
10284
- }
10285
- else if (rawLayout) {
10286
- layout = rawLayout;
10287
- }
10288
- return {
10289
- layout,
10290
- layoutId,
10291
- };
10292
- }
10293
- function createVisualState() {
10294
- return {
10295
- latestValues: {},
10296
- renderState: {
10297
- transform: {},
10298
- transformOrigin: {},
10299
- style: {},
10300
- vars: {},
10301
- },
10302
- };
10303
- }
10304
- function getOrCreateRecord(element, parentProjection, projectionOptions) {
10305
- const existing = visualElementStore.get(element);
10306
- const visualElement = existing ??
10307
- new HTMLVisualElement({
10308
- props: {},
10309
- presenceContext: null,
10310
- visualState: createVisualState(),
10311
- }, { allowProjection: true });
10312
- if (!existing || !visualElement.projection) {
10313
- visualElement.projection = new HTMLProjectionNode(visualElement.latestValues, parentProjection);
10314
- }
10315
- visualElement.projection.setOptions({
10316
- ...projectionOptions,
10317
- visualElement,
10318
- });
10319
- if (!visualElement.current) {
10320
- visualElement.mount(element);
10321
- }
10322
- else if (!visualElement.projection.instance) {
10323
- // Mount projection if VisualElement is already mounted but projection isn't
10324
- // This happens when animate() was called before animateLayout()
10325
- visualElement.projection.mount(element);
10326
- }
10327
- if (!existing) {
10328
- visualElementStore.set(element, visualElement);
10329
- }
10330
- return {
10331
- element,
10332
- visualElement,
10333
- projection: visualElement.projection,
10334
- };
10335
- }
10336
- function findParentRecord(element, recordMap, scope) {
10337
- let parent = element.parentElement;
10338
- while (parent) {
10339
- const record = recordMap.get(parent);
10340
- if (record)
10341
- return record;
10342
- if (parent === scope)
10343
- break;
10344
- parent = parent.parentElement;
10345
- }
10346
- return undefined;
10347
- }
10348
- function getProjectionRoot(afterRecords, beforeRecords) {
10349
- const record = afterRecords[0] || beforeRecords[0];
10350
- return record?.projection.root;
10351
- }
10352
- function collectAnimations(afterRecords) {
10353
- const animations = new Set();
10354
- afterRecords.forEach((record) => {
10355
- const animation = record.projection.currentAnimation;
10356
- if (animation)
10357
- animations.add(animation);
10358
- });
10359
- return Array.from(animations);
10360
- }
10361
10927
 
10362
10928
  /**
10363
10929
  * @deprecated
@@ -10546,6 +11112,14 @@
10546
11112
  const PresenceChild = ({ children, initial, isPresent, onExitComplete, custom, presenceAffectsLayout, mode, anchorX, anchorY, root }) => {
10547
11113
  const presenceChildren = useConstant(newChildrenMap);
10548
11114
  const id = React$1.useId();
11115
+ // Written in a layout effect (not render) so discarded concurrent
11116
+ // renders can't leave the refs pointing at uncommitted state.
11117
+ const isPresentRef = React$1.useRef(isPresent);
11118
+ const onExitCompleteRef = React$1.useRef(onExitComplete);
11119
+ useIsomorphicLayoutEffect(() => {
11120
+ isPresentRef.current = isPresent;
11121
+ onExitCompleteRef.current = onExitComplete;
11122
+ });
10549
11123
  let isReusedContext = true;
10550
11124
  let context = React$1.useMemo(() => {
10551
11125
  isReusedContext = false;
@@ -10564,7 +11138,12 @@
10564
11138
  },
10565
11139
  register: (childId) => {
10566
11140
  presenceChildren.set(childId, false);
10567
- return () => presenceChildren.delete(childId);
11141
+ return () => {
11142
+ presenceChildren.delete(childId);
11143
+ !isPresentRef.current &&
11144
+ !presenceChildren.size &&
11145
+ onExitCompleteRef.current?.();
11146
+ };
10568
11147
  },
10569
11148
  };
10570
11149
  }, [isPresent, presenceChildren, onExitComplete]);
@@ -12041,7 +12620,11 @@
12041
12620
  const { onSessionStart } = handlers;
12042
12621
  onSessionStart &&
12043
12622
  onSessionStart(event, getPanInfo(initialInfo, this.history));
12044
- this.removeListeners = pipe(addPointerEvent(this.contextWindow, "pointermove", this.handlePointerMove), addPointerEvent(this.contextWindow, "pointerup", this.handlePointerUp), addPointerEvent(this.contextWindow, "pointercancel", this.handlePointerUp));
12623
+ // Listen in the capture phase so a descendant calling
12624
+ // stopPropagation() (e.g. in its own pointerup handler) can't
12625
+ // prevent the gesture from ending. See #2794.
12626
+ const eventOptions = { passive: true, capture: true };
12627
+ this.removeListeners = pipe(addPointerEvent(this.contextWindow, "pointermove", this.handlePointerMove, eventOptions), addPointerEvent(this.contextWindow, "pointerup", this.handlePointerUp, eventOptions), addPointerEvent(this.contextWindow, "pointercancel", this.handlePointerUp, eventOptions));
12045
12628
  // Start scroll tracking if element provided
12046
12629
  if (element) {
12047
12630
  this.startScrollTracking(element);
@@ -12885,8 +13468,8 @@
12885
13468
  class DragGesture extends Feature {
12886
13469
  constructor(node) {
12887
13470
  super(node);
12888
- this.removeGroupControls = noop$1;
12889
- this.removeListeners = noop$1;
13471
+ this.removeGroupControls = noop;
13472
+ this.removeListeners = noop;
12890
13473
  this.controls = new VisualElementDragControls(node);
12891
13474
  }
12892
13475
  mount() {
@@ -12896,7 +13479,7 @@
12896
13479
  if (dragControls) {
12897
13480
  this.removeGroupControls = dragControls.subscribe(this.controls);
12898
13481
  }
12899
- this.removeListeners = this.controls.addListeners() || noop$1;
13482
+ this.removeListeners = this.controls.addListeners() || noop;
12900
13483
  }
12901
13484
  update() {
12902
13485
  const { dragControls } = this.node.getProps();
@@ -12935,7 +13518,7 @@
12935
13518
  class PanGesture extends Feature {
12936
13519
  constructor() {
12937
13520
  super(...arguments);
12938
- this.removePointerDownListener = noop$1;
13521
+ this.removePointerDownListener = noop;
12939
13522
  }
12940
13523
  onPointerDown(pointerDownEvent) {
12941
13524
  this.session = new PanSession(pointerDownEvent, this.createPanHandlers(), {
@@ -14722,7 +15305,7 @@
14722
15305
  const getEventTarget = (element) => element === document.scrollingElement ? window : element;
14723
15306
  function scrollInfo(onScroll, { container = document.scrollingElement, trackContentSize = false, ...options } = {}) {
14724
15307
  if (!container)
14725
- return noop$1;
15308
+ return noop;
14726
15309
  let containerHandlers = onScrollHandlers.get(container);
14727
15310
  /**
14728
15311
  * Get the onScroll handlers for this container.
@@ -15006,7 +15589,7 @@
15006
15589
 
15007
15590
  function scroll(onScroll, { axis = "y", container = document.scrollingElement, ...options } = {}) {
15008
15591
  if (!container)
15009
- return noop$1;
15592
+ return noop;
15010
15593
  const optionsWithDefaults = { axis, container, ...options };
15011
15594
  return typeof onScroll === "function"
15012
15595
  ? attachToFunction(onScroll, optionsWithDefaults)
@@ -16177,7 +16760,7 @@
16177
16760
  };
16178
16761
  appearComplete.set(id, false);
16179
16762
  if (readyAnimation.ready) {
16180
- readyAnimation.ready.then(startAnimation).catch(noop$1);
16763
+ readyAnimation.ready.then(startAnimation).catch(noop);
16181
16764
  }
16182
16765
  else {
16183
16766
  startAnimation();
@@ -16334,7 +16917,6 @@
16334
16917
  exports.VisualElement = VisualElement;
16335
16918
  exports.WillChangeMotionValue = WillChangeMotionValue;
16336
16919
  exports.acceleratedValues = acceleratedValues;
16337
- exports.activeAnimations = activeAnimations;
16338
16920
  exports.addAttrValue = addAttrValue;
16339
16921
  exports.addDomEvent = addDomEvent;
16340
16922
  exports.addPointerEvent = addPointerEvent;
@@ -16549,7 +17131,7 @@
16549
17131
  exports.motionValue = motionValue;
16550
17132
  exports.moveItem = moveItem;
16551
17133
  exports.nodeGroup = nodeGroup;
16552
- exports.noop = noop$1;
17134
+ exports.noop = noop;
16553
17135
  exports.number = number;
16554
17136
  exports.numberValueTypes = numberValueTypes;
16555
17137
  exports.observeTimeline = observeTimeline;