diff --git a/web/app/components/apps/app-card-skeleton.tsx b/web/app/components/apps/app-card-skeleton.tsx
new file mode 100644
index 0000000000..806f19973a
--- /dev/null
+++ b/web/app/components/apps/app-card-skeleton.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import * as React from 'react'
+import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
+
+type AppCardSkeletonProps = {
+ count?: number
+}
+
+/**
+ * Skeleton placeholder for App cards during loading states.
+ * Matches the visual layout of AppCard component.
+ */
+export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps) => {
+ return (
+ <>
+ {Array.from({ length: count }).map((_, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+ >
+ )
+})
+
+AppCardSkeleton.displayName = 'AppCardSkeleton'
diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx
index 003b463595..290a73fc7c 100644
--- a/web/app/components/apps/list.tsx
+++ b/web/app/components/apps/list.tsx
@@ -27,7 +27,9 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
+import { cn } from '@/utils/classnames'
import AppCard from './app-card'
+import { AppCardSkeleton } from './app-card-skeleton'
import Empty from './empty'
import Footer from './footer'
import useAppsQueryState from './hooks/use-apps-query-state'
@@ -45,7 +47,7 @@ const List = () => {
const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const router = useRouter()
- const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
+ const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [activeTab, setActiveTab] = useQueryState(
'category',
@@ -89,6 +91,7 @@ const List = () => {
const {
data,
isLoading,
+ isFetching,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
@@ -172,6 +175,8 @@ const List = () => {
const pages = data?.pages ?? []
const hasAnyApp = (pages[0]?.total ?? 0) > 0
+ // Show skeleton during initial load or when refetching with no previous data
+ const showSkeleton = isLoading || (isFetching && pages.length === 0)
return (
<>
@@ -205,23 +210,34 @@ const List = () => {
/>
- {hasAnyApp
- ? (
-
- {isCurrentWorkspaceEditor
- &&
}
- {pages.map(({ data: apps }) => apps.map(app => (
-
- )))}
-
- )
- : (
-
- {isCurrentWorkspaceEditor
- && }
-
-
- )}
+
+ {(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
+
+ )}
+ {(() => {
+ if (showSkeleton)
+ return
+
+ if (hasAnyApp) {
+ return pages.flatMap(({ data: apps }) => apps).map(app => (
+
+ ))
+ }
+
+ // No apps - show empty state
+ return
+ })()}
+
{isCurrentWorkspaceEditor && (
import('@/app/components/app/create-fro
export type CreateAppCardProps = {
className?: string
+ isLoading?: boolean
onSuccess?: () => void
ref: React.RefObject
selectedAppType?: string
@@ -33,6 +34,7 @@ export type CreateAppCardProps = {
const CreateAppCard = ({
ref,
className,
+ isLoading = false,
onSuccess,
selectedAppType,
}: CreateAppCardProps) => {
@@ -56,7 +58,11 @@ const CreateAppCard = ({
return (
{t('createApp', { ns: 'app' })}
diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts
index 0f6c4a64ac..d16d44af20 100644
--- a/web/service/use-apps.ts
+++ b/web/service/use-apps.ts
@@ -12,6 +12,7 @@ import type {
} from '@/models/app'
import type { App, AppModeEnum } from '@/types/app'
import {
+ keepPreviousData,
useInfiniteQuery,
useQuery,
useQueryClient,
@@ -107,6 +108,7 @@ export const useInfiniteAppList = (params: AppListParams, options?: { enabled?:
queryFn: ({ pageParam = normalizedParams.page }) => get
('/apps', { params: { ...normalizedParams, page: pageParam } }),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: normalizedParams.page,
+ placeholderData: keepPreviousData,
...options,
})
}