Clarify handling of async task revs

This commit is contained in:
Eric Bailey
2026-05-05 19:37:56 -05:00
parent b119c36229
commit a930e5832c
2 changed files with 75 additions and 34 deletions
+27 -34
View File
@@ -16,6 +16,7 @@ import {buildPostMediaItem} from '#/components/ComposerV2/store/utils/buildPostM
import {buildThreadPost} from '#/components/ComposerV2/store/utils/buildThreadPost' import {buildThreadPost} from '#/components/ComposerV2/store/utils/buildThreadPost'
import {classifyUriTarget} from '#/components/ComposerV2/store/utils/classifyUriTarget' import {classifyUriTarget} from '#/components/ComposerV2/store/utils/classifyUriTarget'
import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining' import {computePostMediaSelectionsRemaining} from '#/components/ComposerV2/store/utils/computePostMediaSelectionsRemaining'
import {createAsyncTaskRev} from '#/components/ComposerV2/store/utils/createAsyncTaskRev'
import {filterMediaInputs} from '#/components/ComposerV2/store/utils/filterMediaInputs' import {filterMediaInputs} from '#/components/ComposerV2/store/utils/filterMediaInputs'
type Listener = () => void type Listener = () => void
@@ -47,20 +48,14 @@ export function createThreadStore(options: {
const uploadTasks = new Map<string, UploadTask>() const uploadTasks = new Map<string, UploadTask>()
/** /**
* Per-slot generation counters. Quote and embed are orthogonal slots, so * Per-slot revision counters. Quote and embed are orthogonal slots, so
* each has its own counter; cancelling one doesn't invalidate the other. * each has its own counter; invalidating one doesn't invalidate the
* Every action that starts or invalidates a resolution for a slot bumps * other. Every action that starts or supersedes a resolution for a slot
* that slot's gen, and the worker callback compares its captured gen * calls `incrementFor(postId)`, and the worker callback closes over the
* against the current value before writing to state. * returned `isCurrent` checker to decide whether to write back.
*/ */
const quoteGenByPost = new Map<string, number>() const quoteRev = createAsyncTaskRev()
const embedGenByPost = new Map<string, number>() const embedRev = createAsyncTaskRev()
function bumpGen(map: Map<string, number>, postId: string): number {
const next = (map.get(postId) ?? 0) + 1
map.set(postId, next)
return next
}
/** /**
* Action bodies mutate `s` in place. Returning `null` signals a no-op (the * Action bodies mutate `s` in place. Returning `null` signals a no-op (the
@@ -140,12 +135,10 @@ export function createThreadStore(options: {
if (!(postId in s.posts)) return null if (!(postId in s.posts)) return null
// Cancel any in-flight uploads for media on this post before dropping it. // Cancel any in-flight uploads for media on this post before dropping it.
for (const m of s.posts[postId].media) cancelUploadTask(m.id) for (const m of s.posts[postId].media) cancelUploadTask(m.id)
// Bump (and drop) both gens so any stale resolution callbacks for // Drop both rev entries so any stale resolution callbacks for this
// this post can never write back into state. // post can never write back into state.
bumpGen(quoteGenByPost, postId) quoteRev.clearFor(postId)
bumpGen(embedGenByPost, postId) embedRev.clearFor(postId)
quoteGenByPost.delete(postId)
embedGenByPost.delete(postId)
delete s.posts[postId] delete s.posts[postId]
s.isDirty = true s.isDirty = true
return s return s
@@ -305,7 +298,7 @@ export function createThreadStore(options: {
* *
* Otherwise, pending state is written to the target slot synchronously and * Otherwise, pending state is written to the target slot synchronously and
* `resolveLink` runs in the background. The outcome lands in the same slot * `resolveLink` runs in the background. The outcome lands in the same slot
* (resolved or failed). Cancellation is per-slot gen-based. * (resolved or failed). Cancellation is per-slot rev-based.
*/ */
function addUri(postId: string, uri: string) { function addUri(postId: string, uri: string) {
const post = state.posts[postId] const post = state.posts[postId]
@@ -317,7 +310,7 @@ export function createThreadStore(options: {
// No-op only when the slot has a settled value. Pending and failed // No-op only when the slot has a settled value. Pending and failed
// states are replaceable (failed.retry() relies on this). // states are replaceable (failed.retry() relies on this).
if (post.quote?.state === 'resolved') return if (post.quote?.state === 'resolved') return
const gen = bumpGen(quoteGenByPost, postId) const rev = quoteRev.incrementFor(postId)
mutateState(s => { mutateState(s => {
const p = s.posts[postId] const p = s.posts[postId]
if (!p) return null if (!p) return null
@@ -329,7 +322,7 @@ export function createThreadStore(options: {
postId, postId,
uri, uri,
resolveLink: resolveLinkOverride, resolveLink: resolveLinkOverride,
onResolve: handleQuoteResolution(gen, uri), onResolve: handleQuoteResolution(rev, uri),
}) })
return return
} }
@@ -344,7 +337,7 @@ export function createThreadStore(options: {
if (embedSettled) return if (embedSettled) return
if (post.media.length > 0) return if (post.media.length > 0) return
const gen = bumpGen(embedGenByPost, postId) const rev = embedRev.incrementFor(postId)
mutateState(s => { mutateState(s => {
const p = s.posts[postId] const p = s.posts[postId]
if (!p) return null if (!p) return null
@@ -356,14 +349,14 @@ export function createThreadStore(options: {
postId, postId,
uri, uri,
resolveLink: resolveLinkOverride, resolveLink: resolveLinkOverride,
onResolve: handleEmbedResolution(gen, uri), onResolve: handleEmbedResolution(rev, uri),
}) })
} }
function handleQuoteResolution(gen: number, uri: string) { function handleQuoteResolution(rev: number, uri: string) {
return (postId: string, outcome: LinkResolutionOutcome) => { return (postId: string, outcome: LinkResolutionOutcome) => {
if (destroyed) return if (destroyed) return
if (quoteGenByPost.get(postId) !== gen) return if (!quoteRev.isCurrentFor(postId, rev)) return
// Pre-classification said this was a post URL. If resolveLink disagrees // Pre-classification said this was a post URL. If resolveLink disagrees
// (deleted post, embedding disabled, network error, etc.), surface as // (deleted post, embedding disabled, network error, etc.), surface as
@@ -409,10 +402,10 @@ export function createThreadStore(options: {
} }
} }
function handleEmbedResolution(gen: number, uri: string) { function handleEmbedResolution(rev: number, uri: string) {
return (postId: string, outcome: LinkResolutionOutcome) => { return (postId: string, outcome: LinkResolutionOutcome) => {
if (destroyed) return if (destroyed) return
if (embedGenByPost.get(postId) !== gen) return if (!embedRev.isCurrentFor(postId, rev)) return
// Pre-classification said this was a non-post URL. If resolveLink // Pre-classification said this was a non-post URL. If resolveLink
// surprises us with a post outcome, treat as failure rather than // surprises us with a post outcome, treat as failure rather than
@@ -455,7 +448,7 @@ export function createThreadStore(options: {
} }
function removeEmbed(postId: string) { function removeEmbed(postId: string) {
bumpGen(embedGenByPost, postId) embedRev.incrementFor(postId)
mutateState(s => { mutateState(s => {
const post = s.posts[postId] const post = s.posts[postId]
if (!post) return null if (!post) return null
@@ -469,13 +462,13 @@ export function createThreadStore(options: {
/** /**
* Direct setter for an already-resolved quote. Used for draft restore and * Direct setter for an already-resolved quote. Used for draft restore and
* any UI flow that already has the post ref+view in hand. Bumps the quote * any UI flow that already has the post ref+view in hand. Bumps the quote
* gen so any in-flight resolution is invalidated. * rev so any in-flight resolution is invalidated.
*/ */
function setQuoteEmbed( function setQuoteEmbed(
postId: string, postId: string,
ref: {uri: string; cid: string; view?: AppBskyFeedDefs.PostView}, ref: {uri: string; cid: string; view?: AppBskyFeedDefs.PostView},
) { ) {
bumpGen(quoteGenByPost, postId) quoteRev.incrementFor(postId)
mutateState(s => { mutateState(s => {
const post = s.posts[postId] const post = s.posts[postId]
if (!post) return null if (!post) return null
@@ -491,7 +484,7 @@ export function createThreadStore(options: {
} }
function removeQuoteEmbed(postId: string) { function removeQuoteEmbed(postId: string) {
bumpGen(quoteGenByPost, postId) quoteRev.incrementFor(postId)
mutateState(s => { mutateState(s => {
const post = s.posts[postId] const post = s.posts[postId]
if (!post) return null if (!post) return null
@@ -610,8 +603,8 @@ export function createThreadStore(options: {
destroyed = true destroyed = true
for (const task of uploadTasks.values()) task.cancel() for (const task of uploadTasks.values()) task.cancel()
uploadTasks.clear() uploadTasks.clear()
quoteGenByPost.clear() quoteRev.clearAll()
embedGenByPost.clear() embedRev.clearAll()
}, },
getState() { getState() {
return state return state
@@ -0,0 +1,48 @@
/**
* A keyed revision counter used to cancel stale async work. Callers
* `incrementFor(key)` to bump a key's revision and capture the new value,
* then later `isCurrentFor(key, rev)` to check whether their captured
* revision is still the live one. If it isn't, a newer caller has
* superseded them and they should bail.
*
* Example:
* const rev = task.incrementFor(postId)
* doAsyncWork().then(result => {
* if (!task.isCurrentFor(postId, rev)) return
* // ... write result to state
* })
*
* `clearFor(key)` drops the key - any later isCurrentFor call for that
* key returns false. Right hook to call from removePost.
* `clearAll()` drops every key - used on store destroy.
*/
export type AsyncTaskRev = {
/** Bump the key's revision and return the new value. */
incrementFor(key: string): number
/** True while `rev` is still the current revision for `key`. */
isCurrentFor(key: string, rev: number): boolean
/** Drop the key. Future isCurrentFor calls for this key return false. */
clearFor(key: string): void
/** Drop every key. Future isCurrentFor calls return false. */
clearAll(): void
}
export function createAsyncTaskRev(): AsyncTaskRev {
const counters = new Map<string, number>()
return {
incrementFor(key) {
const next = (counters.get(key) ?? 0) + 1
counters.set(key, next)
return next
},
isCurrentFor(key, rev) {
return counters.get(key) === rev
},
clearFor(key) {
counters.delete(key)
},
clearAll() {
counters.clear()
},
}
}