From 3c1647812fe4b07a60537f36e6fe50b46d192b8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 01:58:22 +0000 Subject: [PATCH] Wait out loading queries instead of flashing empty states Favorites carried a `placeholderData` of no favorites for the signed-out case, which also made the pending query look settled and empty: a user with favorites got the "No favorites" zero state on every open. The query now resolves its own sign-in dependency, the way useSaveSettings does, so it stays enabled and its own pending state covers both fetches. Same bug in two other spots: useIsSignedIn reported the access-data placeholder as signed out, so the insert menu's preview showed the static thumbnail before swapping to the live one and toasted a signed-in caller to sign in. It now reports undefined until the answer lands. The configuration menu waits on the document's units rather than rendering parameters in the defaults first. Also add a settings button that opens the app outside the Onshape panel, on the current library and theme with none of Onshape's launch params. Trim the README to setup and a high-level description, keeping the local D1/R2 dump import and FORCE_SIGNED_IN with the rest of the setup steps. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017qHUwx6dKD8xCnfMwa5JvK --- README.md | 36 +++++++------------ src/frontend/features/auth/access-level.tsx | 10 ++++-- src/frontend/features/favorites/queries.ts | 28 +++++++++------ .../insert/components/configurations.tsx | 8 +++-- .../insert/components/insert-menu.tsx | 4 ++- src/frontend/features/insert/queries.ts | 4 +-- .../settings/components/settings-menu.tsx | 28 ++++++++++++++- .../thumbnails/components/thumbnail.tsx | 12 ++++++- .../routes/app/library/$libraryId/route.tsx | 12 +------ 9 files changed, 85 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 4b2634ca..83000505 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # FRCDesignApp -This repo hosts the code for the FRCDesign Onshape App. +This repo hosts the code for the FRCDesign Onshape App, which lets users browse +the FRCDesign part libraries and insert parts into their documents. + +The app is a Cloudflare Worker (`src/backend`) serving an API and a React SPA +(`src/frontend`), which runs both in Onshape's element right panel and directly +in a browser. See `AGENTS.md` for how `src` is laid out. ## Overview @@ -32,6 +37,10 @@ ACCESS_LEVEL_OVERRIDE=admin # One of admin, editor, or user. The level the app is viewed as by default (client-side). VITE_DEFAULT_ACCESS_LEVEL=admin + +# Signs you in as a fake user, so signed-in UI can be tested without an Onshape +# session. Onshape calls it reveals won't work, so leave it unset normally. +FORCE_SIGNED_IN=false ``` ## Onshape OAuth App Setup @@ -124,35 +133,14 @@ You should now be able to run the `Launch dev` VSCode task to launch Vite. You should then be able to launch the FRC Design App from the right panel of any Onshape Part Studio or Assembly and see the FRC Design App UI appear. To see documents, add one or more documents and push a new app version to rebuild the search database. - -To view the state of Cloudflare, type `e` in Vite to launch the local Cloudflare UI instance. - -## Standalone (not-signed-in) mode - -The app also runs without an Onshape login. Opening it directly (e.g. -`https://localhost:3000/`, rather than launching it from the Onshape panel) -serves the read-only library UI: browse groups, search, and open the -configuration menu. Anything that needs Onshape — inserting/deriving, favorites, -saving settings server-side, and live configuration previews — is hidden and -guarded server-side behind sign-in. Settings (theme/library) fall back to -`localStorage`, and the configuration menu uses default units and the stored -(static) thumbnail. - -This needs a populated database. Import a dump of the loaded cert DB into local -D1, and (optionally) its thumbnails into local R2: +Alternatively, import a dump of the loaded cert database into local D1, and (optionally) its thumbnails into local R2: ``` npx wrangler d1 execute DB --local --file=.sql npx wrangler r2 object put frc-design-app-dev-thumbnails/thumbnails// --file=.gif ``` -To exercise the signed-in-only UI (favorites, insert button) without a real -Onshape session, set `FORCE_SIGNED_IN=true` in your `.env`. This is a -testing-only escape hatch — it uses a fake user id and Onshape calls it reveals -won't actually work, so leave it unset normally. Combine with -`ACCESS_LEVEL_OVERRIDE=admin` to also show editor/admin controls — editor -routes require a session as well as the access level, so the override alone -does not reach them. +To view the state of Cloudflare, type `e` in Vite to launch the local Cloudflare UI instance. # Troubleshooting diff --git a/src/frontend/features/auth/access-level.tsx b/src/frontend/features/auth/access-level.tsx index 109de45f..c4ab96fd 100644 --- a/src/frontend/features/auth/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -59,9 +59,13 @@ export function useAccessData(): ResolvedAccessData { }, [serverData, chosenLevel, isPending]); } -/** Whether the caller is signed in to Onshape (from access-data). */ -export function useIsSignedIn(): boolean { - return useAccessData().signedIn; +/** + * Whether the caller is signed in to Onshape, or undefined until access-data + * lands: the placeholder says signed out, which is not yet an answer. + */ +export function useIsSignedIn(): boolean | undefined { + const { signedIn, isPending } = useAccessData(); + return isPending ? undefined : signedIn; } interface RequireAccessLevelProps extends PropsWithChildren { diff --git a/src/frontend/features/favorites/queries.ts b/src/frontend/features/favorites/queries.ts index 18a7fede..2fcc560a 100644 --- a/src/frontend/features/favorites/queries.ts +++ b/src/frontend/features/favorites/queries.ts @@ -2,25 +2,33 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; import { apiGet } from "../../lib/api-client"; import { type FavoritesData } from "@backend/features/favorites/contract"; import { LibraryId } from "@backend/features/library/library-id"; -import { useAccessData } from "../auth/access-level"; +import { getAccessDataQuery } from "../auth/access-level"; import { useLibraryId } from "../library/library-path"; +import { queryClient } from "../../lib/query-client"; import { favoritesQueryKey } from "../../lib/query-keys"; const EMPTY_FAVORITES: FavoritesData = { favorites: {}, favoriteOrder: [] }; -export function getFavoritesQuery(libraryId: LibraryId, enabled = true) { +/** + * Resolved in the query rather than gating it with `enabled`: a disabled query + * reports pending forever, and the access-data placeholder says signed out, so + * a signed-in caller would be shown no favorites until both land. + */ +export function getFavoritesQuery(libraryId: LibraryId) { return queryOptions({ queryKey: favoritesQueryKey(libraryId), - queryFn: () => apiGet("/favorites/library/" + libraryId), - enabled, - // Not signed in: the endpoint 401s, so present no favorites. - placeholderData: EMPTY_FAVORITES + queryFn: async () => { + const { signedIn } = + await queryClient.ensureQueryData(getAccessDataQuery()); + // Not signed in: no favorites, and the endpoint 401s. + if (!signedIn) { + return EMPTY_FAVORITES; + } + return apiGet("/favorites/library/" + libraryId); + } }); } export function useFavoritesQuery() { - const libraryId = useLibraryId(); - // Favorites require sign-in; don't fetch (or display) them otherwise. - const signedIn = useAccessData().signedIn; - return useQuery(getFavoritesQuery(libraryId, signedIn)); + return useQuery(getFavoritesQuery(useLibraryId())); } diff --git a/src/frontend/features/insert/components/configurations.tsx b/src/frontend/features/insert/components/configurations.tsx index 895e6f43..9d902d38 100644 --- a/src/frontend/features/insert/components/configurations.tsx +++ b/src/frontend/features/insert/components/configurations.tsx @@ -88,8 +88,8 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { // Units come from the current document; empty when not connected to one, in // which case each quantity renders in its own unit (see getEvaluateOptions). const isConnected = useIsConnectedToOnshape(); - const unitInfo = - useUnitInfoQuery(search, isConnected).data ?? EMPTY_UNIT_INFO; + const unitInfoQuery = useUnitInfoQuery(search, isConnected); + const unitInfo = unitInfoQuery.data ?? EMPTY_UNIT_INFO; useEffect(() => { // Doing this in a useEffect rather than a .then inside useQuery to prevent some buggy behavior @@ -125,7 +125,9 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { onRecord?.(findRecordForConfiguration(configuration, records)); }, [records, configuration, onRecord]); - if (query.isPending || !configuration) { + // isLoading, not isPending: the units query sits disabled (and so forever + // pending) when there is no document to ask. + if (query.isPending || unitInfoQuery.isLoading || !configuration) { return (
diff --git a/src/frontend/features/insert/components/insert-menu.tsx b/src/frontend/features/insert/components/insert-menu.tsx index 931fb8cf..87437db4 100644 --- a/src/frontend/features/insert/components/insert-menu.tsx +++ b/src/frontend/features/insert/components/insert-menu.tsx @@ -88,7 +88,9 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode { }, [modalId, insertable.name, record, soleRecord]); useEffect(() => { - if (!isSignedIn) { + // Only once known: pending reads as signed out, which would prompt a + // signed-in caller to sign in. + if (isSignedIn === false) { showSignInPreviewToast(); } }, [isSignedIn]); diff --git a/src/frontend/features/insert/queries.ts b/src/frontend/features/insert/queries.ts index e8bf9f41..c18ee992 100644 --- a/src/frontend/features/insert/queries.ts +++ b/src/frontend/features/insert/queries.ts @@ -1,7 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { apiGet } from "../../lib/api-client"; import { - EMPTY_UNIT_INFO, type ConfigurationResult, type UnitInfo } from "@backend/features/configurations/models"; @@ -24,8 +23,7 @@ export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) { instanceType: instancePath.instanceType } }), - enabled, - placeholderData: EMPTY_UNIT_INFO + enabled }); } diff --git a/src/frontend/features/settings/components/settings-menu.tsx b/src/frontend/features/settings/components/settings-menu.tsx index ee3b800b..8e8ca187 100644 --- a/src/frontend/features/settings/components/settings-menu.tsx +++ b/src/frontend/features/settings/components/settings-menu.tsx @@ -7,11 +7,14 @@ import { Theme } from "@backend/features/settings/settings"; import { hasEditorAccess } from "@backend/features/auth/access-level"; import { isWithinAccessLevel } from "@backend/features/auth/access-level"; import { AccessLevel } from "@backend/features/auth/access-level"; +import { LibraryId } from "@backend/features/library/library-id"; import { useSaveSettings } from "../settings"; import { OpenUrlButton } from "../../../components/open-url-button"; import { RequireAccessLevel, useAccessData } from "../../auth/access-level"; import { useUiState } from "../../../lib/ui-state"; import { FEEDBACK_FORM_URL } from "../../../lib/url"; +import { useIsConnectedToOnshape } from "../../../lib/onshape-params"; +import { useLibraryId } from "../../library/library-path"; import { AppSelect } from "../../../components/app-select"; import { makeSelectOption, @@ -69,11 +72,14 @@ function UserSettings(): ReactNode { const location = useRouterState({ select: (state) => state.location }); const navigate = useNavigate(); const saveSettings = useSaveSettings(); + const libraryId = useLibraryId(); + const isConnected = useIsConnectedToOnshape(); + const theme = location.search.theme ?? DEFAULT_SETTINGS.theme; return ( <> { // The url renders it; the write-behind decides what the // entry redirect seeds next time. @@ -84,6 +90,16 @@ function UserSettings(): ReactNode { }); }} /> + {/* Only worth offering from inside Onshape's panel, which is what + the standalone app is roomier than. */} + {isConnected && ( + + + + )} @@ -91,6 +107,16 @@ function UserSettings(): ReactNode { ); } +/** + * The app's own url for the current library, free of the params Onshape + * launches it with — carrying those over is what would keep it embedded. + */ +function standaloneUrl(libraryId: LibraryId, theme: Theme): string { + const url = new URL(`/app/library/${libraryId}`, window.location.origin); + url.searchParams.set("theme", theme); + return url.toString(); +} + interface ThemeSelectProps { theme: Theme; onThemeSelect: Dispatch; diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index f5c27b68..f096429d 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -207,11 +207,21 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { refetchInterval: (query) => query.state.data?.isFallback ? PREVIEW_POLL_MS : false, retry: 2, - enabled: !isFetchingConfiguration && isSignedIn + enabled: !isFetchingConfiguration && isSignedIn === true }); const heightAndWidth = getHeightAndWidth(size, 0.7); + // Not known yet: the stored thumbnail would be swapped for the live preview + // a moment later. + if (isSignedIn === undefined) { + return ( +
+ +
+ ); + } + // Not signed in: no live Onshape preview, so show the stored thumbnail // (Thumbnail falls back to a placeholder when there's none). if (!isSignedIn) { diff --git a/src/frontend/routes/app/library/$libraryId/route.tsx b/src/frontend/routes/app/library/$libraryId/route.tsx index 56319214..1dcbdca5 100644 --- a/src/frontend/routes/app/library/$libraryId/route.tsx +++ b/src/frontend/routes/app/library/$libraryId/route.tsx @@ -1,6 +1,5 @@ import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; import { queryClient } from "../../../../lib/query-client"; -import { getAccessDataQuery } from "../../../../features/auth/access-level"; import { getFavoritesQuery } from "../../../../features/favorites/queries"; import { getLibraryQuery, @@ -49,15 +48,6 @@ export const Route = createFileRoute("/app/library/$libraryId")({ void queryClient.prefetchQuery( getSearchDbQuery(libraryId, cacheVersion) ); - // Favorites are per-user, so the endpoint 401s a signed-out caller. - void queryClient - .ensureQueryData(getAccessDataQuery()) - .then((accessData) => { - if (accessData.signedIn) { - void queryClient.prefetchQuery( - getFavoritesQuery(libraryId) - ); - } - }); + void queryClient.prefetchQuery(getFavoritesQuery(libraryId)); } });