feat: 스플래시 표시 게이팅 로직 추가
Co-Authored-By: Claude Opus 5
@fb938db3b9f97d3892d6121139f6f16de7dcc81c
+++ app/_components/splash-gate.tsx
... | ... | @@ -0,0 +1,76 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useEffect, useRef, useState } from 'react'; | |
| 4 | +import { SplashScreen } from '@/components/ui/splash-screen'; | |
| 5 | +import { SPLASH_SHOWN_STORAGE_KEY } from '@/lib/constants/splash'; | |
| 6 | + | |
| 7 | +/** 로딩 자체가 즉시 끝나도 스플래시가 깜빡이지 않도록 유지하는 최소 표시 시간(ms). */ | |
| 8 | +const SPLASH_MIN_DISPLAY_MS = 700; | |
| 9 | + | |
| 10 | +function hasShownSplashThisSession(): boolean { | |
| 11 | + try { | |
| 12 | + return sessionStorage.getItem(SPLASH_SHOWN_STORAGE_KEY) === '1'; | |
| 13 | + } catch { | |
| 14 | + return false; | |
| 15 | + } | |
| 16 | +} | |
| 17 | + | |
| 18 | +function markSplashShown(): void { | |
| 19 | + try { | |
| 20 | + sessionStorage.setItem(SPLASH_SHOWN_STORAGE_KEY, '1'); | |
| 21 | + } catch { | |
| 22 | + // sessionStorage 접근 불가 환경(시크릿 모드 제한 등) — 매 로드마다 재표시되는 것으로 완화한다. | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** | |
| 27 | + * 스플래시 표시 게이팅 로직 — 비주얼(SplashScreen, design 소유)은 항상 서버 렌더와 동일하게 | |
| 28 | + * 마운트해 hydration 불일치를 만들지 않는다. "이미 이번 세션에 표시했음"으로 인한 깜빡임 방지는 | |
| 29 | + * `app/layout.tsx`의 `<head>` 동기 inline script + CSS가 첫 페인트 전에 `<html>`에 부여한 | |
| 30 | + * data attribute가 담당한다(useEffect는 첫 페인트 이후에 실행되어 깜빡임을 막을 수 없다 — | |
| 31 | + * preventing-flash-before-hydration.md). 이 컴포넌트는 같은 판정을 렌더 중 ref에 1회 계산해두고, | |
| 32 | + * 이미 표시한 세션이면 effect에서 타이머 자체를 걸지 않는다(= state를 effect 본문에서 동기 | |
| 33 | + * 호출하지 않아 cascading render를 만들지 않는다). | |
| 34 | + */ | |
| 35 | +export function SplashGate() { | |
| 36 | + const [isVisible, setIsVisible] = useState(true); | |
| 37 | + const [isExiting, setIsExiting] = useState(false); | |
| 38 | + const skipIntroRef = useRef<boolean | null>(null); | |
| 39 | + | |
| 40 | + if (skipIntroRef.current === null) { | |
| 41 | + skipIntroRef.current = | |
| 42 | + typeof window !== 'undefined' && hasShownSplashThisSession(); | |
| 43 | + } | |
| 44 | + | |
| 45 | + useEffect(() => { | |
| 46 | + if (skipIntroRef.current) { | |
| 47 | + // 이미 <head> inline script + CSS로 숨겨진 상태 — 타이머/퇴장 애니메이션을 돌릴 필요가 없다. | |
| 48 | + return; | |
| 49 | + } | |
| 50 | + | |
| 51 | + const timer = setTimeout(() => { | |
| 52 | + setIsExiting(true); | |
| 53 | + }, SPLASH_MIN_DISPLAY_MS); | |
| 54 | + | |
| 55 | + return () => clearTimeout(timer); | |
| 56 | + }, []); | |
| 57 | + | |
| 58 | + function handleExitAnimationEnd() { | |
| 59 | + markSplashShown(); | |
| 60 | + setIsVisible(false); | |
| 61 | + } | |
| 62 | + | |
| 63 | + if (!isVisible) { | |
| 64 | + return null; | |
| 65 | + } | |
| 66 | + | |
| 67 | + return ( | |
| 68 | + <div | |
| 69 | + data-splash-gate | |
| 70 | + className={isExiting ? 'animate-fade-out' : undefined} | |
| 71 | + onAnimationEnd={isExiting ? handleExitAnimationEnd : undefined} | |
| 72 | + > | |
| 73 | + <SplashScreen /> | |
| 74 | + </div> | |
| 75 | + ); | |
| 76 | +} |
--- app/layout.tsx
+++ app/layout.tsx
... | ... | @@ -3,7 +3,23 @@ |
| 3 | 3 |
import { ViewTransition } from "react";
|
| 4 | 4 |
import { FeedbackHost } from "@/app/_components/feedback-host";
|
| 5 | 5 |
import { FeedbackProvider } from "@/app/_components/feedback-provider";
|
| 6 |
+import { SplashGate } from "@/app/_components/splash-gate";
|
|
| 7 |
+import { SPLASH_SHOWN_STORAGE_KEY } from "@/lib/constants/splash";
|
|
| 6 | 8 |
import "./globals.css"; |
| 9 |
+ |
|
| 10 |
+// 스플래시 재표시 방지 — <head>는 <body>보다 먼저 파싱되므로 body 하위 요소는 아직 존재하지 |
|
| 11 |
+// 않는다(preventing-flash-before-hydration.md의 Themes 패턴과 동일하게 <html>만 조작 가능). |
|
| 12 |
+// 이번 세션에 이미 표시했다면 첫 페인트 전에 <html>에 data attribute를 부여하고, 아래 스타일 |
|
| 13 |
+// 규칙이 SplashGate 루트를 즉시 숨긴다 — useEffect보다 먼저 실행되어 깜빡임이 없다. |
|
| 14 |
+// |
|
| 15 |
+// SPLASH_SHOWN_STORAGE_KEY는 반드시 'use client' 파일이 아닌 lib/constants/splash에서 |
|
| 16 |
+// import한다 — splash-gate.tsx('use client')의 일반 값(문자열) export를 Server Component가
|
|
| 17 |
+// 직접 import하면 클라이언트 레퍼런스 프록시 특성상 undefined로 평가된다(컴포넌트 export만 |
|
| 18 |
+// 정상적으로 프록시된다). |
|
| 19 |
+const SPLASH_HIDE_STYLE = `html[data-splash-shown] [data-splash-gate]{display:none}`;
|
|
| 20 |
+const SPLASH_GATE_SCRIPT = `(function(){try{if(sessionStorage.getItem(${JSON.stringify(
|
|
| 21 |
+ SPLASH_SHOWN_STORAGE_KEY |
|
| 22 |
+)}))document.documentElement.setAttribute("data-splash-shown","")}catch(e){}})()`;
|
|
| 7 | 23 |
|
| 8 | 24 |
const geistSans = Geist({
|
| 9 | 25 |
variable: "--font-geist-sans", |
... | ... | @@ -43,13 +59,18 @@ |
| 43 | 59 |
return ( |
| 44 | 60 |
<html |
| 45 | 61 |
lang="ko" |
| 62 |
+ suppressHydrationWarning |
|
| 46 | 63 |
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
| 47 | 64 |
> |
| 65 |
+ <head> |
|
| 66 |
+ <style dangerouslySetInnerHTML={{ __html: SPLASH_HIDE_STYLE }} />
|
|
| 67 |
+ <script dangerouslySetInnerHTML={{ __html: SPLASH_GATE_SCRIPT }} />
|
|
| 68 |
+ </head> |
|
| 48 | 69 |
<body className="min-h-full flex flex-col"> |
| 70 |
+ <SplashGate /> |
|
| 49 | 71 |
<FeedbackProvider> |
| 50 | 72 |
{/* 라우트 그룹 간 이동을 포함한 모든 전환에 크로스페이드를 적용한다. 클래스명(layout-transition)에
|
| 51 |
- 대한 `::view-transition-old/new` 규칙은 design 레인이 app/globals.css에 제공한다 — 합류 |
|
| 52 |
- 전에는 브라우저 기본 크로스페이드로 동작한다. */} |
|
| 73 |
+ 대한 `::view-transition-old/new` 규칙은 design 레인이 app/globals.css에 제공한다. */} |
|
| 53 | 74 |
<ViewTransition default="layout-transition">{children}</ViewTransition>
|
| 54 | 75 |
{/* 피드백 오버레이는 전환 크로스페이드 대상이 아니므로 ViewTransition 바깥에 둔다. */}
|
| 55 | 76 |
<FeedbackHost /> |
+++ lib/constants/splash.ts
... | ... | @@ -0,0 +1,10 @@ |
| 1 | +/** | |
| 2 | + * 스플래시를 세션당 1회만 표시했음을 기록하는 sessionStorage 키. | |
| 3 | + * | |
| 4 | + * `app/layout.tsx`(Server Component — `<head>` inline script 문자열 생성)와 | |
| 5 | + * `app/_components/splash-gate.tsx`('use client') 양쪽이 같은 값을 참조해야 한다. 'use client' | |
| 6 | + * 모듈의 export는 컴포넌트가 아닌 일반 값(문자열 등)일 경우 Server Component에서 import 시 | |
| 7 | + * `undefined`로 평가된다 — Next.js의 클라이언트 레퍼런스 프록시가 컴포넌트 형태만 가정하기 | |
| 8 | + * 때문이다. 그래서 이 상수는 'use client'가 없는 순수 모듈로 분리한다. | |
| 9 | + */ | |
| 10 | +export const SPLASH_SHOWN_STORAGE_KEY = "edupay-admin-splash-shown"; |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?