Skip to content

fix(react-call): end-all no longer drops same-tick calls#96

Merged
desko27 merged 1 commit into
mainfrom
fix/end-all-same-tick-calls
May 30, 2026
Merged

fix(react-call): end-all no longer drops same-tick calls#96
desko27 merged 1 commit into
mainfrom
fix/end-all-same-tick-calls

Conversation

@desko27
Copy link
Copy Markdown
Owner

@desko27 desko27 commented May 30, 2026

What

Fixes a real library bug: calling the end-all form X.end() (no target promise) and then X.call({...}) one or more times in the same synchronous tick resulted in the new calls never rendering — the stack ended up empty even though call() ran after end(). No error was thrown.

const onClick = () => {
  Upload.end()                                  // end all (stack may be empty — intended no-op)
  for (const file of files) Upload.call({ ... }) // ← these used to vanish
}

Reproduced in the web gallery (React 19) while building examples; the broadcast-update example had been rewritten to avoid the pattern as a workaround.

Root cause

The end-all path used null as a "match everything" sentinel for its deferred removal:

// createEnd, end-all case
storeRef.current.set(null, /* mark all ended */)
setTimeout(() => storeRef.current.remove(null), unmountingDelay) // ← scheduled for NEXT macrotask

// store.remove, with promise === null
stack = stack.filter((c) => promise && c.promise !== promise)    // null && … → keeps NOTHING

So end() scheduled a blanket "clear the entire stack" for the next macrotask. Calls added after end() in the same tick were appended first, then wiped when the deferred remove(null) fired. Removing the leading end() removed the scheduled wipe — hence the calls rendered.

The targeted path (end(promise, value)) was never affected: it only filters out the one matching promise. update() (update-all) was never affected either: it mutates in place and schedules no removal.

The fix

createEnd now captures the exact set of promises it resolves during its synchronous set() pass, and defers removal of only those. The null sentinel is gone from the store — remove takes a Set<Promise> and filters with !promises.has(c.promise). Calls added later in the same tick are never in the captured set, so they survive. Targeted end() passes a one-element set and behaves exactly as before.

See ADR-0020 for the decision and the rejected alternatives.

Tests

New regression block same tick as later call() in end.test.tsx, covering all three interactions called out during triage — each flushes the unmountingDelay macrotask before asserting (the bug only manifested after the deferred removal ran):

  • end-all then call() ×3 → 3 render (the exact repro; was 0 before the fix)
  • end-all with pre-existing calls → the old calls are removed, the later ones kept
  • targeted end(promise) then call() → the targeted call is removed, later ones survive
  • update() then call() → all survive

Full suite green: 132 unit + 5 dist tests, tsc -b clean, biome clean, size-limit holds (main.js 810 B / main.cjs 895 B, both under the 1 KB budget).

Release

Patch changeset for react-call included, so merging to main opens/updates the "Version Packages" PR via the release workflow.

An end-all `end()` (no target promise) scheduled a deferred stack-clear
that re-used `null` as a "match everything" sentinel: `store.remove(null)`
filtered with `promise && c.promise !== promise`, which is falsy for every
item, so it wiped the whole stack on the next macrotask. Any `call()` or
`upsert()` issued after the `end()` in the same synchronous tick was
appended and then clobbered — `X.end(); X.call(...)` rendered nothing.

createEnd now captures the exact set of promises it resolves during its
synchronous `set()` pass and defers removal of only those, so calls added
later survive. The `null` "clear all" branch is gone from the store:
`remove` takes a `Set<Promise>` and filters with `!promises.has(...)`.
Targeted `end(promise, value)` (a one-element set) and `update()` (which
never scheduled a removal) are unchanged.

- index.tsx: capture resolved promises in createEnd; pass the set to remove.
- store.ts: remove(promises: Set<Promise>) instead of the null sentinel.
- end.test.tsx: regression block `same tick as later call()` covering
  end-all, end-all with pre-existing calls, targeted end, and update —
  flushing the unmountingDelay macrotask before asserting.
- ADR-0020 records the scoped-removal decision; changeset filed as patch.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-call Ready Ready Preview, Comment May 30, 2026 10:51pm

@desko27 desko27 merged commit 2db91e2 into main May 30, 2026
8 checks passed
@desko27 desko27 deleted the fix/end-all-same-tick-calls branch May 30, 2026 22:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant