無限清單捲到底卻沒有載入下一頁時,畫面通常只留下「沒有反應」這個模糊症狀。問題可能發生在觀察目標尚未存在、目標放在錯誤的捲動邊界、callback 從未跨越 threshold、請求閘門一直關閉,或資料其實已回來但被取代。若一開始就改 rootMargin、加入更多 effect 或延長等待,往往只是把證據洗掉。
本文把來源筆記整理成一套 無限清單停止載入診斷流程:先證明 sentinel 節點、observer 實例與 root 的關係,再記錄幾何與事件,最後才檢查網路及資料追加。本文聚焦故障證據而非完整清單實作,回答的是故障當下應蒐集哪些證據、如何縮小責任層,以及怎樣以隔離瀏覽器 fixture 重現。
核心答案:先建立可觀測的四段證據鏈
短答:無限清單停止載入診斷應依序證明 target 存在、root 正確、callback 有狀態轉換、載入閘門允許請求;任何一段缺證據,都不應跳到下一層猜測。 這條鏈把 DOM、幾何、非同步通知與資料流程分開,能讓「沒觸發」從主觀描述變成可比對的狀態紀錄。
第一段是節點身分。記錄 sentinel 是否存在、是否仍連在文件中,以及 observer 實際 observe 的是否就是目前節點。條件渲染可能讓 mount 當下沒有 target,之後又建立新節點;舊 ref 曾經有值,不代表新節點已被觀察。第二段是 root 身分。若 sentinel 位於內層捲動容器之外,且 observer 使用預設 viewport,它可能一直相交而不產生新通知。反之,sentinel 位於容器內時,即使 root 為 null,內層捲動及祖先裁切仍可能改變交會;使用 viewport 並非一概錯誤。
第三段是 callback 軌跡,而非單一布林值。至少記錄 isIntersecting、intersectionRatio、target、rootBounds、boundingClientRect 與 time。MDN 說明 callback 收到的是 threshold crossing 的非同步通知,項目以佇列交付;初次要求觀察 target 時也會排入通知。因此第一次 true 不是「使用者剛捲到底」的充分證據。第四段才是載入決策:callback 即使收到 true,仍可能被 loading、hasMore、游標缺失或重複請求保護擋下。
診斷紀錄應區分「觀察事件」與「資料事件」。不要只印一行 load more,因為它無法回答 callback 是否發生、閘門為何拒絕、請求是否送出及 reducer 是追加還是取代。每一層使用相同的測試序號即可,不需要寫入真實帳號、網址、查詢內容或正式 API 回應。
root 與 sentinel 的幾何怎麼判讀
短答:若要直接量測內層捲動視窗,可明確指定該元素為 root,target 須符合其 containing-block 後代條件;先比對兩者矩形與 scrollTop,再判斷 threshold 是否可能被跨越。 只看 CSS 類名或 DOM 鄰近位置不足以證明觀察關係正確。
W3C 規格把非 null root 稱為 explicit root observer,target 必須是 root 在 containing-block 鏈中的後代。MDN 也指出,指定 root 時它必須是 target 的祖先;未指定或為 null 時才使用瀏覽器 viewport。這個規則提供了第一個確定性檢查:在開發工具同時取得 root.getBoundingClientRect() 與 target.getBoundingClientRect(),再確認滾動時是 root 的 scrollTop 改變,而不是外層文件。
若 sentinel 放在內層容器外,但 observer 又使用 viewport,畫面可能出現一個迷惑結果:初始化 callback 已經回報 true,之後使用者只捲動內層容器,target 相對 viewport 沒有跨越任何 threshold,因此不再通知。這不是 observer 壞掉,而是測量座標系與使用者操作不一致。相反地,sentinel 位於容器底部且 root 指向該容器時,在內容足夠高、初始 scrollTop 為 0 且 sentinel 位於可見區之外的 fixture 中,應先觀察到 false,捲到底後才出現 true。
rootMargin 是調整 root intersection rectangle 的工具,不是修正錯誤 root 的替代品。正值可讓內容提早被視為相交,負值會縮小矩形;threshold 則定義可見比例跨越哪些界線。診斷時先使用 rootMargin: "0px" 與 threshold: 0 建立基準,再逐項加入預載距離。若基準都沒有狀態轉換,直接放大 margin 只會讓錯誤更難解釋。
祖先的 overflow 裁切也必須納入。規格的 intersection rectangle 會受 containing block 祖先裁切,再與 root bounds 相交;因此固定高度、overflow: auto 的容器是可用情境,不必因為有 sticky header 就改回監聽 scroll 事件。真正要驗證的是 sentinel 是否在裁切路徑內,以及有效可見區是否仍可跨過門檻。
條件渲染與 observer 生命週期如何取證
短答:把節點出現、observe、cleanup 與重建各自記錄時間及節點身分;若 observe 早於節點建立,之後必須對新節點明確呼叫 observe。 ref 物件保持相同,不代表 ref.current 指向的 DOM 也保持相同。
條件渲染常形成以下時間線:元件第一次 render 時 showList 為 false,sentinel 不在 DOM;effect 執行時看到 null,所以沒有 observe;資料載入後 showList 變 true,sentinel 才建立;但 effect 若只有空相依陣列,就不會再執行。這時 observer 可能根本不存在,或存在但 observation target 集合為空。反覆調整 threshold 不會改變這項事實。
另一種情形是 sentinel 節點被替換。列表切換 loading placeholder、空狀態或表格模式時,同一個 React ref 可能先指向節點 A,cleanup 後再指向節點 B。若 observer 仍觀察 A,B 的可見性不會通知。可在 development-only 診斷器替節點分配短序號,例如 sentinel-1、sentinel-2,並在每次 observe、unobserve、disconnect 時記錄序號;不要把實際業務識別碼放進 log。
cleanup 也可能過早發生。依賴陣列若包含每次 render 都新建的 options 或 callback,effect 會頻繁 disconnect 並重建;時間點剛好落在捲動與 render 之間時,事件可能被新的實例接手,而舊實例的軌跡被清空。診斷時記錄 observer instance 序號和 options 的正規化值,能辨識「從未 observe」與「觀察後立即斷線」兩種不同故障。
MDN 說明 disconnect() 會停止該 observer 對所有 target 的可見性觀察。這也是必要的負向控制:先取得初始 false,接著 disconnect,再捲到底,狀態陣列應維持只有 false。此結論限定於先收到初始 false、斷線前沒有待交付 true 的隔離案例。disconnect 不會清空已排入的 entries;真實程式中,斷線後仍可能收到先前排隊的通知,可用 takeRecords() 取出並清空佇列,或以實例世代標記忽略過期回呼。不能只憑斷線後的 callback 就斷言存在第二個 observer。
callback 有 true,為何仍沒有下一頁
短答:收到 isIntersecting=true 只表達幾何交會,不保證有正面積可見像素,也不代表未被其他元素遮住,不證明請求已送出;接著要逐一記錄 loading、hasMore、cursor 與 requestId 的決策。 把這些閘門壓成一個布林式,會失去拒絕原因。
建議讓載入決策回傳結構化原因,例如 {allowed:false, reason:'already-loading'},而不是只寫 if (...) return。這種小型診斷契約能辨識四類情況:前一個請求的 loading 沒有在失敗路徑清除、最後一頁後 hasMore 正確為 false、游標與最後一列身分不一致,以及同一個 callback 連續通知時被 requestId 去重。它不需要公開 API URL 或資料內容。
如果請求確實送出,再把網路成功與資料整併分開。來源筆記提醒,搜尋應該 replace 並重設 cursor,而下一頁應 append 並依穩定身分去重;輪詢若仍用 replace,可能把已追加頁面洗掉。這些是資料層候選原因,卻不應在幾何證據尚未建立前優先修改。否則 observer 問題與 reducer 問題會同時變動,回歸時無法知道是哪一項修正生效。
前端欄位排序也只涵蓋已載入資料,不能被誤認為跨頁排序。若產品契約要求整體順序,sort 與 keyset 條件必須由後端共同定義。遞迴樹也不是平面清單:root 數量不等於節點總量,拖放與關係圖常依賴完整樹。看到 sentinel 正常並不代表所有資料形狀都適合無限載入。
重現與驗證:固定版本的隔離瀏覽器 fixture
短答:用最小 HTML 建立明確 scroll root,分別驗證正常轉換、延後建立 target 與 disconnect;等待狀態,不用固定 sleep 假裝同步。 fixture 只操作本機 DOM,不連接網站、API、資料庫或 Sheet。
本次補刊已在安全隔離目錄實際執行:Node.js 22.23.2、playwright-core 1.62.1、Chrome 154.0.8037.57。結果為 rootedStates=[false,true]、delayedStates=[true]、disconnectedStates=[false],程序結束碼 0,輸出 externalWrites=0。這證明固定 fixture 的三個平台契約,不代表任何真實應用已修復,也不替代目標專案的整合測試。
核心等待與觀察片段如下;完整 fixture 應同時包含固定高度 root、足夠的列表高度及 assertion:
async function waitForState(predicate, timeoutMs = 2000) {
const deadline = performance.now() + timeoutMs;
while (performance.now() setTimeout(resolve, 20));
}
throw new Error('observer state timeout');
}
const states = [];
const observer = new IntersectionObserver(entries => {
states.push(...entries.map(entry => entry.isIntersecting));
}, { root: document.querySelector('#root'), threshold: 0 });
observer.observe(document.querySelector('#sentinel'));
- 在空白隔離目錄建立
fixture.html與verify-sentinel.cjs,固定安裝playwright-core@1.62.1,並把CHROME_PATH指向相容的本機 Chrome;執行前核對 Node 與套件版本,執行後核對輸出的 Chrome 版本;版本不符不算固定版本驗證。 - 執行正常案例:先讓 sentinel 位於 root 可見區下方,等待狀態包含
false,再設定 root 的scrollTop到底部,等待狀態包含true;精確比對[false,true]。 - 執行延後節點案例:先建立 observer,再建立並插入 sentinel,對實際節點呼叫
observe,捲到底後等待true;若省略 observe,測試必須逾時失敗。 - 執行斷線案例:取得初始
false後呼叫disconnect(),再捲到底並保留有限觀察窗;狀態必須維持[false],不可新增true。 - 保存版本、狀態陣列、結束碼與外部寫入計數;測試失敗時保留原始軌跡,不以重跑到成功取代失敗證據。
固定 100ms 或 200ms 只能等待時間經過,不能證明 callback 已完成。正向案例應等待可觀察狀態並設定失敗上限;負向案例因為要證明「觀察窗內沒有事件」,才需要有限等待。兩者語意不同,不應共用一句「sleep 一下」描述。
完整隔離測試檔案
以下兩個完整檔案就是本次重跑的 fixture。JavaScript 片段本身不是完整 runner。將兩檔放在同一資料夾,安裝上列固定版本套件、設定 CHROME_PATH 後執行 node verify-sentinel.cjs。先自行核對 Node 與套件版本,並核對輸出的 Chrome 版本;版本不同時不應宣稱重現本次固定版本結果。
fixture.html
IntersectionObserver diagnostic fixture
#root { height: 180px; width: 320px; overflow: auto; border: 1px solid #555; }
.item { height: 90px; }
.sentinel { height: 12px; background: #d33; }
window.waitForState = async function (predicate, timeoutMs = 2000) {
const deadline = performance.now() + timeoutMs;
while (performance.now() setTimeout(resolve, 20));
}
throw new Error('observer state timeout');
};
window.fillRoot = function (includeSentinel) {
const root = document.querySelector('#root');
root.replaceChildren();
for (let index = 0; index
verify-sentinel.cjs
const assert = require('node:assert/strict');
const path = require('node:path');
const { chromium } = require('playwright-core');
const executablePath = process.env.CHROME_PATH;
assert(executablePath, 'CHROME_PATH must name a local Chrome executable');
async function run() {
const browser = await chromium.launch({ headless: true, executablePath });
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
await page.goto(`file://${path.join(__dirname, 'fixture.html').replaceAll('\\', '/')}`);
const result = await page.evaluate(async () => {
const root = document.querySelector('#root');
fillRoot(true);
const rootedStates = [];
const rooted = new IntersectionObserver(entries => {
rootedStates.push(...entries.map(entry => entry.isIntersecting));
}, { root, threshold: 0 });
rooted.observe(document.querySelector('#sentinel'));
await waitForState(() => rootedStates.includes(false));
root.scrollTop = root.scrollHeight;
await waitForState(() => rootedStates.includes(true));
rooted.disconnect();
fillRoot(false);
const delayedStates = [];
const delayed = new IntersectionObserver(entries => {
delayedStates.push(...entries.map(entry => entry.isIntersecting));
}, { root, threshold: 0 });
const delayedTarget = document.createElement('div');
delayedTarget.id = 'sentinel';
delayedTarget.className = 'sentinel';
root.append(delayedTarget);
delayed.observe(delayedTarget);
root.scrollTop = root.scrollHeight;
await waitForState(() => delayedStates.includes(true));
delayed.disconnect();
fillRoot(true);
root.scrollTop = 0;
const disconnectedStates = [];
const disconnected = new IntersectionObserver(entries => {
disconnectedStates.push(...entries.map(entry => entry.isIntersecting));
}, { root, threshold: 0 });
disconnected.observe(document.querySelector('#sentinel'));
await waitForState(() => disconnectedStates.includes(false));
disconnected.disconnect();
root.scrollTop = root.scrollHeight;
await new Promise(resolve => setTimeout(resolve, 250));
return {
apiAvailable: typeof IntersectionObserver === 'function',
rootedStates,
delayedStates,
disconnectedStates
};
});
assert.equal(result.apiAvailable, true);
assert.deepEqual(result.rootedStates, [false, true]);
assert.deepEqual(result.delayedStates, [true]);
assert.deepEqual(result.disconnectedStates, [false]);
console.log(JSON.stringify({
status: 'PASS',
browserVersion: browser.version(),
nodeVersion: process.version,
playwrightCoreVersion: require('playwright-core/package.json').version,
...result,
externalWrites: 0
}));
await browser.close();
}
run().catch(error => {
console.error(error.stack || error);
process.exitCode = 1;
});
預期核心輸出(版本欄位另見 runner):
status=PASS
rootedStates=[false,true]
delayedStates=[true]
disconnectedStates=[false]
exitCode=0
判定:三個陣列必須精確符合,逾時或 assertion 失敗即不通過。正向等待上限為 2000ms,disconnect 負向觀察窗為 250ms;這不是所有瀏覽器時序的普遍保證。獨立審查連續重跑三次皆通過;另以省略 observe 的負向變體確認 2000ms 後逾時且 states=[]。runner 的 externalWrites:0 是固定宣告,不是網路監控器;無外部寫入的判斷來自原始碼審查及只操作合成 DOM 的測試範圍,不代表量測了瀏覽器背景流量。
診斷清單與修正邊界
短答:先保存重現條件與事件軌跡,再做單一變更,最後以正向和負向案例回歸;沒有可重現證據時,不宣稱原因已確認。 這能避免把偶發成功、頁面重整或測試延長誤寫成修復成果。
- DOM:sentinel 是否存在、
isConnected是否為 true、目前節點序號是否與 observe 紀錄一致。 - 幾何:實際改變的是哪個
scrollTop,root 與 target 矩形是否可能跨越 threshold,祖先裁切是否符合預期。 - 事件:初始 callback、正向跨越、離開交會及 disconnect 後的軌跡是否可區分。
- 閘門:
loading、hasMore、cursor 與 requestId 各自的接受或拒絕原因是否留下紀錄。 - 資料:下一頁是 append 還是 replace,去重鍵是否穩定,輪詢是否會覆蓋已載入列。
- 範圍:平面清單與遞迴樹是否被混用,client sort 是否被誤當成全資料排序。
修正應只對應已確認的斷點。target 晚出現,就修正 ref/effect 生命週期;root 錯誤,就讓 observer 使用真正捲動容器並確保 target 是其後代;閘門卡住,就修正狀態轉移與失敗清理;資料被覆蓋,才處理 append、merge 與 polling。不要一次更換 observer、分頁 API 與 reducer,否則驗證無法指出哪一個契約恢復。
官方依據與結論
短答:MDN 與 W3C 都把 IntersectionObserver 定義為相對 root 的非同步 threshold 通知,而不是捲動事件替代品;診斷必須圍繞 target、root、交會軌跡與生命週期。 官方規則可約束推理,但具體修復仍要由隔離重現與專案測試證明。
本文查核三個可開啟的官方 HTTPS 來源:MDN 的 Intersection Observer API 說明 root、threshold、初次觀察通知與 callback entry;MDN 的 disconnect() 文件確認它停止觀察所有 targets;W3C 編輯草案(仍在演進的規格文件)定義 explicit/implicit root、target 後代條件、交會矩形與非同步處理模型。參考網址為 https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API、https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/disconnect 與 https://w3c.github.io/IntersectionObserver/。
可靠的排錯結論應長成「節點 B 在時間 T 被 observer 2 以 root R 觀察,捲動後狀態由 false 變 true,但載入被 already-loading 拒絕」,而不是「IntersectionObserver 好像沒作用」。前者能導向單一修正與可重現回歸;後者只會繼續堆疊猜測。當四段證據鏈完整,sentinel 故障就不再是黑盒子。