Skip to content

Commit 545c8e7

Browse files
authored
[UNI-99] feat : Suspense 적용하기 (#44)
* [UNI-109] feat : Dynamic하게 Suspense 적용 * [UNI-110] feat : Google Map Suspense 적용하기 * [UNI-110] fix : cleanup 함수 추가
1 parent 55801f0 commit 545c8e7

File tree

13 files changed

+211
-36
lines changed

13 files changed

+211
-36
lines changed

uniro_admin_frontend/src/fetch/fetch.tsx

Whitespace-only changes.

uniro_frontend/src/App.tsx

+20-16
Original file line numberDiff line numberDiff line change
@@ -10,31 +10,35 @@ import ReportRoutePage from "./pages/reportRoute";
1010
import ReportForm from "./pages/reportForm";
1111
import ReportHazardPage from "./pages/reportHazard";
1212
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
13+
import { DynamicSuspense } from "./container/dynamicSuspense";
14+
import { useDynamicSuspense } from "./hooks/useDynamicSuspense";
1315
import OfflinePage from "./pages/offline";
1416
import useNetworkStatus from "./hooks/useNetworkStatus";
1517
import ErrorPage from "./pages/error";
1618

1719
const queryClient = new QueryClient();
1820

1921
function App() {
20-
useNetworkStatus();
21-
22+
useDynamicSuspense();
23+
useNetworkStatus();
2224
return (
2325
<QueryClientProvider client={queryClient}>
24-
<Routes>
25-
<Route path="/" element={<Demo />} />
26-
<Route path="/landing" element={<LandingPage />} />
27-
<Route path="/university" element={<UniversitySearchPage />} />
28-
<Route path="/building" element={<BuildingSearchPage />} />
29-
<Route path="/map" element={<MapPage />} />
30-
<Route path="/form" element={<ReportForm />} />
31-
<Route path="/result" element={<NavigationResultPage />} />
32-
<Route path="/report/route" element={<ReportRoutePage />} />
33-
<Route path="/report/hazard" element={<ReportHazardPage />} />
34-
/** 에러 페이지 */
35-
<Route path="/error" element={<ErrorPage />} />
36-
<Route path="/error/offline" element={<OfflinePage />} />
37-
</Routes>
26+
<DynamicSuspense>
27+
<Routes>
28+
<Route path="/" element={<Demo />} />
29+
<Route path="/landing" element={<LandingPage />} />
30+
<Route path="/university" element={<UniversitySearchPage />} />
31+
<Route path="/building" element={<BuildingSearchPage />} />
32+
<Route path="/map" element={<MapPage />} />
33+
<Route path="/form" element={<ReportForm />} />
34+
<Route path="/result" element={<NavigationResultPage />} />
35+
<Route path="/report/route" element={<ReportRoutePage />} />
36+
<Route path="/report/hazard" element={<ReportHazardPage />} />
37+
/** 에러 페이지 */
38+
<Route path="/error" element={<ErrorPage />} />
39+
<Route path="/error/offline" element={<OfflinePage />} />
40+
</Routes>
41+
</DynamicSuspense>
3842
</QueryClientProvider>
3943
);
4044
}

uniro_frontend/src/component/NavgationMap.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { NavigationRoute } from "../data/types/route";
44
import createAdvancedMarker from "../utils/markers/createAdvanedMarker";
55
import createMarkerElement from "../components/map/mapMarkers";
66
import { Markers } from "../constant/enum/markerEnum";
7+
import useSuspenseMap from "../hooks/useSuspenseMap";
78

89
type MapProps = {
910
style?: React.CSSProperties;
@@ -17,7 +18,7 @@ type MapProps = {
1718
// TODO: 경로 로딩 완료시 살짝 zoomIn 하는 부분 구현하기
1819

1920
const NavigationMap = ({ style, routes, topPadding = 0, bottomPadding = 0 }: MapProps) => {
20-
const { mapRef, map, AdvancedMarker, Polyline } = useMap();
21+
const { mapRef, map, AdvancedMarker, Polyline } = useSuspenseMap();
2122

2223
const boundsRef = useRef<google.maps.LatLngBounds | null>(null);
2324

@@ -26,7 +27,7 @@ const NavigationMap = ({ style, routes, topPadding = 0, bottomPadding = 0 }: Map
2627
}
2728

2829
useEffect(() => {
29-
if (!map || !AdvancedMarker || !routes || !Polyline) return;
30+
if (!mapRef || !map || !AdvancedMarker || !routes || !Polyline) return;
3031

3132
const { route: routeList } = routes;
3233
if (!routeList || routeList.length === 0) return;
@@ -91,7 +92,7 @@ const NavigationMap = ({ style, routes, topPadding = 0, bottomPadding = 0 }: Map
9192
bottom: bottomPadding,
9293
left: 50,
9394
});
94-
}, [map, AdvancedMarker, Polyline, routes]);
95+
}, [mapRef, map, AdvancedMarker, Polyline, routes]);
9596

9697
useEffect(() => {
9798
if (!map || !boundsRef.current) return;
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import React from "react";
2+
import Loading from "../components/loading/loading";
3+
4+
export const fallbackConfig: Record<string, React.ReactNode> = {
5+
"/": <Loading isLoading={true} loadingContent={"로딩중입니다."} />,
6+
"/map": <Loading isLoading={true} loadingContent={"지도를 로딩하는 중입니다."} />,
7+
"/result": <Loading isLoading={true} loadingContent={"경로를 불러오는 중입니다."} />,
8+
"/report/route": <Loading isLoading={true} loadingContent={"지도를 불러오는 중입니다."} />,
9+
"/report/hazard": <Loading isLoading={true} loadingContent={"위험요소를 불러오는 중입니다."} />,
10+
"/university": <Loading isLoading={true} loadingContent={"대학교 정보를 불러오는 중입니다."} />,
11+
"/form": <Loading isLoading={true} loadingContent={"선택한 정보을 불러오는 중입니다."} />,
12+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ReactNode, Suspense } from "react";
2+
import { useFallbackStore } from "../hooks/useFallbackStore";
3+
4+
export const DynamicSuspense = ({ children }: { children: ReactNode }) => {
5+
const fallback = useFallbackStore((state) => state.fallback);
6+
7+
return <Suspense fallback={fallback}>{children}</Suspense>;
8+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useLocation } from "react-router";
2+
import { useFallbackStore } from "./useFallbackStore";
3+
import { useEffect } from "react";
4+
import { fallbackConfig } from "../constant/fallback";
5+
6+
export const useDynamicSuspense = () => {
7+
const location = useLocation();
8+
const { setFallback } = useFallbackStore();
9+
10+
useEffect(() => {
11+
const newFallback = fallbackConfig[location.pathname] || fallbackConfig["/"];
12+
setFallback(newFallback);
13+
}, [location.pathname, setFallback]);
14+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { create } from "zustand";
2+
import { fallbackConfig } from "../constant/fallback";
3+
4+
interface FallbackStore {
5+
fallback: React.ReactNode;
6+
setFallback: (fallback: React.ReactNode) => void;
7+
}
8+
9+
export const useFallbackStore = create<FallbackStore>((set) => {
10+
return {
11+
fallback: fallbackConfig["/"],
12+
setFallback: (f) => set({ fallback: f }),
13+
};
14+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useCallback, useEffect, useState } from "react";
2+
import { createMapResource } from "../map/createMapResource";
3+
4+
interface MapResource {
5+
map: google.maps.Map | null;
6+
AdvancedMarkerElement: typeof google.maps.marker.AdvancedMarkerElement | null;
7+
Polyline: typeof google.maps.Polyline | null;
8+
}
9+
10+
export function useSuspenseMap(mapOptions?: google.maps.MapOptions) {
11+
const [mapElement, setMapElement] = useState<HTMLDivElement | null>(null);
12+
13+
const [resource, setResource] = useState<{ read(): MapResource }>(() => createMapResource(null, mapOptions));
14+
15+
const mapRef = useCallback((node: HTMLDivElement | null) => {
16+
setMapElement(node);
17+
}, []);
18+
19+
useEffect(() => {
20+
setResource(createMapResource(mapElement, mapOptions));
21+
return () => {
22+
if (mapElement) {
23+
mapElement.innerHTML = "";
24+
}
25+
};
26+
}, [mapElement, mapOptions]);
27+
28+
const { map, AdvancedMarkerElement, Polyline } = resource.read();
29+
30+
return {
31+
mapRef,
32+
map,
33+
AdvancedMarker: AdvancedMarkerElement,
34+
Polyline,
35+
};
36+
}
37+
38+
export default useSuspenseMap;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { initializeMap } from "./initializer/googleMapInitializer";
2+
3+
export interface MapResource {
4+
map: google.maps.Map | null;
5+
AdvancedMarkerElement: typeof google.maps.marker.AdvancedMarkerElement | null;
6+
Polyline: typeof google.maps.Polyline | null;
7+
}
8+
9+
const dummyResource = {
10+
read() {
11+
return {
12+
map: null,
13+
AdvancedMarkerElement: null,
14+
Polyline: null,
15+
} as MapResource;
16+
},
17+
};
18+
19+
export function createMapResource(
20+
mapElement: HTMLDivElement | null,
21+
mapOptions?: google.maps.MapOptions,
22+
): { read(): MapResource } {
23+
if (!mapElement) {
24+
return dummyResource;
25+
}
26+
27+
let status = "pending";
28+
let result: MapResource;
29+
let suspender = initializeMap(mapElement, mapOptions)
30+
.then((res) => {
31+
status = "success";
32+
result = res;
33+
})
34+
.catch((e) => {
35+
status = "error";
36+
result = e;
37+
});
38+
39+
return {
40+
read() {
41+
if (status === "error") {
42+
throw result;
43+
} else if (status === "pending") {
44+
throw suspender;
45+
} else {
46+
return result;
47+
}
48+
},
49+
};
50+
}

uniro_frontend/src/map/initializer/googleMapInitializer.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,8 @@ export const initializeMap = async (
1515
): Promise<MapWithOverlay> => {
1616
const { Map, OverlayView, AdvancedMarkerElement, Polyline } = await loadGoogleMapsLibraries();
1717

18-
// useMap hook에서 error을 catch 하도록 함.
1918
if (!mapElement) {
20-
throw new Error("mapElement is null");
19+
throw new Error("Map Element is not provided");
2120
}
2221

2322
const map = new Map(mapElement, {

uniro_frontend/src/pages/demo.tsx

+5-3
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import DestinationIcon from "../assets/map/destination.svg?react";
99
import { useEffect, useState } from "react";
1010
import ReportButton from "../components/map/reportButton";
1111
import { CautionToggleButton, DangerToggleButton } from "../components/map/floatingButtons";
12-
import { useMutation, useQuery } from "@tanstack/react-query";
12+
import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query";
1313
import { getFetch, postFetch, putFetch } from "../utils/fetch/fetch";
14+
import SuspenseMapComponent from "../component/SuspenseMap";
15+
import { getMockTest } from "../utils/fetch/mockFetch";
1416
import SearchNull from "../components/error/SearchNull";
1517
import Offline from "../components/error/Offline";
1618
import Error from "../components/error/Error";
@@ -35,9 +37,9 @@ export default function Demo() {
3537
const [SuccessModal, isSuccessOpen, openSuccess, closeSuccess] = useModal();
3638
const [destination, setDestination] = useState<string>("역사관");
3739

38-
const { data, status } = useQuery({
40+
const { data, status } = useSuspenseQuery({
3941
queryKey: ["test"],
40-
queryFn: getTest,
42+
queryFn: getMockTest,
4143
});
4244

4345
const { data: postData, mutate: mutatePost } = useMutation<{ id: string }>({

uniro_frontend/src/pages/navigationResult.tsx

+18-12
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useEffect, useState } from "react";
1+
import React, { Suspense, useCallback, useEffect, useState } from "react";
22
import { AnimatePresence, PanInfo, useDragControls } from "framer-motion";
33
import Button from "../components/customButton";
44
import GoBack from "../assets/icon/goBack.svg?react";
@@ -12,13 +12,17 @@ import NavigationMap from "../component/NavgationMap";
1212
import NavigationDescription from "../components/navigation/navigationDescription";
1313
import BottomSheetHandle from "../components/navigation/bottomSheet/bottomSheetHandle";
1414

15+
import useLoading from "../hooks/useLoading";
16+
import { useSuspenseQuery } from "@tanstack/react-query";
17+
import { getMockTest } from "../utils/fetch/mockFetch";
1518
import Loading from "../components/loading/loading";
1619
import BackButton from "../components/map/backButton";
1720

1821
import useLoading from "../hooks/useLoading";
1922
import useUniversityInfo from "../hooks/useUniversityInfo";
2023
import useRedirectUndefined from "../hooks/useRedirectUndefined";
2124

25+
2226
// 1. 돌아가면 위치 reset ✅
2327
// 2. 상세경로 scroll 끝까지 가능하게 하기 ❎
2428
// 3. 코드 리팩토링 하기
@@ -43,14 +47,16 @@ const NavigationResultPage = () => {
4347

4448
useScrollControl();
4549

50+
const { data, status } = useSuspenseQuery({
51+
queryKey: ["test"],
52+
queryFn: getMockTest,
53+
});
4654
const { university } = useUniversityInfo();
4755
useRedirectUndefined<string | undefined>([university]);
4856

4957
useEffect(() => {
50-
show();
51-
const timer = setTimeout(hide, 5000);
52-
return () => clearTimeout(timer);
53-
}, []);
58+
console.log(data);
59+
}, [status]);
5460

5561
const dragControls = useDragControls();
5662

@@ -77,7 +83,12 @@ const NavigationResultPage = () => {
7783

7884
return (
7985
<div className="relative h-svh w-full max-w-[450px] mx-auto">
80-
<Loading isLoading={isLoading} loadingContent="경로 탐색 중입니다" />
86+
<NavigationMap
87+
style={{ width: "100%", height: "100%" }}
88+
routes={route}
89+
topPadding={topBarHeight}
90+
bottomPadding={sheetHeight}
91+
/>
8192
<AnimatedContainer
8293
isVisible={!isDetailView && !isLoading}
8394
positionDelta={286}
@@ -87,12 +98,7 @@ const NavigationResultPage = () => {
8798
>
8899
<NavigationDescription isDetailView={!isDetailView && !isLoading} />
89100
</AnimatedContainer>
90-
<NavigationMap
91-
style={{ width: "100%", height: "100%" }}
92-
routes={route}
93-
topPadding={topBarHeight}
94-
bottomPadding={sheetHeight}
95-
/>
101+
96102
<AnimatedContainer
97103
isVisible={!isDetailView && !isLoading}
98104
className="absolute bottom-0 left-0 w-full mb-[30px] px-4"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export function mockRealisticFetch<T>(
2+
data: T,
3+
minDelay: number = 1000,
4+
maxDelay: number = 4000,
5+
failRate: number = 0.2,
6+
): Promise<T> {
7+
const delay = Math.floor(Math.random() * (maxDelay - minDelay + 1)) + minDelay;
8+
const shouldFail = Math.random() < failRate;
9+
10+
return new Promise((resolve, reject) => {
11+
console.log(`${delay / 1000}초 동안 로딩 중...`);
12+
13+
setTimeout(() => {
14+
if (shouldFail) {
15+
console.error("네트워크 오류 발생!");
16+
reject(new Error("네트워크 오류 발생"));
17+
} else {
18+
console.log("데이터 로드 완료:", data);
19+
resolve(data);
20+
}
21+
}, delay);
22+
});
23+
}
24+
25+
export const getMockTest = async () => {
26+
return mockRealisticFetch({ message: "Hello from Mock API" });
27+
};

0 commit comments

Comments
 (0)