fix: entry meta no title in breadcrumb and catch not found error

- Introduced a new higher-order component, withAppErrorBoundary, to wrap components with error boundary functionality.
- Created EntryNotFoundErrorFallback component to display a user-friendly message when an entry is not found.
- Updated error handling in EntryContent to utilize the new EntryNotFound error type.
- Added EntryContentFallback component to manage entry prefetching and loading states, enhancing user experience during data retrieval.

These changes improve error management and user feedback for entry-related issues.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-09-09 19:22:51 +08:00
parent fdf076f28b
commit b07e2de01e
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
15 changed files with 304 additions and 56 deletions

View File

@ -0,0 +1,31 @@
import type { FC } from "react"
import { createElement } from "react"
import type { ErrorComponentType } from "../errors/enum"
import { AppErrorBoundary } from "./AppErrorBoundary"
interface WithErrorBoundaryOptions {
errorType: ErrorComponentType | ErrorComponentType[]
height?: number | string
}
/**
* Higher-order component that wraps a component with AppErrorBoundary
* @param Component - The component to wrap with ErrorBoundary
* @param options - Configuration options for the ErrorBoundary wrapper
* @returns A new component wrapped with ErrorBoundary
*/
export function withAppErrorBoundary<P extends object>(
Component: FC<P>,
options: WithErrorBoundaryOptions,
): FC<P> {
const { errorType, height } = options
const WrappedComponent = (props: P) => {
return createElement(AppErrorBoundary, { errorType, height }, createElement(Component, props))
}
WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name || "Component"})`
return WrappedComponent as FC<P>
}

View File

@ -0,0 +1,51 @@
import { Logo } from "@follow/components/icons/logo.jsx"
import { Button } from "@follow/components/ui/button/index.js"
import type { FC } from "react"
import { useNavigate } from "react-router"
import { CustomSafeError } from "../../errors/CustomSafeError"
import type { AppErrorFallbackProps } from "../common/AppErrorBoundary"
import { useResetErrorWhenRouteChange } from "./helper"
const EntryNotFoundErrorFallback: FC<AppErrorFallbackProps> = ({ resetError, error }) => {
if (!(error instanceof EntryNotFound)) {
throw error
}
useResetErrorWhenRouteChange(resetError)
const navigate = useNavigate()
return (
<div className="bg-theme-background flex w-full flex-1 flex-col items-center justify-center rounded-md p-2">
<div className="center m-auto flex max-w-prose flex-col gap-4 text-center">
<div className="center mb-8 flex">
<Logo className="size-20" />
</div>
<p className="font-semibold">
The entry you're looking for could not be found. It may have been removed or the URL is
incorrect.
</p>
<div className="center mt-12 gap-4">
<Button
variant="outline"
onClick={() => {
navigate("/")
setTimeout(() => {
resetError()
}, 100)
}}
>
Back
</Button>
</div>
</div>
</div>
)
}
export default EntryNotFoundErrorFallback
export class EntryNotFound extends CustomSafeError {
constructor() {
super("Entry 404")
}
}

View File

@ -7,4 +7,5 @@ export enum ErrorComponentType {
FeedNotFound = "FeedNotFound",
// Section
RSSHubDiscoverError = "RSSHubDiscoverError",
EntryNotFound = "EntryNotFound",
}

View File

@ -7,6 +7,7 @@ const ErrorFallbackMap = {
[ErrorComponentType.Page]: lazy(() => import("./PageError")),
[ErrorComponentType.FeedNotFound]: lazy(() => import("./FeedNotFound")),
[ErrorComponentType.RSSHubDiscoverError]: lazy(() => import("./RSSHubError")),
[ErrorComponentType.EntryNotFound]: lazy(() => import("./EntryNotFound")),
}
export const getErrorFallback = (type: ErrorComponentType) => ErrorFallbackMap[type]

View File

@ -1,6 +1,5 @@
import { PanelSplitter } from "@follow/components/ui/divider/PanelSplitter.js"
import { views } from "@follow/constants"
import { usePrefetchEntryDetail } from "@follow/store/entry/hooks"
import { clsx, cn } from "@follow/utils/utils"
import { easeOut } from "motion/react"
import type { FC, PropsWithChildren } from "react"
@ -29,7 +28,7 @@ const EntryLayoutContentLegacy = () => {
const settingWideMode = useRealInWideMode()
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
usePrefetchEntryDetail(realEntryId)
const showEntryContent = !(
views.find((v) => v.view === view)?.wideMode ||
(settingWideMode && !realEntryId)

View File

@ -78,7 +78,7 @@ const AIEntryLayoutImpl = () => {
animate={{ translateY: 0, opacity: 1, scale: 1 }}
exit={{ translateY: "50px", opacity: 0, scale: 0.98 }}
transition={Spring.smooth(0.3)}
className="bg-theme-background pointer-events-auto relative h-0 flex-1"
className="bg-theme-background pointer-events-auto relative flex h-0 flex-1 flex-col"
>
<EntryContent entryId={realEntryId} className="h-full" />
</m.div>

View File

@ -34,6 +34,7 @@ import { useEntryContent } from "../../hooks"
import { getEntryContentLayout } from "../layouts"
import { SourceContentPanel } from "../SourceContentView"
import { EntryCommandShortcutRegister } from "./EntryCommandShortcutRegister"
import { EntryContentFallback } from "./EntryContentFallback"
import { EntryContentLoading } from "./EntryContentLoading"
import { EntryNoContent } from "./EntryNoContent"
import { EntryScrollingAndNavigationHandler } from "./EntryScrollingAndNavigationHandler.js"
@ -57,6 +58,7 @@ const EntryContentImpl: Component<EntryContentProps> = ({
return { feedId, inboxId: inboxHandle, title, url }
})
if (!entry) throw thenable
useTitle(entry.title)
@ -220,7 +222,13 @@ const EntryContentImpl: Component<EntryContentProps> = ({
</div>
)
}
export const EntryContent = memo(EntryContentImpl)
export const EntryContent: Component<EntryContentProps> = memo((props) => {
return (
<EntryContentFallback entryId={props.entryId}>
<EntryContentImpl {...props} />
</EntryContentFallback>
)
})
const EntryScrollArea: Component<{
scrollerRef: React.Ref<HTMLDivElement | null>

View File

@ -48,6 +48,7 @@ import { SourceContentPanel } from "../SourceContentView"
import { SupportCreator } from "../SupportCreator"
import { ContainerToc } from "./accessories/ContainerToc"
import { EntryCommandShortcutRegister } from "./EntryCommandShortcutRegister"
import { EntryContentFallback } from "./EntryContentFallback"
import { EntryContentLoading } from "./EntryContentLoading"
import { EntryNoContent } from "./EntryNoContent"
import { EntryRenderError } from "./EntryRenderError"
@ -244,7 +245,13 @@ const EntryContentImpl: Component<EntryContentProps> = ({
</>
)
}
export const EntryContent = memo(EntryContentImpl)
export const EntryContent: Component<EntryContentProps> = memo((props) => {
return (
<EntryContentFallback entryId={props.entryId}>
<EntryContentImpl {...props} />
</EntryContentFallback>
)
})
const EntryScrollArea: Component<{
scrollerRef: React.RefObject<HTMLDivElement | null>

View File

@ -1,8 +1,13 @@
import { withSuspense } from "@follow/utils"
import { withAppErrorBoundary } from "~/components/common/withAppErrorBoundary"
import { ErrorComponentType } from "~/components/errors/enum"
import { withFeature } from "~/lib/features"
import { EntryContent as EntryContentAI } from "./EntryContent.ai"
import { EntryContent as EntryContentLegacy } from "./EntryContent.legacy"
export const EntryContent = withSuspense(withFeature("ai")(EntryContentAI, EntryContentLegacy))
export const EntryContent = withAppErrorBoundary(
withFeature("ai")(EntryContentAI, EntryContentLegacy),
{
errorType: ErrorComponentType.EntryNotFound,
},
)

View File

@ -0,0 +1,40 @@
import { usePrefetchEntryDetail } from "@follow/store/entry/hooks"
import { memo, Suspense } from "react"
import { EntryNotFound } from "~/components/errors/EntryNotFound"
import { EntryContentLoading } from "./EntryContentLoading"
interface EntryContentFallbackProps {
entryId: string
children: React.ReactNode
}
/**
* Reusable fallback wrapper component that handles:
* 1. Entry prefetching and 404 detection
* 2. Suspense fallback with loading state
* 3. Error boundary for entry not found cases
*/
export const EntryContentFallback = memo(({ entryId, children }: EntryContentFallbackProps) => {
const { data: realEntry, isLoading: loadingRemoteEntry } = usePrefetchEntryDetail(entryId)
if (!loadingRemoteEntry && !realEntry) {
// 404
throw new EntryNotFound()
}
return (
<Suspense
fallback={
<div className="absolute inset-0 flex flex-1 items-center justify-center">
<EntryContentLoading />
</div>
}
>
{children}
</Suspense>
)
})
EntryContentFallback.displayName = "EntryContentFallback"

View File

@ -20,9 +20,14 @@ export const EntryTitleMetaHandler: Component<{
const feedTitle = feed?.title || inbox?.title
useEffect(() => {
if (entry?.title && feedTitle) {
setEntryTitleMeta({ entryTitle: entry.title, feedTitle, feedId: entry.feedId!, entryId })
}
if (!entry?.feedId) return
setEntryTitleMeta({
entryTitle: entry?.title || "",
feedTitle: feedTitle || "",
feedId: entry?.feedId || "",
entryId,
})
return () => {
setEntryTitleMeta(null)
}

View File

@ -227,7 +227,6 @@ export function EntryHeaderBreadcrumb() {
const { t } = useTranslation()
const view = useRouteParamsSelector((s) => s.view)
const viewName = views.find((v) => v.view === view)?.name
if (!meta) return null
return (
<div className="flex min-w-0 flex-1 overflow-hidden">
@ -241,10 +240,10 @@ export function EntryHeaderBreadcrumb() {
{/* Return Back Button */}
<button
type="button"
className="text-text-secondary no-drag-region hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex shrink-0 items-center rounded bg-transparent p-2"
className="text-text-secondary no-drag-region hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex shrink-0 items-center rounded-full bg-transparent p-2"
onClick={() => navigate({ entryId: null })}
>
<i className="i-mingcute-close-fill size-4" />
<i className="i-mingcute-close-line size-5" />
</button>
{viewName && (
<div className="flex items-center">
@ -261,31 +260,41 @@ export function EntryHeaderBreadcrumb() {
<ViewSubscriptionsDropdown view={view} onNavigate={navigate} />
</div>
)}
{Slash}
<div className="flex items-center">
<button
type="button"
className={cn(
"text-text-secondary no-drag-region hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors",
{meta && (
<>
{Slash}
<div className="flex items-center">
<button
type="button"
className={cn(
"text-text-secondary no-drag-region hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors",
)}
onClick={() => navigate({ entryId: null, feedId: meta.feedId })}
title={meta.feedTitle}
>
<span className="truncate">{meta.feedTitle}</span>
</button>
<FeedEntriesDropdown
feedId={meta.feedId}
currentEntryId={entryId}
onNavigate={navigate}
/>
</div>
{!!meta.entryTitle && (
<>
{Slash}
<span
className="text-text truncate px-1.5 py-0.5 text-sm"
title={meta.entryTitle}
>
{meta.entryTitle}
</span>
</>
)}
onClick={() => navigate({ entryId: null, feedId: meta.feedId })}
title={meta.feedTitle}
>
<span className="truncate">{meta.feedTitle}</span>
</button>
<FeedEntriesDropdown
feedId={meta.feedId}
currentEntryId={entryId}
onNavigate={navigate}
/>
</div>
{Slash}
<span className="text-text truncate px-1.5 py-0.5 text-sm" title={meta.entryTitle}>
{meta.entryTitle}
</span>
</>
)}
</div>
</nav>
</div>

View File

@ -0,0 +1,91 @@
import type * as React from "react"
import type { CSSProperties } from "react"
import { useEffect, useLayoutEffect, useMemo, useRef } from "react"
import { createPortal } from "react-dom"
type Target = HTMLElement | null | string | (() => HTMLElement | null | undefined) | undefined
export interface ReparentPortalProps {
target: Target
children: React.ReactNode
hostClassName?: string
hostStyle?: CSSProperties
hostTag?: keyof HTMLElementTagNameMap
debugName?: string
/**
* Behavior when target is null:
* - true (default): keep the last parent container, do not unmount the subtree
* - false: remove the host from DOM (subtree is unmounted)
*/
keepLastParentOnNull?: boolean
}
function resolveTarget(target: Target): HTMLElement | null {
if (target == null) return null
if (typeof target === "string") return document.querySelector(target) as HTMLElement | null
if (typeof target === "function") return target() ?? null
return target
}
export function ReparentPortal({
target,
children,
hostClassName,
hostStyle,
hostTag = "div",
debugName,
keepLastParentOnNull = true,
}: ReparentPortalProps) {
// Keep Fixed hostEl(Portal's container is always it)
const hostEl = useMemo(() => {
const el = document.createElement(hostTag)
if (debugName) el.dataset.reparentPortal = debugName
return el
}, [hostTag, debugName])
const lastParentRef = useRef<HTMLElement | null>(null)
// Sync styles/classes to hostEl
useEffect(() => {
if (hostClassName != null) hostEl.className = hostClassName
if (hostStyle != null) Object.assign(hostEl.style, hostStyle)
}, [hostEl, hostClassName, hostStyle])
// Move the same hostEl to the target container
useLayoutEffect(() => {
const nextParent = resolveTarget(target)
if (nextParent) {
if (hostEl.parentNode !== nextParent) {
nextParent.append(hostEl)
lastParentRef.current = nextParent
}
return
}
// target is null
if (!keepLastParentOnNull) {
const prev = lastParentRef.current
if (prev && hostEl.parentNode === prev) {
hostEl.remove()
}
lastParentRef.current = null
}
}, [target, hostEl, keepLastParentOnNull])
// When unmounting, remove hostEl from DOM
useLayoutEffect(() => {
return () => {
const parent = hostEl.parentNode
if (parent) hostEl.remove()
}
}, [hostEl])
// Critical fix: no longer depends on "attached" secondary rendering, directly render to hostEl
// If you want to unmount the subtree when target is null and keepLastParentOnNull=false, you can check here:
if (!keepLastParentOnNull && !resolveTarget(target) && !lastParentRef.current) {
return null
}
return createPortal(children, hostEl)
}

View File

@ -6,32 +6,32 @@ const ACCENT_COLOR_MAP: Record<AccentColor, { light: string; dark: string }> = {
dark: "#FF5C00",
},
blue: {
light: "#0066FF", // Brighter blue while maintaining contrast with white text
dark: "#3B82F6", // Darker blue for dark theme
light: "#5CA9F2",
dark: "#2F78E8",
},
green: {
light: "#2DB84D", // Brighter green while maintaining contrast with white text
dark: "#22C55E", // Darker green for dark theme
light: "#4CD7A5",
dark: "#1FA97A",
},
purple: {
light: "#9F52C7", // Brighter purple while maintaining contrast with white text
dark: "#A855F7", // Darker purple for dark theme
light: "#B07BEF",
dark: "#8A3DCC",
},
pink: {
light: "#E62E85", // Brighter pink while maintaining contrast with white text
dark: "#EC4899", // Darker pink for dark theme
light: "#F266A8",
dark: "#C63C82",
},
red: {
light: "#DC3526", // Brighter red while maintaining contrast with white text
dark: "#EF4444", // Darker red for dark theme
light: "#E84A3C",
dark: "#C22E28",
},
yellow: {
light: "#E6A700", // Brighter yellow while maintaining contrast with white text
dark: "#EAB308", // Darker yellow for dark theme
light: "#F7B500",
dark: "#D99800",
},
gray: {
light: "#757580", // Brighter gray while maintaining contrast with white text
dark: "#94A3B8", // Darker gray for dark theme
light: "#8A96A3",
dark: "#5C6673",
},
}

View File

@ -1,4 +1,4 @@
import type { ComponentType, ReactElement } from "react"
import type { ComponentType, FC, ReactElement } from "react"
import { createElement, Suspense } from "react"
type FallbackOptions = ReactElement | ComponentType
@ -14,9 +14,9 @@ interface WithSuspenseOptions {
* @returns A new component wrapped with Suspense
*/
export function withSuspense<P extends object>(
Component: ComponentType<P>,
Component: FC<P>,
options: WithSuspenseOptions = {},
): ComponentType<P> {
): FC<P> {
const { fallback } = options
const WrappedComponent = (props: P) => {
@ -27,5 +27,5 @@ export function withSuspense<P extends object>(
WrappedComponent.displayName = `withSuspense(${Component.displayName || Component.name || "Component"})`
return WrappedComponent
return WrappedComponent as FC<P>
}