fix(widgets): replace old page before showing pagination skeleton

This commit is contained in:
kate522 committed 2026-10-29 21:29:17 +08:00
1 parent 985f18c750
commit 4c13ef478c
4 files changed
+181 -21

No files matched your search

+16 -13
View File
@@ -121,9 +121,8 @@
}
}
// 参考主题 post_ajax 的 beforeSend 语义:骨架每次请求都挪到列表末尾
// (最后一个条目之后、分页控件之前),请求成功后只隐藏而不删除,
// 下一轮翻页复用同一个节点。
// 追加时骨架紧跟已有条目;替换时先清空旧页,骨架留在新页的位置。
// 请求成功后隐藏并保留同一个骨架节点,供下一轮请求复用。
function anchorLoader(container) {
var pag = container.querySelector(':scope > .ajax-pag');
var loader = loaderOf(container);
@@ -144,14 +143,23 @@
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) {
@@ -160,24 +168,19 @@
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); });
}
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 {
@@ -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 构建和差异空白检查通过。未进行真实视觉效果验收。
+153 -4
View File
@@ -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 ? `<div class="ajax-pag"><a data-next-page="${page + 1}">more</a></div>` : '',
};
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: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
page: 2,
paginate_html: '<div class="ajax-pag"><a class="page-numbers" data-widget-page="3">3</a></div>',
});
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: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
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: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
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: '<posts class="posts-mini ajax-item" data-post-id="post-four">Page four</posts>', 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({
@@ -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\(/);
});