From b07e2de01ea1369c3d84bdfaf4c24cc637d3c8a6 Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 9 Sep 2025 19:22:51 +0800 Subject: [PATCH] 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 --- .../common/withAppErrorBoundary.tsx | 31 +++++++ .../src/components/errors/EntryNotFound.tsx | 51 +++++++++++ .../renderer/src/components/errors/enum.ts | 1 + .../renderer/src/components/errors/index.ts | 1 + .../entry-content/EntryLayoutContent.tsx | 3 +- .../modules/entry-column/AIEntryLayout.tsx | 2 +- .../entry-content/EntryContent.ai.tsx | 10 +- .../entry-content/EntryContent.legacy.tsx | 9 +- .../components/entry-content/EntryContent.tsx | 11 ++- .../entry-content/EntryContentFallback.tsx | 40 ++++++++ .../entry-content/EntryTitleMetaHandler.tsx | 11 ++- .../internal/EntryHeaderBreadcrumb.tsx | 63 +++++++------ .../components/src/common/ReparentPortal.tsx | 91 +++++++++++++++++++ .../internal/shared/src/settings/constants.ts | 28 +++--- packages/internal/utils/src/react.ts | 8 +- 15 files changed, 304 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/components/common/withAppErrorBoundary.tsx create mode 100644 apps/desktop/layer/renderer/src/components/errors/EntryNotFound.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContentFallback.tsx create mode 100644 packages/internal/components/src/common/ReparentPortal.tsx diff --git a/apps/desktop/layer/renderer/src/components/common/withAppErrorBoundary.tsx b/apps/desktop/layer/renderer/src/components/common/withAppErrorBoundary.tsx new file mode 100644 index 000000000..8dc65d063 --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/common/withAppErrorBoundary.tsx @@ -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

( + Component: FC

, + options: WithErrorBoundaryOptions, +): FC

{ + 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

+} diff --git a/apps/desktop/layer/renderer/src/components/errors/EntryNotFound.tsx b/apps/desktop/layer/renderer/src/components/errors/EntryNotFound.tsx new file mode 100644 index 000000000..ab96a8344 --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/errors/EntryNotFound.tsx @@ -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 = ({ resetError, error }) => { + if (!(error instanceof EntryNotFound)) { + throw error + } + + useResetErrorWhenRouteChange(resetError) + const navigate = useNavigate() + return ( +

+
+
+ +
+

+ The entry you're looking for could not be found. It may have been removed or the URL is + incorrect. +

+ +
+ +
+
+
+ ) +} +export default EntryNotFoundErrorFallback + +export class EntryNotFound extends CustomSafeError { + constructor() { + super("Entry 404") + } +} diff --git a/apps/desktop/layer/renderer/src/components/errors/enum.ts b/apps/desktop/layer/renderer/src/components/errors/enum.ts index a0286b574..4517ba162 100644 --- a/apps/desktop/layer/renderer/src/components/errors/enum.ts +++ b/apps/desktop/layer/renderer/src/components/errors/enum.ts @@ -7,4 +7,5 @@ export enum ErrorComponentType { FeedNotFound = "FeedNotFound", // Section RSSHubDiscoverError = "RSSHubDiscoverError", + EntryNotFound = "EntryNotFound", } diff --git a/apps/desktop/layer/renderer/src/components/errors/index.ts b/apps/desktop/layer/renderer/src/components/errors/index.ts index aac49986a..4e4b9919b 100644 --- a/apps/desktop/layer/renderer/src/components/errors/index.ts +++ b/apps/desktop/layer/renderer/src/components/errors/index.ts @@ -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] diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx index a05595e6a..1eed2afad 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx @@ -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) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx index 22e98d65a..d81f411e3 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx @@ -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" > diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx index 5dabdbab1..61d629fc6 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx @@ -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 = ({ return { feedId, inboxId: inboxHandle, title, url } }) + if (!entry) throw thenable useTitle(entry.title) @@ -220,7 +222,13 @@ const EntryContentImpl: Component = ({ ) } -export const EntryContent = memo(EntryContentImpl) +export const EntryContent: Component = memo((props) => { + return ( + + + + ) +}) const EntryScrollArea: Component<{ scrollerRef: React.Ref diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx index 85a444157..5fee0bd84 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx @@ -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 = ({ ) } -export const EntryContent = memo(EntryContentImpl) +export const EntryContent: Component = memo((props) => { + return ( + + + + ) +}) const EntryScrollArea: Component<{ scrollerRef: React.RefObject diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx index 4eb7b61cb..91dcc91f7 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx @@ -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, + }, +) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContentFallback.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContentFallback.tsx new file mode 100644 index 000000000..b173bd3ad --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContentFallback.tsx @@ -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 ( + + + + } + > + {children} + + ) +}) + +EntryContentFallback.displayName = "EntryContentFallback" diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryTitleMetaHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryTitleMetaHandler.tsx index f8efdcba4..c109e69c5 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryTitleMetaHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryTitleMetaHandler.tsx @@ -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) } diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderBreadcrumb.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderBreadcrumb.tsx index 1de3d5940..a96635b2c 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderBreadcrumb.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderBreadcrumb.tsx @@ -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 (
@@ -241,10 +240,10 @@ export function EntryHeaderBreadcrumb() { {/* Return Back Button */} {viewName && (
@@ -261,31 +260,41 @@ export function EntryHeaderBreadcrumb() {
)} - {Slash} -
- + + +
+ + {!!meta.entryTitle && ( + <> + {Slash} + + {meta.entryTitle} + + )} - onClick={() => navigate({ entryId: null, feedId: meta.feedId })} - title={meta.feedTitle} - > - {meta.feedTitle} - - - -
- - {Slash} - - - {meta.entryTitle} - + + )} diff --git a/packages/internal/components/src/common/ReparentPortal.tsx b/packages/internal/components/src/common/ReparentPortal.tsx new file mode 100644 index 000000000..874609df6 --- /dev/null +++ b/packages/internal/components/src/common/ReparentPortal.tsx @@ -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(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) +} diff --git a/packages/internal/shared/src/settings/constants.ts b/packages/internal/shared/src/settings/constants.ts index 6b2834559..143f1ca70 100644 --- a/packages/internal/shared/src/settings/constants.ts +++ b/packages/internal/shared/src/settings/constants.ts @@ -6,32 +6,32 @@ const ACCENT_COLOR_MAP: Record = { 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", }, } diff --git a/packages/internal/utils/src/react.ts b/packages/internal/utils/src/react.ts index 8ea5b9d89..a55dcbe9c 100644 --- a/packages/internal/utils/src/react.ts +++ b/packages/internal/utils/src/react.ts @@ -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

( - Component: ComponentType

, + Component: FC

, options: WithSuspenseOptions = {}, -): ComponentType

{ +): FC

{ const { fallback } = options const WrappedComponent = (props: P) => { @@ -27,5 +27,5 @@ export function withSuspense

( WrappedComponent.displayName = `withSuspense(${Component.displayName || Component.name || "Component"})` - return WrappedComponent + return WrappedComponent as FC

}