fix(widgets): replace old page before showing pagination skeleton
This commit is contained in:
1 parent
985f18c750
commit
4c13ef478c
4 files changed
+181
-21
No files matched your search
+16
-13
@@ -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 构建和差异空白检查通过。未进行真实视觉效果验收。
|
||||
@@ -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\(/);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user