From 8420ce42e3c54015dda13a813eac29074ef23b2e Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 26 May 2026 09:03:18 -0600 Subject: [PATCH] Fix loading of recommended methods --- app/(app)/create/CreateFlowLayoutClient.tsx | 2 + .../create/hooks/useFacetRecommendations.ts | 107 ++++++++++-------- .../create/hooks/useMethodCardDeckOrdering.ts | 5 +- .../usePrefetchMethodFacetRecommendations.ts | 27 +++++ .../card/CommunicationMethodsScreen.tsx | 10 +- .../screens/card/ConflictManagementScreen.tsx | 10 +- .../screens/card/MembershipMethodsScreen.tsx | 10 +- .../right-rail/DecisionApproachesScreen.tsx | 10 +- lib/create/facetRecommendationsLoad.ts | 76 +++++++++++++ next.config.mjs | 8 +- tests/unit/facetRecommendationsLoad.test.ts | 54 +++++++++ 11 files changed, 261 insertions(+), 58 deletions(-) create mode 100644 app/(app)/create/hooks/usePrefetchMethodFacetRecommendations.ts create mode 100644 lib/create/facetRecommendationsLoad.ts create mode 100644 tests/unit/facetRecommendationsLoad.test.ts diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 9fb3986..2af41dd 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -12,6 +12,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; +import { usePrefetchMethodFacetRecommendations } from "./hooks/usePrefetchMethodFacetRecommendations"; import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize"; import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions"; import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport"; @@ -167,6 +168,7 @@ function CreateFlowLayoutContent({ replaceState, markCreateFlowInteraction, } = useCreateFlow(); + usePrefetchMethodFacetRecommendations(); const manageStakeholdersIntent = searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) === CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE; diff --git a/app/(app)/create/hooks/useFacetRecommendations.ts b/app/(app)/create/hooks/useFacetRecommendations.ts index 42b4efd..f476a14 100644 --- a/app/(app)/create/hooks/useFacetRecommendations.ts +++ b/app/(app)/create/hooks/useFacetRecommendations.ts @@ -1,8 +1,13 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString"; import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets"; +import { + buildFacetRecommendationRequestKey, + getCachedFacetScores, + loadFacetScores, +} from "../../../../lib/create/facetRecommendationsLoad"; import { useCreateFlow } from "../context/CreateFlowContext"; /** @@ -25,6 +30,34 @@ export type FacetRecommendationsResult = { const EMPTY_SCORES: Record = {}; +function initialFacetRecommendationsResult( + section: RecommendationSection, + queryString: string, +): FacetRecommendationsResult { + const hasAnyFacets = queryString.length > 0; + if (!hasAnyFacets) { + return { + isReady: true, + scoresBySlug: EMPTY_SCORES, + hasAnyFacets: false, + }; + } + const requestKey = buildFacetRecommendationRequestKey(section, queryString); + const cached = getCachedFacetScores(requestKey); + if (cached) { + return { + isReady: true, + scoresBySlug: cached, + hasAnyFacets: true, + }; + } + return { + isReady: false, + scoresBySlug: EMPTY_SCORES, + hasAnyFacets: true, + }; +} + /** * Calls `GET /api/create-flow/methods?section=
&facet.*=...` for the * card-deck step `section` and returns a `slug → score` map for re-ranking @@ -46,14 +79,9 @@ export function useFacetRecommendations( ); const hasAnyFacets = queryString.length > 0; - const [result, setResult] = useState({ - isReady: !hasAnyFacets, - scoresBySlug: EMPTY_SCORES, - hasAnyFacets, - }); - - // Track the last successful request input so we don't re-fetch on every state poke. - const lastQueryRef = useRef(null); + const [result, setResult] = useState(() => + initialFacetRecommendationsResult(section, queryString), + ); useEffect(() => { if (!hasAnyFacets) { @@ -62,51 +90,34 @@ export function useFacetRecommendations( scoresBySlug: EMPTY_SCORES, hasAnyFacets: false, }); - lastQueryRef.current = null; return; } - const requestKey = `${section}?${queryString}`; - if (lastQueryRef.current === requestKey) return; - lastQueryRef.current = requestKey; - const ctrl = new AbortController(); - setResult((prev) => ({ ...prev, isReady: false, hasAnyFacets: true })); - fetch(`/api/create-flow/methods?section=${section}&${queryString}`, { - credentials: "include", - signal: ctrl.signal, - }) - .then(async (res) => { - if (!res.ok) throw new Error(`status ${res.status}`); - return (await res.json()) as { - methods?: { slug: string; matches?: { score?: number } }[]; - }; - }) - .then((json) => { - const scoresBySlug: Record = {}; - for (const m of json.methods ?? []) { - if (typeof m.slug === "string") { - scoresBySlug[m.slug] = m.matches?.score ?? 0; - } - } - setResult({ isReady: true, scoresBySlug, hasAnyFacets: true }); - }) - .catch((e) => { - if ((e as { name?: string }).name === "AbortError") return; - setResult({ - isReady: true, - scoresBySlug: EMPTY_SCORES, - hasAnyFacets: true, - }); + const requestKey = buildFacetRecommendationRequestKey(section, queryString); + const cached = getCachedFacetScores(requestKey); + if (cached) { + setResult({ + isReady: true, + scoresBySlug: cached, + hasAnyFacets: true, }); + return; + } + + let cancelled = false; + setResult((prev) => + prev.isReady && prev.hasAnyFacets + ? { ...prev, isReady: false } + : { isReady: false, scoresBySlug: EMPTY_SCORES, hasAnyFacets: true }, + ); + + void loadFacetScores(section, queryString).then((scoresBySlug) => { + if (cancelled) return; + setResult({ isReady: true, scoresBySlug, hasAnyFacets: true }); + }); return () => { - ctrl.abort(); - // Clear the dedup key so React 19 Strict Mode's mount → unmount → mount - // cycle (and any future remount) re-issues the request instead of - // returning early on the same key. - if (lastQueryRef.current === requestKey) { - lastQueryRef.current = null; - } + cancelled = true; }; }, [section, queryString, hasAnyFacets]); diff --git a/app/(app)/create/hooks/useMethodCardDeckOrdering.ts b/app/(app)/create/hooks/useMethodCardDeckOrdering.ts index 050616b..6b0010e 100644 --- a/app/(app)/create/hooks/useMethodCardDeckOrdering.ts +++ b/app/(app)/create/hooks/useMethodCardDeckOrdering.ts @@ -25,7 +25,9 @@ export function useMethodCardDeckOrdering( methods: readonly MethodEntry[], selectedIds: readonly string[], ) { - const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section); + const { scoresBySlug, hasAnyFacets, isReady } = + useFacetRecommendations(section); + const recommendationsReady = !hasAnyFacets || isReady; const rankedMethods = useMemo( () => rankMethodsByScore(methods, scoresBySlug), @@ -90,5 +92,6 @@ export function useMethodCardDeckOrdering( recommendedIds, sampleCards, methodById, + recommendationsReady, }; } diff --git a/app/(app)/create/hooks/usePrefetchMethodFacetRecommendations.ts b/app/(app)/create/hooks/usePrefetchMethodFacetRecommendations.ts new file mode 100644 index 0000000..a535260 --- /dev/null +++ b/app/(app)/create/hooks/usePrefetchMethodFacetRecommendations.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString"; +import { METHOD_FACET_API_SECTION_IDS } from "../../../../lib/create/customRuleFacets"; +import { loadFacetScores } from "../../../../lib/create/facetRecommendationsLoad"; +import { useCreateFlow } from "../context/CreateFlowContext"; + +/** + * Warms the facet recommendation cache for all method-deck sections once the + * user has community facet selections, so method screens can render ranked + * cards on first paint instead of flashing authoring order. + */ +export function usePrefetchMethodFacetRecommendations(): void { + const { state } = useCreateFlow(); + const queryString = useMemo( + () => buildFacetQueryString(state), + [state], + ); + + useEffect(() => { + if (queryString.length === 0) return; + for (const section of METHOD_FACET_API_SECTION_IDS) { + void loadFacetScores(section, queryString); + } + }, [queryString]); +} diff --git a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx index 1cd86a1..c503919 100644 --- a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx +++ b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx @@ -97,7 +97,8 @@ export function CommunicationMethodsScreen() { [comm.methods, selectedIds, state.customMethodCardMetaById], ); - const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( + const { sampleCards, compactCardIds, methodById, recommendationsReady } = + useMethodCardDeckOrdering( "communication", mergedMethods, selectedIds, @@ -735,7 +736,11 @@ export function CommunicationMethodsScreen() { justification="center" /> -
+
+ {recommendationsReady ? ( + ) : null}
diff --git a/app/(app)/create/screens/card/ConflictManagementScreen.tsx b/app/(app)/create/screens/card/ConflictManagementScreen.tsx index 30d2ab4..e7aa661 100644 --- a/app/(app)/create/screens/card/ConflictManagementScreen.tsx +++ b/app/(app)/create/screens/card/ConflictManagementScreen.tsx @@ -94,7 +94,8 @@ export function ConflictManagementScreen() { [cm.methods, selectedIds, state.customMethodCardMetaById], ); - const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( + const { sampleCards, compactCardIds, methodById, recommendationsReady } = + useMethodCardDeckOrdering( "conflictManagement", mergedMethods, selectedIds, @@ -734,7 +735,11 @@ export function ConflictManagementScreen() { justification="center" /> -
+
+ {recommendationsReady ? ( + ) : null}
diff --git a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx index 1afe6e3..d32df61 100644 --- a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx +++ b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx @@ -95,7 +95,8 @@ export function MembershipMethodsScreen() { [mem.methods, selectedIds, state.customMethodCardMetaById], ); - const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( + const { sampleCards, compactCardIds, methodById, recommendationsReady } = + useMethodCardDeckOrdering( "membership", mergedMethods, selectedIds, @@ -727,7 +728,11 @@ export function MembershipMethodsScreen() { justification="center" /> -
+
+ {recommendationsReady ? ( + ) : null}
diff --git a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx index 86db7b7..d2e7df7 100644 --- a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx +++ b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx @@ -108,7 +108,8 @@ export function DecisionApproachesScreen() { [da.methods, selectedIds, state.customMethodCardMetaById], ); - const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( + const { sampleCards, compactCardIds, methodById, recommendationsReady } = + useMethodCardDeckOrdering( "decisionApproaches", mergedMethods, selectedIds, @@ -761,7 +762,11 @@ export function DecisionApproachesScreen() { } > -
+
+ {recommendationsReady ? ( + ) : null}
; + +const EMPTY_SCORES: FacetScoresBySlug = {}; + +const cache = new Map(); +const inFlight = new Map>(); + +export function buildFacetRecommendationRequestKey( + section: MethodFacetApiSectionId, + queryString: string, +): string { + return `${section}?${queryString}`; +} + +export function getCachedFacetScores( + requestKey: string, +): FacetScoresBySlug | undefined { + return cache.get(requestKey); +} + +function parseScoresFromMethodsJson(json: { + methods?: { slug: string; matches?: { score?: number } }[]; +}): FacetScoresBySlug { + const scoresBySlug: FacetScoresBySlug = {}; + for (const m of json.methods ?? []) { + if (typeof m.slug === "string") { + scoresBySlug[m.slug] = m.matches?.score ?? 0; + } + } + return scoresBySlug; +} + +async function fetchFacetScoresFromApi( + section: MethodFacetApiSectionId, + queryString: string, +): Promise { + const res = await fetch( + `/api/create-flow/methods?section=${section}&${queryString}`, + { credentials: "include" }, + ); + if (!res.ok) throw new Error(`status ${res.status}`); + const json = (await res.json()) as { + methods?: { slug: string; matches?: { score?: number } }[]; + }; + return parseScoresFromMethodsJson(json); +} + +/** + * Loads facet recommendation scores for one method deck. Results are cached + * and in-flight requests are deduped so prefetch + screen hooks share work. + */ +export function loadFacetScores( + section: MethodFacetApiSectionId, + queryString: string, +): Promise { + const requestKey = buildFacetRecommendationRequestKey(section, queryString); + const cached = cache.get(requestKey); + if (cached) return Promise.resolve(cached); + + let pending = inFlight.get(requestKey); + if (!pending) { + pending = fetchFacetScoresFromApi(section, queryString) + .then((scores) => { + cache.set(requestKey, scores); + return scores; + }) + .catch(() => EMPTY_SCORES) + .finally(() => { + inFlight.delete(requestKey); + }); + inFlight.set(requestKey, pending); + } + return pending; +} diff --git a/next.config.mjs b/next.config.mjs index 510c153..0cc418d 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,7 +2,7 @@ import createMDX from "@next/mdx"; /* eslint-env node */ -/** Keep viewBox so inline SVGR art can scale/center like `object-contain`. */ +/** Keep viewBox and unique clip/mask IDs when multiple SVGR icons share a page. */ const svgrLoaderOptions = { svgoConfig: { plugins: [ @@ -14,6 +14,12 @@ const svgrLoaderOptions = { }, }, }, + { + name: "prefixIds", + params: { + prefixClassNames: false, + }, + }, ], }, }; diff --git a/tests/unit/facetRecommendationsLoad.test.ts b/tests/unit/facetRecommendationsLoad.test.ts new file mode 100644 index 0000000..518f3cd --- /dev/null +++ b/tests/unit/facetRecommendationsLoad.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildFacetRecommendationRequestKey, + getCachedFacetScores, + loadFacetScores, +} from "../../lib/create/facetRecommendationsLoad"; + +describe("loadFacetScores", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + methods: [ + { slug: "slack", matches: { score: 3 } }, + { slug: "email", matches: { score: 1 } }, + ], + }), + }), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("caches scores by section and facet query", async () => { + const queryString = "facet.size=small"; + const scores = await loadFacetScores("communication", queryString); + + expect(scores).toEqual({ slack: 3, email: 1 }); + expect( + getCachedFacetScores( + buildFacetRecommendationRequestKey("communication", queryString), + ), + ).toEqual(scores); + expect(fetch).toHaveBeenCalledTimes(1); + + await loadFacetScores("communication", queryString); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("dedupes in-flight requests for the same key", async () => { + const queryString = "facet.size=small"; + const [a, b] = await Promise.all([ + loadFacetScores("membership", queryString), + loadFacetScores("membership", queryString), + ]); + + expect(a).toEqual(b); + expect(fetch).toHaveBeenCalledTimes(1); + }); +});