react-router 에서 히스토리 API로 어플리케이션 같은 Stack View 만들기
근데 이게 맞는지는 모르겠음
요구사항
문제
하이브리드 애플리케이션에서 동작하는 웹뷰 브라우저에서 히스토리가 A -> B -> C -> D 순서로 추가되었다고 가정하자. 이때, B에 진입한 다음, 뒤로가기 (popstate) 를 하는 경우 언제나 B로 돌아와야 한다.
가령, A -> B -> C -> D로 쌓인 Stack에서 뒤로가기를 하는 경우 C / D를 무시하고 B까지 stack이 pop되어야 한다.
대략적으로 안드로이드의 Intent와 유사한 흐름이라고 할 수 있을 것이다. 단지 그게 웹에서 동작해야 할 뿐...
제약 조건
- 히스토리 스택에 쌓이는 페이지의 origin이 언제나 동일하다는 보장이 없다.
- 뒤로 갔다가 앞으로 가는 경우는 기본적으로 상정하지 않기로 한다.
구현
/* hooks/use-check-point.ts */
import { useEffect, useCallback } from "react"
import { useBlocker } from "react-router"
// 전역에서 사용할 키를 지정한다.
const SESSION_STORAGE_CHECKPOINT_KEY = "check-point"
export function useCheckPoint() {
const navigate = useNavigate()
const location = useLocation()
/**
* 현재 상태를 체크포인트에 저장한다.
*/
function save(): void {
// 만약 history.state.saved가 존재하지 않을 경우 === 처음 save를 하는 경우
if (!history.state.saved) {
// history.pushState를 통해서 history.length를 현재 페이지를 기준으로 삼는다.
history.pushState({ ...history.state, saved: true }, "", "")
}
// 세션 스토리지에 저장한다.
globalThis.sessionStorage?.setItem(
SESSION_STORAGE_CHECKPOINT_KEY,
JSON.stringify({
// 전 단계에서 history.length가 1개 더 추가되었기에, -1을 통하여 이를 보정한다.
length: history.length - 1,
}),
)
}
/**
* 체크포인트가 아래 조건을 만족하는지 확인한다.
* - 체크포인트가 존재한다
*/
function exist() {
const checkPoint = globalThis.sessionStorage?.getItem(
SESSION_STORAGE_CHECKPOINT_KEY,
)
return checkPoint !== null
}
/**
* 현재 유효한 체크포인트가 있는지 확인한다.
* 아래 조건에 해당하는 경우 null을 반환한다.
* - 체크포인트가 없는 경우
* - 체크포인트의 상태값이 현재 상태값과 일치하는(같은 페이지인) 경우
*/
function get(): { length: number } | null {
const checkPoint = globalThis.sessionStorage?.getItem(
SESSION_STORAGE_CHECKPOINT_KEY,
)
if (!checkPoint) return null
const parsed = JSON.parse(checkPoint) as { length: number }
if (parsed.length === history.length - 1) {
return null
}
return parsed
}
/**
* 체크포인트로 이동한다.
* 체크포인트가 없는 경우 아무것도 하지 않는다.
*/
async function restore(fallback = () => null): Promise<boolean> {
const checkPoint = get()
// 만약 체크포인트가 존재하는 경우
if (checkPoint !== null) {
const delta = history.length - checkPoint.length
await navigate(-delta)
globalThis.sessionStorage?.removeItem(SESSION_STORAGE_CHECKPOINT_KEY)
return true
}
// 가끔은 엣지 케이스로 checkPoint가 존재하지 않는 경우가 있다.
// 이때는 fallback 코드를 실행한다.
globalThis.sessionStorage?.removeItem(SESSION_STORAGE_CHECKPOINT_KEY)
return fallback()
}
}
/* providers/check-point-provider.tsx */
import { useCheckPoint } from "~/hooks/use-check-point"
import { useEffect, useRef } from "react"
import { useBlocker, useLocation, useNavigate, useOutlet } from "react-router"
import type { Route } from "./+types/index"
export default function StackProvider({ matches }: Route.ComponentProps) {
const navigate = useNavigate()
const { get, exist, restore } = useCheckPoint()
// 현재 뒤로가기가 block 된 이유를 저장한다.
const blockType = useRef<"checkPoint" | "default" | null>(null)
// 뒤로가기 action이 발생하는 경우
// 1. 조건에 따라 blockType을 지정하고
// 2. 뒤로가기 원래 동작을 무시한다.
const blocker = useBlocker(
({ historyAction, currentLocation, nextLocation }) => {
switch (historyAction) {
case "POP": {
// 체크포인트가 있을 경우, 해당 체크포인트로 이동
if (get() !== null) {
blockType.current = "checkPoint"
return true
}
blockType.current = "default"
return true
}
default: {
return false
}
}
},
)
useEffect(
function processBlocker() {
void (async () => {
if (blocker.state === "blocked") {
switch (blockType.current) {
// 체크포인트 이벤트가 실행되어야 하는 경우
case "checkPoint": {
blockType.current = null
// 기존 뒤로가기를 무시한다.
void blocker.reset()
// 체크포인트로 이동한다.
void restore()
return
}
// 그 외
default: {
blockType.current = null
// 체크포인트가 존재하는데 뒤로 가는 경우,
if (exist()) await navigate(-1) // save 과정에서 pushState가 하나 더 생성되었기 때문에 뒤로 1칸 더 이동한다.
// 기존 뒤로가기 이벤트를 실행한다.
void blocker.proceed()
return
}
}
}
})()
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[blocker.state, matches, navigate, restore],
)
return useOutlet()
}
코드의 흐름
페이지별 동작
- A: 프로세스 진입 전 페이지
- B: 플로우를 시작하는 페이지
- C로 넘어가는 무언가가 발동될 경우, save 함수를 호출하여 현재 시점을 저장한다.
- C: 중간에 발생하는 페이지
- 그러나 이때부터 뒤로가기 이벤트가 발생할 경우, restore 함수를 호출하여 B로 Stack을 pop할 것이다.
- D: 결과 페이지
- 이 페이지에서 뒤로가기 액션을 취할 경우 restore 함수를 호출해야 한다.
실제 코드의 흐름
- A에 처음으로 진입한다. (history.length === 1)
- B에서 C로 넘어간다. (2)
- 이때 save 함수가 실행되며 가상의 stack이 1개 더 추가된다. (3)
- C에서 D로 넘어간다. (4)
- D에서 뒤로가기 이벤트가 발생한다. (5)
- useBlocker hook이 실행된다.
- get 함수를 호출하여 체크포인트가 존재하는지 확인한다.
- 존재하기에, blockType을 checkPoint로 지정한다.
- processBlocker useEffect가 실행된다.
- 기존 뒤로가기 이벤트를 무시하고
- restore 함수를 호출한다.
- B로 돌아간다 (2)
- 기존 체크포인트를 제거한다.
FAQ
save 함수가 실행되기 전에 pushState를 실행하는 이유는?
기본적으로, 브라우저의 history.length는 현재 페이지의 history.length를 정확하게 가져오지 않기에 pushState를 통해서 앞으로 가기를 제거함으로써 그나마 정확한 history.length를 가져올 수 있게 된다.
왜 이렇게 구현하냐면, 브라우저는 뒤로가기를 했다가 앞으로 가기를 할 수 있기 때문이다. 예시로, A -> (B -> C -> D) -> E처럼 B ~ D 구간에서는 뒤로가기를 막아두지 않은 상태에서 B -> C -> D -> C -> B로 이동하는 경우... 브라우저상의 history.length는 D (4)가 되지만 (C -> D로 돌아갈 수 있음), 실제로는 B (2)이기에 실제와 브라우저상의 환경 불일치가 발생하기 때문이다.
네이티브 기능을 사용하지 않고 이렇게 구현한 이유는?
해당 웹뷰 콘텐츠가 웹으로 오픈될 수 있음을 염두해두어야 했기 때문이다.
지금 와서 생각해보면, RN에서 페이지가 이동될 때 URL을 바탕으로 Stack을 저장하게 하면 정신건강에 더 이로웠을 수도 있다. 하지만 당시에는 이런 판단을 하기에는 시간이 부족했었고, 실제 웹 환경에서도 동일하게 동작해야 하기에 이렇게 구현하기로 판단했었다.
결과적으로는 이게 가장 나은 선택이었던 것 같다. (아닐 수도)
닫으며
세상에서 가장 좋은 코드는 돌아가는 코드라고 하지만, 그래도 썩 만족스럽지는 않은것 같다. 무언가 더 좋은 방법이 있지 않을까? 라는 생각 + 나와 비슷한 사람들이 있을까? 라는 생각으로 이 글을 작성했다.
당신만의 더 좋은 의견이 있다면, hello@nabi.kim 으로 메일을 보내주시면 꼭 사례하도록 하겠습니다 🙇♀️