@humanspeak/svelte-motion 0.5.4 → 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.
@@ -28,6 +28,7 @@
28
28
  import { isNotEmpty } from '../utils/objects'
29
29
  import { sleep } from '../utils/testing'
30
30
  import { animate, type AnimationOptions, type DOMKeyframesDefinition } from 'motion'
31
+ import { motionValue, svgEffect, type MotionValue } from 'motion-dom'
31
32
  import { isPlaywrightEnv, pwLog } from '../utils/log'
32
33
  import { onDestroy, untrack, type Snippet } from 'svelte'
33
34
  import { VOID_TAGS } from '../utils/constants'
@@ -58,6 +59,11 @@
58
59
  import { attachPan, type AttachPanCleanup } from '../utils/pan'
59
60
  import { ProjectionNode } from '../utils/projection'
60
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'
61
67
  import {
62
68
  resolveInitial,
63
69
  resolveAnimate,
@@ -77,9 +83,19 @@
77
83
  import {
78
84
  transformSVGPathProperties,
79
85
  computeNormalizedSVGInitialAttrs,
86
+ hasSVGPathProperties,
87
+ isSVGPathElement,
80
88
  isSVGTag,
81
89
  SVG_NAMESPACE
82
90
  } from '../utils/svg'
91
+ import {
92
+ createOptimizedAppearData,
93
+ createOptimizedAppearScript,
94
+ finishOptimizedAppearAnimation,
95
+ hasOptimizedAppearAnimation,
96
+ markMotionMounted,
97
+ optimizedAppearDataAttribute
98
+ } from '../utils/optimizedAppear'
83
99
  import { getLayoutIdRegistry } from '../utils/layoutId'
84
100
  import {
85
101
  getLayoutScrollContainerRef,
@@ -93,6 +109,8 @@
93
109
  [key: string]: unknown
94
110
  }
95
111
 
112
+ const componentHydrationId = $props.id()
113
+
96
114
  let {
97
115
  children,
98
116
  tag = 'div',
@@ -234,6 +252,18 @@
234
252
  })
235
253
  setProjectionParent(projection)
236
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
+
237
267
  // Convert a projection `Box` (ancestor chain reset to base, self
238
268
  // transform stripped, scroll containers compensated) to the
239
269
  // `RectLike` shape `computeFlipTransforms` consumes.
@@ -246,9 +276,20 @@
246
276
  width: box.x.max - box.x.min,
247
277
  height: box.y.max - box.y.min
248
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
249
290
 
250
291
  // Ancestor-transform-invariant layout measurement for seeding the
251
- // FLIP effect's first rect.
292
+ // fallback FLIP effect's first rect.
252
293
  const measureLayoutRect = (): RectLike | null => {
253
294
  const box = projection.measure()
254
295
  return box ? boxToRectLike(box) : null
@@ -551,6 +592,173 @@
551
592
  reducedMotion
552
593
  )
553
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
+ )
554
762
 
555
763
  // Derived attributes to keep both branches in sync (focusability, data flags, style, class)
556
764
  const derivedAttrs = $derived<Record<string, unknown>>({
@@ -575,6 +783,16 @@
575
783
  'data-path': dataPath
576
784
  }
577
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
+ : {}),
578
796
  // Apply normalized SVG path attributes synchronously on first render to avoid flash
579
797
  // Compute via svg utils (no dynamic import in SSR/derived expressions)
580
798
  ...(() => {
@@ -588,9 +806,7 @@
588
806
  return {}
589
807
  })(),
590
808
  style: mergeInlineStyles(
591
- initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting'
592
- ? `${styleProp || ''};visibility:hidden`
593
- : styleProp,
809
+ `${initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting' ? `${styleProp || ''};visibility:hidden` : (styleProp ?? '')}${waitEnterBlockedBeforeMount || waitHiddenDisplay !== null ? ';display:none' : ''}`,
594
810
  // The "from" slot: apply initialKeyframes as inline styles during
595
811
  // the mounting/initial phases (before the WAAPI animation locks
596
812
  // its from-value and we promote to 'ready' — see the lifecycle
@@ -869,7 +1085,17 @@
869
1085
  }
870
1086
 
871
1087
  const transitionAnimate: MotionTransition = mergedTransition ?? {}
872
- 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
873
1099
 
874
1100
  // Transform SVG path properties (pathLength, pathOffset) to their CSS equivalents
875
1101
  payload = transformSVGPathProperties(
@@ -877,13 +1103,6 @@
877
1103
  payload as Record<string, unknown>
878
1104
  ) as typeof payload
879
1105
 
880
- // Strip transform keys when reduced-motion is active so the element
881
- // stays in place while opacity / color etc. still animate.
882
- payload = filterReducedMotionKeyframes(
883
- payload as Record<string, unknown>,
884
- reducedMotion
885
- ) as typeof payload
886
-
887
1106
  // Ensure dash properties aren't pinned as inline styles
888
1107
  if (element && (element as HTMLElement).style) {
889
1108
  ;(element as HTMLElement).style.removeProperty('stroke-dasharray')
@@ -897,33 +1116,134 @@
897
1116
 
898
1117
  // A fresh run owns the transform again until it completes.
899
1118
  enterAnimationSettled = false
900
- animateWithLifecycle(
901
- element,
902
- payload as unknown as DOMKeyframesDefinition,
903
- transitionAnimate as unknown as AnimationOptions,
904
- (def) => onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
905
- (def) => {
906
- // Now the target is the resting state — promote it to the
907
- // inline baseline so it persists after WAAPI surrenders the
908
- // property (default fill:'none'). (#377)
909
- enterAnimationSettled = true
910
- onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
911
- }
912
- )
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
+ }
913
1154
  }
914
1155
 
915
- // Track if we've already registered a wait callback to prevent duplicates
916
- let waitCallbackRegistered = $state(false)
917
- let waitUnsubscribe: (() => void) | null = null
918
-
919
1156
  // Cleanup wait callback on component unmount to prevent memory leaks
920
1157
  $effect(() => {
921
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()
922
1165
  waitUnsubscribe?.()
923
1166
  waitUnsubscribe = null
924
1167
  }
925
1168
  })
926
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
+
927
1247
  /**
928
1248
  * Run the enter animation, respecting wait mode if inside AnimatePresence.
929
1249
  * Returns true if animation was deferred (wait mode with blocked enters).
@@ -949,12 +1269,21 @@
949
1269
  return true // Still deferred
950
1270
  }
951
1271
 
952
- const blocked = context.isEnterBlocked?.()
1272
+ const blocked = context.isEnterBlocked?.(presenceKey)
953
1273
  pwLog('[motion] runAnimation: wait mode', { blocked })
954
1274
 
955
1275
  if (blocked) {
956
1276
  pwLog('[motion] runAnimation: enters blocked, deferring')
957
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
+
958
1287
  waitCallbackRegistered = true
959
1288
 
960
1289
  // Register callback to run animation when unblocked
@@ -964,6 +1293,12 @@
964
1293
  waitUnsubscribe = null
965
1294
  waitCallbackRegistered = false
966
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
+
967
1302
  // Snap to initial state first (in case inline styles were removed)
968
1303
  if (initialKeyframes && element) {
969
1304
  const transformedInitial = transformSVGPathProperties(
@@ -995,6 +1330,11 @@
995
1330
  })
996
1331
  return true // Animation was deferred
997
1332
  }
1333
+
1334
+ if (waitHiddenDisplay !== null || waitEnterBlockedBeforeMount) {
1335
+ pwLog('[motion] runAnimation: wait mode no longer blocked, revealing')
1336
+ revealWaitHiddenElement()
1337
+ }
998
1338
  }
999
1339
 
1000
1340
  // Not blocked - run animation immediately
@@ -1018,6 +1358,7 @@
1018
1358
  let objectAnimateRanOnMount = $state(false)
1019
1359
  // Track the serialized animateProp to detect changes for object animate props
1020
1360
  let lastAnimatePropJson = $state<string | undefined>(undefined)
1361
+ let motionDomProjectionUpdatePending = false
1021
1362
  const currentAnimateKey = $derived(
1022
1363
  typeof animateProp === 'string'
1023
1364
  ? animateProp
@@ -1026,6 +1367,72 @@
1026
1367
  : undefined
1027
1368
  )
1028
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
+
1029
1436
  // Projection node lifecycle + the `onProjectionUpdate` listener.
1030
1437
  // Mount once the element binds; seed the baseline layout; unmount on
1031
1438
  // cleanup. Depends ONLY on `element` — the `onProjectionUpdate`
@@ -1053,56 +1460,104 @@
1053
1460
  }
1054
1461
  })
1055
1462
 
1056
- // Minimal layout animation using FLIP when `layout` is enabled.
1057
- // When layout === 'position' we only translate.
1058
- // When layout === true we also scale to smoothly interpolate size changes.
1059
- 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.
1060
1467
  $effect(() => {
1061
1468
  if (!(element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures)) return
1062
1469
 
1063
- // Initialize last rect on first ready frame. We measure through the
1064
- // projection node rather than `measureRect` directly so the rect is
1065
- // ancestor-transform-invariant: the node resets the whole ancestor
1066
- // chain to its mount-time base while reading, which strips any
1067
- // motion-applied (FLIP/drag) transform up the tree. Without this a
1068
- // child re-measures and FLIPs whenever an ancestor's transform
1069
- // animates, even though the child's own layout never moved. (#379)
1470
+ let rafId: number | null = null
1471
+ let wasViewportOffscreenSinceLastLayout = false
1472
+ const flipLayoutMode = layoutProp === 'position' ? 'position' : true
1473
+ motionDomProjection?.seedLayout()
1070
1474
  lastRect = measureLayoutRect()
1071
- // Hint compositor for smoother FLIP transforms
1072
1475
  setCompositorHints(element!, true)
1073
1476
 
1074
- let rafId: number | null = null
1075
- const runFlip = () => {
1076
- // commitLayoutChange does the ancestor-stripped measure, fans
1077
- // the delta out to `onProjectionUpdate` listeners, AND returns
1078
- // the freshly-measured box — so the FLIP reuses that single
1079
- // measurement as `next` instead of walking + transform-resetting
1080
- // 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
+
1081
1506
  const nextBox = projection.commitLayoutChange()
1082
- if (!nextBox) return
1083
- const next = boxToRectLike(nextBox)
1084
- 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)
1085
1511
  lastRect = next
1086
- return
1512
+ wasViewportOffscreenSinceLastLayout = false
1513
+ } else if (nextBox) {
1514
+ lastRect = boxToRectLike(nextBox)
1515
+ wasViewportOffscreenSinceLastLayout = false
1087
1516
  }
1088
- const transforms = computeFlipTransforms(lastRect, next, layoutProp ?? false)
1089
- 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
+
1090
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()
1091
1543
  }
1092
1544
 
1093
- const scheduleFlip = () => {
1094
- if (rafId) cancelAnimationFrame(rafId)
1545
+ const scheduleProjectionCommit = () => {
1546
+ if (rafId) return
1547
+ commitObservedLayout()
1095
1548
  rafId = requestAnimationFrame(() => {
1096
1549
  rafId = null
1097
- runFlip()
1098
1550
  })
1099
1551
  }
1100
- const disconnectObservers = observeLayoutChanges(element!, () => scheduleFlip())
1552
+ const disconnectObservers = observeLayoutChanges(element!, () => scheduleProjectionCommit())
1553
+ window.addEventListener('scroll', rememberOffscreenScroll, { passive: true })
1554
+ element!.addEventListener(presenceLayoutReleaseEvent, commitPresenceLayoutRelease)
1101
1555
 
1102
1556
  return () => {
1103
1557
  disconnectObservers()
1558
+ window.removeEventListener('scroll', rememberOffscreenScroll)
1559
+ element?.removeEventListener(presenceLayoutReleaseEvent, commitPresenceLayoutRelease)
1104
1560
  lastRect = null
1105
- // Reset compositor hints on teardown
1106
1561
  if (element) {
1107
1562
  setCompositorHints(element, false)
1108
1563
  }
@@ -1423,6 +1878,7 @@
1423
1878
 
1424
1879
  $effect(() => {
1425
1880
  if (!(element && isLoaded === 'mounting')) return
1881
+ markMotionMounted()
1426
1882
 
1427
1883
  pwLog('[motion] main effect running', {
1428
1884
  effectiveAnimate: !!effectiveAnimate,
@@ -1452,6 +1908,30 @@
1452
1908
  dataPath = 5
1453
1909
  isLoaded = 'ready'
1454
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
+ }
1455
1935
  pwLog('[motion] path: has initialKeyframes, will animate to target')
1456
1936
  // Apply initial instantly BEFORE exposing 'initial' state
1457
1937
  const transformedInitial = transformSVGPathProperties(
@@ -1572,15 +2052,23 @@
1572
2052
  {#if isVoidTag}
1573
2053
  {#if isSVGTag(String(tag))}
1574
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}
1575
2057
  {:else}
1576
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}
1577
2061
  {/if}
1578
2062
  {:else if isSVGTag(String(tag))}
1579
2063
  <svelte:element this={tag} bind:this={element} xmlns={SVG_NAMESPACE} {...derivedAttrs}>
1580
2064
  {@render children?.()}
1581
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}
1582
2068
  {:else}
1583
2069
  <svelte:element this={tag} bind:this={element} {...derivedAttrs}>
1584
2070
  {@render children?.()}
1585
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}
1586
2074
  {/if}