diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx
index 03e8016c..ab2d76fd 100644
--- a/src/components/NoteCard/MainNoteCard.tsx
+++ b/src/components/NoteCard/MainNoteCard.tsx
@@ -3,6 +3,7 @@ import { ExtendedKind, isNip52CalendarCardKind } from '@/constants'
import { Separator } from '@/components/ui/separator'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link'
+import { preloadNotePageChunk } from '@/pages/secondary/NotePage/NotePageRoute'
import { useSmartNoteNavigationOptional } from '@/PageManager'
import client from '@/services/client.service'
import { Pin } from 'lucide-react'
@@ -85,6 +86,7 @@ function MainNoteCard({
{
// Don't navigate when user has selected text (e.g. for creating a highlight)
const sel = window.getSelection()
diff --git a/src/hooks/useFetchEvent.tsx b/src/hooks/useFetchEvent.tsx
index a83c5ef3..cc574ff6 100644
--- a/src/hooks/useFetchEvent.tsx
+++ b/src/hooks/useFetchEvent.tsx
@@ -1,9 +1,8 @@
-import { getNoteBech32Id } from '@/lib/event'
+import { resolveNoteEventSync } from '@/lib/resolve-note-event-sync'
import { resolveThreadContextEventFromLocalStores } from '@/lib/thread-context-local'
import { useIsEventDeleted } from '@/providers/DeletedEventProvider'
import { useReplyIngress } from '@/hooks/useReplyIngress'
import { eventService } from '@/services/client.service'
-import { navigationEventStore } from '@/services/navigation-event-store'
import { Event } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react'
@@ -15,8 +14,13 @@ export function useFetchEvent(
const isEventDeleted = useIsEventDeleted()
const { addReplies } = useReplyIngress()
const [error, setError] = useState
(null)
- const [event, setEvent] = useState(initialEvent)
- const [isFetching, setIsFetching] = useState(!initialEvent)
+ const [event, setEvent] = useState(() =>
+ eventId ? resolveNoteEventSync(eventId, initialEvent) : initialEvent
+ )
+ const [isFetching, setIsFetching] = useState(() => {
+ if (!eventId) return false
+ return !resolveNoteEventSync(eventId, initialEvent)
+ })
const [refetchToken, setRefetchToken] = useState(0)
const refetch = useCallback(() => {
@@ -41,34 +45,11 @@ export function useFetchEvent(
const skipShortcuts = refetchToken > 0
- // If we have an initial event that matches the eventId, use it and skip fetching
- const initialMatches =
- initialEvent &&
- (initialEvent.id === eventId ||
- (() => {
- try {
- return getNoteBech32Id(initialEvent) === eventId
- } catch {
- return false
- }
- })())
- if (!skipShortcuts && initialMatches && initialEvent) {
- if (!isEventDeleted(initialEvent)) {
- setEvent(initialEvent)
- addReplies([initialEvent])
- setIsFetching(false)
- }
- return () => {
- cancelled = true
- }
- }
-
- // Check navigation event store first (events passed through navigation) — peek so remounts still see it.
if (!skipShortcuts) {
- const navigationEvent = navigationEventStore.peekEvent(eventId)
- if (navigationEvent && !isEventDeleted(navigationEvent)) {
- setEvent(navigationEvent)
- addReplies([navigationEvent])
+ const syncHit = resolveNoteEventSync(eventId, initialEvent)
+ if (syncHit && !isEventDeleted(syncHit)) {
+ setEvent(syncHit)
+ addReplies([syncHit])
setIsFetching(false)
return () => {
cancelled = true
@@ -88,7 +69,7 @@ export function useFetchEvent(
if (!skipShortcuts) {
const fromLocal = await resolveThreadContextEventFromLocalStores(
eventId,
- initialMatches ? initialEvent : undefined
+ resolveNoteEventSync(eventId, initialEvent)
)
if (cancelled) return
if (fromLocal && !isEventDeleted(fromLocal)) {
diff --git a/src/lib/resolve-note-event-sync.ts b/src/lib/resolve-note-event-sync.ts
new file mode 100644
index 00000000..ff6a6b57
--- /dev/null
+++ b/src/lib/resolve-note-event-sync.ts
@@ -0,0 +1,27 @@
+import { getNoteBech32Id } from '@/lib/event'
+import client from '@/services/client.service'
+import { navigationEventStore } from '@/services/navigation-event-store'
+import type { Event } from 'nostr-tools'
+
+function initialEventMatchesId(initialEvent: Event, eventId: string): boolean {
+ if (initialEvent.id === eventId) return true
+ try {
+ return getNoteBech32Id(initialEvent) === eventId
+ } catch {
+ return false
+ }
+}
+
+/** Synchronous note lookup: navigation store → session cache (feed clicks seed both before the panel mounts). */
+export function resolveNoteEventSync(eventId: string | undefined, initialEvent?: Event): Event | undefined {
+ if (!eventId?.trim()) return initialEvent
+
+ if (initialEvent && initialEventMatchesId(initialEvent, eventId)) {
+ return initialEvent
+ }
+
+ const fromNav = navigationEventStore.peekEvent(eventId)
+ if (fromNav) return fromNav
+
+ return client.peekSessionCachedEvent(eventId.trim())
+}
diff --git a/src/pages/secondary/NotePage/NotePageInstantShell.tsx b/src/pages/secondary/NotePage/NotePageInstantShell.tsx
new file mode 100644
index 00000000..1683fe4e
--- /dev/null
+++ b/src/pages/secondary/NotePage/NotePageInstantShell.tsx
@@ -0,0 +1,128 @@
+import ContentPreview from '@/components/ContentPreview'
+import UserAvatar from '@/components/UserAvatar'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Separator } from '@/components/ui/separator'
+import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
+import {
+ getParentBech32Id,
+ getParentEventHexId,
+ getRootBech32Id,
+ getRootEventHexId
+} from '@/lib/event'
+import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
+import { resolveNoteEventSync } from '@/lib/resolve-note-event-sync'
+import client from '@/services/client.service'
+import type { Event } from 'nostr-tools'
+import { useTranslation } from 'react-i18next'
+
+function peekThreadContextEvent(bech32OrHex: string | undefined): Event | undefined {
+ if (!bech32OrHex?.trim()) return undefined
+ return client.peekSessionCachedEvent(bech32OrHex.trim())
+}
+
+function ThreadContextPreview({ event }: { event: Event }) {
+ return (
+
+ )
+}
+
+/**
+ * Shown while the lazy NotePage chunk loads. Renders the clicked note from the navigation/session
+ * cache when available so the secondary panel is not blank.
+ */
+export default function NotePageInstantShell({
+ id,
+ index,
+ hideTitlebar = false,
+ initialEvent
+}: {
+ id?: string
+ index?: number
+ hideTitlebar?: boolean
+ initialEvent?: Event
+}) {
+ const { t } = useTranslation()
+ const event = resolveNoteEventSync(id, initialEvent)
+
+ if (!event) {
+ return (
+
+
+
+ )
+ }
+
+ const cachedContext = getCachedThreadContextEvents(event)
+ const parentBech32 = getParentBech32Id(event)
+ const rootBech32 = getRootBech32Id(event)
+ const parentHex = getParentEventHexId(event)?.toLowerCase()
+ const rootHex = getRootEventHexId(event)?.toLowerCase()
+ const selfHex = event.id.toLowerCase()
+
+ const parentEvent =
+ cachedContext.find((e) => parentHex && e.id.toLowerCase() === parentHex) ??
+ peekThreadContextEvent(parentBech32)
+ const rootEvent =
+ cachedContext.find((e) => rootHex && e.id.toLowerCase() === rootHex) ??
+ peekThreadContextEvent(rootBech32)
+
+ const parentEventForStrip =
+ parentEvent && parentEvent.id.toLowerCase() !== selfHex ? parentEvent : undefined
+ const rootEventForStrip =
+ rootEvent && rootEvent.id.toLowerCase() !== selfHex ? rootEvent : undefined
+
+ return (
+
+
+ {rootEventForStrip && parentEventForStrip?.id !== rootEventForStrip.id && (
+
+ )}
+ {parentEventForStrip && (
+ <>
+
+
+ >
+ )}
+ {(rootEventForStrip || parentEventForStrip) &&
}
+
+
+
+ )
+}
diff --git a/src/pages/secondary/NotePage/NotePageRoute.tsx b/src/pages/secondary/NotePage/NotePageRoute.tsx
new file mode 100644
index 00000000..e532ad7f
--- /dev/null
+++ b/src/pages/secondary/NotePage/NotePageRoute.tsx
@@ -0,0 +1,23 @@
+import type { TPageRef } from '@/types'
+import type { Event } from 'nostr-tools'
+import { forwardRef, lazy, Suspense } from 'react'
+import NotePageInstantShell from './NotePageInstantShell'
+
+const NotePageLazy = lazy(() => import('./index'))
+
+/** Sync entry: instant shell while the lazy NotePage chunk loads. */
+const NotePageRoute = forwardRef<
+ TPageRef,
+ { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }
+>((props, ref) => (
+ }>
+
+
+))
+NotePageRoute.displayName = 'NotePageRoute'
+export default NotePageRoute
+
+/** Warm the note panel chunk on feed hover / pointer-down. */
+export function preloadNotePageChunk(): void {
+ void import('./index')
+}
diff --git a/src/routes.tsx b/src/routes.tsx
index 814ea2cb..52e70133 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -7,6 +7,7 @@ import {
type LazyExoticComponent,
type ReactElement
} from 'react'
+import NotePageRoute from './pages/secondary/NotePage/NotePageRoute'
/** Lazy + Suspense so importing `routes` does not sync-pull pages that depend on PageManager (breaks Vite HMR cycles). */
const FollowingListPageLazy = lazy(() => import('./pages/secondary/FollowingListPage'))
@@ -28,7 +29,6 @@ const PinListPageLazy = lazy(() => import('./pages/secondary/PinListPage'))
const ProfileBadgesListPageLazy = lazy(() => import('./pages/secondary/ProfileBadgesListPage'))
const InterestListPageLazy = lazy(() => import('./pages/secondary/InterestListPage'))
const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage'))
-const NotePageLazy = lazy(() => import('./pages/secondary/NotePage'))
const OthersRelaySettingsPageLazy = lazy(() => import('./pages/secondary/OthersRelaySettingsPage'))
const PostSettingsPageLazy = lazy(() => import('./pages/secondary/PostSettingsPage'))
const ProfileEditorPageLazy = lazy(() => import('./pages/secondary/ProfileEditorPage'))
@@ -60,7 +60,7 @@ function SR(C: LazyExoticComponent>): ReactElement {
)
}
-const notePageElement = SR(NotePageLazy)
+const notePageElement =
const noteListPageElement = SR(NoteListPageLazy)
const rssArticlePageElement = SR(RssArticlePageLazy)
diff --git a/vite.config.ts b/vite.config.ts
index 5ff69fdf..67dd7809 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -37,7 +37,11 @@ function fullReloadOnProvidersAndPages(): Plugin {
apply: 'serve',
handleHotUpdate({ file, server }) {
const normalized = file.replace(/\\/g, '/')
- if (normalized.includes('/src/providers/') || normalized.includes('/src/pages/')) {
+ if (
+ normalized.includes('/src/providers/') ||
+ normalized.includes('/src/pages/') ||
+ normalized.endsWith('/src/PageManager.tsx')
+ ) {
server.ws.send({ type: 'full-reload' })
return []
}
@@ -61,25 +65,38 @@ function blobFromLogArgs(args: unknown[]): string {
/**
* `http-proxy` logs `Error: connect ECONNREFUSED …` via `console.error`, bypassing Vite's `logger.error`.
*/
+const DEV_INDEX_RELAY_PROXY_PATH_MARKERS = [
+ '/api/languagetool',
+ '/v2/',
+ '/api/piper-tts',
+ '/api/translate',
+ '/sites',
+ '/dev-index-relay',
+ '/dev-cors-index-relay',
+ '/api/events'
+] as const
+
+function isDevProxyConnectivityNoise(blob: string): boolean {
+ return (
+ blob.includes('ECONNREFUSED') ||
+ blob.includes('ETIMEDOUT') ||
+ blob.includes('ECONNRESET') ||
+ blob.includes('EHOSTUNREACH')
+ )
+}
+
function isOptionalDevProxyConnRefusedNoise(args: unknown[]): boolean {
const blob = blobFromLogArgs(args)
- if (!blob.includes('ECONNREFUSED')) return false
+ if (!isDevProxyConnectivityNoise(blob)) return false
+ if (DEV_INDEX_RELAY_PROXY_PATH_MARKERS.some((m) => blob.includes(m))) return true
if (blob.includes('127.0.0.1:') || blob.includes('localhost:')) return true
return OPTIONAL_DEV_PROXY_LOOPBACK_PORTS.some((port) => new RegExp(`\\b:${port}\\b`).test(blob))
}
function isOptionalDevProxyHttpError(text: string): boolean {
if (!text.includes('http proxy error')) return false
- if (!text.includes('ECONNREFUSED')) return false
- if (
- text.includes('/api/languagetool') ||
- text.includes('/v2/') ||
- text.includes('/api/piper-tts') ||
- text.includes('/api/translate') ||
- text.includes('/sites') ||
- text.includes('/dev-index-relay') ||
- text.includes('/api/events')
- ) {
+ if (!isDevProxyConnectivityNoise(text)) return false
+ if (DEV_INDEX_RELAY_PROXY_PATH_MARKERS.some((m) => text.includes(m))) {
return true
}
return OPTIONAL_DEV_PROXY_LOOPBACK_PORTS.some((port) => text.includes(`127.0.0.1:${port}`))
@@ -259,7 +276,14 @@ export default defineConfig(({ mode }) => {
'/dev-index-relay': {
target: devIndexRelayTarget,
changeOrigin: true,
- rewrite: (p) => p.replace(/^\/dev-index-relay/, '') || '/'
+ timeout: 12_000,
+ proxyTimeout: 12_000,
+ rewrite: (p) => p.replace(/^\/dev-index-relay/, '') || '/',
+ configure: jsonProxyErrorHandler(502, {
+ ok: false,
+ error: 'dev_index_relay_unreachable',
+ hint: 'Start the local index relay (VITE_DEV_INDEX_RELAY_TARGET, default :4000) or disable that kind-10243 URL in dev'
+ })
},
/**
* Some public index relays (e.g. nos.lol) omit `Content-Type` from CORS preflight
@@ -270,7 +294,14 @@ export default defineConfig(({ mode }) => {
target: devCorsIndexRelayTarget,
changeOrigin: true,
secure: true,
- rewrite: (p) => p.replace(/^\/dev-cors-index-relay/, '') || '/'
+ timeout: 12_000,
+ proxyTimeout: 12_000,
+ rewrite: (p) => p.replace(/^\/dev-cors-index-relay/, '') || '/',
+ configure: jsonProxyErrorHandler(502, {
+ ok: false,
+ error: 'cors_index_relay_unreachable',
+ hint: 'Remote index relay unreachable — check network or VITE_DEV_CORS_INDEX_RELAY_TARGET'
+ })
}
}
},