Skip to content

2.0.0-beta.14 - refresh() does not coalesce: overlapping actions trigger N redundant fetches #2712

@brenelz

Description

@brenelz

refresh() does not coalesce: overlapping actions trigger N redundant fetches

Summary

When N actions overlap and each calls refresh(sameResource) after their async work, the resource is recomputed N times instead of once. There's no built-in way to express "refresh this resource at most once per tick," so consumers end up hand-rolling inflight counters or composing onSettled with userland microtask coalescers.

Versions

@solidjs/signals@2.0.0-beta.14

Reproduction

import { action, flush, createRoot, createMemo, refresh, createEffect, onSettled }
  from "@solidjs/signals";

const deferred = () => { let r; return { promise: new Promise(x => (r = x)), resolve: () => r() }; };
let fetchCount = 0;
const resolvers = [];
const getComments = () => { const id = ++fetchCount; return new Promise(r => resolvers.push(() => r([id]))); };

const { runA, runB, dA, dB } = createRoot(() => {
  const comments = createMemo(() => getComments());
  createEffect(() => comments(), () => {});
  const dA = deferred(), dB = deferred();
  const runA = action(function* () { yield dA.promise; onSettled(() => refresh(comments)); });
  const runB = action(function* () { yield dB.promise; onSettled(() => refresh(comments)); });
  return { runA, runB, dA, dB };
});

await new Promise(r => setTimeout(r)); resolvers.shift()(); // initial mount fetch
const before = fetchCount;
const pA = runA(), pB = runB(); flush();
dA.resolve(); await pA;
dB.resolve(); await pB;
console.log(`extra fetches: ${fetchCount - before}`); // expected 1, actual 2

Expected: 1 extra fetch (overlapping refresh calls collapse to one recompute).
Actual: 2 extra fetches (one recompute per call).

Suggested fix

Make refresh() idempotent within a microtask: skip if a recompute is already scheduled for the target.

const scheduledRefreshes = new Set();
function refresh(target) {
  const node = target?.[$REFRESH];
  if (!node || typeof node._fn !== "function" || node._flags & REACTIVE_DISPOSED) return;
  if (scheduledRefreshes.has(node)) return;
  scheduledRefreshes.add(node);
  queueMicrotask(() => {
    scheduledRefreshes.delete(node);
    if (!(node._flags & REACTIVE_DISPOSED)) recompute(node);
  });
  schedule();
}

Verified locally against the repro: with this patch, exactly 1 extra fetch; without it, 2.

With the fix, the natural pattern just works:

const addCommentAction = action(function* (text: string) {
  yield addComment(text);
  onSettled(() => refresh(comments));   // N callbacks, dedupes to 1 GET
});

Why this layer (not onSettled)

onSettled is modeled on cleanup callbacks: each call registers an independent listener, so making it dedupe by callback identity or target would break the "register N independent things" model. refresh is the side-effecting primitive — "do this work, at most once per tick per target" is a small refinement, not a contract break, and it composes uniformly whether refresh is called from inside an action, from onSettled, from a rapidly-clicked button handler, etc.

Userland workaround today

const refreshScheduled = new WeakSet<object>();
function trailingRefresh<T extends object>(target: T) {
  if (refreshScheduled.has(target)) return;
  refreshScheduled.add(target);
  queueMicrotask(() => { refreshScheduled.delete(target); refresh(target); });
}

const addCommentAction = action(function* (text: string) {
  yield addComment(text);
  onSettled(() => trailingRefresh(comments));
});

Works, but requires onSettled for tick-alignment + a userland coalescer + correct optimistic-state setup so transitions actually merge (related issue). The fact that three pieces are needed to express "trailing-once refresh" is the motivation for handling it at the primitive layer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions