Học từ Facebook #11: Modal cuộn bằng window, trang nền giữ đúng chỗ
Cách Facebook cho modal toàn trang cuộn bằng chính window mà trang nền vẫn đứng yên: chồng màn hình, position: fixed và top âm. Có code React và demo.
Bạn đang cuộn feed tới bài thứ 30, bấm vào một ảnh. Một modal "permalink" mở ra, dài hơn màn hình vì có cả trăm bình luận. Bạn cuộn hết bình luận, bấm vào một ảnh khác (modal thứ hai), rồi đóng cả hai. Feed phải về đúng bài thứ 30.
Nghe đơn giản, nhưng gần như mọi cách làm quen thuộc đều hỏng ở đâu đó:
- Overlay
position: fixed+overflow: autobên trong,body { overflow: hidden }: trên iOS Safari đời cũ,overflow: hiddentrênbodykhông chặn được việc cuộn trang nền. Cuộn tới cuối modal thì trang nền bị cuộn theo (scroll chaining). Thanh cuộn biến mất làm cả trang xô ngang 15px trên Windows. Còn modal cuộn bằng mộtdivnên mất một số hành vi của trang thật: thanh địa chỉ trên mobile không thu gọn (vùng nhìn thấy nhỏ hơn), và phím Space/PageDown thì phải focus đúng chỗ mới chạy. - Mẹo
body { position: fixed; top: -scrollY }(thư viện body-scroll-lock làm vậy): chặn được iOS, nhưng modal vẫn cuộn bằngdiv. - Chuyển hẳn sang trang mới: cuộn bằng window thật, nhưng feed bị unmount, quay lại thì phải tải lại và đoán vị trí cuộn.
Facebook chọn cách thứ tư: modal cuộn bằng chính window, còn trang nền bị "đóng băng" tại chỗ nhưng vẫn mount nguyên vẹn. Toàn bộ nằm trong CometDocumentScrollView.react (thư mục Modal/ trong facebook-codebase).
Tip
Tóm tắt
- Mỗi màn hình (trang chính, từng modal) là một "scroll view". Chỉ view nằm sau cùng trong DOM ở trong luồng tài liệu, nên cuộn trang là cuộn nó.
- Các view bên dưới bị "đóng băng":
position: fixed; top: -scrollY; left: -scrollX. Trông y như cũ nhưng không còn góp chiều cao vào document. - Đóng modal: view bên dưới bỏ
fixedrồiwindow.scrollTo()về vị trí đã lưu, trong cùng một lần commit nên không nháy. - Dialog nhỏ thì
<dialog>+overscroll-behavior: containlà đủ; kỹ thuật này đáng dùng cho modal dạng "trang" (permalink, xem ảnh).
#Ý tưởng: một chồng màn hình, chỉ màn hình trên cùng được dùng window
Mỗi "màn hình" (trang chính, mỗi modal) được bọc trong một CometDocumentScrollView. Tại mọi thời điểm:
- Màn hình nằm sau cùng trong DOM (thường là modal mở sau cùng, vì portal được append vào cuối
body) là màn hình attached. Nó nằm trong luồng tài liệu bình thường, nên chiều cao của document chính là chiều cao của nó. Cuộn trang chính là cuộn nó. - Mọi màn hình khác là detached:
position: fixed; top: -y; left: -x; width: 100%, với(x, y)là vị trí cuộn lúc nó bị che. Nhờtopâm đúng bằng vị trí cuộn cũ, nó trông y hệt lúc trước, nhưng không còn góp chiều cao vào document.
stateDiagram-v2 [*] --> Attached: mount, là view sau cùng trong DOM [*] --> Detached: mount nhưng có view khác nằm sau Attached --> Detached: view mới mount phía sau. Lưu scrollX/Y, chuyển sang fixed, top = -scrollY Detached --> Attached: view phía sau unmount. Bỏ fixed, window.scrollTo vị trí đã lưu Attached --> [*]: unmount. View sau cùng còn lại được attach
Khi modal đóng, màn hình bên dưới được attach lại: bỏ fixed, trở về luồng tài liệu (document lại dài như cũ), rồi window.scrollTo(x, y). Người dùng không thấy gì nhảy vì mọi bước chạy trong layout effect: setState gọi trong layout effect được React xử lý đồng bộ, render lại và commit ngay, trước khi trình duyệt vẽ frame tiếp theo. (Facebook còn bọc setAttached trong runWithPriority(Immediate) để chắc chắn điều đó.)
#Facebook làm thế nào?
Đọc Modal/CometDocumentScrollView.react.js:
Registry ở module scope. Ba biến dùng chung cho mọi instance:
i = new Map(), // element -> hàm setAttached
j = new Set(), // các view đang bị ẩn (HiddenSubtree)
k = null; // view đang attached
js
So thứ tự DOM bằng compareDocumentPosition:
function l(a, b) { return !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) }
js
l(a, b) đúng khi b nằm sau a trong tài liệu. Hàm m() duyệt registry, tìm view nằm sau cùng và không bị ẩn.
Khi mount (layout effect): nếu chưa có view active hoặc view mới nằm sau view active, thì gọi setAttached(false) cho view cũ và view mới thành active. Ngược lại, view mới tự đặt mình là detached. Hàm setAttached được đăng ký vào map, và bọc trong JSScheduler.runWithPriority(unstable_Immediate, …) để cập nhật state đồng bộ, không để sang frame sau. Khi bị detach, nó chép vị trí cuộn đã lưu (r.current) vào state w, dùng để vẽ top/left âm.
Khi unmount: xoá khỏi map. Nếu chính nó đang active thì k = m() (view sau cùng còn lại) và gọi setAttached(true) cho view đó.
Theo dõi vị trí cuộn: chỉ view attached mới gắn listener scroll (passive) lên window, lưu pageXOffset/pageYOffset vào ref, và nếu có contextKey thì lưu thêm vào một map theo key.
Khôi phục: một layout effect khác thấy view vừa chuyển từ detached sang attached thì window.scrollTo(w.x, w.y). View mới mount thì cuộn về (0, 0) nếu resetScrollOnMount (mặc định true, nên modal mới luôn bắt đầu từ trên cùng).
Style khi detached:
"aria-hidden": !0,
style: { left: -w.x, top: -w.y },
xstyle: [h.detached /* position:fixed; left:0; top:0; width:100% */, …]
js
Thêm một chi tiết: gatekeeper gkx("1660344") trên Chrome/Edge thêm overflow: hidden cho view detached (style tên disablePositionSticky). Cái tên cho thấy mục đích: tắt các phần tử position: sticky bên trong màn hình đã bị đóng băng.
Liên kết với "HiddenSubtree": mỗi view bọc con trong HiddenSubtreeContextProvider với isBackgrounded = detached. Mọi component bên trong (video, timer, tracking thời gian xem ở bài #2) biết mình đang bị che để tạm dừng.
Ai dùng nó? Hai chỗ, đã tra trong repo:
BaseCometModal.reactbọc mỗi modal trong mộtCometDocumentScrollView. Trong đó có một lớp maskposition: fixedphủ kín màn hình, và phần nội dungposition: relative; min-height: 100vh. Modal cũng dùngFocusRegion(focus vào heading đầu tiên, giữ focus, trả focus khi đóng, xem bài #8) và đặt lại cấp heading về 1.CometTabBarView.reactbọc toàn bộ trang chính vớiresetScrollOnMount: false(trang chính không cuộn về đầu khi mount),contextKeylà key cuộn của route hiện tại vàmaintainScrollForContextkhi route được "giữ sống". NhờcontextKey, đổi route trong cùng màn hình cũng nhớ được vị trí cuộn riêng của từng route.
sequenceDiagram participant F as Feed view participant M as Modal view participant W as window Note over F,W: Feed attached, scrollY = 1028 M->>M: mount, layout effect, mình nằm sau Feed M->>F: setAttached(false) F->>F: lưu (0, 1028), render fixed với top = -1028px M->>W: scrollTo(0, 0) Note over F,W: document giờ cao bằng Modal, cuộn trang là cuộn Modal W-->>M: người dùng cuộn tới 1232 M->>M: unmount khi đóng M->>F: setAttached(true) F->>F: bỏ fixed, quay lại luồng tài liệu F->>W: scrollTo(0, 1028)
#Viết lại
Bản viết lại giữ nguyên cơ chế, bỏ phần gắn với hạ tầng của Facebook (JSScheduler, gatekeeper). Phần cốt lõi là hai layout effect: đăng ký vào registry, và đặt lại vị trí cuộn khi vừa mount hoặc vừa được attach lại:
src/lib/documentScrollView.tsx
// Đăng ký vào registry. Layout effect: phải xong trước khi trình duyệt vẽ frame đầu tiên.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
if (activeView == null || isAfter(activeView, el)) {
// mình nằm sau view đang active -> mình lên làm active, view cũ bị detach
if (activeView != null) views.get(activeView)?.(false);
activeView = el;
} else {
setDetached(true);
}
views.set(el, (attached) => {
if (!attached) {
// Đọc vị trí THẬT ngay lúc bị detach: listener `scroll` chỉ chạy ở frame sau, nên nếu
// trang vừa bị cuộn trong cùng frame (scrollIntoView, focus…) thì giá trị của nó đã cũ.
// Ngoại lệ: view khác vừa kéo window về 0 trong task này (StrictMode chạy effect 2 lần),
// khi đó giá trị đúng là giá trị đã lưu ở lần trước.
if (!resettingScroll) lastScrollRef.current = { x: window.scrollX, y: window.scrollY };
setOffset({ ...lastScrollRef.current });
}
setDetached(!attached);
});
return () => {
views.delete(el);
if (activeView === el) {
activeView = findLastView();
if (activeView != null) views.get(activeView)?.(true);
}
};
}, []);
// Vừa mount (đang attached) hoặc vừa được attach lại -> đặt vị trí cuộn của window.
useLayoutEffect(() => {
const wasDetached = wasDetachedRef.current;
wasDetachedRef.current = detached;
if (detached) return;
if (wasDetached === null) {
if (resetScrollOnMount) {
scrollWindowTo(0, 0);
resettingScroll = true;
queueMicrotask(() => {
resettingScroll = false;
});
}
} else if (wasDetached) {
scrollWindowTo(offset.x, offset.y);
}
}, [detached, offset, resetScrollOnMount]);
tsx
Xem toàn bộ src/lib/documentScrollView.tsx
src/lib/documentScrollView.tsx
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
/**
* Nhiều "màn hình" chồng lên nhau (trang feed, modal permalink, modal lồng trong modal…)
* nhưng chỉ MỘT màn hình được dùng thanh cuộn thật của `window` (ý tưởng từ
* CometDocumentScrollView của Facebook).
*
* - View nằm SAU CÙNG trong DOM là view "attached": nó nằm trong luồng bình thường, nên
* chiều cao document = chiều cao của nó và cuộn trang chính là cuộn nó.
* - Mọi view khác bị "detached": `position: fixed; top: -scrollY; left: -scrollX`. Nhìn vẫn
* y như cũ (đứng yên tại đúng chỗ đang xem), nhưng không còn góp chiều cao vào document.
* - Khi view trên cùng đóng, view kế tiếp được attach lại và `window.scrollTo()` về đúng
* vị trí đã lưu.
*/
type SetAttached = (attached: boolean) => void;
const views = new Map<HTMLElement, SetAttached>();
let activeView: HTMLElement | null = null;
function isAfter(a: Node, b: Node) {
return !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
}
/** View nằm sau cùng trong DOM trong số các view còn đăng ký. */
function findLastView(): HTMLElement | null {
let last: HTMLElement | null = null;
views.forEach((_, el) => {
if (last == null || isAfter(last, el)) last = el;
});
return last;
}
// Bật khi một view vừa mount đặt lại cuộn về đầu, tắt ở microtask kế tiếp.
// Dùng để nhận ra lần chạy effect thứ hai của StrictMode, khi window đã bị kéo về 0.
let resettingScroll = false;
function scrollWindowTo(x: number, y: number) {
// 'instant': không để `scroll-behavior: smooth` của trang làm nội dung trôi
window.scrollTo({ left: x, top: y, behavior: 'instant' });
}
const DetachedContext = createContext(false);
/** true khi màn hình chứa component này đang bị một màn hình khác che (vd: để dừng video). */
export function useIsDocumentScrollViewDetached() {
return useContext(DetachedContext);
}
export interface DocumentScrollViewProps {
children: ReactNode;
/**
* Nhớ vị trí cuộn theo key (vd: theo route). Đổi key -> cuộn về vị trí đã nhớ cho key đó
* (nếu `maintainScrollForContext`) hoặc về đầu trang.
*/
contextKey?: string;
maintainScrollForContext?: boolean;
/** View mới mount thì cuộn về đầu (modal mới mở luôn bắt đầu từ trên cùng). */
resetScrollOnMount?: boolean;
className?: string;
style?: CSSProperties;
}
export function DocumentScrollView({
children,
contextKey,
maintainScrollForContext = false,
resetScrollOnMount = true,
className,
style,
}: DocumentScrollViewProps) {
const ref = useRef<HTMLDivElement>(null);
const [detached, setDetached] = useState(false);
// vị trí cuộn lúc bị detach: dùng để vẽ (top/left âm) và để khôi phục khi attach lại
const [offset, setOffset] = useState({ x: 0, y: 0 });
const lastScrollRef = useRef({ x: 0, y: 0 });
const savedByContextRef = useRef(new Map<string, { x: number; y: number }>());
const wasDetachedRef = useRef<boolean | null>(null);
const prevContextKeyRef = useRef(contextKey);
// Đăng ký vào registry. Layout effect: phải xong trước khi trình duyệt vẽ frame đầu tiên.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
if (activeView == null || isAfter(activeView, el)) {
// mình nằm sau view đang active -> mình lên làm active, view cũ bị detach
if (activeView != null) views.get(activeView)?.(false);
activeView = el;
} else {
setDetached(true);
}
views.set(el, (attached) => {
if (!attached) {
// Đọc vị trí THẬT ngay lúc bị detach: listener `scroll` chỉ chạy ở frame sau, nên nếu
// trang vừa bị cuộn trong cùng frame (scrollIntoView, focus…) thì giá trị của nó đã cũ.
// Ngoại lệ: view khác vừa kéo window về 0 trong task này (StrictMode chạy effect 2 lần),
// khi đó giá trị đúng là giá trị đã lưu ở lần trước.
if (!resettingScroll) lastScrollRef.current = { x: window.scrollX, y: window.scrollY };
setOffset({ ...lastScrollRef.current });
}
setDetached(!attached);
});
return () => {
views.delete(el);
if (activeView === el) {
activeView = findLastView();
if (activeView != null) views.get(activeView)?.(true);
}
};
}, []);
// Vừa mount (đang attached) hoặc vừa được attach lại -> đặt vị trí cuộn của window.
useLayoutEffect(() => {
const wasDetached = wasDetachedRef.current;
wasDetachedRef.current = detached;
if (detached) return;
if (wasDetached === null) {
if (resetScrollOnMount) {
scrollWindowTo(0, 0);
resettingScroll = true;
queueMicrotask(() => {
resettingScroll = false;
});
}
} else if (wasDetached) {
scrollWindowTo(offset.x, offset.y);
}
}, [detached, offset, resetScrollOnMount]);
// Đổi context (vd: chuyển route trong cùng màn hình): khôi phục vị trí đã nhớ hoặc lên đầu.
useLayoutEffect(() => {
if (prevContextKeyRef.current === contextKey) return;
prevContextKeyRef.current = contextKey;
const saved =
maintainScrollForContext && contextKey != null
? savedByContextRef.current.get(contextKey)
: undefined;
const target = saved ?? { x: 0, y: 0 };
if (detached) setOffset(target);
else scrollWindowTo(target.x, target.y);
}, [contextKey, maintainScrollForContext, detached]);
// Chỉ view đang attached mới theo dõi vị trí cuộn của window.
useEffect(() => {
if (detached) return;
const onScroll = () => {
const position = { x: window.scrollX, y: window.scrollY };
lastScrollRef.current = position;
if (contextKey != null) savedByContextRef.current.set(contextKey, position);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [detached, contextKey]);
const detachedStyle: CSSProperties | undefined = detached
? { position: 'fixed', top: -offset.y, left: -offset.x, width: '100%' }
: undefined;
return (
<DetachedContext.Provider value={detached}>
<div
ref={ref}
className={className}
style={{ ...style, ...detachedStyle }}
// màn hình bị che: trình đọc màn hình bỏ qua, Tab không đi vào được
aria-hidden={detached || undefined}
inert={detached}
data-scroll-view={detached ? 'detached' : 'attached'}
>
{children}
</div>
</DetachedContext.Provider>
);
}
tsx
Khác bản gốc: đọc vị trí cuộn thật ngay lúc bị detach (sửa 1), scrollTo với behavior: 'instant' (sửa 2), thêm inert cho view bị che (sửa 3); bỏ tắt sticky bằng overflow: hidden và bỏ HiddenSubtree (thay bằng một context nhỏ). Chi tiết ở dưới.
#Đi qua code
Registry (views, activeView, isAfter, findLastView) là bản gọn của i, k, l, m. Thứ tự trong DOM quyết định ai ở trên cùng, không phải thứ tự mount. Nhờ vậy modal render qua portal (luôn ở cuối body) tự nhiên nằm trên trang chính, kể cả khi trang chính mount sau (ví dụ khi tải thẳng URL permalink rồi mới hydrate feed bên dưới).
Effect đăng ký dùng useLayoutEffect: phải đổi vai trò attached/detached trước khi trình duyệt vẽ frame đầu tiên của modal. Dùng useEffect thì sẽ có một frame modal hiện ra mà trang nền chưa đóng băng, document vẫn dài, và người dùng thấy nhảy.
Sửa 1, đọc vị trí cuộn thật lúc bị detach. Bản gốc lấy vị trí từ ref mà listener scroll cập nhật. Nhưng sự kiện scroll chỉ được gửi một lần mỗi frame, ở bước render tiếp theo. Nếu trang vừa bị cuộn trong cùng frame với cú click (ví dụ element.focus() hay scrollIntoView() tự cuộn tới nút ngay trước khi nó được bấm), ref vẫn giữ giá trị cũ. Mình gặp đúng lỗi này khi viết test: Playwright tự scrollIntoView trước khi click, và đóng modal xong feed về sai chỗ gần 500px. Bản viết lại đọc window.scrollX/Y ngay trong setAttached(false), vì lúc đó view vẫn đang attached nên vị trí cuộn của window chính là của nó.
Ngoại lệ cho StrictMode. Ở chế độ phát triển, StrictMode cho mỗi component vừa mount chạy effect thêm một lượt (mount → unmount → mount) để lộ bug. Lần chạy đầu, modal đã kéo window về (0, 0). Lần chạy thứ hai mà đọc window thì sẽ lưu nhầm 0. Cờ resettingScroll (bật khi một view vừa reset cuộn, tắt ở microtask kế tiếp) nhận ra trường hợp này để dùng lại giá trị đã lưu ở lần trước.
Sửa 2, behavior: 'instant'. Nếu trang có html { scroll-behavior: smooth }, mọi scrollTo mặc định sẽ cuộn mượt và feed trôi từ đầu trang xuống đúng chỗ ngay trước mắt người dùng. Truyền rõ behavior: 'instant' để bỏ qua CSS đó.
Sửa 3, thêm inert. Facebook chỉ đặt aria-hidden cho view bị che, còn chuyện chặn Tab giao cho FocusRegion của modal. Thuộc tính inert (đã có ở mọi trình duyệt hiện đại, React 19 hỗ trợ dạng boolean) làm cả hai việc trong một: trình đọc màn hình bỏ qua, và không focus hay click được.
Context useIsDocumentScrollViewDetached là bản thu nhỏ của HiddenSubtree: component bên trong biết mình đang bị che để dừng video hoặc ngừng đếm giờ.
#Demo
Cuộn xuống giữa feed, bấm "Mở bài viết", cuộn hết bình luận (để ý window.scrollY ở góc phải: cuộn modal là cuộn trang thật). Mở thêm ảnh (modal lồng), bấm Esc rồi bấm Đóng. Ô góc phải sẽ báo bài viết đã về đúng chỗ cũ hay chưa. Chuyển sang chế độ "Kiểu thường" để so sánh: lúc đó window.scrollY đứng yên vì modal cuộn bằng div.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Tại sao không làm cách khác?
"body { overflow: hidden } là đủ rồi?" Trên desktop thì gần đủ, trừ việc trang xô ngang khi thanh cuộn biến mất. Trên mobile thì không:
- iOS Safari trong nhiều năm bỏ qua
overflow: hiddentrênbodykhi người dùng vuốt. Các bản Safari gần đây đã cải thiện, nhưng nếu còn phải hỗ trợ iPhone đời cũ thì đừng dựa vào nó. - Modal cuộn bằng
divthì thanh địa chỉ không thu gọn, nên vùng nhìn thấy nhỏ hơn. - Cuộn tới cuối
divrồi vuốt tiếp thì trình duyệt "chuyền" cú cuộn cho trang nền (scroll chaining). Muốn chặn phải thêmoverscroll-behavior: contain.
"Dùng <dialog> với showModal()?" <dialog> giải quyết rất tốt phần focus và accessibility: phần còn lại của trang tự thành inert, Esc tự đóng, focus tự quay về. Nhưng bản thân dialog vẫn là một hộp overflow: auto nằm trên top layer, tức vẫn là "modal cuộn bằng div", và không chặn được việc cuộn trang nền. Bạn hoàn toàn có thể kết hợp: dùng <dialog> cho focus, và kỹ thuật trong bài cho phần cuộn.
"Sao không chuyển hẳn sang route mới?" Facebook vẫn đổi URL khi mở permalink, nên Back, share link hay mở tab mới đều chạy. Điểm khác là trang bên dưới không unmount. Feed không phải tải lại, video đang phát chỉ tạm dừng, và vị trí cuộn được giữ chính xác tới từng pixel.
"Sao không lưu vị trí rồi dùng scroll-restoration?" history.scrollRestoration của trình duyệt chỉ khôi phục khi điều hướng Back/Forward, và chỉ cho document. Nó không biết gì về chuyện một modal che lên trang.
#Edge case và cạm bẫy
- Modal ngắn hơn màn hình. Nội dung modal
min-height: 100vhthì document cao đúng một màn hình. Trên Windows, thanh cuộn biến mất và trang xô ngang. Thêmhtml { scrollbar-gutter: stable }để luôn chừa chỗ cho thanh cuộn. 100vhtrên mobile.100vhlà chiều cao viewport lớn (khi thanh địa chỉ đã thu lại), nên lúc thanh địa chỉ còn hiện, modal ngắn vẫn dư ra một khoảng cuộn nhỏ. Dùngmin-height: 100dvh(viewport động) và giữ100vhphía trước làm fallback; demo làm vậy.position: stickytrong view bị đóng băng. Header sticky của feed nằm trong một hộpfixed, có thể dính sai chỗ. Facebook tắt nó bằngoverflow: hiddentrên view detached (chỉ ở Chrome/Edge, qua gatekeeper). Bản viết lại chưa làm; nếu feed của bạn có sticky header, hãy thêm style đó.- Phần tử
fixedbên trong view bị đóng băng vẫn fixed theo viewport, vì view detached không cótransform. Đừng thêmtransformvào view detached, vì nó sẽ biến các phần tửfixedbên trong thành tương đối với view. - Đổi kích thước cửa sổ khi đang có modal. View detached dùng
toptính bằng pixel. Nội dung bên dưới reflow (cửa sổ hẹp lại, chữ xuống dòng) thì bài viết bạn đang xem có thể dịch đi một chút. Facebook cũng chấp nhận điều này. - Scroll anchoring. Khi view được attach lại, trình duyệt có thể tự chỉnh
scrollTopnếu nội dung phía trên thay đổi chiều cao (ví dụ ảnh vừa tải xong). Đây thường là điều tốt: thứ người dùng đang nhìn đứng yên. - SSR. Toàn bộ logic nằm trong layout effect nên không chạy trên server. HTML server trả về có mọi view ở trạng thái attached, và trình duyệt vẽ HTML đó trước khi hydrate: tải thẳng URL permalink thì người dùng thấy feed và modal xếp chồng theo chiều dọc trong tích tắc, rồi mới nhảy về đúng hình. Cách sửa: quyết định trạng thái ban đầu ngay trên server theo route (route permalink thì view trang nền render sẵn ở dạng detached với
top: 0, vì lần tải đầu luôn ởscrollY = 0). Bản viết lại chưa có prop cho việc này. - Nhiều
DocumentScrollViewlồng nhau không được hỗ trợ: registry giả định các view là anh em trong cây (trang chính và các portal). Đừng bọc view trong view.
#Dùng trong dự án
Với Next.js App Router, "intercepting routes" + "parallel routes" là cách chuẩn để làm modal permalink (URL đổi, trang nền giữ nguyên). Kết hợp với DocumentScrollView (file documentScrollView.tsx dùng hook nên cần dòng 'use client' ở đầu file):
// app/layout.tsx
export default function RootLayout({ children, modal }: { children: ReactNode; modal: ReactNode }) {
return (
<html lang='vi'>
<body>
<DocumentScrollView resetScrollOnMount={false}>{children}</DocumentScrollView>
{modal /* @modal slot: mỗi modal tự bọc DocumentScrollView */}
</body>
</html>
);
}
tsx
// app/@modal/(.)photo/[id]/page.tsx
'use client';
export default function PhotoModal() {
const router = useRouter();
return (
<DocumentScrollView>
<div className='mask' onClick={() => router.back()} />
<article className='modal-content'>…</article>
</DocumentScrollView>
);
}
tsx
// app/@modal/default.tsx: slot không khớp route nào (vd: tải lại trang) thì không render gì
export default function Default() {
return null;
}
tsx
Hai chỗ Next.js tự cuộn có thể "đánh nhau" với window.scrollTo của DocumentScrollView:
<Link>mặc định cuộn trang về đầu (hoặc tới phần tử mới) khi điều hướng. Link mở modal nên đặtscroll={false}, để việc cuộn do DocumentScrollView quyết định.- Khi Back/Forward, trình duyệt và router có thể tự khôi phục vị trí cuộn của entry lịch sử. Kiểm tra kỹ thao tác "mở modal → Back" trên app thật; nếu thấy trang nhảy hai lần, cân nhắc
history.scrollRestoration = 'manual'(và tự khôi phục vị trí cho các trang không có modal).
Thêm focus trap và Esc theo layer từ bài #8 là có một modal đầy đủ như của Facebook.
#Kiểm thử
Cách kiểm thử đáng tin nhất là đo vị trí trên màn hình của chính bài viết đã bấm, trước khi mở và sau khi đóng.
Ví dụ test (bản phác, không có trong repo)
Ví dụ Playwright, baseURL trỏ tới /demos/facebook-patterns/index.html:
test('đóng modal thì feed về đúng chỗ', async ({ page }) => {
await page.goto('#/document-scroll-modal');
await page.evaluate(() => window.scrollTo(0, 1500));
// Playwright tự cuộn nút vào khung nhìn trước khi click: demo tự ghi lại vị trí
// của bài viết NGAY lúc click và so với vị trí sau khi đóng.
await page.locator('#sm-post-12').getByRole('button').click();
// modal đang cuộn bằng window thật
await page.mouse.wheel(0, 2000);
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
await page.getByRole('button', { name: 'Đóng' }).click();
await expect(page.getByTestId('sm-result')).toContainText('về đúng chỗ cũ');
});
ts
Đừng so window.scrollY trước và sau, vì Playwright (và người dùng thật) có thể cuộn ngay trước khi click. Hãy so thứ người dùng thực sự nhìn thấy: vị trí của bài viết.
#So với thư viện
- react-remove-scroll (Radix Dialog dùng nó) và body-scroll-lock: chặn cuộn trang nền bằng cách huỷ sự kiện
wheel/touchmovevà chỉnh style củabody. Modal vẫn cuộn bằngdiv. Đơn giản, đủ dùng cho dialog nhỏ. <dialog>+overscroll-behavior: contain: cách thuần web, tốt cho focus và accessibility, nhưng vẫn là cuộn bằngdiv.- Scroll view stack của Facebook: phức tạp hơn, nhưng cho modal toàn trang các hành vi cuộn của trang thật (thanh địa chỉ thu gọn trên mobile, phím cuộn không cần focus) mà trang nền vẫn đứng yên đúng chỗ. Đổi lại, pull-to-refresh cũng là của cả trang: kéo ở đầu modal sẽ tải lại toàn bộ trang, nên có thể bạn muốn chặn nó bằng
overscroll-behavior-y: nonetrênhtml. Đáng dùng cho modal dạng "trang": permalink, xem ảnh, chi tiết sản phẩm.
Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Phần 1
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D
- #11 Modal cuộn bằng window, giữ vị trí trang nền (bài này)
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

