From 3f40e49e25f389a84a100dc1db9cd55d8d7304bd Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 26 Dec 2025 16:28:19 +1100 Subject: [PATCH 01/10] fix: Improve loading experience with proper page-ready signaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace pathname-based loading hide with explicit page-ready signal - Loading overlay now persists until page content is actually rendered - Add 10-second safety timeout to prevent infinite loading states - Add memoization to ClimbCard, BoardRenderer, and BoardLitupHolds - Add Suspense boundary with skeleton fallback to board layout - Create PageReadySignal component to signal when pages are ready This fixes the "freeze" at the end of loading where the overlay would disappear before the page content was rendered, causing a blank screen. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../[size_id]/[set_ids]/[angle]/layout.tsx | 7 +- .../[size_id]/[set_ids]/[angle]/list/page.tsx | 2 + .../[angle]/view/[climb_uuid]/page.tsx | 40 ++++--- .../board-page/board-page-skeleton.tsx | 30 +++++ .../board-renderer/board-litup-holds.tsx | 111 +++++++++++------- .../board-renderer/board-renderer.tsx | 58 ++++----- .../app/components/climb-card/climb-card.tsx | 61 ++++++---- .../components/loading/page-ready-signal.tsx | 19 +++ .../providers/navigation-loading-provider.tsx | 46 ++++++-- 9 files changed, 249 insertions(+), 125 deletions(-) create mode 100644 packages/web/app/components/board-page/board-page-skeleton.tsx create mode 100644 packages/web/app/components/loading/page-ready-signal.tsx diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx index 9c84c52b..67880247 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import { PropsWithChildren } from 'react'; import { Affix, Layout } from 'antd'; import { ParsedBoardRouteParameters, BoardRouteParameters, BoardDetails } from '@/app/lib/types'; @@ -14,6 +14,7 @@ import { ConnectionSettingsProvider } from '@/app/components/connection-manager/ import { PartyProvider } from '@/app/components/party-manager/party-context'; import PartyProfileWrapper from '@/app/components/party-manager/party-profile-wrapper'; import { Metadata } from 'next'; +import BoardPageSkeleton from '@/app/components/board-page/board-page-skeleton'; /** * Generates a user-friendly page title from board details. @@ -152,7 +153,9 @@ export default async function BoardLayout(props: PropsWithChildren - {children} + }> + {children} + diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/page.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/page.tsx index ea89a4bf..2e3d9bb5 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/page.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/page.tsx @@ -11,6 +11,7 @@ import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; import ClimbsList from '@/app/components/board-page/climbs-list'; import { searchClimbs } from '@/app/lib/db/queries/climbs/search-climbs'; import { getBoardDetails } from '@/app/lib/data/queries'; +import PageReadySignal from '@/app/components/loading/page-ready-signal'; export default async function DynamicResultsPage(props: { params: Promise; @@ -86,6 +87,7 @@ export default async function DynamicResultsPage(props: { return ( <> + ); } diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx index 37ad4814..1e4d7b69 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx @@ -22,6 +22,7 @@ import { kilterBetaLinks, tensionBetaLinks } from '@/app/lib/db/schema'; import { eq } from 'drizzle-orm'; import { BetaLink } from '@/app/lib/api-wrappers/sync-api-types'; import styles from './climb-view.module.css'; +import PageReadySignal from '@/app/components/loading/page-ready-signal'; export async function generateMetadata(props: { params: Promise }): Promise { const params = await props.params; @@ -191,27 +192,30 @@ export default async function DynamicResultsPage(props: { params: Promise - {/* Actions Section */} -
- -
- - {/* Main Content */} -
-
- + <> +
+ {/* Actions Section */} +
+
-
- + + {/* Main Content */} +
+
+ +
+
+ +
-
+ + ); } catch (error) { console.error('Error fetching results or climb:', error); diff --git a/packages/web/app/components/board-page/board-page-skeleton.tsx b/packages/web/app/components/board-page/board-page-skeleton.tsx new file mode 100644 index 00000000..0a6f7be7 --- /dev/null +++ b/packages/web/app/components/board-page/board-page-skeleton.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Row, Col, Skeleton, Card } from 'antd'; + +/** + * Skeleton loading UI for the board page, matching the ClimbsList grid layout. + * Used as a fallback for Suspense boundaries. + */ +const ClimbCardSkeleton = () => ( + } + > + + +); + +const BoardPageSkeleton = () => { + return ( + + {Array.from({ length: 10 }, (_, i) => ( + + + + ))} + + ); +}; + +export default BoardPageSkeleton; diff --git a/packages/web/app/components/board-renderer/board-litup-holds.tsx b/packages/web/app/components/board-renderer/board-litup-holds.tsx index 44dd5e3d..b7b892a8 100644 --- a/packages/web/app/components/board-renderer/board-litup-holds.tsx +++ b/packages/web/app/components/board-renderer/board-litup-holds.tsx @@ -9,49 +9,74 @@ interface BoardLitupHoldsProps { onHoldClick?: (holdId: number) => void; } -const BoardLitupHolds: React.FC = ({ - holdsData, - litUpHoldsMap, - mirrored, - thumbnail, - onHoldClick, -}) => { - if (!holdsData) return null; - - return ( - <> - {holdsData.map((hold) => { - const isLitUp = litUpHoldsMap[hold.id]?.state && litUpHoldsMap[hold.id].state !== 'OFF'; - const color = isLitUp ? litUpHoldsMap[hold.id].color : 'transparent'; - - let renderHold = hold; - if (mirrored && hold.mirroredHoldId) { - const mirroredHold = holdsData.find(({ id }) => id === hold.mirroredHoldId); - if (!mirroredHold) { - throw new Error("Couldn't find mirrored hold"); - } - renderHold = mirroredHold; - } - - return ( - onHoldClick(renderHold.id) : undefined} - /> - ); - })} - - ); +const areLitUpHoldsMapsEqual = (prev: LitUpHoldsMap, next: LitUpHoldsMap): boolean => { + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(next); + + if (prevKeys.length !== nextKeys.length) return false; + + for (const key of prevKeys) { + const prevHold = prev[key]; + const nextHold = next[key]; + if (!nextHold) return false; + if (prevHold.state !== nextHold.state || prevHold.color !== nextHold.color) { + return false; + } + } + + return true; }; +const BoardLitupHolds = React.memo( + ({ holdsData, litUpHoldsMap, mirrored, thumbnail, onHoldClick }: BoardLitupHoldsProps) => { + if (!holdsData) return null; + + return ( + <> + {holdsData.map((hold) => { + const isLitUp = litUpHoldsMap[hold.id]?.state && litUpHoldsMap[hold.id].state !== 'OFF'; + const color = isLitUp ? litUpHoldsMap[hold.id].color : 'transparent'; + + let renderHold = hold; + if (mirrored && hold.mirroredHoldId) { + const mirroredHold = holdsData.find(({ id }) => id === hold.mirroredHoldId); + if (!mirroredHold) { + throw new Error("Couldn't find mirrored hold"); + } + renderHold = mirroredHold; + } + + return ( + onHoldClick(renderHold.id) : undefined} + /> + ); + })} + + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.holdsData === nextProps.holdsData && + prevProps.mirrored === nextProps.mirrored && + prevProps.thumbnail === nextProps.thumbnail && + prevProps.onHoldClick === nextProps.onHoldClick && + areLitUpHoldsMapsEqual(prevProps.litUpHoldsMap, nextProps.litUpHoldsMap) + ); + }, +); + +BoardLitupHolds.displayName = 'BoardLitupHolds'; + export default BoardLitupHolds; diff --git a/packages/web/app/components/board-renderer/board-renderer.tsx b/packages/web/app/components/board-renderer/board-renderer.tsx index c9dc83c1..5a3e23a0 100644 --- a/packages/web/app/components/board-renderer/board-renderer.tsx +++ b/packages/web/app/components/board-renderer/board-renderer.tsx @@ -12,33 +12,37 @@ export type BoardProps = { onHoldClick?: (holdId: number) => void; }; -const BoardRenderer = ({ boardDetails, thumbnail, litUpHoldsMap, mirrored, onHoldClick }: BoardProps) => { - const { boardWidth, boardHeight, holdsData } = boardDetails; +const BoardRenderer = React.memo( + ({ boardDetails, thumbnail, litUpHoldsMap, mirrored, onHoldClick }: BoardProps) => { + const { boardWidth, boardHeight, holdsData } = boardDetails; - return ( - - {Object.keys(boardDetails.images_to_holds).map((imageUrl) => ( - - ))} - {litUpHoldsMap && ( - - )} - - ); -}; + return ( + + {Object.keys(boardDetails.images_to_holds).map((imageUrl) => ( + + ))} + {litUpHoldsMap && ( + + )} + + ); + }, +); + +BoardRenderer.displayName = 'BoardRenderer'; export default BoardRenderer; diff --git a/packages/web/app/components/climb-card/climb-card.tsx b/packages/web/app/components/climb-card/climb-card.tsx index 358a7a06..95d0d490 100644 --- a/packages/web/app/components/climb-card/climb-card.tsx +++ b/packages/web/app/components/climb-card/climb-card.tsx @@ -18,29 +18,42 @@ type ClimbCardProps = { actions?: React.JSX.Element[]; }; -const ClimbCard = ({ climb, boardDetails, onCoverClick, selected, actions }: ClimbCardProps) => { - const cover = ; - - const cardTitle = climb ? ( - - ) : ( - 'Loading...' - ); - - return ( - - {cover} - - ); -}; +const ClimbCard = React.memo( + ({ climb, boardDetails, onCoverClick, selected, actions }: ClimbCardProps) => { + const cover = ; + + const cardTitle = climb ? ( + + ) : ( + 'Loading...' + ); + + return ( + + {cover} + + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.climb?.uuid === nextProps.climb?.uuid && + prevProps.selected === nextProps.selected && + prevProps.boardDetails === nextProps.boardDetails && + prevProps.onCoverClick === nextProps.onCoverClick && + prevProps.actions === nextProps.actions + ); + }, +); + +ClimbCard.displayName = 'ClimbCard'; export default ClimbCard; diff --git a/packages/web/app/components/loading/page-ready-signal.tsx b/packages/web/app/components/loading/page-ready-signal.tsx new file mode 100644 index 00000000..54b7b324 --- /dev/null +++ b/packages/web/app/components/loading/page-ready-signal.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { useEffect } from 'react'; +import { useNavigationLoading } from '../providers/navigation-loading-provider'; + +/** + * A component that signals to the NavigationLoadingProvider that the page content + * has finished rendering. Place this at the end of your page component to hide + * the loading overlay once the page is ready. + */ +export default function PageReadySignal() { + const { signalPageReady } = useNavigationLoading(); + + useEffect(() => { + signalPageReady(); + }, [signalPageReady]); + + return null; +} diff --git a/packages/web/app/components/providers/navigation-loading-provider.tsx b/packages/web/app/components/providers/navigation-loading-provider.tsx index efb0f3c2..7f96a5c0 100644 --- a/packages/web/app/components/providers/navigation-loading-provider.tsx +++ b/packages/web/app/components/providers/navigation-loading-provider.tsx @@ -1,13 +1,14 @@ 'use client'; -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import { usePathname } from 'next/navigation'; +import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; import AnimatedBoardLoading from '../loading/animated-board-loading'; import { BoardDetails } from '@/app/lib/types'; type NavigationLoadingContextType = { showLoading: (boardDetails?: BoardDetails | null) => void; hideLoading: () => void; + signalPageReady: () => void; + isLoading: boolean; }; const NavigationLoadingContext = createContext(null); @@ -20,28 +21,51 @@ export function useNavigationLoading() { return context; } +const LOADING_TIMEOUT_MS = 10000; // 10 second safety timeout + export function NavigationLoadingProvider({ children }: { children: React.ReactNode }) { const [isLoading, setIsLoading] = useState(false); const [boardDetails, setBoardDetails] = useState(null); - const pathname = usePathname(); + const timeoutRef = useRef(null); - const showLoading = useCallback((details?: BoardDetails | null) => { - setBoardDetails(details || null); - setIsLoading(true); + const clearLoadingTimeout = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } }, []); const hideLoading = useCallback(() => { + clearLoadingTimeout(); setIsLoading(false); setBoardDetails(null); - }, []); + }, [clearLoadingTimeout]); - // Hide loading when route changes (navigation complete) - useEffect(() => { + const showLoading = useCallback((details?: BoardDetails | null) => { + clearLoadingTimeout(); + setBoardDetails(details || null); + setIsLoading(true); + + // Set a safety timeout to prevent infinite loading + timeoutRef.current = setTimeout(() => { + console.warn('Navigation loading timed out after 10 seconds'); + hideLoading(); + }, LOADING_TIMEOUT_MS); + }, [clearLoadingTimeout, hideLoading]); + + const signalPageReady = useCallback(() => { hideLoading(); - }, [pathname, hideLoading]); + }, [hideLoading]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + clearLoadingTimeout(); + }; + }, [clearLoadingTimeout]); return ( - + {children} From fde181158d49c9b37b02dce132d0b25a565114e9 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 26 Dec 2025 16:35:22 +1100 Subject: [PATCH 02/10] fix: TypeScript error in areLitUpHoldsMapsEqual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert string keys from Object.keys() to numbers for LitUpHoldsMap indexing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../web/app/components/board-renderer/board-litup-holds.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/web/app/components/board-renderer/board-litup-holds.tsx b/packages/web/app/components/board-renderer/board-litup-holds.tsx index b7b892a8..5b58e04d 100644 --- a/packages/web/app/components/board-renderer/board-litup-holds.tsx +++ b/packages/web/app/components/board-renderer/board-litup-holds.tsx @@ -16,8 +16,9 @@ const areLitUpHoldsMapsEqual = (prev: LitUpHoldsMap, next: LitUpHoldsMap): boole if (prevKeys.length !== nextKeys.length) return false; for (const key of prevKeys) { - const prevHold = prev[key]; - const nextHold = next[key]; + const numKey = Number(key); + const prevHold = prev[numKey]; + const nextHold = next[numKey]; if (!nextHold) return false; if (prevHold.state !== nextHold.state || prevHold.color !== nextHold.color) { return false; From f3198f801bced53e5da20408e729e16f82a87cf5 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 26 Dec 2025 16:42:28 +1100 Subject: [PATCH 03/10] fix: Add 'use client' directive to BoardPageSkeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component uses AntD components which need to be client-side rendered. Also use modular antd imports for better tree-shaking. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../web/app/components/board-page/board-page-skeleton.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/web/app/components/board-page/board-page-skeleton.tsx b/packages/web/app/components/board-page/board-page-skeleton.tsx index 0a6f7be7..f40bd3db 100644 --- a/packages/web/app/components/board-page/board-page-skeleton.tsx +++ b/packages/web/app/components/board-page/board-page-skeleton.tsx @@ -1,5 +1,10 @@ +'use client'; + import React from 'react'; -import { Row, Col, Skeleton, Card } from 'antd'; +import Row from 'antd/es/row'; +import Col from 'antd/es/col'; +import Skeleton from 'antd/es/skeleton'; +import Card from 'antd/es/card'; /** * Skeleton loading UI for the board page, matching the ClimbsList grid layout. From f968a16cc9a9c1eb8a983b286f638838bc0be0bc Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 26 Dec 2025 16:50:37 +1100 Subject: [PATCH 04/10] fix: Add pathname fallback and ref tracking to loading provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-add pathname change detection with 2-second delay as fallback - Use refs to track loading state for reliable timeout checks - Keep 10-second ultimate timeout as safety net The loading will now hide: 1. Immediately when signalPageReady() is called 2. After 2 seconds when pathname changes (fallback) 3. After 10 seconds no matter what (safety net) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 165 ++++-------------- .../providers/navigation-loading-provider.tsx | 53 ++++-- 2 files changed, 76 insertions(+), 142 deletions(-) diff --git a/package-lock.json b/package-lock.json index 10dfa3c5..1a8e7807 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2804,14 +2804,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/@petamoriken/float16": { - "version": "3.9.3", - "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", - "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@rc-component/async-validator": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz", @@ -4195,7 +4187,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4755,7 +4747,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -5411,20 +5403,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5648,70 +5626,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gel": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/gel/-/gel-2.2.0.tgz", - "integrity": "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@petamoriken/float16": "^3.8.7", - "debug": "^4.3.4", - "env-paths": "^3.0.0", - "semver": "^7.6.2", - "shell-quote": "^1.8.1", - "which": "^4.0.0" - }, - "bin": { - "gel": "dist/cli.mjs" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/gel/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" - } - }, - "node_modules/gel/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/gel/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6562,7 +6476,7 @@ "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "node-gyp-build": "bin.js", @@ -7167,6 +7081,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7186,6 +7101,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -7405,6 +7321,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/scroll-into-view-if-needed": { @@ -7496,20 +7413,6 @@ "node": ">=10" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -7909,7 +7812,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -7932,12 +7835,12 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -7949,12 +7852,12 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -7966,12 +7869,12 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -7983,12 +7886,12 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -8000,12 +7903,12 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -8017,12 +7920,12 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -8034,12 +7937,12 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -8051,12 +7954,12 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -8068,12 +7971,12 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8085,12 +7988,12 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8102,12 +8005,12 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8119,12 +8022,12 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8136,12 +8039,12 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8153,12 +8056,12 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8170,12 +8073,12 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8187,12 +8090,12 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8204,12 +8107,12 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8221,12 +8124,12 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -8238,12 +8141,12 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -8255,12 +8158,12 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -8272,12 +8175,12 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -8289,12 +8192,12 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -8306,12 +8209,12 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -8323,12 +8226,12 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -8340,12 +8243,12 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -8357,12 +8260,12 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -8371,7 +8274,7 @@ "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/packages/web/app/components/providers/navigation-loading-provider.tsx b/packages/web/app/components/providers/navigation-loading-provider.tsx index 7f96a5c0..cb992c9e 100644 --- a/packages/web/app/components/providers/navigation-loading-provider.tsx +++ b/packages/web/app/components/providers/navigation-loading-provider.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; +import { usePathname } from 'next/navigation'; import AnimatedBoardLoading from '../loading/animated-board-loading'; import { BoardDetails } from '@/app/lib/types'; @@ -22,47 +23,77 @@ export function useNavigationLoading() { } const LOADING_TIMEOUT_MS = 10000; // 10 second safety timeout +const PATHNAME_FALLBACK_MS = 2000; // 2 second fallback after pathname change export function NavigationLoadingProvider({ children }: { children: React.ReactNode }) { const [isLoading, setIsLoading] = useState(false); const [boardDetails, setBoardDetails] = useState(null); + const pathname = usePathname(); const timeoutRef = useRef(null); + const pathnameTimeoutRef = useRef(null); + const isLoadingRef = useRef(false); // Track loading state for timeouts - const clearLoadingTimeout = useCallback(() => { + const clearAllTimeouts = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } + if (pathnameTimeoutRef.current) { + clearTimeout(pathnameTimeoutRef.current); + pathnameTimeoutRef.current = null; + } }, []); const hideLoading = useCallback(() => { - clearLoadingTimeout(); + clearAllTimeouts(); + isLoadingRef.current = false; setIsLoading(false); setBoardDetails(null); - }, [clearLoadingTimeout]); + }, [clearAllTimeouts]); const showLoading = useCallback((details?: BoardDetails | null) => { - clearLoadingTimeout(); + clearAllTimeouts(); setBoardDetails(details || null); + isLoadingRef.current = true; setIsLoading(true); // Set a safety timeout to prevent infinite loading timeoutRef.current = setTimeout(() => { - console.warn('Navigation loading timed out after 10 seconds'); - hideLoading(); + if (isLoadingRef.current) { + console.warn('Navigation loading timed out after 10 seconds'); + hideLoading(); + } }, LOADING_TIMEOUT_MS); - }, [clearLoadingTimeout, hideLoading]); + }, [clearAllTimeouts, hideLoading]); const signalPageReady = useCallback(() => { - hideLoading(); + if (isLoadingRef.current) { + hideLoading(); + } }, [hideLoading]); - // Cleanup timeout on unmount + // Fallback: hide loading after pathname changes (with delay to allow page to render) + useEffect(() => { + if (isLoadingRef.current) { + // Clear any existing pathname timeout + if (pathnameTimeoutRef.current) { + clearTimeout(pathnameTimeoutRef.current); + } + // Set a fallback timeout after pathname change + pathnameTimeoutRef.current = setTimeout(() => { + if (isLoadingRef.current) { + hideLoading(); + } + }, PATHNAME_FALLBACK_MS); + } + }, [pathname, hideLoading]); + + // Cleanup on unmount useEffect(() => { return () => { - clearLoadingTimeout(); + clearAllTimeouts(); }; - }, [clearLoadingTimeout]); + }, [clearAllTimeouts]); return ( From a9a91325eb52cafcc7e190803bee4b78776f70ac Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 26 Dec 2025 16:54:06 +1100 Subject: [PATCH 05/10] fix: Improve skeleton to match ClimbCard structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ClimbCardTitleSkeleton matching horizontal layout with V grade - Add BoardRendererSkeleton with board background and hold circles - Add CardActionsSkeleton with action button placeholders - Use theme tokens for consistent styling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../board-page/board-page-skeleton.tsx | 116 +++++++++++++++++- 1 file changed, 112 insertions(+), 4 deletions(-) diff --git a/packages/web/app/components/board-page/board-page-skeleton.tsx b/packages/web/app/components/board-page/board-page-skeleton.tsx index f40bd3db..0987fc39 100644 --- a/packages/web/app/components/board-page/board-page-skeleton.tsx +++ b/packages/web/app/components/board-page/board-page-skeleton.tsx @@ -3,8 +3,108 @@ import React from 'react'; import Row from 'antd/es/row'; import Col from 'antd/es/col'; -import Skeleton from 'antd/es/skeleton'; +import Flex from 'antd/es/flex'; import Card from 'antd/es/card'; +import { themeTokens } from '@/app/theme/theme-config'; + +/** + * Skeleton that mimics the ClimbCard title structure (horizontal layout with V grade) + */ +const ClimbCardTitleSkeleton = () => ( + + {/* Left side: Name and info stacked */} + + {/* Name placeholder */} +
+ {/* Quality/setter placeholder */} +
+ + {/* Right side: V grade placeholder */} +
+ +); + +/** + * Skeleton that mimics the BoardRenderer with board background and hold circles + */ +const BoardRendererSkeleton = () => ( +
+ {/* Simulated hold circles scattered on the board */} + {[ + { top: '15%', left: '30%', size: 16 }, + { top: '25%', left: '60%', size: 14 }, + { top: '35%', left: '45%', size: 18 }, + { top: '45%', left: '25%', size: 14 }, + { top: '55%', left: '70%', size: 16 }, + { top: '65%', left: '40%', size: 14 }, + { top: '75%', left: '55%', size: 18 }, + { top: '85%', left: '35%', size: 16 }, + ].map((hold, i) => ( +
+ ))} +
+); + +/** + * Card action button placeholders + */ +const CardActionsSkeleton = () => ( + + {[1, 2, 3, 4].map((i) => ( +
+ ))} + +); /** * Skeleton loading UI for the board page, matching the ClimbsList grid layout. @@ -13,10 +113,18 @@ import Card from 'antd/es/card'; const ClimbCardSkeleton = () => ( } + style={{ + backgroundColor: themeTokens.semantic.surface, + }} + styles={{ + header: { paddingTop: 8, paddingBottom: 6 }, + body: { padding: 6 }, + actions: { borderTop: '1px solid var(--ant-color-border-secondary)' }, + }} + title={} + actions={[]} > - + ); From 5ed5b5b096c32d667a67b834e1c80c52ea17ea4e Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 26 Dec 2025 17:00:52 +1100 Subject: [PATCH 06/10] fix: Match skeleton structure to actual ClimbCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use actual AntD Card actions prop with real icons (muted color) - Simplify board renderer skeleton to just a placeholder area - Match the card structure: header, body, actions - Adjust sizes for better visual match 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../board-page/board-page-skeleton.tsx | 87 +++++-------------- 1 file changed, 24 insertions(+), 63 deletions(-) diff --git a/packages/web/app/components/board-page/board-page-skeleton.tsx b/packages/web/app/components/board-page/board-page-skeleton.tsx index 0987fc39..fbd12de9 100644 --- a/packages/web/app/components/board-page/board-page-skeleton.tsx +++ b/packages/web/app/components/board-page/board-page-skeleton.tsx @@ -5,6 +5,7 @@ import Row from 'antd/es/row'; import Col from 'antd/es/col'; import Flex from 'antd/es/flex'; import Card from 'antd/es/card'; +import { InfoCircleOutlined, ForkOutlined, HeartOutlined, PlusCircleOutlined } from '@ant-design/icons'; import { themeTokens } from '@/app/theme/theme-config'; /** @@ -13,13 +14,13 @@ import { themeTokens } from '@/app/theme/theme-config'; const ClimbCardTitleSkeleton = () => ( {/* Left side: Name and info stacked */} - + {/* Name placeholder */}
@@ -27,8 +28,8 @@ const ClimbCardTitleSkeleton = () => (
@@ -36,79 +37,35 @@ const ClimbCardTitleSkeleton = () => ( {/* Right side: V grade placeholder */}
); /** - * Skeleton that mimics the BoardRenderer with board background and hold circles + * Skeleton that mimics the BoardRenderer - just a placeholder area */ const BoardRendererSkeleton = () => (
- {/* Simulated hold circles scattered on the board */} - {[ - { top: '15%', left: '30%', size: 16 }, - { top: '25%', left: '60%', size: 14 }, - { top: '35%', left: '45%', size: 18 }, - { top: '45%', left: '25%', size: 14 }, - { top: '55%', left: '70%', size: 16 }, - { top: '65%', left: '40%', size: 14 }, - { top: '75%', left: '55%', size: 18 }, - { top: '85%', left: '35%', size: 16 }, - ].map((hold, i) => ( -
- ))} -
-); - -/** - * Card action button placeholders - */ -const CardActionsSkeleton = () => ( - - {[1, 2, 3, 4].map((i) => ( -
- ))} - + /> ); /** * Skeleton loading UI for the board page, matching the ClimbsList grid layout. - * Used as a fallback for Suspense boundaries. + * Uses the same Card structure as ClimbCard with muted action icons. */ const ClimbCardSkeleton = () => ( ( styles={{ header: { paddingTop: 8, paddingBottom: 6 }, body: { padding: 6 }, - actions: { borderTop: '1px solid var(--ant-color-border-secondary)' }, }} title={} - actions={[]} + actions={[ + , + , + , + , + ]} > From 6b127521a9ea345757194938fa36d1ebcb84959b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Dec 2025 06:12:53 +0000 Subject: [PATCH 07/10] fix: Clean up lint issues in PR #376 - Remove unnecessary Promise.all wrappers around single promises in layout.tsx and page.tsx - Remove leftover debugger statement in board-config-live-preview.tsx --- .../[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx | 4 ++-- .../[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx | 2 +- .../app/components/setup-wizard/board-config-live-preview.tsx | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx index 67880247..4e7c742e 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx @@ -129,8 +129,8 @@ export default async function BoardLayout(props: PropsWithChildren diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx index 1e4d7b69..7b1398f0 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx @@ -102,7 +102,7 @@ export default async function DynamicResultsPage(props: { params: Promise m.getLayouts(parsedParams.board_name)); diff --git a/packages/web/app/components/setup-wizard/board-config-live-preview.tsx b/packages/web/app/components/setup-wizard/board-config-live-preview.tsx index c180848d..de7d8725 100644 --- a/packages/web/app/components/setup-wizard/board-config-live-preview.tsx +++ b/packages/web/app/components/setup-wizard/board-config-live-preview.tsx @@ -56,7 +56,6 @@ export default function BoardConfigLivePreview({ let details = cachedDetails; if (!details) { try { - debugger; details = await fetchBoardDetails(safeBoardName, safeLayoutId, safeSizeId, setIds); } catch (error) { console.error('Failed to fetch board details:', error); From f18311504973d15479b3929ccfc781acdce1bc92 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Dec 2025 06:36:57 +0000 Subject: [PATCH 08/10] refactor: Improve skeleton and memo implementations - Convert BoardPageSkeleton to server component (removed 'use client') - Use Ant Design Skeleton components instead of custom divs - Improve ClimbCard memo comparison: - Add areActionsEqual helper for proper array comparison - Handle empty arrays case (actions={[]}) - Add fallback boardDetails comparison by identifiers - Better structured comparison logic with early returns --- .../board-page/board-page-skeleton.tsx | 57 ++++++------------- .../app/components/climb-card/climb-card.tsx | 50 +++++++++++++--- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/packages/web/app/components/board-page/board-page-skeleton.tsx b/packages/web/app/components/board-page/board-page-skeleton.tsx index fbd12de9..21387f97 100644 --- a/packages/web/app/components/board-page/board-page-skeleton.tsx +++ b/packages/web/app/components/board-page/board-page-skeleton.tsx @@ -1,10 +1,9 @@ -'use client'; - import React from 'react'; import Row from 'antd/es/row'; import Col from 'antd/es/col'; import Flex from 'antd/es/flex'; import Card from 'antd/es/card'; +import Skeleton from 'antd/es/skeleton'; import { InfoCircleOutlined, ForkOutlined, HeartOutlined, PlusCircleOutlined } from '@ant-design/icons'; import { themeTokens } from '@/app/theme/theme-config'; @@ -14,58 +13,33 @@ import { themeTokens } from '@/app/theme/theme-config'; const ClimbCardTitleSkeleton = () => ( {/* Left side: Name and info stacked */} - - {/* Name placeholder */} -
- {/* Quality/setter placeholder */} -
+ + + {/* Right side: V grade placeholder */} -
+ ); /** - * Skeleton that mimics the BoardRenderer - just a placeholder area + * Skeleton that mimics the BoardRenderer - square placeholder for the board image */ const BoardRendererSkeleton = () => ( -
+ > + + ); /** - * Skeleton loading UI for the board page, matching the ClimbsList grid layout. - * Uses the same Card structure as ClimbCard with muted action icons. + * Skeleton loading UI for ClimbCard, matching the card structure with muted action icons. */ const ClimbCardSkeleton = () => ( ( ); +/** + * Skeleton loading UI for the board page, matching the ClimbsList grid layout. + */ const BoardPageSkeleton = () => { return ( diff --git a/packages/web/app/components/climb-card/climb-card.tsx b/packages/web/app/components/climb-card/climb-card.tsx index 95d0d490..01ac9910 100644 --- a/packages/web/app/components/climb-card/climb-card.tsx +++ b/packages/web/app/components/climb-card/climb-card.tsx @@ -18,6 +18,26 @@ type ClimbCardProps = { actions?: React.JSX.Element[]; }; +/** + * Compare actions arrays for memo equality. + * Handles common cases: both undefined, both empty arrays, or same reference. + */ +const areActionsEqual = ( + prev: React.JSX.Element[] | undefined, + next: React.JSX.Element[] | undefined, +): boolean => { + // Same reference (including both undefined) + if (prev === next) return true; + // One undefined, one not + if (!prev || !next) return false; + // Both empty arrays (common case: actions={[]} passed each render) + if (prev.length === 0 && next.length === 0) return true; + // Different lengths + if (prev.length !== next.length) return false; + // Compare by keys (React elements should have stable keys) + return prev.every((el, i) => el.key === next[i].key); +}; + const ClimbCard = React.memo( ({ climb, boardDetails, onCoverClick, selected, actions }: ClimbCardProps) => { const cover = ; @@ -44,13 +64,29 @@ const ClimbCard = React.memo( ); }, (prevProps, nextProps) => { - return ( - prevProps.climb?.uuid === nextProps.climb?.uuid && - prevProps.selected === nextProps.selected && - prevProps.boardDetails === nextProps.boardDetails && - prevProps.onCoverClick === nextProps.onCoverClick && - prevProps.actions === nextProps.actions - ); + // Compare climb by uuid (stable identifier) + if (prevProps.climb?.uuid !== nextProps.climb?.uuid) return false; + // Compare selected state + if (prevProps.selected !== nextProps.selected) return false; + // Compare boardDetails by reference (stable from server) or by key identifiers + if (prevProps.boardDetails !== nextProps.boardDetails) { + // Fallback: compare by stable identifiers if references differ + const prevBd = prevProps.boardDetails; + const nextBd = nextProps.boardDetails; + if ( + prevBd.board_name !== nextBd.board_name || + prevBd.layout_id !== nextBd.layout_id || + prevBd.size_id !== nextBd.size_id + ) { + return false; + } + } + // Compare callbacks by reference (parent should memoize with useCallback) + if (prevProps.onCoverClick !== nextProps.onCoverClick) return false; + // Compare actions arrays properly + if (!areActionsEqual(prevProps.actions, nextProps.actions)) return false; + + return true; }, ); From 874eb15f8d59c552cd5470b803d8d8e4084ae3ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Dec 2025 06:47:15 +0000 Subject: [PATCH 09/10] fix: Restore 'use client' to BoardPageSkeleton Ant Design Skeleton components with animations require client-side rendering. The server component approach caused hydration errors. --- packages/web/app/components/board-page/board-page-skeleton.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web/app/components/board-page/board-page-skeleton.tsx b/packages/web/app/components/board-page/board-page-skeleton.tsx index 21387f97..1a7c38b1 100644 --- a/packages/web/app/components/board-page/board-page-skeleton.tsx +++ b/packages/web/app/components/board-page/board-page-skeleton.tsx @@ -1,3 +1,5 @@ +'use client'; + import React from 'react'; import Row from 'antd/es/row'; import Col from 'antd/es/col'; From 41c6318b334d3f632182673959d6f5c3939a41a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Dec 2025 06:50:08 +0000 Subject: [PATCH 10/10] fix: Increase skeleton height to better match actual content --- .../web/app/components/board-page/board-page-skeleton.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/web/app/components/board-page/board-page-skeleton.tsx b/packages/web/app/components/board-page/board-page-skeleton.tsx index 1a7c38b1..1d5c2c12 100644 --- a/packages/web/app/components/board-page/board-page-skeleton.tsx +++ b/packages/web/app/components/board-page/board-page-skeleton.tsx @@ -25,15 +25,16 @@ const ClimbCardTitleSkeleton = () => ( ); /** - * Skeleton that mimics the BoardRenderer - square placeholder for the board image + * Skeleton that mimics the BoardRenderer - placeholder for the board image. + * Uses minHeight to approximate the actual BoardRenderer which has maxHeight: 55vh. */ const BoardRendererSkeleton = () => (