From 3ecbb699a291267e72cab5122508015e96b33d48 Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Fri, 22 May 2026 16:34:49 +0000 Subject: [PATCH 01/12] Add Salesforce chat widget to book details page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add chat_book_details feature flag to shared-data.ts - Create Chat component with Salesforce Embedded Messaging integration - Integrate Chat component into BookDetails page with feature flag - Pass user information to Salesforce pre-chat form when available - Configure sProduct field as "Website" for Salesforce Related to CORE-1416 🤖 Generated with [Claude Code](https://claude.com/claude-code) lint Co-Authored-By: Claude Sonnet 4.5 --- src/app/components/chat/chat.scss | 8 +++ src/app/components/chat/chat.tsx | 103 ++++++++++++++++++++++++++++++ src/app/contexts/shared-data.ts | 2 +- src/app/pages/details/details.tsx | 5 ++ 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/app/components/chat/chat.scss create mode 100644 src/app/components/chat/chat.tsx diff --git a/src/app/components/chat/chat.scss b/src/app/components/chat/chat.scss new file mode 100644 index 000000000..036a894c3 --- /dev/null +++ b/src/app/components/chat/chat.scss @@ -0,0 +1,8 @@ +// Salesforce chat widget styling +// The chat widget is injected by Salesforce and appears as a fixed overlay +// This file is a placeholder for any custom styling overrides if needed + +.embeddedServiceHelpButton { + // Salesforce injects the chat button here + // Default positioning is bottom-right corner +} diff --git a/src/app/components/chat/chat.tsx b/src/app/components/chat/chat.tsx new file mode 100644 index 000000000..1132683ec --- /dev/null +++ b/src/app/components/chat/chat.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import useUserContext from '~/contexts/user'; +import './chat.scss'; + +declare global { + interface Window { + embeddedservice_bootstrap?: { + settings: { + language: string; + }; + init: ( + orgId: string, + deploymentName: string, + baseUrl: string, + options: {scrt2URL: string} + ) => void; + prechatAPI?: { + setHiddenPrechatFields: (fields: Record) => void; + }; + }; + } +} + +const SALESFORCE_CONFIG = { + orgId: '00DU0000000Kwch', + deploymentName: 'Web_Messaging_Deployment', + baseUrl: 'https://openstax.my.site.com/ESWWebMessagingDeployme1716235390398', + scrt2URL: 'https://openstax.my.salesforce-scrt.com', + bootstrapScript: 'https://openstax.my.site.com/ESWWebMessagingDeployme1716235390398/assets/js/bootstrap.min.js' +}; + +function initEmbeddedMessaging() { + try { + if (window.embeddedservice_bootstrap) { + window.embeddedservice_bootstrap.settings.language = 'en_US'; + window.embeddedservice_bootstrap.init( + SALESFORCE_CONFIG.orgId, + SALESFORCE_CONFIG.deploymentName, + SALESFORCE_CONFIG.baseUrl, + {scrt2URL: SALESFORCE_CONFIG.scrt2URL} + ); + } + } catch (err) { + console.error('Error initializing Salesforce chat:', err); + } +} + +export default function Chat() { + const userContext = useUserContext(); + const [scriptLoaded, setScriptLoaded] = React.useState(false); + + React.useEffect(() => { + const script = document.createElement('script'); + + script.src = SALESFORCE_CONFIG.bootstrapScript; + script.type = 'text/javascript'; + script.async = true; + + script.onload = () => { + setScriptLoaded(true); + }; + + script.onerror = () => { + console.error('Failed to load Salesforce chat script'); + }; + + document.body.appendChild(script); + + return () => { + if (document.body.contains(script)) { + document.body.removeChild(script); + } + if (window.embeddedservice_bootstrap) { + delete window.embeddedservice_bootstrap; + } + }; + }, []); + + // eslint-disable-next-line complexity + React.useEffect(() => { + if (!scriptLoaded || !window.embeddedservice_bootstrap) { + return; + } + + const userModel = userContext?.userModel; + + if (userModel && window.embeddedservice_bootstrap.prechatAPI) { + const hiddenFields: Record = { + sProduct: 'Website' + }; + + if (userModel.uuid) { + hiddenFields.OpenStax_UUID__c = userModel.uuid; // eslint-disable-line camelcase + } + + window.embeddedservice_bootstrap.prechatAPI.setHiddenPrechatFields(hiddenFields); + } + + initEmbeddedMessaging(); + }, [scriptLoaded, userContext]); + + return null; +} diff --git a/src/app/contexts/shared-data.ts b/src/app/contexts/shared-data.ts index 29f228034..566253653 100644 --- a/src/app/contexts/shared-data.ts +++ b/src/app/contexts/shared-data.ts @@ -2,7 +2,7 @@ import buildContext from '~/components/jsx-helpers/build-context'; import cmsFetch from '~/helpers/cms-fetch'; import {usePromise} from '~/helpers/use-data'; -type FlagName = 'myox_pardot' | 'my_openstax' | 'new_subjects'; +type FlagName = 'myox_pardot' | 'my_openstax' | 'new_subjects' | 'chat_book_details'; type Flag = { name: FlagName; diff --git a/src/app/pages/details/details.tsx b/src/app/pages/details/details.tsx index 49c2566cf..6ba91e48d 100644 --- a/src/app/pages/details/details.tsx +++ b/src/app/pages/details/details.tsx @@ -14,6 +14,8 @@ import useDetailsContext, { ContextValues } from './context'; import {WindowContextProvider} from '~/contexts/window'; +import useSharedDataContext from '~/contexts/shared-data'; +import Chat from '~/components/chat/chat'; import './details.scss'; import './table-of-contents.scss'; @@ -76,6 +78,8 @@ function TocSlideoutAndContent({children}: {children: React.ReactNode}) { export function BookDetails() { const model = useDetailsContext(); const {setLanguage} = useLanguageContext(); + const {flags} = useSharedDataContext(); + const chatEnabled = flags && flags.chat_book_details; useEffect(() => { setPageColor(model.coverColor); @@ -97,6 +101,7 @@ export function BookDetails() { + {chatEnabled && } ); } From ef0e6a3a54837cbda75fe60ef702b24cd8afeb7d Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Tue, 26 May 2026 13:31:08 -0500 Subject: [PATCH 02/12] Address Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix chat initialization to prevent re-initialization on userContext changes - Use ref to track initialization state - Derive stable user primitives to avoid unnecessary effect triggers - Only initialize once even if user context object changes - Set sProduct field for all users (logged in and anonymous) - Previously only set for logged-in users - Now always set to "Website" - Add all user information fields to pre-chat form - FirstName, LastName, Email, School in addition to UUID - Only set fields that are available - Add comprehensive tests for Chat component - Test script injection and initialization - Test user field handling for various scenarios - Test cleanup behavior - Test single initialization guarantee - Move Chat component to router level for persistence - Chat now renders at MainRoutes level in router.tsx - Maintains conversation state across navigation - Only shown on book details pages when feature flag enabled - Removed from BookDetails component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Replace empty CSS rule with comment Lint Co-Authored-By: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.5 --- src/app/components/chat/chat.scss | 7 +- src/app/components/chat/chat.tsx | 52 +++++-- src/app/components/shell/router.tsx | 19 +++ src/app/pages/details/details.tsx | 5 - test/src/components/chat.test.tsx | 207 ++++++++++++++++++++++++++++ 5 files changed, 265 insertions(+), 25 deletions(-) create mode 100644 test/src/components/chat.test.tsx diff --git a/src/app/components/chat/chat.scss b/src/app/components/chat/chat.scss index 036a894c3..da9f75688 100644 --- a/src/app/components/chat/chat.scss +++ b/src/app/components/chat/chat.scss @@ -1,8 +1,3 @@ // Salesforce chat widget styling // The chat widget is injected by Salesforce and appears as a fixed overlay -// This file is a placeholder for any custom styling overrides if needed - -.embeddedServiceHelpButton { - // Salesforce injects the chat button here - // Default positioning is bottom-right corner -} +// Add custom styling overrides here if needed diff --git a/src/app/components/chat/chat.tsx b/src/app/components/chat/chat.tsx index 1132683ec..faf2577c8 100644 --- a/src/app/components/chat/chat.tsx +++ b/src/app/components/chat/chat.tsx @@ -45,10 +45,21 @@ function initEmbeddedMessaging() { } } +// eslint-disable-next-line complexity export default function Chat() { const userContext = useUserContext(); const [scriptLoaded, setScriptLoaded] = React.useState(false); + const initializedRef = React.useRef(false); + // Derive stable user primitives + const userModel = userContext?.userModel; + const uuid = userModel?.uuid; + const firstName = userModel?.first_name; + const lastName = userModel?.last_name; + const email = userModel?.email; + const school = userModel?.accountsModel?.school_name; + + // Load Salesforce script once React.useEffect(() => { const script = document.createElement('script'); @@ -70,34 +81,47 @@ export default function Chat() { if (document.body.contains(script)) { document.body.removeChild(script); } - if (window.embeddedservice_bootstrap) { - delete window.embeddedservice_bootstrap; - } + // Note: Don't delete window.embeddedservice_bootstrap to maintain + // conversation state across component remounts }; }, []); + // Initialize chat once and set user fields // eslint-disable-next-line complexity React.useEffect(() => { - if (!scriptLoaded || !window.embeddedservice_bootstrap) { + if (!scriptLoaded || !window.embeddedservice_bootstrap || initializedRef.current) { return; } - const userModel = userContext?.userModel; - - if (userModel && window.embeddedservice_bootstrap.prechatAPI) { - const hiddenFields: Record = { - sProduct: 'Website' - }; + // Set sProduct for all users (logged in or not) + const hiddenFields: Record = { + sProduct: 'Website' + }; - if (userModel.uuid) { - hiddenFields.OpenStax_UUID__c = userModel.uuid; // eslint-disable-line camelcase - } + // Add user information if available + if (uuid) { + hiddenFields.OpenStax_UUID__c = uuid; // eslint-disable-line camelcase + } + if (firstName) { + hiddenFields.FirstName = firstName; + } + if (lastName) { + hiddenFields.LastName = lastName; + } + if (email) { + hiddenFields.Email = email; + } + if (school) { + hiddenFields.School = school; + } + if (window.embeddedservice_bootstrap.prechatAPI) { window.embeddedservice_bootstrap.prechatAPI.setHiddenPrechatFields(hiddenFields); } initEmbeddedMessaging(); - }, [scriptLoaded, userContext]); + initializedRef.current = true; + }, [scriptLoaded, uuid, firstName, lastName, email, school]); return null; } diff --git a/src/app/components/shell/router.tsx b/src/app/components/shell/router.tsx index 335be629e..8c33fce54 100644 --- a/src/app/components/shell/router.tsx +++ b/src/app/components/shell/router.tsx @@ -16,6 +16,8 @@ import { generateFooterPageRoutes } from './router-helpers/page-routes'; import {NonPortalRouteWrapper} from './router-helpers/non-portal-route-wrapper'; +import useSharedDataContext from '~/contexts/shared-data'; +import Chat from '~/components/chat/chat'; import './skip-to-content.scss'; function doSkipToContent(event: React.MouseEvent) { @@ -77,6 +79,22 @@ export default function Router() { function MainRoutes() { const {Layout} = useLayoutContext(); + const {pathname} = useLocation(); + const {flags} = useSharedDataContext(); + + // Determine if chat should be shown based on current route and feature flags + const showChat = React.useMemo(() => { + if (!flags) { + return false; + } + + // Check if we're on a book details page + if (pathname.startsWith('/details/') && flags.chat_book_details) { + return true; + } + + return false; + }, [pathname, flags]); return ( @@ -91,6 +109,7 @@ function MainRoutes() { } /> } /> + {showChat && } ); } diff --git a/src/app/pages/details/details.tsx b/src/app/pages/details/details.tsx index 6ba91e48d..49c2566cf 100644 --- a/src/app/pages/details/details.tsx +++ b/src/app/pages/details/details.tsx @@ -14,8 +14,6 @@ import useDetailsContext, { ContextValues } from './context'; import {WindowContextProvider} from '~/contexts/window'; -import useSharedDataContext from '~/contexts/shared-data'; -import Chat from '~/components/chat/chat'; import './details.scss'; import './table-of-contents.scss'; @@ -78,8 +76,6 @@ function TocSlideoutAndContent({children}: {children: React.ReactNode}) { export function BookDetails() { const model = useDetailsContext(); const {setLanguage} = useLanguageContext(); - const {flags} = useSharedDataContext(); - const chatEnabled = flags && flags.chat_book_details; useEffect(() => { setPageColor(model.coverColor); @@ -101,7 +97,6 @@ export function BookDetails() { - {chatEnabled && } ); } diff --git a/test/src/components/chat.test.tsx b/test/src/components/chat.test.tsx new file mode 100644 index 000000000..ef01ce3d6 --- /dev/null +++ b/test/src/components/chat.test.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import {render, waitFor} from '@testing-library/preact'; +import Chat from '~/components/chat/chat'; +import * as UserContext from '~/contexts/user'; + +// Mock the user context +jest.mock('~/contexts/user'); + +describe('Chat', () => { + let mockEmbeddedService: any; + + beforeEach(() => { + // Clean up any existing scripts and global objects + document.querySelectorAll('script[src*="bootstrap.min.js"]').forEach((el) => el.remove()); + delete (window as any).embeddedservice_bootstrap; + + // Create mock embeddedservice_bootstrap + mockEmbeddedService = { + settings: { + language: '' + }, + init: jest.fn(), + prechatAPI: { + setHiddenPrechatFields: jest.fn() + } + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('appends Salesforce bootstrap script once', () => { + (UserContext.default as jest.Mock).mockReturnValue({}); + + render(); + + const scripts = document.querySelectorAll('script[src*="bootstrap.min.js"]'); + expect(scripts.length).toBe(1); + expect(scripts[0].getAttribute('src')).toContain('bootstrap.min.js'); + expect(scripts[0].getAttribute('type')).toBe('text/javascript'); + }); + + it('initializes embeddedservice_bootstrap when script loads', async () => { + (UserContext.default as jest.Mock).mockReturnValue({}); + + render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.init).toHaveBeenCalledWith( + '00DU0000000Kwch', + 'Web_Messaging_Deployment', + 'https://openstax.my.site.com/ESWWebMessagingDeployme1716235390398', + {scrt2URL: 'https://openstax.my.salesforce-scrt.com'} + ); + }); + }); + + it('sets sProduct for anonymous users', async () => { + (UserContext.default as jest.Mock).mockReturnValue({}); + + render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.prechatAPI.setHiddenPrechatFields).toHaveBeenCalledWith({ + sProduct: 'Website' + }); + }); + }); + + it('sets user information for logged-in users', async () => { + const mockUserContext = { + userModel: { + uuid: 'test-uuid-123', + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com', + accountsModel: { + school_name: 'Test University' + } + } + }; + + (UserContext.default as jest.Mock).mockReturnValue(mockUserContext); + + render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.prechatAPI.setHiddenPrechatFields).toHaveBeenCalledWith({ + sProduct: 'Website', + OpenStax_UUID__c: 'test-uuid-123', + FirstName: 'John', + LastName: 'Doe', + Email: 'john.doe@example.com', + School: 'Test University' + }); + }); + }); + + it('sets partial user information when some fields are missing', async () => { + const mockUserContext = { + userModel: { + uuid: 'test-uuid-123', + first_name: 'John' + // Missing last_name, email, school + } + }; + + (UserContext.default as jest.Mock).mockReturnValue(mockUserContext); + + render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.prechatAPI.setHiddenPrechatFields).toHaveBeenCalledWith({ + sProduct: 'Website', + OpenStax_UUID__c: 'test-uuid-123', + FirstName: 'John' + }); + }); + }); + + it('removes script on unmount', () => { + (UserContext.default as jest.Mock).mockReturnValue({}); + + const {unmount} = render(); + + expect(document.querySelectorAll('script[src*="bootstrap.min.js"]').length).toBe(1); + + unmount(); + + expect(document.querySelectorAll('script[src*="bootstrap.min.js"]').length).toBe(0); + }); + + it('handles script load error gracefully', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(); + (UserContext.default as jest.Mock).mockReturnValue({}); + + render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + script.onerror?.(new Event('error')); + + expect(consoleError).toHaveBeenCalledWith('Failed to load Salesforce chat script'); + + consoleError.mockRestore(); + }); + + it('only initializes once even if user context changes', async () => { + const mockUserContext = { + userModel: { + uuid: 'test-uuid-123', + first_name: 'John' + } + }; + + (UserContext.default as jest.Mock).mockReturnValue(mockUserContext); + + const {rerender} = render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + }); + + // Update user context + (UserContext.default as jest.Mock).mockReturnValue({ + userModel: { + uuid: 'test-uuid-123', + first_name: 'Jane' + } + }); + + rerender(); + + // Should not initialize again + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + }); +}); From a8f210379c27d9f80e6989d9e6d1112dce9361e4 Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Wed, 27 May 2026 13:08:53 +0000 Subject: [PATCH 03/12] Add support for additional chat feature flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on review feedback, added support for: - chat_logged_in_only: Shows chat on any page when user is logged in - chat_subjects: Shows chat on subjects pages (/subjects/*) - chat_contact: Shows chat on contact page (/contact) Changes: - Added three new feature flags to shared-data.ts - Updated router logic to check all feature flags and user login status - Chat now shows based on any of these conditions: - User is logged in AND chat_logged_in_only flag is enabled - On book details page AND chat_book_details flag is enabled - On subjects page AND chat_subjects flag is enabled - On contact page AND chat_contact flag is enabled The logic is additive - if multiple flags are enabled, chat will show on all applicable pages. The chat_logged_in_only flag enables chat site-wide for authenticated users. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Update router.tsx Co-Authored-By: Claude Sonnet 4.5 --- src/app/components/shell/router.tsx | 23 ++++++++++++++++++++++- src/app/contexts/shared-data.ts | 9 ++++++++- test/src/components/chat.test.tsx | 3 ++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/app/components/shell/router.tsx b/src/app/components/shell/router.tsx index 8c33fce54..c74fd8da8 100644 --- a/src/app/components/shell/router.tsx +++ b/src/app/components/shell/router.tsx @@ -17,6 +17,7 @@ import { } from './router-helpers/page-routes'; import {NonPortalRouteWrapper} from './router-helpers/non-portal-route-wrapper'; import useSharedDataContext from '~/contexts/shared-data'; +import useUserContext from '~/contexts/user'; import Chat from '~/components/chat/chat'; import './skip-to-content.scss'; @@ -81,20 +82,40 @@ function MainRoutes() { const {Layout} = useLayoutContext(); const {pathname} = useLocation(); const {flags} = useSharedDataContext(); + const userContext = useUserContext(); // Determine if chat should be shown based on current route and feature flags + // eslint-disable-next-line complexity const showChat = React.useMemo(() => { if (!flags) { return false; } + // Check if user is logged in + const isLoggedIn = Boolean(userContext?.userModel?.uuid); + + // If chat_logged_in_only is enabled and user is logged in, show on any page + if (flags.chat_logged_in_only && isLoggedIn) { + return true; + } + // Check if we're on a book details page if (pathname.startsWith('/details/') && flags.chat_book_details) { return true; } + // Check if we're on a subjects page + if (pathname.startsWith('/subjects') && flags.chat_subjects) { + return true; + } + + // Check if we're on the contact page + if (pathname.startsWith('/contact') && flags.chat_contact) { + return true; + } + return false; - }, [pathname, flags]); + }, [pathname, flags, userContext]); return ( diff --git a/src/app/contexts/shared-data.ts b/src/app/contexts/shared-data.ts index 566253653..1c7dd6ce0 100644 --- a/src/app/contexts/shared-data.ts +++ b/src/app/contexts/shared-data.ts @@ -2,7 +2,14 @@ import buildContext from '~/components/jsx-helpers/build-context'; import cmsFetch from '~/helpers/cms-fetch'; import {usePromise} from '~/helpers/use-data'; -type FlagName = 'myox_pardot' | 'my_openstax' | 'new_subjects' | 'chat_book_details'; +type FlagName = + | 'myox_pardot' + | 'my_openstax' + | 'new_subjects' + | 'chat_book_details' + | 'chat_subjects' + | 'chat_contact' + | 'chat_logged_in_only'; type Flag = { name: FlagName; diff --git a/test/src/components/chat.test.tsx b/test/src/components/chat.test.tsx index ef01ce3d6..73ab1df16 100644 --- a/test/src/components/chat.test.tsx +++ b/test/src/components/chat.test.tsx @@ -3,8 +3,9 @@ import {render, waitFor} from '@testing-library/preact'; import Chat from '~/components/chat/chat'; import * as UserContext from '~/contexts/user'; -// Mock the user context +// Mock the user context and scss jest.mock('~/contexts/user'); +jest.mock('~/components/chat/chat.scss', () => ({})); describe('Chat', () => { let mockEmbeddedService: any; From 42138c32c6dd94bfe05db84a000498c602b447ca Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Wed, 27 May 2026 15:03:45 +0000 Subject: [PATCH 04/12] Address Copilot review comments on initialization and field updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use window flag for initialization tracking instead of component ref - Added window.__salesforceChatInitialized flag - Persists across component remounts to maintain chat session - Prevents re-initialization when component unmounts/remounts - Separate initialization from field updates - Split into two effects: one for init, one for field updates - Init effect only runs once (guarded by window flag) - Field update effect runs whenever user info changes - Allows fields to be updated when user logs in after initialization - Fix syntax error in test file - Added missing comma after first_name in test mock object - Add comprehensive tests for new behavior - Test field updates when user logs in after initialization - Test initialization state persists across remounts - Updated existing tests to clean up window flag Addresses comments: - Comment 1: Window flag persists across remounts - Comment 2: Fields can now update independently of initialization - Comment 3: Init only runs once, field updates run as needed - Comment 4: Fixed syntax error 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/app/components/chat/chat.tsx | 31 +++++++---- test/src/components/chat.test.tsx | 86 ++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/src/app/components/chat/chat.tsx b/src/app/components/chat/chat.tsx index faf2577c8..cde2785e7 100644 --- a/src/app/components/chat/chat.tsx +++ b/src/app/components/chat/chat.tsx @@ -18,6 +18,7 @@ declare global { setHiddenPrechatFields: (fields: Record) => void; }; }; + __salesforceChatInitialized?: boolean; } } @@ -49,7 +50,6 @@ function initEmbeddedMessaging() { export default function Chat() { const userContext = useUserContext(); const [scriptLoaded, setScriptLoaded] = React.useState(false); - const initializedRef = React.useRef(false); // Derive stable user primitives const userModel = userContext?.userModel; @@ -81,15 +81,29 @@ export default function Chat() { if (document.body.contains(script)) { document.body.removeChild(script); } - // Note: Don't delete window.embeddedservice_bootstrap to maintain - // conversation state across component remounts + // Note: Don't delete window.embeddedservice_bootstrap or __salesforceChatInitialized + // to maintain conversation state across component remounts }; }, []); - // Initialize chat once and set user fields + // Initialize chat widget once (on first mount or after refresh) + React.useEffect(() => { + if (!scriptLoaded || !window.embeddedservice_bootstrap) { + return; + } + + // Only initialize if not already initialized (persists across component remounts) + if (!window.__salesforceChatInitialized) { + initEmbeddedMessaging(); + window.__salesforceChatInitialized = true; + } + }, [scriptLoaded]); + + // Update pre-chat fields whenever user information changes + // This allows fields to update when a user logs in after chat is initialized // eslint-disable-next-line complexity React.useEffect(() => { - if (!scriptLoaded || !window.embeddedservice_bootstrap || initializedRef.current) { + if (!scriptLoaded || !window.embeddedservice_bootstrap?.prechatAPI) { return; } @@ -115,12 +129,7 @@ export default function Chat() { hiddenFields.School = school; } - if (window.embeddedservice_bootstrap.prechatAPI) { - window.embeddedservice_bootstrap.prechatAPI.setHiddenPrechatFields(hiddenFields); - } - - initEmbeddedMessaging(); - initializedRef.current = true; + window.embeddedservice_bootstrap.prechatAPI.setHiddenPrechatFields(hiddenFields); }, [scriptLoaded, uuid, firstName, lastName, email, school]); return null; diff --git a/test/src/components/chat.test.tsx b/test/src/components/chat.test.tsx index 73ab1df16..dc13b5576 100644 --- a/test/src/components/chat.test.tsx +++ b/test/src/components/chat.test.tsx @@ -14,6 +14,7 @@ describe('Chat', () => { // Clean up any existing scripts and global objects document.querySelectorAll('script[src*="bootstrap.min.js"]').forEach((el) => el.remove()); delete (window as any).embeddedservice_bootstrap; + delete (window as any).__salesforceChatInitialized; // Create mock embeddedservice_bootstrap mockEmbeddedService = { @@ -120,7 +121,7 @@ describe('Chat', () => { const mockUserContext = { userModel: { uuid: 'test-uuid-123', - first_name: 'John' + first_name: 'John', // Missing last_name, email, school } }; @@ -205,4 +206,87 @@ describe('Chat', () => { // Should not initialize again expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); }); + + it('updates pre-chat fields when user logs in after initialization', async () => { + // Start with anonymous user + (UserContext.default as jest.Mock).mockReturnValue({}); + + const {rerender} = render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + }); + + // Verify initial fields (anonymous user) + expect(mockEmbeddedService.prechatAPI.setHiddenPrechatFields).toHaveBeenCalledWith({ + sProduct: 'Website' + }); + + // Clear mock calls + mockEmbeddedService.prechatAPI.setHiddenPrechatFields.mockClear(); + + // Simulate user logging in + (UserContext.default as jest.Mock).mockReturnValue({ + userModel: { + uuid: 'test-uuid-456', + first_name: 'Jane', + last_name: 'Doe', + email: 'jane.doe@example.com', + accountsModel: { + school_name: 'Example University' + } + } + }); + + rerender(); + + // Verify fields updated with user information + await waitFor(() => { + expect(mockEmbeddedService.prechatAPI.setHiddenPrechatFields).toHaveBeenCalledWith({ + sProduct: 'Website', + OpenStax_UUID__c: 'test-uuid-456', + FirstName: 'Jane', + LastName: 'Doe', + Email: 'jane.doe@example.com', + School: 'Example University' + }); + }); + + // Init should still only have been called once + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + }); + + it('preserves initialization state across component remounts', async () => { + (UserContext.default as jest.Mock).mockReturnValue({}); + + const {unmount} = render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load and initialization + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + }); + + // Unmount component + unmount(); + + // Remount component + render(); + + // Wait a bit to ensure no re-initialization + await new Promise(resolve => setTimeout(resolve, 100)); + + // Init should still only have been called once (state persists via window flag) + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + }); }); From 84c9785a40affd380cd765649ab438acddcba802 Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Wed, 27 May 2026 10:28:03 -0500 Subject: [PATCH 05/12] chat_logged_in_only -> chat_logged_in --- src/app/components/shell/router.tsx | 4 ++-- src/app/contexts/shared-data.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/components/shell/router.tsx b/src/app/components/shell/router.tsx index c74fd8da8..5837eb182 100644 --- a/src/app/components/shell/router.tsx +++ b/src/app/components/shell/router.tsx @@ -94,8 +94,8 @@ function MainRoutes() { // Check if user is logged in const isLoggedIn = Boolean(userContext?.userModel?.uuid); - // If chat_logged_in_only is enabled and user is logged in, show on any page - if (flags.chat_logged_in_only && isLoggedIn) { + // If chat_logged_in is enabled and user is logged in, show on any page + if (flags.chat_logged_in && isLoggedIn) { return true; } diff --git a/src/app/contexts/shared-data.ts b/src/app/contexts/shared-data.ts index 1c7dd6ce0..d05a94a06 100644 --- a/src/app/contexts/shared-data.ts +++ b/src/app/contexts/shared-data.ts @@ -9,7 +9,7 @@ type FlagName = | 'chat_book_details' | 'chat_subjects' | 'chat_contact' - | 'chat_logged_in_only'; + | 'chat_logged_in'; type Flag = { name: FlagName; From e525469af6badfd6ab1b50a8c91270ffd8cf7939 Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Wed, 27 May 2026 15:58:56 +0000 Subject: [PATCH 06/12] Address final Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add explicit hide/show logic for Salesforce chat widget - Hide chat elements on component unmount to prevent widget from staying visible - Show chat elements on component mount if widget was previously initialized - Targets Salesforce-injected DOM elements (.embeddedServiceHelpButton, etc.) - Ensures route/flag gating works correctly after initialization - Remove setTimeout from unit test - Test can assert immediately since no new onload event is triggered - Prevents test suite slowdown and potential flakiness - __salesforceChatInitialized flag prevents re-init without delay 🤖 Generated with [Claude Code](https://claude.com/claude-code) Shorten lines Co-Authored-By: Claude Sonnet 4.5 --- src/app/components/chat/chat.tsx | 22 ++++++++++++++++++++++ test/src/components/chat.test.tsx | 4 +--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/app/components/chat/chat.tsx b/src/app/components/chat/chat.tsx index cde2785e7..0e5e644f9 100644 --- a/src/app/components/chat/chat.tsx +++ b/src/app/components/chat/chat.tsx @@ -81,11 +81,33 @@ export default function Chat() { if (document.body.contains(script)) { document.body.removeChild(script); } + // Hide the chat widget when component unmounts + // The Salesforce widget injects elements with these selectors + const chatElements = document.querySelectorAll( + '.embeddedServiceHelpButton, .dockableContainer, .sidebarBody, .embeddedServiceSidebar' + ); + + chatElements.forEach((el) => { + (el as HTMLElement).style.display = 'none'; + }); // Note: Don't delete window.embeddedservice_bootstrap or __salesforceChatInitialized // to maintain conversation state across component remounts }; }, []); + // Show chat widget when component mounts (if it was previously hidden) + React.useEffect(() => { + if (scriptLoaded && window.__salesforceChatInitialized) { + const chatElements = document.querySelectorAll( + '.embeddedServiceHelpButton, .dockableContainer, .sidebarBody, .embeddedServiceSidebar' + ); + + chatElements.forEach((el) => { + (el as HTMLElement).style.display = ''; + }); + } + }, [scriptLoaded]); + // Initialize chat widget once (on first mount or after refresh) React.useEffect(() => { if (!scriptLoaded || !window.embeddedservice_bootstrap) { diff --git a/test/src/components/chat.test.tsx b/test/src/components/chat.test.tsx index dc13b5576..2873f9023 100644 --- a/test/src/components/chat.test.tsx +++ b/test/src/components/chat.test.tsx @@ -283,10 +283,8 @@ describe('Chat', () => { // Remount component render(); - // Wait a bit to ensure no re-initialization - await new Promise(resolve => setTimeout(resolve, 100)); - // Init should still only have been called once (state persists via window flag) + // No need to wait - no new script load event is triggered on remount expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); }); }); From 603252021076c2c3f487b77c8d4143b774d71a8a Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Wed, 27 May 2026 12:24:18 -0500 Subject: [PATCH 07/12] Hide/show chatElement by id --- src/app/components/chat/chat.tsx | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/app/components/chat/chat.tsx b/src/app/components/chat/chat.tsx index 0e5e644f9..ef337cf5c 100644 --- a/src/app/components/chat/chat.tsx +++ b/src/app/components/chat/chat.tsx @@ -83,13 +83,11 @@ export default function Chat() { } // Hide the chat widget when component unmounts // The Salesforce widget injects elements with these selectors - const chatElements = document.querySelectorAll( - '.embeddedServiceHelpButton, .dockableContainer, .sidebarBody, .embeddedServiceSidebar' - ); + const chatElement = document.getElementById('embedded-messaging'); - chatElements.forEach((el) => { - (el as HTMLElement).style.display = 'none'; - }); + if (chatElement) { + chatElement.style.display = 'none'; + } // Note: Don't delete window.embeddedservice_bootstrap or __salesforceChatInitialized // to maintain conversation state across component remounts }; @@ -98,13 +96,11 @@ export default function Chat() { // Show chat widget when component mounts (if it was previously hidden) React.useEffect(() => { if (scriptLoaded && window.__salesforceChatInitialized) { - const chatElements = document.querySelectorAll( - '.embeddedServiceHelpButton, .dockableContainer, .sidebarBody, .embeddedServiceSidebar' - ); + const chatElement = document.getElementById('embedded-messaging'); - chatElements.forEach((el) => { - (el as HTMLElement).style.display = ''; - }); + if (chatElement) { + chatElement.style.removeProperty('display'); + } } }, [scriptLoaded]); From 411f534d5f25f8a1c8a833d7813104e008e03ab7 Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Fri, 29 May 2026 22:44:45 +0000 Subject: [PATCH 08/12] Address final Copilot review comments (Review 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment 1: Short-circuit script loading when bootstrap already exists - Check if window.embeddedservice_bootstrap exists on mount - If it exists, immediately set scriptLoaded=true and skip script injection - Prevents unnecessary script reload and speeds up widget re-display on remount - Eliminates potential delay or failure when re-showing hidden widget Comment 2: Only set initialization flag on successful init - Changed initEmbeddedMessaging() to return boolean success indicator - Only set window.__salesforceChatInitialized = true if init succeeded - Prevents permanently broken state if initialization fails - Allows retry on future mounts if initialization threw an error Comment 3: Update test to reflect new script loading behavior - Updated test comment and assertions to match short-circuit logic - Verifies no new script is injected on remount when bootstrap exists - Added explicit check that script count remains 0 after remount - Clarifies that scriptLoaded is set immediately via short-circuit Performance optimization in router.tsx: - Extract isLoggedIn as stable primitive before useMemo - Only re-evaluate showChat when isLoggedIn changes, not entire userContext - Reduces unnecessary re-renders when unrelated user properties update 🤖 Generated with [Claude Code](https://claude.com/claude-code) Consistent return value Co-Authored-By: Claude Sonnet 4.5 --- src/app/components/chat/chat.tsx | 21 +++++++++++++++++---- src/app/components/shell/router.tsx | 8 ++++---- test/src/components/chat.test.tsx | 13 ++++++++++--- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/app/components/chat/chat.tsx b/src/app/components/chat/chat.tsx index ef337cf5c..4a9f5fcd1 100644 --- a/src/app/components/chat/chat.tsx +++ b/src/app/components/chat/chat.tsx @@ -30,7 +30,7 @@ const SALESFORCE_CONFIG = { bootstrapScript: 'https://openstax.my.site.com/ESWWebMessagingDeployme1716235390398/assets/js/bootstrap.min.js' }; -function initEmbeddedMessaging() { +function initEmbeddedMessaging(): boolean { try { if (window.embeddedservice_bootstrap) { window.embeddedservice_bootstrap.settings.language = 'en_US'; @@ -40,9 +40,12 @@ function initEmbeddedMessaging() { SALESFORCE_CONFIG.baseUrl, {scrt2URL: SALESFORCE_CONFIG.scrt2URL} ); + return true; } + return false; } catch (err) { console.error('Error initializing Salesforce chat:', err); + return false; } } @@ -59,8 +62,14 @@ export default function Chat() { const email = userModel?.email; const school = userModel?.accountsModel?.school_name; - // Load Salesforce script once + // Load Salesforce script once, or short-circuit if already loaded React.useEffect(() => { + // Short-circuit if bootstrap is already available from a previous mount + if (window.embeddedservice_bootstrap) { + setScriptLoaded(true); + return () => undefined; + } + const script = document.createElement('script'); script.src = SALESFORCE_CONFIG.bootstrapScript; @@ -112,8 +121,12 @@ export default function Chat() { // Only initialize if not already initialized (persists across component remounts) if (!window.__salesforceChatInitialized) { - initEmbeddedMessaging(); - window.__salesforceChatInitialized = true; + const success = initEmbeddedMessaging(); + + // Only set the flag if initialization succeeded + if (success) { + window.__salesforceChatInitialized = true; + } } }, [scriptLoaded]); diff --git a/src/app/components/shell/router.tsx b/src/app/components/shell/router.tsx index 5837eb182..455037139 100644 --- a/src/app/components/shell/router.tsx +++ b/src/app/components/shell/router.tsx @@ -84,6 +84,9 @@ function MainRoutes() { const {flags} = useSharedDataContext(); const userContext = useUserContext(); + // Derive stable user login state + const isLoggedIn = Boolean(userContext?.userModel?.uuid); + // Determine if chat should be shown based on current route and feature flags // eslint-disable-next-line complexity const showChat = React.useMemo(() => { @@ -91,9 +94,6 @@ function MainRoutes() { return false; } - // Check if user is logged in - const isLoggedIn = Boolean(userContext?.userModel?.uuid); - // If chat_logged_in is enabled and user is logged in, show on any page if (flags.chat_logged_in && isLoggedIn) { return true; @@ -115,7 +115,7 @@ function MainRoutes() { } return false; - }, [pathname, flags, userContext]); + }, [pathname, flags, isLoggedIn]); return ( diff --git a/test/src/components/chat.test.tsx b/test/src/components/chat.test.tsx index 2873f9023..7794c33ea 100644 --- a/test/src/components/chat.test.tsx +++ b/test/src/components/chat.test.tsx @@ -280,11 +280,18 @@ describe('Chat', () => { // Unmount component unmount(); - // Remount component + // Verify script was removed from DOM + expect(document.querySelectorAll('script[src*="bootstrap.min.js"]').length).toBe(0); + + // Remount component - bootstrap object still exists in window, so no new script is created render(); - // Init should still only have been called once (state persists via window flag) - // No need to wait - no new script load event is triggered on remount + // Because window.embeddedservice_bootstrap still exists, the component short-circuits + // and doesn't inject a new script. scriptLoaded is set immediately. + // The initialization effect sees __salesforceChatInitialized is already true and doesn't re-init. expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + + // Verify no new script was added (short-circuit logic worked) + expect(document.querySelectorAll('script[src*="bootstrap.min.js"]').length).toBe(0); }); }); From a813084647859708d6529ec51f55c9ea1c47ba2a Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Tue, 2 Jun 2026 15:04:25 +0000 Subject: [PATCH 09/12] Address final Copilot review comments (Review 13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Issue:** `useUserContext()` only exposes `userModel` when `model.last_name` is truthy, which means logged-in users without a last name would be treated as anonymous and pre-chat fields wouldn't populate. **Resolution:** - Changed to derive user primitives from `userStatus` (always available) - Added fallback to `userModel` for compatibility - User info now extracted using: `userStatus?.uuid || userModel?.uuid` - Same pattern applied for firstName, lastName, email, school **Issue:** When short-circuiting because `window.embeddedservice_bootstrap` already exists, the effect returned a no-op cleanup (`() => undefined`), so the hide-on-unmount logic never ran, leaving the widget visible. **Resolution:** - Refactored to always return proper cleanup function - Moved script creation into conditional block - Cleanup now always hides chat widget on unmount, regardless of whether script was injected or short-circuited **Issue:** Using `userModel?.uuid` to determine login state can incorrectly treat some logged-in users as anonymous (same root cause as comment 1). **Resolution:** - Changed to: `userStatus?.uuid || userModel?.uuid` - Ensures `chat_logged_in` flag works correctly for all logged-in users - Site-wide chat now properly enabled when flag is set **Before:** - User info only available when `last_name` was present - Short-circuit path had no cleanup logic - Some logged-in users treated as anonymous **After:** - User info always accessible via `userStatus` - Cleanup always runs to hide widget on unmount - All logged-in users correctly identified 🤖 Generated with [Claude Code](https://claude.com/claude-code) Update router.tsx Co-Authored-By: Claude Sonnet 4.5 --- src/app/components/chat/chat.tsx | 46 ++++++++++++++++------------- src/app/components/shell/router.tsx | 5 ++-- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/app/components/chat/chat.tsx b/src/app/components/chat/chat.tsx index 4a9f5fcd1..d651a7a23 100644 --- a/src/app/components/chat/chat.tsx +++ b/src/app/components/chat/chat.tsx @@ -54,40 +54,44 @@ export default function Chat() { const userContext = useUserContext(); const [scriptLoaded, setScriptLoaded] = React.useState(false); - // Derive stable user primitives + // Derive stable user primitives from userStatus (which is always available) + // with fallback to userModel when available + const userStatus = userContext?.userStatus; const userModel = userContext?.userModel; - const uuid = userModel?.uuid; - const firstName = userModel?.first_name; - const lastName = userModel?.last_name; - const email = userModel?.email; - const school = userModel?.accountsModel?.school_name; + const uuid = userStatus?.uuid || userModel?.uuid; + const firstName = userStatus?.firstName || userModel?.first_name; + const lastName = userStatus?.lastName || userModel?.last_name; + const email = userStatus?.email || userModel?.email; + const school = userStatus?.school || userModel?.accountsModel?.school_name; // Load Salesforce script once, or short-circuit if already loaded React.useEffect(() => { + let script: HTMLScriptElement | null = null; + // Short-circuit if bootstrap is already available from a previous mount if (window.embeddedservice_bootstrap) { setScriptLoaded(true); - return () => undefined; - } - - const script = document.createElement('script'); + } else { + script = document.createElement('script'); - script.src = SALESFORCE_CONFIG.bootstrapScript; - script.type = 'text/javascript'; - script.async = true; + script.src = SALESFORCE_CONFIG.bootstrapScript; + script.type = 'text/javascript'; + script.async = true; - script.onload = () => { - setScriptLoaded(true); - }; + script.onload = () => { + setScriptLoaded(true); + }; - script.onerror = () => { - console.error('Failed to load Salesforce chat script'); - }; + script.onerror = () => { + console.error('Failed to load Salesforce chat script'); + }; - document.body.appendChild(script); + document.body.appendChild(script); + } + // Always return cleanup function to hide widget on unmount return () => { - if (document.body.contains(script)) { + if (script && document.body.contains(script)) { document.body.removeChild(script); } // Hide the chat widget when component unmounts diff --git a/src/app/components/shell/router.tsx b/src/app/components/shell/router.tsx index 455037139..eedd7f715 100644 --- a/src/app/components/shell/router.tsx +++ b/src/app/components/shell/router.tsx @@ -78,14 +78,15 @@ export default function Router() { ); } +// eslint-disable-next-line complexity function MainRoutes() { const {Layout} = useLayoutContext(); const {pathname} = useLocation(); const {flags} = useSharedDataContext(); const userContext = useUserContext(); - // Derive stable user login state - const isLoggedIn = Boolean(userContext?.userModel?.uuid); + // Derive stable user login state from userStatus (always available) with fallback to userModel + const isLoggedIn = Boolean(userContext?.userStatus?.uuid || userContext?.userModel?.uuid); // Determine if chat should be shown based on current route and feature flags // eslint-disable-next-line complexity From 57d44129be68c6873c4d1ec6d38fef1f35678d4e Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Tue, 2 Jun 2026 15:49:10 +0000 Subject: [PATCH 10/12] Centralize isLoggedIn logic in useUserContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Roy's review comment, consolidate isLoggedIn logic into a single canonical location in contexts/user.ts and have all other places use it. Changes: - Added isLoggedIn to useUserContext return value - Uses userStatus?.uuid || model?.uuid (the correct check) - Always available in context, even when userModel is not - Documented as the canonical way to check login state - Updated all places that previously defined isLoggedIn locally: 1. router.tsx - Removed local calculation, now uses context 2. form-header.tsx - Removed incorrect last_name check, now uses context 3. adoption.tsx - Removed incorrect last_name checks (2 instances), now uses context Benefits: - Single source of truth for login state - Fixes inconsistent implementations (some used last_name incorrectly) - Ensures all features work correctly for users without last names - Easier to maintain and update in the future Addresses Review 15 feedback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Update adoption.tsx Co-Authored-By: Claude Sonnet 4.5 --- src/app/components/form-header/form-header.tsx | 3 +-- src/app/components/shell/router.tsx | 5 +---- src/app/contexts/user.ts | 8 ++++++-- src/app/pages/adoption/adoption.tsx | 6 ++---- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/app/components/form-header/form-header.tsx b/src/app/components/form-header/form-header.tsx index 032375293..154d7b611 100644 --- a/src/app/components/form-header/form-header.tsx +++ b/src/app/components/form-header/form-header.tsx @@ -42,8 +42,7 @@ function FormHeader({ data: Record; prefix: string; }) { - const {userStatus} = useUserContext(); - const isLoggedIn = Boolean(userStatus?.userInfo?.last_name); + const {userStatus, isLoggedIn} = useUserContext(); const heading = pickField(data, prefix, 'IntroHeading', isLoggedIn); const description = pickField(data, prefix, 'IntroDescription', isLoggedIn); const replacements = React.useMemo(() => buildReplacements(userStatus), [userStatus]); diff --git a/src/app/components/shell/router.tsx b/src/app/components/shell/router.tsx index eedd7f715..6c1f2f945 100644 --- a/src/app/components/shell/router.tsx +++ b/src/app/components/shell/router.tsx @@ -83,10 +83,7 @@ function MainRoutes() { const {Layout} = useLayoutContext(); const {pathname} = useLocation(); const {flags} = useSharedDataContext(); - const userContext = useUserContext(); - - // Derive stable user login state from userStatus (always available) with fallback to userModel - const isLoggedIn = Boolean(userContext?.userStatus?.uuid || userContext?.userModel?.uuid); + const {isLoggedIn} = useUserContext(); // Determine if chat should be shown based on current route and feature flags // eslint-disable-next-line complexity diff --git a/src/app/contexts/user.ts b/src/app/contexts/user.ts index 214c1107c..4e89cebee 100644 --- a/src/app/contexts/user.ts +++ b/src/app/contexts/user.ts @@ -47,6 +47,9 @@ function useContextValue() { model?.accountsModel?.faculty_status === 'confirmed_faculty'; const [fetchTime, updateMyOpenStaxUser] = useRefreshable(() => Date.now()); const myOpenStaxUser = useMyOpenStaxUser(isVerified, fetchTime); + // Derive login state from userStatus (always available) with fallback to model + // This is the canonical way to check if a user is logged in + const isLoggedIn = Boolean(userStatus?.uuid || model?.uuid); const value = React.useMemo( () => model.last_name @@ -59,12 +62,13 @@ function useContextValue() { userModel: model, uuid: model.uuid, isVerified, + isLoggedIn, userStatus, myOpenStaxUser, updateMyOpenStaxUser } - : {userStatus, myOpenStaxUser}, - [model, userStatus, isVerified, myOpenStaxUser, updateMyOpenStaxUser] + : {isLoggedIn, userStatus, myOpenStaxUser}, + [model, userStatus, isVerified, isLoggedIn, myOpenStaxUser, updateMyOpenStaxUser] ); React.useEffect(() => { diff --git a/src/app/pages/adoption/adoption.tsx b/src/app/pages/adoption/adoption.tsx index 59f369e87..9e8026bbc 100644 --- a/src/app/pages/adoption/adoption.tsx +++ b/src/app/pages/adoption/adoption.tsx @@ -176,8 +176,7 @@ function FacultyForm({ const afterSubmit = useAfterSubmit(selectedBooksRef); const {onSubmit, submitting, FormTarget} = useFormTarget(afterSubmit); const {adoptionUrl} = useSalesforceContext(); - const {userModel, uuid} = useUserContext(); - const isLoggedIn = Boolean(userModel?.last_name); + const {uuid, isLoggedIn} = useUserContext(); const adoptions = useAdoptions(uuid); const preselectedValues = React.useMemo( () => adoptions?.Books.map((b) => b.name), @@ -297,8 +296,7 @@ export default function AdoptionForm() { const [selectedRole, setSelectedRole] = useState(''); const [hideRoleSelector, setHideRoleSelector] = useState(false); const ref = useRef(null); - const {userModel} = useUserContext(); - const isLoggedIn = Boolean(userModel?.last_name); + const {userModel, isLoggedIn} = useUserContext(); const initialRender = useRef(true); const onPageChange = React.useCallback((page: number) => { setHideRoleSelector(page > 1); From 79be4f72454418f452a3853fe2ad86650c79da4c Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Tue, 2 Jun 2026 16:26:33 +0000 Subject: [PATCH 11/12] Address final Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Clear script handlers on cleanup (Comment 1) - Clear onload and onerror handlers before removing script - Prevents setState-on-unmounted-component warnings - Handlers can fire after unmount if network request completes late 2. Update PR description (Comments 2 & 3) - Clarified that widget is hidden (not removed) to preserve session state - Added explicit description of hide/show behavior when navigating - Updated User Experience section with widget visibility details - Updated Technical Implementation section 3. Add uuid to user context fallback branch (Comment 4) - Expose uuid in fallback branch: {isLoggedIn, userStatus, myOpenStaxUser, uuid: userStatus?.uuid} - Makes context shape consistent across both branches - Consumers can now reliably destructure uuid alongside isLoggedIn - uuid will be undefined for truly anonymous users 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/app/components/chat/chat.tsx | 3 +++ src/app/contexts/user.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/components/chat/chat.tsx b/src/app/components/chat/chat.tsx index d651a7a23..cee1eb5c3 100644 --- a/src/app/components/chat/chat.tsx +++ b/src/app/components/chat/chat.tsx @@ -92,6 +92,9 @@ export default function Chat() { // Always return cleanup function to hide widget on unmount return () => { if (script && document.body.contains(script)) { + // Clear handlers to prevent setState on unmounted component + script.onload = null; + script.onerror = null; document.body.removeChild(script); } // Hide the chat widget when component unmounts diff --git a/src/app/contexts/user.ts b/src/app/contexts/user.ts index 4e89cebee..067aa8798 100644 --- a/src/app/contexts/user.ts +++ b/src/app/contexts/user.ts @@ -67,7 +67,7 @@ function useContextValue() { myOpenStaxUser, updateMyOpenStaxUser } - : {isLoggedIn, userStatus, myOpenStaxUser}, + : {isLoggedIn, userStatus, myOpenStaxUser, uuid: userStatus?.uuid}, [model, userStatus, isVerified, isLoggedIn, myOpenStaxUser, updateMyOpenStaxUser] ); From 4a0decb6b2347f5f5245a5e17288328c752065a0 Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Wed, 3 Jun 2026 07:16:36 -0500 Subject: [PATCH 12/12] Use chat_logged_in_only to restrict to logged in users --- src/app/components/shell/router.tsx | 6 +++--- src/app/contexts/shared-data.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/components/shell/router.tsx b/src/app/components/shell/router.tsx index 6c1f2f945..95a29e250 100644 --- a/src/app/components/shell/router.tsx +++ b/src/app/components/shell/router.tsx @@ -92,9 +92,9 @@ function MainRoutes() { return false; } - // If chat_logged_in is enabled and user is logged in, show on any page - if (flags.chat_logged_in && isLoggedIn) { - return true; + // If chat_logged_in_only is enabled, return false unless logged in + if (flags.chat_logged_in_only && !isLoggedIn) { + return false; } // Check if we're on a book details page diff --git a/src/app/contexts/shared-data.ts b/src/app/contexts/shared-data.ts index d05a94a06..1c7dd6ce0 100644 --- a/src/app/contexts/shared-data.ts +++ b/src/app/contexts/shared-data.ts @@ -9,7 +9,7 @@ type FlagName = | 'chat_book_details' | 'chat_subjects' | 'chat_contact' - | 'chat_logged_in'; + | 'chat_logged_in_only'; type Flag = { name: FlagName;