@ssgoi/react-native 7.1.0-beta.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MeurSyphus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @ssgoi/react-native
2
+
3
+ Experimental SSGOI screen transitions for React Native. `fade` and `slide` share the web route matcher, physics integrators, timeline simulation, and motion geometry. Reanimated plays the numerical timelines on the UI thread.
4
+
5
+ This is a source preview of the first milestone in [#402](https://github.com/meursyphus/ssgoi/issues/402). Start with [`templates/expo`](../../templates/expo) from this checkout; this PR does not publish a package or bump versions.
6
+
7
+ ## Expo Router
8
+
9
+ The example pins Expo SDK 56.0.21, Expo Router 56.2.20, React Native 0.85.3, React 19.2.3, Reanimated 4.3.1, and Worklets 0.8.3. Expo's standard navigator API is alpha; newer SDKs need separate validation. The Expo peer range is optional and broad so an unused integration does not force router upgrades.
10
+
11
+ ```tsx
12
+ // app/_layout.tsx
13
+ import { Ssgoi, type SsgoiConfig } from "@ssgoi/react-native";
14
+ import { SsgoiRouteBoundary } from "@ssgoi/react-native/expo-router";
15
+ import { slide } from "@ssgoi/react-native/view-transitions";
16
+
17
+ const config = {
18
+ transitions: [{ from: "/posts", to: "/posts/*", transition: slide() }],
19
+ } satisfies SsgoiConfig;
20
+
21
+ export default function Layout() {
22
+ return (
23
+ <Ssgoi config={config}>
24
+ <SsgoiRouteBoundary />
25
+ </Ssgoi>
26
+ );
27
+ }
28
+ ```
29
+
30
+ Keep page files such as `app/posts/index.tsx` and `app/posts/[id].tsx`, and use `Link`, `router.push()`, `router.replace()`, and `router.back()` from Expo Router normally. The boundary discovers screens from files. It replaces the navigator in that layout; do not wrap an existing `<Stack>` or `<Slot>` inside it. Supply safe-area handling in your app.
31
+
32
+ `Ssgoi` provides config and selects the native platform. `SsgoiRouteBoundary` has the same name as `@ssgoi/react/nextjs`, but owns an internal Expo navigator and supplies a boundary for each screen. Popped/replaced screens keep their actual React instance and route context until completion. Routes still in the stack remain mounted and retain input and scroll state.
33
+
34
+ ## Configuration
35
+
36
+ - `transitions`: `on`/`except`, `from`/`to`, `ordered`, `priority`, and `bidirectional` have the same matching semantics as the web.
37
+ - Functional rules receive `{ isMobile }`, based on the host width below 768 logical units.
38
+ - `middleware(from, to)` rewrites matching paths without changing screen instance identity.
39
+ - `fade({ physics })` fades out then in; `slide({ physics })` moves both screens together. Physics are computed in JS once and sent as numeric data to the UI runtime.
40
+ - `<Ssgoi reducedMotion="system" | "always" | "never">` defaults to the system setting. Initial preference loading also respects reduced motion.
41
+ - `onTransitionError(error)` reports failed animation preparation. The latest screen is revealed and outgoing resources are released even when preparation fails.
42
+
43
+ Web `TransitionConfig`/`WebAnimation` objects cannot be passed to native config. Explicit `preserveScroll` restoration is rejected; mounted screens retain their native scroll state naturally. There is no automatic restoration for a removed/recreated screen yet.
44
+
45
+ ## Boundary options
46
+
47
+ Basic usage needs no props. `initialRouteName` selects the initial file-discovered screen, and `style` styles the native host.
48
+
49
+ `resolve({ pathname, route })` returns `{ id, key? }`, matching the naming used by the web boundary. It receives each screen's own route, including its params and instance key, rather than the currently focused global pathname. `id` is an absolute matching path. `routeKey` overrides the optional resolved key. Keys are scoped by native route instance: a constant `routeKey` never merges two pushed screens or keeps a popped screen alive after exit. Changing the resolved key recreates that route's boundary content; navigation animations follow changes in the active native route key.
50
+
51
+ ## Current scope
52
+
53
+ The first integration targets one Expo stack on iOS/Android without native headers or modal presentation. It supports push, pop, replace, direct entry, rapid-navigation cleanup, reduced motion, accessibility/touch exclusion of outgoing screens, and cleanup on host resizing or app backgrounding. Preloaded routes are rendered when they become active. A bounded watchdog settles screens that cannot finish layout or playback.
54
+
55
+ Interactive swipe, predictive-back progress, shared elements, nested-navigator coordination, explicit scroll restoration, Expo Web, and public pause/reverse/debug-host controls are follow-up work. The native header, gesture, and screen options of Expo's built-in Stack are not exposed by this boundary.
56
+
57
+ ## Validation
58
+
59
+ The shared runtime uses `TransitionView<TTarget, TPayload>`, `Pose<TTarget>`,
60
+ `Timeline<TTarget, TStyle>`, and `Animation<TTarget, TStyle>`. Targets are opaque
61
+ to core. Web imports retain `HTMLElement` as their default; RN binds each
62
+ transition view to a stable `NativeSurfaceHandle` with a real native View ref.
63
+ Refs stay on the RN thread while numerical plans cross to the UI runtime.
64
+ Native playback uses completion callbacks rather than pretending that the
65
+ web's synchronous pose getter can read current UI-thread state.
66
+
67
+ ```sh
68
+ pnpm --filter @ssgoi/core build
69
+ pnpm --filter @ssgoi/react-native build
70
+ pnpm --filter @ssgoi/react-native test:run
71
+ SSGOI_TEST_PLATFORM=android pnpm --filter @ssgoi/react-native test:run
72
+ node scripts/check-native-isolation.mjs
73
+ pnpm --filter ssgoi-expo-template typecheck
74
+ pnpm --filter ssgoi-expo-template exec expo export --platform ios --platform android
75
+ ```
76
+
77
+ The integration tests use the real Expo Router with native module mocks and a controlled frame clock. They verify React lifetime and routing behavior, not native pixels, UI-thread performance, or physical back gestures. Device/simulator checks remain necessary before treating this preview as production-ready.
@@ -0,0 +1,8 @@
1
+ import { type BoundaryOptions } from "./navigator.js";
2
+ export interface SsgoiRouteBoundaryProps extends BoundaryOptions {
3
+ initialRouteName?: string;
4
+ }
5
+ /** Place in app/_layout.tsx. Expo discovers page files; no manual Screen list is required. */
6
+ export declare function SsgoiRouteBoundary(props: SsgoiRouteBoundaryProps): import("react/jsx-runtime").JSX.Element;
7
+ export type { ExpoRouteLocation } from "./navigator.js";
8
+ export type { RouteBoundaryState } from "./types.js";
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createStandardNavigator } from "standard-navigation";
3
+ import { StackRouter, unstable_integrateWithRouter } from "expo-router";
4
+ import { NativeNavigator, } from "./navigator.js";
5
+ const navigator = createStandardNavigator(NativeNavigator);
6
+ // Keep the alpha Expo API confined to this optional entry point.
7
+ if (typeof unstable_integrateWithRouter !== "function") {
8
+ throw new Error("SSGOI: the expo-router entry requires Expo Router's standard navigator API (validated with 56.2.20).");
9
+ }
10
+ const ExpoBoundary = unstable_integrateWithRouter(navigator, StackRouter, { useOnlyUserDefinedScreens: false });
11
+ /** Place in app/_layout.tsx. Expo discovers page files; no manual Screen list is required. */
12
+ export function SsgoiRouteBoundary(props) {
13
+ return _jsx(ExpoBoundary, { ...props });
14
+ }
@@ -0,0 +1,3 @@
1
+ export { Ssgoi } from "./ssgoi.js";
2
+ export type { SsgoiProps } from "./ssgoi.js";
3
+ export type * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { Ssgoi } from "./ssgoi.js";
@@ -0,0 +1,6 @@
1
+ import { type NavigationDirection, type PageMotionPlan } from "@ssgoi/core/runtime";
2
+ import type { SsgoiConfig } from "./types.js";
3
+ /** Selected internally by the native provider; the application never assembles drivers. */
4
+ export declare const nativePlatform: {
5
+ prepare(config: SsgoiConfig, from: string, to: string, direction: NavigationDirection, width: number): PageMotionPlan | null;
6
+ };
@@ -0,0 +1,22 @@
1
+ import { createPageMotionPlan, resolveTransitionRule, } from "@ssgoi/core/runtime";
2
+ /** Selected internally by the native provider; the application never assembles drivers. */
3
+ export const nativePlatform = {
4
+ prepare(config, from, to, direction, width) {
5
+ const paths = config.middleware?.(from, to) ?? { from, to };
6
+ const rules = typeof config.transitions === "function"
7
+ ? config.transitions({ isMobile: width > 0 && width < 768 })
8
+ : (config.transitions ?? []);
9
+ if (rules.some((rule) => rule.preserveScroll !== undefined)) {
10
+ throw new Error("SSGOI: explicit preserveScroll restoration is not supported on React Native yet.");
11
+ }
12
+ const match = resolveTransitionRule(paths.from, paths.to, rules, direction);
13
+ if (!match)
14
+ return null;
15
+ const transition = match.transition;
16
+ if (transition?.platform !== "ssgoi-native" ||
17
+ (transition.kind !== "fade" && transition.kind !== "slide")) {
18
+ throw new Error("SSGOI: use fade/slide from @ssgoi/react-native/view-transitions in native config.");
19
+ }
20
+ return createPageMotionPlan(transition.kind, match.direction, transition.physics);
21
+ },
22
+ };
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from "react";
2
+ import { type LayoutChangeEvent } from "react-native";
3
+ import { type SharedValue } from "react-native-reanimated";
4
+ import { type PageMotionPlan } from "@ssgoi/core/runtime";
5
+ import type { PlaybackState } from "./playback.js";
6
+ import type { NativeSurfaceHandle } from "./native-surface-handle.js";
7
+ export interface NativeRouteSurfaceProps {
8
+ target: NativeSurfaceHandle;
9
+ children: ReactNode;
10
+ active: boolean;
11
+ side: "in" | "out" | null;
12
+ id: number;
13
+ width: number;
14
+ plan: PageMotionPlan | null;
15
+ playback: SharedValue<PlaybackState>;
16
+ onLayout: (event: LayoutChangeEvent) => void;
17
+ }
18
+ export declare function NativeRouteSurface({ target, children, active, side, id, width, plan, playback, onLayout, }: NativeRouteSurfaceProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { StyleSheet } from "react-native";
3
+ import Animated, { useAnimatedStyle, } from "react-native-reanimated";
4
+ import { interpolateFrame, pageMotionStyle, } from "@ssgoi/core/runtime";
5
+ export function NativeRouteSurface({ target, children, active, side, id, width, plan, playback, onLayout, }) {
6
+ const animatedStyle = useAnimatedStyle(() => {
7
+ if (!side || !plan)
8
+ return { opacity: active ? 1 : 0, transform: [{ translateX: 0 }] };
9
+ const clock = playback.value;
10
+ // New screens get their starting pose even before the layout effect installs the clock.
11
+ const elapsed = clock.id === id ? clock.elapsed : 0;
12
+ const track = side === "in" ? plan.in : plan.out;
13
+ const progress = elapsed >= track.offset + track.duration
14
+ ? 1
15
+ : interpolateFrame(track.frames, Math.max(0, elapsed - track.offset))
16
+ .position;
17
+ const pose = pageMotionStyle(plan.kind, side, plan.direction, progress);
18
+ return {
19
+ opacity: Math.max(0, Math.min(1, pose.opacity)),
20
+ transform: [{ translateX: pose.x * width }],
21
+ };
22
+ }, [active, side, id, width, plan]);
23
+ const visible = active || (side !== null && plan !== null);
24
+ return (_jsx(Animated.View, { ref: target.ref, collapsable: false, onLayout: onLayout, pointerEvents: active ? "auto" : "none", accessibilityElementsHidden: !active, importantForAccessibility: active ? "auto" : "no-hide-descendants", style: [
25
+ StyleSheet.absoluteFill,
26
+ { display: visible ? "flex" : "none", zIndex: side === "in" ? 1 : 0 },
27
+ animatedStyle,
28
+ ], children: children }));
29
+ }
@@ -0,0 +1,7 @@
1
+ import type { RefObject } from "react";
2
+ import type { View } from "react-native";
3
+ /** Owned by React on the RN thread. Never serialize this ref into a UI worklet. */
4
+ export interface NativeSurfaceHandle {
5
+ readonly key: string;
6
+ readonly ref: RefObject<View | null>;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { type StyleProp, type ViewStyle } from "react-native";
2
+ import type { NavigatorArgs, NavigatorRoute } from "standard-navigation";
3
+ import type { RouteBoundaryState } from "./types.js";
4
+ export interface ExpoRouteLocation {
5
+ pathname: string;
6
+ route: Readonly<NavigatorRoute>;
7
+ }
8
+ export interface BoundaryOptions {
9
+ style?: StyleProp<ViewStyle>;
10
+ /** Evaluated against each screen's own route, including retained screens. */
11
+ resolve?: (location: ExpoRouteLocation) => RouteBoundaryState;
12
+ /** Scoped by the native route key; never merges distinct pushed screen instances. */
13
+ routeKey?: string | number;
14
+ }
15
+ export type NavigatorEvents = {
16
+ transitionStart: {
17
+ data: {
18
+ closing: boolean;
19
+ };
20
+ canPreventDefault: false;
21
+ };
22
+ transitionEnd: {
23
+ data: {
24
+ closing: boolean;
25
+ };
26
+ canPreventDefault: false;
27
+ };
28
+ };
29
+ export type ScreenOptions = Record<string, never>;
30
+ type ContentProps = NavigatorArgs<ScreenOptions, NavigatorEvents> & BoundaryOptions;
31
+ /** The Expo subpath supplies router descriptors; native rendering remains independent of Expo. */
32
+ export declare function NativeNavigator({ state, descriptors, emitter, style, resolve, routeKey, }: ContentProps): import("react/jsx-runtime").JSX.Element;
33
+ export {};
@@ -0,0 +1,187 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createRef, useCallback, useEffect, useId, useLayoutEffect, useMemo, useState, } from "react";
3
+ import { AppState, View, useWindowDimensions, } from "react-native";
4
+ import { normalizePath } from "@ssgoi/core/runtime";
5
+ import { createPresence, reconcilePresence, settlePresence, } from "./presence.js";
6
+ import { NativeRouteSurface } from "./native-route-surface.js";
7
+ import { useNativePlayback } from "./playback.js";
8
+ import { useSsgoi } from "./ssgoi.js";
9
+ function screenFor(route, descriptor, namespace, options, target) {
10
+ const pathname = route.href?.startsWith("/")
11
+ ? normalizePath(route.href)
12
+ : null;
13
+ const resolved = pathname === null
14
+ ? null
15
+ : (options.resolve?.({ pathname, route }) ?? { id: pathname });
16
+ if (resolved &&
17
+ (typeof resolved.id !== "string" || !resolved.id.startsWith("/"))) {
18
+ throw new Error("SSGOI: resolve must return an absolute route id such as /posts/42.");
19
+ }
20
+ return {
21
+ key: route.key,
22
+ path: resolved?.id ?? null,
23
+ target: target ?? {
24
+ key: JSON.stringify([namespace, route.key]),
25
+ ref: createRef(),
26
+ },
27
+ value: {
28
+ descriptor,
29
+ boundaryKey: JSON.stringify([
30
+ namespace,
31
+ route.key,
32
+ options.routeKey ?? resolved?.key ?? route.key,
33
+ ]),
34
+ },
35
+ };
36
+ }
37
+ /** The Expo subpath supplies router descriptors; native rendering remains independent of Expo. */
38
+ export function NativeNavigator({ state, descriptors, emitter, style, resolve, routeKey, }) {
39
+ const ssgoi = useSsgoi();
40
+ const namespace = useId();
41
+ const window = useWindowDimensions();
42
+ const [layout, setLayout] = useState({ width: 0, height: 0 });
43
+ const [readyKeys, setReadyKeys] = useState(() => new Set());
44
+ const activeKey = state.routes[state.index]?.key ?? null;
45
+ const [snapshot, setSnapshot] = useState(() => ({
46
+ navigation: null,
47
+ resolve,
48
+ routeKey,
49
+ presence: createPresence([], null),
50
+ }));
51
+ const targets = new Map(snapshot.presence.entries.map((screen) => [screen.key, screen.target]));
52
+ // Standard navigation appends preloaded routes after index. They are not active stack entries.
53
+ const routes = state.routes.slice(0, state.index + 1);
54
+ const screens = routes.map((route) => {
55
+ const descriptor = descriptors[route.key];
56
+ if (!descriptor)
57
+ throw new Error(`SSGOI: missing route descriptor for ${route.name}.`);
58
+ return screenFor(route, descriptor, namespace, { resolve, routeKey }, targets.get(route.key));
59
+ });
60
+ const liveScenes = new Map(screens.map((screen) => [screen.key, screen.value]));
61
+ let current = snapshot;
62
+ if (snapshot.navigation !== state ||
63
+ snapshot.resolve !== resolve ||
64
+ snapshot.routeKey !== routeKey) {
65
+ current = {
66
+ navigation: state,
67
+ resolve,
68
+ routeKey,
69
+ presence: reconcilePresence(snapshot.presence, screens, activeKey),
70
+ };
71
+ // Derive retained siblings before commit: effect cleanup would already be too late.
72
+ setSnapshot(current);
73
+ }
74
+ const presence = current.presence;
75
+ const transition = presence.transition;
76
+ const id = transition?.id ?? presence.generation;
77
+ const width = layout.width || window.width;
78
+ const prepared = useMemo(() => {
79
+ if (!transition?.from.path || !transition.to.path || ssgoi.reducedMotion)
80
+ return { plan: null, error: null };
81
+ try {
82
+ return {
83
+ plan: ssgoi.platform.prepare(ssgoi.config, transition.from.path, transition.to.path, transition.direction, width),
84
+ error: null,
85
+ };
86
+ }
87
+ catch (error) {
88
+ return {
89
+ plan: null,
90
+ error: error instanceof Error ? error : new Error(String(error)),
91
+ };
92
+ }
93
+ }, [transition, ssgoi.config, ssgoi.platform, ssgoi.reducedMotion, width]);
94
+ const finish = useCallback((generation) => {
95
+ setSnapshot((previous) => {
96
+ const next = settlePresence(previous.presence, generation);
97
+ return next === previous.presence
98
+ ? previous
99
+ : { ...previous, presence: next };
100
+ });
101
+ }, []);
102
+ const ready = layout.width > 0 &&
103
+ layout.height > 0 &&
104
+ !!transition &&
105
+ readyKeys.has(transition.to.key);
106
+ const playback = useNativePlayback(id, transition ? prepared.plan : null, ready, finish);
107
+ useLayoutEffect(() => {
108
+ if (!transition)
109
+ return;
110
+ if (!prepared.plan) {
111
+ finish(transition.id);
112
+ if (prepared.error)
113
+ ssgoi.onTransitionError?.(prepared.error);
114
+ }
115
+ }, [transition, prepared, finish, ssgoi.onTransitionError]);
116
+ useEffect(() => {
117
+ if (!transition)
118
+ return;
119
+ // Bounds preparation when a screen remains suspended or cannot be measured.
120
+ const timeout = setTimeout(() => finish(transition.id), 1500 + (prepared.plan?.duration ?? 0));
121
+ const subscription = AppState.addEventListener("change", (next) => {
122
+ if (next === "background" || next === "inactive")
123
+ finish(transition.id);
124
+ });
125
+ if (AppState.currentState === "background" ||
126
+ AppState.currentState === "inactive")
127
+ finish(transition.id);
128
+ return () => {
129
+ clearTimeout(timeout);
130
+ subscription.remove();
131
+ };
132
+ }, [transition, prepared.plan, finish]);
133
+ useEffect(() => {
134
+ if (!transition || !prepared.plan || !ready)
135
+ return;
136
+ const pair = [transition.from.key, transition.to.key];
137
+ for (const key of pair)
138
+ emitter.emit({
139
+ type: "transitionStart",
140
+ target: key,
141
+ data: { closing: key === transition.from.key },
142
+ });
143
+ return () => {
144
+ for (const key of pair)
145
+ emitter.emit({
146
+ type: "transitionEnd",
147
+ target: key,
148
+ data: { closing: key === transition.from.key },
149
+ });
150
+ };
151
+ }, [transition, prepared.plan, ready, emitter]);
152
+ useEffect(() => {
153
+ const keys = new Set(presence.entries.map((s) => s.key));
154
+ setReadyKeys((previous) => [...previous].every((key) => keys.has(key))
155
+ ? previous
156
+ : new Set([...previous].filter((key) => keys.has(key))));
157
+ }, [presence.entries]);
158
+ const onLayout = (event) => {
159
+ const next = event.nativeEvent.layout;
160
+ if (layout.width > 0 &&
161
+ layout.height > 0 &&
162
+ (next.width !== layout.width || next.height !== layout.height))
163
+ finish(id);
164
+ if (next.width !== layout.width || next.height !== layout.height)
165
+ setLayout({ width: next.width, height: next.height });
166
+ };
167
+ return (_jsx(View, { style: [{ flex: 1, overflow: "hidden" }, style], onLayout: onLayout, children: presence.entries.map((entry) => {
168
+ // Live routes get updated options/context; a removed outgoing route keeps its last descriptor.
169
+ const scene = liveScenes.get(entry.key) ?? entry.value;
170
+ const side = transition?.from.key === entry.key
171
+ ? "out"
172
+ : transition?.to.key === entry.key
173
+ ? "in"
174
+ : null;
175
+ return (_jsx(NativeRouteSurface, { target: entry.target, active: entry.key === activeKey, side: side, id: id, width: width, plan: side ? prepared.plan : null, playback: playback, onLayout: (event) => {
176
+ if (event.nativeEvent.layout.width > 0 &&
177
+ event.nativeEvent.layout.height > 0) {
178
+ setReadyKeys((previous) => previous.has(entry.key)
179
+ ? previous
180
+ : new Set([...previous, entry.key]));
181
+ }
182
+ }, children: _jsx(SceneContent, { children: scene.descriptor.render() }, scene.boundaryKey) }, entry.key));
183
+ }) }));
184
+ }
185
+ function SceneContent({ children }) {
186
+ return children;
187
+ }
@@ -0,0 +1,9 @@
1
+ import type { PageMotionPlan } from "@ssgoi/core/runtime";
2
+ export type PlaybackState = {
3
+ id: number;
4
+ elapsed: number;
5
+ running: boolean;
6
+ plan: PageMotionPlan | null;
7
+ };
8
+ /** One UI clock owns both tracks, including fade's sequence. No per-frame RN callbacks. */
9
+ export declare function useNativePlayback(id: number, plan: PageMotionPlan | null, ready: boolean, onComplete: (id: number) => void): import("react-native-reanimated").SharedValue<PlaybackState>;
@@ -0,0 +1,33 @@
1
+ import { useLayoutEffect } from "react";
2
+ import { useFrameCallback, useSharedValue } from "react-native-reanimated";
3
+ import { scheduleOnRN } from "react-native-worklets";
4
+ /** One UI clock owns both tracks, including fade's sequence. No per-frame RN callbacks. */
5
+ export function useNativePlayback(id, plan, ready, onComplete) {
6
+ const playback = useSharedValue({
7
+ id,
8
+ plan: null,
9
+ elapsed: 0,
10
+ running: false,
11
+ });
12
+ useLayoutEffect(() => {
13
+ playback.value = { id, plan, elapsed: 0, running: ready && plan !== null };
14
+ return () => {
15
+ playback.value = { id, plan: null, elapsed: 0, running: false };
16
+ };
17
+ }, [id, plan, ready, playback]);
18
+ const clock = useFrameCallback((frame) => {
19
+ const current = playback.value;
20
+ if (!current.running || !current.plan)
21
+ return;
22
+ const elapsed = Math.min(current.plan.duration, current.elapsed + Math.min(frame.timeSincePreviousFrame ?? 0, 1000 / 30));
23
+ const finished = elapsed >= current.plan.duration;
24
+ playback.value = { ...current, elapsed, running: !finished };
25
+ if (finished)
26
+ scheduleOnRN(onComplete, current.id);
27
+ }, false);
28
+ useLayoutEffect(() => {
29
+ clock.setActive(ready && plan !== null);
30
+ return () => clock.setActive(false);
31
+ }, [clock, ready, plan]);
32
+ return playback;
33
+ }
@@ -0,0 +1,2 @@
1
+ export { createPresence, reconcilePresence, settlePresence, } from "@ssgoi/core/runtime";
2
+ export type Screen<T> = import("@ssgoi/core/runtime").TransitionView<import("./native-surface-handle.js").NativeSurfaceHandle, T>;
@@ -0,0 +1 @@
1
+ export { createPresence, reconcilePresence, settlePresence, } from "@ssgoi/core/runtime";
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from "react";
2
+ import { type StyleProp, type ViewStyle } from "react-native";
3
+ import { nativePlatform } from "./native-platform.js";
4
+ import type { SsgoiConfig } from "./types.js";
5
+ export interface SsgoiProps {
6
+ config: SsgoiConfig;
7
+ children: ReactNode;
8
+ style?: StyleProp<ViewStyle>;
9
+ reducedMotion?: "system" | "always" | "never";
10
+ /** Report failed preparation; the navigator still reveals the latest screen. */
11
+ onTransitionError?: (error: Error) => void;
12
+ }
13
+ type Context = {
14
+ config: SsgoiConfig;
15
+ platform: typeof nativePlatform;
16
+ reducedMotion: boolean;
17
+ onTransitionError?: (error: Error) => void;
18
+ };
19
+ export declare function Ssgoi({ config, children, style, reducedMotion, onTransitionError, }: SsgoiProps): import("react/jsx-runtime").JSX.Element;
20
+ export declare function useSsgoi(): Context;
21
+ export {};
package/dist/ssgoi.js ADDED
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useMemo, useState, } from "react";
3
+ import { AccessibilityInfo, View, } from "react-native";
4
+ import { nativePlatform } from "./native-platform.js";
5
+ const SsgoiContext = createContext(null);
6
+ export function Ssgoi({ config, children, style, reducedMotion = "system", onTransitionError, }) {
7
+ // Respect reduced motion while the asynchronous system preference is loading.
8
+ const [systemReducedMotion, setSystemReducedMotion] = useState(true);
9
+ useEffect(() => {
10
+ if (reducedMotion !== "system")
11
+ return;
12
+ let disposed = false;
13
+ let changed = false;
14
+ const subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", (value) => {
15
+ changed = true;
16
+ setSystemReducedMotion(value);
17
+ });
18
+ void AccessibilityInfo.isReduceMotionEnabled()
19
+ .then((value) => {
20
+ if (!disposed && !changed)
21
+ setSystemReducedMotion(value);
22
+ })
23
+ .catch(() => { });
24
+ return () => {
25
+ disposed = true;
26
+ subscription.remove();
27
+ };
28
+ }, [reducedMotion]);
29
+ const context = useMemo(() => ({
30
+ config,
31
+ platform: nativePlatform,
32
+ reducedMotion: reducedMotion === "always" ||
33
+ (reducedMotion === "system" && systemReducedMotion),
34
+ onTransitionError,
35
+ }), [config, reducedMotion, systemReducedMotion, onTransitionError]);
36
+ return (_jsx(SsgoiContext.Provider, { value: context, children: _jsx(View, { style: [{ flex: 1 }, style], children: children }) }));
37
+ }
38
+ export function useSsgoi() {
39
+ const context = useContext(SsgoiContext);
40
+ if (!context)
41
+ throw new Error("SSGOI: render SsgoiRouteBoundary inside <Ssgoi config={config}>.");
42
+ return context;
43
+ }
@@ -0,0 +1,19 @@
1
+ import type { PhysicsOptions, RouteConfig, RouteRule, TransitionsResolverArgs } from "@ssgoi/core/runtime";
2
+ /** Native presets carry data, so no DOM or JS callback crosses into the UI runtime. */
3
+ export type NativeTransition = {
4
+ readonly platform: "ssgoi-native";
5
+ readonly kind: "fade" | "slide";
6
+ readonly physics?: PhysicsOptions;
7
+ };
8
+ /** Mounted native screens retain their own scroll; explicit restoration is not implemented yet. */
9
+ export type SsgoiTransitionRule = RouteRule<NativeTransition> & {
10
+ preserveScroll?: never;
11
+ };
12
+ export type SsgoiConfig = Omit<RouteConfig<NativeTransition>, "transitions"> & {
13
+ transitions?: readonly SsgoiTransitionRule[] | ((args: TransitionsResolverArgs) => readonly SsgoiTransitionRule[]);
14
+ };
15
+ export type { PhysicsOptions, SpringConfig, InertiaConfig, NavigationDirection, } from "@ssgoi/core/runtime";
16
+ export type RouteBoundaryState = {
17
+ id: string;
18
+ key?: string | number;
19
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { NativeTransition, PhysicsOptions } from "./types.js";
2
+ export interface FadeOptions {
3
+ physics?: PhysicsOptions;
4
+ }
5
+ export interface SlideOptions {
6
+ physics?: PhysicsOptions;
7
+ }
8
+ export declare function fade({ physics }?: FadeOptions): NativeTransition;
9
+ export declare function slide({ physics }?: SlideOptions): NativeTransition;
@@ -0,0 +1,6 @@
1
+ export function fade({ physics } = {}) {
2
+ return { platform: "ssgoi-native", kind: "fade", physics };
3
+ }
4
+ export function slide({ physics } = {}) {
5
+ return { platform: "ssgoi-native", kind: "slide", physics };
6
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@ssgoi/react-native",
3
+ "version": "7.1.0-beta.0",
4
+ "description": "React Native screen transitions for SSGOI with optional Expo Router integration",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./view-transitions": {
14
+ "types": "./dist/view-transitions.d.ts",
15
+ "default": "./dist/view-transitions.js"
16
+ },
17
+ "./expo-router": {
18
+ "types": "./dist/expo-router.d.ts",
19
+ "default": "./dist/expo-router.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "dependencies": {
27
+ "standard-navigation": "0.0.9",
28
+ "@ssgoi/core": "^7.1.0-beta.0"
29
+ },
30
+ "peerDependencies": {
31
+ "react": "^19.2.3",
32
+ "react-native": ">=0.85.3 <0.86",
33
+ "react-native-reanimated": "~4.3.1",
34
+ "react-native-worklets": "0.8.3",
35
+ "expo-router": "*"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "expo-router": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "devDependencies": {
43
+ "@types/react": "^19.2.0",
44
+ "typescript": "~5.8.3",
45
+ "react": "19.2.3",
46
+ "react-native": "0.85.3",
47
+ "react-native-reanimated": "4.3.1",
48
+ "react-native-worklets": "0.8.3",
49
+ "expo": "56.0.21",
50
+ "expo-router": "56.2.20",
51
+ "jest": "^29.7.0",
52
+ "jest-expo": "56.0.5",
53
+ "@types/jest": "^29.5.14",
54
+ "react-test-renderer": "19.2.3",
55
+ "@testing-library/react-native": "^13.3.3",
56
+ "react-dom": "19.2.3",
57
+ "babel-preset-expo": "56.0.20",
58
+ "@react-native/jest-preset": "0.85.3"
59
+ },
60
+ "license": "MIT",
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/meursyphus/ssgoi.git",
64
+ "directory": "packages/react-native"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ },
69
+ "scripts": {
70
+ "build": "tsc -p tsconfig.json",
71
+ "test:run": "jest --runInBand",
72
+ "typecheck": "tsc -p tsconfig.json --noEmit"
73
+ }
74
+ }