Found some potential bugs in the List API

Hey there, I’m working with claude code to setup a pretty robust events system in Webflow where I can add recurring events into CMS collection lists (based on some CMS fields, recurring events are duplicated and shown in a variety of different layouts - cards, calendar view etc.).

As a part of this I ran into 2 issues with the Attributes API, I was able to find some workarounds but according to claude they were genuine bugs within the api (I had it confirm after looking at the sourcecode of the rep). so I thought I’d run this by your team so you could take a look.
Apologies if this is totally off-base, I won’t say I understand the issues 100% but thought it would be worth sharing if your team is able to validate and fix them on your end.

Here’s the full bug report I had claude write:

Bug Report: List API — DOM rendering is asynchronous and decoupled from every promise/hook we could find that’s meant to signal “done”

Environment

  • @finsweet/attributes v2, List module: <script async type="module" src="https://cdn.jsdelivr.net/npm/@finsweet/attributes@2/attributes.js" fs-list></script>
  • Webflow Collection List with fs-list-element="list", some instances also using fs-list-load="all"
  • Programmatic API via window.FinsweetAttributes.push(['list', callback]), not the declarative filter UI
  • Source references below are against the public finsweet/attributes GitHub repo, packages/list/src/

Bug 1: createItem() items hidden on the first pipeline run only

What we’re doing
We need to render more cards than there are CMS items — e.g., one CMS entry (“Weekly Class”) should produce 4 separate cards for a given month, one per weekly occurrence. Declarative filtering can’t do this (it filters existing items, it doesn’t multiply them), so we use the JS API. Inside an addHook('filter', ...) callback, for each extra occurrence we clone an existing item’s element and register it:

const clone = originalElement.cloneNode(true);

originalElement.insertAdjacentElement('afterend', clone);

const newItem = listInstance.createItem(clone);

// newItem is included in the array returned from the filter hook

This matches the pattern shown in your own docs for addHook('filter', ...) and createItem().

The bug
On page load, the very first time our filter hook runs, the cloned elements are correctly inserted into the DOM and correctly included in the array returned from the hook — but they end up with an inline style="display: none" applied to them and never become visible.

If the exact same hook runs again immediately after — identical data, identical logic, identical result — the clones render correctly. Every run after the first works correctly, indefinitely. Only the very first pipeline run for a given list instance is affected.

What we ruled out

  • Not a data/logic bug on our end — we logged the input/output of our hook on both the broken first run and a working later run; identical in every respect (same computed count, same array length returned).
  • Not a load-order issue — deferring our first triggerHook('filter') call made no difference.
  • Not interceptable via the documented hook lifecycle — we added a cleanup pass on afterRender (the last phase in the documented lifecycle: start → filter → sort → static → pagination → beforeRender → render → afterRender) that explicitly reset the clones’ display style. This did not fix it, meaning whatever applies the hidden style runs after or outside that phase.
  • What did work: attaching a MutationObserver to each cloned element to watch for style changes and immediately revert display: none. This confirms something in the List module actively hides elements it doesn’t recognize as part of its own tracked item set, through a code path not reachable via any documented hook.

Related finding
Your docs show the fully “correct” way to add an item is not just createItem() + returning it from a hook, but also:

const newItem = listInstance.createItem(element);

listInstance.items.value = [...listInstance.items.value, newItem];

We tried this. Doing it from inside the filter hook caused the page to hang/become unresponsive — consistent with an infinite loop, since mutating items.value seems to re-trigger the same pipeline the filter hook is itself part of. We abandoned this approach as soon as we saw the hang, so we can’t confirm the exact mechanism, but it’s reproducible and severe enough to flag on its own.


Bug 2: loadingPaginatedItems resolves before paginated items are actually in the DOM

What we’re doing
For a different component, we don’t use addHook/createItem at all — we only need to know when a fs-list-load="all" Collection List has finished loading every Webflow-paginated page, so we can safely scan wrap.querySelectorAll(...) ourselves afterward. Per your docs, we await the documented API for this:

window.FinsweetAttributes.push(['list', (listInstances) => {

const listInstance = listInstances.find((l) => l.listElement === list);

Promise.resolve(listInstance.loadingPaginatedItems).then(() => {

// scan the DOM for items here

});

}]);

The bug
loadingPaginatedItems resolves once every page has been fetched over HTTP and merged into list.items.value — confirmed by reading packages/list/src/load/load.ts: the promise (list.loadingPaginatedItems = (async () => { ... })(), lines 37–49) only awaits parallelItemsLoad/chainedPagesLoad, both of which fetch pages and push the parsed items into list.items.value (parseLoadedPage, lines 158–186) — neither function touches the DOM at all.

Actual DOM insertion happens in a completely separate, reactively-triggered pipeline. List watches items via @vue/reactivity’s watch() (components/List.ts line 576: watch(this.items, () => this.triggerHook(key), { immediate: true })), which cascades through the same 8-phase lifecycle as Bug 1 (start → filter → sort → static → pagination → beforeRender → render → afterRender). Two things make this pipeline’s completion decoupled in time from loadingPaginatedItems resolving:

  1. watch() from @vue/reactivity schedules its callback asynchronously (not synchronously in the same tick as the items.value write), so there’s an inherent gap between “data updated” and “render pipeline even starts.”
  2. The built-in render hook (components/List.ts lines 494–564) does await new Promise(requestAnimationFrame) per item, plus awaits any CSS animations on that item finishing, before considering that item rendered. For a batch of several newly-loaded items, that’s several animation frames (and potentially CSS transition durations) of real wall-clock time between the data being “loaded” and the DOM actually holding those elements.

Concretely reproduced: a 4-page paginated Collection List (fs-list-load="all", page-count element reading “1 / 4”). We awaited loadingPaginatedItems, then did a one-time synchronous scan+hide of every item. Result was non-deterministic across reloads — a captured DOM snapshot showed exactly the last page’s 3 items with no inline style applied at all (not display:none — genuinely untouched by our code), while every earlier page’s items were correctly hidden. They weren’t un-hidden after the fact; they simply weren’t in the DOM yet when our scan ran, despite loadingPaginatedItems having already resolved.

What we ruled out

  • Not a data bug — same entries/counts logged before and after, only the timing of DOM presence varied.
  • Your README documents loadingPaginatedItems as “An awaitable Promise that resolves once all the Webflow CMS paginated items have been loaded.” That reads as “the items are now present,” but per source it only means “fetched via HTTP and merged into items.value.” Nothing next to that property, or anywhere else in the README we found, flags that DOM rendering is a distinct, later, asynchronous step driven by the item lifecycle described elsewhere in the same doc.
  • We looked for a more precise “fully rendered” signal to await instead of guessing with a timer. Internal currentHook/queuedHook state on the List instance (components/List.ts lines 857–871, cleared at lines 587–588) does track exactly this — it’s undefined/undefined only once the entire cascade, including afterRender, has fully settled with nothing queued behind it. But neither property appears in the README’s “Reactive properties” or “Standard properties” tables, so they’re undocumented internals with no stability guarantee — depending on them felt riskier than the alternative below.

What worked (our workaround): stopped trusting any single “ready” signal from the API and instead watch the DOM directly — debounce on the list container’s childList mutations (treat it as “settled” once no new item nodes have been appended for ~150ms) before scanning, plus a standing MutationObserver on the container that hides any genuine template item that shows up later than expected, regardless of when it arrives. Implementation-agnostic, but clearly a workaround for a gap that shouldn’t need one.


Questions for your team

  1. Is there a supported way to add items via createItem() from within a filter hook that doesn’t also require mutating items.value?

  2. If items.value mutation is required, what’s the safe way to do it from inside a hook that belongs to the same pipeline, without triggering re-entrant/infinite execution?

  3. Is “elements not in items.value get hidden, but only on the first render pass” (Bug 1) expected behavior? If so, is there a hook or signal that would let us handle it reliably, instead of a MutationObserver workaround?

  4. Is there a supported, documented way to know when the render pipeline triggered by a paginated-items load has fully completed (all afterRender passes done, nothing queued) — as opposed to just when the fetch/merge step (loadingPaginatedItems) has completed?

  5. If not, would you consider clarifying loadingPaginatedItems’s doc entry to explicitly state that it guarantees pages have been fetched and merged into items.value, but not that they’ve been rendered into the DOM yet?

  6. Are currentHook/queuedHook intended to be a stable, public signal for “pipeline fully idle”? If so, could they be documented and officially exposed as a supported way to detect full settle, rather than something we only found by reading source?

    Bug Report: List API — DOM rendering is asynchronous and decoupled from every promise/hook we could find that’s meant to signal “done”

Environment

  • @finsweet/attributes v2, List module: <script async type="module" src="https://cdn.jsdelivr.net/npm/@finsweet/attributes@2/attributes.js" fs-list></script>
  • Webflow Collection List with fs-list-element="list", some instances also using fs-list-load="all"
  • Programmatic API via window.FinsweetAttributes.push(['list', callback]), not the declarative filter UI
  • Source references below are against the public finsweet/attributes GitHub repo, packages/list/src/

Bug 1: createItem() items hidden on the first pipeline run only

What we’re doing
We need to render more cards than there are CMS items — e.g., one CMS entry (“Weekly Class”) should produce 4 separate cards for a given month, one per weekly occurrence. Declarative filtering can’t do this (it filters existing items, it doesn’t multiply them), so we use the JS API. Inside an addHook('filter', ...) callback, for each extra occurrence we clone an existing item’s element and register it:

const clone = originalElement.cloneNode(true);

originalElement.insertAdjacentElement('afterend', clone);

const newItem = listInstance.createItem(clone);

// newItem is included in the array returned from the filter hook

This matches the pattern shown in your own docs for addHook('filter', ...) and createItem().

The bug
On page load, the very first time our filter hook runs, the cloned elements are correctly inserted into the DOM and correctly included in the array returned from the hook — but they end up with an inline style="display: none" applied to them and never become visible.

If the exact same hook runs again immediately after — identical data, identical logic, identical result — the clones render correctly. Every run after the first works correctly, indefinitely. Only the very first pipeline run for a given list instance is affected.

What we ruled out

  • Not a data/logic bug on our end — we logged the input/output of our hook on both the broken first run and a working later run; identical in every respect (same computed count, same array length returned).
  • Not a load-order issue — deferring our first triggerHook('filter') call made no difference.
  • Not interceptable via the documented hook lifecycle — we added a cleanup pass on afterRender (the last phase in the documented lifecycle: start → filter → sort → static → pagination → beforeRender → render → afterRender) that explicitly reset the clones’ display style. This did not fix it, meaning whatever applies the hidden style runs after or outside that phase.
  • What did work: attaching a MutationObserver to each cloned element to watch for style changes and immediately revert display: none. This confirms something in the List module actively hides elements it doesn’t recognize as part of its own tracked item set, through a code path not reachable via any documented hook.

Related finding
Your docs show the fully “correct” way to add an item is not just createItem() + returning it from a hook, but also:

const newItem = listInstance.createItem(element);

listInstance.items.value = [...listInstance.items.value, newItem];

We tried this. Doing it from inside the filter hook caused the page to hang/become unresponsive — consistent with an infinite loop, since mutating items.value seems to re-trigger the same pipeline the filter hook is itself part of. We abandoned this approach as soon as we saw the hang, so we can’t confirm the exact mechanism, but it’s reproducible and severe enough to flag on its own.


Bug 2: loadingPaginatedItems resolves before paginated items are actually in the DOM

What we’re doing
For a different component, we don’t use addHook/createItem at all — we only need to know when a fs-list-load="all" Collection List has finished loading every Webflow-paginated page, so we can safely scan wrap.querySelectorAll(...) ourselves afterward. Per your docs, we await the documented API for this:

window.FinsweetAttributes.push(['list', (listInstances) => {

const listInstance = listInstances.find((l) => l.listElement === list);

Promise.resolve(listInstance.loadingPaginatedItems).then(() => {

// scan the DOM for items here

});

}]);

The bug
loadingPaginatedItems resolves once every page has been fetched over HTTP and merged into list.items.value — confirmed by reading packages/list/src/load/load.ts: the promise (list.loadingPaginatedItems = (async () => { ... })(), lines 37–49) only awaits parallelItemsLoad/chainedPagesLoad, both of which fetch pages and push the parsed items into list.items.value (parseLoadedPage, lines 158–186) — neither function touches the DOM at all.

Actual DOM insertion happens in a completely separate, reactively-triggered pipeline. List watches items via @vue/reactivity’s watch() (components/List.ts line 576: watch(this.items, () => this.triggerHook(key), { immediate: true })), which cascades through the same 8-phase lifecycle as Bug 1 (start → filter → sort → static → pagination → beforeRender → render → afterRender). Two things make this pipeline’s completion decoupled in time from loadingPaginatedItems resolving:

  1. watch() from @vue/reactivity schedules its callback asynchronously (not synchronously in the same tick as the items.value write), so there’s an inherent gap between “data updated” and “render pipeline even starts.”
  2. The built-in render hook (components/List.ts lines 494–564) does await new Promise(requestAnimationFrame) per item, plus awaits any CSS animations on that item finishing, before considering that item rendered. For a batch of several newly-loaded items, that’s several animation frames (and potentially CSS transition durations) of real wall-clock time between the data being “loaded” and the DOM actually holding those elements.

Concretely reproduced: a 4-page paginated Collection List (fs-list-load="all", page-count element reading “1 / 4”). We awaited loadingPaginatedItems, then did a one-time synchronous scan+hide of every item. Result was non-deterministic across reloads — a captured DOM snapshot showed exactly the last page’s 3 items with no inline style applied at all (not display:none — genuinely untouched by our code), while every earlier page’s items were correctly hidden. They weren’t un-hidden after the fact; they simply weren’t in the DOM yet when our scan ran, despite loadingPaginatedItems having already resolved.

What we ruled out

  • Not a data bug — same entries/counts logged before and after, only the timing of DOM presence varied.
  • Your README documents loadingPaginatedItems as “An awaitable Promise that resolves once all the Webflow CMS paginated items have been loaded.” That reads as “the items are now present,” but per source it only means “fetched via HTTP and merged into items.value.” Nothing next to that property, or anywhere else in the README we found, flags that DOM rendering is a distinct, later, asynchronous step driven by the item lifecycle described elsewhere in the same doc.
  • We looked for a more precise “fully rendered” signal to await instead of guessing with a timer. Internal currentHook/queuedHook state on the List instance (components/List.ts lines 857–871, cleared at lines 587–588) does track exactly this — it’s undefined/undefined only once the entire cascade, including afterRender, has fully settled with nothing queued behind it. But neither property appears in the README’s “Reactive properties” or “Standard properties” tables, so they’re undocumented internals with no stability guarantee — depending on them felt riskier than the alternative below.

What worked (our workaround): stopped trusting any single “ready” signal from the API and instead watch the DOM directly — debounce on the list container’s childList mutations (treat it as “settled” once no new item nodes have been appended for ~150ms) before scanning, plus a standing MutationObserver on the container that hides any genuine template item that shows up later than expected, regardless of when it arrives. Implementation-agnostic, but clearly a workaround for a gap that shouldn’t need one.


Questions for your team

  1. Is there a supported way to add items via createItem() from within a filter hook that doesn’t also require mutating items.value?
  2. If items.value mutation is required, what’s the safe way to do it from inside a hook that belongs to the same pipeline, without triggering re-entrant/infinite execution?
  3. Is “elements not in items.value get hidden, but only on the first render pass” (Bug 1) expected behavior? If so, is there a hook or signal that would let us handle it reliably, instead of a MutationObserver workaround?
  4. Is there a supported, documented way to know when the render pipeline triggered by a paginated-items load has fully completed (all afterRender passes done, nothing queued) — as opposed to just when the fetch/merge step (loadingPaginatedItems) has completed?
  5. If not, would you consider clarifying loadingPaginatedItems’s doc entry to explicitly state that it guarantees pages have been fetched and merged into items.value, but not that they’ve been rendered into the DOM yet?
  6. Are currentHook/queuedHook intended to be a stable, public signal for “pipeline fully idle”? If so, could they be documented and officially exposed as a supported way to detect full settle, rather than something we only found by reading source?

Hey @caleb.raney!

The behaviors you’re describing — createItem() display:none on first-run, loadingPaginatedItems resolving before DOM rendering, and the items.value mutation causing hooks re-entry — go beyond what our public documentation covers. The hook lifecycle (filtersortstaticpaginationbeforeRenderrenderafterRender) and the callback API are documented, but the specific timing guarantees, internal item tracking during first-render, and whether currentHook/queuedHook are intended to be public signals are things only the dev team can speak to definitively.

We can’t confirm yet whether these are bugs vs. intentional internal behaviors, but your reproductions sound solid and the MutationObserver workarounds are reasonable interim approaches while this gets investigated. :thinking:

We’re routing this directly to the team that owns the List API. Your 6 questions are exactly the kind of thing they’ll want to dig into — especially the documentation gap around loadingPaginatedItems (Q5) and whether currentHook/queuedHook could be exposed as stable public signals (Q6).

If you have a minimal reproduction — staging URL, CodeSandbox, or similar — that would strengthen the report even further. What you’ve already provided is detailed enough to work with, but a live environment always helps speed things along.

Dev team review timelines depend on their current sprint, but we’ll update you as we hear back and can relay any follow-up between you and the team. :flexed_biceps: