@humanspeak/svelte-motion 0.5.3 → 0.6.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.
@@ -5,6 +5,8 @@
5
5
 
6
6
  <script lang="ts">
7
7
  import { getMotionConfig } from '../components/motionConfig.context'
8
+ import { getLazyMotionContext } from '../components/lazyMotion.context'
9
+ import { domMax } from '../features/domMax'
8
10
  import {
9
11
  filterReducedMotionKeyframes,
10
12
  useReducedMotionConfig
@@ -26,6 +28,7 @@
26
28
  import { isNotEmpty } from '../utils/objects'
27
29
  import { sleep } from '../utils/testing'
28
30
  import { animate, type AnimationOptions, type DOMKeyframesDefinition } from 'motion'
31
+ import { motionValue, svgEffect, type MotionValue } from 'motion-dom'
29
32
  import { isPlaywrightEnv, pwLog } from '../utils/log'
30
33
  import { onDestroy, untrack, type Snippet } from 'svelte'
31
34
  import { VOID_TAGS } from '../utils/constants'
@@ -56,6 +59,11 @@
56
59
  import { attachPan, type AttachPanCleanup } from '../utils/pan'
57
60
  import { ProjectionNode } from '../utils/projection'
58
61
  import { getProjectionParent, setProjectionParent } from '../components/projection.context'
62
+ import { MotionDomProjectionAdapter } from '../utils/motionDomProjection'
63
+ import {
64
+ getMotionDomProjectionParent,
65
+ setMotionDomProjectionParent
66
+ } from '../components/motionDomProjection.context'
59
67
  import {
60
68
  resolveInitial,
61
69
  resolveAnimate,
@@ -75,9 +83,19 @@
75
83
  import {
76
84
  transformSVGPathProperties,
77
85
  computeNormalizedSVGInitialAttrs,
86
+ hasSVGPathProperties,
87
+ isSVGPathElement,
78
88
  isSVGTag,
79
89
  SVG_NAMESPACE
80
90
  } from '../utils/svg'
91
+ import {
92
+ createOptimizedAppearData,
93
+ createOptimizedAppearScript,
94
+ finishOptimizedAppearAnimation,
95
+ hasOptimizedAppearAnimation,
96
+ markMotionMounted,
97
+ optimizedAppearDataAttribute
98
+ } from '../utils/optimizedAppear'
81
99
  import { getLayoutIdRegistry } from '../utils/layoutId'
82
100
  import {
83
101
  getLayoutScrollContainerRef,
@@ -91,6 +109,8 @@
91
109
  [key: string]: unknown
92
110
  }
93
111
 
112
+ const componentHydrationId = $props.id()
113
+
94
114
  let {
95
115
  children,
96
116
  tag = 'div',
@@ -156,6 +176,11 @@
156
176
  let enterAnimationSettled = $state(false)
157
177
  let dataPath = $state<number>(-1)
158
178
  const motionConfig = $derived(getMotionConfig())
179
+ const lazyMotion = getLazyMotionContext()
180
+ const activeFeatures = $derived(lazyMotion?.getFeatures() ?? domMax)
181
+ const hasGestureFeatures = $derived(!!activeFeatures.gestures)
182
+ const hasDragFeatures = $derived(!!activeFeatures.drag)
183
+ const hasLayoutFeatures = $derived(!!activeFeatures.layout)
159
184
  const reducedMotionState = useReducedMotionConfig()
160
185
  // `.current` is $state-backed inside reducedMotionState; tracking it via
161
186
  // $derived makes `reducedMotion` re-evaluate whenever the OS preference
@@ -227,6 +252,18 @@
227
252
  })
228
253
  setProjectionParent(projection)
229
254
 
255
+ const motionDomProjectionParent =
256
+ typeof window !== 'undefined' ? getMotionDomProjectionParent() : null
257
+ const motionDomProjection =
258
+ typeof window !== 'undefined'
259
+ ? new MotionDomProjectionAdapter({
260
+ parent: motionDomProjectionParent
261
+ })
262
+ : null
263
+ if (motionDomProjection) {
264
+ setMotionDomProjectionParent(motionDomProjection)
265
+ }
266
+
230
267
  // Convert a projection `Box` (ancestor chain reset to base, self
231
268
  // transform stripped, scroll containers compensated) to the
232
269
  // `RectLike` shape `computeFlipTransforms` consumes.
@@ -239,9 +276,20 @@
239
276
  width: box.x.max - box.x.min,
240
277
  height: box.y.max - box.y.min
241
278
  })
279
+ const domRectToRectLike = (rect: DOMRect): RectLike => ({
280
+ left: rect.left,
281
+ top: rect.top,
282
+ width: rect.width,
283
+ height: rect.height
284
+ })
285
+ const isViewportOffscreen = (rect: DOMRect): boolean =>
286
+ rect.bottom <= 0 ||
287
+ rect.right <= 0 ||
288
+ rect.top >= window.innerHeight ||
289
+ rect.left >= window.innerWidth
242
290
 
243
291
  // Ancestor-transform-invariant layout measurement for seeding the
244
- // FLIP effect's first rect.
292
+ // fallback FLIP effect's first rect.
245
293
  const measureLayoutRect = (): RectLike | null => {
246
294
  const box = projection.measure()
247
295
  return box ? boxToRectLike(box) : null
@@ -544,6 +592,173 @@
544
592
  reducedMotion
545
593
  )
546
594
  )
595
+ const optimizedAppearId = $derived(
596
+ effectiveInitialProp !== false &&
597
+ isNotEmpty(initialKeyframes) &&
598
+ isNotEmpty(animateKeyframes)
599
+ ? `svelte-motion-${componentHydrationId}`
600
+ : undefined
601
+ )
602
+ const optimizedAppearEntries = $derived(
603
+ createOptimizedAppearData(
604
+ initialKeyframes as Record<string, unknown> | undefined,
605
+ animateKeyframes as Record<string, unknown> | undefined,
606
+ mergedTransition
607
+ )
608
+ )
609
+ const optimizedAppearScript = $derived(
610
+ createOptimizedAppearScript(optimizedAppearId, optimizedAppearEntries)
611
+ )
612
+ const renderedOptimizedAppearScript = $derived(
613
+ optimizedAppearScript && (typeof window === 'undefined' || !window.MotionIsMounted)
614
+ ? optimizedAppearScript
615
+ : ''
616
+ )
617
+ const applyAnimateRestingStyle = () => {
618
+ if (!element) return
619
+ const restingValues = resolveRestingValues(
620
+ animateKeyframes as DOMKeyframesDefinition | undefined
621
+ ) as Record<string, unknown> | undefined
622
+ if (!restingValues) return
623
+ element.setAttribute(
624
+ 'style',
625
+ mergeInlineStyles(element.getAttribute('style') ?? '', undefined, restingValues)
626
+ )
627
+ }
628
+ const isJsdomRuntime = (): boolean =>
629
+ typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent)
630
+ const getTransitionFallbackMs = (transition: AnimationOptions | undefined): number => {
631
+ const duration = typeof transition?.duration === 'number' ? transition.duration : 0
632
+ const delay = typeof transition?.delay === 'number' ? transition.delay : 0
633
+ return Math.max(0, (duration + delay) * 1000)
634
+ }
635
+ let cleanupSVGPathAttributeEffect: (() => void) | null = null
636
+
637
+ /**
638
+ * Reads the current normalized SVG path drawing state from DOM
639
+ * attributes. `motion-dom`'s svgEffect owns future writes; this only
640
+ * seeds its MotionValues from the currently rendered frame.
641
+ *
642
+ * @param {SVGPathElement} path The SVG path element to inspect.
643
+ * @returns {{ pathLength: number; pathSpacing: number; pathOffset: number }} The normalized drawing state.
644
+ */
645
+ const readSVGPathDrawingState = (
646
+ path: SVGPathElement
647
+ ): { pathLength: number; pathSpacing: number; pathOffset: number } => {
648
+ const dashArray =
649
+ path.getAttribute('stroke-dasharray') || path.style.strokeDasharray || '1 0'
650
+ const [rawLength, rawSpacing] = dashArray
651
+ .split(/[,\s]+/)
652
+ .filter(Boolean)
653
+ .map((part) => Number.parseFloat(part))
654
+ const rawOffset = Number.parseFloat(
655
+ path.getAttribute('stroke-dashoffset') || path.style.strokeDashoffset || '0'
656
+ )
657
+
658
+ return {
659
+ pathLength: Number.isFinite(rawLength) ? rawLength : 1,
660
+ pathSpacing: Number.isFinite(rawSpacing) ? rawSpacing : 1,
661
+ pathOffset: Number.isFinite(rawOffset) ? -rawOffset : 0
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Removes custom SVG path props from keyframes after `svgEffect` has
667
+ * taken ownership of them.
668
+ *
669
+ * @param {Record<string, unknown>} keyframes Keyframes to copy.
670
+ * @returns {Record<string, unknown>} Keyframes without SVG path-only props.
671
+ */
672
+ const stripSVGPathKeyframes = (keyframes: Record<string, unknown>): Record<string, unknown> => {
673
+ const stripped = { ...keyframes }
674
+ delete stripped.pathLength
675
+ delete stripped.pathSpacing
676
+ delete stripped.pathOffset
677
+ return stripped
678
+ }
679
+
680
+ /**
681
+ * Extracts an animation completion promise from a Motion control when
682
+ * one is available.
683
+ *
684
+ * @param {unknown} control The return value from `animate`.
685
+ * @returns {Promise<unknown> | null} The finished promise, or null.
686
+ */
687
+ const getFinishedPromise = (control: unknown): Promise<unknown> | null => {
688
+ if (!control || typeof control !== 'object') return null
689
+ const finished = (control as { finished?: unknown }).finished
690
+ return finished && typeof (finished as Promise<unknown>).then === 'function'
691
+ ? (finished as Promise<unknown>)
692
+ : null
693
+ }
694
+
695
+ /**
696
+ * Animates SVG path drawing props via motion-dom's `svgEffect`, matching
697
+ * upstream's attribute-based pathLength/pathSpacing/pathOffset behavior.
698
+ *
699
+ * @param {SVGPathElement} path The path element to animate.
700
+ * @param {Record<string, unknown>} keyframes Keyframes containing SVG path props.
701
+ * @param {MotionTransition} transition The transition to apply to generated MotionValues.
702
+ * @returns {Promise<unknown>[]} Promises for generated path animations.
703
+ */
704
+ const animateSVGPathAttributes = (
705
+ path: SVGPathElement,
706
+ keyframes: Record<string, unknown>,
707
+ transition: MotionTransition
708
+ ): Promise<unknown>[] => {
709
+ if (!hasSVGPathProperties(keyframes)) return []
710
+
711
+ cleanupSVGPathAttributeEffect?.()
712
+ const current = readSVGPathDrawingState(path)
713
+ const values: Record<string, MotionValue<number>> = {}
714
+
715
+ if ('pathLength' in keyframes) {
716
+ values.pathLength = motionValue(current.pathLength)
717
+ }
718
+ if ('pathLength' in keyframes || 'pathSpacing' in keyframes) {
719
+ values.pathSpacing = motionValue(current.pathSpacing)
720
+ }
721
+ if ('pathOffset' in keyframes) {
722
+ values.pathOffset = motionValue(current.pathOffset)
723
+ }
724
+
725
+ cleanupSVGPathAttributeEffect = svgEffect(path, values)
726
+
727
+ return Object.entries(values)
728
+ .map(([key, value]) => {
729
+ const control = animate(
730
+ value as never,
731
+ (key === 'pathSpacing' && !('pathSpacing' in keyframes)
732
+ ? 1
733
+ : keyframes[key]) as never,
734
+ transition as unknown as AnimationOptions
735
+ )
736
+ return getFinishedPromise(control)
737
+ })
738
+ .filter((promise): promise is Promise<unknown> => promise !== null)
739
+ }
740
+
741
+ onDestroy(() => {
742
+ cleanupSVGPathAttributeEffect?.()
743
+ cleanupSVGPathAttributeEffect = null
744
+ })
745
+
746
+ // Wait-mode enter coordination needs to affect the first rendered attrs,
747
+ // before the blocked entrant can participate in layout.
748
+ let waitCallbackRegistered = $state(false)
749
+ let waitUnsubscribe: (() => void) | null = null
750
+ let waitHiddenDisplay: string | null = null
751
+ let waitEnterReleased = $state(false)
752
+ let waitLayoutParent: HTMLElement | null = null
753
+ let waitLayoutParentWidth = ''
754
+ let waitLayoutParentHeight = ''
755
+ let waitLayoutViewportScrollX = 0
756
+ let waitLayoutViewportScrollY = 0
757
+ const presenceLayoutHoldAttribute = 'data-presence-layout-hold'
758
+ const presenceLayoutReleaseEvent = 'svelte-motion:presence-layout-release'
759
+ const waitEnterBlockedBeforeMount = $derived(
760
+ context?.mode === 'wait' && !waitEnterReleased && context.isEnterBlocked(presenceKey)
761
+ )
547
762
 
548
763
  // Derived attributes to keep both branches in sync (focusability, data flags, style, class)
549
764
  const derivedAttrs = $derived<Record<string, unknown>>({
@@ -553,7 +768,8 @@
553
768
  // key, empty array) would otherwise add `tabindex=0` for an
554
769
  // element that never actually receives a tap gesture — an
555
770
  // unintended tab stop. (#349 CR feedback)
556
- ...(isNotEmpty(resolvedWhileTap) &&
771
+ ...(hasGestureFeatures &&
772
+ isNotEmpty(resolvedWhileTap) &&
557
773
  !isNativelyFocusable(tag, rest as Record<string, unknown>) &&
558
774
  ((rest as Record<string, unknown>)?.tabindex ??
559
775
  (rest as Record<string, unknown>)?.tabIndex ??
@@ -567,6 +783,16 @@
567
783
  'data-path': dataPath
568
784
  }
569
785
  : {}),
786
+ ...(renderedOptimizedAppearScript
787
+ ? { [optimizedAppearDataAttribute]: optimizedAppearId }
788
+ : {}),
789
+ ...(layoutProp
790
+ ? { 'data-layout': String(layoutProp), 'data-svelte-motion-layout': '' }
791
+ : {}),
792
+ ...(scopedLayoutId ? { 'data-layout-id': scopedLayoutId } : {}),
793
+ ...(waitEnterBlockedBeforeMount || waitHiddenDisplay !== null
794
+ ? { 'data-presence-wait-hidden': 'true' }
795
+ : {}),
570
796
  // Apply normalized SVG path attributes synchronously on first render to avoid flash
571
797
  // Compute via svg utils (no dynamic import in SSR/derived expressions)
572
798
  ...(() => {
@@ -580,9 +806,7 @@
580
806
  return {}
581
807
  })(),
582
808
  style: mergeInlineStyles(
583
- initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting'
584
- ? `${styleProp || ''};visibility:hidden`
585
- : styleProp,
809
+ `${initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting' ? `${styleProp || ''};visibility:hidden` : (styleProp ?? '')}${waitEnterBlockedBeforeMount || waitHiddenDisplay !== null ? ';display:none' : ''}`,
586
810
  // The "from" slot: apply initialKeyframes as inline styles during
587
811
  // the mounting/initial phases (before the WAAPI animation locks
588
812
  // its from-value and we promote to 'ready' — see the lifecycle
@@ -626,7 +850,7 @@
626
850
  // after any non-zero duration settle animation.
627
851
  let teardownDrag: (() => void) | null = null
628
852
  $effect(() => {
629
- if (!(element && isLoaded === 'ready')) return
853
+ if (!(element && isLoaded === 'ready' && hasDragFeatures)) return
630
854
  // Only attach if drag enabled
631
855
  if (!dragProp) return
632
856
  // Clean up previous
@@ -787,7 +1011,7 @@
787
1011
  isLoaded
788
1012
  })
789
1013
  }
790
- if (!element) return
1014
+ if (!element || !hasGestureFeatures) return
791
1015
  // Defer attachment until the element has settled out of the enter
792
1016
  // animation phase — matches the gate every other gesture effect
793
1017
  // in this file uses (drag, whileTap, whileHover, whileFocus,
@@ -861,7 +1085,17 @@
861
1085
  }
862
1086
 
863
1087
  const transitionAnimate: MotionTransition = mergedTransition ?? {}
864
- let payload = $state.snapshot(resolvedAnimate)
1088
+ const rawPayload = filterReducedMotionKeyframes(
1089
+ $state.snapshot(resolvedAnimate) as Record<string, unknown>,
1090
+ reducedMotion
1091
+ ) as Record<string, unknown>
1092
+ const svgPathFinished =
1093
+ isSVGPathElement(element) && hasSVGPathProperties(rawPayload)
1094
+ ? animateSVGPathAttributes(element, rawPayload, transitionAnimate)
1095
+ : []
1096
+ let payload = (
1097
+ svgPathFinished.length > 0 ? stripSVGPathKeyframes(rawPayload) : rawPayload
1098
+ ) as typeof rawPayload
865
1099
 
866
1100
  // Transform SVG path properties (pathLength, pathOffset) to their CSS equivalents
867
1101
  payload = transformSVGPathProperties(
@@ -869,13 +1103,6 @@
869
1103
  payload as Record<string, unknown>
870
1104
  ) as typeof payload
871
1105
 
872
- // Strip transform keys when reduced-motion is active so the element
873
- // stays in place while opacity / color etc. still animate.
874
- payload = filterReducedMotionKeyframes(
875
- payload as Record<string, unknown>,
876
- reducedMotion
877
- ) as typeof payload
878
-
879
1106
  // Ensure dash properties aren't pinned as inline styles
880
1107
  if (element && (element as HTMLElement).style) {
881
1108
  ;(element as HTMLElement).style.removeProperty('stroke-dasharray')
@@ -889,33 +1116,134 @@
889
1116
 
890
1117
  // A fresh run owns the transform again until it completes.
891
1118
  enterAnimationSettled = false
892
- animateWithLifecycle(
893
- element,
894
- payload as unknown as DOMKeyframesDefinition,
895
- transitionAnimate as unknown as AnimationOptions,
896
- (def) => onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
897
- (def) => {
898
- // Now the target is the resting state — promote it to the
899
- // inline baseline so it persists after WAAPI surrenders the
900
- // property (default fill:'none'). (#377)
901
- enterAnimationSettled = true
902
- onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
903
- }
904
- )
1119
+ const completeEnterAnimation = (
1120
+ def: DOMKeyframesDefinition | undefined = payload as unknown as DOMKeyframesDefinition
1121
+ ) => {
1122
+ if (enterAnimationSettled) return
1123
+ // Now the target is the resting state — promote it to the
1124
+ // inline baseline so it persists after WAAPI surrenders the
1125
+ // property (default fill:'none'). (#377)
1126
+ applyAnimateRestingStyle()
1127
+ enterAnimationSettled = true
1128
+ onAnimationCompleteProp?.(def)
1129
+ }
1130
+ if (isNotEmpty(payload)) {
1131
+ animateWithLifecycle(
1132
+ element,
1133
+ payload as unknown as DOMKeyframesDefinition,
1134
+ transitionAnimate as unknown as AnimationOptions,
1135
+ (def) =>
1136
+ onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
1137
+ (def) =>
1138
+ completeEnterAnimation(def as unknown as DOMKeyframesDefinition | undefined)
1139
+ )
1140
+ } else if (svgPathFinished.length > 0) {
1141
+ onAnimationStartProp?.(rawPayload as unknown as DOMKeyframesDefinition)
1142
+ Promise.all(svgPathFinished)
1143
+ .then(() => completeEnterAnimation(rawPayload as unknown as DOMKeyframesDefinition))
1144
+ .catch(() =>
1145
+ completeEnterAnimation(rawPayload as unknown as DOMKeyframesDefinition)
1146
+ )
1147
+ }
1148
+ if (isJsdomRuntime()) {
1149
+ window.setTimeout(
1150
+ () => completeEnterAnimation(),
1151
+ getTransitionFallbackMs(transitionAnimate as AnimationOptions)
1152
+ )
1153
+ }
905
1154
  }
906
1155
 
907
- // Track if we've already registered a wait callback to prevent duplicates
908
- let waitCallbackRegistered = $state(false)
909
- let waitUnsubscribe: (() => void) | null = null
910
-
911
1156
  // Cleanup wait callback on component unmount to prevent memory leaks
912
1157
  $effect(() => {
913
1158
  return () => {
1159
+ if (element && waitHiddenDisplay !== null) {
1160
+ element.style.display = waitHiddenDisplay
1161
+ element.removeAttribute('data-presence-wait-hidden')
1162
+ waitHiddenDisplay = null
1163
+ }
1164
+ releaseWaitLayoutHold()
914
1165
  waitUnsubscribe?.()
915
1166
  waitUnsubscribe = null
916
1167
  }
917
1168
  })
918
1169
 
1170
+ const getPresenceLayoutParent = (): HTMLElement | null => {
1171
+ let parent = element?.parentElement ?? null
1172
+ const layoutParent = element?.parentElement?.closest<HTMLElement>(
1173
+ '[data-svelte-motion-layout]'
1174
+ )
1175
+ if (layoutParent) return layoutParent
1176
+
1177
+ while (parent && getComputedStyle(parent).display === 'contents') {
1178
+ parent = parent.parentElement
1179
+ }
1180
+ return parent
1181
+ }
1182
+
1183
+ const holdWaitLayout = () => {
1184
+ if (!element || waitLayoutParent) return
1185
+ const parent = getPresenceLayoutParent()
1186
+ if (!parent) return
1187
+
1188
+ const rect = parent.getBoundingClientRect()
1189
+ waitLayoutParent = parent
1190
+ waitLayoutParentWidth = parent.style.width
1191
+ waitLayoutParentHeight = parent.style.height
1192
+ waitLayoutViewportScrollX = typeof window !== 'undefined' ? window.scrollX : 0
1193
+ waitLayoutViewportScrollY = typeof window !== 'undefined' ? window.scrollY : 0
1194
+ parent.setAttribute(presenceLayoutHoldAttribute, 'true')
1195
+ parent.style.width = `${rect.width}px`
1196
+ parent.style.height = `${rect.height}px`
1197
+ }
1198
+
1199
+ function releaseWaitLayoutHold() {
1200
+ if (!waitLayoutParent) return
1201
+ const parent = waitLayoutParent
1202
+ const previousRect = parent.getBoundingClientRect()
1203
+ parent.removeAttribute(presenceLayoutHoldAttribute)
1204
+ if (waitLayoutParentWidth) {
1205
+ parent.style.width = waitLayoutParentWidth
1206
+ } else {
1207
+ parent.style.removeProperty('width')
1208
+ }
1209
+ if (waitLayoutParentHeight) {
1210
+ parent.style.height = waitLayoutParentHeight
1211
+ } else {
1212
+ parent.style.removeProperty('height')
1213
+ }
1214
+ const viewportScrolledDuringHold =
1215
+ typeof window !== 'undefined' &&
1216
+ (window.scrollX !== waitLayoutViewportScrollX ||
1217
+ window.scrollY !== waitLayoutViewportScrollY)
1218
+ parent.dispatchEvent(
1219
+ new CustomEvent(presenceLayoutReleaseEvent, {
1220
+ detail: {
1221
+ previousRect: domRectToRectLike(previousRect),
1222
+ viewportScrolledDuringHold
1223
+ }
1224
+ })
1225
+ )
1226
+ waitLayoutParent = null
1227
+ waitLayoutParentWidth = ''
1228
+ waitLayoutParentHeight = ''
1229
+ waitLayoutViewportScrollX = 0
1230
+ waitLayoutViewportScrollY = 0
1231
+ }
1232
+
1233
+ const revealWaitHiddenElement = () => {
1234
+ waitEnterReleased = true
1235
+ if (waitHiddenDisplay !== null && element) {
1236
+ if (waitHiddenDisplay) {
1237
+ element.style.display = waitHiddenDisplay
1238
+ } else {
1239
+ element.style.removeProperty('display')
1240
+ }
1241
+ element.removeAttribute('data-presence-wait-hidden')
1242
+ waitHiddenDisplay = null
1243
+ }
1244
+ releaseWaitLayoutHold()
1245
+ }
1246
+
919
1247
  /**
920
1248
  * Run the enter animation, respecting wait mode if inside AnimatePresence.
921
1249
  * Returns true if animation was deferred (wait mode with blocked enters).
@@ -941,12 +1269,21 @@
941
1269
  return true // Still deferred
942
1270
  }
943
1271
 
944
- const blocked = context.isEnterBlocked?.()
1272
+ const blocked = context.isEnterBlocked?.(presenceKey)
945
1273
  pwLog('[motion] runAnimation: wait mode', { blocked })
946
1274
 
947
1275
  if (blocked) {
948
1276
  pwLog('[motion] runAnimation: enters blocked, deferring')
949
1277
 
1278
+ waitEnterReleased = false
1279
+ if (waitHiddenDisplay === null) {
1280
+ waitHiddenDisplay =
1281
+ element.style.display === 'none' ? '' : element.style.display
1282
+ element.style.display = 'none'
1283
+ element.setAttribute('data-presence-wait-hidden', 'true')
1284
+ holdWaitLayout()
1285
+ }
1286
+
950
1287
  waitCallbackRegistered = true
951
1288
 
952
1289
  // Register callback to run animation when unblocked
@@ -956,6 +1293,12 @@
956
1293
  waitUnsubscribe = null
957
1294
  waitCallbackRegistered = false
958
1295
 
1296
+ // Reveal synchronously after the exiting placeholder has
1297
+ // been removed. The parent is fixed-size until the next
1298
+ // frame, so it measures the final entrant instead of an
1299
+ // overlap between exiting and entering content.
1300
+ revealWaitHiddenElement()
1301
+
959
1302
  // Snap to initial state first (in case inline styles were removed)
960
1303
  if (initialKeyframes && element) {
961
1304
  const transformedInitial = transformSVGPathProperties(
@@ -987,6 +1330,11 @@
987
1330
  })
988
1331
  return true // Animation was deferred
989
1332
  }
1333
+
1334
+ if (waitHiddenDisplay !== null || waitEnterBlockedBeforeMount) {
1335
+ pwLog('[motion] runAnimation: wait mode no longer blocked, revealing')
1336
+ revealWaitHiddenElement()
1337
+ }
990
1338
  }
991
1339
 
992
1340
  // Not blocked - run animation immediately
@@ -1010,6 +1358,7 @@
1010
1358
  let objectAnimateRanOnMount = $state(false)
1011
1359
  // Track the serialized animateProp to detect changes for object animate props
1012
1360
  let lastAnimatePropJson = $state<string | undefined>(undefined)
1361
+ let motionDomProjectionUpdatePending = false
1013
1362
  const currentAnimateKey = $derived(
1014
1363
  typeof animateProp === 'string'
1015
1364
  ? animateProp
@@ -1018,6 +1367,72 @@
1018
1367
  : undefined
1019
1368
  )
1020
1369
 
1370
+ $effect(() => {
1371
+ if (!motionDomProjection) return
1372
+ motionDomProjection.updateOptions({
1373
+ layout: layoutProp,
1374
+ layoutId: scopedLayoutId,
1375
+ transition: mergedTransition as never,
1376
+ style: styleProp
1377
+ })
1378
+ })
1379
+
1380
+ $effect(() => {
1381
+ if (!motionDomProjection) return
1382
+ if (!element) return
1383
+ motionDomProjection.mount(element)
1384
+ return () => {
1385
+ motionDomProjection.unmount()
1386
+ }
1387
+ })
1388
+
1389
+ let explicitLayoutSnapshot: RectLike | null = null
1390
+ let lastRect: RectLike | null = null
1391
+ const trackLayoutProjectionDependencies = () => [
1392
+ classProp,
1393
+ styleProp,
1394
+ scopedLayoutId,
1395
+ mergedTransition
1396
+ ]
1397
+
1398
+ $effect.pre(() => {
1399
+ const shouldProject = element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures
1400
+ // Track common layout-affecting props so Svelte-owned updates can
1401
+ // snapshot before the DOM patch, matching upstream MeasureLayout.
1402
+ trackLayoutProjectionDependencies()
1403
+
1404
+ if (!shouldProject) {
1405
+ explicitLayoutSnapshot = null
1406
+ return
1407
+ }
1408
+
1409
+ explicitLayoutSnapshot = measureLayoutRect()
1410
+ projection.willUpdate()
1411
+ motionDomProjection?.willUpdate()
1412
+ motionDomProjectionUpdatePending = true
1413
+ })
1414
+
1415
+ $effect(() => {
1416
+ const shouldProject = element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures
1417
+ trackLayoutProjectionDependencies()
1418
+
1419
+ if (!shouldProject || !motionDomProjectionUpdatePending) return
1420
+ motionDomProjectionUpdatePending = false
1421
+ const previous = explicitLayoutSnapshot
1422
+ explicitLayoutSnapshot = null
1423
+ if (previous) {
1424
+ const next = measureLayoutRect()
1425
+ if (next) {
1426
+ const flipLayoutMode = layoutProp === 'position' ? 'position' : true
1427
+ const transforms = computeFlipTransforms(previous, next, flipLayoutMode)
1428
+ runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
1429
+ lastRect = next
1430
+ }
1431
+ }
1432
+ projection.didUpdate()
1433
+ motionDomProjection?.didUpdate()
1434
+ })
1435
+
1021
1436
  // Projection node lifecycle + the `onProjectionUpdate` listener.
1022
1437
  // Mount once the element binds; seed the baseline layout; unmount on
1023
1438
  // cleanup. Depends ONLY on `element` — the `onProjectionUpdate`
@@ -1045,56 +1460,104 @@
1045
1460
  }
1046
1461
  })
1047
1462
 
1048
- // Minimal layout animation using FLIP when `layout` is enabled.
1049
- // When layout === 'position' we only translate.
1050
- // When layout === true we also scale to smoothly interpolate size changes.
1051
- let lastRect: RectLike | null = null
1463
+ // Upstream layout projection via motion-dom. Svelte runes mode doesn't
1464
+ // expose the React-style pre/post render hook pair used upstream, so the
1465
+ // component snapshots committed layout changes through DOM observers while
1466
+ // keeping the existing local ProjectionNode event fan-out alive.
1052
1467
  $effect(() => {
1053
- if (!(element && layoutProp && isLoaded === 'ready')) return
1054
-
1055
- // Initialize last rect on first ready frame. We measure through the
1056
- // projection node rather than `measureRect` directly so the rect is
1057
- // ancestor-transform-invariant: the node resets the whole ancestor
1058
- // chain to its mount-time base while reading, which strips any
1059
- // motion-applied (FLIP/drag) transform up the tree. Without this a
1060
- // child re-measures and FLIPs whenever an ancestor's transform
1061
- // animates, even though the child's own layout never moved. (#379)
1468
+ if (!(element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures)) return
1469
+
1470
+ let rafId: number | null = null
1471
+ let wasViewportOffscreenSinceLastLayout = false
1472
+ const flipLayoutMode = layoutProp === 'position' ? 'position' : true
1473
+ motionDomProjection?.seedLayout()
1062
1474
  lastRect = measureLayoutRect()
1063
- // Hint compositor for smoother FLIP transforms
1064
1475
  setCompositorHints(element!, true)
1065
1476
 
1066
- let rafId: number | null = null
1067
- const runFlip = () => {
1068
- // commitLayoutChange does the ancestor-stripped measure, fans
1069
- // the delta out to `onProjectionUpdate` listeners, AND returns
1070
- // the freshly-measured box — so the FLIP reuses that single
1071
- // measurement as `next` instead of walking + transform-resetting
1072
- // the ancestor chain a second time per frame. (#379)
1477
+ const rememberOffscreenScroll = () => {
1478
+ if (isViewportOffscreen(element!.getBoundingClientRect())) {
1479
+ wasViewportOffscreenSinceLastLayout = true
1480
+ }
1481
+ }
1482
+
1483
+ const commitObservedLayout = () => {
1484
+ if (element!.hasAttribute('data-layout-size-animation')) {
1485
+ return
1486
+ }
1487
+
1488
+ const hasPresenceHold = element!.hasAttribute(presenceLayoutHoldAttribute)
1489
+ const hasHiddenWaitEnter = !!element!.querySelector(
1490
+ '[data-presence-wait-hidden="true"]'
1491
+ )
1492
+ const hasPresencePlaceholder = !!element!.querySelector(
1493
+ '[data-presence-placeholder="true"]'
1494
+ )
1495
+
1496
+ if (hasPresenceHold || hasHiddenWaitEnter) {
1497
+ return
1498
+ }
1499
+
1500
+ if (hasPresencePlaceholder) {
1501
+ lastRect = measureLayoutRect()
1502
+ motionDomProjection?.seedLayout()
1503
+ return
1504
+ }
1505
+
1073
1506
  const nextBox = projection.commitLayoutChange()
1074
- if (!nextBox) return
1075
- const next = boxToRectLike(nextBox)
1076
- if (!lastRect) {
1507
+ if (nextBox && lastRect) {
1508
+ const next = boxToRectLike(nextBox)
1509
+ const transforms = computeFlipTransforms(lastRect, next, flipLayoutMode)
1510
+ runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
1077
1511
  lastRect = next
1078
- return
1512
+ wasViewportOffscreenSinceLastLayout = false
1513
+ } else if (nextBox) {
1514
+ lastRect = boxToRectLike(nextBox)
1515
+ wasViewportOffscreenSinceLastLayout = false
1079
1516
  }
1080
- const transforms = computeFlipTransforms(lastRect, next, layoutProp ?? false)
1081
- runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
1517
+ motionDomProjection?.commitObservedLayoutChange()
1518
+ }
1519
+
1520
+ const commitPresenceLayoutRelease = (event: Event) => {
1521
+ const detail = (
1522
+ event as CustomEvent<{
1523
+ previousRect?: RectLike
1524
+ viewportScrolledDuringHold?: boolean
1525
+ }>
1526
+ ).detail
1527
+ const previous = detail?.previousRect
1528
+ const viewportRect = element!.getBoundingClientRect()
1529
+ const next = measureLayoutRect()
1530
+ if (!(previous && next)) return
1531
+
1082
1532
  lastRect = next
1533
+ const shouldSkipLayoutAnimation =
1534
+ detail?.viewportScrolledDuringHold ||
1535
+ wasViewportOffscreenSinceLastLayout ||
1536
+ isViewportOffscreen(viewportRect)
1537
+ if (!shouldSkipLayoutAnimation) {
1538
+ const transforms = computeFlipTransforms(previous, next, flipLayoutMode)
1539
+ runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
1540
+ }
1541
+ wasViewportOffscreenSinceLastLayout = false
1542
+ motionDomProjection?.commitObservedLayoutChange()
1083
1543
  }
1084
1544
 
1085
- const scheduleFlip = () => {
1086
- if (rafId) cancelAnimationFrame(rafId)
1545
+ const scheduleProjectionCommit = () => {
1546
+ if (rafId) return
1547
+ commitObservedLayout()
1087
1548
  rafId = requestAnimationFrame(() => {
1088
1549
  rafId = null
1089
- runFlip()
1090
1550
  })
1091
1551
  }
1092
- const disconnectObservers = observeLayoutChanges(element!, () => scheduleFlip())
1552
+ const disconnectObservers = observeLayoutChanges(element!, () => scheduleProjectionCommit())
1553
+ window.addEventListener('scroll', rememberOffscreenScroll, { passive: true })
1554
+ element!.addEventListener(presenceLayoutReleaseEvent, commitPresenceLayoutRelease)
1093
1555
 
1094
1556
  return () => {
1095
1557
  disconnectObservers()
1558
+ window.removeEventListener('scroll', rememberOffscreenScroll)
1559
+ element?.removeEventListener(presenceLayoutReleaseEvent, commitPresenceLayoutRelease)
1096
1560
  lastRect = null
1097
- // Reset compositor hints on teardown
1098
1561
  if (element) {
1099
1562
  setCompositorHints(element, false)
1100
1563
  }
@@ -1105,7 +1568,16 @@
1105
1568
  // Shared layout animation via layoutId.
1106
1569
  // On mount, consume the previous snapshot and FLIP from its position.
1107
1570
  $effect(() => {
1108
- if (!(element && scopedLayoutId && layoutIdRegistry && isLoaded === 'ready')) return
1571
+ if (
1572
+ !(
1573
+ element &&
1574
+ scopedLayoutId &&
1575
+ layoutIdRegistry &&
1576
+ isLoaded === 'ready' &&
1577
+ hasLayoutFeatures
1578
+ )
1579
+ )
1580
+ return
1109
1581
 
1110
1582
  const prev = layoutIdRegistry.consume(scopedLayoutId)
1111
1583
  if (!prev) return // First appearance, no animation needed
@@ -1123,7 +1595,10 @@
1123
1595
 
1124
1596
  // whileTap handling via motion-dom's press()
1125
1597
  $effect(() => {
1126
- if (!(element && isLoaded === 'ready' && isNotEmpty(resolvedWhileTap))) return
1598
+ if (
1599
+ !(element && isLoaded === 'ready' && hasGestureFeatures && isNotEmpty(resolvedWhileTap))
1600
+ )
1601
+ return
1127
1602
  return attachWhileTap(
1128
1603
  element!,
1129
1604
  (resolvedWhileTap ?? {}) as Record<string, unknown>,
@@ -1143,7 +1618,15 @@
1143
1618
 
1144
1619
  // whileHover handling, gated to true-hover devices to avoid sticky states on touch
1145
1620
  $effect(() => {
1146
- if (!(element && isLoaded === 'ready' && isNotEmpty(resolvedWhileHover))) return
1621
+ if (
1622
+ !(
1623
+ element &&
1624
+ isLoaded === 'ready' &&
1625
+ hasGestureFeatures &&
1626
+ isNotEmpty(resolvedWhileHover)
1627
+ )
1628
+ )
1629
+ return
1147
1630
  return attachWhileHover(
1148
1631
  element!,
1149
1632
  (resolvedWhileHover ?? {}) as Record<string, unknown>,
@@ -1158,7 +1641,15 @@
1158
1641
 
1159
1642
  // whileFocus handling for keyboard focus interactions
1160
1643
  $effect(() => {
1161
- if (!(element && isLoaded === 'ready' && isNotEmpty(resolvedWhileFocus))) return
1644
+ if (
1645
+ !(
1646
+ element &&
1647
+ isLoaded === 'ready' &&
1648
+ hasGestureFeatures &&
1649
+ isNotEmpty(resolvedWhileFocus)
1650
+ )
1651
+ )
1652
+ return
1162
1653
  return attachWhileFocus(
1163
1654
  element!,
1164
1655
  (resolvedWhileFocus ?? {}) as Record<string, unknown>,
@@ -1173,7 +1664,15 @@
1173
1664
 
1174
1665
  // whileInView handling for viewport intersection
1175
1666
  $effect(() => {
1176
- if (!(element && isLoaded === 'ready' && isNotEmpty(resolvedWhileInView))) return
1667
+ if (
1668
+ !(
1669
+ element &&
1670
+ isLoaded === 'ready' &&
1671
+ hasGestureFeatures &&
1672
+ isNotEmpty(resolvedWhileInView)
1673
+ )
1674
+ )
1675
+ return
1177
1676
  return attachWhileInView(
1178
1677
  element!,
1179
1678
  (resolvedWhileInView ?? {}) as Record<string, unknown>,
@@ -1379,6 +1878,7 @@
1379
1878
 
1380
1879
  $effect(() => {
1381
1880
  if (!(element && isLoaded === 'mounting')) return
1881
+ markMotionMounted()
1382
1882
 
1383
1883
  pwLog('[motion] main effect running', {
1384
1884
  effectiveAnimate: !!effectiveAnimate,
@@ -1408,6 +1908,30 @@
1408
1908
  dataPath = 5
1409
1909
  isLoaded = 'ready'
1410
1910
  } else if (isNotEmpty(initialKeyframes)) {
1911
+ const canHandoffOptimizedAppear = hasOptimizedAppearAnimation(optimizedAppearId)
1912
+ if (canHandoffOptimizedAppear) {
1913
+ pwLog('[motion] path: optimized appear handoff')
1914
+ dataPath = 6
1915
+ isLoaded = 'initial'
1916
+ initialAnimationTriggered = true
1917
+ if (animateProp && typeof animateProp !== 'string') {
1918
+ objectAnimateRanOnMount = true
1919
+ lastAnimatePropJson = JSON.stringify(animateProp)
1920
+ }
1921
+ finishOptimizedAppearAnimation(optimizedAppearId)
1922
+ .then(() => {
1923
+ applyAnimateRestingStyle()
1924
+ enterAnimationSettled = true
1925
+ isLoaded = 'ready'
1926
+ onAnimationCompleteProp?.(
1927
+ resolvedAnimate as DOMKeyframesDefinition | undefined
1928
+ )
1929
+ })
1930
+ .catch(() => {
1931
+ isLoaded = 'ready'
1932
+ })
1933
+ return
1934
+ }
1411
1935
  pwLog('[motion] path: has initialKeyframes, will animate to target')
1412
1936
  // Apply initial instantly BEFORE exposing 'initial' state
1413
1937
  const transformedInitial = transformSVGPathProperties(
@@ -1528,15 +2052,23 @@
1528
2052
  {#if isVoidTag}
1529
2053
  {#if isSVGTag(String(tag))}
1530
2054
  <svelte:element this={tag} bind:this={element} xmlns={SVG_NAMESPACE} {...derivedAttrs} />
2055
+ <!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
2056
+ {@html renderedOptimizedAppearScript}
1531
2057
  {:else}
1532
2058
  <svelte:element this={tag} bind:this={element} {...derivedAttrs} />
2059
+ <!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
2060
+ {@html renderedOptimizedAppearScript}
1533
2061
  {/if}
1534
2062
  {:else if isSVGTag(String(tag))}
1535
2063
  <svelte:element this={tag} bind:this={element} xmlns={SVG_NAMESPACE} {...derivedAttrs}>
1536
2064
  {@render children?.()}
1537
2065
  </svelte:element>
2066
+ <!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
2067
+ {@html renderedOptimizedAppearScript}
1538
2068
  {:else}
1539
2069
  <svelte:element this={tag} bind:this={element} {...derivedAttrs}>
1540
2070
  {@render children?.()}
1541
2071
  </svelte:element>
2072
+ <!-- trunk-ignore(eslint/svelte/no-at-html-tags): optimized appear emits a JSON-escaped SSR bootstrap script, not user-authored HTML. -->
2073
+ {@html renderedOptimizedAppearScript}
1542
2074
  {/if}