Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
11fcbc2
chore(test-server): support NOJS_ELEMENTS_PATH env var override
ErickXavier Jul 29, 2026
a7b2a5c
fix(i18n): load locale bundle when i18n() called after init()
ErickXavier Jul 29, 2026
166d397
docs(loops): add Common Mistakes callout for container-vs-item direct…
ErickXavier Jul 29, 2026
7f24cc3
test(loops): cover animate-stagger with external template path
ErickXavier Jul 29, 2026
b2b6586
docs(site): deduplicate 3x locale list in docs shell
ErickXavier Jul 29, 2026
e5bfd7f
Merge pull request #303 from no-js-dev/chore/NOJS-295-NOJS-298
ErickXavier Jul 29, 2026
79654ff
Merge pull request #306 from no-js-dev/test/NOJS-295-NOJS-300
ErickXavier Jul 29, 2026
46b4d1f
fix/NOJS-295-NOJS-296: fix insert-mode duplication in HTTP directive
ErickXavier Jul 29, 2026
9764c49
fix/NOJS-295-NOJS-296: set observer root to nearest scrollable ancestor
ErickXavier Jul 29, 2026
ded57f6
Merge pull request #304 from no-js-dev/fix/NOJS-295-NOJS-297
ErickXavier Jul 29, 2026
9704e65
docs(loops): move Common Mistakes section to before See Also
ErickXavier Jul 29, 2026
93ada2a
Merge pull request #305 from no-js-dev/docs/NOJS-295-NOJS-301
ErickXavier Jul 29, 2026
3f997f2
Merge pull request #307 from no-js-dev/docs/NOJS-295-NOJS-302
ErickXavier Jul 29, 2026
94cf475
fix/NOJS-295-NOJS-296: fix third observer root and correct comment
ErickXavier Jul 29, 2026
c2e36fb
Merge fix/NOJS-295 into fix/NOJS-295-NOJS-296 (rebuild dist)
ErickXavier Jul 29, 2026
30b7a9f
Merge pull request #308 from no-js-dev/fix/NOJS-295-NOJS-296
ErickXavier Jul 29, 2026
9ccc898
test(e2e): harden flaky waits, dedup assertions, Elements skip guard
ErickXavier Jul 29, 2026
50658de
test(e2e): add deterministic cancel-signal to CANCEL interceptor test
ErickXavier Jul 29, 2026
2c34601
Merge pull request #309 from no-js-dev/test/NOJS-295-NOJS-299
ErickXavier Jul 29, 2026
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
244 changes: 244 additions & 0 deletions __tests__/directives-http-pagination.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import '../src/directives/binding.js';
import '../src/directives/http.js';
import '../src/directives/refs.js';
import '../src/directives/events.js';
import '../src/directives/loops.js';


// ── IntersectionObserver mock ──────────────────────────────────────────────
Expand Down Expand Up @@ -1623,4 +1624,247 @@ describe('HTTP Pagination & Triggers', () => {
expect(el.__ctx.cursor).toBe('next');
});
});


// ═══════════════════════════════════════════════════════════════════════
// Insert-mode DOM deduplication (ADR-002 D1)
// ═══════════════════════════════════════════════════════════════════════

describe('Insert-mode DOM deduplication (ADR-002 D1)', () => {

test('61 — append mode renders exactly 4 unique items across 2 fetches (no duplication)', 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 } = buildDom({
get: '/api/items?page={page}',
as: 'items',
'get-insert': 'append',
'get-page': '1',
'get-trigger': 'button',
}, '<span class="item" each="item in items" bind="item.name"></span>');

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

// First fetch: 2 items rendered
expect(el.__ctx.items).toEqual(page1);
let items = el.querySelectorAll('.item');
expect(items.length).toBe(2);
expect(items[0].textContent).toBe('A');
expect(items[1].textContent).toBe('B');

// Click "Load More" button for second fetch
const btn = el.querySelector('[data-nojs-load-more]');
expect(btn).not.toBeNull();
btn.click();
await wait();

// After second fetch: exactly 4 items (no duplication), page1 then page2
expect(el.__ctx.items).toEqual([...page1, ...page2]);
items = el.querySelectorAll('.item');
expect(items.length).toBe(4);
expect(items[0].textContent).toBe('A');
expect(items[1].textContent).toBe('B');
expect(items[2].textContent).toBe('C');
expect(items[3].textContent).toBe('D');
});

test('62 — prepend mode renders correct count and order across 2 fetches', async () => {
const page1 = [{ id: 1, name: 'X' }, { id: 2, name: 'Y' }];
const page2 = [{ id: 3, name: 'P' }, { id: 4, name: 'Q' }];
global.fetch = mockFetchSequence([
{ data: page1 },
{ data: page2 },
]);

const { el } = buildDom({
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>');

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

// First fetch
expect(el.__ctx.items).toEqual(page1);
let items = el.querySelectorAll('.item');
expect(items.length).toBe(2);

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

// After second fetch: 4 items, page2 before page1
expect(el.__ctx.items).toEqual([...page2, ...page1]);
items = el.querySelectorAll('.item');
expect(items.length).toBe(4);
expect(items[0].textContent).toBe('P');
expect(items[1].textContent).toBe('Q');
expect(items[2].textContent).toBe('X');
expect(items[3].textContent).toBe('Y');
});

test('63 — replace mode (no get-insert) renders exact count without accumulation', async () => {
const data1 = [{ id: 1, name: 'First' }];
const data2 = [{ id: 2, name: 'Second' }, { id: 3, name: 'Third' }];
global.fetch = mockFetchSequence([
{ data: data1 },
{ data: data2 },
]);

const { el } = buildDom({
get: '/api/items',
as: 'items',
}, '<span class="item" each="item in items" bind="item.name"></span>');

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

// First fetch: 1 item
expect(el.__ctx.items).toEqual(data1);
let items = el.querySelectorAll('.item');
expect(items.length).toBe(1);
expect(items[0].textContent).toBe('First');

// Trigger re-fetch (replace mode uses el.refresh = doRequest)
el.refresh();
await wait();

// After second fetch: only data2, no accumulation
expect(el.__ctx.items).toEqual(data2);
items = el.querySelectorAll('.item');
expect(items.length).toBe(2);
expect(items[0].textContent).toBe('Second');
expect(items[1].textContent).toBe('Third');
});
});


// ═══════════════════════════════════════════════════════════════════════
// Observer root option (ADR-002 D2)
// ═══════════════════════════════════════════════════════════════════════

describe('Observer root option (ADR-002 D2)', () => {

test('64 — scroll observer uses scrollable ancestor as root', async () => {
global.fetch = mockFetchSequence([
{ data: [{ id: 1 }] },
{ data: [{ id: 2 }] },
]);

const scrollable = document.createElement('div');
scrollable.style.overflowY = 'auto';

const parent = document.createElement('div');
parent.setAttribute('state', '{}');
const el = document.createElement('div');
el.setAttribute('get', '/api/items?page={page}');
el.setAttribute('as', 'items');
el.setAttribute('get-insert', 'append');
el.setAttribute('get-page', '1');
el.setAttribute('get-trigger', 'scroll');

parent.appendChild(el);
scrollable.appendChild(parent);
document.body.appendChild(scrollable);

processTree(parent);
await wait();

// After first fetch, _setupScrollObserver is called
const scrollObs = MockIntersectionObserver._instances.find(
(o) => o._entries.some((e) => e.hasAttribute && e.hasAttribute('data-nojs-sentinel'))
);
expect(scrollObs).toBeDefined();
expect(scrollObs._options.root).toBe(scrollable);
expect(scrollObs._options.rootMargin).toBe('200px');
});

test('65 — scroll observer uses null root at document level', async () => {
global.fetch = mockFetchSequence([
{ data: [{ id: 1 }] },
{ data: [{ id: 2 }] },
]);

const { el } = buildDom({
get: '/api/items?page={page}',
as: 'items',
'get-insert': 'append',
'get-page': '1',
'get-trigger': 'scroll',
});

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

const scrollObs = MockIntersectionObserver._instances.find(
(o) => o._entries.some((e) => e.hasAttribute && e.hasAttribute('data-nojs-sentinel'))
);
expect(scrollObs).toBeDefined();
expect(scrollObs._options.root).toBeNull();
});

test('66 — visible trigger observer uses scrollable ancestor as root', async () => {
global.fetch = mockFetchJson([{ id: 1 }]);

const scrollable = document.createElement('div');
scrollable.style.overflowY = 'auto';

const parent = document.createElement('div');
parent.setAttribute('state', '{}');
const el = document.createElement('div');
el.setAttribute('get', '/api/items');
el.setAttribute('as', 'items');
el.setAttribute('get-trigger', 'visible');

parent.appendChild(el);
scrollable.appendChild(parent);
document.body.appendChild(scrollable);

processTree(parent);

// visible trigger creates observer immediately
const visObs = MockIntersectionObserver._instances.find(
(o) => o._entries.includes(el)
);
expect(visObs).toBeDefined();
expect(visObs._options.root).toBe(scrollable);
});

test('67 — scroll-without-insert fallback observer uses scrollable ancestor as root', async () => {
global.fetch = mockFetchJson([{ id: 1 }]);

const scrollable = document.createElement('div');
scrollable.style.overflowY = 'auto';

const parent = document.createElement('div');
parent.setAttribute('state', '{}');
const el = document.createElement('div');
el.setAttribute('get', '/api/items');
el.setAttribute('as', 'items');
el.setAttribute('get-trigger', 'scroll');

parent.appendChild(el);
scrollable.appendChild(parent);
document.body.appendChild(scrollable);

processTree(parent);

// scroll without get-insert falls back to visible-like observer on el
const obs = MockIntersectionObserver._instances.find(
(o) => o._entries.includes(el)
);
expect(obs).toBeDefined();
expect(obs._options.root).toBe(scrollable);
});
});
});
Loading