AgentSkillsCN

frontend-development

为 Flare Stack 博客的测试架构与最佳实践提供指导。无论是编写测试用例、调试测试,还是借助 Vitest 与 Cloudflare Workers 池重构测试代码,此技能都能助您事半功倍。

SKILL.md
--- frontmatter
name: frontend-development
description: Frontend development patterns for the Flare Stack Blog. Use when implementing data fetching with TanStack Query, creating route loaders, building infinite scroll, adding skeleton/pending states, or organizing UI components.

Frontend Development Patterns

This skill covers TanStack Query patterns, route loaders, component organization, and frontend best practices.

Data Fetching Architecture

The project follows TanStack Start / TanStack Query standard practices for seamless SSR and client-side caching.

1. Query Definition

Organize query definitions in features/<name>/queries/index.ts. Use a Query Key Factory pattern to centralize and type-safe your cache keys.

typescript
// features/posts/queries/index.ts
import { queryOptions, infiniteQueryOptions } from "@tanstack/react-query";
import { findPostBySlugFn, getPostsFn } from "../api/posts.api";

// 1. Define Query Key Factory (static arrays + functions)
export const POSTS_KEYS = {
  all: ["posts"] as const,

  // Parent keys (static arrays for prefix invalidation)
  lists: ["posts", "list"] as const,
  details: ["posts", "detail"] as const,
  adminLists: ["posts", "admin-list"] as const,

  // Child keys (functions for specific queries)
  list: (filters?: { tagName?: string }) =>
    ["posts", "list", filters] as const,
  detail: (idOrSlug: number | string) =>
    ["posts", "detail", idOrSlug] as const,
  adminList: (params: GetPostsInput) =>
    ["posts", "admin-list", params] as const,
};

// 2. Define Query Options
export function postBySlugQuery(slug: string) {
  return queryOptions({
    queryKey: POSTS_KEYS.detail(slug),
    queryFn: () => findPostBySlugFn({ data: { slug } }),
  });
}

export function postsInfiniteQuery(tag?: string) {
  return infiniteQueryOptions({
    queryKey: POSTS_KEYS.list({ tagName: tag }),
    queryFn: ({ pageParam }) =>
      getPostsFn({ data: { cursor: pageParam, tag } }),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: undefined,
  });
}

Key Pattern:

  • Parent keys are static arrays (no ()) — used for prefix-based cache invalidation
  • Child keys are functions — used for specific queries with parameters
typescript
// Invalidation (no parentheses - static reference)
queryClient.invalidateQueries({ queryKey: POSTS_KEYS.adminLists });

// Query (with parentheses - function call)
useQuery({ queryKey: POSTS_KEYS.detail(postId) });

2. Route Loader (routes/<path>.tsx)

Use loader functions with ensureQueryData() or prefetchQuery() for SSR:

typescript
// $slug.tsx
export const Route = createFileRoute("/_public/post/$slug")({
  loader: async ({ context, params }) => {
    const post = await context.queryClient.ensureQueryData(
      postBySlugQuery(params.slug),
    );
    if (!post) throw notFound();
    return post;
  },
  head: ({ loaderData: post }) => ({
    meta: [{ title: post?.title }],
  }),
  component: PostPage,
});

Key Points:

  • ensureQueryData(): Fetches and caches if not present
  • prefetchQuery() / prefetchInfiniteQuery(): For background prefetching
  • loaderData: Available in head function for dynamic SEO

3. Component Data Access

Use useSuspenseQuery or useSuspenseInfiniteQuery in components. Since loaders prefetch, this is typically synchronous:

typescript
function PostComponent() {
  const { slug } = Route.useParams();
  const { data: post } = useSuspenseQuery(postBySlugQuery(slug));

  return <article>{post.content}</article>;
}

4. Infinite Scroll Pattern

Use IntersectionObserver to observe a bottom sentinel element:

typescript
function PostList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useSuspenseInfiniteQuery(postsInfiniteQuery());

  const sentinelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 0.1 }
    );

    if (sentinelRef.current) observer.observe(sentinelRef.current);
    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    <>
      {data.pages.flatMap((page) => page.posts.map((post) => (
        <PostCard key={post.id} post={post} />
      )))}

      <div ref={sentinelRef} className="h-4" />
      {isFetchingNextPage && <LoadingSpinner />}
    </>
  );
}

Skeleton / Pending States

Define pendingComponent for critical routes to show skeletons during client navigation:

typescript
export const Route = createFileRoute("/_public/post/$slug")({
  pendingComponent: PostSkeleton,
  // ...
});

function PostSkeleton() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-muted rounded w-3/4 mb-4" />
      <div className="h-4 bg-muted rounded w-full mb-2" />
      <div className="h-4 bg-muted rounded w-5/6" />
    </div>
  );
}

Component Organization

src/components/ Directory

SubdirectoryPurpose
ui/Atomic UI components (Button, Input, Card). Use cva for variants.
common/Shared business components (ThemeProvider, LoadingFallback)
layout/Layout components (Header, Footer, Sidebar)

Feature Components

Components specific to a feature belong in features/<name>/components/.

Custom Hooks (src/hooks/)

Reusable hooks for cross-feature functionality:

HookPurpose
use-debounce.tsGeneric debouncing
use-media-query.tsResponsive media queries
use-navigate-back.tsNavigation with fallback
use-active-toc.tsTable of contents active state

Naming Conventions

TypeConventionExample
Components & Hookskebab-case filespost-item.tsx, use-debounce.ts
React ComponentsPascalCasePostItem
Variables & FunctionscamelCasegetPosts
Types & InterfacesPascalCasePostItemProps