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.
refresh()does not coalesce: overlapping actions trigger N redundant fetchesSummary
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-rollinginflightcounters or composingonSettledwith userland microtask coalescers.Versions
@solidjs/signals@2.0.0-beta.14Reproduction
Expected: 1 extra fetch (overlapping
refreshcalls collapse to one recompute).Actual: 2 extra fetches (one
recomputeper call).Suggested fix
Make
refresh()idempotent within a microtask: skip if a recompute is already scheduled for the target.Verified locally against the repro: with this patch, exactly 1 extra fetch; without it, 2.
With the fix, the natural pattern just works:
Why this layer (not
onSettled)onSettledis 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.refreshis 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 whetherrefreshis called from inside an action, fromonSettled, from a rapidly-clicked button handler, etc.Userland workaround today
Works, but requires
onSettledfor 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.