@octane-xplat/motion 0.0.0 → 0.7.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.
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { spring } from 'motion-dom'
3
+ import { runAnimation } from './engine'
4
+ import { MotionValue } from './value'
5
+ import { Controller } from './controller'
6
+ import type { Clock } from './clock-types'
7
+
8
+ function fakeClock() {
9
+ let time = 0,
10
+ id = 0
11
+
12
+ const callbacks = new Map<number, () => void>()
13
+ const clock: Clock = {
14
+ now: () => time,
15
+ request: (fn) => {
16
+ callbacks.set(++id, fn)
17
+ return id
18
+ },
19
+ cancel: (key) => {
20
+ callbacks.delete(key)
21
+ },
22
+ }
23
+
24
+ return {
25
+ clock,
26
+ advance(ms: number) {
27
+ time += ms
28
+ const pending = [...callbacks.values()]
29
+ callbacks.clear()
30
+ pending.forEach((fn) => fn())
31
+ },
32
+ count: () => callbacks.size,
33
+ }
34
+ }
35
+
36
+ describe('portable playback', () => {
37
+ it('uses elapsed time and reaches the exact tween endpoint after suspension', async () => {
38
+ const time = fakeClock()
39
+ let value = 0
40
+ const controls = runAnimation(
41
+ time.clock,
42
+ 0,
43
+ 100,
44
+ { duration: 1, delay: 0.1, ease: 'linear' },
45
+ (next) => {
46
+ value = next
47
+ },
48
+ )
49
+
50
+ time.advance(50)
51
+ expect(value).toBe(0)
52
+ time.advance(550)
53
+ expect(value).toBeCloseTo(50)
54
+ time.advance(10000)
55
+ expect(value).toBe(100)
56
+ expect(await controls.finished).toBe('finished')
57
+ expect(time.count()).toBe(0)
58
+ })
59
+
60
+ it('matches upstream spring samples at irregular frame intervals', () => {
61
+ const time = fakeClock()
62
+ let value = 0
63
+ const options = { stiffness: 180, damping: 16, mass: 2, velocity: 90 }
64
+ const reference = spring({ ...options, keyframes: [0, 100] })
65
+ runAnimation(time.clock, 0, 100, { ...options, type: 'spring' }, (next) => {
66
+ value = next
67
+ })
68
+
69
+ let elapsed = 0
70
+ for (const dt of [8, 16, 34, 5, 100, 10000]) {
71
+ elapsed += dt
72
+ time.advance(dt)
73
+ const expected = reference.next(elapsed)
74
+ expect(value).toBeCloseTo(expected.done ? 100 : expected.value)
75
+ }
76
+
77
+ expect(value).toBe(100)
78
+ })
79
+
80
+ it('settles cancellation once and never calls completion', async () => {
81
+ const time = fakeClock()
82
+ let completes = 0
83
+ const controls = runAnimation(
84
+ time.clock,
85
+ 0,
86
+ 1,
87
+ {},
88
+ () => {},
89
+ () => {
90
+ completes++
91
+ },
92
+ )
93
+
94
+ controls.stop()
95
+ controls.stop()
96
+ time.advance(1000)
97
+ expect(await controls.finished).toBe('cancelled')
98
+ expect(completes).toBe(0)
99
+ })
100
+
101
+ it('preserves velocity on retarget and jump cancels/reset velocity', async () => {
102
+ const time = fakeClock()
103
+ const value = new MotionValue(0, time.clock)
104
+ const first = value.animate(100, { type: 'spring' })
105
+ time.advance(16)
106
+ time.advance(16)
107
+ expect(value.getVelocity()).toBeGreaterThan(0)
108
+ const second = value.animate(0, { type: 'spring' })
109
+ expect(await first.finished).toBe('replaced')
110
+ value.jump(25)
111
+ expect(value.get()).toBe(25)
112
+ expect(value.getVelocity()).toBe(0)
113
+ expect(await second.finished).toBe('cancelled')
114
+ expect(time.count()).toBe(0)
115
+ })
116
+
117
+ it('cleans every channel on controller disposal', async () => {
118
+ const time = fakeClock()
119
+ const controller = new Controller(time.clock)
120
+ const result = controller.animate({ x: 40, opacity: 0 }, { duration: 1 }, false)
121
+ controller.destroy()
122
+ time.advance(1000)
123
+ expect(await result).toBe('replaced')
124
+ expect(time.count()).toBe(0)
125
+ })
126
+ })
package/src/engine.ts ADDED
@@ -0,0 +1,159 @@
1
+ import { keyframes, spring } from 'motion-dom'
2
+ import type { Clock } from './clock-types'
3
+ import type { Transition } from './types'
4
+
5
+ /** Terminal status; cancellation never masquerades as completion. */
6
+ export type AnimationResult = 'finished' | 'cancelled' | 'replaced'
7
+ /** A cancellable animation whose finished promise always settles. */
8
+ export interface AnimationControls {
9
+ finished: Promise<AnimationResult>
10
+ stop(reason?: Exclude<AnimationResult, 'finished'>): void
11
+ }
12
+
13
+ export function validateTransition(t: Transition): void {
14
+ if (t.type === 'spring' && (t.duration !== undefined || t.ease !== undefined)) {
15
+ throw new Error('motion: physical springs do not accept duration or ease')
16
+ }
17
+
18
+ const allowed = [
19
+ 'type',
20
+ 'duration',
21
+ 'delay',
22
+ 'ease',
23
+ 'stiffness',
24
+ 'damping',
25
+ 'mass',
26
+ 'velocity',
27
+ 'restSpeed',
28
+ 'restDelta',
29
+ ]
30
+
31
+ for (const key of Object.keys(t)) {
32
+ if (!allowed.includes(key)) {
33
+ throw new Error(`motion: unsupported transition ${key}`)
34
+ }
35
+ }
36
+
37
+ if (t.type !== undefined && t.type !== 'tween' && t.type !== 'spring') {
38
+ throw new Error('motion: unsupported transition type')
39
+ }
40
+
41
+ for (const key of [
42
+ 'duration',
43
+ 'delay',
44
+ 'stiffness',
45
+ 'damping',
46
+ 'mass',
47
+ 'velocity',
48
+ 'restSpeed',
49
+ 'restDelta',
50
+ ] as const) {
51
+ const value = t[key]
52
+ if (value !== undefined && (!Number.isFinite(value) || (key !== 'velocity' && value < 0))) {
53
+ throw new Error(`motion: invalid ${key}`)
54
+ }
55
+ }
56
+
57
+ for (const key of ['mass', 'stiffness', 'damping', 'restSpeed', 'restDelta'] as const) {
58
+ if (t[key] === 0) {
59
+ throw new Error(`motion: ${key} must be positive`)
60
+ }
61
+ }
62
+
63
+ if (t.ease !== undefined) {
64
+ if (Array.isArray(t.ease)) {
65
+ if (
66
+ t.ease.length !== 4 ||
67
+ !t.ease.every(Number.isFinite) ||
68
+ t.ease[0] < 0 ||
69
+ t.ease[0] > 1 ||
70
+ t.ease[2] < 0 ||
71
+ t.ease[2] > 1
72
+ ) {
73
+ throw new Error('motion: invalid cubic Bezier')
74
+ }
75
+ } else if (!['linear', 'easeIn', 'easeOut', 'easeInOut'].includes(t.ease)) {
76
+ throw new Error('motion: unsupported easing')
77
+ }
78
+ }
79
+ }
80
+
81
+ // Pure upstream generators share trajectories across platforms; host scheduling
82
+ // stays outside Motion's browser-global frame loop.
83
+ export function runAnimation(
84
+ clock: Clock,
85
+ from: number,
86
+ to: number,
87
+ transition: Transition,
88
+ update: (value: number) => void,
89
+ complete?: () => void,
90
+ ): AnimationControls {
91
+ validateTransition(transition)
92
+ let frame = 0
93
+ let settled = false
94
+ let resolve!: (result: AnimationResult) => void
95
+ const finished = new Promise<AnimationResult>((done) => {
96
+ resolve = done
97
+ })
98
+
99
+ const settle = (result: AnimationResult) => {
100
+ if (settled) {
101
+ return
102
+ }
103
+
104
+ settled = true
105
+ clock.cancel(frame)
106
+ resolve(result)
107
+ if (result === 'finished') {
108
+ complete?.()
109
+ }
110
+ }
111
+
112
+ const duration = transition.duration ?? 0.3
113
+ const generator =
114
+ transition.type === 'spring'
115
+ ? spring({
116
+ keyframes: [from, to],
117
+ stiffness: transition.stiffness ?? 100,
118
+ damping: transition.damping ?? 10,
119
+ mass: transition.mass ?? 1,
120
+ velocity: transition.velocity ?? 0,
121
+ restSpeed: transition.restSpeed,
122
+ restDelta: transition.restDelta,
123
+ })
124
+ : keyframes({
125
+ keyframes: [from, to],
126
+ duration: duration * 1000,
127
+ ease: transition.ease ?? 'easeInOut',
128
+ })
129
+
130
+ const start = clock.now() + (transition.delay ?? 0) * 1000
131
+ const tick = () => {
132
+ if (settled) {
133
+ return
134
+ }
135
+
136
+ const elapsed = Math.max(0, clock.now() - start)
137
+ if (clock.now() >= start) {
138
+ const state = generator.next(elapsed)
139
+ update(state.done ? to : state.value)
140
+ if (state.done) {
141
+ settle('finished')
142
+ return
143
+ }
144
+ }
145
+
146
+ if (!settled) {
147
+ frame = clock.request(tick)
148
+ }
149
+ }
150
+
151
+ if (transition.type !== 'spring' && duration === 0 && !transition.delay) {
152
+ update(to)
153
+ settle('finished')
154
+ } else {
155
+ frame = clock.request(tick)
156
+ }
157
+
158
+ return { finished, stop: (reason = 'cancelled') => settle(reason) }
159
+ }
package/src/hooks.tsrx ADDED
@@ -0,0 +1,56 @@
1
+ import { useRef, useLayoutEffect } from 'octane'
2
+ import { interpolate } from 'motion-dom'
3
+ import { clock } from './clock'
4
+ import { MotionValue, type MotionValueEvents } from './value'
5
+ import type { Transition } from './types'
6
+
7
+ /** Create an owned numeric value that writes to bound hosts without rendering. */
8
+ export function useMotionValue(initial: number): MotionValue {
9
+ const ref = useRef<MotionValue | null>(null)
10
+ if (!ref.current) ref.current = new MotionValue(initial, clock)
11
+ const value = ref.current
12
+ useLayoutEffect(() => () => value.destroy(), [])
13
+ return value
14
+ }
15
+
16
+ /** Subscribe to a value event; subscriptions follow value/callback replacement. */
17
+ export function useMotionValueEvent<K extends keyof MotionValueEvents>(value: MotionValue, event: K, callback: MotionValueEvents[K]): void {
18
+ useLayoutEffect(() => value.on(event, callback), [value, event, callback])
19
+ }
20
+
21
+ /** Derive a numeric value using a range map, unary transform, or multi-input transform. */
22
+ export function useTransform(source: MotionValue, input: number[], output: number[], options?: { clamp?: boolean }): MotionValue
23
+ export function useTransform(source: MotionValue, transform: (value: number) => number): MotionValue
24
+ export function useTransform(source: MotionValue[], transform: (values: number[]) => number): MotionValue
25
+ export function useTransform(source: MotionValue | MotionValue[], input: number[] | ((value: any) => number), output?: number[], options?: { clamp?: boolean }): MotionValue {
26
+ const sources = Array.isArray(source) ? source : [source]
27
+ if (Array.isArray(input) && (!output || input.length !== output.length || input.length < 2)) throw new Error('motion: transform ranges must have matching lengths >= 2')
28
+ const transform: (value: any) => number = typeof input === 'function' ? input : interpolate(input, output!, options)
29
+ const read = () => transform(Array.isArray(source) ? sources.map((value) => value.get()) : source.get())
30
+ const value = useMotionValue(read())
31
+ // A source array may be freshly allocated; compare its members, not its identity.
32
+ const previous = useRef<MotionValue[]>(sources)
33
+ if (previous.current.length !== sources.length || previous.current.some((value, i) => value !== sources[i])) previous.current = sources
34
+ useLayoutEffect(() => {
35
+ const update = () => value.set(read())
36
+ const off = sources.map((source) => source.on('change', update))
37
+ update()
38
+ return () => off.forEach((unsubscribe) => unsubscribe())
39
+ }, [previous.current, input, output, options?.clamp])
40
+
41
+ return value
42
+ }
43
+
44
+ /** A spring value: set animates; jump snaps. A source value is followed directly. */
45
+ export function useSpring(source: number | MotionValue, options: Omit<Transition, 'type' | 'duration' | 'ease' | 'delay'> = {}): MotionValue {
46
+ const value = useMotionValue(source instanceof MotionValue ? source.get() : source)
47
+ useLayoutEffect(() => {
48
+ const follow = (target: number) => { value.animate(target, { ...options, type: 'spring' }) }
49
+ const detach = value.attach(follow)
50
+ const unsubscribe = source instanceof MotionValue ? source.on('change', follow) : undefined
51
+ if (source instanceof MotionValue && source.get() !== value.get()) follow(source.get())
52
+ return () => { unsubscribe?.(); detach() }
53
+ }, [source instanceof MotionValue ? source : null, JSON.stringify(options)])
54
+
55
+ return value
56
+ }
@@ -0,0 +1,6 @@
1
+ import type { Target } from './types'
2
+ export interface HostAdapter {
3
+ read(): Target
4
+ write(values: Target): void
5
+ restore(): void
6
+ }
package/src/host.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { HostAdapter } from './host-types'
2
+ // The retained NativeScript View exposes direct transform channels in DIP.
3
+ export function attachHost(node: any): HostAdapter {
4
+ const original = {
5
+ opacity: node.opacity ?? 1,
6
+ x: node.translateX ?? 0,
7
+ y: node.translateY ?? 0,
8
+ scaleX: node.scaleX ?? 1,
9
+ scaleY: node.scaleY ?? 1,
10
+ rotate: node.rotate ?? 0,
11
+ }
12
+
13
+ if (
14
+ original.x ||
15
+ original.y ||
16
+ original.rotate ||
17
+ original.scaleX !== 1 ||
18
+ original.scaleY !== 1
19
+ ) {
20
+ throw new Error('motion: put existing transforms on an outer container')
21
+ }
22
+
23
+ const write = (values: any) => {
24
+ if (values.opacity !== undefined) {
25
+ node.opacity = Math.min(1, Math.max(0, values.opacity))
26
+ }
27
+
28
+ if (values.x !== undefined) {
29
+ node.translateX = values.x
30
+ }
31
+
32
+ if (values.y !== undefined) {
33
+ node.translateY = values.y
34
+ }
35
+
36
+ if (values.rotate !== undefined) {
37
+ node.rotate = values.rotate
38
+ }
39
+
40
+ if (values.scale !== undefined || values.scaleX !== undefined) {
41
+ node.scaleX = (values.scale ?? 1) * (values.scaleX ?? 1)
42
+ }
43
+
44
+ if (values.scale !== undefined || values.scaleY !== undefined) {
45
+ node.scaleY = (values.scale ?? 1) * (values.scaleY ?? 1)
46
+ }
47
+ }
48
+
49
+ return { read: () => ({ ...original, scale: 1 }), write, restore: () => write(original) }
50
+ }
@@ -0,0 +1,34 @@
1
+ import type { HostAdapter } from './host-types'
2
+ export function attachHost(node: HTMLElement): HostAdapter {
3
+ const transform = node.style.transform
4
+ const opacity = node.style.opacity
5
+ const computed = getComputedStyle(node)
6
+ if (computed.transform && computed.transform !== 'none') {
7
+ throw new Error('motion: put existing CSS transforms on an outer container')
8
+ }
9
+
10
+ return {
11
+ read: () => ({
12
+ opacity: Number(computed.opacity || 1),
13
+ x: 0,
14
+ y: 0,
15
+ scale: 1,
16
+ scaleX: 1,
17
+ scaleY: 1,
18
+ rotate: 0,
19
+ }),
20
+ write(values) {
21
+ if (values.opacity !== undefined) {
22
+ node.style.opacity = String(Math.min(1, Math.max(0, values.opacity)))
23
+ }
24
+
25
+ if (Object.keys(values).some((key) => key !== 'opacity')) {
26
+ node.style.transform = `translateX(${values.x ?? 0}px) translateY(${values.y ?? 0}px) scale(${values.scale ?? 1}) scaleX(${values.scaleX ?? 1}) scaleY(${values.scaleY ?? 1}) rotate(${values.rotate ?? 0}deg)`
27
+ }
28
+ },
29
+ restore() {
30
+ node.style.transform = transform
31
+ node.style.opacity = opacity
32
+ },
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { MotionView, MotionRow, MotionPressable } from './components.tsrx'
2
+ /** Bounded motion hosts for shared xplat UI primitives. */
3
+ export const motion = { View: MotionView, Row: MotionRow, Pressable: MotionPressable }
4
+ export { MotionConfig, useReducedMotion } from './config.tsrx'
5
+ export { useMotionValue, useTransform, useSpring, useMotionValueEvent } from './hooks.tsrx'
6
+ export type { MotionValue, MotionValueEvents } from './value'
7
+ export type { AnimationControls, AnimationResult } from './engine'
8
+ export type * from './types'
9
+ export { Presence } from './presence.tsrx'
@@ -0,0 +1,9 @@
1
+ import { MotionView, MotionRow, MotionPressable } from './components.tsrx'
2
+ /** Bounded motion hosts for shared xplat UI primitives. */
3
+ export const motion = { View: MotionView, Row: MotionRow, Pressable: MotionPressable }
4
+ export { MotionConfig, useReducedMotion } from './config.tsrx'
5
+ export { useMotionValue, useTransform, useSpring, useMotionValueEvent } from './hooks.tsrx'
6
+ export type { MotionValue, MotionValueEvents } from './value'
7
+ export type { AnimationControls, AnimationResult } from './engine'
8
+ export type * from './types'
9
+ export { Presence } from './presence.tsrx'
@@ -0,0 +1,26 @@
1
+ export function blockInput(node: any): () => void {
2
+ const enabled = node.isUserInteractionEnabled
3
+ const iosHidden = node.ios?.accessibilityElementsHidden
4
+ const androidImportance = node.android?.getImportantForAccessibility?.()
5
+ node.ios?.endEditing?.(true)
6
+ const focused = node.android?.findFocus?.()
7
+ if (focused) {
8
+ const platform = globalThis as any
9
+ const context = focused.getContext()
10
+ context
11
+ .getSystemService(platform.android.content.Context.INPUT_METHOD_SERVICE)
12
+ ?.hideSoftInputFromWindow(focused.getWindowToken(), 0)
13
+
14
+ focused.clearFocus()
15
+ }
16
+
17
+ node.isUserInteractionEnabled = false
18
+ if (node.ios) {node.ios.accessibilityElementsHidden = true}
19
+ node.android?.setImportantForAccessibility?.(4) // IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
20
+ return () => {
21
+ node.isUserInteractionEnabled = enabled
22
+ if (node.ios) {node.ios.accessibilityElementsHidden = iosHidden}
23
+ if (androidImportance !== undefined)
24
+ {node.android?.setImportantForAccessibility?.(androidImportance)}
25
+ }
26
+ }
@@ -0,0 +1,17 @@
1
+ export function blockInput(node: HTMLElement): () => void {
2
+ const inert = node.hasAttribute('inert')
3
+ const hidden = node.getAttribute('aria-hidden')
4
+ const pointerEvents = node.style.pointerEvents
5
+ const focused = node.ownerDocument.activeElement as HTMLElement | null
6
+ if (focused && node.contains(focused)) {focused.blur()}
7
+ node.setAttribute('inert', '')
8
+ node.setAttribute('aria-hidden', 'true')
9
+ node.style.pointerEvents = 'none'
10
+ return () => {
11
+ if (!inert) {node.removeAttribute('inert')}
12
+ if (hidden === null) {node.removeAttribute('aria-hidden')}
13
+ else {node.setAttribute('aria-hidden', hidden)}
14
+
15
+ node.style.pointerEvents = pointerEvents
16
+ }
17
+ }
@@ -0,0 +1,64 @@
1
+ import { expect, it, vi } from 'vitest'
2
+ import { PresenceState } from './presence-state'
3
+ import type { AnimationResult } from './engine'
4
+ const deferred = () => {
5
+ let resolve!: (result: AnimationResult) => void
6
+ const promise = new Promise<AnimationResult>((done) => {
7
+ resolve = done
8
+ })
9
+
10
+ return { promise, resolve }
11
+ }
12
+
13
+ const drain = async () => {
14
+ await Promise.resolve()
15
+ await Promise.resolve()
16
+ }
17
+
18
+ it('waits for every registered child and ignores replaced exit completions', async () => {
19
+ const done = vi.fn(),
20
+ state = new PresenceState(true, done)
21
+
22
+ const a = deferred(),
23
+ b = deferred(),
24
+ replacement = deferred()
25
+
26
+ const memberA = { exit: vi.fn(() => a.promise), enter: vi.fn() }
27
+ state.register(memberA)
28
+ state.register({ exit: () => b.promise, enter() {} })
29
+ state.setPresent(false)
30
+ memberA.exit.mockReturnValue(replacement.promise)
31
+ state.restart(memberA)
32
+ a.resolve('finished')
33
+ b.resolve('finished')
34
+ await drain()
35
+ expect(done).not.toHaveBeenCalled()
36
+ replacement.resolve('finished')
37
+ await drain()
38
+ expect(done).toHaveBeenCalledTimes(1)
39
+ })
40
+
41
+ it('unregisters removed children and completes an empty boundary once', async () => {
42
+ const done = vi.fn(),
43
+ state = new PresenceState(true, done)
44
+
45
+ const pending = deferred()
46
+ const off = state.register({ exit: () => pending.promise, enter() {} })
47
+ state.setPresent(false)
48
+ off()
49
+ await drain()
50
+ expect(done).toHaveBeenCalledTimes(1)
51
+ pending.resolve('finished')
52
+ await drain()
53
+ expect(done).toHaveBeenCalledTimes(1)
54
+ })
55
+
56
+ it('a destroyed boundary never invokes completion', async () => {
57
+ const done = vi.fn(),
58
+ state = new PresenceState(true, done)
59
+
60
+ state.setPresent(false)
61
+ state.destroy()
62
+ await drain()
63
+ expect(done).not.toHaveBeenCalled()
64
+ })
@@ -0,0 +1,76 @@
1
+ import type { AnimationResult } from './engine'
2
+
3
+ export interface PresenceMember {
4
+ exit(): Promise<AnimationResult>
5
+ enter(): void
6
+ }
7
+
8
+ // Membership is explicit: replacing an exit invalidates its completion token.
9
+ // Microtask completion lets every child register before an empty/instant exit ends.
10
+ export class PresenceState {
11
+ private members = new Set<PresenceMember>()
12
+ private pending = new Map<PresenceMember, number>()
13
+ private sequence = 0
14
+ private generation = 0
15
+ private disposed = false
16
+ private removed = false
17
+ constructor(
18
+ public present: boolean,
19
+ private complete: () => void,
20
+ ) {}
21
+ register(member: PresenceMember): () => void {
22
+ this.members.add(member)
23
+ if (!this.present) {this.restart(member)}
24
+ return () => {
25
+ this.members.delete(member)
26
+ this.pending.delete(member)
27
+ this.check()
28
+ }
29
+ }
30
+ setPresent(present: boolean) {
31
+ if (this.disposed || present === this.present) {return}
32
+ this.present = present
33
+ ++this.generation
34
+ this.removed = false
35
+ this.pending.clear()
36
+ if (present) {
37
+ for (const member of this.members) {member.enter()}
38
+ } else {
39
+ for (const member of this.members) {this.restart(member)}
40
+ this.check()
41
+ }
42
+ }
43
+ restart(member: PresenceMember) {
44
+ if (this.disposed || this.present || !this.members.has(member)) {return}
45
+ const token = ++this.sequence
46
+ this.pending.set(member, token)
47
+ void member.exit().then((result) => {
48
+ if (this.disposed || this.present || this.pending.get(member) !== token) {return}
49
+ // Replacement is owned by the next restart. Cancellation cannot remove a live subtree.
50
+ if (result !== 'finished') {return}
51
+ this.pending.delete(member)
52
+ this.check()
53
+ })
54
+ }
55
+ private check() {
56
+ const generation = this.generation
57
+ queueMicrotask(() => {
58
+ if (
59
+ !this.disposed &&
60
+ !this.present &&
61
+ !this.removed &&
62
+ !this.pending.size &&
63
+ generation === this.generation
64
+ ) {
65
+ this.removed = true
66
+ this.complete()
67
+ }
68
+ })
69
+ }
70
+ destroy() {
71
+ this.disposed = true
72
+ ++this.generation
73
+ this.members.clear()
74
+ this.pending.clear()
75
+ }
76
+ }
@@ -0,0 +1,52 @@
1
+ import { createContext, useRef, useLayoutEffect, useState } from 'octane'
2
+ import { View } from '@octane-xplat/ui'
3
+ import type { PresenceProps } from './types'
4
+ import { PresenceState } from './presence-state'
5
+ import { blockInput } from './presence-input'
6
+
7
+ export const PresenceContext = createContext<PresenceState | null>(null)
8
+
9
+ /** Retain live children until their exits finish. Keep this boundary mounted. */
10
+ export function Presence(props: PresenceProps) @{
11
+ const [retained, setRetained] = useState(props.present)
12
+ const latest = useRef(props)
13
+ latest.current = props
14
+ const ref = useRef<PresenceState | null>(null)
15
+ if (!ref.current) ref.current = new PresenceState(props.present, () => {
16
+ setRetained(false)
17
+ latest.current.onExitComplete?.()
18
+ })
19
+
20
+ const state = ref.current
21
+ const host = useRef<any>(null)
22
+ const restoreInput = useRef<(() => void) | null>(null)
23
+ const syncInput = () => {
24
+ if (!props.present && host.current && !restoreInput.current) restoreInput.current = blockInput(host.current)
25
+ if (props.present && restoreInput.current) { restoreInput.current(); restoreInput.current = null }
26
+ }
27
+
28
+ useLayoutEffect(() => {
29
+ if (props.present) setRetained(true)
30
+ syncInput()
31
+ state.setPresent(props.present)
32
+ }, [props.present])
33
+
34
+ useLayoutEffect(() => () => {
35
+ state.destroy()
36
+ restoreInput.current?.()
37
+ restoreInput.current = null
38
+ host.current = null
39
+ }, [])
40
+
41
+ const { present, onExitComplete, children, bind, ...viewProps } = props
42
+
43
+ @if (props.present || retained) {
44
+ <View {...viewProps} bind={(node: any) => {
45
+ if (host.current !== node) { restoreInput.current?.(); restoreInput.current = null; host.current = node }
46
+ syncInput()
47
+ bind?.(node)
48
+ }}>
49
+ <PresenceContext value={state}>{children}</PresenceContext>
50
+ </View>
51
+ }
52
+ }