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
+189 -29

No files matched your search

+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({