Problem
:for re-renders every item in the loop when any signal in the component scope changes — even signals completely unrelated to the loop data. This causes:
- Visual glitch: all
:ifelements inside the loop flash as they're torn down and re-created (the server-rendered HTML is briefly visible before:ifremoves it) - Console spam: hundreds of
[stx] bindIf REMOVING elementlog lines per interaction - Performance: O(n) DOM operations for a O(1) state change
Reproduction
<script client lang="ts">
const items = state([
{ id: '1', title: 'Item A', type: 'run' },
{ id: '2', title: 'Item B', type: 'strength' },
{ id: '3', title: 'Item C', type: 'run' },
])
// This signal is NOT used in the :for loop
const modalOpen = state(false)
</script>
<div :for="item in items" :key="item.id">
<span :if="item.type === 'run'">��</span>
<span :if="item.type === 'strength'">��️</span>
<span :text="item.title"></span>
</div>
<!-- Clicking this rebuilds ALL 3 loop items -->
<button @click="modalOpen = !modalOpen">Toggle Modal</button>
<div :show="modalOpen">Modal content</div>Clicking "Toggle Modal" triggers a full teardown/rebuild of all 3 :for items, even though modalOpen has nothing to do with items. Every :if inside the loop fires bindIf REMOVING + bindIf REMOVED, and the elements flash briefly before being re-created.
In a real-world calendar view with 30+ workout cards (each containing ~13 :if directives), a single menu toggle produces 390+ bindIf log lines and a visible UI glitch.
Root Cause
In packages/stx/src/signals.ts (line ~1856), bindFor's effect() callback:
effect(() => {
currentElements.forEach(e => e.remove()); // tears down ALL items
currentElements = [];
// ... re-evaluates list, re-creates all items from scratch
});This effect subscribes to every signal accessed during evaluation, including signals read by other bindings in the same component scope. When any signal in the scope changes, the effect re-runs and does a full DOM teardown/rebuild.
The :key attribute is stripped during setup (line ~1790) and never used for diffing:
wrapper.removeAttribute(':key');Two separate issues
Issue 1: Over-broad dependency tracking
The :for effect should only re-run when the list signal itself changes, not when unrelated signals (modalOpen, menuOpen, etc.) change. Currently, the effect function runs inside a global effect() that tracks all signal reads in the scope.
Issue 2: No key-based diffing
When the list does change, the loop should diff by :key and only update/add/remove the specific items that changed — not tear down and rebuild everything. This is how v-for + :key works in Vue, {#each} + (key) in Svelte, and key prop in React.
Expected Behavior
- Changing
modalOpenshould not trigger any:forre-render - Changing one item in
itemsshould only re-render that item, not all items - Adding/removing an item should only add/remove that DOM node, keeping siblings intact
:ifelements inside the loop should not flash/glitch on unrelated state changes
Impact
This makes :for impractical for any list with conditional rendering (:if) inside loop items. Common patterns like dropdown menus, accordion toggles, or inline editing inside a list all trigger the full rebuild storm. The workaround (switching :if to :show) hides the visual glitch but the DOM rebuild still happens underneath.
Suggested Approach
- Narrow dependency tracking: Evaluate the list expression in an isolated tracking scope so only the list signal is tracked, not every signal in the component
- Key-based reconciliation: Use
:keyto identify items across renders, then diff old vs new arrays and only add/remove/reorder the changed DOM nodes (similar to Vue'spatchKeyedChildren)
Environment
- stx version: 0.2.10 (local linked)
- Browser: Chrome (latest)
- Template: coaches/calendar.stx in a training dashboard app (7 days × 3-5 items per day = ~30 cards, each with ~13
:ifdirectives)
🤖 Generated with Claude Code