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
90 changes: 90 additions & 0 deletions __tests__/loop-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,93 @@ describe('Loop + State interaction', () => {
expect(statuses[1].textContent).toBe('false');
});
});

// ── Filter outer-scope dependency tracking (NOJS-335) ────────────────

describe('Loop filter subscribes to outer-scope dependencies', () => {
afterEach(() => {
document.body.innerHTML = '';
Object.keys(_stores).forEach((k) => delete _stores[k]);
});

test('toggling outer boolean re-filters the loop', () => {
document.body.innerHTML = `
<div state="{ items: [{name:'Alice',active:true},{name:'Bob',active:false},{name:'Charlie',active:true}], showActive: true }">
<button class="toggle" on:click="showActive = !showActive">Toggle</button>
<ul>
<li each="item in items" filter="!showActive || item.active">
<span class="name" bind="item.name"></span>
</li>
</ul>
</div>
`;
processTree(document.body);

const names = () => [...document.querySelectorAll('.name')].map(n => n.textContent);

// Initial: showActive=true → only active items pass the filter
expect(names()).toEqual(['Alice', 'Charlie']);

// Toggle showActive to false → all items should show
document.querySelector('.toggle').click();
expect(names()).toEqual(['Alice', 'Bob', 'Charlie']);

// Toggle back to true → only active items again
document.querySelector('.toggle').click();
expect(names()).toEqual(['Alice', 'Charlie']);
});

test('typing into model-bound search re-filters the loop via includes', () => {
document.body.innerHTML = `
<div state="{ users: [{name:'Alice'},{name:'Bob'},{name:'Charlie'}], query: '' }">
<input class="search" model="query" />
<ul>
<li each="user in users" filter="!query || user.name.toLowerCase().includes(query.toLowerCase())">
<span class="rname" bind="user.name"></span>
</li>
</ul>
</div>
`;
processTree(document.body);

const names = () => [...document.querySelectorAll('.rname')].map(n => n.textContent);

// Initially all items shown (query is empty)
expect(names()).toEqual(['Alice', 'Bob', 'Charlie']);

// Type 'li' → matches Alice and Charlie
const input = document.querySelector('.search');
input.value = 'li';
input.dispatchEvent(new Event('input', { bubbles: true }));
expect(names()).toEqual(['Alice', 'Charlie']);

// Clear search → all items again
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
expect(names()).toEqual(['Alice', 'Bob', 'Charlie']);
});

test('keyed loop filter subscribes to outer deps', () => {
document.body.innerHTML = `
<div state="{ items: [{id:1,name:'Alice',active:true},{id:2,name:'Bob',active:false},{id:3,name:'Charlie',active:true}], showActive: true }">
<button class="toggle" on:click="showActive = !showActive">Toggle</button>
<ul>
<li each="item in items" key="item.id" filter="!showActive || item.active">
<span class="kname" bind="item.name"></span>
</li>
</ul>
</div>
`;
processTree(document.body);

const names = () => [...document.querySelectorAll('.kname')].map(n => n.textContent);

expect(names()).toEqual(['Alice', 'Charlie']);

document.querySelector('.toggle').click();
expect(names()).toEqual(['Alice', 'Bob', 'Charlie']);

document.querySelector('.toggle').click();
expect(names()).toEqual(['Alice', 'Charlie']);
});
});
6 changes: 3 additions & 3 deletions dist/iife/no.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/iife/no.js.map

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions e2e/examples/loops.html
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,24 @@
<template id="fr-no-data"><p data-test="fr-tpl-else">Empty list</p></template>
</section>

<!-- 17. Filter with outer-scope boolean toggle (NOJS-335) -->
<section state="{ items335: [{name:'Alice',active:true},{name:'Bob',active:false},{name:'Charlie',active:true}], showActive: true }">
<p class="test-label">Test 17: Filter with outer-scope boolean toggle</p>
<button data-test="filter-toggle" on:click="showActive = !showActive">Toggle</button>
<ul data-test="filter-outer-list">
<li data-test="filter-outer-item" each="item in items335" filter="!showActive || item.active" bind="item.name"></li>
</ul>
</section>

<!-- 18. Filter with outer-scope search query (NOJS-335) -->
<section state="{ users335: [{name:'Alice'},{name:'Bob'},{name:'Charlie'}], query335: '' }">
<p class="test-label">Test 18: Filter with outer-scope search query</p>
<input data-test="filter-search" model="query335" type="text" placeholder="Search..." />
<ul data-test="filter-search-list">
<li data-test="filter-search-item" each="user in users335" filter="!query335 || user.name.toLowerCase().includes(query335.toLowerCase())" bind="user.name"></li>
</ul>
</section>

<script src="../../dist/iife/no.js"></script>
<!--
WORKAROUND: processTree(document.body) breaks when a self-repeating loop
Expand Down
41 changes: 41 additions & 0 deletions e2e/tests/loops.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,45 @@ test.describe('Loops', () => {
await expect(items).toHaveCount(0);
await expect(elseEl).toBeVisible();
});

// ── Filter outer-scope dependency tracking (NOJS-335) ──────────────

test('17 — filter with outer boolean toggle re-filters on change', async ({ page }) => {
const items = page.getByTestId('filter-outer-item');

// Initial: showActive=true → only active items (Alice, Charlie)
await expect(items).toHaveCount(2);
await expect(items.nth(0)).toHaveText('Alice');
await expect(items.nth(1)).toHaveText('Charlie');

// Toggle showActive to false → all items
await page.getByTestId('filter-toggle').click();
await expect(items).toHaveCount(3);
await expect(items.nth(0)).toHaveText('Alice');
await expect(items.nth(1)).toHaveText('Bob');
await expect(items.nth(2)).toHaveText('Charlie');

// Toggle back → active-only again
await page.getByTestId('filter-toggle').click();
await expect(items).toHaveCount(2);
await expect(items.nth(0)).toHaveText('Alice');
await expect(items.nth(1)).toHaveText('Charlie');
});

test('18 — filter with outer search query re-filters on input', async ({ page }) => {
const items = page.getByTestId('filter-search-item');

// Initially all items shown
await expect(items).toHaveCount(3);

// Type 'li' → matches Alice and Charlie
await page.getByTestId('filter-search').fill('li');
await expect(items).toHaveCount(2);
await expect(items.nth(0)).toHaveText('Alice');
await expect(items.nth(1)).toHaveText('Charlie');

// Clear → all items
await page.getByTestId('filter-search').fill('');
await expect(items).toHaveCount(3);
});
});
9 changes: 7 additions & 2 deletions src/directives/loops.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,12 @@ const _loopHandler = {

// Same-reference optimisation: propagate to managed clones without DOM
// rebuild. Skipped while the else template is showing — the managed
// nodes are template content, not item clones.
// nodes are template content, not item clones. Also skipped when a
// filter expression exists — the filter may reference outer-scope
// variables whose change should produce a different filtered list even
// when the source array reference is unchanged.
const managedClones = _getManagedClones(startMarker, endMarker);
if (!elseRendered && list === prevList && list.length > 0 && managedClones.length > 0) {
if (!elseRendered && !filterExpr && list === prevList && list.length > 0 && managedClones.length > 0) {
for (const clone of managedClones) {
if (clone.__ctx && clone.__ctx.$notify) clone.__ctx.$notify();
}
Expand Down Expand Up @@ -640,6 +643,8 @@ const _loopHandler = {
const savedEl = _currentEl;
_setCurrentEl(parent);
_watchExpr(listPath, ctx, update);
if (filterExpr) _watchExpr(filterExpr, ctx, update);
if (sortProp) _watchExpr(sortProp, ctx, update);
update();
_setCurrentEl(savedEl);
},
Expand Down