devto 2026-08-02 원문 보기 ↗
This is an English translation of my original article on Qiita.
My Expo app occasionally stayed on the splash screen when it was launched by tapping a push notification from a terminated state. Opening the app normally worked. Tapping a notification after the app had already started also worked.
The splash screen was not the cause. Two navigation operations were competing during startup.
The authenticated layout checks for a token in SecureStore when it mounts.
useEffect(() => {
(async () => {
const token = await getToken();
if (!token) {
router.replace("/login");
return;
}
setAuthChecked(true);
})();
}, []);
The same layout also started push token registration and notification response handling.
useRegisterPushToken();
The notification hook called getLastNotificationResponseAsync() and opened the route stored in the notification data.
Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) handleNotificationTap(response.notification);
});
During a cold start, two operations could run at nearly the same time:
router.replace("/login") or allowed the authenticated layout to render.router.push(path).Both ran before navigation had settled. The screen never appeared, so the app looked as if SplashScreen itself had frozen.
I changed the notification hook to accept an enabled flag.
const [authChecked, setAuthChecked] = useState(false);
useEffect(() => {
(async () => {
const token = await getToken();
if (!token) {
router.replace("/login");
return;
}
setAuthChecked(true);
})();
}, []);
useRegisterPushToken(authChecked);
The hook does not start its effects while enabled is false.
export function useRegisterPushToken(enabled = true) {
useEffect(() => {
if (!enabled) return;
if (!Notifications) return;
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) handleNotificationTap(response.notification);
});
const subscription =
Notifications.addNotificationResponseReceivedListener((response) => {
handleNotificationTap(response.notification);
});
return () => subscription.remove();
}, [enabled]);
}
The app now handles the most recent notification only after authentication has finished and the authenticated layout can render. This removed the cold-start navigation race.
Notifications created by different parts of the system could contain either a relative path or an absolute URL. I convert both forms into an internal path before calling Expo Router.
function handleNotificationTap(notification: Notifications.Notification) {
const data = notification.request.content.data as
| { url?: string; linkUrl?: string }
| null;
const raw = data?.url ?? data?.linkUrl;
if (!raw) return;
const path = raw.startsWith("/")
? raw
: (raw.match(/^https?:\/\/[^/]+(\/.+)$/)?.[1] ?? null);
if (!path) return;
router.push(path as never);
}
Production code should verify the host before extracting a path from an absolute URL. The example above only shows the normalization step.
I checked each of these paths after the change:
A notification launch changes the initialization order. When an app appears stuck on its splash screen, it is worth checking whether authentication and deep-link handling are both trying to navigate during startup.