Scope: This paper walks the actual implementation of Reactor's async-resource
system as it exists on feat/async-resources-phase1. It is the companion to the
design spec at docs/specs/020-async-resources-design.md:
the spec defines what the system should do and why; this paper documents
how the code does it, state-by-state, and walks the threading and race-condition
analysis line by line.
Where the code diverges from the intent of the spec, or where the walkthrough surfaced a latent bug, the issue is captured in a BUG or GAP box in the relevant section. The paper always describes the desired state of the system; the boxes record delta to current code so that a follow-up pass can close them.
Files covered
| File | Role |
|---|---|
src/Reactor/Core/AsyncValue.cs |
The four-state ADT every read hook returns |
src/Reactor/Core/InfiniteResource.cs |
Pull-model paginated view + LoadState + Page |
src/Reactor/Core/QueryCache.cs |
Process-wide ref-counted cache with TTL eviction |
src/Reactor/Core/FocusRevalidationService.cs |
Window-activation invalidation sweep |
src/Reactor/Core/ReactorFeatureFlags.cs |
Rollout flags (FocusRevalidation, UseHookBasedPaging) |
src/Reactor/Hooks/UseResource.cs |
Single-value fetch hook, owns ResourceHookState<T> |
src/Reactor/Hooks/UseInfiniteResource.cs |
Paginated hook, owns InfiniteHookState<TItem, TCursor> |
src/Reactor/Hooks/UseMutation.cs |
Write hook with optimistic / invalidate / callbacks |
src/Reactor/Hooks/PendingScope.cs |
Ref-counted loading set for bubble-up fallback |
src/Reactor/Hooks/Pending.cs |
Component that hosts a PendingScope and flips visibility |
src/Reactor/Data/DataSourceResourceExtensions.cs |
IDataSource<T> → UseInfiniteResource adapter |
The async system is layered. Each layer has a narrow contract, and each successive layer consumes only the layer below. This lets us reason about threading locally:
┌──────────────────────────────────────────────────────────┐
│ Render code (Component.Render) │
│ returns an Element tree built from AsyncValue<T> matches│
└──────────────────────┬───────────────────────────────────┘
│ calls ctx.UseResource / UseInfiniteResource / UseMutation
┌──────────────────────▼───────────────────────────────────┐
│ Hook layer │
│ • UseResourceCore → ResourceHookState<T> │
│ • UseInfiniteResourceCore → InfiniteHookState<I,C> │
│ • UseMutationCore → MutationHookState<I,R> │
│ Owns: hook identity, CTS, dispatcher, rerender tick, │
│ PendingScope registration, focus enrollment. │
└──────────────────────┬───────────────────────────────────┘
│ reads/writes string keys
┌──────────────────────▼───────────────────────────────────┐
│ QueryCache │
│ ConcurrentDictionary<string, Slot>; Slot has per-key │
│ lock, ref-count, CacheEntry<T> payload, CacheTime, │
│ ZeroSubscribersAt. Single shared eviction Timer. │
└──────────────────────┬───────────────────────────────────┘
│ EntryChanged event (key-scoped)
┌──────────────────────▼───────────────────────────────────┐
│ Cross-cutting services │
│ • FocusRevalidationService.RevalidateNow() │
│ • UseMutation(InvalidateKeys) / cache.Invalidate │
│ • UseInfiniteResource page cache (same QueryCache) │
└──────────────────────────────────────────────────────────┘
Three invariants hold across the whole system and are the foundation of the race-condition argument in §11:
- Render is UI-thread-affine.
RenderContext.BeginRendercapturesEnvironment.CurrentManagedThreadId(RenderContext.cs:21), and all hook settersAssertUIThreadin DEBUG builds unlessthreadSafe: trueis passed. Hook registration and the initial synchronous work ofUseResource/UseInfiniteResourceboth happen inside this render, and therefore on the UI thread. - Async completions land on the dispatcher. Every async continuation in the
async system (
UseResource.ScheduleCompletion,UseInfiniteResource's page completion,UseMutation's mutator continuation) posts throughIHookDispatcher.Postbefore touching hook state. The dispatcher is captured at hook registration time withDispatcherQueue.GetForCurrentThread()(UseResource.cs:168-176). In unit tests where no WinUI dispatcher exists,Postcalls fall through to inline invocation on the completion thread; the tests serialize by drivingctx.BeginRender/ctx.FlushEffectsmanually. - The QueryCache is the only shared mutable state that spans hooks. Any
cross-hook coordination (dedup, invalidation, focus revalidation) flows
through cache keys and the
EntryChangedevent.
AsyncValue.cs:15-72. Four sealed records under an abstract record, constructor
closed with private protected:
| Case | Payload | When entered |
|---|---|---|
Loading |
(singleton) | First render, cache miss, fetch in flight, no prior data |
Data(T Value) |
Fresh value | Fetch succeeded, or cache hit inside StaleTime |
Error(Exception) |
The exception | Fetch failed (or retries exhausted); prior data discarded |
Reloading(T Previous) |
Last-good value | Cache hit past StaleTime, OR a refetch started with Data on screen |
Match is a non-hot-path convenience that lets Reloading fall through to the
data: lambda by default (AsyncValue.cs:59-71). The spec's §5.1 prefers a C#
switch expression per render; the switch gets exhaustiveness checking and
avoids one delegate allocation per case.
Note that Loading is a singleton (Loading.Instance) and carries no payload —
allocation-free on transition. The hook exploits this in StartAttempt to
reset state without allocating.
QueryCache.cs:49-335. This is the only shared mutable state in the entire
async system. Everything else is either (a) per-hook instance state or (b)
immutable records pulled from this cache. The cache's correctness drives the
correctness of the system.
ConcurrentDictionary<string, Slot>
Slot {
readonly object Lock // per-slot mutex
object? Entry // actually a CacheEntry<T> — type erased
int SubscriberCount
TimeSpan CacheTime
DateTime? ZeroSubscribersAt
bool IsEvicted
}
The ConcurrentDictionary gives us lock-free lookup of the slot; the
per-Slot Lock serializes mutation of Entry, SubscriberCount,
ZeroSubscribersAt, and IsEvicted. This is finer-grained than a cache-wide
lock and avoids head-of-line blocking when many hooks touch distinct keys.
CacheEntry<T> (line 26) is a C# record and, through its explicit
ICacheEntry interface (line 18), lets the cache manipulate SubscriberCount
without knowing T. The interface's WithSubscriberCount returns a fresh
record via with { SubscriberCount = count } — the entry itself is immutable;
only the slot's Entry reference rotates.
Subscribe(key): Unsubscribe(key):
loop: lock(slot.Lock):
slot = _slots.GetOrAdd(key, new Slot) assert SubscriberCount > 0
lock(slot.Lock): SubscriberCount--
if slot.IsEvicted continue ─┐ if SubscriberCount == 0:
SubscriberCount++ │ ZeroSubscribersAt = now
ZeroSubscribersAt = null │
EnsureEvictionTimer() │
return │
│
EvictNow() (every 1s): │
for each slot: │
lock(slot.Lock): │
if SubscriberCount == 0 │
and ZeroSubscribersAt │
and now - t >= CacheTime: │
slot.IsEvicted = true ───┘ // visible to retrying Subscribe above
_slots.Remove(key)
Three races are prevented by design:
Race A — Subscribe races Eviction. If EvictNow is inside lock(slot.Lock)
and about to set IsEvicted = true, a concurrent Subscribe that already won
GetOrAdd(key) (getting the same Slot) must wait on the lock. When it enters
the critical section it sees IsEvicted == true, continues the outer loop,
and does a fresh GetOrAdd — which will insert a brand new Slot because
EvictNow has removed the old one from the dictionary. Net effect: the
subscribe never increments a dead slot's counter.
Race B — Set races Eviction. Same mechanism as Race A. Set also has
while (true) { slot = _slots.GetOrAdd(...); lock { if (slot.IsEvicted) continue; ... } } (line 97). The invariant is "any code that mutates a slot
under its Lock must re-check IsEvicted and retry on another slot."
Race C — Unsubscribe under zero. Unsubscribe throws
InvalidOperationException on a missing slot or SubscriberCount <= 0. This
is defensive — the invariant "every Subscribe has exactly one paired
Unsubscribe" is enforced by UseResource / UseInfiniteResource hook
lifecycle, and a violation indicates a hook-logic bug. Failing loud catches
it early (the tests rely on this; see QueryCacheTests).
EnsureEvictionTimer (line 296) lazily starts one shared System.Threading.Timer
on first Subscribe. The timer fires every EvictionPollInterval (default 1s,
mutable for tests) and calls EvictNow. Exactly one timer serves the entire
cache, giving O(1) pressure on the OS timer wheel regardless of slot count.
EvictNow holds slot.Lock across the dictionary Remove, so racing
Subscribe / Set operators either (a) win the lock before eviction, and
raise SubscriberCount > 0 so the slot is no longer eligible to evict; or (b)
observe IsEvicted = true and retry with a fresh slot. The ICollection.Remove
overload (line 243) compares by KeyValuePair, so a concurrent TryAdd that
replaces the slot doesn't see its fresh slot removed.
EvictNow is also exposed publicly (line 226) to let framerate and stress
tests drive eviction deterministically rather than waiting for the 1-second
polling interval.
The cache fires EntryChanged(key) on: Set, Invalidate, InvalidatePattern
(one fire per affected key), Clear, EvictNow. The firing path
(FireEntryChanged, line 307) supports an injected DispatcherPost callback
— tests leave it null for inline invocation; the production bootstrap (see
ReactorHost, not in this branch's scope but wired in phase 3) sets it to
marshal to the UI dispatcher so handlers are single-threaded.
Handlers (the per-hook OnEntryChanged in ResourceHookState) capture the
handler reference before invocation, so unsubscribing a handler mid-event does
not race the event dispatch.
AppContexts.QueryCache (QueryCache.cs:347) is a Context<QueryCache> with
a fresh default instance installed at class init. Tests can override by
.Provide(AppContexts.QueryCache, customCache) on an ancestor; this is how
unit tests get a fresh cache per test. Hosts that want multi-tenant caches
install one per tenant the same way.
UseResourceCore (UseResource.cs:107-160) registers in slot order:
UseRef<string?>—hookIdRef.Current. A GUID generated once on first render; survives every subsequent render for this component instance and forms the{hookId}/{depsHash}cache key.UseRef<ResourceHookState<T>?>— the per-hook mutable state.UseReducer(0, threadSafe: true)— the rerender tick. Thread-safe because the completion handler fires from either the dispatcher or the thread pool (when no dispatcher is installed), and must be safe to call off-UI.UseContext(AppContexts.PendingScope)— nearest ancestorPendingScope, or null.UseContext(AppContexts.FocusRevalidation)— the service the hook will enroll its cache key with ifRefetchOnWindowFocusis set.UseEffect(() => () => state.Dispose())— a one-shot cleanup registered at mount.UseEffectwith an implicit empty dep array runs once; its cleanup fires when the component unmounts (RenderContext.RunCleanups).
The five Use* calls before UseEffect means every UseResource consumes
six hook slots. As a consequence, UseResource obeys the rules-of-hooks:
it must be called unconditionally, in the same order, every render.
UseResourceCore:
state = stateRef.Current ??= new ResourceHookState(...)
newKey = options.CacheKey ?? $"{hookId}/{depsHash(deps)}"
firstRender = state.LastDeps is null
depsChanged = !firstRender && state.CacheKey != newKey
if firstRender or depsChanged:
state.TransitionToKey(newKey, deps) // cancels old CTS, unsubscribe/subscribe
EnterKey(state, fetcher, options) // dispatch on cache state
else:
ReconcileWithCache(state, fetcher, options)
return state.LastValue
TransitionToKey (UseResource.cs:447-465):
- Cancels any in-flight CTS for the old key.
- If the old key was non-empty and different, calls
Cache.Unsubscribe(oldKey)and_focusService?.Unenroll(oldKey). - Updates
CacheKeytonewKey, callsCache.Subscribe(newKey), enrolls with focus service if needed, snapshotsLastDeps.
EnterKey (UseResource.cs:178-207) is the new-key dispatcher:
Cache.TryGet<T>(key, out entry):- Age
<= StaleTime→state.LastValue = Data(entry.Value). Done. - Age
> StaleTime→state.LastValue = Reloading(entry.Value), thenBeginFetchto refresh.
- Age
- Cache miss +
RefetchOnMount: false→state.LastValue = Loading.Instance, no fetch. - Cache miss + default →
BeginFetch.
BeginFetch (UseResource.cs:226-236) dedupes on state.InFlight, disposes
any old CTS, mints a new CancellationTokenSource, and calls
StartAttempt(..., attempt: 0, inlineSyncResult: true).
StartAttempt (UseResource.cs:238-298) is where the spec's "no Loading
flash" property is implemented. It invokes the fetcher inside a try / catch,
then inspects the returned Task<T>:
| Task state (initial attempt only) | Action |
|---|---|
IsCompletedSuccessfully |
cache.Set(...), LastValue = Data(v), InFlight = false. Same render. |
IsCanceled |
Keep Data if we had one, else Loading. No throw. |
IsFaulted |
HandleFailure (retry or Error). Same render on terminal. |
| Pending | LastValue = switch { Data d → Reloading(d.Value); Reloading r → r; _ → Loading }. Mark InFlight = true. Schedule continuation via ScheduleCompletion. |
On retries (attempts > 0), inlineSyncResult is false, which changes two
things: we never overwrite LastValue to an intermediate Loading/Reloading
inside this call (the retry is invisible from the UI's point of view beyond
staying in whatever state it was), and failure of the final attempt triggers
state.RequestRerender() to flush Error to screen.
GAP — cross-key data leakage on deps change. When deps change → new cache
key → cache miss, BeginFetch → StartAttempt runs the pending-path switch
which maps Data d (from the previous key) to Reloading(d.Value). That
surfaces the old query's data as the new query's stale-while-revalidate
previous value. The spec §6.2 step 6 is explicit that the old result should
not bleed into the new key's UI:
When deps change, cancel the in-flight token for the old key and re-evaluate from step 1 with the new key. The old result stays in cache.
The desired fix is to reset state.LastValue = Loading.Instance inside
TransitionToKey (before EnterKey runs) whenever the transition is not a
first-render. The sync-complete fast path in StartAttempt still wins (it
overwrites to Data(v) on the same render), and the cache-hit-fresh path in
EnterKey still wins (it overwrites to Data(entry.Value)). Only the
cache-miss-pending case gets the correction, which is precisely where the
leakage occurs today.
UseResource.cs:324-365. The continuation is attached with
TaskContinuationOptions.ExecuteSynchronously, so when the task completes the
continuation runs on the thread that completed the task (thread pool, typically).
The continuation body is a closure that builds Apply() and then dispatches it:
if (state.Dispatcher is { } disp) disp.Post(Apply);
else Apply();Apply runs on the dispatcher thread (UI) when a dispatcher is present. It
performs, in order:
if (state.IsDisposed) return;— drop silently.if (ct.IsCancellationRequested) return;— deps changed or unmount; drop silently per spec §6.4.if (t.IsCanceledOrDropped()) return;— cancellation via the token or an unwrappedOperationCanceledExceptionin a faulted task. Also drop silently.if (t.IsFaulted)→HandleFailure(...)withinlineSyncResult: false.- Success path:
cache.Set(key, value, staleTime, cacheTime)→next = Data(value)→ record-equality-compare tostate.LastValue→ assign →state.InFlight = false→state.RequestRerender()only if actually changed. The equality skip prevents a render storm when the cache is updated with a value identical to the one already rendered (common with polling sources).
The three-gate drop sequence (IsDisposed, IsCancellationRequested, IsCanceledOrDropped) is the race-free unmount/deps-change guarantee. Any one of them is sufficient to cause the result to be ignored; all three checks together close the window where a late-arriving result could clobber the current state.
HandleFailure (UseResource.cs:300-322) decides retry-vs-terminate based on
attempt < options.RetryCount. If retrying, it schedules a System.Threading.Timer
via state.ScheduleRetry with a delay of 100 * (1 << attempt) ms — so 100,
200, 400, 800 ms for attempts 0..3. The timer's callback re-enters
StartAttempt(..., attempt + 1, inlineSyncResult: false).
ScheduleRetry (UseResource.cs:467-479) captures Cts?.Token at schedule
time, not at fire time, so a deps-change that cancels the old CTS between
scheduling and firing causes the retry to no-op (if (ct.IsCancellationRequested) return; at line 475). The timer is self-disposing in its own callback.
ResourceHookState subscribes to cache.EntryChanged at construction
(UseResource.cs:424). The handler (line 430):
private void OnEntryChanged(string key)
{
if (IsDisposed) return;
if (!string.Equals(key, CacheKey, StringComparison.Ordinal)) return;
if (LastValue is AsyncValue<T>.Data && !Cache.TryGet<T>(key, out _))
RequestRerender();
}The narrow trigger — LastValue is Data && cache entry is gone — means the
hook only reacts to invalidations (mutation side-effects, focus revalidation,
pattern clears). A redundant Set that writes an identical value to the
existing entry is ignored here because the cache still has the entry. A
fresh Set with a different value also doesn't trigger a rerender through
this path; instead, the next render picks up the new entry via
ReconcileWithCache (which at present only refetches on gone-entry, so a
different-value Set from another hook would not flow to this hook until its
own deps change).
GAP — sibling-updates-value visibility. If hook A and hook B share a cache
key (via explicit CacheKey) and hook A completes a fetch, hook B's
OnEntryChanged is called but the narrow condition ignores it. Hook B
therefore does not re-render with hook A's new value until its own render
happens for another reason. This is arguably fine today (the spec's §12.5
"shared state across siblings" only works cleanly when both hooks start
rendering at similar times), but the path from cache is updated →
subscribed hooks rerender with the new value is not plumbed. A phase-2 fix
is to broaden OnEntryChanged to RequestRerender() whenever the cached
entry's Value differs from (LastValue as Data)?.Value.
ResourceHookState.Dispose (UseResource.cs:481-495):
- Sets
IsDisposed = true(plain bool — see §11 race C for the narrow edge). - Unregisters the cache
EntryChangedhandler. - Cancels and disposes
Cts. - Unsubscribes the cache key and (if enrolled) the focus service.
- Unregisters from the
PendingScope.
Fire order: RenderContext.RunCleanups iterates _hooks and fires each
EffectHookState.Cleanup in hook-order. The cleanup we registered
(UseResource.cs:141) invokes state.Dispose(), so Dispose runs exactly
once per unmount. The if (IsDisposed) return; guard at line 483 makes the
call idempotent.
Because Cts.Cancel() happens synchronously inside Dispose, any in-flight
fetcher whose body observes the token sees IsCancellationRequested == true
immediately. The fetcher is expected to propagate via OperationCanceledException
or return a Task<T> in the Canceled state; either is handled by the
IsCanceledOrDropped drop gate in Apply. See the
Unmount_Cancels_InFlight_And_Drops_Late_Result test.
┌────────┐
first render, │ │ cache hit (age ≤ StaleTime),
cache miss, │ │ OR async completion success
RefetchOnMount=F │ │
┌──────────► Loading├────────────────────────────────┐
│ │ │ │
│ └───┬────┘ │
┌────────┴──┐ │ BeginFetch (pending) │
│ NotAsked* │ │ │
│ (never │ ▼ ▼
│ observ.) │ ┌─────────┐ async completion ┌─────────┐
└───────────┘ │ │ (success, cts live) │ │
│ Loading │───────────────────────► │ Data │◄──┐
cache hit (stale) │ pending │ │ │ │
OR Data→refetch │ │ async completion └────┬────┘ │ cache-hit
┌───────────┴─────────┴──── (failure, retries │ │ fresh, or
▼ exhausted) │ │ refetched
┌──────────┐ │ │ │ same value
│ │◄────────────────────────┤ │ │
│Reloading │ ▼ │ │
│(prev) │ ┌──────────┐ │ │
└────┬─────┘ │ │ │ │
│ async success │ Error │ │ │
│ (new value) │ │ │ │
└──────────────────────────┼──────────┘ │ │
│ │ │
Data ── deps-change / cache invalid ─┴──────► BeginFetch ──────┴────────┘
↑ (Data is stable while cache entry exists and deps unchanged)
│
└── Error → no auto-recovery; a subsequent deps change or a manual
cache Invalidate triggers BeginFetch again.
Legend: boxes are observable AsyncValue<T> states; arrows are transitions
driven by either render-time decisions (cache lookups, deps change) or async
completions. *NotAsked* is not an AsyncValue<T> case — it's shown to make
explicit that the hook has no pre-fetch state; first render always enters
Loading (or Data on the sync-complete fast path).
UseInfiniteResource keeps an independent AsyncValue-style state per page
but exposes a single InfiniteResource<TItem> handle to the render code. The
handle carries:
Items : IReadOnlyList<TItem?>— flat virtual index.nullat an index means "the page that contains this index is either in-flight, or about to be fetched."LoadState : Loading | Idle | EndOfList | Error(e)— aggregate of the most recent page fetch.TotalCount?,HasMore,EstimatedRemaining— convenience derivatives.ItemAt(int),EnsureRange(int, int),FetchNext(),Retry(),Refresh()— the pull-model API that virtualized list controls call.
The hook's role is to translate ItemAt / EnsureRange pulls into page
fetches, dedup concurrent pulls, cache per-page results in the shared
QueryCache, and restart cleanly on deps change / refresh / unmount.
Pages are cached in two places:
InfiniteResource._pagesdictionary (InfiniteResource.cs:69) — the hot-path accessor forItemAt. Subject to LRU eviction whenInfiniteResourceOptions.MaxLoadedPagesis set. HoldsPageSlot(IReadOnlyList<TItem>?)—nullitems means in-flight placeholder.QueryCacheunder{keyPrefix}/page:{n}— the warm-path. Survives LRU eviction from_pages; survives unmount-and-remount insideCacheTime.InfiniteHookState.SubscribeKey(line 331) ref-counts every page key that this hook has ever loaded or started loading, so the cache retains them until unmount.
On RequestPage, the hook always checks the QueryCache first
(UseInfiniteResource.cs:221). A hit warm-starts the page into _pages via
Resource.ApplyPageResult, skipping the network round-trip entirely. This is
the back/forward-nav-is-instant property.
The single most subtle code path is the ItemAt → fetch dispatch. Full text:
public TItem? ItemAt(int index)
{
TItem? result = default;
bool scheduleFetch = false;
int pageToFetch = -1;
lock (_lock)
{
if (index < 0) return default;
int pageIndex = index / _options.PageSize;
if (_pages.TryGetValue(pageIndex, out var slot)) { /* ... return cached / null */ }
if (_totalCount is { } total && index >= total) return default;
MarkPageInFlightLocked(pageIndex); // <-- claims the slot inside the lock
scheduleFetch = true;
pageToFetch = pageIndex;
}
if (scheduleFetch) _pageRequestedCallback?.Invoke(pageToFetch);
return default;
}Two ItemAt calls from two different indices in the same page race like this:
- Caller A enters the lock, sees
_pageshas no slot forpageIndex, callsMarkPageInFlightLocked(pageIndex)which writes_pages[pageIndex] = new PageSlot(null), releases the lock, calls back to the hook. - Caller B enters the lock, sees
_pagesdoes have a slot (withItems = null), follows the cached-slot branch, returnsdefaultwithout scheduling.
So the "in-flight placeholder" is visible to concurrent callers and dedupes
them — even before the hook's _pageRequestedCallback has returned. This is
why the spec's "one fetch per page" guarantee holds under pull-model race.
The hook's RequestPage (UseInfiniteResource.cs:213) also dedupes via
_pageCts.ContainsKey(pageIndex), giving a belt-and-suspenders defense: the
resource-side claim prevents duplicate callback invocations, and the
hook-side check prevents duplicate CancellationTokenSource creation.
Page N ≥ 1 needs the cursor returned by page N-1. RequestPage handles this
by recursing (UseInfiniteResource.cs:239-252):
if (!HasLoadedPage(pageIndex - 1))
{
RequestPage(pageIndex - 1);
if (!HasLoadedPage(pageIndex - 1)) return; // N-1 still pending — drop
}
cursor = Resource.GetCursor<TCursor>();If the recursive call for N-1 completes synchronously (sync-complete fetcher
or cache hit), the guard passes and we proceed to fetch N. If it remains
pending, we return and rely on the completion of N-1 plus a subsequent
ItemAt / EnsureRange / FetchNext to re-kick N.
BUG — page N dropped when page N-1 is in flight. The return; at line
244 is silent. If no subsequent caller triggers page N, it is never fetched.
Virtualized list controls usually call EnsureRange whenever the viewport
shifts, which re-triggers, so in practice this self-heals — but the
hook-level correctness argument wants an explicit "on page N-1 completion,
flush any pending follow-on pages that were dropped for want of cursor."
Today no such queue exists.
The desired fix is a small HashSet<int> _pendingCursorChainedPages in
InfiniteHookState. When RequestPage drops a higher-page fetch, it adds
pageIndex to the set. CommitSuccess for page N-1 pops any queued
followers for page N and re-invokes RequestPage. The set stays empty in
the happy path so there is zero overhead for the common case.
InfiniteResource.cs:172-196 computes the page range under the lock, claims
each not-yet-loaded slot with MarkPageInFlightLocked, collects the page
indices, then releases the lock and invokes the callbacks serially outside
the lock. Same claim-before-release property as ItemAt.
When _totalCount is known (page 0 reported it), lastIndex is clamped to
total - 1 so we never request pages past the known end.
InfiniteHookState.Refresh (UseInfiniteResource.cs:337-364):
- Cancel every in-flight page CTS.
Cache.Invalidate(key)for every subscribed page key — firesEntryChangedonce per key.Cache.Unsubscribeeach key and clear_subscribedKeys.Resource = CreateResource()— drops a brand-newInfiniteResource<TItem>in place of the old one. Consumers that re-readstate.Resourceafter the rerender get the fresh handle. Consumers that captured a reference to the old handle will see it frozen at whatever state it was in at refresh time.- Mark
PendingScopeloading, rerender, thenRequestPage(0).
GAP — scroll preservation contract is consumer-owned but not documented
here. Spec D17 says InfiniteResource.Refresh() is data-only and the
control owns scroll restoration. The hook exposes the LoadState transition
(Idle → Loading in the new resource) and RowKey-based item identity via
the IDataSource<T> adapter. Today, the new-resource swap happens before
the _rerender() call, so a consumer that snapshots scroll state inside
its LoadState.Loading render will actually be reading the old resource's
final state on the render before the one where the new resource appears.
Depending on scroll-snapshot timing, this may be fine or may need a second
render to settle. Worth verifying with a LazyVStack framerate fixture once
one exists.
┌────────────────────┐
│ Loading │◄──────────── Refresh() (any state → Loading)
│ (initial; page 0 │
page N fetch │ in-flight; or │ Retry() from Error
starts via │ any page N+1 │ │
RequestPage/ │ via ItemAt) │ │
ItemAt/Ensure │ │ ▼
Range/FetchNext │ │ ┌─────────┐
─────────► │ │ │ │
└───┬────────────────┘ │ Error │
│ │ (pageIx)│
success │ │ │
(NextCursor ▼ └────┬────┘
non-null) ┌─────────┐ │ page fetch failed
─────────► │ Idle │◄────────────────┘
│ │
│ (items │ FetchNext() / ItemAt() on unfetched range
│ visible)│ ──► back to Loading
└────┬────┘
success │
(NextCursor│
== null) │
▼
┌──────────┐
│EndOfList │ (terminal; HasMore == false)
└──────────┘
Per-page concurrency: multiple pages may be in flight simultaneously (e.g.,
EnsureRange spans three uncached pages). LoadState is the most recent
start; it is Loading while any fetch is in flight, transitions to Idle
only after the last in-flight page completes, and to Error on the most
recent failed page.
UseInfiniteResourceCore line 73: compare state.KeyPrefix to the freshly-
computed newKeyPrefix. On change, state.TransitionToDeps(newPrefix, newDeps):
- Cancels and disposes every in-flight
_pageCts. - Unsubscribes every entry in
_subscribedKeysand clears the set. - Updates
KeyPrefix,LastDeps. - Creates a fresh
InfiniteResource<TItem>for the new deps. The old resource'sItemsarray and_pagesdictionary are gone as soon as the hook'sstate.Resourcereference rotates — assuming no consumer holds a long-lived reference, the GC reclaims them immediately. - Sets PendingScope loading back to true; calls
KickOffFirstPage()which entersRequestPage(0).
Previous-key pages remain in QueryCache under their old prefix until
CacheTime expires, so a deps change back to the previous value within the
window hits cache and re-populates instantly.
DataSourceResourceExtensions.UseDataSource (DataSourceResourceExtensions.cs:18-37)
is a thin hook that projects any IDataSource<T> into UseInfiniteResource:
return ctx.UseInfiniteResource<T, string>(
fetchPage: async (cursor, ct) =>
{
var req = request with { ContinuationToken = cursor };
var page = await source.GetPageAsync(req, ct).ConfigureAwait(false);
return new Page<T, string>(page.Items, page.ContinuationToken, page.TotalCount);
},
cache: cache,
deps: new object[] { source, request.Sort ?? (object)"", request.Filters ?? (object)"", request.SearchQuery ?? "" },
options: options, dispatcher: dispatcher);Deps include the source identity plus the request's sort/filter/search —
any change restarts pagination, which is the correct behavior for an
IDataSource<T>.
Unlike reads, mutations have a 1:N relationship between the hook and the call:
one Mutation<TInput, TResult> handle is returned per hook slot and per
component lifetime, and it can be RunAsync(input)-invoked arbitrarily many
times across that lifetime. Each RunAsync call has its own linked
CancellationTokenSource.
MutationHookState (UseMutation.cs:160-345) owns:
_unmountCts— a single CTS cancelled on component unmount._pendingCount— number of concurrently-in-flight mutations._lastResult,_error— whichever finishes last wins.Mutator,Options— rotated every render so the latest closures win (same convention asUseCallback).
RunAsync(input):
if IsDisposed: return Task.FromCanceled
try options?.OnOptimistic?(input) // synchronous on caller thread
catch: return Task.FromException
callCts = LinkedTokenSource(_unmountCts.Token)
pendingCount++, RequestRerender()
tcs = new TaskCompletionSource
try inner = Mutator(input, callCts.Token)
catch: FinishFailure(..) + tcs.SetException
inner.ContinueWith(ExecuteSynchronously):
Apply():
if cancelled: FinishCancelled(...); tcs.TrySetCanceled
elif faulted: FinishFailure(...); tcs.TrySetException
else: FinishSuccess(...); tcs.TrySetResult
if dispatcher: dispatcher.Post(Apply) else Apply()
return tcs.Task
OnOptimistic runs synchronously on the caller. If it throws, the mutator
never runs — preventing the half-applied state where the cache is patched
but the server call failed to even start.
FinishSuccess calls cache.Invalidate(key) for each key in
Options.InvalidateKeys before firing OnSuccess. That ordering matters:
an OnSuccess that re-reads the cache (to confirm the mutation landed) sees
the entries already invalidated, rather than reading stale values that would
cause a flicker on the next render.
Overlapping RunAsync calls each get their own callCts; both complete in
completion order. There is no built-in serialization — if the caller wants
"only one in flight at a time," they gate the RunAsync invocation
themselves (e.g., Button.IsEnabled(!mut.IsPending)).
MutationHookState.Dispose:
_isDisposed = true._unmountCts.Cancel()— every linkedcallCtsobserves cancellation through the linked-token mechanism; in-flight mutators seect.IsCancellationRequested == trueand should cooperatively cancel._unmountCts.Dispose().
RunAsync called post-Dispose returns Task.FromCanceled immediately rather
than firing callbacks on a dead component.
PendingScope (PendingScope.cs:20-79) is a thread-safe dictionary from
opaque object token → bool loading-state, plus a Changed event. Each
hook inside the scope uses this as the token and calls
Register(this, isLoading: true) on construction, SetLoading(this, false)
as soon as its state leaves Loading, and Unregister(this) on unmount.
AnyLoading is a linear scan of the dictionary's values — O(n) in the
number of resources inside the scope. For the typical scope size (a few
resources per modal / page), this is irrelevant. For very dense trees it
could be replaced with a running counter, but that's premature today.
PendingComponent.Render (Pending.cs:44-77):
UseRef<PendingScope?>— the per-instance scope. Never shared, so disposing aPendingcleans up its own scope.UseReducer(0, threadSafe: true)— rerender tick.UseEffectto subscribe/unsubscribescope.Changed. Re-arms the handler on every render so a stale handler from a previous mount can't fire.- Emits a 1×1
Gridwith both child and fallback mounted, withVisible(!showFallback)/Visible(showFallback)on the two branches, and.Provide(AppContexts.PendingScope, scope)to expose the scope to the subtree.
The key design decision: both trees stay mounted. The child's
UseResource hooks remain active while the fallback is on screen, continue
their fetches, and when they flip out of Loading, the scope fires Changed,
the component re-renders, and visibility flips. This is what makes Pending
not Suspense — there is no unwinding, no re-render, no reconciler work.
Per spec §10.1, only AsyncValue.Loading (not Reloading) counts as "still
fetching" for the bubble-up. ResourceHookState.NotifyPending
(UseResource.cs:440-445) encodes this:
private void NotifyPending()
{
if (PendingScope is null) return;
bool loading = _lastValue is AsyncValue<T>.Loading;
PendingScope.SetLoading(this, loading);
}Reloading(prev) means "we have something to show" — the subtree renders
normally, and if a parent Pending flipped to fallback during the initial
load, a refetch won't flip it back. This matches TanStack Query's
isLoading vs isFetching distinction.
For UseInfiniteResource, NotifyPending (UseInfiniteResource.cs:160-168)
applies the same rule at page-set granularity: "loading" only when
LoadState is Loading && Items.Count == 0. Once any page has landed, the
list is considered renderable and subsequent page fetches do not re-trigger
the fallback.
┌────────────────────────────────┐
│ PendingScope.AnyLoading? │
└─────┬──────────────────────┬───┘
│ false │ true
▼ ▼
┌──────────────────┐ ┌─────────────────────┐
│ render child │ │ render fallback │
│ child subtree │ │ child subtree still │
│ visible │ │ mounted but Visible=│
│ │ │ false; hooks run │
└──────┬───────────┘ └─────────┬───────────┘
│ │
│ any child resource │ all resources leave
│ enters Loading │ Loading (Data / Error /
│ (e.g. deps change │ Reloading count as "done")
│ on a shared-deps │
│ pattern) │
└────────► │
◄┘
scope.Changed fires → re-render
Nested Pendings are independent: each PendingComponent provides a fresh
scope via AppContexts.PendingScope, so a descendant hook registers only
with its nearest ancestor PendingComponent, not every ancestor.
FocusRevalidationService.cs:23-121. Off by default
(ReactorFeatureFlags.FocusRevalidation = false — ReactorFeatureFlags.cs:40).
When enabled and opted into per-hook via ResourceOptions.RefetchOnWindowFocus = true, the service tracks the set of cache keys under observation and
invalidates the ones past their StaleTime on window-activation events.
Invariants:
- Enrollment is opt-in and opt-out on the hook side
(
ResourceHookState.TransitionToKeyenrolls the new key;Disposeand previous-key transitions unenroll). RevalidateNowis throttled: returnsArray.Empty<string>()if it was called withinThrottleWindow(default 30s) of the previous call. This prevents Alt-Tab thrashing from firing a sweep on every focus transition.RevalidateNowForcebypasses the throttle for tests.- The enrolled set is snapshotted out of the lock before iteration so a
handler that
Unenrollsitself mid-sweep does not mutate the live collection. - The sweep works only through
QueryCache.TryGetFetchedAt— a non-generic metadata peek that reads the entry's age without knowingT. This lets the service stay generic-free.
The actual OS event plumbing (CoreWindow.Activated, CoreApplication.Resuming)
is not in this phase-1 branch; it's wired in phase 4 per the spec. The service
exists standalone so hooks can enroll against the right contract today, and
the plumbing switches on later.
Every mutable type the async system owns is protected by an internal
Monitor lock or a threadSafe: true reducer. Today's split:
| Component | Sync mechanism | Reasoning |
|---|---|---|
QueryCache slot |
Per-slot Monitor lock (QueryCache.cs) |
Cross-cutting shared state; eviction runs on a thread-pool timer; tests use the cache without a dispatcher. Decoupling the cache from UI affinity is a feature. |
QueryCache._timerLock |
Monitor + Interlocked |
Timer create/dispose is rare and can run from any thread. |
MutationHookState._lock |
Monitor lock |
RunAsync is intentionally callable from any thread; the lock protects _pendingCount / _lastResult / _error against the cross-thread caller. |
InfiniteResource._lock |
Monitor lock |
Documented thread-safe contract; UseInfiniteResourceThreadingTests drive ItemAt / EnsureRange from background threads to verify it. Production callers (virtualized list controls during layout) are UI-thread-affined, but the contract is the broader one. |
PendingScope._lock |
Monitor lock |
All production callers are UI-thread-affined, but the no-dispatcher edge (headless host, certain test paths) can fire SetLoading from a Task completion thread. The lock keeps that path safe in Release as well as DEBUG. |
FocusRevalidationService._lock |
Monitor lock |
Same shape as PendingScope. WinUI's activation/resume callbacks fire on the UI thread, but the lock keeps misuse from corrupting the enrolled set. |
UseResource / UseInfiniteResource / UseMutation rerender reducer |
threadSafe: true |
The hook continuation Apply runs on the dispatcher thread in production, but the test-suite InlineDispatcher runs Apply on whatever thread completed the underlying Task. The threadSafe reducer is what makes those test paths safe. |
Pending's rerender reducer |
threadSafe: true |
PendingScope.Changed should fire on the UI thread, but the no-dispatcher edge can land it on a thread-pool thread; the threadSafe reducer is the rerender-path safety net. |
The locks are uniformly uncontested in production — the UI thread reaches
them through the dispatcher and competing background-thread callers only
appear in test fixtures that deliberately exercise the thread-safe contract.
Keeping them is cheap (a few ns per uncontested Monitor take) and guarantees
serialization in all builds, not just DEBUG.
A previous attempt (PR #93, branch async/dispatcher-affinity) proposed
flipping PendingScope and FocusRevalidationService from internal locks to
DEBUG-only thread-affinity assertions. The framing was "the UI thread,
reached through IHookDispatcher.Post, is the synchronization mechanism —
defensive locks are redundant." The change was rejected after review. The
reasoning, captured here so the same proposal doesn't get re-litigated:
- No measurable benefit. Both locks are uncontested and off the hot
path.
PendingScopeis touched once per hook lifecycle event; theFocusRevalidationServicelives behind a feature flag that is off by default. Removing them is not a perf win. - Net correctness regression in Release builds. A
[Conditional("DEBUG")]AssertOwnerThread()is compiled away in Release. The lock guaranteed serialization unconditionally; the assertion does not. The no-dispatcher edge case (state.Dispatcher == null, headless hosts, certain test paths whereUseResourceapplies completions inline on the Task completion thread) is real enough that the same PR keptPending's rerender reducerthreadSafe: trueas belt-and-suspenders — butPendingScope's dictionary itself had no equivalent fallback. Trading unconditional serialization for "it shouldn't happen, and if it does, only DEBUG catches it" is a worse contract than what we have. - Two paradigms, not one. The proposal explicitly deferred migrating
QueryCache,MutationHookState,InfiniteResource, and the three async reducers (each defer was load-bearing —InfiniteResource's lock backs a documented thread-safe contract that threading tests deliberately exercise; the reducers'threadSafe: trueexists because the testInlineDispatcherrunsApplyoff the UI thread). The end state would have been a codebase with two synchronization paradigms (lock-based and affinity-based) where it previously had one — harder to reason about, not easier. - Test-side regression. The proposal rewrote three
PendingTeststo dropawait Task.Delay(...)settle-loops, replacing them with a hard dependency on default-modeTaskCompletionSourcerunning continuations inline onSetResult. That couples the tests to an SDK behavior detail — anyone later wrappingSetResultinTask.Runor constructing the TCS withRunContinuationsAsynchronouslywould get a mystery affinity-assert failure.
If a future migration wants to fully embrace "the dispatcher is the
synchronization mechanism," it should land all subsystems at once
(including a marshalling test dispatcher that lets the reducers drop
threadSafe: true), not partially. Until then the locks stay.
One artifact from that branch is worth keeping in mind even though we did
not adopt it: a typed IHookDispatcher.InvokeAsync<T>(Func<T>) extension
that wraps Post in a TaskCompletionSource<T> so background code can
read UI-affined state and get a value back. We chose not to add it
speculatively — there is no current caller — but it is the right shape
for the rare cross-thread read should one ever need it.
The overall property we want to prove is:
Safety. For every call to a user-supplied
fetcherormutator, exactly one of the following is true at the time the call's result or exception is surfaced:
- The result is applied to the cache + hook state and the component re-renders.
- The result is silently dropped because the hook unmounted, deps changed, or the token was cancelled.
Results are never applied to state in a stale key, never double-applied, and never cause a race-through against the render thread.
The argument is built from five layers.
RenderContext.BeginRender captures the UI thread id; setters assert in DEBUG.
Hook registration runs on that thread. The synchronous portion of
UseResourceCore (hook-id, state creation, key derivation, TransitionToKey,
EnterKey, StartAttempt-pending-decision) all runs while the render is in
flight, i.e., on the UI thread. Nothing from another thread can enter the
hook's state mutation paths during this window.
ScheduleCompletion attaches a continuation with
TaskContinuationOptions.ExecuteSynchronously. The continuation's body then
posts Apply through the injected IHookDispatcher (or calls it inline if
none). In production, the dispatcher is WinUI's DispatcherQueue, which
serializes enqueued actions onto the UI thread. So Apply — the only code
path that writes state.LastValue / state.InFlight / state.Cts after the
initial render — runs on the UI thread.
In unit tests where no dispatcher is present, Apply runs inline on whichever
thread completed the Task. The tests ensure single-threaded access by driving
the render loop manually with deterministic TaskCompletionSource-backed
fetchers.
Inside Apply:
if (state.IsDisposed) return; // gate 1
if (ct.IsCancellationRequested) return; // gate 2
if (t.IsCanceledOrDropped()) return; // gate 3Gate 1 catches unmount: Dispose sets IsDisposed = true before any
subsequent teardown, and sets it before cancelling the CTS, so a
continuation that races Dispose will see either IsDisposed = true OR an
already-cancelled token (usually both).
Gate 2 catches deps-change: TransitionToKey cancels the old CTS before
substituting a new one. The continuation captured the old ct at schedule
time, so a late result on the old token is dropped.
Gate 3 catches the case where the fetcher's task faulted with
OperationCanceledException unwrapped — we don't surface cancellation as
Error.
All three gates together close the window. The only window where a race
could clobber state is between cache.Set(key, value, ...) at line 354 and
state.LastValue = next2 at line 357, during which a concurrent Dispose
would set IsDisposed = true. In practice this window is on the same thread
(UI thread) if a dispatcher is present — Dispose is itself called during
RunCleanups, which runs during the UI thread's render / unmount cycle —
so no actual race exists. In the dispatcher-less test harness, the tests
avoid interleaving Dispose with completion by calling RunCleanups
explicitly after draining the dispatcher queue.
QueryCache operations are described in §3. The key argument: every mutation
to a Slot holds slot.Lock, and the IsEvicted flag plus the while-loop
retry pattern in Subscribe / Set makes the (dictionary insert, slot
mutate, dictionary remove) sequence effectively atomic from the caller's
perspective. A Subscribe and a concurrent EvictNow either serialize such
that the Subscribe increments a live slot, or the Subscribe observes
IsEvicted=true and retries on a fresh slot.
The rerender path uses UseReducer(0, threadSafe: true), which takes a lock
and uses EqualityComparer.Default.Equals for change detection. A rerender
requested from a background thread is serialized into the reducer and then
flows through the reconciler on its next scheduled tick. No state is mutated
outside the reducer's lock on that path.
IsDisposed is a plain bool, not volatile or interlocked. On x86 /
x64 CLR, aligned bool writes are atomic and visible across threads without
explicit memory barriers, but the .NET memory model technically allows
reorder. In practice: (a) the hook's Dispose runs on the UI thread as part
of the reconciler's unmount flow; (b) the continuation observing IsDisposed
has already crossed the dispatcher boundary, which includes a memory fence;
(c) if the test harness has no dispatcher, the single-threaded test loop
prevents concurrent access. So while not strictly lock-free-correct in the
ECMA memory model, the property holds under the threading topology the code
documents.
state.InFlight is a plain bool. Same argument. Reads happen during
render (UI thread); the one cross-thread write is in the continuation's
Apply, which also runs on the UI thread through the dispatcher. The test
harness paths run single-threaded.
QueryCache.EntryChanged's invocation list. C# events use a
copy-on-subscribe invocation list; += / -= are non-destructive. The
handler reference captured at FireEntryChanged time is stable across the
invocation even if another thread unsubscribes. The one remaining window —
unsubscribe-then-dispose between capturing the handler reference and
invoking it — is guarded by the handler's own IsDisposed check.
To tie everything together, trace one interaction from the spec's §6.3 example
(UserProfile fetching a user by id):
1. Render #1 on UI thread:
a. UserProfile.Render() runs; calls ctx.UseResource(fetcher, [userId]).
b. UseResourceCore allocates hookId (GUID), creates ResourceHookState,
registers UseEffect cleanup, captures DispatcherQueue for current thread.
c. TransitionToKey: newKey = "a1b2.../hash(userId)". No prior key.
Cache.Subscribe(newKey) → SubscriberCount = 1, Slot created.
PendingScope (if any) gets Register(state, isLoading: true).
d. EnterKey: cache miss; RefetchOnMount=true → BeginFetch.
e. BeginFetch → StartAttempt(attempt=0, inline=true):
i. Create CTS, capture token.
ii. fetcher(ct) returns a pending Task<User>.
iii. Task not complete → pending branch:
LastValue = switch over prior LastValue → Loading.Instance.
InFlight = true.
ScheduleCompletion: attach continuation with ExecuteSynchronously.
f. Render returns Loading; reconciler renders Skeleton.
2. Network returns (thread pool):
a. Task transitions to RanToCompletion with the User payload.
b. Continuation fires on a thread-pool thread.
c. Continuation body posts Apply to IHookDispatcher.Post → queued on UI.
3. UI thread drains dispatcher:
a. Apply() runs:
- state.IsDisposed false; ct not cancelled; task not cancelled.
- cache.Set(key, user, staleTime, cacheTime): slot.Entry = new
CacheEntry<User>(user, now, staleTime, SubscriberCount=1).
FireEntryChanged(key) → our own OnEntryChanged fires, sees
LastValue=Loading (not Data), does not trigger a redundant rerender.
- next = new Data(user); record-equality vs Loading → changed → assign.
- NotifyPending: not Loading → PendingScope.SetLoading(state, false).
- InFlight = false; RequestRerender() → tick++ → reducer triggers
reconciler re-render.
4. Render #2 on UI thread:
a. UserProfile.Render() runs; ctx.UseResource(fetcher, [userId]).
b. stateRef.Current already set.
c. Same key, same deps → not firstRender, not depsChanged.
d. ReconcileWithCache: LastValue is Data AND cache still has the entry →
no-op.
e. Return state.LastValue = Data(user).
f. Render returns VStack(Heading(user.Name), Text(user.Email)).
5. Later, user navigates away:
a. Reconciler calls RunCleanups on UserProfile's RenderContext.
b. UseEffect cleanup fires: state.Dispose().
c. Dispose: IsDisposed = true; unsubscribe cache EntryChanged;
Cts.Cancel(); Cts.Dispose(); Cache.Unsubscribe(key) → SubscriberCount
= 0 → ZeroSubscribersAt = now. PendingScope.Unregister(state).
d. The entry stays in the cache until CacheTime expires. If the user
navigates back within CacheTime, render #1 of the next mount hits
EnterKey's cache-hit path and returns Data(user) on the same render,
no Loading flash.
Every step runs on the UI thread (or crosses exactly one dispatcher
boundary). The only piece of shared state is the QueryCache slot for this
key, which serializes access via its per-slot lock.
| # | Location | Class | Description |
|---|---|---|---|
| 1 | UseResource.cs:289-294 |
BUG | Cross-key data leakage on deps change. When deps change and the new key is a cache miss, StartAttempt's inline-pending switch maps the old key's Data(old) to Reloading(old), surfacing the old query's value as the new query's stale-while-revalidate previous. Fix: reset state.LastValue = Loading.Instance in TransitionToKey before EnterKey runs, for non-first renders. See §4.3. |
| 2 | UseInfiniteResource.cs:239-244 |
BUG | Dropped follower page when N-1 is in flight. RequestPage(N) recurses into RequestPage(N-1); if N-1 is already in-flight, the call silently returns and page N is never fetched. In practice virtualized controls re-request via EnsureRange, but the correctness argument wants a _pendingCursorChainedPages set that flushes on CommitSuccess. See §5.4. |
| 3 | UseResource.cs:430-438 |
GAP | Sibling-updates-value not plumbed. OnEntryChanged only fires a rerender when an entry is removed. A fresh Set with a different value from a sibling hook is not delivered to this hook until its own deps change. Broaden the condition to "LastValue is Data && cache value differs from LastValue.Value." See §4.6. |
| 4 | UseInfiniteResource.cs:337-364 |
GAP | Refresh replaces state.Resource before rerender. Consumers that snapshot scroll state during the LoadState.Loading render may be one frame out of sync relative to the resource-swap. Verify with a LazyVStack framerate fixture once one exists; may need a two-phase refresh (mark old resource as refreshing → render → swap in new resource → render). See §5.6. |
| 5 | UseResource.cs:395-400 |
MINOR | LastValue property setter always calls NotifyPending even when value didn't change. Pending scopes fire redundant Changed events on stale-while-revalidate re-applies. Trivial optimization; wrap setter with equality check. |
| 6 | UseResource.cs:289-295 (retry inline path) |
MINOR | When StartAttempt runs on a retry (inlineSyncResult=false), the pending-branch LastValue switch is skipped entirely. That is intentional (don't re-flash Loading on a retry), but it means NotifyPending isn't called, so PendingScope is not re-armed if the retry somehow reverts the state to Loading. In practice it can't, because a retry starts from an Error which doesn't participate in Pending anyway. Worth a short comment pointing this out. |
| 7 | QueryCache.cs:267-283 |
MINOR | TryGetFetchedAt returns false on missing slot OR non-ICacheEntry Entry. The doc comment says "should be impossible through the public API," which is true today. If generic invariants later change (e.g., a second payload wrapper), this becomes a silent-skip. Consider asserting non-null ICacheEntry when slot is present. |
None of the items above are blockers. Items 1 and 2 are the only ones with user-visible semantic effects; the rest are about defense-in-depth or future hardening.
UseHookBasedPaging(defaultfalse) — DataGrid reads paged data throughUseInfiniteResourcerather than the legacyDataPageCache. Flips on in phase 3.FocusRevalidation(defaultfalse) — enable window-activation sweep. Per-hook opt-in viaResourceOptions.RefetchOnWindowFocus.
| Knob | Default | Source |
|---|---|---|
ResourceOptions.StaleTime |
TimeSpan.Zero |
UseResource.cs:20 |
ResourceOptions.CacheTime |
5 min | UseResource.cs:21 |
ResourceOptions.RetryCount |
0 | UseResource.cs:14 |
ResourceOptions.RefetchOnMount |
true |
UseResource.cs:15 |
ResourceOptions.RefetchOnWindowFocus |
false |
UseResource.cs:17 |
InfiniteResourceOptions.PageSize |
50 | InfiniteResource.cs:42 |
InfiniteResourceOptions.MaxLoadedPages |
null (unbounded) |
InfiniteResource.cs:43 |
QueryCache.EvictionPollInterval |
1 s | QueryCache.cs:52 |
FocusRevalidationService.ThrottleWindow |
30 s | FocusRevalidationService.cs:33 |
| Retry backoff | 100 * 2^attempt ms |
UseResource.cs:310 |
// Single fetch.
AsyncValue<T> UseResource<T>(
Func<CancellationToken, Task<T>> fetcher,
object[] deps,
ResourceOptions? options = null);
// Paginated fetch.
InfiniteResource<TItem> UseInfiniteResource<TItem, TCursor>(
Func<TCursor?, CancellationToken, Task<Page<TItem, TCursor>>> fetchPage,
object[] deps,
InfiniteResourceOptions? options = null);
// Write.
Mutation<TInput, TResult> UseMutation<TInput, TResult>(
Func<TInput, CancellationToken, Task<TResult>> mutator,
MutationOptions<TInput, TResult>? options = null);
// IDataSource<T> adapter.
InfiniteResource<T> UseDataSource<T>(
this RenderContext ctx,
IDataSource<T> source,
DataRequest request,
QueryCache cache,
InfiniteResourceOptions? options = null);
// Bubble-up fallback.
Element Pending(Element fallback, Element child);Context<QueryCache> QueryCache— the ambient cache. Override per test / per subtree with.Provide.Context<PendingScope?> PendingScope— nearest ancestor scope, or null.Context<FocusRevalidationService?> FocusRevalidation— nearest service.
docs/specs/020-async-resources-design.md— the design specification; this reference paper's intent.docs/guide/hooks-internals.md— the hook / state foundation that async resources build on.docs/guide/reconciliation.md— how component unmount / effect-cleanup / rerender flows work, which this system depends on.tests/Reactor.Tests/Core/UseResourceTests.csand siblings — deterministic test harness that exercises every transition described here.tests/Reactor.AppTests.Host/SelfTest/Fixtures/AsyncResource*Fixtures.cs— framerate and snapshot selfhost fixtures built on top of the hook surface.