← Tất cả bài viết
WebGL Hero Biến Mất Khi Bấm Nút Back Trình Duyệt
Nền WebGL Next.js chạy ngon lúc tải trang và khi điều hướng Link, nhưng trắng xóa sau khi bấm nút Back? Đây là nguyên nhân popstate và cách rebuild scene R3F.

Đây là một lỗi lên production nhiều hơn nó đáng ra được phép: WebGL hero biến mất khi bấm nút Back của trình duyệt. Lần tải đầu tiên hoàn hảo. Điều hướng bằng <Link> client-side chạy ngon. Bấm Forward vẫn ổn. Rồi người dùng bấm nút Back gốc của trình duyệt, quay lại trang chủ, và canvas trắng xóa — nền biến mất, FPS bình thường, không một dòng lỗi nào trên console để mà lần theo. Tuần này tôi vừa truy đúng lỗi này trên một site có hero được dựng bởi preloader R3F, nên để tôi tiết kiệm cho bạn cả một buổi chiều.
Nói ngắn gọn: một scene được khởi tạo một lần duy nhất bởi preloader lúc boot sẽ không có đường rebuild khi người dùng quay lại trang sở hữu nó. Điều hướng trong app và popstate/traverse đi theo hai luồng code khác nhau, và gần như ai cũng xử lý cái đầu rồi quên mất cái sau.
Triệu chứng, thật chính xác
Tái hiện đúng theo thứ tự này — chính thứ tự mới là manh mối:
- Hard-load trang chủ. WebGL hero render. Ổn.
- Bấm
<Link>sang/blog. Rời trang. Ổn. - Bấm nút Back của trình duyệt (không phải link back trong app). Bạn đang ở
/lại. - Canvas trắng xóa. Không hero. Không lỗi.
requestAnimationFramevẫn chạy, FPS vẫn tốt — chỉ là chẳng có gì trong scene.
Nếu bước 2 là link trong app còn bước 3 là nút Back/Forward gốc của trình duyệt, thì chính sự bất đối xứng đó là dấu vân tay. Hệ thống transition được test bằng cách bấm quanh site; nó chưa bao giờ được test với thanh điều khiển của trình duyệt. Đây là lý do phổ biến nhất khiến nền WebGL Next.js mất sau khi bấm nút back.
Nguyên nhân gốc: dựng một lần, không bao giờ dựng lại
Phần lớn site cao cấp dựng scene hero bên trong một preloader lúc boot — cái intro chạy ở lần tải đầu tiên, sample một tấm ảnh hoặc decode texture, lắp ráp particle field, rồi chạy reveal. Để giữ soft navigation nhẹ nhàng, scene đó bị bỏ lại một cách có chủ đích khi rời trang. Pattern thường trông như thế này:
// PreloaderScene.tsx — phiên bản ngây thơ
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
export function PreloaderScene({ initialPath }: { initialPath: string }) {
const pathname = usePathname()
const [done, setDone] = useState(false)
useEffect(() => {
// "Bỏ lại khi soft nav" — unmount ngay khi rời route sở hữu.
if (pathname !== initialPath) setDone(true)
}, [pathname, initialPath])
if (done) return null // ← cái bẫy
return <HeroCanvas />
}
Đọc kỹ cái effect. Nó chỉ bao giờ set done thành true. Khoảnh khắc pathname !== initialPath, scene bị tháo dỡ — đúng, đó là tối ưu có chủ đích. Nhưng không có nhánh nào lật done trở lại false khi bạn quay về initialPath. Một khi cờ đã chốt, if (done) return null giữ scene unmount mãi mãi. Lần rời trang đầu tiên giết nó vĩnh viễn, và trigger dễ thấy nhất là nút Back gốc vì đó là cách tự nhiên nhất người dùng quay lại route gốc.
Đây hoàn toàn không phải lỗi WebGL. GL context, shader, vòng useFrame — tất cả đều ổn. Component chứa chúng đơn giản là không bao giờ re-mount. Tự kiểm chứng: log bên trong render của HeroCanvas. Lúc quay về, nó không chạy. Xong.
Vì sao nút Back trình duyệt là ca người ta bỏ sót
Có ba cách khác nhau để quay lại route sở hữu scene, và chúng không đều đi qua handler <Link> của bạn:
| Đường vào | Cơ chế | Bắn popstate? | navigationType (Navigation API) |
|---|---|---|---|
Bấm <Link> trong app | App Router push | Không | push |
router.back() (lập trình) | History traverse | Có | traverse |
| Nút Back / Forward gốc | History traverse | Có | traverse |
| Reload toàn trang | Tải document | Không (doc mới) | reload |
Code preloader, transition và SPA-nav tùy chỉnh thường xuyên chặn cú click — nó bọc <Link>, nó animate khi router.push. Nhưng nút Back/Forward của trình duyệt không đi qua bất cứ thứ nào trong đó. Chúng phát ra sự kiện popstate, và ở trình duyệt hiện đại là sự kiện navigate với navigationType: 'traverse'. Nếu logic rebuild của bạn chỉ nằm trong lớp bọc Link, traverse âm thầm bỏ qua nó. Đó chính là lý do hero sống sót khi Forward nhưng chết khi Back.
usePathname() có cập nhật khi traverse (App Router giữ nó đồng bộ), nên effect theo pathname là hook đúng — bạn chỉ cần làm cho nó xử lý cả lúc quay về, không chỉ lúc rời đi.
Cách sửa: một đường rebuild-khi-quay-lại tường minh
Mô hình tư duy: preloader lúc boot sở hữu lần render đầu tiên, còn effect theo pathname sở hữu mọi lần vào lại. Khi quay về bạn không replay cả cái intro — bạn dựng lại scene và snap thẳng tới trạng thái đã đáp.
// PreloaderScene.tsx — phiên bản đã sửa
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
export function PreloaderScene({ initialPath }: { initialPath: string }) {
const pathname = usePathname()
const [mounted, setMounted] = useState(true)
const bootedRef = useRef(false) // chốt: đừng chạy trùng ở lần render đầu
useEffect(() => {
// Lần chạy effect đầu tiên thuộc về preloader boot — bỏ qua ở đây.
if (!bootedRef.current) {
bootedRef.current = true
return
}
if (pathname !== initialPath) {
// Rời route sở hữu → bỏ scene lại (tối ưu).
setMounted(false)
} else {
// Quay về route sở hữu → rebuild tường minh.
reestablishScene()
setMounted(true)
}
}, [pathname, initialPath])
if (!mounted) return null
return <HeroCanvas skipIntro={bootedRef.current} />
}
Cái chốt bootedRef rất quan trọng: không có nó effect sẽ chạy cả ở lần render đầu và bạn sẽ kích hoạt trùng phần setup mà boot đã làm (một cú nháy, hoặc hai timeline tranh nhau). Bỏ qua lần chạy đầu; để boot sở hữu nó.
reestablishScene() là nơi công việc thật diễn ra. Nó làm lại đúng những gì boot đã làm để tạo ra scene, rồi nhảy thẳng tới trạng thái cuối thay vì animate từ số không:
// reestablishScene.ts
import gsap from 'gsap'
export function reestablishScene() {
// 1) Tôn trọng ĐÚNG những guard mà boot đã dùng — đừng rebuild cái boot đã bỏ qua.
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const isMobile = window.matchMedia('(max-width: 768px)').matches
const supportsWebGL = !!document.createElement('canvas').getContext('webgl2')
if (isMobile || !supportsWebGL) return // boot chưa từng dựng ở đây → không có gì để khôi phục
// 2) Dựng lại INPUT của scene — sample/decode lại nguồn mà boot đã tiêu thụ.
// (Texture đã decode / tone ảnh đã sample có thể đã bị GC cùng mount cũ.)
rebuildSceneInputs()
// 3) Snap tới trạng thái đã đáp được lưu — bỏ qua đoạn intro.
const target = readPersistedHeroState() // tư thế "ổn định" không phụ thuộc scroll
applyHeroState(target, { immediate: true })
// 4) Fade-in nhanh để cú swap không phải một cú pop cứng.
gsap.killTweensOf('.hero-canvas')
gsap.fromTo(
'.hero-canvas',
{ autoAlpha: reduceMotion ? 1 : 0 },
{ autoAlpha: 1, duration: reduceMotion ? 0 : 0.4, ease: 'power2.out' },
)
}
Bốn nguyên tắc làm cho nó chắc chắn:
- Dựng lại input, đừng giả định chúng còn sống. Khi mount cũ bị tháo, texture đã decode và dữ liệu ảnh đã sample có thể bị garbage-collect. Lúc quay về bạn thường phải decode/sample lại — khôi phục mỗi React state cho bạn một scene với buffer rỗng (lại trắng).
- Snap tới trạng thái đã đáp, bỏ qua intro. Nhiệm vụ của intro là màn reveal ấn tượng đầu tiên. Khi quay lại bằng nút Back người dùng đã xem rồi; replay một màn lắp ráp kéo dài vài giây cảm giác như hỏng. Nhảy tới tư thế ổn định, rồi fade 400 ms.
- Tôn trọng guard của boot. Nếu boot bỏ WebGL trên mobile hay khi reduced-motion,
reestablishScene()cũng phải bỏ — nếu không bạn triệu hồi một scene lên đúng thiết bị đã cố tình được tha. - Chốt lần render đầu. Guard
bootedRefngăn boot và return cùng bắn khi tải trang.
Bắt cú traverse mà effect theo pathname không thấy
usePathname() phủ được các transition của App Router, nhưng nếu bạn có logic mệnh lệnh gắn với history thật của trình duyệt — một snapshot scroll, trạng thái audio, vị trí Lenis — hãy đăng ký thẳng vào traverse. Đây là lớp chắc ăn kép:
// useTraverseRebuild.ts
import { useEffect } from 'react'
export function useTraverseRebuild(onTraverseReturn: () => void, ownerPath: string) {
useEffect(() => {
// Trình duyệt hiện đại: Navigation API phân biệt traverse rõ ràng.
const nav = (window as unknown as { navigation?: EventTarget }).navigation
if (nav) {
const handler = (e: Event) => {
const ev = e as unknown as { navigationType?: string; destination?: { url: string } }
if (ev.navigationType === 'traverse') {
const url = ev.destination ? new URL(ev.destination.url) : null
if (url?.pathname === ownerPath) onTraverseReturn()
}
}
nav.addEventListener('navigate', handler)
return () => nav.removeEventListener('navigate', handler)
}
// Fallback (Safari/Firefox chưa có Navigation API): popstate.
const onPop = () => {
if (window.location.pathname === ownerPath) onTraverseReturn()
}
window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop)
}, [onTraverseReturn, ownerPath])
}
Navigation API cho bạn tín hiệu traverse sạch sẽ trong Chromium; popstate là fallback phổ quát ở mọi nơi khác. Giữa effect theo pathname và hook này, mọi đường vào lại đều được phủ.
Bài học thật: luôn test nút Back gốc
Lỗi không nằm ở WebGL. Nó là điểm mù khi test. Preloader tùy chỉnh, page transition và code SPA nav được rèn luyện bằng cách bấm quanh site — click Link và điều hướng Forward — và vượt qua. Nút Back/Forward gốc của trình duyệt lái một cơ chế khác (popstate / traverse) mà kiểu test-bằng-click không bao giờ chạm tới. Bất kỳ component hay scene nào được khởi tạo một lần lúc boot đều cần một đường rebuild-khi-quay-lại tường minh, và cách duy nhất bạn phát hiện nó thiếu là thật sự bấm Back.
Hai thói quen giữ lỗi này ngoài production:
- Thêm Back/Forward vào kịch bản QA thủ công. Tải → Link đi nơi khác → Back gốc → mọi thứ còn sống không? Rồi Forward. Mất mười giây và bắt được cả một lớp lỗi transition.
- Verify WebGL trong tab thật, đang foreground. Một tab automation/headless đang chạy nền báo
document.visibilityState === 'hidden', vốn tạm dừngrequestAnimationFrame— R3F khi đó trông như "treo" vì lý do chẳng liên quan gì đến code của bạn. Chạy nó bằng một trang Playwright hiển thị nơi RAF thực sự chạy, và xác nhận bằng screenshot thay vì đoán code đúng.
Nếu nghe giống đúng kiểu debug dự án bạn cần — WebGL chạy ngon nhưng có gì đó về navigation, transition hay lifecycle hỏng một cách tinh vi — thì đó chính xác là việc tôi làm. Xem dịch vụ creative development, và duyệt các dự án WebGL thực tế trong kho projects.
Về nền tảng sâu hơn cho những scene như thế này, xem React Three Fiber cho người mới và tối ưu hiệu năng Three.js. Một câu chuyện debug tương tự: vì sao video scrub theo scroll bị giật và cách sửa keyframe, và để giữ mỗi lần vào lại nhẹ nhàng, Core Web Vitals cho web nhiều chuyển động.
FAQ
Vì sao canvas R3F chỉ trắng sau khi bấm nút Back trình duyệt?
Vì điều hướng Link và nút Back gốc đi theo hai luồng code khác nhau. Logic transition của bạn nhiều khả năng rebuild khi <Link>/push nhưng không rebuild khi popstate/traverse. Một scene dựng một lần lúc boot mà không có nhánh rebuild-khi-quay-lại sẽ nằm unmount sau lần rời đầu tiên, và Back là cách tự nhiên nhất người dùng vào lại route.
Vì sao không có lỗi console khi nền WebGL biến mất?
Vì chẳng có gì throw. GL context và vòng render đều khỏe — component chứa đơn giản là không re-mount, nên không có scene để vẽ. Một canvas trắng-mà-không-lỗi gần như luôn là vấn đề lifecycle/mount, không phải lỗi shader hay WebGL.
usePathname() có cập nhật khi bấm nút Back trong Next.js không?
Có. App Router của Next.js giữ usePathname() đồng bộ khi history traverse, nên effect theo pathname là hook hợp lệ để phát hiện lần quay về. Bạn chỉ cần xử lý nhánh quay về (reset + rebuild), không chỉ nhánh rời đi.
Làm sao phân biệt back/forward với điều hướng thường?
Dùng sự kiện navigate của Navigation API và kiểm tra navigationType === 'traverse' (cả back lẫn forward), với window.addEventListener('popstate', …) làm fallback cho trình duyệt chưa có nó. So sánh pathname đích với route sở hữu scene trước khi rebuild.
Vì sao replay intro khi quay về lại cảm giác như hỏng?
Intro là màn reveal ấn tượng đầu tiên; khi quay lại bằng nút Back người dùng đã xem rồi. Replay một màn lắp ráp vài giây bị đọc thành giật hoặc thụt lùi. Hãy dựng lại scene và snap thẳng tới trạng thái ổn định, rồi fade-in ngắn (~400 ms).


