From 6f6cfad0aa2b9c8db3a4ccb09732d048967c32bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 21:05:13 +0000 Subject: [PATCH 1/7] Standardize library query keys under a single prefix Every library-scoped query is now ["library", libraryId, , ...], so useRefreshLibrary invalidates that one prefix instead of enumerating each query's match key. Drops the *QueryMatchKey helpers (two of which were already unused) and renames the library snapshot key to libraryDataQueryKey, freeing libraryQueryKey to name the prefix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN1YyJcDC5UGqCoNhfUuNy --- .../library/components/group-card.tsx | 4 +- src/frontend/features/library/queries.ts | 4 +- src/frontend/lib/query-keys.ts | 42 +++++++------------ src/frontend/lib/refresh.ts | 42 +++++++------------ 4 files changed, 34 insertions(+), 58 deletions(-) diff --git a/src/frontend/features/library/components/group-card.tsx b/src/frontend/features/library/components/group-card.tsx index b4f1e3f8..1a37c44e 100644 --- a/src/frontend/features/library/components/group-card.tsx +++ b/src/frontend/features/library/components/group-card.tsx @@ -21,7 +21,7 @@ import { GroupStatusBadge } from "../../build-status/components/build-status"; import { useRefreshLibrary } from "../../../lib/refresh"; import { useBuildStatusQuery } from "../../build-status/queries"; import { useCacheVersion, useLibraryQuery } from "../queries"; -import { libraryQueryKey } from "../../../lib/query-keys"; +import { libraryDataQueryKey } from "../../../lib/query-keys"; import { toLibraryPath, useIsHome, useLibraryId } from "../library-path"; import { getQueryUpdater } from "../../../lib/query-cache"; @@ -187,7 +187,7 @@ function useSetGroupOrderMutation() { const libraryId = useLibraryId(); const cacheVersion = useCacheVersion(); const refreshLibrary = useRefreshLibrary(); - const key = libraryQueryKey(libraryId, cacheVersion); + const key = libraryDataQueryKey(libraryId, cacheVersion); return useMutation({ mutationKey: ["group-order"], diff --git a/src/frontend/features/library/queries.ts b/src/frontend/features/library/queries.ts index 53d6e83b..4309efda 100644 --- a/src/frontend/features/library/queries.ts +++ b/src/frontend/features/library/queries.ts @@ -9,13 +9,13 @@ import { useAccessData } from "../auth/access-level"; import { toLibraryPath, useLibraryId } from "./library-path"; import { jobStatusQueryKey, - libraryQueryKey, + libraryDataQueryKey, libraryVersionQueryKey } from "../../lib/query-keys"; export function getLibraryQuery(libraryId: LibraryId, cacheVersion: number) { return queryOptions({ - queryKey: libraryQueryKey(libraryId, cacheVersion), + queryKey: libraryDataQueryKey(libraryId, cacheVersion), queryFn: async () => apiGet("/library-data/library/" + libraryId, { cacheId: cacheVersion diff --git a/src/frontend/lib/query-keys.ts b/src/frontend/lib/query-keys.ts index 427973d7..170be178 100644 --- a/src/frontend/lib/query-keys.ts +++ b/src/frontend/lib/query-keys.ts @@ -1,6 +1,6 @@ /** - * Every query key in one place: features read their own keys here, and the - * cross-feature refresh flows invalidate by the match keys. + * Every query key in one place. Everything scoped to a library hangs off + * {@link libraryQueryKey}, so the refresh flows invalidate that one prefix. */ import { LibraryId } from "@backend/features/library/library-id"; import { InstancePath } from "@backend/lib/onshape/path"; @@ -20,49 +20,37 @@ export function unitInfoQueryKey(instancePath: InstancePath) { return ["unit-info", instancePath]; } -export function libraryQueryMatchKey() { - return ["library"]; +/** The prefix every library-scoped query extends with its endpoint. */ +export function libraryQueryKey(libraryId: LibraryId) { + return ["library", libraryId]; } -export function libraryQueryKey(libraryId: LibraryId, cacheVersion: number) { - return ["library", libraryId, cacheVersion]; -} - -export function libraryVersionQueryMatchKey() { - return ["library-version"]; +export function libraryDataQueryKey( + libraryId: LibraryId, + cacheVersion: number +) { + return [...libraryQueryKey(libraryId), "library-data", cacheVersion]; } export function libraryVersionQueryKey(libraryId: LibraryId) { - return ["library-version", libraryId]; -} - -export function searchDbQueryMatchKey() { - return ["search-db"]; + return [...libraryQueryKey(libraryId), "library-version"]; } export function searchDbQueryKey(libraryId: LibraryId, cacheVersion: number) { - return ["search-db", libraryId, cacheVersion]; + return [...libraryQueryKey(libraryId), "search-db", cacheVersion]; } export function favoritesQueryKey(libraryId: LibraryId) { - return ["favorites", libraryId]; -} - -export function buildStatusQueryMatchKey() { - return ["build-status"]; + return [...libraryQueryKey(libraryId), "favorites"]; } export function buildStatusQueryKey( libraryId: LibraryId, cacheVersion: number ) { - return ["build-status", libraryId, cacheVersion]; -} - -export function jobStatusQueryMatchKey() { - return ["job-status"]; + return [...libraryQueryKey(libraryId), "build-status", cacheVersion]; } export function jobStatusQueryKey(libraryId: LibraryId) { - return ["job-status", libraryId]; + return [...libraryQueryKey(libraryId), "job-status"]; } diff --git a/src/frontend/lib/refresh.ts b/src/frontend/lib/refresh.ts index c7953f12..1253d3f2 100644 --- a/src/frontend/lib/refresh.ts +++ b/src/frontend/lib/refresh.ts @@ -3,51 +3,39 @@ import { useRouter } from "@tanstack/react-router"; import { queryClient } from "./query-client"; import { useJobStatusQuery } from "../features/library/queries"; import { - buildStatusQueryMatchKey, + accessDataQueryKey, favoritesQueryKey, - libraryQueryMatchKey, - libraryVersionQueryMatchKey + libraryQueryKey } from "./query-keys"; -import { accessDataQueryKey } from "./query-keys"; import { useLibraryId } from "../features/library/library-path"; -import type { LibraryId } from "@backend/features/library/library-id"; - -/** Refetches the current user's favorites, which aren't version-keyed. */ -function refetchFavorites(libraryId: LibraryId): Promise { - return queryClient.invalidateQueries({ - queryKey: favoritesQueryKey(libraryId) - }); -} /** - * Refreshes the library view (and favorites). Invalidating the snapshot queries - * also rolls a failed optimistic update back to server truth on the refetch. + * Refreshes everything scoped to the current library, plus the caller's access. + * Invalidating the snapshot queries also rolls a failed optimistic update back + * to server truth on the refetch. */ export function useRefreshLibrary(): () => Promise { const router = useRouter(); const libraryId = useLibraryId(); return useCallback(async () => { - await queryClient.refetchQueries({ - queryKey: libraryVersionQueryMatchKey() - }); - await queryClient.refetchQueries({ queryKey: accessDataQueryKey() }); - await queryClient.invalidateQueries({ - queryKey: libraryQueryMatchKey() - }); - await queryClient.invalidateQueries({ - queryKey: buildStatusQueryMatchKey() - }); - await refetchFavorites(libraryId); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: libraryQueryKey(libraryId) + }), + queryClient.invalidateQueries({ queryKey: accessDataQueryKey() }) + ]); await router.invalidate(); }, [router, libraryId]); } -/** Refreshes just the current user's favorites. */ +/** Refreshes just the current user's favorites, which aren't version-keyed. */ export function useRefreshFavorites(): () => Promise { const router = useRouter(); const libraryId = useLibraryId(); return useCallback(async () => { - await refetchFavorites(libraryId); + await queryClient.invalidateQueries({ + queryKey: favoritesQueryKey(libraryId) + }); await router.invalidate(); }, [router, libraryId]); } From 0e634f61e59a0f4c5aea15f4270ee6f21c7dc5b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 21:11:01 +0000 Subject: [PATCH 2/7] Keep the group page's header while its data loads Opening a group replaced the whole page with a bare zero state until the library snapshot landed, leaving no name and no way back, and reused the home page's plural "Loading groups..." copy. The header now renders without a group, showing a skeleton for the name and dropping the menu until there is something to act on, so the loading and error states sit inside the page instead of standing in for it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN1YyJcDC5UGqCoNhfUuNy --- .../library/$libraryId/groups/$groupId.tsx | 56 ++++++++++++++++--- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index cf9a3388..0eb53e9b 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -6,14 +6,14 @@ import { useNavigate, useParams } from "@tanstack/react-router"; -import { Box, Button, Group } from "@mantine/core"; +import { Box, Button, Group, Skeleton } from "@mantine/core"; import { ArrowLeft, ArrowUUpLeft, Warning } from "@phosphor-icons/react"; import { BORDER, IconSize, SECTION_HEADER_HEIGHT } from "../../../../../lib/style-constants"; -import { ReactNode } from "react"; +import { PropsWithChildren, ReactNode } from "react"; import { SearchResults } from "../../../../../features/search/components/search-results"; import { GroupOut, Insertables } from "@backend/features/library/contract"; import { hasEditorAccess } from "@backend/features/auth/access-level"; @@ -52,9 +52,17 @@ function GroupList(): ReactNode { const uiState = useUiState()[0]; if (libraryQuery.isPending) { - return ; + return ( + + + + ); } else if (libraryQuery.isError) { - return ; + return ( + + + + ); } const groups = libraryQuery.data.groups; const insertables = libraryQuery.data.insertables; @@ -110,10 +118,28 @@ function GroupList(): ReactNode { ); } -function GroupHeaderRow({ group }: { group: GroupOut }): ReactNode { +/** + * The page's frame around a state that has no group to show yet, so the header + * (and its way back) is up before the data it names lands. + */ +function GroupZeroState({ children }: PropsWithChildren): ReactNode { + return ( + <> + + {children} + + ); +} + +/** Roughly a group name, so the skeleton doesn't resize the row when it lands. */ +const TITLE_SKELETON_WIDTH = 160; +const TITLE_SKELETON_HEIGHT = 14; + +/** The group is absent until the library data it comes from has loaded. */ +function GroupHeaderRow({ group }: { group?: GroupOut }): ReactNode { const navigate = useNavigate(); const libraryId = useLibraryId(); - const menuItems = ; + const menuItems = group ? : undefined; const header = ( } - title={group.name} + title={ + group?.name ?? ( + . + component="span" + display="inline-block" + w={TITLE_SKELETON_WIDTH} + h={TITLE_SKELETON_HEIGHT} + /> + ) + } /> - {menuItems} + {menuItems && {menuItems}} ); + // Nothing to act on until the group lands, so no context menu yet. + if (!menuItems) { + return header; + } return {header}; } From 62b5daa5a96f59c1f51fac31281360a1b2d11210 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 21:17:04 +0000 Subject: [PATCH 3/7] Revert the group page header skeleton, fix its loading copy The spinner the header work was meant to restore was already showing; only its wording was wrong, carried over from the home page's list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN1YyJcDC5UGqCoNhfUuNy --- .../library/$libraryId/groups/$groupId.tsx | 56 +++---------------- 1 file changed, 8 insertions(+), 48 deletions(-) diff --git a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx index 0eb53e9b..acf80caa 100644 --- a/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx +++ b/src/frontend/routes/app/library/$libraryId/groups/$groupId.tsx @@ -6,14 +6,14 @@ import { useNavigate, useParams } from "@tanstack/react-router"; -import { Box, Button, Group, Skeleton } from "@mantine/core"; +import { Box, Button, Group } from "@mantine/core"; import { ArrowLeft, ArrowUUpLeft, Warning } from "@phosphor-icons/react"; import { BORDER, IconSize, SECTION_HEADER_HEIGHT } from "../../../../../lib/style-constants"; -import { PropsWithChildren, ReactNode } from "react"; +import { ReactNode } from "react"; import { SearchResults } from "../../../../../features/search/components/search-results"; import { GroupOut, Insertables } from "@backend/features/library/contract"; import { hasEditorAccess } from "@backend/features/auth/access-level"; @@ -52,17 +52,9 @@ function GroupList(): ReactNode { const uiState = useUiState()[0]; if (libraryQuery.isPending) { - return ( - - - - ); + return ; } else if (libraryQuery.isError) { - return ( - - - - ); + return ; } const groups = libraryQuery.data.groups; const insertables = libraryQuery.data.insertables; @@ -118,28 +110,10 @@ function GroupList(): ReactNode { ); } -/** - * The page's frame around a state that has no group to show yet, so the header - * (and its way back) is up before the data it names lands. - */ -function GroupZeroState({ children }: PropsWithChildren): ReactNode { - return ( - <> - - {children} - - ); -} - -/** Roughly a group name, so the skeleton doesn't resize the row when it lands. */ -const TITLE_SKELETON_WIDTH = 160; -const TITLE_SKELETON_HEIGHT = 14; - -/** The group is absent until the library data it comes from has loaded. */ -function GroupHeaderRow({ group }: { group?: GroupOut }): ReactNode { +function GroupHeaderRow({ group }: { group: GroupOut }): ReactNode { const navigate = useNavigate(); const libraryId = useLibraryId(); - const menuItems = group ? : undefined; + const menuItems = ; const header = ( } - title={ - group?.name ?? ( - . - component="span" - display="inline-block" - w={TITLE_SKELETON_WIDTH} - h={TITLE_SKELETON_HEIGHT} - /> - ) - } + title={group.name} /> - {menuItems && {menuItems}} + {menuItems} ); - // Nothing to act on until the group lands, so no context menu yet. - if (!menuItems) { - return header; - } return {header}; } From 6e30cd239a515ddf310f9a647a5043a4c8919934 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 21:33:58 +0000 Subject: [PATCH 4/7] Hold the last preview render while a new one is queued The worker stands the element default in for a configuration it has yet to render, so changing configuration dropped the preview the user had back to the default before the new render landed. The preview now keeps the last real render until the one it asked for arrives; only an element with nothing rendered yet shows the default. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN1YyJcDC5UGqCoNhfUuNy --- .../thumbnails/components/thumbnail.tsx | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index d346f342..1f6996a9 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -1,12 +1,16 @@ import { useQuery } from "@tanstack/react-query"; -import { loadImage, loadImageResult } from "../../../lib/api-client"; +import { + loadImage, + loadImageResult, + type LoadedImage +} from "../../../lib/api-client"; import { ElementType } from "@backend/lib/onshape/element-type"; import { ThumbnailSize } from "@backend/features/thumbnails/types"; import { ElementPath } from "@backend/lib/onshape/path"; import { Box, Card, Center, HoverCard, Loader } from "@mantine/core"; import { Question } from "@phosphor-icons/react"; -import { ComponentPropsWithRef, ReactNode } from "react"; +import { ComponentPropsWithRef, ReactNode, useState } from "react"; import { DEFAULT_CANONICAL_CONFIGURATION } from "@backend/features/configurations/canonical"; import { thumbnailUrl } from "@backend/features/thumbnails/keys"; import { SectionError } from "../../../components/app-zero-state"; @@ -179,6 +183,18 @@ const PREVIEW_POLL_MS = 4000; */ const WARM_EVERY_POLLS = 15; +/** + * The last render actually produced, kept across configuration changes: the + * worker stands the element default in until a new one lands. + */ +function useLastRenderedUrl(image?: LoadedImage): string | undefined { + const [lastRendered, setLastRendered] = useState(); + if (image && !image.isFallback && image.url !== lastRendered) { + setLastRendered(image.url); + } + return lastRendered; +} + export function PreviewImage(props: PreviewImageProps): ReactNode { const { path, @@ -228,6 +244,7 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { retry: 2, enabled: !isFetchingConfiguration && isSignedIn === true }); + const lastRenderedUrl = useLastRenderedUrl(thumbnailQuery.data); const heightAndWidth = getHeightAndWidth(size, 0.7); @@ -281,6 +298,13 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { ); } + // The url this poll fetched, so the render shows from the response that + // reported it — except a stand-in, which must not displace a real render. + const previewUrl = + thumbnailQuery.data.isFallback && lastRenderedUrl + ? lastRenderedUrl + : thumbnailQuery.data.url; + return ( <> From 7a30841678e12b4ee81186334fc0a005f5a913c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 21:50:05 +0000 Subject: [PATCH 5/7] Rework the indexing rows around Onshape's own vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assemblies pull metadata from the assembly tab, so the configuration cap never applies to them; the indexing row now says so instead of reporting an error an admin cannot act on. Configurations report their real total. countConfigurations stops at the index cap because the load path enumerates every combination, so counting for display gets its own depth-first pass that holds one path at a time. The per-parameter Indexed/Not indexed badge becomes an icon shown only where Onshape excludes a parameter from affecting properties, which is the lever on the count — quantity parameters no longer read as a state an admin can change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN1YyJcDC5UGqCoNhfUuNy --- .../configurations/combinations.test.ts | 46 +++++++++++ .../features/configurations/combinations.ts | 69 ++++++++++++++++ .../build-status/components/build-status.tsx | 82 ++++++++++--------- 3 files changed, 159 insertions(+), 38 deletions(-) diff --git a/src/backend/features/configurations/combinations.test.ts b/src/backend/features/configurations/combinations.test.ts index 1b68ed1b..7c7286da 100644 --- a/src/backend/features/configurations/combinations.test.ts +++ b/src/backend/features/configurations/combinations.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { AUTO_INDEX_THRESHOLD, + countCombinations, countConfigurations, enumerateConfigurations, IndexingBand, @@ -178,6 +179,51 @@ describe("countConfigurations", () => { }); }); +describe("countCombinations", () => { + it("counts an insertable with nothing to vary as having none", () => { + expect(countCombinations([])).toBe(0); + expect( + countCombinations([ + enumParam("A", ["x", "y"], { isCosmetic: true }) + ]) + ).toBe(0); + }); + + it("agrees with countConfigurations under the index cap", () => { + for (const configs of [2, 7, AUTO_INDEX_THRESHOLD, 500]) { + const params = paramsWithConfigs(configs); + expect(countCombinations(params)).toBe( + countConfigurations(params).count + ); + } + }); + + it("counts on past the index cap, which countConfigurations stops at", () => { + const params = paramsWithConfigs(MAX_PART_NUMBER_CONFIGURATIONS * 4); + expect(countConfigurations(params).count).toBeNull(); + expect(countCombinations(params)).toBe( + MAX_PART_NUMBER_CONFIGURATIONS * 4 + ); + }); + + it("skips values a visibility condition hides, as enumeration does", () => { + const params: ConfigurationParameter[] = [ + enumParam("A", ["a1", "a2"]), + { + ...enumParam("B", ["b1", "b2", "b3"]), + condition: equals("A", "a1") + } + ]; + expect(countCombinations(params)).toBe( + enumerateConfigurations(params).configurations.length + ); + }); + + it("gives up past its own cap rather than counting forever", () => { + expect(countCombinations(paramsWithConfigs(64), 32)).toBeNull(); + }); +}); + describe("isIndexingEnabled", () => { it.each([ // Under the threshold everything indexes, custom included: a part with diff --git a/src/backend/features/configurations/combinations.ts b/src/backend/features/configurations/combinations.ts index f9cde0be..484c993e 100644 --- a/src/backend/features/configurations/combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -103,6 +103,75 @@ export function isIndexedParameter( return !parameter.isCosmetic; } +/** + * The most combinations counted out for display. Far past the index cap, which + * bounds work rather than what an admin is shown. + */ +export const MAX_COUNTED_CONFIGURATIONS = 100_000; + +/** + * The true combination count, which runs past the index cap so the admin card + * can show what there is to bring down. Null past {@link MAX_COUNTED_CONFIGURATIONS}. + */ +export function countCombinations( + parameters: ConfigurationParameter[], + cap: number = MAX_COUNTED_CONFIGURATIONS +): number | null { + // Depth-first, unlike enumerateConfigurations: only the count is wanted, so + // one path is held at a time rather than every combination at once. + const indexed = parameters.filter(isIndexedParameter); + let count = 0; + let capped = false; + + const walk = (depth: number, configuration: ParameterValues) => { + if (depth === indexed.length) { + // The lone default that nothing-to-vary reaches is not a + // configuration of its own. + if (Object.keys(configuration).length > 0) { + count++; + capped = count > cap; + } + return; + } + const parameter = indexed[depth]; + const values = evaluateCondition( + parameter.condition, + configuration, + parameters + ) + ? parameterValues(parameter, configuration, parameters) + : []; + // Hidden here, or with nothing to pick: left unset for Onshape to default. + if (values.length === 0) { + walk(depth + 1, configuration); + return; + } + for (const value of values) { + walk(depth + 1, { ...configuration, [parameter.id]: value }); + if (capped) { + return; + } + } + }; + + walk(0, {}); + return capped ? null : count; +} + +/** The values indexing varies this parameter over, given what is set so far. */ +function parameterValues( + parameter: EnumParameter | BooleanParameter, + configuration: ParameterValues, + parameters: ConfigurationParameter[] +): string[] { + if (parameter.type === ParameterType.BOOLEAN) { + return ["true", "false"]; + } + return getVisibleOptions(parameter, configuration, parameters).map( + (option) => option.id + ); +} + export interface EnumerateResult { /** The enumerated configurations, or empty when `capped`. */ configurations: ParameterValues[]; diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index eec7b08f..439a1604 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -14,6 +14,7 @@ import { import { Check, Clock, + FileX, Info, Warning, WarningOctagon, @@ -44,6 +45,7 @@ import { InsertableBuildStatus } from "@backend/features/build-checker/contract"; import { getVendorName, Vendor } from "@backend/features/library/vendors"; +import { ElementType } from "@backend/lib/onshape/element-type"; import { ConfigurationParameter, ParameterType @@ -51,9 +53,10 @@ import { import { AUTO_INDEX_THRESHOLD, type ConfigurationCount, + countCombinations, countConfigurations, IndexingBand, - isIndexedParameter, + MAX_COUNTED_CONFIGURATIONS, MAX_PART_NUMBER_CONFIGURATIONS } from "@backend/features/configurations/combinations"; import { FontWeight, IconSize } from "../../../lib/style-constants"; @@ -533,10 +536,7 @@ function InsertableHoverMenu({ status={status} configurationCount={configurationCount} /> - + @@ -703,11 +703,18 @@ function IndexingRow({ const mutation = useIndexConfigurationsMutation(insertableId); let control: ReactNode; - if (band === IndexingBand.EXCEEDED) { + if (status.elementType === ElementType.ASSEMBLY) { + control = ( + + ); + } else if (band === IndexingBand.EXCEEDED) { control = ( ); } else if (band === IndexingBand.AUTOMATIC) { @@ -788,12 +795,20 @@ function useConfigurationCount( return useMemo(() => countConfigurations(parameters ?? []), [parameters]); } -/** Open-ended past the cap, where enumeration stops before reaching a total. */ +/** The true total, which runs past the index cap the band is decided by. */ +function useDisplayedConfigurationCount( + status: InsertableBuildStatus +): number | null { + const parameters = status.configuration?.parameters; + return useMemo(() => countCombinations(parameters ?? []), [parameters]); +} + +/** Open-ended only past the counting cap, which nothing real reaches. */ function configurationCountValue(count: number | null): StateRowValue { if (count === null) { return { kind: "text", - text: `Over ${MAX_PART_NUMBER_CONFIGURATIONS}` + text: `Over ${MAX_COUNTED_CONFIGURATIONS.toLocaleString()}` }; } if (count === 0) { @@ -804,12 +819,11 @@ function configurationCountValue(count: number | null): StateRowValue { /** The read-only auto-detected facts for an insertable. */ function InsertableParsedSection({ - status, - count + status }: { status: InsertableBuildStatus; - count: number | null; }): ReactNode { + const count = useDisplayedConfigurationCount(status); return ( <> @@ -851,7 +865,9 @@ function ConfigurationSection({ > {parameter.name} - + @@ -863,42 +879,32 @@ function ConfigurationSection({ ); } -/** Why a parameter is or isn't varied when indexing, shown on hover. */ -function getIndexedDescription(parameter: ConfigurationParameter): string { - if (isIndexedParameter(parameter)) { - return "Varied when indexing part numbers, so it multiplies this insertable's configuration count."; - } - if ( - parameter.type === ParameterType.QUANTITY || - parameter.type === ParameterType.STRING - ) { - return "Quantity and text parameters are never varied — they stay at their Onshape default."; - } - return "Excluded from properties, so it stays at its default instead of multiplying the configuration count."; -} - -/** Whether indexing varies this parameter — the lever on the configuration count. */ -function IndexedBadge({ +/** + * Onshape's "exclude from affecting configured properties", which is the lever + * on the configuration count. Part studios only, and Onshape enforces that. + */ +function ExcludedFromPropertiesIcon({ parameter }: { parameter: ConfigurationParameter; }): ReactNode { - const isIndexed = isIndexedParameter(parameter); + if (!parameter.isCosmetic) { + return null; + } return ( - - {isIndexed ? "Indexed" : "Not indexed"} - + ); } From 92ecc372fa33d81f52d04f8c274331d849c16a37 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 22:10:57 +0000 Subject: [PATCH 6/7] Document standalone mode, and keep .env out of the tests Onshape's own entry needs a session and https, so there was no written way to run the app locally; standalone mode is, and it wants three vars rather than the README's OAuth setup. Those vars reached the test Worker as well, where FORCE_SIGNED_IN rewrote what the auth tests assert, so the pool no longer loads .env. Also trims comments added over this branch to the length AGENTS.md asks for, and drops ones the signature already gave. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN1YyJcDC5UGqCoNhfUuNy --- AGENTS.md | 42 +++++++++++++++++++ .../features/configurations/combinations.ts | 17 ++------ .../build-status/components/build-status.tsx | 4 +- .../thumbnails/components/thumbnail.tsx | 3 +- src/frontend/lib/refresh.ts | 5 +-- vitest.config.ts | 4 ++ 6 files changed, 55 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8acc6b47..46dd8421 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,48 @@ Anything the frontend imports from a backend feature must be a leaf module — pure types and functions, no Worker-only imports — or it lands in the client bundle. +# Running the app + +Onshape launches the app at `/init`, which needs a real Onshape session and +https. Standalone mode is the same SPA without either, and is how to drive the +app locally — including headless. Don't build a stub server for the API; run the +real one. + +Put this in `.env` (git-ignored). It is the whole set needed to get a signed-in +admin; the OAuth keys in the README are only for talking to Onshape itself: + +``` +FORCE_SIGNED_IN=true # a fake user, so no OAuth round trip +ACCESS_LEVEL_OVERRIDE=admin # what the server grants +VITE_DEFAULT_ACCESS_LEVEL=admin # what the client renders as +``` + +Then `npm run dev` (applies local D1 migrations, then serves +http://localhost:3000). The dev server goes https only when `localhost-key.pem` +and `localhost.pem` are present, so leave them out for a headless browser. + +The test Worker ignores `.env` (`vitest.config.ts` turns that off), so leaving +one in place does not rewrite what the auth tests assert. + +Where to point it: + +- `/` — redirects to the last library used, from `localStorage`. +- `/app/library/` — a library; ids are in `library-id.ts`. +- `/app/library//groups/` — one group. + +Insert and derive key off a full element path in the search params, which is +what `useIsConnectedToOnshape` tests, so standalone hides them. Append what +Onshape would send to exercise that UI: +`?elementType=PARTSTUDIO&documentId=…&instanceType=w&instanceId=…&elementId=…` + +Local D1 starts empty, so a library renders "No groups found". Import a cert +dump rather than reloading from Onshape, which spends the account's API +allocation: + +``` +npx wrangler d1 execute DB --local --file=.sql +``` + # Cloudflare Workers STOP. Your knowledge of Cloudflare Workers APIs and limits may be outdated. Always retrieve current documentation before any Workers, KV, R2, D1, Durable Objects, Queues, Vectorize, AI, or Agents SDK task. diff --git a/src/backend/features/configurations/combinations.ts b/src/backend/features/configurations/combinations.ts index 484c993e..4df70d2c 100644 --- a/src/backend/features/configurations/combinations.ts +++ b/src/backend/features/configurations/combinations.ts @@ -103,30 +103,22 @@ export function isIndexedParameter( return !parameter.isCosmetic; } -/** - * The most combinations counted out for display. Far past the index cap, which - * bounds work rather than what an admin is shown. - */ +/** The most combinations counted for display, far past the index cap on work. */ export const MAX_COUNTED_CONFIGURATIONS = 100_000; -/** - * The true combination count, which runs past the index cap so the admin card - * can show what there is to bring down. Null past {@link MAX_COUNTED_CONFIGURATIONS}. - */ +/** The true count, which runs past the index cap so the admin card can show it. */ export function countCombinations( parameters: ConfigurationParameter[], cap: number = MAX_COUNTED_CONFIGURATIONS ): number | null { - // Depth-first, unlike enumerateConfigurations: only the count is wanted, so - // one path is held at a time rather than every combination at once. + // Depth-first: only the count is wanted, so one path is held rather than all. const indexed = parameters.filter(isIndexedParameter); let count = 0; let capped = false; const walk = (depth: number, configuration: ParameterValues) => { if (depth === indexed.length) { - // The lone default that nothing-to-vary reaches is not a - // configuration of its own. + // The lone empty default is not a configuration of its own. if (Object.keys(configuration).length > 0) { count++; capped = count > cap; @@ -158,7 +150,6 @@ export function countCombinations( return capped ? null : count; } -/** The values indexing varies this parameter over, given what is set so far. */ function parameterValues( parameter: EnumParameter | BooleanParameter, configuration: ParameterValues, diff --git a/src/frontend/features/build-status/components/build-status.tsx b/src/frontend/features/build-status/components/build-status.tsx index 439a1604..d216342a 100644 --- a/src/frontend/features/build-status/components/build-status.tsx +++ b/src/frontend/features/build-status/components/build-status.tsx @@ -880,8 +880,8 @@ function ConfigurationSection({ } /** - * Onshape's "exclude from affecting configured properties", which is the lever - * on the configuration count. Part studios only, and Onshape enforces that. + * Onshape's "exclude from affecting configured properties", the lever on the + * count. Part studios only, which Onshape itself enforces. */ function ExcludedFromPropertiesIcon({ parameter diff --git a/src/frontend/features/thumbnails/components/thumbnail.tsx b/src/frontend/features/thumbnails/components/thumbnail.tsx index 1f6996a9..8c9b946b 100644 --- a/src/frontend/features/thumbnails/components/thumbnail.tsx +++ b/src/frontend/features/thumbnails/components/thumbnail.tsx @@ -298,8 +298,7 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { ); } - // The url this poll fetched, so the render shows from the response that - // reported it — except a stand-in, which must not displace a real render. + // A stand-in must not displace a render the user already has. const previewUrl = thumbnailQuery.data.isFallback && lastRenderedUrl ? lastRenderedUrl diff --git a/src/frontend/lib/refresh.ts b/src/frontend/lib/refresh.ts index 1253d3f2..d347e0ab 100644 --- a/src/frontend/lib/refresh.ts +++ b/src/frontend/lib/refresh.ts @@ -10,9 +10,8 @@ import { import { useLibraryId } from "../features/library/library-path"; /** - * Refreshes everything scoped to the current library, plus the caller's access. - * Invalidating the snapshot queries also rolls a failed optimistic update back - * to server truth on the refetch. + * Refreshes the current library and the caller's access. Invalidating the + * snapshot queries also rolls a failed optimistic update back to server truth. */ export function useRefreshLibrary(): () => Promise { const router = useRouter(); diff --git a/vitest.config.ts b/vitest.config.ts index bb858c06..d88075f6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,10 @@ import { import { defineConfig } from "vitest/config"; import { alias } from "./vite.config"; +// `.env` is for driving the app locally (see AGENTS.md); letting it reach the +// test Worker would make FORCE_SIGNED_IN rewrite what the auth tests assert. +process.env.CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV = "false"; + export default defineConfig({ test: { projects: [ From 3d7221885f73e4b87f23e01b595753ca818730ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 22:22:02 +0000 Subject: [PATCH 7/7] Collapse the two access-level env vars into one ACCESS_LEVEL_OVERRIDE granted the level and VITE_DEFAULT_ACCESS_LEVEL picked the one viewed, so both had to be set to the same thing to get a usable dev session. Wrangler puts .env on the Worker's env as well as Vite's, so one VITE_-prefixed entry reaches both sides. The override also outranked a real Onshape session anywhere it was set, despite the README saying it did nothing in production. It is now gated the way FORCE_SIGNED_IN already was, with a test that fails without it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DN1YyJcDC5UGqCoNhfUuNy --- AGENTS.md | 5 +-- README.md | 8 ++-- src/backend/features/auth/caller.test.ts | 45 +++++++++++++++++++++ src/backend/features/auth/caller.ts | 20 +++++++-- src/backend/features/auth/guards.test.ts | 2 +- src/backend/features/auth/guards.ts | 2 +- src/backend/lib/context.ts | 3 +- src/frontend/features/auth/access-level.tsx | 4 +- src/frontend/vite-env.d.ts | 4 +- 9 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 src/backend/features/auth/caller.test.ts diff --git a/AGENTS.md b/AGENTS.md index 46dd8421..caea24ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,9 +38,8 @@ Put this in `.env` (git-ignored). It is the whole set needed to get a signed-in admin; the OAuth keys in the README are only for talking to Onshape itself: ``` -FORCE_SIGNED_IN=true # a fake user, so no OAuth round trip -ACCESS_LEVEL_OVERRIDE=admin # what the server grants -VITE_DEFAULT_ACCESS_LEVEL=admin # what the client renders as +FORCE_SIGNED_IN=true # a fake user, so no OAuth round trip +VITE_ACCESS_LEVEL_OVERRIDE=admin # granted by the server, and viewed by the client ``` Then `npm run dev` (applies local D1 migrations, then serves diff --git a/README.md b/README.md index 83000505..c2f28784 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,9 @@ OAUTH_CLIENT_ID= OAUTH_CLIENT_SECRET= SESSION_SECRET=gNSzdRbs4dJYz0obHfeRwaD+u5QbZgJx+V8/rgUH6AiOdoppP3wjeaM97nZmxeJa -# One of admin, editor, or user. Sets the max access level granted. Does nothing in production. -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 +# One of admin, editor, or user. Granted by the server and viewed by the client, +# so both sides agree. Ignored in production. +VITE_ACCESS_LEVEL_OVERRIDE=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. diff --git a/src/backend/features/auth/caller.test.ts b/src/backend/features/auth/caller.test.ts new file mode 100644 index 00000000..8a5d0b82 --- /dev/null +++ b/src/backend/features/auth/caller.test.ts @@ -0,0 +1,45 @@ +import { env } from "cloudflare:workers"; +import { env as processEnv } from "process"; +import { afterEach, describe, expect, it } from "vitest"; +import { AccessLevel } from "./access-level"; +import { productionCaller } from "./caller"; +import { createApp } from "../../app"; +import { jsonRequest } from "../../../__test_utils__"; + +const app = createApp(productionCaller); + +/** What the real caller resolves for a request carrying no Onshape session. */ +async function getMaxAccessLevel(override?: AccessLevel): Promise { + const res = await app.request("/api/access-data", jsonRequest("GET"), { + ...env, + VITE_ACCESS_LEVEL_OVERRIDE: override + }); + const body: { maxAccessLevel: AccessLevel } = await res.json(); + return body.maxAccessLevel; +} + +describe("the dev access-level override", () => { + const nodeEnv = processEnv.NODE_ENV; + afterEach(() => { + processEnv.NODE_ENV = nodeEnv; + }); + + it("grants the level it names", async () => { + expect(await getMaxAccessLevel(AccessLevel.ADMIN)).toBe( + AccessLevel.ADMIN + ); + }); + + // It is the one thing standing between a stray env var and admin, so it + // must not survive a production build. + it("is ignored in production", async () => { + processEnv.NODE_ENV = "production"; + expect(await getMaxAccessLevel(AccessLevel.ADMIN)).toBe( + AccessLevel.USER + ); + }); + + it("leaves an unset override to the caller's own session", async () => { + expect(await getMaxAccessLevel()).toBe(AccessLevel.USER); + }); +}); diff --git a/src/backend/features/auth/caller.ts b/src/backend/features/auth/caller.ts index 1de61134..4ff2fc15 100644 --- a/src/backend/features/auth/caller.ts +++ b/src/backend/features/auth/caller.ts @@ -91,9 +91,21 @@ export async function isAuthenticated(c: AppContext): Promise { } } -/** FORCE_SIGNED_IN is a dev-only escape hatch, ignored in production. */ +/** The dev-only escape hatches are ignored in production. */ +function isDevelopment(): boolean { + return processEnv.NODE_ENV !== "production"; +} + export function isForceSignedIn(c: AppContext): boolean { - return !!c.env.FORCE_SIGNED_IN && processEnv.NODE_ENV !== "production"; + return !!c.env.FORCE_SIGNED_IN && isDevelopment(); +} + +/** The access level granted without asking Onshape, in dev only. */ +function getAccessLevelOverride(c: AppContext): AccessLevel | undefined { + if (!isDevelopment()) { + return undefined; + } + return c.env.VITE_ACCESS_LEVEL_OVERRIDE as AccessLevel | undefined; } /** @@ -157,8 +169,8 @@ export const productionCaller: CallerFactory = (c) => ({ return getCachedUserId(c); }, getAccessLevel: async () => { - const override = c.env.ACCESS_LEVEL_OVERRIDE; - if (override) return override as AccessLevel; + const override = getAccessLevelOverride(c); + if (override) return override; // getCachedAccessLevel needs a real Onshape session, so only call it // for a genuinely signed-in caller (not FORCE_SIGNED_IN). if (!isForceSignedIn(c) && (await isSignedIn(c))) { diff --git a/src/backend/features/auth/guards.test.ts b/src/backend/features/auth/guards.test.ts index 79d20848..3c2e9c6d 100644 --- a/src/backend/features/auth/guards.test.ts +++ b/src/backend/features/auth/guards.test.ts @@ -55,7 +55,7 @@ describe("requireEditorMiddleware", () => { }); // Access level alone would admit a signed-out caller wherever it is - // granted without a session, e.g. behind a dev ACCESS_LEVEL_OVERRIDE. + // granted without a session, e.g. behind a dev access-level override. it("401s an editor-level caller who is not signed in", async () => { const app = createTestApp({ signedIn: false, diff --git a/src/backend/features/auth/guards.ts b/src/backend/features/auth/guards.ts index afe15042..36fa1798 100644 --- a/src/backend/features/auth/guards.ts +++ b/src/backend/features/auth/guards.ts @@ -25,7 +25,7 @@ export const requireSignInMiddleware: MiddlewareHandler = async ( /** * Editing implies a session: access level alone would admit a signed-out caller - * under a dev `ACCESS_LEVEL_OVERRIDE`, and answer 403 rather than 401 otherwise. + * under a dev access-level override, and answer 403 rather than 401 otherwise. */ export const requireEditorMiddleware: MiddlewareHandler = async ( c, diff --git a/src/backend/lib/context.ts b/src/backend/lib/context.ts index 025817d6..172da95e 100644 --- a/src/backend/lib/context.ts +++ b/src/backend/lib/context.ts @@ -18,7 +18,8 @@ export interface AppBindings { /** Renders a configuration's thumbnails outside a request; see ThumbnailWorkflow. */ THUMBNAIL_WORKFLOW: Workflow; ADMIN_TEAM: string; - ACCESS_LEVEL_OVERRIDE?: string; + /** Dev-only: the access level granted, bypassing Onshape. */ + VITE_ACCESS_LEVEL_OVERRIDE?: string; /** Testing-only: treat requests as signed in with a fake user. Not for production. */ FORCE_SIGNED_IN?: string; } diff --git a/src/frontend/features/auth/access-level.tsx b/src/frontend/features/auth/access-level.tsx index c4ab96fd..32d58dbf 100644 --- a/src/frontend/features/auth/access-level.tsx +++ b/src/frontend/features/auth/access-level.tsx @@ -16,9 +16,9 @@ const DEFAULT_ACCESS_DATA: AccessData = { signedIn: false }; -/** The level the app is viewed as by default; overridable in dev via a Vite var. */ +/** The level the app is viewed as by default; the dev override grants it too. */ const DEFAULT_ACCESS_LEVEL = - (import.meta.env.VITE_DEFAULT_ACCESS_LEVEL as AccessLevel | undefined) ?? + (import.meta.env.VITE_ACCESS_LEVEL_OVERRIDE as AccessLevel | undefined) ?? AccessLevel.USER; export function getAccessDataQuery() { diff --git a/src/frontend/vite-env.d.ts b/src/frontend/vite-env.d.ts index 892a08a5..c51c8a0f 100644 --- a/src/frontend/vite-env.d.ts +++ b/src/frontend/vite-env.d.ts @@ -1,8 +1,8 @@ /// interface ImportMetaEnv { - /** Dev-only: the access level the app is viewed as by default. */ - readonly VITE_DEFAULT_ACCESS_LEVEL?: string; + /** Dev-only: the access level granted, and so the one viewed by default. */ + readonly VITE_ACCESS_LEVEL_OVERRIDE?: string; } interface ImportMeta {