Optimize ThunkStatusOrError

This commit is contained in:
Jon Staab 2026-08-31 10:03:01 -07:00
parent a14f599fcb
commit 3bf3927896
13 changed files with 106 additions and 21 deletions

View file

@ -47,7 +47,7 @@
</button>
{/each}
</div>
<ThunkStatusOrDeleted {event}>
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}

View file

@ -43,7 +43,7 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ThunkStatusOrDeleted {event}>
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}

View file

@ -58,7 +58,7 @@
</button>
{/each}
</div>
<ThunkStatusOrDeleted {event}>
<ThunkStatusOrDeleted {event} {context}>
{#snippet status()}
<ClassifiedStatus {event} />
{/snippet}

View file

@ -30,7 +30,7 @@
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex grow flex-wrap justify-end gap-2">
<ThunkStatusOrDeleted {event}>
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}

View file

@ -38,7 +38,7 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ThunkStatusOrDeleted {event}>
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}

View file

@ -35,7 +35,7 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ThunkStatusOrDeleted {event}>
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import cx from "classnames"
import {get, readable} from "svelte/store"
import {readable} from "svelte/store"
import {
hash,
gte,
@ -39,6 +39,7 @@
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
import RoomItemContent from "@app/components/RoomItemContent.svelte"
import {profiles, thunks, user} from "@app/core"
import {noThunks, thunksByEventId} from "@app/thunks"
import {colors} from "@app/theme"
import {ENABLE_ZAPS} from "@app/env"
import type {FeedContext} from "@app/feeds"
@ -69,7 +70,7 @@
const h = tagValue(tagSpec("h"), event.tags)
const today = formatTimestampAsDate(now())
const profileDisplay = $profiles.display(event.pubkey, [url]).$
const thunk = $thunks.merge(get($thunks.history).filter(t => t.event.id === event.id))
const thunk = $derived($thunks.merge($thunksByEventId.get(event.id) ?? noThunks))
const [_, colorValue] = colors[hash(event.pubkey) % colors.length]
const qTag = matchTag(tagSpec("q"), event.tags)

View file

@ -34,7 +34,7 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ThunkStatusOrDeleted {event}>
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}

View file

@ -17,7 +17,7 @@
<div class="flex w-full justify-end px-1 text-xs {restProps.class}">
<span class="flex items-center gap-1">
<span class="spinner-xs mx-1 h-3 w-3 translate-y-px"></span>
<span class="spinner spinner-xs mx-1 translate-y-px"></span>
<span class="opacity-50">Sending...</span>
<button
type="button"

View file

@ -2,24 +2,22 @@
import type {Snippet} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {PublishStatus} from "@welshman/net"
import {deriveIsDeleted} from "@app/repository"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import {thunks} from "@app/core"
import type {FeedContext} from "@app/feeds"
import {noThunks, thunksByEventId} from "@app/thunks"
type Props = {
event: TrustedEvent
context: FeedContext
status?: Snippet
children?: Snippet
}
const {event, status, children}: Props = $props()
// Editing a replaceable event hands this a different event
const deleted = $derived(deriveIsDeleted(event))
const history = $thunks.history
// Subscribed rather than read: a thunk mutates its results in place and notifies, so reading
// them off the object would leave this showing a publish that has since finished.
const thunk = $derived($thunks.merge($history.filter(t => t.event.id === event.id)))
const {event, context, status, children}: Props = $props()
const deleted = $derived(context.deleted(event))
const pending = $derived($thunksByEventId.get(event.id) ?? noThunks)
const thunk = $derived($thunks.merge(pending))
</script>
{#if $deleted}

View file

@ -8,6 +8,7 @@ import {
addressTags,
getAddress,
getCommentFiltersForRoot,
getIdOrAddress,
getReplyFilters,
hexTags,
isReplaceableKind,
@ -48,6 +49,7 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}
const eventsByTarget = new Map<string, TrustedEvent[]>()
const targetsByEventId = new Map<string, string[]>()
const subscribersByTarget = new Map<string, Set<(events: TrustedEvent[]) => void>>()
const deletedChecks = new Map<string, Set<() => void>>()
const addEvent = (event: TrustedEvent, touched: Set<string>) => {
// An event seen before its target was tracked stays unfiled, so that adding the target
@ -86,6 +88,22 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}
}
}
const notifyDeleted = (added: TrustedEvent[], removed: Set<string>) => {
if (deletedChecks.size > 0) {
if (removed.size > 0) {
for (const checks of deletedChecks.values()) {
for (const check of checks) check()
}
} else {
for (const event of added) {
for (const check of deletedChecks.get(getIdOrAddress(event)) || []) {
check()
}
}
}
}
}
const notify = (touched: Set<string>) => {
for (const target of touched) {
for (const subscriber of subscribersByTarget.get(target) || []) {
@ -146,6 +164,7 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}
}
notify(touched)
notifyDeleted(added, removed)
}),
)
@ -183,6 +202,29 @@ export const makeFeedContext = ({relays}: {relays: string[] | Promise<string[]>}
return {
add,
deleted: (event: TrustedEvent) =>
readable(repository.isDeleted(event), set => {
const key = getIdOrAddress(event)
const check = () => set(repository.isDeleted(event))
let checks = deletedChecks.get(key)
if (!checks) {
checks = new Set()
deletedChecks.set(key, checks)
}
checks.add(check)
check()
return () => {
checks.delete(check)
if (checks.size === 0) {
deletedChecks.delete(key)
}
}
}),
related: (event: TrustedEvent): Readable<TrustedEvent[]> => {
add(event)

43
src/app/thunks.ts Normal file
View file

@ -0,0 +1,43 @@
import {derived} from "svelte/store"
import {pushToMapKey} from "@welshman/lib"
import {Thunks} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {fromApp} from "@app/core"
let previous = new Map<string, Thunk[]>()
// Publishes indexed by the event they carry.
//
// Every row that shows publish status wants the thunks for one event, and each of them filtering
// the whole history is O(rows × history) on every publish — history only shrinks when a thunk is
// aborted, so it grows for as long as the session lasts. Indexing once leaves each row a lookup.
export const thunksByEventId = derived(
fromApp($app => $app.use(Thunks).history),
$history => {
const byId = new Map<string, Thunk[]>()
for (const thunk of $history) {
pushToMapKey(byId, thunk.event.id, thunk)
}
// Hand back the array from last time wherever an event's thunks are unchanged. A row derives
// a merged thunk from this, and that merge subscribes to each thunk it holds — rebuilding it
// every time anything anywhere publishes churns the object its status components are watching.
for (const [id, thunks] of byId) {
const before = previous.get(id)
if (before?.length === thunks.length && before.every((thunk, i) => thunk === thunks[i])) {
byId.set(id, before)
}
}
previous = byId
return byId
},
new Map<string, Thunk[]>(),
)
// Shared, so a row with nothing in flight keeps the same value across every publish and doesn't
// rebuild anything downstream
export const noThunks: Thunk[] = []

View file

@ -8,8 +8,9 @@
animation: spinner-spin 0.7s linear infinite;
}
/* sizes are standalone (share the element with .spinner, not nested under it)
— some call sites use spinner-xs on its own, e.g. ThunkPending */
/* sizes share the element with .spinner rather than nesting under it, and only override its
dimensions — the shape, colour and animation all come from .spinner, so a size on its own
renders as a bordered box */
.spinner-sm {
@apply h-4 w-4 border-1;
}