Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
379 changes: 379 additions & 0 deletions __tests__/directives-http-pagination.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1867,4 +1867,383 @@ describe('HTTP Pagination & Triggers', () => {
expect(obs._options.root).toBe(scrollable);
});
});


// ═══════════════════════════════════════════════════════════════════════
// Prepend scroll compensation (issue #316)
// ═══════════════════════════════════════════════════════════════════════

describe('Prepend scroll compensation', () => {

function buildDomWithScroll(attrs, children = '') {
const scrollContainer = document.createElement('div');
scrollContainer.style.overflowY = 'auto';

const parent = document.createElement('div');
parent.setAttribute('state', '{}');
const el = document.createElement('div');
for (const [k, v] of Object.entries(attrs)) {
el.setAttribute(k, v);
}
if (children) el.innerHTML = children;
parent.appendChild(el);
scrollContainer.appendChild(parent);
document.body.appendChild(scrollContainer);

let _scrollTop = 200;
Object.defineProperty(scrollContainer, 'scrollHeight', {
get: () => {
const items = el.querySelectorAll('.item');
return 500 + items.length * 100;
},
configurable: true,
});
Object.defineProperty(scrollContainer, 'scrollTop', {
get: () => _scrollTop,
set: (v) => { _scrollTop = v; },
configurable: true,
});

return { scrollContainer, parent, el, getScrollTop: () => _scrollTop };
}

test('68 — prepend: sync zero-delta arms nothing; subsequent prepend compensates with no stale interference', async () => {
const page1 = [{ id: 1, name: 'A' }];
const page2 = [{ id: 2, name: 'B' }];
const page3 = [{ id: 3, name: 'C' }, { id: 4, name: 'D' }];
global.fetch = mockFetchSequence([
{ data: page1 },
{ data: page2 },
{ data: page3 },
]);

const { scrollContainer, el, getScrollTop } = buildDomWithScroll({
get: '/api/items?page={page}',
as: 'items',
'get-insert': 'prepend',
'get-page': '1',
'get-trigger': 'button',
}, '<span class="item" each="item in items" bind="item.name"></span>');

// Non-overflowing: scrollHeight stays at 500 until 3+ items
Object.defineProperty(scrollContainer, 'scrollHeight', {
get: () => {
const count = el.querySelectorAll('.item').length;
return count >= 3 ? 500 + count * 100 : 500;
},
configurable: true,
});

processTree(el.parentElement);
await wait();

// First fetch: 1 item, scrollHeight = 500
expect(el.__ctx.items).toEqual(page1);
expect(el.querySelectorAll('.item').length).toBe(1);

// Second fetch (prepend): 2 items, scrollHeight still 500 → delta = 0
let btn = el.querySelector('[data-nojs-load-more]');
btn.click();
await wait();
expect(el.__ctx.items).toEqual([...page2, ...page1]);
expect(el.querySelectorAll('.item').length).toBe(2);
// No compensation (delta was 0, sync render); slot cleared
expect(getScrollTop()).toBe(200);
expect(el._deferredRenderSlot).toBeUndefined();

// Third fetch (prepend): 4 items, scrollHeight = 900
// delta = 900 - 500 = 400 → scrollTop = 200 + 400 = 600
// A stale slot from the zero-delta prepend would interfere.
btn = el.querySelector('[data-nojs-load-more]');
btn.click();
await wait();
expect(el.__ctx.items.length).toBe(4);
expect(getScrollTop()).toBe(600);
});

test('69 — prepend with animate-leave: delta-based compensation at deferred render completion', async () => {
const page1 = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
const page2 = [{ id: 3, name: 'C' }, { id: 4, name: 'D' }];
global.fetch = mockFetchSequence([
{ data: page1 },
{ data: page2 },
]);

const { el, getScrollTop } = buildDomWithScroll({
get: '/api/items?page={page}',
as: 'items',
'get-insert': 'prepend',
'get-page': '1',
'get-trigger': 'button',
}, '<span class="item" each="item in items" animate-leave="fadeOut" bind="item.name"></span>');

processTree(el.parentElement);
await wait();

// First fetch: 2 items rendered
expect(el.__ctx.items).toEqual(page1);
expect(el.querySelectorAll('.item').length).toBe(2);
expect(getScrollTop()).toBe(200);

const btn = el.querySelector('[data-nojs-load-more]');
btn.click();
// Wait for fetch + deferred render (setTimeout(done, 0)) + callback
await wait(50);

// 4 items → scrollHeight = 900, oldScrollHeight = 700, delta = 200
// scrollTop += delta → 200 + 200 = 400
expect(el.querySelectorAll('.item').length).toBe(4);
expect(getScrollTop()).toBe(400);
});

test('70 — user scroll during deferral window preserved in delta-based compensation', async () => {
jest.useFakeTimers();

const page1 = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
const page2 = [{ id: 3, name: 'C' }, { id: 4, name: 'D' }];
global.fetch = mockFetchSequence([
{ data: page1 },
{ data: page2 },
]);

const { scrollContainer, el, getScrollTop } = buildDomWithScroll({
get: '/api/items?page={page}',
as: 'items',
'get-insert': 'prepend',
'get-page': '1',
'get-trigger': 'button',
}, '<span class="item" each="item in items" animate-leave="fadeOut" animate-duration="5000" bind="item.name"></span>');

processTree(el.parentElement);
await jest.advanceTimersByTimeAsync(50);

expect(el.querySelectorAll('.item').length).toBe(2);

// Trigger deferred prepend
const btn = el.querySelector('[data-nojs-load-more]');
btn.click();
await jest.advanceTimersByTimeAsync(10);

// User scrolls during the deferral window
scrollContainer.scrollTop = 350;
expect(getScrollTop()).toBe(350);

// Fire the deferred render (advance past 5s animate-duration)
jest.advanceTimersByTime(5000);
await jest.advanceTimersByTimeAsync(0);

// 4 items → scrollHeight = 900, oldScrollHeight = 700, delta = 200
// Delta-based: 350 (user position) + 200 = 550
expect(el.querySelectorAll('.item').length).toBe(4);
expect(getScrollTop()).toBe(550);

jest.useRealTimers();
});

test('71 — unrelated DOM mutation during deferral does not trigger compensation', async () => {
jest.useFakeTimers();

const page1 = [{ id: 1, name: 'A' }];
const page2 = [{ id: 2, name: 'B' }];
global.fetch = mockFetchSequence([
{ data: page1 },
{ data: page2 },
]);

const { el, getScrollTop } = buildDomWithScroll({
get: '/api/items?page={page}',
as: 'items',
'get-insert': 'prepend',
'get-page': '1',
'get-trigger': 'button',
}, '<span class="item" each="item in items" animate-leave="fadeOut" animate-duration="5000" bind="item.name"></span>');

processTree(el.parentElement);
await jest.advanceTimersByTimeAsync(50);

expect(el.querySelectorAll('.item').length).toBe(1);

// Trigger deferred prepend
const btn = el.querySelector('[data-nojs-load-more]');
btn.click();
await jest.advanceTimersByTimeAsync(10);

// Inject unrelated DOM mutation — with the old MutationObserver
// this would have mis-triggered compensation
const indicator = document.createElement('div');
indicator.className = 'loading-indicator';
el.appendChild(indicator);
await jest.advanceTimersByTimeAsync(0);

// scrollTop must NOT have changed
expect(getScrollTop()).toBe(200);

// Clean up and fire the real deferred render
el.removeChild(indicator);
jest.advanceTimersByTime(5000);
await jest.advanceTimersByTimeAsync(0);

// Only now: 2 items → scrollHeight = 700, oldScrollHeight = 600, delta = 100
// scrollTop = 200 + 100 = 300
expect(el.querySelectorAll('.item').length).toBe(2);
expect(getScrollTop()).toBe(300);

jest.useRealTimers();
});

test('72 — dispose mid-defer: no compensation, slot cleaned', async () => {
jest.useFakeTimers();

const page1 = [{ id: 1, name: 'A' }];
const page2 = [{ id: 2, name: 'B' }, { id: 3, name: 'C' }];
global.fetch = mockFetchSequence([
{ data: page1 },
{ data: page2 },
]);

const { el, parent, getScrollTop } = buildDomWithScroll({
get: '/api/items?page={page}',
as: 'items',
'get-insert': 'prepend',
'get-page': '1',
'get-trigger': 'button',
}, '<span class="item" each="item in items" animate-leave="fadeOut" animate-duration="5000" bind="item.name"></span>');

processTree(parent);
await jest.advanceTimersByTimeAsync(50);

expect(el.__ctx.items).toEqual(page1);
expect(el.querySelectorAll('.item').length).toBe(1);

// Trigger deferred prepend
const btn = el.querySelector('[data-nojs-load-more]');
btn.click();
await jest.advanceTimersByTimeAsync(10);

// Dispose before the deferred render fires
_disposeTree(el);

// Slot cleared by disposer
expect(el._deferredRenderSlot).toBeUndefined();

// Advance past the 5s animate-duration timeout
jest.advanceTimersByTime(10000);
await jest.advanceTimersByTimeAsync(0);

// scrollTop NOT adjusted — slot was cleaned on dispose
expect(getScrollTop()).toBe(200);

jest.useRealTimers();
});

test('73 — nested markup (el > ul > li[each]): slot walks up to get-host and compensation fires', async () => {
const page1 = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
const page2 = [{ id: 3, name: 'C' }, { id: 4, name: 'D' }];
global.fetch = mockFetchSequence([
{ data: page1 },
{ data: page2 },
]);

// Nested: el[get] > ul > li[each] — loop's parent is <ul>, not el
const scrollContainer = document.createElement('div');
scrollContainer.style.overflowY = 'auto';
const stateEl = document.createElement('div');
stateEl.setAttribute('state', '{}');
const el = document.createElement('div');
el.setAttribute('get', '/api/items?page={page}');
el.setAttribute('as', 'items');
el.setAttribute('get-insert', 'prepend');
el.setAttribute('get-page', '1');
el.setAttribute('get-trigger', 'button');
el.innerHTML = '<ul><li class="item" each="item in items" bind="item.name"></li></ul>';
stateEl.appendChild(el);
scrollContainer.appendChild(stateEl);
document.body.appendChild(scrollContainer);

let _scrollTop = 200;
Object.defineProperty(scrollContainer, 'scrollHeight', {
get: () => {
const items = el.querySelectorAll('.item');
return 500 + items.length * 100;
},
configurable: true,
});
Object.defineProperty(scrollContainer, 'scrollTop', {
get: () => _scrollTop,
set: (v) => { _scrollTop = v; },
configurable: true,
});

processTree(stateEl);
await wait();

expect(el.__ctx.items).toEqual(page1);
expect(el.querySelectorAll('.item').length).toBe(2);

const btn = el.querySelector('[data-nojs-load-more]');
btn.click();
await wait();

// 4 items → scrollHeight = 900, oldScrollHeight = 700, delta = 200
// scrollTop = 200 + 200 = 400
expect(el.querySelectorAll('.item').length).toBe(4);
expect(_scrollTop).toBe(400);
});

test('74 — nested markup with animate-leave: deferred compensation fires across nesting gap', async () => {
const page1 = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
const page2 = [{ id: 3, name: 'C' }, { id: 4, name: 'D' }];
global.fetch = mockFetchSequence([
{ data: page1 },
{ data: page2 },
]);

// Nested: el[get] > ul > li[each animate-leave]
const scrollContainer = document.createElement('div');
scrollContainer.style.overflowY = 'auto';
const stateEl = document.createElement('div');
stateEl.setAttribute('state', '{}');
const el = document.createElement('div');
el.setAttribute('get', '/api/items?page={page}');
el.setAttribute('as', 'items');
el.setAttribute('get-insert', 'prepend');
el.setAttribute('get-page', '1');
el.setAttribute('get-trigger', 'button');
el.innerHTML = '<ul><li class="item" each="item in items" animate-leave="fadeOut" bind="item.name"></li></ul>';
stateEl.appendChild(el);
scrollContainer.appendChild(stateEl);
document.body.appendChild(scrollContainer);

let _scrollTop = 200;
Object.defineProperty(scrollContainer, 'scrollHeight', {
get: () => {
const items = el.querySelectorAll('.item');
return 500 + items.length * 100;
},
configurable: true,
});
Object.defineProperty(scrollContainer, 'scrollTop', {
get: () => _scrollTop,
set: (v) => { _scrollTop = v; },
configurable: true,
});

processTree(stateEl);
await wait();

expect(el.__ctx.items).toEqual(page1);
expect(el.querySelectorAll('.item').length).toBe(2);

const btn = el.querySelector('[data-nojs-load-more]');
btn.click();
// Wait for fetch + deferred render (setTimeout(done, 0)) + callback
await wait(50);

// Slot walked up from <ul> (loop parent) to el (get-host).
// 4 items → scrollHeight = 900, oldScrollHeight = 700, delta = 200
// scrollTop = 200 + 200 = 400
expect(el.querySelectorAll('.item').length).toBe(4);
expect(_scrollTop).toBe(400);
});
});
});
Loading