From 88dccc0990a7601f042f3f9d4db049ae6b2b2244 Mon Sep 17 00:00:00 2001 From: Jack D Date: Wed, 11 Mar 2026 14:06:23 +0000 Subject: [PATCH 1/2] complete proposal for expanding widget convex wrapper hooks --- apps/widget/src/AuthoringOverlay.tsx | 28 ++--- apps/widget/src/ChecklistOverlay.tsx | 23 ++-- apps/widget/src/CsatPrompt.tsx | 8 +- apps/widget/src/OutboundOverlay.tsx | 18 +-- apps/widget/src/SurveyOverlay.tsx | 13 +-- apps/widget/src/TooltipAuthoringOverlay.tsx | 26 +++-- apps/widget/src/Widget.tsx | 78 ++++++------- apps/widget/src/components/Home.tsx | 20 ++-- apps/widget/src/hooks/useEventTracking.ts | 13 +-- .../widget/src/hooks/useNavigationTracking.ts | 8 +- .../src/hooks/useWidgetConversationFlow.ts | 13 +-- apps/widget/src/hooks/useWidgetSession.ts | 23 ++-- apps/widget/src/hooks/useWidgetSettings.ts | 8 +- .../src/hooks/useWidgetShellValidation.ts | 13 +-- apps/widget/src/hooks/useWidgetTicketFlow.ts | 33 +++--- apps/widget/src/lib/convex/hooks.ts | 40 ++++--- .../widget/src/test/refHardeningGuard.test.ts | 105 +++++++++++++++--- .../src/tourOverlay/useTourOverlaySession.ts | 8 +- .../tasks.md | 24 ++-- 19 files changed, 271 insertions(+), 231 deletions(-) diff --git a/apps/widget/src/AuthoringOverlay.tsx b/apps/widget/src/AuthoringOverlay.tsx index 13d03ce..ad463f0 100644 --- a/apps/widget/src/AuthoringOverlay.tsx +++ b/apps/widget/src/AuthoringOverlay.tsx @@ -1,10 +1,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared"; import { scoreSelectorQuality } from "@opencom/sdk-core"; import { ErrorFeedbackBanner } from "./components/ErrorFeedbackBanner"; +import { + useWidgetMutation, + useWidgetQuery, + widgetMutationRef, + widgetQueryRef, +} from "./lib/convex/hooks"; interface TourStep { _id: Id<"tourSteps">; @@ -46,14 +50,12 @@ type AuthoringSessionData = session?: null; }; -const validateAuthoringSessionQueryRef = makeFunctionReference< - "query", +const validateAuthoringSessionQueryRef = widgetQueryRef< { token: string }, AuthoringSessionData | null >("authoringSessions:validate"); -const updateAuthoringStepMutationRef = makeFunctionReference< - "mutation", +const updateAuthoringStepMutationRef = widgetMutationRef< { token: string; stepId: Id<"tourSteps">; @@ -64,14 +66,12 @@ const updateAuthoringStepMutationRef = makeFunctionReference< null >("authoringSessions:updateStep"); -const setAuthoringCurrentStepMutationRef = makeFunctionReference< - "mutation", +const setAuthoringCurrentStepMutationRef = widgetMutationRef< { token: string; stepId: Id<"tourSteps"> }, null >("authoringSessions:setCurrentStep"); -const endAuthoringSessionMutationRef = makeFunctionReference< - "mutation", +const endAuthoringSessionMutationRef = widgetMutationRef< { token: string }, null >("authoringSessions:end"); @@ -184,12 +184,12 @@ export function AuthoringOverlay({ token, onExit }: AuthoringOverlayProps) { ); const overlayRef = useRef(null); - const sessionData = useQuery(validateAuthoringSessionQueryRef, { token }) as + const sessionData = useWidgetQuery(validateAuthoringSessionQueryRef, { token }) as | AuthoringSessionData | undefined; - const updateStepMutation = useMutation(updateAuthoringStepMutationRef); - const setCurrentStepMutation = useMutation(setAuthoringCurrentStepMutationRef); - const endSessionMutation = useMutation(endAuthoringSessionMutationRef); + const updateStepMutation = useWidgetMutation(updateAuthoringStepMutationRef); + const setCurrentStepMutation = useWidgetMutation(setAuthoringCurrentStepMutationRef); + const endSessionMutation = useWidgetMutation(endAuthoringSessionMutationRef); const steps = useMemo( () => (sessionData?.valid ? (sessionData.steps ?? []) : []), diff --git a/apps/widget/src/ChecklistOverlay.tsx b/apps/widget/src/ChecklistOverlay.tsx index d5a5c43..f6073d7 100644 --- a/apps/widget/src/ChecklistOverlay.tsx +++ b/apps/widget/src/ChecklistOverlay.tsx @@ -1,9 +1,13 @@ import { useState, useCallback } from "react"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { CheckSquare, ChevronDown, ChevronUp, ExternalLink } from "./icons"; import type { Id } from "@opencom/convex/dataModel"; import { safeOpenUrl } from "./utils/safeOpenUrl"; +import { + useWidgetMutation, + useWidgetQuery, + widgetMutationRef, + widgetQueryRef, +} from "./lib/convex/hooks"; interface ChecklistTask { id: string; @@ -37,14 +41,12 @@ type EligibleChecklistRecord = { progress: { completedTaskIds: string[] } | null; }; -const eligibleChecklistsQueryRef = makeFunctionReference< - "query", +const eligibleChecklistsQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; sessionToken?: string }, EligibleChecklistRecord[] >("checklists:getEligible"); -const completeChecklistTaskMutationRef = makeFunctionReference< - "mutation", +const completeChecklistTaskMutationRef = widgetMutationRef< { visitorId: Id<"visitors">; checklistId: Id<"checklists">; @@ -55,8 +57,7 @@ const completeChecklistTaskMutationRef = makeFunctionReference< null >("checklists:completeTask"); -const uncompleteChecklistTaskMutationRef = makeFunctionReference< - "mutation", +const uncompleteChecklistTaskMutationRef = widgetMutationRef< { visitorId: Id<"visitors">; checklistId: Id<"checklists">; @@ -75,14 +76,14 @@ export function ChecklistOverlay({ }: ChecklistOverlayProps) { const [expandedChecklist, setExpandedChecklist] = useState | null>(null); - const eligibleChecklists = useQuery(eligibleChecklistsQueryRef, { + const eligibleChecklists = useWidgetQuery(eligibleChecklistsQueryRef, { workspaceId, visitorId, sessionToken: sessionToken ?? undefined, }) as EligibleChecklistRecord[] | undefined; - const completeTask = useMutation(completeChecklistTaskMutationRef); - const uncompleteTask = useMutation(uncompleteChecklistTaskMutationRef); + const completeTask = useWidgetMutation(completeChecklistTaskMutationRef); + const uncompleteTask = useWidgetMutation(uncompleteChecklistTaskMutationRef); const handleTaskClick = useCallback( async (checklist: Checklist, task: ChecklistTask, isCompleted: boolean) => { diff --git a/apps/widget/src/CsatPrompt.tsx b/apps/widget/src/CsatPrompt.tsx index 4466e0f..6994e0c 100644 --- a/apps/widget/src/CsatPrompt.tsx +++ b/apps/widget/src/CsatPrompt.tsx @@ -1,11 +1,9 @@ import { useState } from "react"; -import { useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { X } from "./icons"; import type { Id } from "@opencom/convex/dataModel"; +import { useWidgetMutation, widgetMutationRef } from "./lib/convex/hooks"; -const submitCsatResponseMutationRef = makeFunctionReference< - "mutation", +const submitCsatResponseMutationRef = widgetMutationRef< { conversationId: Id<"conversations">; rating: number; @@ -37,7 +35,7 @@ export function CsatPrompt({ const [submitted, setSubmitted] = useState(false); const [error, setError] = useState(null); - const submitCsat = useMutation(submitCsatResponseMutationRef); + const submitCsat = useWidgetMutation(submitCsatResponseMutationRef); const handleSubmit = async () => { if (rating === null) return; diff --git a/apps/widget/src/OutboundOverlay.tsx b/apps/widget/src/OutboundOverlay.tsx index bea6804..5ec106c 100644 --- a/apps/widget/src/OutboundOverlay.tsx +++ b/apps/widget/src/OutboundOverlay.tsx @@ -1,6 +1,4 @@ import { useState, useEffect, useCallback, useRef, useMemo, type MouseEvent } from "react"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { X } from "./icons"; import type { Id } from "@opencom/convex/dataModel"; import type { @@ -8,6 +6,12 @@ import type { OutboundClickAction, } from "@opencom/types"; import { safeOpenUrl } from "./utils/safeOpenUrl"; +import { + useWidgetMutation, + useWidgetQuery, + widgetMutationRef, + widgetQueryRef, +} from "./lib/convex/hooks"; type ClickAction = OutboundClickAction>; type OutboundMessage = EligibleOutboundMessage< @@ -17,8 +21,7 @@ type OutboundMessage = EligibleOutboundMessage< Id<"articles"> >; -const eligibleOutboundMessagesQueryRef = makeFunctionReference< - "query", +const eligibleOutboundMessagesQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; @@ -29,8 +32,7 @@ const eligibleOutboundMessagesQueryRef = makeFunctionReference< OutboundMessage[] >("outboundMessages:getEligible"); -const trackOutboundImpressionMutationRef = makeFunctionReference< - "mutation", +const trackOutboundImpressionMutationRef = widgetMutationRef< { messageId: Id<"outboundMessages">; visitorId: Id<"visitors">; @@ -97,7 +99,7 @@ export function OutboundOverlay({ const [timeOnPage, setTimeOnPage] = useState(0); const staggerTimers = useRef[]>([]); - const eligibleMessages = useQuery(eligibleOutboundMessagesQueryRef, { + const eligibleMessages = useWidgetQuery(eligibleOutboundMessagesQueryRef, { workspaceId, visitorId, sessionToken: sessionToken ?? undefined, @@ -105,7 +107,7 @@ export function OutboundOverlay({ sessionId, }) as OutboundMessage[] | undefined; - const trackImpression = useMutation(trackOutboundImpressionMutationRef); + const trackImpression = useWidgetMutation(trackOutboundImpressionMutationRef); // Track scroll depth useEffect(() => { diff --git a/apps/widget/src/SurveyOverlay.tsx b/apps/widget/src/SurveyOverlay.tsx index 7606209..46f7a3b 100644 --- a/apps/widget/src/SurveyOverlay.tsx +++ b/apps/widget/src/SurveyOverlay.tsx @@ -1,6 +1,4 @@ import { useState, useEffect } from "react"; -import { useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { normalizeSurveyAnswerValue } from "./surveyOverlay/answers"; import { LargeFormatContainer, @@ -8,9 +6,9 @@ import { SurveyQuestionRenderer, } from "./surveyOverlay/components"; import type { SurveyOverlayProps } from "./surveyOverlay/types"; +import { useWidgetMutation, widgetMutationRef } from "./lib/convex/hooks"; -const submitSurveyResponseMutationRef = makeFunctionReference< - "mutation", +const submitSurveyResponseMutationRef = widgetMutationRef< { surveyId: string; visitorId: string; @@ -22,8 +20,7 @@ const submitSurveyResponseMutationRef = makeFunctionReference< null >("surveys:submitResponse"); -const recordSurveyImpressionMutationRef = makeFunctionReference< - "mutation", +const recordSurveyImpressionMutationRef = widgetMutationRef< { surveyId: string; visitorId: string; @@ -48,8 +45,8 @@ export function SurveyOverlay({ const [isSubmitting, setIsSubmitting] = useState(false); const [showThankYou, setShowThankYou] = useState(false); - const submitResponse = useMutation(submitSurveyResponseMutationRef); - const recordImpression = useMutation(recordSurveyImpressionMutationRef); + const submitResponse = useWidgetMutation(submitSurveyResponseMutationRef); + const recordImpression = useWidgetMutation(recordSurveyImpressionMutationRef); const totalSteps = survey.questions.length; const isIntroStep = currentStep === -1; diff --git a/apps/widget/src/TooltipAuthoringOverlay.tsx b/apps/widget/src/TooltipAuthoringOverlay.tsx index 23a6598..74533c7 100644 --- a/apps/widget/src/TooltipAuthoringOverlay.tsx +++ b/apps/widget/src/TooltipAuthoringOverlay.tsx @@ -1,10 +1,14 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { scoreSelectorQuality, type SelectorQualityMetadata } from "@opencom/sdk-core"; import type { Id } from "@opencom/convex/dataModel"; import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared"; import { ErrorFeedbackBanner } from "./components/ErrorFeedbackBanner"; +import { + useWidgetMutation, + useWidgetQuery, + widgetMutationRef, + widgetQueryRef, +} from "./lib/convex/hooks"; interface TooltipAuthoringOverlayProps { token: string; @@ -34,14 +38,12 @@ type TooltipSessionData = tooltip?: null; }; -const validateTooltipAuthoringSessionQueryRef = makeFunctionReference< - "query", +const validateTooltipAuthoringSessionQueryRef = widgetQueryRef< { token: string; workspaceId: Id<"workspaces"> }, TooltipSessionData | null >("tooltipAuthoringSessions:validate"); -const updateTooltipSelectorMutationRef = makeFunctionReference< - "mutation", +const updateTooltipSelectorMutationRef = widgetMutationRef< { token: string; workspaceId: Id<"workspaces">; @@ -51,8 +53,7 @@ const updateTooltipSelectorMutationRef = makeFunctionReference< null >("tooltipAuthoringSessions:updateSelector"); -const endTooltipAuthoringSessionMutationRef = makeFunctionReference< - "mutation", +const endTooltipAuthoringSessionMutationRef = widgetMutationRef< { token: string; workspaceId: Id<"workspaces"> }, null >("tooltipAuthoringSessions:end"); @@ -142,11 +143,14 @@ export function TooltipAuthoringOverlay({ ); const overlayRef = useRef(null); - const sessionData = useQuery(validateTooltipAuthoringSessionQueryRef, { token, workspaceId }) as + const sessionData = useWidgetQuery(validateTooltipAuthoringSessionQueryRef, { + token, + workspaceId, + }) as | TooltipSessionData | undefined; - const updateSelectorMutation = useMutation(updateTooltipSelectorMutationRef); - const endSessionMutation = useMutation(endTooltipAuthoringSessionMutationRef); + const updateSelectorMutation = useWidgetMutation(updateTooltipSelectorMutationRef); + const endSessionMutation = useWidgetMutation(endTooltipAuthoringSessionMutationRef); const tooltip = sessionData?.valid ? sessionData.tooltip : null; diff --git a/apps/widget/src/Widget.tsx b/apps/widget/src/Widget.tsx index 1443dca..6240352 100644 --- a/apps/widget/src/Widget.tsx +++ b/apps/widget/src/Widget.tsx @@ -1,6 +1,4 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { MessageCircle, Plus } from "./icons"; import { Home as HomeComponent, useHomeConfig } from "./components/Home"; import { ConversationList } from "./components/ConversationList"; @@ -38,6 +36,7 @@ import { } from "./widgetShell/helpers"; import type { WidgetProps, WidgetView } from "./widgetShell/types"; import { WidgetMainShell } from "./widgetShell/WidgetMainShell"; +import { useWidgetQuery, widgetQueryRef } from "./lib/convex/hooks"; type ConversationListItem = { _id: Id<"conversations">; @@ -192,20 +191,17 @@ type CommonIssueButtonRecord = { conversationStarter?: string; }; -const visitorConversationsQueryRef = makeFunctionReference< - "query", +const visitorConversationsQueryRef = widgetQueryRef< { visitorId: Id<"visitors">; sessionToken: string; workspaceId: Id<"workspaces"> }, ConversationListItem[] >("conversations:listByVisitor"); -const totalUnreadForVisitorQueryRef = makeFunctionReference< - "query", +const totalUnreadForVisitorQueryRef = widgetQueryRef< { visitorId: Id<"visitors">; sessionToken: string; workspaceId: Id<"workspaces"> }, number >("conversations:getTotalUnreadForVisitor"); -const articleSearchForVisitorQueryRef = makeFunctionReference< - "query", +const articleSearchForVisitorQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; @@ -215,20 +211,17 @@ const articleSearchForVisitorQueryRef = makeFunctionReference< ArticleListItem[] >("articles:searchForVisitor"); -const articleListForVisitorQueryRef = makeFunctionReference< - "query", +const articleListForVisitorQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; sessionToken: string }, ArticleListItem[] >("articles:listForVisitor"); -const collectionHierarchyForVisitorQueryRef = makeFunctionReference< - "query", +const collectionHierarchyForVisitorQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; sessionToken: string }, CollectionHierarchyItem[] >("collections:listHierarchyForVisitor"); -const articleGetForVisitorQueryRef = makeFunctionReference< - "query", +const articleGetForVisitorQueryRef = widgetQueryRef< { id: Id<"articles">; workspaceId: Id<"workspaces">; @@ -238,32 +231,27 @@ const articleGetForVisitorQueryRef = makeFunctionReference< ArticleListItem | null >("articles:getForVisitor"); -const automationSettingsQueryRef = makeFunctionReference< - "query", +const automationSettingsQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces"> }, AutomationSettingsRecord >("automationSettings:getOrCreate"); -const commonIssueButtonsQueryRef = makeFunctionReference< - "query", +const commonIssueButtonsQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces"> }, CommonIssueButtonRecord[] >("commonIssueButtons:list"); -const officeHoursOpenQueryRef = makeFunctionReference< - "query", +const officeHoursOpenQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces"> }, OfficeHoursStatusRecord >("officeHours:isCurrentlyOpen"); -const expectedReplyTimeQueryRef = makeFunctionReference< - "query", +const expectedReplyTimeQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces"> }, string >("officeHours:getExpectedReplyTime"); -const availableToursQueryRef = makeFunctionReference< - "query", +const availableToursQueryRef = widgetQueryRef< { visitorId: Id<"visitors">; workspaceId: Id<"workspaces">; @@ -273,26 +261,22 @@ const availableToursQueryRef = makeFunctionReference< TourListItem[] >("tourProgress:getAvailableTours"); -const allToursQueryRef = makeFunctionReference< - "query", +const allToursQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; sessionToken: string }, TourListItem[] >("tours:listAll"); -const eligibleChecklistsQueryRef = makeFunctionReference< - "query", +const eligibleChecklistsQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; sessionToken: string }, EligibleChecklistItem[] >("checklists:getEligible"); -const activeSurveysQueryRef = makeFunctionReference< - "query", +const activeSurveysQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; sessionToken: string }, ActiveSurveyRecord[] >("surveys:getActiveSurveys"); -const availableTooltipsQueryRef = makeFunctionReference< - "query", +const availableTooltipsQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; @@ -424,7 +408,7 @@ export function Widget({ }); // ── Queries ──────────────────────────────────────────────────────── - const visitorConversations = useQuery( + const visitorConversations = useWidgetQuery( visitorConversationsQueryRef, isValidIdFormat && visitorId && sessionToken ? { visitorId, sessionToken, workspaceId: activeWorkspaceId as Id<"workspaces"> } @@ -472,7 +456,7 @@ export function Widget({ onTabChange: setActiveTab, }); - const totalUnread = useQuery( + const totalUnread = useWidgetQuery( totalUnreadForVisitorQueryRef, isValidIdFormat && visitorId && sessionToken ? { visitorId, sessionToken, workspaceId: activeWorkspaceId as Id<"workspaces"> } @@ -487,7 +471,7 @@ export function Widget({ return Number.isFinite(parsedCount) && parsedCount > 0 ? Math.floor(parsedCount) : 0; }, [totalUnread]); - const articleSearchResults = useQuery( + const articleSearchResults = useWidgetQuery( articleSearchForVisitorQueryRef, isValidIdFormat && visitorId && sessionToken && articleSearchQuery.length >= 2 ? { @@ -500,13 +484,13 @@ export function Widget({ ) as ArticleListItem[] | undefined; // List published articles for browsing (when not searching), filtered by visitor audience - const publishedArticles = useQuery( + const publishedArticles = useWidgetQuery( articleListForVisitorQueryRef, isValidIdFormat && visitorId && sessionToken ? { workspaceId: activeWorkspaceId as Id<"workspaces">, visitorId, sessionToken } : "skip" ) as ArticleListItem[] | undefined; - const publishedCollections = useQuery( + const publishedCollections = useWidgetQuery( collectionHierarchyForVisitorQueryRef, isValidIdFormat && visitorId && sessionToken ? { @@ -525,7 +509,7 @@ export function Widget({ return publishedArticles?.find((article) => article._id === selectedArticleId); }, [selectedArticleId, articleSearchResults, publishedArticles]); - const selectedArticleQuery = useQuery( + const selectedArticleQuery = useWidgetQuery( articleGetForVisitorQueryRef, isValidIdFormat && selectedArticleId && @@ -548,30 +532,30 @@ export function Widget({ selectedArticleQuery === undefined; // Automation settings for self-serve features (getOrCreate returns defaults for new workspaces) - const automationSettings = useQuery( + const automationSettings = useWidgetQuery( automationSettingsQueryRef, isValidIdFormat ? { workspaceId: activeWorkspaceId as Id<"workspaces"> } : "skip" ) as AutomationSettingsRecord | undefined; // Common issue buttons for quick actions - const commonIssueButtons = useQuery( + const commonIssueButtons = useWidgetQuery( commonIssueButtonsQueryRef, isValidIdFormat ? { workspaceId: activeWorkspaceId as Id<"workspaces"> } : "skip" ) as CommonIssueButtonRecord[] | undefined; // Office hours for reply time expectations - const officeHoursStatus = useQuery( + const officeHoursStatus = useWidgetQuery( officeHoursOpenQueryRef, isValidIdFormat ? { workspaceId: activeWorkspaceId as Id<"workspaces"> } : "skip" ) as OfficeHoursStatusRecord | undefined; - const expectedReplyTime = useQuery( + const expectedReplyTime = useWidgetQuery( expectedReplyTimeQueryRef, isValidIdFormat ? { workspaceId: activeWorkspaceId as Id<"workspaces"> } : "skip" ) as string | undefined; // Tour queries - const availableTours = useQuery( + const availableTours = useWidgetQuery( availableToursQueryRef, isValidIdFormat && visitorId && sessionToken ? { @@ -583,7 +567,7 @@ export function Widget({ : "skip" ) as TourListItem[] | undefined; - const allTours = useQuery( + const allTours = useWidgetQuery( allToursQueryRef, isValidIdFormat && visitorId && sessionToken ? { workspaceId: activeWorkspaceId as Id<"workspaces">, visitorId, sessionToken } @@ -591,14 +575,14 @@ export function Widget({ ) as TourListItem[] | undefined; // Checklist query for showing empty state - const eligibleChecklists = useQuery( + const eligibleChecklists = useWidgetQuery( eligibleChecklistsQueryRef, isValidIdFormat && visitorId && sessionToken ? { workspaceId: activeWorkspaceId as Id<"workspaces">, visitorId, sessionToken } : "skip" ) as EligibleChecklistItem[] | undefined; - const activeSurveys = useQuery( + const activeSurveys = useWidgetQuery( activeSurveysQueryRef, isValidIdFormat && visitorId && sessionToken ? { @@ -612,7 +596,7 @@ export function Widget({ const [displayedSurvey, setDisplayedSurvey] = useState(null); // Available tooltips based on audience rules and triggers - const availableTooltips = useQuery( + const availableTooltips = useWidgetQuery( availableTooltipsQueryRef, isValidIdFormat && visitorId && sessionToken ? { diff --git a/apps/widget/src/components/Home.tsx b/apps/widget/src/components/Home.tsx index 2f775a3..72d909d 100644 --- a/apps/widget/src/components/Home.tsx +++ b/apps/widget/src/components/Home.tsx @@ -1,6 +1,4 @@ import { useState } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { getDefaultHomeConfig, @@ -9,6 +7,7 @@ import { type PublicMessengerSettings, } from "@opencom/types"; import { Search, MessageCircle, ChevronRight, FileText, Bell, Send } from "../icons"; +import { useWidgetQuery, widgetQueryRef } from "../lib/convex/hooks"; interface Conversation { _id: Id<"conversations">; @@ -27,20 +26,17 @@ interface Article { slug: string; } -const publicHomeConfigQueryRef = makeFunctionReference< - "query", +const publicHomeConfigQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; isIdentified: boolean }, NormalizedHomeConfig >("messengerSettings:getPublicHomeConfig"); -const visitorArticlesListQueryRef = makeFunctionReference< - "query", +const visitorArticlesListQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; sessionToken: string }, Article[] >("articles:listForVisitor"); -const visitorArticlesSearchQueryRef = makeFunctionReference< - "query", +const visitorArticlesSearchQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; @@ -80,17 +76,17 @@ export function Home({ }: HomeProps) { const [searchQuery, setSearchQuery] = useState(""); - const homeConfig = useQuery(publicHomeConfigQueryRef, { + const homeConfig = useWidgetQuery(publicHomeConfigQueryRef, { workspaceId, isIdentified, }) as NormalizedHomeConfig | undefined; - const featuredArticles = useQuery( + const featuredArticles = useWidgetQuery( visitorArticlesListQueryRef, visitorId && sessionToken ? { workspaceId, visitorId, sessionToken } : "skip" ) as Article[] | undefined; - const searchResults = useQuery( + const searchResults = useWidgetQuery( visitorArticlesSearchQueryRef, visitorId && sessionToken && searchQuery.length >= 2 ? { workspaceId, visitorId, sessionToken, query: searchQuery } @@ -268,7 +264,7 @@ export function Home({ } export function useHomeConfig(workspaceId: Id<"workspaces"> | undefined, isIdentified: boolean) { - const homeConfig = useQuery( + const homeConfig = useWidgetQuery( publicHomeConfigQueryRef, workspaceId ? { workspaceId, isIdentified } : "skip" ) as NormalizedHomeConfig | undefined; diff --git a/apps/widget/src/hooks/useEventTracking.ts b/apps/widget/src/hooks/useEventTracking.ts index 678c319..a0b0090 100644 --- a/apps/widget/src/hooks/useEventTracking.ts +++ b/apps/widget/src/hooks/useEventTracking.ts @@ -1,6 +1,4 @@ import { useCallback, useEffect } from "react"; -import { useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { setIdentifyCallback, @@ -8,6 +6,7 @@ import { type UserIdentification, type EventProperties, } from "../main"; +import { useWidgetMutation, widgetMutationRef } from "../lib/convex/hooks"; interface UseEventTrackingOptions { visitorId: Id<"visitors"> | null; @@ -22,8 +21,7 @@ interface UseEventTrackingOptions { type WidgetEventPropertyValue = string | number | boolean | null | Array; type WidgetEventProperties = Record; -const identifyVisitorMutationRef = makeFunctionReference< - "mutation", +const identifyVisitorMutationRef = widgetMutationRef< { visitorId: Id<"visitors">; sessionToken?: string; @@ -37,8 +35,7 @@ const identifyVisitorMutationRef = makeFunctionReference< null >("visitors:identify"); -const trackEventMutationRef = makeFunctionReference< - "mutation", +const trackEventMutationRef = widgetMutationRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; @@ -90,8 +87,8 @@ export function useEventTracking({ setUserInfo, onTrackEventName, }: UseEventTrackingOptions) { - const identifyVisitor = useMutation(identifyVisitorMutationRef); - const trackEventMutation = useMutation(trackEventMutationRef); + const identifyVisitor = useWidgetMutation(identifyVisitorMutationRef); + const trackEventMutation = useWidgetMutation(trackEventMutationRef); // Identification callback const handleIdentify = useCallback( diff --git a/apps/widget/src/hooks/useNavigationTracking.ts b/apps/widget/src/hooks/useNavigationTracking.ts index 85d421e..0d3d07f 100644 --- a/apps/widget/src/hooks/useNavigationTracking.ts +++ b/apps/widget/src/hooks/useNavigationTracking.ts @@ -1,7 +1,6 @@ import { useEffect, useRef, useCallback } from "react"; -import { useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; +import { useWidgetMutation, widgetMutationRef } from "../lib/convex/hooks"; interface TooltipTriggerContext { currentUrl?: string; @@ -21,8 +20,7 @@ interface UseNavigationTrackingOptions { onTooltipContextChange: (updater: (prev: TooltipTriggerContext) => TooltipTriggerContext) => void; } -const trackAutoEventMutationRef = makeFunctionReference< - "mutation", +const trackAutoEventMutationRef = widgetMutationRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; @@ -58,7 +56,7 @@ export function useNavigationTracking({ const maxScrollDepth = useRef(0); const scrollDepthThresholds = useRef(new Set()); - const trackAutoEventMutation = useMutation(trackAutoEventMutationRef); + const trackAutoEventMutation = useWidgetMutation(trackAutoEventMutationRef); const trackPageView = useCallback(() => { if (!visitorId || !activeWorkspaceId) return; diff --git a/apps/widget/src/hooks/useWidgetConversationFlow.ts b/apps/widget/src/hooks/useWidgetConversationFlow.ts index d4656dd..2b16561 100644 --- a/apps/widget/src/hooks/useWidgetConversationFlow.ts +++ b/apps/widget/src/hooks/useWidgetConversationFlow.ts @@ -1,21 +1,18 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; -import { useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import type { WidgetView } from "../widgetShell/types"; +import { useWidgetMutation, widgetMutationRef } from "../lib/convex/hooks"; type ConversationStatus = "open" | "closed" | "snoozed"; type CreatedConversationResult = { _id: Id<"conversations"> } | Id<"conversations"> | null; -const createConversationForVisitorMutationRef = makeFunctionReference< - "mutation", +const createConversationForVisitorMutationRef = widgetMutationRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; sessionToken: string }, CreatedConversationResult >("conversations:createForVisitor"); -const markConversationReadMutationRef = makeFunctionReference< - "mutation", +const markConversationReadMutationRef = widgetMutationRef< { id: Id<"conversations">; readerType: "visitor"; @@ -89,8 +86,8 @@ export function useWidgetConversationFlow({ const createConversationRequestRef = useRef | null> | null>(null); const latestDraftConversationIdRef = useRef | null>(null); - const createConversation = useMutation(createConversationForVisitorMutationRef); - const markAsRead = useMutation(markConversationReadMutationRef); + const createConversation = useWidgetMutation(createConversationForVisitorMutationRef); + const markAsRead = useWidgetMutation(markConversationReadMutationRef); const selectedConversation = useMemo(() => { if (!conversationId) { diff --git a/apps/widget/src/hooks/useWidgetSession.ts b/apps/widget/src/hooks/useWidgetSession.ts index 9e83f61..05a4536 100644 --- a/apps/widget/src/hooks/useWidgetSession.ts +++ b/apps/widget/src/hooks/useWidgetSession.ts @@ -1,10 +1,9 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import { useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { generateSessionId } from "../utils/session"; import { detectDevice } from "../utils/device"; import type { UserIdentification } from "../main"; +import { useWidgetMutation, widgetMutationRef } from "../lib/convex/hooks"; interface UseWidgetSessionOptions { activeWorkspaceId: string | undefined; @@ -32,26 +31,22 @@ type HostedOnboardingVerificationResult = { accepted: boolean; }; -const bootWidgetSessionMutationRef = makeFunctionReference< - "mutation", +const bootWidgetSessionMutationRef = widgetMutationRef< Record, BootSessionResult >("widgetSessions:boot"); -const refreshWidgetSessionMutationRef = makeFunctionReference< - "mutation", +const refreshWidgetSessionMutationRef = widgetMutationRef< { sessionToken: string }, RefreshSessionResult >("widgetSessions:refresh"); -const visitorHeartbeatMutationRef = makeFunctionReference< - "mutation", +const visitorHeartbeatMutationRef = widgetMutationRef< { visitorId: Id<"visitors">; sessionToken?: string; origin: string }, null >("visitors:heartbeat"); -const recordHostedOnboardingVerificationEventMutationRef = makeFunctionReference< - "mutation", +const recordHostedOnboardingVerificationEventMutationRef = widgetMutationRef< { workspaceId: Id<"workspaces">; token: string; origin: string; currentUrl: string }, HostedOnboardingVerificationResult >("workspaces:recordHostedOnboardingVerificationEvent"); @@ -76,10 +71,10 @@ export function useWidgetSession({ const visitorIdRef = useRef | null>(null); visitorIdRef.current = visitorId; - const bootSession = useMutation(bootWidgetSessionMutationRef); - const refreshSession = useMutation(refreshWidgetSessionMutationRef); - const heartbeatMutation = useMutation(visitorHeartbeatMutationRef); - const recordHostedOnboardingVerificationEvent = useMutation( + const bootSession = useWidgetMutation(bootWidgetSessionMutationRef); + const refreshSession = useWidgetMutation(refreshWidgetSessionMutationRef); + const heartbeatMutation = useWidgetMutation(visitorHeartbeatMutationRef); + const recordHostedOnboardingVerificationEvent = useWidgetMutation( recordHostedOnboardingVerificationEventMutationRef ); const lastRecordedVerificationTokenRef = useRef(null); diff --git a/apps/widget/src/hooks/useWidgetSettings.ts b/apps/widget/src/hooks/useWidgetSettings.ts index aaec4f1..5de6c4b 100644 --- a/apps/widget/src/hooks/useWidgetSettings.ts +++ b/apps/widget/src/hooks/useWidgetSettings.ts @@ -1,6 +1,4 @@ import { useState, useEffect, useMemo } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { getDefaultPublicMessengerSettings, @@ -8,13 +6,13 @@ import { type PublicMessengerSettings, } from "@opencom/types"; import { getThemeRoot } from "../portal"; +import { useWidgetQuery, widgetQueryRef } from "../lib/convex/hooks"; export type MessengerSettings = PublicMessengerSettings; const DEFAULT_SETTINGS: MessengerSettings = getDefaultPublicMessengerSettings(); -const publicMessengerSettingsQueryRef = makeFunctionReference< - "query", +const publicMessengerSettingsQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces"> }, Partial | null >("messengerSettings:getPublicSettings"); @@ -33,7 +31,7 @@ function settingsCacheKey(workspaceId: string): string { export function useWidgetSettings(activeWorkspaceId: string | undefined, isValidIdFormat: boolean) { // Messenger customization settings - const messengerSettingsData = useQuery( + const messengerSettingsData = useWidgetQuery( publicMessengerSettingsQueryRef, isValidIdFormat ? { workspaceId: activeWorkspaceId as Id<"workspaces"> } : "skip" ) as Partial | null | undefined; diff --git a/apps/widget/src/hooks/useWidgetShellValidation.ts b/apps/widget/src/hooks/useWidgetShellValidation.ts index 38ee1a2..ac17969 100644 --- a/apps/widget/src/hooks/useWidgetShellValidation.ts +++ b/apps/widget/src/hooks/useWidgetShellValidation.ts @@ -1,7 +1,6 @@ import { useEffect, useMemo, useState } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; +import { useWidgetQuery, widgetQueryRef } from "../lib/convex/hooks"; type WorkspaceRecord = { _id: Id<"workspaces">; @@ -12,14 +11,12 @@ type OriginValidationResult = { reason?: string; } | null; -const workspaceGetQueryRef = makeFunctionReference< - "query", +const workspaceGetQueryRef = widgetQueryRef< { id: Id<"workspaces"> }, WorkspaceRecord >("workspaces:get"); -const workspaceValidateOriginQueryRef = makeFunctionReference< - "query", +const workspaceValidateOriginQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces">; origin: string }, OriginValidationResult >("workspaces:validateOrigin"); @@ -33,12 +30,12 @@ export function useWidgetShellValidation(activeWorkspaceId: string | undefined) [activeWorkspaceId] ); - const workspaceValidation = useQuery( + const workspaceValidation = useWidgetQuery( workspaceGetQueryRef, isValidIdFormat ? { id: activeWorkspaceId as Id<"workspaces"> } : "skip" ) as WorkspaceRecord | undefined; - const originValidation = useQuery( + const originValidation = useWidgetQuery( workspaceValidateOriginQueryRef, isValidIdFormat ? { diff --git a/apps/widget/src/hooks/useWidgetTicketFlow.ts b/apps/widget/src/hooks/useWidgetTicketFlow.ts index fb1c4c4..abf86be 100644 --- a/apps/widget/src/hooks/useWidgetTicketFlow.ts +++ b/apps/widget/src/hooks/useWidgetTicketFlow.ts @@ -1,10 +1,14 @@ import { useCallback, useState, type MutableRefObject } from "react"; -import { useMutation, useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared"; import { normalizeTicketFormData } from "../widgetShell/helpers"; import type { TicketFormData, WidgetView } from "../widgetShell/types"; +import { + useWidgetMutation, + useWidgetQuery, + widgetMutationRef, + widgetQueryRef, +} from "../lib/convex/hooks"; interface UseWidgetTicketFlowOptions { activeWorkspaceId?: string; @@ -53,32 +57,27 @@ type TicketFormRecord = { fields: TicketField[]; } | null; -const visitorTicketsQueryRef = makeFunctionReference< - "query", +const visitorTicketsQueryRef = widgetQueryRef< { visitorId: Id<"visitors">; sessionToken: string; workspaceId: Id<"workspaces"> }, VisitorTicketRecord[] >("tickets:listByVisitor"); -const ticketGetQueryRef = makeFunctionReference< - "query", +const ticketGetQueryRef = widgetQueryRef< { id: Id<"tickets">; visitorId?: Id<"visitors">; sessionToken?: string }, TicketDetailRecord | null >("tickets:get"); -const defaultTicketFormQueryRef = makeFunctionReference< - "query", +const defaultTicketFormQueryRef = widgetQueryRef< { workspaceId: Id<"workspaces"> }, TicketFormRecord >("ticketForms:getDefaultForVisitor"); -const addTicketCommentMutationRef = makeFunctionReference< - "mutation", +const addTicketCommentMutationRef = widgetMutationRef< { ticketId: Id<"tickets">; visitorId: Id<"visitors">; content: string; sessionToken?: string }, null >("tickets:addComment"); -const createTicketMutationRef = makeFunctionReference< - "mutation", +const createTicketMutationRef = widgetMutationRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; @@ -104,14 +103,14 @@ export function useWidgetTicketFlow({ const [isSubmittingTicket, setIsSubmittingTicket] = useState(false); const [ticketErrorFeedback, setTicketErrorFeedback] = useState(null); - const visitorTickets = useQuery( + const visitorTickets = useWidgetQuery( visitorTicketsQueryRef, isValidIdFormat && visitorId && sessionToken ? { visitorId, sessionToken, workspaceId: activeWorkspaceId as Id<"workspaces"> } : "skip" ) as VisitorTicketRecord[] | undefined; - const selectedTicket = useQuery( + const selectedTicket = useWidgetQuery( ticketGetQueryRef, selectedTicketId ? { @@ -122,13 +121,13 @@ export function useWidgetTicketFlow({ : "skip" ) as TicketDetailRecord | null | undefined; - const ticketForm = useQuery( + const ticketForm = useWidgetQuery( defaultTicketFormQueryRef, isValidIdFormat ? { workspaceId: activeWorkspaceId as Id<"workspaces"> } : "skip" ) as TicketFormRecord | undefined; - const addTicketComment = useMutation(addTicketCommentMutationRef); - const createTicket = useMutation(createTicketMutationRef); + const addTicketComment = useWidgetMutation(addTicketCommentMutationRef); + const createTicket = useWidgetMutation(createTicketMutationRef); const handleBackFromTickets = useCallback(() => { setTicketErrorFeedback(null); diff --git a/apps/widget/src/lib/convex/hooks.ts b/apps/widget/src/lib/convex/hooks.ts index 540fae0..6479b59 100644 --- a/apps/widget/src/lib/convex/hooks.ts +++ b/apps/widget/src/lib/convex/hooks.ts @@ -1,4 +1,11 @@ -import { useAction, useMutation, useQuery } from "convex/react"; +import { + type OptionalRestArgsOrSkip, + type ReactAction, + type ReactMutation, + useAction, + useMutation, + useQuery, +} from "convex/react"; import { makeFunctionReference, type FunctionReference } from "convex/server"; type WidgetArgs = Record; @@ -27,27 +34,26 @@ export type WidgetActionRef = FunctionReference export function widgetQueryRef( functionName: string ): WidgetQueryRef { - return makeFunctionReference<"query", Args, Result>(functionName) as WidgetQueryRef< - Args, - Result - >; + return makeFunctionReference<"query", Args, Result>(functionName); } export function widgetMutationRef( functionName: string ): WidgetMutationRef { - return makeFunctionReference<"mutation", Args, Result>(functionName) as WidgetMutationRef< - Args, - Result - >; + return makeFunctionReference<"mutation", Args, Result>(functionName); } export function widgetActionRef( functionName: string ): WidgetActionRef { - return makeFunctionReference<"action", Args, Result>(functionName) as WidgetActionRef< - Args, - Result + return makeFunctionReference<"action", Args, Result>(functionName); +} + +function toWidgetQueryArgs( + args: Args | "skip" +): OptionalRestArgsOrSkip> { + return (args === "skip" ? ["skip"] : [args]) as OptionalRestArgsOrSkip< + WidgetQueryRef >; } @@ -55,17 +61,17 @@ export function useWidgetQuery( queryRef: WidgetQueryRef, args: Args | "skip" ): Result | undefined { - return useQuery(queryRef as never, args as never) as Result | undefined; + return useQuery(queryRef, ...toWidgetQueryArgs(args)); } export function useWidgetMutation( mutationRef: WidgetMutationRef -): (args: Args) => Promise { - return useMutation(mutationRef as never) as unknown as (args: Args) => Promise; +): ReactMutation> { + return useMutation(mutationRef); } export function useWidgetAction( actionRef: WidgetActionRef -): (args: Args) => Promise { - return useAction(actionRef as never) as unknown as (args: Args) => Promise; +): ReactAction> { + return useAction(actionRef); } diff --git a/apps/widget/src/test/refHardeningGuard.test.ts b/apps/widget/src/test/refHardeningGuard.test.ts index b5b4f36..707522a 100644 --- a/apps/widget/src/test/refHardeningGuard.test.ts +++ b/apps/widget/src/test/refHardeningGuard.test.ts @@ -1,27 +1,81 @@ -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, extname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const TEST_DIR = dirname(fileURLToPath(import.meta.url)); +const WIDGET_SRC_DIR = resolve(TEST_DIR, ".."); const HELPER_PATH = resolve(TEST_DIR, "convexFunctionRefs.ts"); const ADAPTER_PATH = resolve(TEST_DIR, "../lib/convex/hooks.ts"); +const MAIN_PATH = resolve(TEST_DIR, "../main.tsx"); const CONVERSATION_WRAPPER_PATH = resolve( TEST_DIR, "../hooks/convex/useConversationViewConvex.ts" ); const TOUR_WRAPPER_PATH = resolve(TEST_DIR, "../hooks/convex/useTourProgressConvex.ts"); -const MIGRATED_TEST_FILES = [ +const APPROVED_TEST_BOUNDARY_FILES = [ resolve(TEST_DIR, "useWidgetSession.test.tsx"), resolve(TEST_DIR, "widgetNewConversation.test.tsx"), + resolve(TEST_DIR, "widgetShellOrchestration.test.tsx"), + resolve(TEST_DIR, "widgetTicketErrorFeedback.test.tsx"), + resolve(TEST_DIR, "widgetTourBridgeLifecycle.test.tsx"), + resolve(TEST_DIR, "widgetTourStart.test.tsx"), + resolve(TEST_DIR, "outboundOverlay.test.tsx"), + resolve(TEST_DIR, "tourOverlay.test.tsx"), resolve(TEST_DIR, "../components/ConversationView.test.tsx"), ]; -const MIGRATED_RUNTIME_FILES = [ - resolve(TEST_DIR, "../components/ConversationView.tsx"), - resolve(TEST_DIR, "../tourOverlay/useTourOverlayActions.ts"), +const APPROVED_DIRECT_CONVEX_BOUNDARY_FILES = [ + ADAPTER_PATH, + MAIN_PATH, + ...APPROVED_TEST_BOUNDARY_FILES, ]; +const DIRECT_CONVEX_IMPORT_PATTERN = /from "convex\/react"/; +const DIRECT_REF_FACTORY_PATTERN = /makeFunctionReference\(/; + +function collectSourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const entryPath = resolve(dir, entry.name); + + if (entry.isDirectory()) { + return collectSourceFiles(entryPath); + } + + if (!entry.isFile()) { + return []; + } + + const extension = extname(entry.name); + return extension === ".ts" || extension === ".tsx" ? [entryPath] : []; + }); +} + +function isApprovedDirectConvexBoundary(filePath: string): boolean { + return APPROVED_DIRECT_CONVEX_BOUNDARY_FILES.includes(filePath); +} + +function findUnexpectedWidgetDirectConvexBoundaries(): string[] { + return collectSourceFiles(WIDGET_SRC_DIR).flatMap((filePath) => { + if (isApprovedDirectConvexBoundary(filePath)) { + return []; + } + + const source = readFileSync(filePath, "utf8"); + const violations: string[] = []; + + if (DIRECT_CONVEX_IMPORT_PATTERN.test(source)) { + violations.push(`${relative(WIDGET_SRC_DIR, filePath)}: direct convex/react import`); + } + + if (DIRECT_REF_FACTORY_PATTERN.test(source)) { + violations.push(`${relative(WIDGET_SRC_DIR, filePath)}: direct makeFunctionReference call`); + } + + return violations; + }); +} + describe("widget ref hardening guards", () => { it("uses Convex supported function-name extraction in the shared helper", () => { const source = readFileSync(HELPER_PATH, "utf8"); @@ -31,11 +85,13 @@ describe("widget ref hardening guards", () => { }); it("keeps migrated session/conversation tests on the shared helper", () => { - for (const filePath of MIGRATED_TEST_FILES) { + for (const filePath of APPROVED_TEST_BOUNDARY_FILES) { const source = readFileSync(filePath, "utf8"); - expect(source).toContain("matchesFunctionPath"); - expect(source).not.toMatch(/\bfunction getFunctionPath\(/); + if (filePath.endsWith("ConversationView.test.tsx")) { + expect(source).toContain("matchesFunctionPath"); + expect(source).not.toMatch(/\bfunction getFunctionPath\(/); + } } }); @@ -48,9 +104,14 @@ describe("widget ref hardening guards", () => { expect(source).toContain("export function useWidgetQuery"); expect(source).toContain("export function useWidgetMutation"); expect(source).toContain("export function useWidgetAction"); + expect(source).toContain("function toWidgetQueryArgs"); + expect(source).toContain("OptionalRestArgsOrSkip"); + expect(source).not.toContain("as never"); + expect(source).not.toContain("as unknown as"); + expect(source.match(/as OptionalRestArgsOrSkip/g)).toHaveLength(1); }); - it("keeps migrated runtime modules on widget-local Convex wrappers", () => { + it("keeps widget runtime modules on widget-local Convex wrappers", () => { const conversationWrapperSource = readFileSync(CONVERSATION_WRAPPER_PATH, "utf8"); const tourWrapperSource = readFileSync(TOUR_WRAPPER_PATH, "utf8"); @@ -58,12 +119,24 @@ describe("widget ref hardening guards", () => { expect(conversationWrapperSource).toContain("useWidgetMutation"); expect(conversationWrapperSource).toContain("useWidgetAction"); expect(tourWrapperSource).toContain("useWidgetMutation"); + expect(findUnexpectedWidgetDirectConvexBoundaries()).toEqual([]); + }); - for (const filePath of MIGRATED_RUNTIME_FILES) { - const source = readFileSync(filePath, "utf8"); - - expect(source).not.toContain('from "convex/react"'); - expect(source).not.toContain("makeFunctionReference("); - } + it("keeps the approved direct Convex boundaries explicit", () => { + expect( + APPROVED_DIRECT_CONVEX_BOUNDARY_FILES.map((filePath) => relative(WIDGET_SRC_DIR, filePath)) + ).toEqual([ + "lib/convex/hooks.ts", + "main.tsx", + "test/useWidgetSession.test.tsx", + "test/widgetNewConversation.test.tsx", + "test/widgetShellOrchestration.test.tsx", + "test/widgetTicketErrorFeedback.test.tsx", + "test/widgetTourBridgeLifecycle.test.tsx", + "test/widgetTourStart.test.tsx", + "test/outboundOverlay.test.tsx", + "test/tourOverlay.test.tsx", + "components/ConversationView.test.tsx", + ]); }); }); diff --git a/apps/widget/src/tourOverlay/useTourOverlaySession.ts b/apps/widget/src/tourOverlay/useTourOverlaySession.ts index bbc9993..545c0cb 100644 --- a/apps/widget/src/tourOverlay/useTourOverlaySession.ts +++ b/apps/widget/src/tourOverlay/useTourOverlaySession.ts @@ -1,12 +1,10 @@ import { useCallback, useEffect, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; -import { useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { getAdvanceGuidance } from "./messages"; import type { ElementPosition, TooltipPosition, TourData } from "./types"; +import { useWidgetMutation, widgetMutationRef } from "../lib/convex/hooks"; -const startTourProgressMutationRef = makeFunctionReference< - "mutation", +const startTourProgressMutationRef = widgetMutationRef< { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; @@ -93,7 +91,7 @@ export function useTourOverlaySession({ const [currentStepIndex, setCurrentStepIndex] = useState(0); const [suppressedTourIds, setSuppressedTourIds] = useState>(new Set()); - const startTour = useMutation(startTourProgressMutationRef); + const startTour = useWidgetMutation(startTourProgressMutationRef); const currentStep = activeTour?.steps[currentStepIndex] ?? null; const resetFeedback = useCallback(() => { diff --git a/openspec/changes/expand-widget-local-convex-wrapper-hooks/tasks.md b/openspec/changes/expand-widget-local-convex-wrapper-hooks/tasks.md index cc416ad..d208092 100644 --- a/openspec/changes/expand-widget-local-convex-wrapper-hooks/tasks.md +++ b/openspec/changes/expand-widget-local-convex-wrapper-hooks/tasks.md @@ -1,26 +1,26 @@ ## 1. Inventory and guardrails -- [ ] 1.1 Freeze the March 11, 2026 widget direct-import inventory and record the approved adapter, bootstrap, and test exceptions. -- [ ] 1.2 Extend widget hardening guard coverage so newly covered runtime files fail verification if they keep direct `convex/react` imports. -- [ ] 1.3 Record the current adapter escape hatches in `apps/widget/src/lib/convex/hooks.ts` so any residual casts stay deliberate and reviewable. +- [x] 1.1 Freeze the March 11, 2026 widget direct-import inventory and record the approved adapter, bootstrap, and test exceptions. +- [x] 1.2 Extend widget hardening guard coverage so newly covered runtime files fail verification if they keep direct `convex/react` imports. +- [x] 1.3 Record the current adapter escape hatches in `apps/widget/src/lib/convex/hooks.ts` so any residual casts stay deliberate and reviewable. ## 2. Harden the widget adapter boundary -- [ ] 2.1 Review `apps/widget/src/lib/convex/hooks.ts` and replace broad `as never` / `as unknown as` boundaries with narrower typed helpers where Convex typing allows it. -- [ ] 2.2 Keep any unavoidable adapter escape hatches explicit, localized, and covered by the widget hardening guard or targeted tests. +- [x] 2.1 Review `apps/widget/src/lib/convex/hooks.ts` and replace broad `as never` / `as unknown as` boundaries with narrower typed helpers where Convex typing allows it. +- [x] 2.2 Keep any unavoidable adapter escape hatches explicit, localized, and covered by the widget hardening guard or targeted tests. ## 3. Migrate shell, session, and tracking boundaries -- [ ] 3.1 Add or extend wrapper coverage for `Widget.tsx`, `components/Home.tsx`, `useWidgetSession.ts`, `useWidgetShellValidation.ts`, `useEventTracking.ts`, and `useNavigationTracking.ts`. -- [ ] 3.2 Migrate `useWidgetConversationFlow.ts` and `useWidgetTicketFlow.ts` to consume widget-local wrappers or feature-local typed ref helpers instead of direct hook imports. +- [x] 3.1 Add or extend wrapper coverage for `Widget.tsx`, `components/Home.tsx`, `useWidgetSession.ts`, `useWidgetShellValidation.ts`, `useEventTracking.ts`, and `useNavigationTracking.ts`. +- [x] 3.2 Migrate `useWidgetConversationFlow.ts` and `useWidgetTicketFlow.ts` to consume widget-local wrappers or feature-local typed ref helpers instead of direct hook imports. ## 4. Migrate overlay and tour-support domains -- [ ] 4.1 Add or extend wrapper coverage for outbound, checklist, survey, CSAT, authoring, and tooltip overlay modules. -- [ ] 4.2 Migrate `tourOverlay/useTourOverlaySession.ts` and update any touched widget runtime tests that pin the old direct-hook boundary. +- [x] 4.1 Add or extend wrapper coverage for outbound, checklist, survey, CSAT, authoring, and tooltip overlay modules. +- [x] 4.2 Migrate `tourOverlay/useTourOverlaySession.ts` and update any touched widget runtime tests that pin the old direct-hook boundary. ## 5. Verification -- [ ] 5.1 Run `pnpm --filter @opencom/widget typecheck`. -- [ ] 5.2 Run targeted widget tests for touched wrapper, adapter, and hardening-guard domains. -- [ ] 5.3 Run `openspec validate expand-widget-local-convex-wrapper-hooks --strict --no-interactive`. +- [x] 5.1 Run `pnpm --filter @opencom/widget typecheck`. +- [x] 5.2 Run targeted widget tests for touched wrapper, adapter, and hardening-guard domains. +- [x] 5.3 Run `openspec validate expand-widget-local-convex-wrapper-hooks --strict --no-interactive`. From 85245215b19df9533a01abefcf5901b94da43c53 Mon Sep 17 00:00:00 2001 From: Jack D Date: Wed, 11 Mar 2026 14:17:56 +0000 Subject: [PATCH 2/2] include generic typed calls to makeFunctionReference --- apps/widget/src/test/refHardeningGuard.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/widget/src/test/refHardeningGuard.test.ts b/apps/widget/src/test/refHardeningGuard.test.ts index 707522a..a840466 100644 --- a/apps/widget/src/test/refHardeningGuard.test.ts +++ b/apps/widget/src/test/refHardeningGuard.test.ts @@ -32,7 +32,7 @@ const APPROVED_DIRECT_CONVEX_BOUNDARY_FILES = [ ]; const DIRECT_CONVEX_IMPORT_PATTERN = /from "convex\/react"/; -const DIRECT_REF_FACTORY_PATTERN = /makeFunctionReference\(/; +const DIRECT_REF_FACTORY_PATTERN = /\bmakeFunctionReference(?:\s*<[\s\S]*?>)?\s*\(/; function collectSourceFiles(dir: string): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { @@ -77,6 +77,19 @@ function findUnexpectedWidgetDirectConvexBoundaries(): string[] { } describe("widget ref hardening guards", () => { + it("flags direct makeFunctionReference calls with or without generic arguments", () => { + const bareCallSource = 'const ref = ' + 'makeFunctionReference' + '("foo:bar");'; + const genericCallSource = + "const ref = " + + "makeFunctionReference" + + '<"query", { nested: FunctionReference<"query"> }, Result>("foo:bar");'; + + expect(DIRECT_REF_FACTORY_PATTERN.test(bareCallSource)).toBe(true); + expect( + DIRECT_REF_FACTORY_PATTERN.test(genericCallSource) + ).toBe(true); + }); + it("uses Convex supported function-name extraction in the shared helper", () => { const source = readFileSync(HELPER_PATH, "utf8");