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:
parent
fdf076f28b
commit
b07e2de01e
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -7,4 +7,5 @@ export enum ErrorComponentType {
|
|||
FeedNotFound = "FeedNotFound",
|
||||
// Section
|
||||
RSSHubDiscoverError = "RSSHubDiscoverError",
|
||||
EntryNotFound = "EntryNotFound",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue