graniteEvent.addEventListener("backEvent") 해제 후 네이티브 기본 동작(종료 얼럿)이 복원되지 않습니다

이 글의 성격은 무엇인가요?

질문 / 문제 해결

내용을 설명해주세요

WebView 미니앱에서 graniteEvent.addEventListener("backEvent", ...) 으로 뒤로가기 이벤트를 등록한 뒤, unsubscription() 으로 해제하면 네이티브 기본 동작(종료 확인 얼럿)이 복원되지 않는 현상을 발견했습니다.

재현 경로

  1. 인트로 페이지(최초 화면)에서 Nav Bar 뒤로가기 클릭 → 네이티브 종료 얼럿 정상 표시 :white_check_mark:
  2. 다른 페이지로 이동 → 해당 페이지에서 graniteEvent.addEventListener("backEvent", { onEvent: ... }) 등록
  3. Nav Bar 뒤로가기 클릭 → JS 핸들러 정상 호출 :white_check_mark:
  4. navigate(-1) 로 인트로 페이지로 복귀 → 컴포넌트 언마운트 시 useEffect cleanup에서 unsubscription() 호출
  5. 인트로 페이지에서 Nav Bar 뒤로가기 클릭 → 네이티브 종료 얼럿이 표시되지 않음 :cross_mark:

기대 동작

모든 JS backEvent 핸들러가 해제된 후, 네이티브 기본 동작(첫 화면에서의 종료 확인 얼럿)이 복원되어야 합니다.

실제 동작

한 번이라도 addEventListener("backEvent")를 등록/해제하면, 이후 해당 WebView 세션에서 네이티브 기본 동작이 동작하지 않습니다. 앱을 완전히 종료하고 재진입하면 다시 정상 동작합니다.

환경

  • @apps-in-toss/web-framework 2.x (WebView)
  • React SPA (React Router로 클라이언트 라우팅)

현재 workaround

인트로 페이지에서 직접 useBackEvent + closeView() 로 종료 처리하고 있습니다. 다만 이 방식은 네이티브 종료 얼럿의 문구/동작이 변경될 경우 불일치가 발생할 수 있습니다.

appName (선택)

draw

안녕하세요 :slight_smile:
backEvent를 해제하면, 기본 알럿이 표시되어야하는데
다른 페이지에서 인트로 페이지로 복귀할때 cleanup이 제대로 호출되었을까요 ?

export function useBackEvent(onBack?: () => void): void {
	const callbackRef = useRef(onBack);
	callbackRef.current = onBack;

	useEffect(() => {
		return graniteEvent.addEventListener("backEvent", {
			onEvent() {
				callbackRef.current?.();
			},
		});
	}, []);
}

네 복귀할 때 useEffect의 cleanup 콜백에서 구독 해제 호출하고 있습니다.

다른 페이지에서 이런식으로 쓰고 계신가요 ?!

  useBackEvent(() => {
    const ok = window.confirm(
      '',
    )
    if (ok) {
      navigate(-1)
    }
  })

네 맞습니다!

const handleBack = useCallback(() => navigate(-1), [navigate]);

useBackEvent(handleBack);

음.. 동일하게 라우팅 및 뒤로가기 이벤트를 해지하였을때 정상적으로 기본 알럿이 나오는데,
인트로 페이지에서 useBackEvent 이벤트 관련 코드는 모두 제거 후, 다시 한번 확인해주시겠어요 ?

@Dylan

추가 조사 결과를 공유합니다.

backEvent는 무관하고 history.replaceState() 자체가 원인이었습니다.

최소 재현

backEvent listener 없이, 아무 페이지에서 아래만 실행하면 네이티브 종료 얼럿이 영구적으로 깨집니다:

history.replaceState(null, "", "/any-path");

이후 history.back()으로 첫 페이지(히스토리 바닥)에 도달해도 네이티브 종료 얼럿이 뜨지 않습니다.

격리 테스트 결과

조건 종료 얼럿
backEvent add/remove만 (replaceState 없음) :white_check_mark: 정상
backEvent + replaceState :cross_mark:
replaceState만 (backEvent 없음) :cross_mark:

영향

React Router 등 SPA에서 navigate({ replace: true })를 사용하면 내부적으로 history.replaceState()를 호출하므로, replace 네비게이션을 쓰는 모든 앱인토스 웹앱에서 동일 증상이 발생할 수 있습니다.

현재 워크어라운드로 인트로에서 useBackEvent + closeView()로 종료를 직접 처리하고 있습니다.

1개의 좋아요

replaceState라면 state가 대체되어 깨질 수 있을 것 같아요 :cry:
혹시 replaceState가 필요하신 이유를 알 수 있을까요 ?

다단계 SPA 퍼널에서 all-replace 네비게이션 전략을 사용하고 있습니다.

첫 페이지 진입 시 push 1회, 이후 모든 전환은 navigate({ replace: true })로 처리해서 히스토리 스택을 항상 [/, /현재페이지](최대 2개)로 유지합니다. 웹뷰에서 history.clear()가 불가능하다 보니, 뒤로가기 한 번에 홈으로 돌아가는 앱다운 흐름을 만들려면 replace가 유일한 방법이었습니다.

React Router 등 SPA 프레임워크에서 흔히 쓰이는 패턴이라 다른 앱에서도 동일 증상이 발생할 수 있을 것 같습니다. 혹시 WebView 환경에서 히스토리 스택을 관리하면서 네이티브 종료 얼럿을 유지할 수 있는 권장 패턴이 있을까요? 현재는 워크어라운드로 useBackEvent + closeView()로 직접 종료를 처리하고 있습니다.

뒤로가기 한번에 홈으로 랜딩하는 플로우가 필요하시다면,
홈버튼을 추가해보시는건 어떨까요?


답변 감사합니다!

홈버튼 제안을 검토해봤는데, 저희 서비스 구조상 홈버튼만으로는 해결이 어려운 상황이라 공유드립니다.

all-replace가 필요한 이유

저희 서비스는 다단계 퍼널 구조라 push를 사용하면 히스토리 스택이 10개 이상으로 깊어집니다.

이때 Android 하단 시스템 백버튼이 문제입니다. iOS는 setIosSwipeGestureEnabled(false)로 스와이프를 막을 수 있지만, Android 시스템 백버튼은 비활성화가 불가능합니다. 스택이 깊은 상태에서 백버튼을 누르면 이미 완료된 중간 단계 화면들을 다시 거치게 되어 UX가 깨집니다.

홈버튼을 추가해도 "홈으로 가는 추가 경로"가 생길 뿐, Android 백버튼으로 스택을 역주행하는 문제는 그대로 남습니다.

현재 워크어라운드

진입 페이지에서 useBackEvent + closeView()로 종료를 직접 처리하고 있습니다. 동작에는 문제가 없지만, 네이티브 종료 얼럿의 문구나 동작이 변경될 경우 불일치가 생길 수 있다는 점이 아쉽습니다.

요청사항

replaceState() 호출이 네이티브 종료 얼럿 상태를 깨뜨리는 현상은, 다단계 SPA 미니앱에서 흔히 사용하는 패턴에 영향을 주는 플랫폼 이슈로 보입니다. 이 부분에 대한 수정 계획이 있는지 확인 부탁드려도 될까요?