From 5df1d0b346138aa98ef2b7d707b4eea33c72b32c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 2 May 2026 19:59:42 +0200 Subject: [PATCH] speed up app --- package-lock.json | 32 ++- package.json | 3 +- src/PageManager.tsx | 90 ++++---- .../NoteList/VirtualizedFeedRows.tsx | 156 ++++++++++++++ src/components/NoteList/index.tsx | 46 +++-- src/hooks/use-event-callback.ts | 11 + src/providers/DeepBrowsingProvider.tsx | 11 +- src/providers/NostrProvider/index.tsx | 193 +++++++++++++----- src/services/client.service.ts | 23 ++- src/services/indexed-db.service.ts | 46 +++-- 10 files changed, 460 insertions(+), 151 deletions(-) create mode 100644 src/components/NoteList/VirtualizedFeedRows.tsx create mode 100644 src/hooks/use-event-callback.ts diff --git a/package-lock.json b/package-lock.json index 05122e66..3b9f5588 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.3.1", + "version": "23.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.3.1", + "version": "23.4.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", @@ -47,6 +47,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@scure/base": "^2.0.0", "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-virtual": "^3.13.24", "@tiptap/core": "^2.12.0", "@tiptap/extension-document": "^2.12.0", "@tiptap/extension-emoji": "^2.26.1", @@ -5707,6 +5708,33 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index f2d93de3..0034e793 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.3.1", + "version": "23.4.0", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", @@ -77,6 +77,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@scure/base": "^2.0.0", "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-virtual": "^3.13.24", "@tiptap/core": "^2.12.0", "@tiptap/extension-document": "^2.12.0", "@tiptap/extension-emoji": "^2.26.1", diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 019390ed..df74cf77 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -37,6 +37,7 @@ import { useRef, useState } from 'react' +import { useEventCallback } from '@/hooks/use-event-callback' import { flushSync } from 'react-dom' import { useTranslation } from 'react-i18next' import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp' @@ -233,6 +234,27 @@ function mergePrimaryPageEntry( return [...prev, { name: entry.name, element, props: entry.props }] } +function renderActivePrimaryPageContent( + primaryPages: TPrimaryPageStateEntry[], + currentPrimaryPage: TPrimaryPageName +): ReactNode { + const entry = + primaryPages.find((p) => p.name === currentPrimaryPage) ?? + (primaryPages.length > 0 ? primaryPages[0] : undefined) + if (!entry) return null + try { + logger.debug(`Rendering active primary page: ${entry.name}`) + return entry.props ? applyPrimaryPageProps(entry.element, entry.props) : entry.element + } catch (error) { + logger.error(`Error rendering ${entry.name} component:`, error) + return ( +
+ Error rendering {entry.name}: {error instanceof Error ? error.message : String(error)} +
+ ) + } +} + export { PrimaryPageContext, usePrimaryPage } export { useSecondaryPage, useSecondaryPageOptional } @@ -987,30 +1009,9 @@ function MainContentArea({ ) : ( - // Show normal primary pages - primaryPages.map(({ name, element, props }) => { - const isCurrentPage = currentPrimaryPage === name - logger.debug(`Primary page ${name}:`, { isCurrentPage, currentPrimaryPage }) - return ( -
- {(() => { - try { - logger.debug(`Rendering ${name} component`) - return props ? applyPrimaryPageProps(element, props) : element - } catch (error) { - logger.error(`Error rendering ${name} component:`, error) - return
Error rendering {name}: {error instanceof Error ? error.message : String(error)}
- } - })()} -
- ) - }) +
+ {renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)} +
)} {/* DEPRECATED: Secondary panel removed - double-panel functionality disabled */} @@ -1820,6 +1821,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // NEVER scroll to top - feed should maintain scroll position at all times } + const navigatePrimaryPageStable = useEventCallback(navigatePrimaryPage) + const goBack = () => { if (primaryViewType === 'settings-sub') { navigatePrimaryPage('settings') @@ -2031,12 +2034,21 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { setSinglePaneSheetOpen(shouldBeOpen) }, [panelMode, isSmallScreen, secondaryStack.length, drawerOpen]) - const primaryPageContextValue: PrimaryPageContextValue = { - navigate: navigatePrimaryPage, - current: currentPrimaryPage, - currentPageProps, - display: isSmallScreen ? secondaryStack.length === 0 : true - } + const primaryPageContextValue = useMemo( + (): PrimaryPageContextValue => ({ + navigate: navigatePrimaryPageStable, + current: currentPrimaryPage, + currentPageProps, + display: isSmallScreen ? secondaryStack.length === 0 : true + }), + [ + navigatePrimaryPageStable, + currentPrimaryPage, + currentPageProps, + isSmallScreen, + secondaryStack.length + ] + ) return ( @@ -2049,7 +2061,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0, - navigateToPrimaryPage: navigatePrimaryPage + navigateToPrimaryPage: navigatePrimaryPageStable }} > @@ -2119,17 +2131,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { ) })} - {primaryPages.map(({ name, element, props }) => ( -
- {props ? applyPrimaryPageProps(element, props) : element} + {secondaryStack.length === 0 ? ( +
+ {renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)}
- ))} + ) : null} )}
@@ -2165,7 +2171,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { push: pushSecondaryPage, pop: popSecondaryPage, currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0, - navigateToPrimaryPage: navigatePrimaryPage + navigateToPrimaryPage: navigatePrimaryPageStable }} > diff --git a/src/components/NoteList/VirtualizedFeedRows.tsx b/src/components/NoteList/VirtualizedFeedRows.tsx new file mode 100644 index 00000000..7998f25d --- /dev/null +++ b/src/components/NoteList/VirtualizedFeedRows.tsx @@ -0,0 +1,156 @@ +import NoteCard from '@/components/NoteCard' +import MediaGridItem from '@/components/MediaGridItem' +import { useVirtualizer, useWindowVirtualizer } from '@tanstack/react-virtual' +import type { Event } from 'nostr-tools' +import { memo } from 'react' + +const ESTIMATE_NOTE_ROW_PX = 280 +const ESTIMATE_GRID_ROW_PX = 120 +const VIRTUAL_OVERSCAN = 10 + +export type VirtualizedFeedRowsProps = { + events: Event[] + gridLayout: boolean + filterMutedNotes: boolean + eventReasonLabelMap: Map + /** When true, list scrolls with `window`; otherwise `scrollElement` must be set. */ + useWindowScroll: boolean + scrollElement: HTMLElement | null + /** Document offset of the list root (window virtualizer scroll margin). */ + scrollMarginTop: number +} + +const WindowRows = memo(function WindowRows({ + events, + gridLayout, + filterMutedNotes, + eventReasonLabelMap, + scrollMarginTop +}: Omit) { + const rowCount = gridLayout ? Math.ceil(events.length / 3) : events.length + const virtualizer = useWindowVirtualizer({ + count: rowCount, + estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), + overscan: VIRTUAL_OVERSCAN, + scrollMargin: scrollMarginTop, + getItemKey: (index) => (gridLayout ? `grid-${index}` : (events[index]?.id ?? `i-${index}`)) + }) + + return ( +
+ {virtualizer.getVirtualItems().map((vi) => ( +
+ {gridLayout ? ( +
+ {events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => ( + + ))} +
+ ) : ( + + )} +
+ ))} +
+ ) +}) + +const ElementRows = memo(function ElementRows({ + events, + gridLayout, + filterMutedNotes, + eventReasonLabelMap, + scrollElement +}: Omit & { + scrollElement: HTMLElement +}) { + const rowCount = gridLayout ? Math.ceil(events.length / 3) : events.length + const virtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => scrollElement, + estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), + overscan: VIRTUAL_OVERSCAN, + getItemKey: (index) => (gridLayout ? `grid-${index}` : (events[index]?.id ?? `i-${index}`)) + }) + + return ( +
+ {virtualizer.getVirtualItems().map((vi) => ( +
+ {gridLayout ? ( +
+ {events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => ( + + ))} +
+ ) : ( + + )} +
+ ))} +
+ ) +}) + +/** Window- or element-scrolling virtual list for feed rows (and 3-column media grid by row). */ +export default memo(function VirtualizedFeedRows({ + events, + gridLayout, + filterMutedNotes, + eventReasonLabelMap, + useWindowScroll, + scrollElement, + scrollMarginTop +}: VirtualizedFeedRowsProps) { + if (events.length === 0) { + return null + } + + if (useWindowScroll) { + return ( + + ) + } + + if (!scrollElement) { + return null + } + + return ( + + ) +}) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index b3ad7335..4c73488c 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -77,8 +77,8 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select' -import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' -import MediaGridItem from '../MediaGridItem' +import { NoteCardLoadingSkeleton } from '../NoteCard' +import VirtualizedFeedRows from './VirtualizedFeedRows' const LIMIT = 150 // Per-shard REQ limit for timeline + loadMore (larger batches = fewer round-trips) const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds @@ -846,6 +846,7 @@ const NoteList = forwardRef( }, [hostPrimaryPageName, primaryPageCurrent, resetFeedClientFilterState]) const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey + const prevSubRequestsKeyForTimelineRef = useRef(null) const feedTimelineScopePrevRef = useRef(undefined) /** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */ @@ -1258,6 +1259,19 @@ const NoteList = forwardRef( [showFeedClientFilter, applyClientFeedFilter, filteredEvents] ) + const [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState(null) + const [feedVirtualScrollMarginTop, setFeedVirtualScrollMarginTop] = useState(0) + useLayoutEffect(() => { + const root = feedRootRef.current + if (!root) { + setFeedVirtualScrollParent(null) + setFeedVirtualScrollMarginTop(0) + return + } + setFeedVirtualScrollParent(getNearestScrollableAncestor(root)) + setFeedVirtualScrollMarginTop(root.offsetTop) + }, [timelineSubscriptionKey, refreshCount, clientFilteredEvents.length]) + const clientFilteredNewEvents = useMemo( () => showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents, @@ -3371,23 +3385,17 @@ const NoteList = forwardRef( {t('Feed full search empty')} ) : null} - {gridLayout ? ( -
- {clientFilteredEvents.map((event) => ( - - ))} -
- ) : ( - clientFilteredEvents.map((event) => ( - - )) - )} + {clientFilteredEvents.length > 0 ? ( + + ) : null} {listSourceEvents.length === 0 && !feedFullSearchActive && (loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? ( diff --git a/src/hooks/use-event-callback.ts b/src/hooks/use-event-callback.ts new file mode 100644 index 00000000..4329b33c --- /dev/null +++ b/src/hooks/use-event-callback.ts @@ -0,0 +1,11 @@ +import { useCallback, useRef } from 'react' + +/** + * Stable callback identity with always-fresh implementation (React `useEvent` pattern). + * Avoids stale closures without forcing unrelated consumers to re-render. + */ +export function useEventCallback(fn: (...args: A) => R): (...args: A) => R { + const ref = useRef(fn) + ref.current = fn + return useCallback((...args: A) => ref.current(...args), []) +} diff --git a/src/providers/DeepBrowsingProvider.tsx b/src/providers/DeepBrowsingProvider.tsx index 7337996f..44a26626 100644 --- a/src/providers/DeepBrowsingProvider.tsx +++ b/src/providers/DeepBrowsingProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useRef, useState } from 'react' +import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' type TDeepBrowsingContext = { deepBrowsing: boolean @@ -69,9 +69,10 @@ export function DeepBrowsingProvider({ } }, [active, scrollAreaRef]) - return ( - - {children} - + const value = useMemo( + () => ({ deepBrowsing, lastScrollTop }), + [deepBrowsing, lastScrollTop] ) + + return {children} } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index dbe4f1fd..fa73cf8d 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -49,8 +49,9 @@ import dayjs from 'dayjs' import { Event, kinds, VerifiedEvent, getEventHash, validateEvent } from 'nostr-tools' import * as nip19 from 'nostr-tools/nip19' import * as nip49 from 'nostr-tools/nip49' -import { NostrContext } from '@/providers/nostr-context' -import { useCallback, useEffect, useRef, useState } from 'react' +import { NostrContext, type TNostrContext } from '@/providers/nostr-context' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useEventCallback } from '@/hooks/use-event-callback' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { BunkerSigner } from './bunker.signer' @@ -1573,60 +1574,142 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { }) }, [account]) + const startLogin = useCallback(() => setOpenLoginDialog(true), []) + + const removeAccountStable = useEventCallback(removeAccount) + const switchAccountStable = useEventCallback(switchAccount) + const nsecLoginStable = useEventCallback(nsecLogin) + const ncryptsecLoginStable = useEventCallback(ncryptsecLogin) + const npubLoginStable = useEventCallback(npubLogin) + const nip07LoginStable = useEventCallback(nip07Login) + const bunkerLoginStable = useEventCallback(bunkerLogin) + const nostrConnectionLoginStable = useEventCallback(nostrConnectionLogin) + const publishStable = useEventCallback(publish) + const attemptDeleteStable = useEventCallback(attemptDelete) + const signHttpAuthStable = useEventCallback(signHttpAuth) + const nip04EncryptStable = useEventCallback(nip04Encrypt) + const nip04DecryptStable = useEventCallback(nip04Decrypt) + const checkLoginStable = useEventCallback(checkLogin) + const signEventStable = useEventCallback(signEvent) + const updateRelayListEventStable = useEventCallback(updateRelayListEvent) + const updateCacheRelayListEventStable = useEventCallback(updateCacheRelayListEvent) + const updateHttpRelayListEventStable = useEventCallback(updateHttpRelayListEvent) + const updateProfileEventStable = useEventCallback(updateProfileEvent) + const updateFollowListEventStable = useEventCallback(updateFollowListEvent) + const updateMuteListEventStable = useEventCallback(updateMuteListEvent) + const updateBookmarkListEventStable = useEventCallback(updateBookmarkListEvent) + const updateInterestListEventStable = useEventCallback(updateInterestListEvent) + const updateUserEmojiListEventStable = useEventCallback(updateUserEmojiListEvent) + const updateFavoriteRelaysEventStable = useEventCallback(updateFavoriteRelaysEvent) + const updateBlockedRelaysEventStable = useEventCallback(updateBlockedRelaysEvent) + const updateRssFeedListEventStable = useEventCallback(updateRssFeedListEvent) + + const nostrContextValue = useMemo( + (): TNostrContext => ({ + isInitialized, + isAccountSessionHydrating, + pubkey: account?.pubkey ?? null, + profile, + profileEvent, + relayList, + cacheRelayListEvent, + httpRelayListEvent, + followListEvent, + muteListEvent, + bookmarkListEvent, + interestListEvent, + favoriteRelaysEvent, + blockedRelaysEvent, + userEmojiListEvent, + rssFeedListEvent, + account, + accounts, + nsec, + ncryptsec, + switchAccount: switchAccountStable, + nsecLogin: nsecLoginStable, + ncryptsecLogin: ncryptsecLoginStable, + nip07Login: nip07LoginStable, + bunkerLogin: bunkerLoginStable, + nostrConnectionLogin: nostrConnectionLoginStable, + npubLogin: npubLoginStable, + removeAccount: removeAccountStable, + publish: publishStable, + attemptDelete: attemptDeleteStable, + signHttpAuth: signHttpAuthStable, + nip04Encrypt: nip04EncryptStable, + nip04Decrypt: nip04DecryptStable, + startLogin, + checkLogin: checkLoginStable, + signEvent: signEventStable, + updateRelayListEvent: updateRelayListEventStable, + updateCacheRelayListEvent: updateCacheRelayListEventStable, + updateHttpRelayListEvent: updateHttpRelayListEventStable, + updateProfileEvent: updateProfileEventStable, + updateFollowListEvent: updateFollowListEventStable, + updateMuteListEvent: updateMuteListEventStable, + updateBookmarkListEvent: updateBookmarkListEventStable, + updateInterestListEvent: updateInterestListEventStable, + updateUserEmojiListEvent: updateUserEmojiListEventStable, + updateFavoriteRelaysEvent: updateFavoriteRelaysEventStable, + updateBlockedRelaysEvent: updateBlockedRelaysEventStable, + updateRssFeedListEvent: updateRssFeedListEventStable, + requestAccountNetworkHydrate + }), + [ + isInitialized, + isAccountSessionHydrating, + account, + accounts, + attemptDeleteStable, + blockedRelaysEvent, + bookmarkListEvent, + bunkerLoginStable, + cacheRelayListEvent, + checkLoginStable, + favoriteRelaysEvent, + followListEvent, + httpRelayListEvent, + interestListEvent, + muteListEvent, + ncryptsec, + ncryptsecLoginStable, + nip04DecryptStable, + nip04EncryptStable, + nip07LoginStable, + nostrConnectionLoginStable, + npubLoginStable, + nsec, + nsecLoginStable, + profile, + profileEvent, + publishStable, + relayList, + removeAccountStable, + requestAccountNetworkHydrate, + rssFeedListEvent, + signEventStable, + signHttpAuthStable, + startLogin, + switchAccountStable, + updateBlockedRelaysEventStable, + updateBookmarkListEventStable, + updateCacheRelayListEventStable, + updateFavoriteRelaysEventStable, + updateFollowListEventStable, + updateHttpRelayListEventStable, + updateInterestListEventStable, + updateMuteListEventStable, + updateProfileEventStable, + updateRelayListEventStable, + updateRssFeedListEventStable, + updateUserEmojiListEventStable, + userEmojiListEvent + ] + ) + return ( - setOpenLoginDialog(true), - checkLogin, - signEvent, - updateRelayListEvent, - updateCacheRelayListEvent, - updateHttpRelayListEvent, - updateProfileEvent, - updateFollowListEvent, - updateMuteListEvent, - updateBookmarkListEvent, - updateInterestListEvent, - updateUserEmojiListEvent, - updateFavoriteRelaysEvent, - updateBlockedRelaysEvent, - updateRssFeedListEvent, - requestAccountNetworkHydrate - }} - > + {children} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 2bc766f7..739c77fd 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -2470,12 +2470,13 @@ class ClientService extends EventTarget { /** * Stream matching events to the UI immediately. Initial completion is either aggregate `oneose` from all * relays, or {@link firstRelayResultGraceMs} after the first event (whichever comes first). + * While still before EOSE, coalesce bursts onto one rAF so we do not sort the full buffer on every microtask. */ - let streamFlushMicrotask = false + let streamFlushRafId: number | null = null const flushStreamingSnapshot = () => { if (eosedAt) return const emit = () => { - streamFlushMicrotask = false + streamFlushRafId = null if (eosedAt) return if (needSort) { const sorted = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) @@ -2485,13 +2486,15 @@ class ClientService extends EventTarget { } } if (events.length <= 1) { - streamFlushMicrotask = false + if (streamFlushRafId != null) { + cancelAnimationFrame(streamFlushRafId) + streamFlushRafId = null + } emit() return } - if (!streamFlushMicrotask) { - streamFlushMicrotask = true - queueMicrotask(emit) + if (streamFlushRafId == null) { + streamFlushRafId = requestAnimationFrame(emit) } } @@ -2627,6 +2630,10 @@ class ClientService extends EventTarget { if (eosedAt != null) return clearFirstResultGraceTimer() + if (streamFlushRafId != null) { + cancelAnimationFrame(streamFlushRafId) + streamFlushRafId = null + } eosedAt = dayjs().unix() @@ -2713,6 +2720,10 @@ class ClientService extends EventTarget { timelineKey: key, closer: () => { clearFirstResultGraceTimer() + if (streamFlushRafId != null) { + cancelAnimationFrame(streamFlushRafId) + streamFlushRafId = null + } clearHttpTimelinePoll() onEvents = () => {} onNew = () => {} diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index c9139a00..bc16a12e 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -2798,27 +2798,31 @@ class IndexedDbService { if (uniq.length === 0) return [] await this.initPromise if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return [] - const out: Event[] = [] - await Promise.all( - uniq.map( - (id) => - new Promise((resolve, reject) => { - const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') - const get = tx.objectStore(StoreNames.EVENT_ARCHIVE).get(id) - get.onsuccess = () => { - const row = get.result as TArchivedEventRow | undefined - if (row?.value) out.push(row.value) - tx.commit() - resolve() - } - get.onerror = (e) => { - tx.commit() - reject(idbEventToError(e)) - } - }) - ) - ) - return out + return new Promise((resolve, reject) => { + const out: Event[] = [] + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') + const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) + let pending = uniq.length + const doneOne = () => { + pending -= 1 + if (pending === 0) { + tx.commit() + resolve(out) + } + } + for (const id of uniq) { + const get = store.get(id) + get.onsuccess = () => { + const row = get.result as TArchivedEventRow | undefined + if (row?.value) out.push(row.value) + doneOne() + } + get.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + } + }) } async deleteArchivedEvent(eventId: string): Promise {