ReviewOS

stacks/stx

fix: :for keyed items don't re-evaluate bindings when data changes

#1669
Closed glennmichael123 opened this 11 days ago ·
11 days ago

Problem

When a :for loop uses key-based diffing (:key), and an item's data changes but its :key stays the same, the reused DOM node's bindings (:text, :if, :class, etc.) are NOT re-evaluated. The old values remain until a full page reload.

Reproduction

<script client lang="ts">
  const items = state([
    { id: '1', title: 'Original Title' },
    { id: '2', title: 'Another Item' },
  ])

  function editItem() {
    const updated = JSON.parse(JSON.stringify(items.value))
    updated[0] = { ...updated[0], title: 'Updated Title' }
    items.value = updated
  }
</script>

<div :for="item in items" :key="item.id">
  <span :text="item.title"></span>
</div>

<button @click="editItem()">Edit</button>

Steps:

  1. Page renders: "Original Title", "Another Item"
  2. Click "Edit" — items.value is reassigned with a new object where item id: '1' has title: 'Updated Title'
  3. Expected: The first item's text updates to "Updated Title"
  4. Actual: The text stays as "Original Title" until page reload

Root Cause

The key-based diffing in bindFor (added in #1666) correctly matches existing DOM nodes by :key and reuses them. However, when a keyed node is reused, its :text, :if, :class, and other bindings are not re-evaluated with the new item data. The bindings were set up during the initial render and reference the old item object — they don't get rebound to the new item.

Current Workaround

Change the item's id (and thus its :key) when editing, forcing stx to treat it as a new element:

// Instead of keeping the same id:
updated[0] = { ...updated[0], title: 'Updated Title' }

// Change the id so :key differs:
updated[0] = { ...updated[0], id: old.id + '-e' + Date.now(), title: 'Updated Title' }

This defeats the purpose of key-based diffing since it forces a destroy + recreate instead of an in-place update.

Expected Behavior

When :for reuses a DOM node by matching :key:

  1. The item variable in the loop scope should be updated to reference the new data
  2. All bindings (:text, :if, :class, :show, @click handlers) should re-evaluate with the new item
  3. The DOM node itself should be preserved (no destroy/recreate) — only the content updates

This is how Vue's v-for + :key works: keyed nodes are reused but their bindings are patched with new data.

Impact

Any editable list where items are modified in-place (e.g. calendar events, todo items, table rows) requires the :key workaround. Without it, edits appear to not save until page reload.

Environment

  • stx version: 0.2.10 (local linked)
  • Discovered in a calendar app where editing a workout card's title via a modal didn't reflect in the grid until reload

🤖 Generated with Claude Code

11 days ago

Fixed on main. bindFor in packages/stx/src/signals.ts now wraps each loop item in its own state() signal stored per-key in itemSignalMap (line 2097-2107). When key-based diffing matches an existing DOM node, the reused node's item signal is updated in place (lines 2228-2232):

const signals = itemSignalMap.get(key);
if (signals) {
  signals.item.set(list[i]);  // ← all bindings tracking this signal re-evaluate
  signals.index.set(i);
}

The in-source comment at line 2226 explicitly references this issue:

Update the item signal so bindings re-evaluate with new data. This is the fix for #1669 — without this, reused elements show stale data because the old bindings captured the old item via closure.

So :text / :if / :class bindings inside a keyed item now update in place when the item's data changes, without forcing a destroy + recreate via key-changing.

Sign in to comment on this issue.