Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion packages/react-native-screen-transitions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"peerDependencies": {
"@react-navigation/native": ">=7.3.0",
"@types/react": "*",
"expo-router": ">=56.0.0",
"react": ">=19.2.0",
"react-native": "*",
"react-native-gesture-handler": ">=2.16.1",
Expand All @@ -79,6 +80,9 @@
"react-native-worklets": ">=0.8.0"
},
"peerDependenciesMeta": {
"expo-router": {
"optional": true
},
"react-native-teleport": {
"optional": true
}
Expand All @@ -90,7 +94,7 @@
"@react-navigation/native": "7.3.13",
"@testing-library/react-native": "13.3.3",
"@types/react": "~19.2.17",
"expo-router": "56.2.11",
"expo-router": "58.0.17",
"react-native-builder-bob": "0.41.0",
"react-test-renderer": "19.2.3",
"typescript": "catalog:"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "bun:test";
import { createBlankStackProps } from "../navigators/expo-router/create-blank-stack-props";

const route = (key: string) => ({ key, name: key });

const navigation = {
getState: () => ({ key: "stack", index: 0, routes: [] }),
dispatch: () => {},
};

describe("createBlankStackProps", () => {
it("passes the state through when no routes follow the focused route", () => {
const state = { key: "stack", index: 1, routes: [route("a"), route("b")] };

const props = createBlankStackProps({ state, navigation });

expect(props.navigationState).toBe(state);
expect(props.navigation).toBe(navigation);
});

it("drops preloaded routes that follow the focused route", () => {
const a = route("a");
const b = route("b");
const state = {
key: "stack",
index: 1,
routes: [a, b, { ...route("preloaded"), isPreloaded: true }],
};

const props = createBlankStackProps({ state, navigation });

expect(props.navigationState).toEqual({
key: "stack",
index: 1,
routes: [a, b],
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "bun:test";
import { getStackRoutes } from "../utils/navigation/get-stack-routes";

const route = (key: string) => ({ key, name: key });

describe("getStackRoutes", () => {
it("reads preloaded routes from `preloadedRoutes`", () => {
const routes = [route("a"), route("b")];
const preloadedRoutes = [route("preloaded")];

const result = getStackRoutes({ index: 1, routes, preloadedRoutes });

expect(result.routes).toBe(routes);
expect(result.preloadedRoutes).toBe(preloadedRoutes);
});

it("returns no preloaded routes when the state has none", () => {
const routes = [route("a")];

expect(getStackRoutes({ index: 0, routes })).toEqual({
routes,
preloadedRoutes: [],
});
});

it("splits preloaded routes that follow the focused route", () => {
const a = route("a");
const b = route("b");
const preloaded = { ...route("preloaded"), isPreloaded: true };

const result = getStackRoutes({ index: 1, routes: [a, b, preloaded] });

expect(result.routes).toEqual([a, b]);
expect(result.preloadedRoutes).toEqual([preloaded]);
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { NavigationState, Route } from "@react-navigation/native";
import type { NavigationState } from "@react-navigation/native";
import { useMemo } from "react";
import { Overlay } from "../../components/overlay";
import { ScreenComposer } from "../../providers/screen/screen-composer";
import { BlankStackStoreProvider } from "../../providers/stack/blank-stack.provider";
import { StackCoreProvider } from "../../providers/stack/core.provider";
import type { BaseStackDescriptor, BaseStackRoute } from "../../types";
import { getStackRoutes } from "../../utils/navigation/get-stack-routes";
import { isOverlayVisible } from "../../utils/overlay/visibility";
import {
ScreenTransitionsAdapterProvider,
Expand All @@ -24,10 +25,6 @@ import type {

type DescriptorMap = Record<string, BaseStackDescriptor>;

type NavigationStateWithPreloads = NavigationState & {
preloadedRoutes?: Route<string>[];
};

type TransitionStackState = {
routes: BaseStackRoute[];
routeKeys: string[];
Expand All @@ -36,15 +33,6 @@ type TransitionStackState = {
shouldShowFloatOverlay: boolean;
};

const EMPTY_PRELOADED_ROUTES: Route<string>[] = [];

function getPreloadedRoutes(state: NavigationState): Route<string>[] {
return (
(state as NavigationStateWithPreloads).preloadedRoutes ??
EMPTY_PRELOADED_ROUTES
);
}

function normalizeDescriptor(
descriptor: BaseStackDescriptor,
): BaseStackDescriptor {
Expand All @@ -63,8 +51,7 @@ function buildTransitionStackState({
state: NavigationState;
descriptors: DescriptorMap;
}): TransitionStackState {
const routes = state.routes as BaseStackRoute[];
const preloadedRoutes = getPreloadedRoutes(state) as BaseStackRoute[];
const { routes, preloadedRoutes } = getStackRoutes<BaseStackRoute>(state);
const allRoutes = routes.concat(preloadedRoutes);
const routeKeys = routes.map((route) => route.key);
const scenes: ScreenTransitionsAdapterScene[] = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type {
BaseStackNavigation,
BaseStackRoute,
BaseStackState,
} from "../../types/stack.types";
import { getStackRoutes } from "../../utils/navigation/get-stack-routes";
import type { BlankStackStandardNavigatorProps } from "../create-blank-stack-navigator";

export function createBlankStackProps({
state,
navigation,
}: {
state: BaseStackState<BaseStackRoute>;
navigation: BaseStackNavigation;
}): BlankStackStandardNavigatorProps {
const { routes } = getStackRoutes(state);

return {
navigationState: routes === state.routes ? state : { ...state, routes },
navigation,
};
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type IntegrateWithRouterOptions,
type StackNavigationState,
StackRouter,
type StackRouterOptions,
Expand All @@ -10,21 +11,27 @@ import {
type BlankStackStandardEventMap,
type BlankStackStandardNavigatorProps,
} from "../create-blank-stack-navigator";
import { createBlankStackProps } from "./create-blank-stack-props";

type BlankStackExpoRouterParamList = Record<string, object | undefined>;

type BlankStackExpoRouterState =
StackNavigationState<BlankStackExpoRouterParamList>;

// Expo Router 57+ adds a sixth type argument for `createProps` that 56 rejects,
// so the options are checked with `satisfies` and cast to fit every version.
export const BlankStack = unstable_integrateWithRouter<
BlankStackNavigationOptions,
StackNavigationState<BlankStackExpoRouterParamList>,
BlankStackExpoRouterState,
BlankStackStandardEventMap,
BlankStackStandardNavigatorProps,
StackRouterOptions
>(BlankStackNavigator, StackRouter, {
createProps: ({ state, navigation }) => ({
navigationState: state,
navigation,
}),
});
createProps: createBlankStackProps,
} satisfies IntegrateWithRouterOptions<
BlankStackExpoRouterState,
BlankStackStandardNavigatorProps
> as never);

export const BlankStackScreen = BlankStack.Screen;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
type StackRoutesState<Route> = {
routes: Route[];
index: number;
preloadedRoutes?: Route[];
};

/**
* Splits a stack state into its active and preloaded routes.
* React Navigation keeps preloaded routes in `preloadedRoutes`, while
* Expo Router 58+ keeps them in `routes` after `index`.
*/
export function getStackRoutes<Route>(state: StackRoutesState<Route>) {
const activeCount = state.index + 1;

if (state.routes.length <= activeCount) {
return {
routes: state.routes,
preloadedRoutes: state.preloadedRoutes ?? [],
};
}

return {
routes: state.routes.slice(0, activeCount),
preloadedRoutes: state.routes.slice(activeCount),
};
}