Browse Source

fixed popstate paging

imwald
Silberengel 1 month ago
parent
commit
a501af521c
  1. 307
      src/PageManager.tsx
  2. 2
      src/components/NoteDrawer/index.tsx
  3. 12
      src/components/NoteList/index.tsx
  4. 24
      src/components/ui/sheet.tsx
  5. 13
      src/constants.ts
  6. 31
      src/services/client.service.ts

307
src/PageManager.tsx

@ -30,10 +30,12 @@ import {
Suspense, Suspense,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState useState
} from 'react' } from 'react'
import { flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp' import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp'
import { import {
@ -250,21 +252,6 @@ function buildRssArticleUrl(articleUrl: string, currentPage: TPrimaryPageName |
} }
/** True for secondary routes that show an RSS / web article in the panel (contextual or bare). */ /** True for secondary routes that show an RSS / web article in the panel (contextual or bare). */
function secondaryUrlIsRssArticle(url: string): boolean {
let path = url.split('?')[0].split('#')[0]
try {
if (path.startsWith('http://') || path.startsWith('https://')) {
path = new URL(path).pathname
}
} catch {
/* keep path */
}
return (
/^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/rss-item\/[^/?#]+/.test(path) ||
/^\/rss-item\/[^/?#]+/.test(path)
)
}
function replaceHistoryWithPrimaryPageUrl( function replaceHistoryWithPrimaryPageUrl(
page: TPrimaryPageName, page: TPrimaryPageName,
props?: { spell?: string } | Record<string, unknown> | null props?: { spell?: string } | Record<string, unknown> | null
@ -876,12 +863,22 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
]) ])
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([]) const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
/** Latest stack for popstate / pop() — avoids stale length when history and React state race. */
const secondaryStackRef = useRef<TStackItem[]>([])
useLayoutEffect(() => {
secondaryStackRef.current = secondaryStack
}, [secondaryStack])
const [primaryNoteView, setPrimaryNoteViewState] = useState<ReactNode | null>(null) const [primaryNoteView, setPrimaryNoteViewState] = useState<ReactNode | null>(null)
const [primaryViewType, setPrimaryViewType] = useState<'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null>(null) const [primaryViewType, setPrimaryViewType] = useState<'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null>(null)
const [savedPrimaryPage, setSavedPrimaryPage] = useState<TPrimaryPageName | null>(null) const [savedPrimaryPage, setSavedPrimaryPage] = useState<TPrimaryPageName | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null) const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null)
const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode()) const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode())
/** Latest primary page for async callbacks (drawer-close timer) without resubscribing effects on every primary change. */
const currentPrimaryPageRef = useRef<TPrimaryPageName>(currentPrimaryPage)
useLayoutEffect(() => {
currentPrimaryPageRef.current = currentPrimaryPage
}, [currentPrimaryPage])
const navigationCounterRef = useRef(0) const navigationCounterRef = useRef(0)
const primaryPanelRefreshRef = useRef<(() => void) | null>(null) const primaryPanelRefreshRef = useRef<(() => void) | null>(null)
const registerPrimaryPanelRefresh = useCallback((fn: (() => void) | null) => { const registerPrimaryPanelRefresh = useCallback((fn: (() => void) | null) => {
@ -973,9 +970,39 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const closeDrawer = useCallback(() => { const closeDrawer = useCallback(() => {
if (!drawerOpen) return // Already closed if (!drawerOpen) return // Already closed
setDrawerOpen(false) setDrawerOpen(false)
// Don't clear noteId here - let onOpenChange handle it when animation completes // Don't clear noteId here — scheduled in the drawer-close effect after the sheet animation.
}, [drawerOpen]) }, [drawerOpen])
const ignorePopStateRef = useRef(false) const ignorePopStateRef = useRef(false)
/** Avoid duplicating history entries when drawer/mode deps re-run the PageManager effect. */
const historySeedDoneRef = useRef(false)
/** When set before closing the note drawer, replaceState uses this URL instead of buildPrimaryPageUrl (popstate edge cases). */
const pendingDrawerCloseUrlRef = useRef<string | null>(null)
useEffect(() => {
const useDrawer = isSmallScreen || panelMode === 'single'
if (!useDrawer || drawerOpen || !drawerNoteId) return
const timer = window.setTimeout(() => {
const pending = pendingDrawerCloseUrlRef.current
pendingDrawerCloseUrlRef.current = null
if (pending) {
window.history.replaceState(null, '', pending)
} else {
const page = currentPrimaryPageRef.current
replaceHistoryWithPrimaryPageUrl(
page,
primaryPagePropsRef.current.get(page) as { spell?: string } | undefined
)
}
setDrawerNoteId(null)
setDrawerInitialEvent(null)
}, 350)
return () => {
window.clearTimeout(timer)
pendingDrawerCloseUrlRef.current = null
}
}, [drawerOpen, drawerNoteId, isSmallScreen, panelMode])
// Handle browser back button for primary note view // Handle browser back button for primary note view
useEffect(() => { useEffect(() => {
@ -996,6 +1023,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}, [primaryNoteView, drawerOpen]) }, [primaryNoteView, drawerOpen])
useEffect(() => { useEffect(() => {
if (!historySeedDoneRef.current) {
historySeedDoneRef.current = true
if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) { if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) {
window.history.replaceState( window.history.replaceState(
null, null,
@ -1039,12 +1068,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const pushNoteUrlOnStack = (noteUrl: string) => { const pushNoteUrlOnStack = (noteUrl: string) => {
setSecondaryStack((prevStack) => { setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, noteUrl)) return prevStack if (isCurrentPage(prevStack, noteUrl)) return prevStack
const { newStack, newItem } = pushNewPageToStack( const { newStack, newItem } = pushNewPageToStack(prevStack, noteUrl, maxStackSize)
prevStack,
noteUrl,
maxStackSize,
window.history.state?.index
)
if (newItem) { if (newItem) {
window.history.replaceState({ index: newItem.index, url: noteUrl }, '', noteUrl) window.history.replaceState({ index: newItem.index, url: noteUrl }, '', noteUrl)
} }
@ -1131,12 +1155,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
setSecondaryStack((prevStack) => { setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, contextualRssUrl)) return prevStack if (isCurrentPage(prevStack, contextualRssUrl)) return prevStack
const { newStack, newItem } = pushNewPageToStack( const { newStack, newItem } = pushNewPageToStack(prevStack, contextualRssUrl, maxStackSize)
prevStack,
contextualRssUrl,
maxStackSize,
window.history.state?.index
)
if (newItem) { if (newItem) {
window.history.replaceState({ index: newItem.index, url: contextualRssUrl }, '', contextualRssUrl) window.history.replaceState({ index: newItem.index, url: contextualRssUrl }, '', contextualRssUrl)
} }
@ -1194,12 +1213,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
setSecondaryStack((prevStack) => { setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, url)) return prevStack if (isCurrentPage(prevStack, url)) return prevStack
const { newStack, newItem } = pushNewPageToStack( const { newStack, newItem } = pushNewPageToStack(prevStack, url, maxStackSize)
prevStack,
url,
maxStackSize,
window.history.state?.index
)
if (newItem) { if (newItem) {
window.history.replaceState({ index: newItem.index, url }, '', url) window.history.replaceState({ index: newItem.index, url }, '', url)
} }
@ -1275,6 +1289,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// which is handled elsewhere // which is handled elsewhere
} }
} }
}
const onPopState = (e: PopStateEvent) => { const onPopState = (e: PopStateEvent) => {
if (ignorePopStateRef.current) { if (ignorePopStateRef.current) {
@ -1282,11 +1297,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
return return
} }
const closeModal = modalManager.pop() // If the side panel has frames, this popstate is almost certainly stack navigation — do not let
if (closeModal) { // modalManager steal it (history.forward + return), which leaves the URL changed and the panel stale.
ignorePopStateRef.current = true if (secondaryStackRef.current.length === 0) {
window.history.forward() const closeModal = modalManager.pop()
return if (closeModal) {
ignorePopStateRef.current = true
window.history.forward()
return
}
} }
let state = e.state as { index: number; url: string } | null let state = e.state as { index: number; url: string } | null
@ -1337,16 +1356,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Only in single-pane mode or mobile // Only in single-pane mode or mobile
if (!noteIdToShow && drawerOpen && (isSmallScreen || panelMode === 'single')) { if (!noteIdToShow && drawerOpen && (isSmallScreen || panelMode === 'single')) {
setDrawerOpen(false) setDrawerOpen(false)
setTimeout(() => {
setDrawerNoteId(null)
setDrawerInitialEvent(null)
// Restore URL to current primary page
const pageUrl = buildPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
window.history.replaceState(null, '', pageUrl)
}, 350)
} }
setSecondaryStack((pre) => { setSecondaryStack((pre) => {
@ -1389,8 +1398,44 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
return newStack return newStack
} }
if (state.index === currentIndex) { if (state.index === currentIndex && currentItem) {
return pre const historyState = state
const urlMatches =
currentItem.url === historyState.url ||
secondaryPanelUrlsMatch(currentItem.url, historyState.url)
if (urlMatches) {
return pre
}
const j = pre.findIndex(
(item) =>
item.index === historyState.index &&
(item.url === historyState.url ||
secondaryPanelUrlsMatch(item.url, historyState.url))
)
if (j >= 0) {
const sliced = pre.slice(0, j + 1)
const nt = sliced[sliced.length - 1]
if (nt && !nt.component) {
const { component, ref } = findAndCreateComponent(nt.url, nt.index)
if (component) {
nt.component = component
nt.ref = ref
}
}
return sliced
}
const built = findAndCreateComponent(historyState.url, historyState.index)
if (built.component) {
return [
{
index: historyState.index,
url: historyState.url,
component: built.component,
ref: built.ref
}
]
}
return syncSecondaryStackWhenPopStateStateIsNull(pre, historyState.url)
} }
// Go back // Go back
@ -1415,15 +1460,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isPrimaryPage) { if (isPrimaryPage) {
// On mobile or single-pane: if drawer is open, close it // On mobile or single-pane: if drawer is open, close it
if (drawerOpen && (isSmallScreen || panelMode === 'single')) { if (drawerOpen && (isSmallScreen || panelMode === 'single')) {
pendingDrawerCloseUrlRef.current = restoredPrimaryBrowserUrl(pathname, state!.url)
setDrawerOpen(false) setDrawerOpen(false)
const historyUrl = state!.url
setTimeout(() => {
setDrawerNoteId(null)
setDrawerInitialEvent(null)
// Ensure URL matches the primary page (preserve /spells?spell=)
const pageUrl = restoredPrimaryBrowserUrl(pathname, historyUrl)
window.history.replaceState(null, '', pageUrl)
}, 350)
} }
return [] return []
} }
@ -1435,9 +1473,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
if (noteId) { if (noteId) {
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
// Single-pane mode or mobile: open in drawer // Single-pane / mobile: align stack with history (returning `pre` left stale UI).
openDrawer(noteId) openDrawer(noteId)
return pre const built = findAndCreateComponent(state.url, state.index)
if (built.component) {
return [
{ index: state.index, url: state.url, component: built.component, ref: built.ref }
]
}
return syncSecondaryStackWhenPopStateStateIsNull(pre, state.url)
} }
// Double-pane mode: continue with stack creation // Double-pane mode: continue with stack creation
} }
@ -1507,7 +1551,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
return () => { return () => {
window.removeEventListener('popstate', onPopState) window.removeEventListener('popstate', onPopState)
} }
}, [isSmallScreen, openDrawer, closeDrawer, panelMode, drawerOpen]) }, [
isSmallScreen,
openDrawer,
closeDrawer,
panelMode,
drawerOpen,
drawerNoteId /* keep in sync while drawer stays open (quote→note); stale id broke Back in single-pane */
])
// Listen for tab state changes from components // Listen for tab state changes from components
useEffect(() => { useEffect(() => {
@ -1656,17 +1707,19 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
const popSecondaryPage = () => { const popSecondaryPage = () => {
const stackLen = secondaryStackRef.current.length
// In double-pane mode, never open drawer - just pop from stack // In double-pane mode, never open drawer - just pop from stack
if (panelMode === 'double' && !isSmallScreen) { if (panelMode === 'double' && !isSmallScreen) {
if (secondaryStack.length === 1) { if (stackLen === 1) {
const closingUrl = secondaryStack[secondaryStack.length - 1]?.url ?? '' flushSync(() => {
setSecondaryStack([]) setSecondaryStack([])
if (secondaryUrlIsRssArticle(closingUrl)) { })
replaceHistoryWithPrimaryPageUrl( secondaryStackRef.current = []
currentPrimaryPage, replaceHistoryWithPrimaryPageUrl(
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined currentPrimaryPage,
) primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
} )
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage) const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
@ -1678,44 +1731,36 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
})) }))
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
} else if (secondaryStack.length > 1) { } else if (stackLen > 1) {
// Must use real history navigation: replaceState + slice desyncs URL from the session stack // Must use real history navigation: replaceState + slice desyncs URL from the session stack
// (e.g. note → highlight → Back: bar shows the article but the panel still shows the highlight). // (e.g. note → highlight → Back: bar shows the article but the panel still shows the highlight).
// popstate applies {@link onPopState} so stack and URL stay aligned with pushState indices. // popstate applies {@link onPopState} so stack and URL stay aligned with pushState indices.
window.history.back() window.history.back()
} else { } else {
// Just go back in history - popstate will handle stack update // Stack empty but user hit back/close: align URL to primary without history.go(-1), which
window.history.go(-1) // changes the address bar but does not run our stack sync (panel/URL desync + double-click).
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
} }
return return
} }
// Single-pane mode or mobile: check if drawer is open and stack is empty - close drawer instead // Single-pane mode or mobile: check if drawer is open and stack is empty - close drawer instead
if (drawerOpen && secondaryStack.length === 0) { if (drawerOpen && stackLen === 0) {
// Close drawer and reveal the background page // Close drawer and reveal the background page
setDrawerOpen(false) setDrawerOpen(false)
setTimeout(() => {
setDrawerNoteId(null)
setDrawerInitialEvent(null)
}, 350)
return return
} }
// On mobile or single-pane: if stack has 1 item and drawer is open, close drawer and clear stack // On mobile or single-pane: if stack has 1 item and drawer is open, close drawer and clear stack
if ((isSmallScreen || panelMode === 'single') && secondaryStack.length === 1 && drawerOpen) { if ((isSmallScreen || panelMode === 'single') && stackLen === 1 && drawerOpen) {
const closingUrl = secondaryStack[secondaryStack.length - 1]?.url ?? ''
setDrawerOpen(false) setDrawerOpen(false)
setTimeout(() => { flushSync(() => {
setDrawerNoteId(null) setSecondaryStack([])
setDrawerInitialEvent(null) })
if (secondaryUrlIsRssArticle(closingUrl)) { secondaryStackRef.current = []
replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
}
}, 350)
setSecondaryStack([])
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage) const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
@ -1729,15 +1774,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
return return
} }
if (secondaryStack.length === 1) { if (stackLen === 1) {
const closingUrl = secondaryStack[secondaryStack.length - 1]?.url ?? '' flushSync(() => {
setSecondaryStack([]) setSecondaryStack([])
if (secondaryUrlIsRssArticle(closingUrl)) { })
replaceHistoryWithPrimaryPageUrl( secondaryStackRef.current = []
currentPrimaryPage, replaceHistoryWithPrimaryPageUrl(
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined currentPrimaryPage,
) primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
} )
const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage) const savedFeedState = savedFeedStateRef.current.get(currentPrimaryPage)
@ -1748,21 +1793,24 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
})) }))
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
} else if (secondaryStack.length > 1) { } else if (stackLen > 1) {
// Same as double-pane: let popstate shrink the stack so it matches history. // Same as double-pane: let popstate shrink the stack so it matches history.
window.history.back() window.history.back()
} else { } else {
window.history.go(-1) replaceHistoryWithPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
} }
} }
const clearSecondaryPages = () => { const clearSecondaryPages = () => {
if (secondaryStack.length === 0) return if (secondaryStackRef.current.length === 0) return
// Capture the length before clearing const stackLength = secondaryStackRef.current.length
const stackLength = secondaryStack.length flushSync(() => {
// Clear the state immediately for instant navigation setSecondaryStack([])
setSecondaryStack([]) })
// Also update browser history to keep it in sync secondaryStackRef.current = []
window.history.go(-stackLength) window.history.go(-stackLength)
} }
@ -1873,23 +1921,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<NoteDrawer <NoteDrawer
open={drawerOpen} open={drawerOpen}
initialEvent={drawerInitialEvent} initialEvent={drawerInitialEvent}
onOpenChange={(open) => { onOpenChange={setDrawerOpen}
setDrawerOpen(open)
// Only clear noteId when Sheet is fully closed (after animation completes)
// Use 350ms to ensure animation is fully done (animation is 300ms)
if (!open) {
// Restore URL to current primary page
const pageUrl = buildPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
window.history.replaceState(null, '', pageUrl)
setTimeout(() => {
setDrawerNoteId(null)
setDrawerInitialEvent(null)
}, 350)
}
}}
noteId={drawerNoteId} noteId={drawerNoteId}
/> />
)} )}
@ -2007,30 +2039,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<NoteDrawer <NoteDrawer
open={drawerOpen} open={drawerOpen}
initialEvent={drawerInitialEvent} initialEvent={drawerInitialEvent}
onOpenChange={(open) => { onOpenChange={setDrawerOpen}
setDrawerOpen(open)
// Only clear noteId when Sheet is fully closed (after animation completes)
// Use 350ms to ensure animation is fully done (animation is 300ms)
if (!open) {
// Restore URL to current primary page
const pageUrl = buildPrimaryPageUrl(
currentPrimaryPage,
primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined
)
window.history.replaceState(null, '', pageUrl)
setTimeout(() => {
setDrawerNoteId(null)
setDrawerInitialEvent(null)
}, 350)
}
}}
noteId={drawerNoteId} noteId={drawerNoteId}
/> />
)} )}
{/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */} {/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */}
{panelMode === 'single' && !isSmallScreen && secondaryStack.length > 0 && !drawerOpen && ( {panelMode === 'single' && !isSmallScreen && secondaryStack.length > 0 && !drawerOpen && (
<Sheet <Sheet
open={true} open={true}
registerWithModalManager={false}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
// Close drawer and go back // Close drawer and go back

2
src/components/NoteDrawer/index.tsx

@ -44,7 +44,7 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }:
if (!displayNoteId) return null if (!displayNoteId) return null
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange} registerWithModalManager={false}>
<SheetContent side="right" className="w-full sm:max-w-[1042px] overflow-y-auto p-0"> <SheetContent side="right" className="w-full sm:max-w-[1042px] overflow-y-auto p-0">
<div className="min-h-full"> <div className="min-h-full">
<NotePage <NotePage

12
src/components/NoteList/index.tsx

@ -383,6 +383,8 @@ const NoteList = forwardRef(
* That stacks subscriptions on strict relays (e.g. 10 subs) and triggers rejections / rate limits. * That stacks subscriptions on strict relays (e.g. 10 subs) and triggers rejections / rate limits.
*/ */
const timelineEstablishedCloserRef = useRef<(() => void) | null>(null) const timelineEstablishedCloserRef = useRef<(() => void) | null>(null)
/** Bumps on each timeline effect run so Strict Mode / fast remount does not stack subscribeTimeline waves. */
const timelineEffectGenerationRef = useRef(0)
/** Session snapshot was written to state; log once after commit (see feed-paint layout effect). */ /** Session snapshot was written to state; log once after commit (see feed-paint layout effect). */
const feedPaintSessionPendingRef = useRef(false) const feedPaintSessionPendingRef = useRef(false)
/** Relay / one-shot data was written to state; log once after commit. */ /** Relay / one-shot data was written to state; log once after commit. */
@ -1111,6 +1113,9 @@ const NoteList = forwardRef(
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh]) useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh])
useEffect(() => { useEffect(() => {
const effectGen = ++timelineEffectGenerationRef.current
const timelineEffectStale = () => effectGen !== timelineEffectGenerationRef.current
timelineEstablishedCloserRef.current?.() timelineEstablishedCloserRef.current?.()
timelineEstablishedCloserRef.current = null timelineEstablishedCloserRef.current = null
@ -1164,6 +1169,7 @@ const NoteList = forwardRef(
let effectActive = true let effectActive = true
async function init() { async function init() {
if (timelineEffectStale()) return undefined
feedPaintSessionPendingRef.current = false feedPaintSessionPendingRef.current = false
feedPaintRelayPendingRef.current = false feedPaintRelayPendingRef.current = false
feedPaintRelayMetaRef.current = null feedPaintRelayMetaRef.current = null
@ -1292,6 +1298,7 @@ const NoteList = forwardRef(
if (oneShotFetch) { if (oneShotFetch) {
setHasMore(false) setHasMore(false)
try { try {
if (timelineEffectStale()) return undefined
const firstRelayGraceResolved = const firstRelayGraceResolved =
oneShotFirstRelayGraceMs === undefined oneShotFirstRelayGraceMs === undefined
? FIRST_RELAY_RESULT_GRACE_MS ? FIRST_RELAY_RESULT_GRACE_MS
@ -1306,7 +1313,7 @@ const NoteList = forwardRef(
}) })
) )
) )
if (!effectActive) return undefined if (!effectActive || timelineEffectStale()) return undefined
if (batches.some((b) => b.length > 0)) { if (batches.some((b) => b.length > 0)) {
feedRelayReturnedAnyEventRef.current = true feedRelayReturnedAnyEventRef.current = true
} }
@ -1397,6 +1404,7 @@ const NoteList = forwardRef(
| undefined | undefined
try { try {
if (timelineEffectStale()) return undefined
// Opening many relay subs can exceed 2s on spell feeds; a short race // Opening many relay subs can exceed 2s on spell feeds; a short race
// rejects, the catch closes the late subscription, and the list stays empty after refresh. // rejects, the catch closes the late subscription, and the list stays empty after refresh.
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
@ -1569,7 +1577,7 @@ const NoteList = forwardRef(
) )
const result = await Promise.race([timelineSubscribePromise, timeoutPromise]) const result = await Promise.race([timelineSubscribePromise, timeoutPromise])
if (!effectActive) { if (!effectActive || timelineEffectStale()) {
result.closer() result.closer()
return undefined return undefined
} }

24
src/components/ui/sheet.tsx

@ -7,11 +7,28 @@ import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import modalManager from '@/services/modal-manager.service' import modalManager from '@/services/modal-manager.service'
const Sheet = ({ children, open, onOpenChange, ...props }: SheetPrimitive.DialogProps) => { type SheetProps = SheetPrimitive.DialogProps & {
/**
* When true (default), the sheet registers with {@link modalManager} so the global popstate
* handler can close it on browser back. Disable for overlays that are driven by the same
* history stack as the SPA (e.g. note drawer); otherwise back pops the modal first and fights
* {@link PageManager}'s secondary navigation.
*/
registerWithModalManager?: boolean
}
const Sheet = ({
children,
open,
onOpenChange,
registerWithModalManager = true,
...props
}: SheetProps) => {
const [innerOpen, setInnerOpen] = React.useState(open ?? false) const [innerOpen, setInnerOpen] = React.useState(open ?? false)
const id = React.useMemo(() => `sheet-${randomString()}`, []) const id = React.useMemo(() => `sheet-${randomString()}`, [])
React.useEffect(() => { React.useEffect(() => {
if (!registerWithModalManager) return
if (open) { if (open) {
modalManager.register(id, () => { modalManager.register(id, () => {
onOpenChange?.(false) onOpenChange?.(false)
@ -19,9 +36,10 @@ const Sheet = ({ children, open, onOpenChange, ...props }: SheetPrimitive.Dialog
} else { } else {
modalManager.unregister(id) modalManager.unregister(id)
} }
}, [open]) }, [open, registerWithModalManager])
React.useEffect(() => { React.useEffect(() => {
if (!registerWithModalManager) return
if (open !== undefined) { if (open !== undefined) {
return return
} }
@ -33,7 +51,7 @@ const Sheet = ({ children, open, onOpenChange, ...props }: SheetPrimitive.Dialog
} else { } else {
modalManager.unregister(id) modalManager.unregister(id)
} }
}, [innerOpen]) }, [innerOpen, open, registerWithModalManager])
return ( return (
<SheetPrimitive.Root <SheetPrimitive.Root

13
src/constants.ts

@ -46,8 +46,16 @@ export const MAX_CONCURRENT_RELAY_CONNECTIONS = 10
/** /**
* Max concurrent live REQ subscriptions on a single relay. Some relays enforce 10 SUBs; stay under * Max concurrent live REQ subscriptions on a single relay. Some relays enforce 10 SUBs; stay under
* the advertised cap to avoid "too many subscriptions" NOTICEs when other clients or shards overlap. * the advertised cap to avoid "too many subscriptions" NOTICEs when other clients or shards overlap.
* Use 7 so overlapping timeline waves / auth resubscribe still stay below 10.
*/ */
export const MAX_CONCURRENT_SUBS_PER_RELAY = 9 export const MAX_CONCURRENT_SUBS_PER_RELAY = 7
/**
* How many timeline shards may open relay subscriptions at once. Each shard sends one REQ per relay
* in its list; with 6 shards in parallel a popular relay can see 6+ SUBs from this app alone, and a
* second feed wave (remount / strict mode) pushes past strict relay caps (e.g. nostr.sovbit.host 10).
*/
export const TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY = 2
/** Max relays to publish each event to (outboxes first, then targets' inboxes, then extras). */ /** Max relays to publish each event to (outboxes first, then targets' inboxes, then extras). */
export const MAX_PUBLISH_RELAYS = 20 export const MAX_PUBLISH_RELAYS = 20
@ -216,7 +224,8 @@ export const SOCIAL_KIND_BLOCKED_RELAY_URLS = [
'wss://aggr.nostr.land', 'wss://aggr.nostr.land',
'wss://search.nos.today', 'wss://search.nos.today',
'wss://trending.nostr.wine', 'wss://trending.nostr.wine',
'wss://sendit.nosflare.com' 'wss://sendit.nosflare.com',
'wss://relay.nip46.com'
] ]
/** Relays that reject #e (and similar) tag filters; skip for reply/quote/stats fetches. */ /** Relays that reject #e (and similar) tag filters; skip for reply/quote/stats fetches. */

31
src/services/client.service.ts

@ -7,6 +7,7 @@ import {
relayFilterIncludesSocialKindBlockedKind, relayFilterIncludesSocialKindBlockedKind,
SOCIAL_KIND_BLOCKED_RELAY_URLS, SOCIAL_KIND_BLOCKED_RELAY_URLS,
MAX_PUBLISH_RELAYS, MAX_PUBLISH_RELAYS,
TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY,
OUTBOX_PUBLISH_RETRY_DELAY_MS, OUTBOX_PUBLISH_RETRY_DELAY_MS,
NIP66_DISCOVERY_RELAY_URLS, NIP66_DISCOVERY_RELAY_URLS,
PROFILE_FETCH_RELAY_URLS, PROFILE_FETCH_RELAY_URLS,
@ -121,6 +122,27 @@ import { MacroService, createBookstrService } from './client-macro.service'
type TTimelineRef = [string, number] type TTimelineRef = [string, number]
/** Run async work on each item with at most `concurrency` tasks in flight; results match `items` order. */
async function mapPoolWithConcurrency<T, R>(
items: readonly T[],
concurrency: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
if (items.length === 0) return []
const c = Math.max(1, Math.min(concurrency, items.length))
const results = new Array<R>(items.length)
let next = 0
const worker = async () => {
while (true) {
const i = next++
if (i >= items.length) break
results[i] = await fn(items[i]!, i)
}
}
await Promise.all(Array.from({ length: c }, () => worker()))
return results
}
class ClientService extends EventTarget { class ClientService extends EventTarget {
static instance: ClientService static instance: ClientService
@ -1537,9 +1559,11 @@ class ClientService extends EventTarget {
} }
: undefined : undefined
const subs = await Promise.all( const subs = await mapPoolWithConcurrency(
subRequests.map(({ urls, filter }, shardIndex) => { subRequests,
return this._subscribeTimeline( TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY,
({ urls, filter }, shardIndex) =>
this._subscribeTimeline(
urls, urls,
filter, filter,
{ {
@ -1579,7 +1603,6 @@ class ClientService extends EventTarget {
} }
} }
) )
})
) )
const key = this.generateMultipleTimelinesKey(subRequests) const key = this.generateMultipleTimelinesKey(subRequests)

Loading…
Cancel
Save