refactor: consolidate shared helpers, prune deps, server-first RSC#176
refactor: consolidate shared helpers, prune deps, server-first RSC#176
Conversation
…user slug Add prefetchable wenqu books query options, resolveMediaUrl, getUserSlug, uniqueById, and useAuthResultEffect helpers; delete duplicate hooks/profile.ts and migrate ~25 call sites to use the new shared utilities. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove 13 unused/redundant packages (react-switch, react-select, react-blurhash, @sentry/tracing, recharts, react-transition-group, intersection-observer, @uptrace/node, opentelemetry-node-metrics, classnames, @heroicons/react, @types/heapdump, @types/react-transition-group) and migrate 14 files from @heroicons/react to lucide-react equivalents. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Delete legacy button/index.tsx and button/button-simple.tsx; migrate all remaining callers (webhooks create, newbie content, otp-box) to the feature-complete button/button.tsx with the standard isLoading prop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tton
Consolidate 5 nearly-identical settings/{exports,orders,account,web,webhooks}
layouts into a shared SettingsSectionLayout server component; extract
ExportTriggerButton for the 3 export destination cards and replace each
ad-hoc submit button + inline SVG spinner with the shared Button isLoading.
Delete legacy unused export.email.tsx.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…g wrappers Replace 5 verbatim glassmorphism class strings in comments pages with the shared glassCardClass constant. Replace the dedup-by-id reduce pattern in square/profile clipping lists with uniqueById helper. Collapse 10 identical loading.tsx files down to single-line re-exports of CenterPageLoading. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract shared server-side prefetch helper and auth page shell; migrate auth-v4, github callback, and the auth callback layout to reuse them. Removes the radial-gradient CSS variable duplication and three copies of the public-data + wenqu book prefetch pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… to RSC Remove 'use client' from button.tsx, payment/canceled/content.tsx, and link-indicator.tsx where no client APIs are used. Convert premium/badge and profile-analytics to async server components using getTranslation. Split privacy page and auth/signin layout into server shells that render a small <PageTrack> client island (and TrackedGithubLink for signin). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…in-viewport Replace the react-cool-inview dependency in ListFooter with lake-ui's built-in useInViewport hook and drop react-cool-inview from deps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request refactors the authentication and dashboard pages by centralizing layout components, simplifying data prefetching, and standardizing UI elements. It replaces @heroicons/react with lucide-react, introduces utility functions for image resolution and user slug generation, and removes unused dependencies. Feedback includes improving the safety of domain length checks, adding guards for empty prefetch queries, simplifying deduplication logic using Set, and sorting query keys to ensure cache stability.
| export function getUserSlug( | ||
| p: Pick<ProfileQuery['me'], 'id' | 'domain'> | ||
| ): string | number { | ||
| return p.domain.length > 2 ? p.domain : p.id |
There was a problem hiding this comment.
Accessing p.domain.length is unsafe if p.domain is null or undefined. Given that GraphQL fields are often nullable, adding a truthiness check or using optional chaining prevents potential runtime crashes if the user profile data is incomplete.
| return p.domain.length > 2 ? p.domain : p.id | |
| return (p.domain && p.domain.length > 2) ? p.domain : p.id |
| data.data?.public.books.map((x) => x.doubanId).filter(isValidDoubanId) ?? [] | ||
|
|
||
| const rq = getReactQueryClient() | ||
| await rq.prefetchQuery(wenquBooksByIdsQueryOptions(dbIds)) |
There was a problem hiding this comment.
It is more efficient to guard the prefetchQuery call with a check for dbIds.length > 0. If the array is empty, the prefetch is unnecessary and would result in a network request with an empty query parameter, which is inconsistent with the guards used in other parts of the codebase (e.g., in square/page.tsx).
if (dbIds.length > 0) {
await rq.prefetchQuery(wenquBooksByIdsQueryOptions(dbIds))
}| return doubanIds.filter(isValidDoubanId).reduce<string[]>((acc, x) => { | ||
| if (!acc.includes(x)) { | ||
| acc.push(x) | ||
| } | ||
| return acc | ||
| }, []) |
There was a problem hiding this comment.
| export function wenquBooksByIdsQueryOptions(dbIds: string[]) { | ||
| return { | ||
| queryKey: ['wenqu', 'books', 'dbIds', dbIds] as const, | ||
| queryFn: () => | ||
| wenquRequest<WenquSearchResponse>( | ||
| `/books/search?dbIds=${dbIds.join('&dbIds=')}` | ||
| ), | ||
| staleTime: duration3Days, | ||
| gcTime: duration3Days, | ||
| } | ||
| } |
There was a problem hiding this comment.
Sorting the dbIds array before using it in the queryKey and request URL is highly recommended. Since TanStack Query uses the key for caching, different permutations of the same IDs would lead to cache misses and redundant network requests. Sorting ensures that the cache key remains stable regardless of the input order, improving both client-side and server-side cache efficiency.
| export function wenquBooksByIdsQueryOptions(dbIds: string[]) { | |
| return { | |
| queryKey: ['wenqu', 'books', 'dbIds', dbIds] as const, | |
| queryFn: () => | |
| wenquRequest<WenquSearchResponse>( | |
| `/books/search?dbIds=${dbIds.join('&dbIds=')}` | |
| ), | |
| staleTime: duration3Days, | |
| gcTime: duration3Days, | |
| } | |
| } | |
| export function wenquBooksByIdsQueryOptions(dbIds: string[]) { | |
| const sortedIds = [...dbIds].sort() | |
| return { | |
| queryKey: ['wenqu', 'books', 'dbIds', sortedIds] as const, | |
| queryFn: () => | |
| wenquRequest<WenquSearchResponse>( | |
| `/books/search?dbIds=${sortedIds.join('&dbIds=')}` | |
| ), | |
| staleTime: duration3Days, | |
| gcTime: duration3Days, | |
| } | |
| } |
| const avatar = avatarUrl?.startsWith('http') | ||
| ? avatarUrl | ||
| : `${CDN_DEFAULT_DOMAIN}/${avatarUrl}` | ||
| const avatar = resolveMediaUrl(avatarUrl) |
There was a problem hiding this comment.
🔴 resolveMediaUrl(undefined) returns empty string, crashing Next.js Image component
In navigation-bar/avatar.tsx, avatarUrl has type string | undefined. The old code ${CDN_DEFAULT_DOMAIN}/${avatarUrl} would produce a non-empty string like "https://cdn.example.com/undefined" when avatarUrl was undefined — a broken URL but not a crash. The new code calls resolveMediaUrl(avatarUrl) which returns '' for undefined input (src/utils/image.ts:11-13). This empty string is then passed directly to <Image src={avatar}> at line 25, and Next.js Image component throws a runtime error when src is an empty string.
Prompt for agents
In src/components/navigation-bar/avatar.tsx, resolveMediaUrl(avatarUrl) returns an empty string when avatarUrl is undefined. This empty string is passed to the Next.js Image component which requires a non-empty src string.
Either:
1. Add a guard/fallback in the component: if avatar is empty, render a placeholder div instead of Image, or use a default avatar URL.
2. Or change resolveMediaUrl to return a default placeholder image URL instead of empty string when path is falsy.
The simplest fix would be to add a conditional render: if avatar is empty, show a placeholder element instead of the Image component.
Was this helpful? React with 👍 or 👎 to provide feedback.
| }, [ | ||
| called, | ||
| loading, | ||
| error, | ||
| authResponse, | ||
| navigate, | ||
| options.skipZeroIdGuard, | ||
| getRedirectPath, | ||
| ]) |
There was a problem hiding this comment.
🔴 Unstable getRedirectPath callback in useEffect deps causes repeated auth side effects
The useAuthResultEffect hook includes getRedirectPath in its useEffect dependency array (line 90). All callers (useAuthBy3rdPartSuccessed, useLoginV3Successed, useAuthByPhoneSuccessed) pass inline arrow functions that create new references on every render. Once auth succeeds and all guards pass, every subsequent re-render creates a new getRedirectPath → triggers the effect → calls onAuthEnd again (which calls syncLoginStateToServer, writes to localStorage/sessionStorage) and queues another navigate. The old per-function implementations had stable deps [called, loading, error, authResponse, navigate] that wouldn't re-trigger after auth completed.
| }, [ | |
| called, | |
| loading, | |
| error, | |
| authResponse, | |
| navigate, | |
| options.skipZeroIdGuard, | |
| getRedirectPath, | |
| ]) | |
| }, [ | |
| called, | |
| loading, | |
| error, | |
| authResponse, | |
| navigate, | |
| options.skipZeroIdGuard, | |
| // getRedirectPath is intentionally omitted — callers pass inline arrows | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| ]) |
Was this helpful? React with 👍 or 👎 to provide feedback.
Code ReviewThis is a well-organized refactoring PR with clear goals. The DRY improvements, dependency pruning, and RSC conversions are all valuable. Here are my findings organized by priority. 🔴 Issues Worth Fixing Before Merge1. No error handling in The Apollo query and React Query prefetch both run without any try/catch: const data = await client.query<PublicDataQuery>({ ... }) // can throw
await rq.prefetchQuery(wenquBooksByIdsQueryOptions(dbIds)) // can throwThis function is called by 2. Empty `/books/search?dbIds=${dbIds.join('&dbIds=')}`When 3. type UserContent = Pick<User, 'id' | 'name' | 'email' | 'avatar' | 'createdAt' | 'domain'>
🟡 Suggestions4. export function getUserSlug(p: Pick<ProfileQuery['me'], 'id' | 'domain'>): string | numberEvery call site immediately calls 5. export function isValidDoubanId(id: string): boolean {
return id.length > 3
}The 6. src={resolveMediaUrl(creator?.avatar)}
7. The file had 122 lines of implementation using 8. setTimeout(() => {
navigate(getRedirectPath(authResponse) as Route)
}, 100)This was in the original ✅ Things That Look Good
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1c6cd14ae4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| authResponse, | ||
| navigate, | ||
| options.skipZeroIdGuard, | ||
| getRedirectPath, |
There was a problem hiding this comment.
Remove unstable redirect callback from auth effect deps
Including getRedirectPath in useAuthResultEffect’s dependency array makes the effect fire on every re-render after auth succeeds, because each caller passes a new inline lambda (useAuthBy3rdPartSuccessed, useLoginV3Successed, useAuthByPhoneSuccessed). In those states, the effect repeats onAuthEnd(...) and queues duplicate navigations, which can trigger repeated login-state sync calls and flaky redirects before unmount.
Useful? React with 👍 / 👎.
| if (!path) { | ||
| return '' |
There was a problem hiding this comment.
Preserve a non-empty media URL for missing avatar paths
Returning '' for falsy media paths causes consumers that pass the value directly to next/image (for example AvatarOnNavigationBar) to render an invalid image src when a user has no avatar yet. That produces runtime errors in Next.js instead of a degradable broken image, so users with empty avatar values can hit a navigation bar render failure.
Useful? React with 👍 / 👎.
Summary
Codebase quality sweep that consolidates duplicated patterns, prunes unused/redundant packages, and pushes more components to server-first RSC. Organized as 8 self-contained commits that can each be reviewed independently.
wenquBooksByIdsQueryOptions,resolveMediaUrl,getUserSlug,uniqueById,useAuthResultEffect,checkIsPremium; ~25 call sites migrated.react-switch,react-select,react-blurhash,@sentry/tracing,recharts,react-transition-group,intersection-observer,@uptrace/node,opentelemetry-node-metrics,classnames,@heroicons/react,@types/heapdump,@types/react-transition-group); migrated 14 files from@heroicons/react→lucide-react; droppedreact-cool-inviewforlake-ui/hooks/use-in-viewport.button/index.tsxandbutton/button-simple.tsx; every caller now uses the single feature-completebutton/button.tsx(isLoading, variants, sizes).settings/{exports,orders,account,web,webhooks}/layout.tsxfiles into a sharedSettingsSectionLayout. ExtractedExportTriggerButton; removed inline SVG spinner duplication in flomo/notion/mail export cards; deleted deadexport.email.tsx.glassCardClassfor 5 verbatim comment cards, replaced the dedup-by-id reduce in square/profile lists withuniqueById, and collapsed 10 identicalloading.tsxfiles to single-line re-exports.fetchPublicDataWithBooksandAuthPageShell; migratedauth-v4,github, andcallbackto reuse them (removes 3× radial-gradient duplication + 3× public-data prefetch).'use client'frombutton.tsx,payment/canceled/content.tsx, andlink-indicator.tsx; convertedpremium/badgeandprofile-analyticsto async RSC usinggetTranslation(); splitpolicy/privacy/page.tsxandauth/signin/layout.tsxinto server shells with small client islands (PageTrack,TrackedGithubLink).Not in this PR (intentionally deferred)
@tanstack/react-table→lake-ui/tablemigration (4 tables with heavy custom styling — needs visual regression testing).@floating-ui/react→lake-ui/modalmigration — different API, needs manual verification.Both can be done as follow-ups once this PR is merged.
Test plan
🤖 Generated with Claude Code