Files
jrshikoku/lib/useAppContainerController.ts

400 lines
13 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useWindowDimensions } from "react-native";
import { useFonts } from "expo-font";
import type { EventArg } from "@react-navigation/native";
import type { RootTabParamList } from "../types/navigation";
import { useAreaInfo } from "../stateBox/useAreaInfo";
import { useTrainMenu } from "../stateBox/useTrainMenu";
import { AS } from "../storageControl";
import { STORAGE_KEYS } from "../constants/storage";
import {
lastObservedRootRouteRef,
positionsLifecycleRef,
rootNavigationRef,
startupExplicitTargetRef,
waitForStartupNavigationResolution,
} from "./rootNavigation";
import { recordAppLifecycleRootNavigation } from "./observability/appLifecycleCrashSentinel";
import { telemetry } from "./observability/telemetry";
import {
resolveStartupInitialRoute,
type RootRouteName,
} from "./appContainerPolicy";
const positionsRootTelemetry = telemetry.scope("positions.root");
const operationRootTelemetry = telemetry.scope("operation.root");
const navigationRootTelemetry = telemetry.scope("nav.root");
const navigationTabTelemetry = telemetry.scope("nav.tab");
type RootTabPressEvent = EventArg<"tabPress", true>;
type RootNavigationSource = "ready" | "change";
/**
* AppContainerの起動・navigation・タブ状態処理をまとめたcontroller。
* NavigationContainerやTabのJSXはApps.tsx側に残し、処理とレイアウトの
* 境界だけをここで固定する。
*/
export function useAppContainerController() {
const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo();
const { selectedLine } = useTrainMenu();
const { width, height } = useWindowDimensions();
const [isExtraWindowOpen, setIsExtraWindowOpen] = useState(false);
const [startupInitialRoute, setStartupInitialRoute] =
useState<RootRouteName>(null);
const [hasVisitedPositions, setHasVisitedPositions] = useState(false);
const [hasVisitedInformation, setHasVisitedInformation] = useState(false);
const [currentRootRoute, setCurrentRootRoute] =
useState<RootRouteName>(null);
const [navigationReady, setNavigationReady] = useState(false);
const startupPrewarmAttemptedRef = useRef(false);
const informationStartupPrewarmAttemptedRef = useRef(false);
const lastRootNavStateRef = useRef("");
useEffect(() => {
let cancelled = false;
void (async () => {
await waitForStartupNavigationResolution();
if (cancelled) return;
let startPageSetting: unknown = null;
if (!startupExplicitTargetRef.current) {
try {
startPageSetting = await AS.getItem(STORAGE_KEYS.START_PAGE);
} catch {
// Keep the default route when the setting is unset or unreadable.
}
}
if (cancelled) return;
const nextInitialRoute = resolveStartupInitialRoute({
hasExplicitTarget: startupExplicitTargetRef.current,
startPageSetting,
});
setStartupInitialRoute(nextInitialRoute);
setHasVisitedPositions(nextInitialRoute === "positions");
navigationRootTelemetry.breadcrumb({
message: "startup initial root route resolved",
data: {
startupExplicitTarget: startupExplicitTargetRef.current,
initialRoute: nextInitialRoute,
},
});
})();
return () => {
cancelled = true;
};
}, []);
const applyRootNavigationState = useCallback(
(state: any, source: RootNavigationSource) => {
const activeRoute = state?.routes?.[state?.index ?? 0];
const hasExtra = (activeRoute?.state?.index ?? 0) > 0;
const nestedState = activeRoute?.state;
const nestedRoute =
nestedState?.routes?.[nestedState.index ?? 0]?.name ?? null;
const signature = JSON.stringify({
rootIndex: state?.index ?? 0,
rootRoute: activeRoute?.name ?? null,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
});
if (lastRootNavStateRef.current !== signature) {
lastRootNavStateRef.current = signature;
navigationRootTelemetry.breadcrumb({
message:
source === "ready" ? "root state initialized" : "root state change",
data: {
rootIndex: state?.index ?? 0,
rootRoute: activeRoute?.name ?? null,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
source,
},
});
}
const rootRouteName = (activeRoute?.name ?? null) as RootRouteName;
if (rootRouteName) {
lastObservedRootRouteRef.current = rootRouteName;
}
recordAppLifecycleRootNavigation({
rootIndex: state?.index ?? 0,
rootRoute: rootRouteName,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
source,
});
telemetry.tag("root_tab", rootRouteName ?? "unknown");
telemetry.context("root_navigation", {
rootIndex: state?.index ?? 0,
rootRoute: rootRouteName,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
source,
operationOrientation: width > height ? "landscape" : "portrait",
hasVisitedPositions,
hasVisitedInformation,
});
setCurrentRootRoute(rootRouteName);
setIsExtraWindowOpen(hasExtra);
},
[hasVisitedInformation, hasVisitedPositions, height, width],
);
const handleNavigationReady = useCallback(() => {
navigationRootTelemetry.info("navigation ready");
applyRootNavigationState(
rootNavigationRef.getRootState?.() ?? rootNavigationRef.getState?.(),
"ready",
);
setNavigationReady(true);
}, [applyRootNavigationState]);
const handleNavigationStateChange = useCallback(
(state: any) => {
applyRootNavigationState(state, "change");
},
[applyRootNavigationState],
);
const handleRootTabPress = useCallback(
(routeName: keyof RootTabParamList, event: RootTabPressEvent) => {
const rootState = rootNavigationRef.getState();
const activeRootRouteName =
(rootState?.routes?.[rootState.index ?? 0]?.name as
| keyof RootTabParamList
| undefined) ??
currentRootRoute ??
null;
const leavingUnstablePositions =
routeName !== "positions" &&
positionsLifecycleRef.current.isUnstable;
const blockingUnstableExit =
routeName !== "positions" &&
positionsLifecycleRef.current.blockTabExit;
navigationTabTelemetry.breadcrumb({
message: blockingUnstableExit
? "root tabPress blocked while positions unstable"
: leavingUnstablePositions
? "root tabPress intercepted for unstable positions"
: "root tabPress",
data: {
route: routeName,
currentRootRoute,
activeRootRouteName,
unstablePositions: positionsLifecycleRef.current.isUnstable,
blockTabExit: positionsLifecycleRef.current.blockTabExit,
},
});
if (blockingUnstableExit) {
event.preventDefault();
positionsLifecycleRef.current.resetBeforeLeave?.();
requestAnimationFrame(() => {
setTimeout(() => {
rootNavigationRef.navigate(routeName);
}, 0);
});
return;
}
if (leavingUnstablePositions) {
event.preventDefault();
positionsLifecycleRef.current.resetBeforeLeave?.();
requestAnimationFrame(() => {
setTimeout(() => {
rootNavigationRef.navigate(routeName);
}, 0);
});
positionsRootTelemetry.breadcrumb({
message:
"positions root exit reset and continued while session unstable",
data: {
route: routeName,
},
});
}
},
[currentRootRoute],
);
const handleRootTabFocus = useCallback((routeName: keyof RootTabParamList) => {
navigationTabTelemetry.breadcrumb({
message: "root tab focus",
data: { route: routeName },
});
}, []);
const handleRootTabBlur = useCallback((routeName: keyof RootTabParamList) => {
navigationTabTelemetry.breadcrumb({
message: "root tab blur",
data: { route: routeName },
});
}, []);
const handleRootTabState = useCallback(
(routeName: keyof RootTabParamList, stateType: string) => {
navigationTabTelemetry.breadcrumb({
message: "root tab state",
data: {
route: routeName,
stateType,
},
});
},
[],
);
const activatePositionsFromTabPress = useCallback(() => {
positionsRootTelemetry.info("positions root tabPress activation");
setHasVisitedPositions(true);
}, []);
const activatePositionsFromFocus = useCallback(() => {
positionsRootTelemetry.info("positions root focus activation");
setHasVisitedPositions(true);
}, []);
const preservePositionsOnBlur = useCallback(() => {
positionsRootTelemetry.info("positions root blur preserved");
}, []);
const activateInformationFromTabPress = useCallback(() => {
operationRootTelemetry.info("operation root tabPress activation");
setHasVisitedInformation(true);
}, []);
const activateInformationFromFocus = useCallback(() => {
operationRootTelemetry.info("operation root focus activation");
setHasVisitedInformation(true);
}, []);
const preserveInformationOnBlur = useCallback(() => {
operationRootTelemetry.info("operation root blur preserved");
}, []);
const [fontLoaded] = useFonts({
"JR-Nishi": require("../assets/fonts/jr-nishi.otf"),
"JR-WEST-PLUS": require("../assets/fonts/JR-WEST-PLUS.otf"),
Zou: require("../assets/fonts/DelaGothicOne-Regular.ttf"),
"JNR-font": require("../assets/fonts/JNRfont_pict.ttf"),
DiaPro: require("../assets/fonts/DiaPro-Regular.otf"),
});
useEffect(() => {
if (!navigationReady || !fontLoaded) return;
if (startupPrewarmAttemptedRef.current) return;
if (hasVisitedPositions) {
startupPrewarmAttemptedRef.current = true;
return;
}
if (currentRootRoute === null || currentRootRoute === "positions") return;
startupPrewarmAttemptedRef.current = true;
positionsRootTelemetry.breadcrumb({
message: "positions startup prewarm scheduled",
data: {
currentRootRoute,
},
});
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled || hasVisitedPositions) return;
positionsRootTelemetry.breadcrumb({
message: "positions startup prewarm activated",
data: {
currentRootRoute,
},
});
setHasVisitedPositions(true);
}, 500);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [currentRootRoute, fontLoaded, hasVisitedPositions, navigationReady]);
useEffect(() => {
if (!navigationReady || !fontLoaded) return;
if (informationStartupPrewarmAttemptedRef.current) return;
if (hasVisitedInformation) {
informationStartupPrewarmAttemptedRef.current = true;
return;
}
if (currentRootRoute === null || currentRootRoute === "information") return;
informationStartupPrewarmAttemptedRef.current = true;
operationRootTelemetry.breadcrumb({
message: "operation startup prewarm scheduled",
data: {
currentRootRoute,
},
});
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled || hasVisitedInformation) return;
operationRootTelemetry.breadcrumb({
message: "operation startup prewarm activated",
data: {
currentRootRoute,
},
});
setHasVisitedInformation(true);
}, 500);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [currentRootRoute, fontLoaded, hasVisitedInformation, navigationReady]);
return {
areaInfo,
areaIconBadgeText,
isInfo,
selectedLine,
isExtraWindowOpen,
startupInitialRoute,
hasVisitedPositions,
hasVisitedInformation,
currentRootRoute,
navigationReady,
fontLoaded,
shouldPreloadPositions:
navigationReady &&
fontLoaded &&
currentRootRoute === "topMenu" &&
!hasVisitedPositions,
shouldPreloadInformation:
navigationReady &&
fontLoaded &&
currentRootRoute === "topMenu" &&
!hasVisitedInformation,
handleNavigationReady,
handleNavigationStateChange,
handleRootTabPress,
handleRootTabFocus,
handleRootTabBlur,
handleRootTabState,
activatePositionsFromTabPress,
activatePositionsFromFocus,
preservePositionsOnBlur,
activateInformationFromTabPress,
activateInformationFromFocus,
preserveInformationOnBlur,
};
}
export type AppContainerController = ReturnType<
typeof useAppContainerController
>;