Skip to content

fix(patterns,performance,browser,ui) - replace global-version and sin… - #59

Merged
hexplus merged 4 commits into
mainfrom
chore/59-framework-hardening
Aug 28, 2026
Merged

fix(patterns,performance,browser,ui) - replace global-version and sin…#59
hexplus merged 4 commits into
mainfrom
chore/59-framework-hardening

Conversation

@hexplus

@hexplus hexplus commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Description

Four public helpers tracked concurrent async work with state that cannot represent it: a global version counter, a single mutable native handle, and a plain boolean. Each produced a reproducible failure.

optimisticList() — a global counter cannot answer a per-row question. Rollback was guarded by "has a newer operation started since?", which is wrong in both directions. Skipping the rollback left a failed operation's item on screen permanently:

[1,2,3] · add(4) pending · add(5) pending · A fails
  →  [1,2,3,4,5]        the item that failed to save is still there

Running it was no better: restoring the captured array discarded every change newer operations had made to rows the failing one never touched. Row identity was also broken — a fallback to Object.is located the first equal value, so [1] plus an optimistic add(1) confirmed as 10 produced [10, 1].

The list is now a ledger of rows with stable ids and per-row operation ownership. Existence and value are owned separately: a failed add withdraws its own row whatever its value-ownership has become, while a successful add publishes only if it still owns the value. Disjoint operations never interact; where two touch the same row the later one wins. items() projects values only — no id or wrapper is observable.

Chunk invalidation did not stop in-flight work. invalidate(id) and clear() left the pending map untouched, so load("a") → clear() → old load resolves wrote the discarded value straight back into the cache. The same continuation deleted the pending entry unconditionally, removing a newer load's; and because the stale promise stayed in the map, a post-invalidation load(id, freshLoader) deduplicated against it and never called its own loader.

The pending entry object is the load's claim on the key, so removing it from the map revokes ownership — no generation counters, no map that grows. Superseded work still settles for its original caller. Nothing is cancelled and nothing claims to be: the loader API takes no abort signal.

Lifecycle callbacks were participants, not observers. A throwing onLoadEnd turned a cached success into a rejection and delivered its own exception to onLoadError — the caller was told "failed" while registry.get(id) returned the value. A throwing onLoadStart stopped the load from starting at all. Callbacks now run contained and report through the runtime error pipeline; onLoadError receives only genuine loader/timeout errors, exactly once.

wakeLock() could orphan a native handle. Two overlapping requests acquired two sentinels and kept only the last reference; release() then released the survivor while the other stayed held with nothing left to release it. release() also did nothing about a request already in flight, so a lock could reactivate after being given up, and a stale sentinel's release event cleared the current one's state.

Requests now share one in-flight acquisition, release()/dispose() revoke ownership before awaiting anything, and any sentinel arriving without ownership is released immediately — which makes an orphan structurally impossible rather than merely unlikely.

viewTransition().isTransitioning() described the last run to finish, not the controller. Two overlapping starts raced over one boolean, so the flag went false while an earlier transition was still running. It now stays true while any run is in flight and becomes false exactly when the last settles, in any order; every caller keeps its own resolution or rejection.

Related Issue

Closes #

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Breaking: a failed optimisticList operation now reverses its own mutation instead of being suppressed by a newer one, so a failed item disappears where it previously persisted. Code that relied on the old suppression will see different arrays. wakeLock request/release failures are reported through the runtime error pipeline rather than console.warn.

Checklist

  • I have read CONTRIBUTING.md
  • My code builds without errors
  • I have tested my changes
  • I have updated documentation if needed

@hexplus
hexplus merged commit 47a95d9 into main Aug 28, 2026
5 checks passed
@hexplus
hexplus deleted the chore/59-framework-hardening branch August 28, 2026 22:14
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