Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix focus re-renders #704

Closed
wants to merge 3 commits into from
Closed
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
4 changes: 3 additions & 1 deletion packages/expo-metro-runtime/src/HMRClient.native.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
module.exports = require("react-native/Libraries/Utilities/HMRClient");
const HMRClient = require("react-native/Libraries/Utilities/HMRClient");

export default HMRClient;
13 changes: 8 additions & 5 deletions packages/expo-router/src/__tests__/smoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,20 @@ it("nested layouts", async () => {
"(app)/_layout": AppLayout,
"(app)/index": Index,
"(app)/(tabs)/_layout": TabsLayout,
"(app)/(tabs)/home/_layout": StackLayout,
"(app)/(tabs)/home/_layout": {
unstable_settings: { initialRouteName: "index" },
default: StackLayout,
},
"(app)/(tabs)/home/index": Home,
"(app)/(tabs)/home/nested": HomeNested,
});

expect(await screen.findByText("HomeNested")).toBeOnTheScreen();

expect(AppLayout).toHaveBeenCalledTimes(3);
expect(TabsLayout).toHaveBeenCalledTimes(2);
expect(StackLayout).toHaveBeenCalledTimes(2);
expect(AppLayout).toHaveBeenCalledTimes(4);
expect(TabsLayout).toHaveBeenCalledTimes(3);
expect(StackLayout).toHaveBeenCalledTimes(3);
expect(Index).toHaveBeenCalledTimes(1);
expect(Home).toHaveBeenCalledTimes(1);
expect(Home).toHaveBeenCalledTimes(2);
expect(HomeNested).toHaveBeenCalledTimes(1);
});
73 changes: 73 additions & 0 deletions packages/expo-router/src/__tests__/useFocusEffect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { Text } from "react-native";

import { router, useFocusEffect } from "../exports";
import { Stack } from "../layouts/Stack";
import { act, renderRouter, screen } from "../testing-library";

it("does not re-render extraneously", () => {
const events: string[] = [];
renderRouter({
_layout: () => {
useFocusEffect(() => {
events.push("_layout");
});

return <Stack />;
},
index: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
useFocusEffect(() => {
events.push("index");
});
return <Text>index</Text>;
},
two: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
useFocusEffect(() => {
events.push("two");
});
return <Text>two</Text>;
},
});

expect(screen).toHavePathname("/");
expect(events).toEqual(["_layout", "index"]);

act(() => router.push("/two"));
expect(screen).toHavePathname("/two");
expect(events).toEqual(["_layout", "index", "two"]);
act(() => router.push("/"));
expect(screen).toHavePathname("/");
expect(events).toEqual(["_layout", "index", "two", "index"]);
});

it("can navigate instantly after mounting", () => {
const events: string[] = [];
renderRouter({
_layout: () => {
useFocusEffect(() => {
events.push("_layout");
router.push("/two");
});

return <Stack />;
},
index: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
useFocusEffect(() => {
events.push("index");
});
return <Text>index</Text>;
},
two: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
useFocusEffect(() => {
events.push("two");
});
return <Text>two</Text>;
},
});

expect(screen).toHavePathname("/two");
expect(events).toEqual(["_layout", "two"]);
});
19 changes: 19 additions & 0 deletions packages/expo-router/src/global-state/router-store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class RouterStore {

rootStateSubscribers = new Set<() => void>();
storeSubscribers = new Set<() => void>();
private readySubscribers = new Set<() => void>();

linkTo = linkTo.bind(this);
getSortedRoutes = getSortedRoutes.bind(this);
Expand Down Expand Up @@ -182,7 +183,25 @@ export class RouterStore {
requestAnimationFrame(() => _internal_maybeHideAsync());
}
this.isReady = true;

for (const subscriber of this.readySubscribers) {
subscriber();
}
};

/** Called once when the navigation is ready. Specifically added for `useFocusEvent` + redirection. */
onceReady = (callback: () => void) => {
if (this.navigationRef.isReady()) {
callback();
} else {
const remove = () => {
callback();
this.readySubscribers.delete(remove);
};
this.readySubscribers.add(remove);
}
};

subscribeToRootState = (subscriber: () => void) => {
this.rootStateSubscribers.add(subscriber);
return () => this.rootStateSubscribers.delete(subscriber);
Expand Down
3 changes: 1 addition & 2 deletions packages/expo-router/src/link/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { GestureResponderEvent, Platform } from "react-native";

import { useRouter } from "../hooks";
import { router } from "../imperative-api";
import { useFocusEffect } from "../useFocusEffect";
import { Href, resolveHref } from "./href";
import useLinkToPathProps from "./useLinkToPathProps";
Expand All @@ -28,7 +28,6 @@ export interface LinkProps extends Omit<TextProps, "href" | "hoverStyle"> {

/** Redirects to the href as soon as the component is mounted. */
export function Redirect({ href }: { href: Href }) {
const router = useRouter();
useFocusEffect(() => {
try {
router.replace(href);
Expand Down
60 changes: 0 additions & 60 deletions packages/expo-router/src/link/useLoadedNavigation.ts

This file was deleted.

24 changes: 17 additions & 7 deletions packages/expo-router/src/useFocusEffect.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// A fork of `useFocusEffect` that waits for the navigation state to load before
// running the effect. This is especially useful for native redirects.
import { useNavigation } from "@react-navigation/native";
import * as React from "react";

import { useOptionalNavigation } from "./link/useLoadedNavigation";
import { useExpoRouter } from "./global-state/router-store";

type EffectCallback = () => undefined | void | (() => void);

Expand All @@ -17,8 +18,8 @@ export function useFocusEffect(
effect: EffectCallback,
do_not_pass_a_second_prop?: never
) {
const navigation = useOptionalNavigation();

const { navigationRef, onceReady } = useExpoRouter();
const navigation = useNavigation();
if (do_not_pass_a_second_prop !== undefined) {
const message =
"You passed a second argument to 'useFocusEffect', but it only accepts one argument. " +
Expand Down Expand Up @@ -79,8 +80,8 @@ export function useFocusEffect(
}
};

// We need to run the effect on intial render/dep changes if the screen is focused
if (navigation.isFocused()) {
// We need to run the effect on initial render/dep changes if the screen is focused
if (navigation.isFocused() && navigationRef.isReady()) {
cleanup = callback();
isFocused = true;
}
Expand All @@ -96,8 +97,17 @@ export function useFocusEffect(
cleanup();
}

cleanup = callback();
isFocused = true;
if (!navigationRef.isReady()) {
onceReady(() => {
if (!isFocused && navigation.isFocused()) {
cleanup = callback();
isFocused = true;
}
});
} else {
cleanup = callback();
isFocused = true;
}
});

const unsubscribeBlur = navigation.addListener("blur", () => {
Expand Down