diff --git a/assets/widgets/frontend.js b/assets/widgets/frontend.js index bc8730cb..3d5ccc6e 100644 --- a/assets/widgets/frontend.js +++ b/assets/widgets/frontend.js @@ -121,9 +121,8 @@ } } - // 参考主题 post_ajax 的 beforeSend 语义:骨架每次请求都挪到列表末尾 - // (最后一个条目之后、分页控件之前),请求成功后只隐藏而不删除, - // 下一轮翻页复用同一个节点。 + // 追加时骨架紧跟已有条目;替换时先清空旧页,骨架留在新页的位置。 + // 请求成功后隐藏并保留同一个骨架节点,供下一轮请求复用。 function anchorLoader(container) { var pag = container.querySelector(':scope > .ajax-pag'); var loader = loaderOf(container); @@ -144,40 +143,44 @@ if (!widget || !instance || !config.restUrl || container.dataset.loading === '1') return; container.querySelectorAll(':scope > .aksrt-widget-request-error').forEach(function (error) { error.remove(); }); setBusy(container, true); + var previous = []; + if (!append) { + // 对齐参考主题 beforeSend:数字分页与跳页先移除旧内容,而非等待响应。 + // 暂存原节点,只在请求失败时恢复内容和分页控件。 + var loader = loaderOf(container); + Array.prototype.slice.call(container.children).forEach(function (child) { + if (child === loader) return; + previous.push(child); + child.remove(); + }); + } anchorLoader(container); return fetch(itemsUrl(widget, instance, page, segment), { credentials: 'same-origin', headers: { Accept: 'application/json' } }) .then(function (response) { if (!response.ok) throw new Error('items'); return response.json(); }) .then(function (data) { var fragment = document.createElement('div'); fragment.innerHTML = String(data.html || ''); - if (append) { - // 追加方向为底部:新条目插到骨架之前,骨架始终贴住最后一张卡片。 - var anchor = loaderOf(container) || container.querySelector(':scope > .ajax-pag'); - Array.prototype.slice.call(fragment.children).forEach(function (node) { - if (anchor && anchor.parentNode === container) { - container.insertBefore(node, anchor); - } else { - container.appendChild(node); - } - }); - } else { - // 替换时保留骨架节点本身(参考主题 success 后只 hide() 不删除)。 - var loader = loaderOf(container); - Array.prototype.slice.call(container.children).forEach(function (child) { - if (child !== loader && !child.classList.contains('ajax-pag')) child.remove(); - }); - Array.prototype.slice.call(fragment.children).forEach(function (node) { container.appendChild(node); }); - } + var anchor = loaderOf(container) || container.querySelector(':scope > .ajax-pag'); + Array.prototype.slice.call(fragment.children).forEach(function (node) { + if (anchor && anchor.parentNode === container) { + container.insertBefore(node, anchor); + } else { + container.appendChild(node); + } + }); Array.prototype.slice.call(container.querySelectorAll(':scope > .ajax-pag')).forEach(function (node) { node.remove(); }); if (data.paginate_html) container.insertAdjacentHTML('beforeend', data.paginate_html); container.dataset.page = String(data.page || page); container.dataset.loaded = '1'; anchorLoader(container); + previous = []; scan(container); if (window.jQuery) window.jQuery(document).trigger('auto_fun'); }) .catch(function () { var loader = loaderOf(container); + previous.forEach(function (node) { container.insertBefore(node, loader); }); + anchorLoader(container); if (loader && loader.parentNode === container) { loader.insertAdjacentHTML('beforebegin', errorMarkup(page, append)); } else { diff --git a/tests/tab-posts-reference-alignment-2026-10-29.md b/tests/tab-posts-reference-alignment-2026-10-29.md index fb5b1d90..66c4d9f0 100644 --- a/tests/tab-posts-reference-alignment-2026-10-29.md +++ b/tests/tab-posts-reference-alignment-2026-10-29.md @@ -43,3 +43,13 @@ - `mini-pay-badge-reference.test.cjs` 检查真实 mini 渲染输出的四种付费类型、现金、积分、免费、两级 VIP、会员开关、标签位置和样式规则。 补充验证结果:新增 6 项付费标签测试通过,仓库全部 Node 测试 791/791 通过;139 条 PHP 断言、修改文件的 PHP 语法检查、CSS 解析、公共资源构建、后台 SPA 构建和差异空白检查通过。未进行真实视觉效果验收。 + +## 分页骨架加载时机修复 + +对照参考主题 `js/main.js` 中 `post_ajax()` 的 `beforeSend`:替换分页需要在发出请求前移除旧文章,骨架才会占据新页内容的位置。原实现直到响应成功才移除旧文章,导致数字分页和跳页期间骨架仍追加在旧页下方。 + +- 替换请求先暂存并移除旧页内容和分页控件,保留同一个骨架节点;成功时插入新页,失败时恢复原节点,并允许重试目标页。 +- 追加请求继续保留已有文章,在末尾展示骨架。合并响应成功后的内容插入逻辑,删除延迟清空旧页的重复分支。 +- 新增 4 项请求尚未完成时的行为测试,覆盖数字分页、连续切页、失败恢复与重试、加载更多、跳页和栏目隔离;修复测试 DOM 对后代选择器的匹配,确保栏目隔离断言定位到正确栏目。 + +验证结果:全部 Node 测试 795/795 通过,139 条 PHP 断言通过,前端脚本语法、公共资源构建、后台 SPA 构建和差异空白检查通过。未进行真实视觉效果验收。 diff --git a/tests/widget-system/tab-posts-runtime.test.cjs b/tests/widget-system/tab-posts-runtime.test.cjs index 996f852e..6d2107bc 100644 --- a/tests/widget-system/tab-posts-runtime.test.cjs +++ b/tests/widget-system/tab-posts-runtime.test.cjs @@ -197,7 +197,7 @@ function matchesSelector(node, selector) { if (!matchesCompound(cursor, tokens.pop())) return false; while (tokens.length) { const combinator = tokens.pop(); - const previous = tokens.pop(); + const previous = combinator === '>' ? tokens.pop() : combinator; if (combinator === '>') { cursor = cursor.parentNode; if (!cursor || !matchesCompound(cursor, previous)) return false; @@ -345,8 +345,9 @@ function createRuntime({ fetch, pages } = {}) { } const requests = []; const config = { restUrl: '/wp-json/aksrt/v1', i18n: { error: 'error', retry: 'retry' } }; - const fetchStub = fetch || ((url) => { + const fetchStub = (url) => { requests.push(url); + if (fetch) return fetch(url); const page = Number(new URL(url, 'https://example.test').searchParams.get('page')); const segment = new URL(url, 'https://example.test').searchParams.get('segment'); const response = (pages && pages[`${segment}:${page}`]) || { @@ -355,7 +356,7 @@ function createRuntime({ fetch, pages } = {}) { paginate_html: page < 3 ? `
more
` : '', }; return Promise.resolve({ ok: true, json: () => Promise.resolve(response) }); - }); + }; const window = { aksrtWidgets: config, matchMedia: () => ({ matches: false }), @@ -380,6 +381,155 @@ function createRuntime({ fetch, pages } = {}) { const tick = () => new Promise((resolve) => setImmediate(resolve)); const pageRequests = (requests) => requests.map((url) => Number(new URL(url, 'https://example.test').searchParams.get('page'))); +function pendingFetch() { + const pending = []; + return { + pending, + fetch(url) { + return new Promise((resolve, reject) => pending.push({ + url, + resolve(data) { resolve({ ok: true, json: () => Promise.resolve(data) }); }, + reject, + })); + }, + }; +} + +function addPageLink(document, pager, page) { + const link = document.createElement('a'); + link.className = 'page-numbers'; + link.setAttribute('data-widget-page', String(page)); + pager.querySelector('.ajax-pag').appendChild(link); + return link; +} + +function assertOnlyLoader(pager, loader, message) { + assert.equal(pager.children.length, 1, message); + assert.equal(pager.firstChild, loader); +} + +test('numbered pagination replaces the old page with its skeleton before the response arrives', async () => { + const transport = pendingFetch(); + const runtime = createRuntime({ fetch: transport.fetch }); + const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); + const originalPost = pager.querySelector('[data-post-id="post-one"]'); + const loader = runtime.document.createElement('div'); + loader.className = 'post_ajax_loader'; + pager.appendChild(loader); + + addPageLink(runtime.document, pager, 2).click(); + + assert.equal(pager.querySelectorAll('[data-post-id]').length, 0, 'old rows must stop occupying space while the replacement is pending'); + assert.equal(originalPost.parentNode, null); + assert.equal(pager.querySelector('.ajax-pag'), null, 'old page controls leave the loading page'); + assertOnlyLoader(pager, loader); + assert.equal(loader.classList.contains('is-loading'), true); + assert.equal(pager.getAttribute('aria-busy'), 'true'); + assert.equal(pager.dataset.page, '1', 'the current page changes only after a successful response'); + + transport.pending[0].resolve({ + html: 'Page two', + page: 2, + paginate_html: '
3
', + }); + await tick(); + + assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-two']); + assert.equal(pager.querySelector('.post_ajax_loader'), loader); + assert.equal(loader.classList.contains('is-loading'), false); + assert.equal(pager.getAttribute('aria-busy'), 'false'); + assert.equal(pager.dataset.page, '2'); + + pager.querySelector('[data-widget-page="3"]').click(); + assertOnlyLoader(pager, loader, 'the next replacement reuses the same skeleton in the cleared page'); + assert.equal(loader.classList.contains('is-loading'), true); + transport.pending[1].resolve({ html: '', page: 3, paginate_html: '' }); + await tick(); + assert.equal(pager.dataset.page, '3'); + assert.equal(loader.classList.contains('is-loading'), false); +}); + +test('a failed numbered page restores the old content and retries the requested replacement', async () => { + const transport = pendingFetch(); + const runtime = createRuntime({ fetch: transport.fetch }); + const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); + const originalPost = pager.querySelector('[data-post-id="post-one"]'); + const originalPagination = pager.querySelector('.ajax-pag'); + const link = addPageLink(runtime.document, pager, 2); + link.click(); + const loader = pager.querySelector('.post_ajax_loader'); + link.click(); + assert.deepEqual(pageRequests(runtime.requests), [2], 'a detached trigger cannot start a duplicate request'); + transport.pending[0].reject(new Error('temporary failure')); + await tick(); + + assert.equal(pager.querySelector('[data-post-id="post-one"]'), originalPost, 'failure restores the original row node'); + assert.equal(pager.querySelector('.ajax-pag'), originalPagination, 'failure restores the original pagination controls'); + assert.equal(pager.dataset.page, '1'); + assert.equal(pager.getAttribute('aria-busy'), 'false'); + assert.equal(loader.classList.contains('is-loading'), false); + const retry = pager.querySelector('[data-widget-retry]'); + assert.equal(retry.dataset.retryPage, '2'); + assert.equal(retry.dataset.retryAppend, '0'); + + retry.click(); + assert.deepEqual(pageRequests(runtime.requests), [2, 2]); + assertOnlyLoader(pager, loader, 'retry clears the restored page before showing its replacement skeleton'); + transport.pending[1].resolve({ + html: 'Page two', + page: 2, + paginate_html: '', + }); + await tick(); + assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-two']); + assert.equal(pager.querySelector('[data-widget-retry]'), null); + assert.equal(pager.dataset.page, '2'); +}); + +test('load-more keeps existing rows and places its pending skeleton after them', async () => { + const transport = pendingFetch(); + const runtime = createRuntime({ fetch: transport.fetch }); + const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); + const originalPost = pager.querySelector('[data-post-id="post-one"]'); + pager.querySelector('[data-next-page="2"]').click(); + const loader = pager.querySelector('.post_ajax_loader'); + + assert.equal(pager.querySelector('[data-post-id="post-one"]'), originalPost); + assert.equal(pager.children.indexOf(loader), pager.children.indexOf(originalPost) + 1); + assert.equal(loader.classList.contains('is-loading'), true); + transport.pending[0].resolve({ + html: 'Page two', + page: 2, + paginate_html: '', + }); + await tick(); + assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-one', 'post-two']); + assert.equal(pager.querySelector('.post_ajax_loader'), loader); + assert.equal(pager.children.indexOf(loader), 2); + assert.equal(loader.classList.contains('is-loading'), false); +}); + +test('jump pagination clears the page during loading and keeps the skeleton scoped to that tab', async () => { + const transport = pendingFetch(); + const runtime = createRuntime({ fetch: transport.fetch }); + const pager = runtime.widget.querySelector('#panel-one [data-widget-items]'); + const otherPager = runtime.widget.querySelector('#panel-two [data-widget-items]'); + const otherChildren = otherPager.children.slice(); + const { button } = addJumpControl(runtime.document, pager, { value: '4' }); + button.click(); + const loader = pager.querySelector('.post_ajax_loader'); + + assertOnlyLoader(pager, loader); + assert.equal(loader.classList.contains('is-loading'), true); + assert.equal(otherPager.children.length, otherChildren.length); + otherChildren.forEach((child, index) => assert.equal(otherPager.children[index], child, 'another tab keeps its existing DOM')); + assert.equal(otherPager.querySelector('.is-loading'), null); + transport.pending[0].resolve({ html: 'Page four', page: 4, paginate_html: '' }); + await tick(); + assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-four']); + assert.equal(pager.dataset.page, '4'); +}); + test('tab panes cache results and keep their current page after switching away and back', async () => { const runtime = createRuntime(); const paneOne = runtime.widget.querySelector('#panel-one'); @@ -432,7 +582,6 @@ test('overflowing tab centering includes the nav current scroll offset', () => { test('a failed load-more can retry page two and append it without replacing page one', async () => { let pageTwoAttempts = 0; const runtime = createRuntime({ fetch: (url) => { - runtime.requests.push(url); const page = Number(new URL(url, 'https://example.test').searchParams.get('page')); if (page === 2 && pageTwoAttempts++ === 0) return Promise.reject(new Error('temporary failure')); return Promise.resolve({ diff --git a/tests/widget-system/zibll-modern-widgets-contract.test.cjs b/tests/widget-system/zibll-modern-widgets-contract.test.cjs index f04843f7..3422a09c 100644 --- a/tests/widget-system/zibll-modern-widgets-contract.test.cjs +++ b/tests/widget-system/zibll-modern-widgets-contract.test.cjs @@ -47,8 +47,8 @@ test('widget REST client keeps the skeleton across the load-more cycle', () => { assert.match(frontend, /loader\.classList\.add\('is-loading'\)/); assert.doesNotMatch(frontend, /loader\.style\.display/); - // The skeleton is anchored to the end of the list on every request, exactly - // like the reference post_ajax beforeSend hook. + // The loader follows any retained rows and precedes the pagination controls. + // Replacement clears old rows before loading; runtime tests cover that cycle. assert.match(frontend, /function anchorLoader\(container\) \{/); assert.match(frontend, /if \(!loader\) return null;/); assert.match(frontend, /pag\.parentNode\.insertBefore\(loader, pag\)/); @@ -57,8 +57,6 @@ test('widget REST client keeps the skeleton across the load-more cycle', () => { // flush under the last card. assert.match(frontend, /var anchor = loaderOf\(container\) \|\| container\.querySelector\(':scope > \.ajax-pag'\);/); - // A replace must preserve the loader node (the reference success only hides it). - assert.match(frontend, /if \(child !== loader && !child\.classList\.contains\('ajax-pag'\)\) child\.remove\(\);/); assert.match(frontend, /return fetch\(itemsUrl\(/); });