From 0b2f4e9eec607c2115c1940cf1e3708042ccc416 Mon Sep 17 00:00:00 2001 From: Ryan Pope Date: Wed, 5 Jun 2024 15:49:39 -0400 Subject: [PATCH] chat-headless: remove session storage support in favor of local storage (#49) Removes all references and usages of session storage in favor of local storage. This means that conversation state will now persist across pages as well as refreshes, so long as all pages are on the same hostname. TEST=manual,auto Updated unit tests, saw them pass. Ran local test page and refreshed, closed tabs, reopened. State persisted always. --- package-lock.json | 12 +++- .../chat-headless-react/THIRD-PARTY-NOTICES | 4 +- packages/chat-headless/THIRD-PARTY-NOTICES | 2 +- ...-headless.chatheadless.initlocalstorage.md | 21 ++++++ ...eadless.chatheadless.initsessionstorage.md | 21 ------ .../docs/chat-headless.chatheadless.md | 2 +- .../docs/chat-headless.headlessconfig.md | 2 +- ...less.headlessconfig.savetolocalstorage.md} | 8 +-- .../chat-headless/etc/chat-headless.api.md | 4 +- packages/chat-headless/package.json | 2 +- .../chat-headless/src/ChatHeadlessImpl.ts | 8 +-- .../chat-headless/src/models/ChatHeadless.ts | 6 +- .../src/models/HeadlessConfig.ts | 4 +- .../chat-headless/src/slices/conversation.ts | 52 ++++++++++---- .../tests/chatheadless.chatapi.test.ts | 2 +- .../chat-headless/tests/chatheadless.test.ts | 67 +++++++++++++++---- 16 files changed, 147 insertions(+), 70 deletions(-) create mode 100644 packages/chat-headless/docs/chat-headless.chatheadless.initlocalstorage.md delete mode 100644 packages/chat-headless/docs/chat-headless.chatheadless.initsessionstorage.md rename packages/chat-headless/docs/{chat-headless.headlessconfig.savetosessionstorage.md => chat-headless.headlessconfig.savetolocalstorage.md} (56%) diff --git a/package-lock.json b/package-lock.json index cbd2fa4..6bad2de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18722,7 +18722,7 @@ }, "packages/chat-headless": { "name": "@yext/chat-headless", - "version": "0.8.0", + "version": "0.9.0", "license": "BSD-3-Clause", "dependencies": { "@reduxjs/toolkit": "^1.9.5", @@ -19047,6 +19047,16 @@ "@types/yargs-parser": "*" } }, + "packages/chat-headless-react/node_modules/@yext/chat-headless": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@yext/chat-headless/-/chat-headless-0.8.0.tgz", + "integrity": "sha512-2KSrIpnkMOvC8anQJ/VkYhH1VNeOA4Zka5h+HU0STXldtLZ3hKSlSufd/87qR+ie1M3xgcrlZHGBHX8fB2FOyw==", + "dependencies": { + "@reduxjs/toolkit": "^1.9.5", + "@yext/analytics": "^0.6.3", + "@yext/chat-core": "^0.8.0" + } + }, "packages/chat-headless-react/node_modules/ansi-styles": { "version": "4.3.0", "dev": true, diff --git a/packages/chat-headless-react/THIRD-PARTY-NOTICES b/packages/chat-headless-react/THIRD-PARTY-NOTICES index 32ede0c..9b79030 100644 --- a/packages/chat-headless-react/THIRD-PARTY-NOTICES +++ b/packages/chat-headless-react/THIRD-PARTY-NOTICES @@ -3,7 +3,7 @@ https://www.npmjs.com/package/generate-license-file The following npm package may be included in this product: - - @babel/runtime@7.24.5 + - @babel/runtime@7.24.7 This package contains the following license and notice below: @@ -67,7 +67,7 @@ The following npm packages may be included in this product: - @types/hoist-non-react-statics@3.3.5 - @types/prop-types@15.7.12 - @types/react-dom@18.3.0 - - @types/react@18.3.2 + - @types/react@18.3.3 - @types/use-sync-external-store@0.0.3 These packages each contain the following license and notice below: diff --git a/packages/chat-headless/THIRD-PARTY-NOTICES b/packages/chat-headless/THIRD-PARTY-NOTICES index 53446c5..2458adc 100644 --- a/packages/chat-headless/THIRD-PARTY-NOTICES +++ b/packages/chat-headless/THIRD-PARTY-NOTICES @@ -3,7 +3,7 @@ https://www.npmjs.com/package/generate-license-file The following npm package may be included in this product: - - @babel/runtime@7.24.5 + - @babel/runtime@7.24.7 This package contains the following license and notice below: diff --git a/packages/chat-headless/docs/chat-headless.chatheadless.initlocalstorage.md b/packages/chat-headless/docs/chat-headless.chatheadless.initlocalstorage.md new file mode 100644 index 0000000..8a309f1 --- /dev/null +++ b/packages/chat-headless/docs/chat-headless.chatheadless.initlocalstorage.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [@yext/chat-headless](./chat-headless.md) > [ChatHeadless](./chat-headless.chatheadless.md) > [initLocalStorage](./chat-headless.chatheadless.initlocalstorage.md) + +## ChatHeadless.initLocalStorage() method + +Loads the [ConversationState](./chat-headless.conversationstate.md) from local storage, if present, and adds a listener to keep the conversation state in sync with the stored state + +**Signature:** + +```typescript +initLocalStorage(): void; +``` +**Returns:** + +void + +## Remarks + +This is called by default if [HeadlessConfig.saveToLocalStorage](./chat-headless.headlessconfig.savetolocalstorage.md) is true. + diff --git a/packages/chat-headless/docs/chat-headless.chatheadless.initsessionstorage.md b/packages/chat-headless/docs/chat-headless.chatheadless.initsessionstorage.md deleted file mode 100644 index 653b536..0000000 --- a/packages/chat-headless/docs/chat-headless.chatheadless.initsessionstorage.md +++ /dev/null @@ -1,21 +0,0 @@ - - -[Home](./index.md) > [@yext/chat-headless](./chat-headless.md) > [ChatHeadless](./chat-headless.chatheadless.md) > [initSessionStorage](./chat-headless.chatheadless.initsessionstorage.md) - -## ChatHeadless.initSessionStorage() method - -Loads the [ConversationState](./chat-headless.conversationstate.md) from session storage, if present, and adds a listener to keep the conversation state in sync with the stored state - -**Signature:** - -```typescript -initSessionStorage(): void; -``` -**Returns:** - -void - -## Remarks - -This is called by default if [HeadlessConfig.saveToSessionStorage](./chat-headless.headlessconfig.savetosessionstorage.md) is true. - diff --git a/packages/chat-headless/docs/chat-headless.chatheadless.md b/packages/chat-headless/docs/chat-headless.chatheadless.md index e5275c9..75f8748 100644 --- a/packages/chat-headless/docs/chat-headless.chatheadless.md +++ b/packages/chat-headless/docs/chat-headless.chatheadless.md @@ -19,7 +19,7 @@ export interface ChatHeadless | [addListener(listener)](./chat-headless.chatheadless.addlistener.md) | Adds a listener for a specific state value of type T. | | [addMessage(message)](./chat-headless.chatheadless.addmessage.md) | Adds a new message to [ConversationState.messages](./chat-headless.conversationstate.messages.md) | | [getNextMessage(text, source)](./chat-headless.chatheadless.getnextmessage.md) | Performs a Chat API request for the next message generated by chat bot using the conversation state (e.g. message history and notes). Update the state with the response data. | -| [initSessionStorage()](./chat-headless.chatheadless.initsessionstorage.md) | Loads the [ConversationState](./chat-headless.conversationstate.md) from session storage, if present, and adds a listener to keep the conversation state in sync with the stored state | +| [initLocalStorage()](./chat-headless.chatheadless.initlocalstorage.md) | Loads the [ConversationState](./chat-headless.conversationstate.md) from local storage, if present, and adds a listener to keep the conversation state in sync with the stored state | | [report(eventPayload)](./chat-headless.chatheadless.report.md) | Send Chat related analytics event to Yext Analytics API. | | [restartConversation()](./chat-headless.chatheadless.restartconversation.md) | Resets all fields within [ConversationState](./chat-headless.conversationstate.md) | | [setCanSendMessage(canSendMessage)](./chat-headless.chatheadless.setcansendmessage.md) | Sets [ConversationState.canSendMessage](./chat-headless.conversationstate.cansendmessage.md) to the specified state | diff --git a/packages/chat-headless/docs/chat-headless.headlessconfig.md b/packages/chat-headless/docs/chat-headless.headlessconfig.md index d786c89..218aab1 100644 --- a/packages/chat-headless/docs/chat-headless.headlessconfig.md +++ b/packages/chat-headless/docs/chat-headless.headlessconfig.md @@ -18,5 +18,5 @@ export interface HeadlessConfig extends ChatConfig | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [analyticsConfig?](./chat-headless.headlessconfig.analyticsconfig.md) | | Omit<ChatAnalyticsConfig, "apiKey" \| "env" \| "region"> & { baseEventPayload?: DeepPartial<ChatEventPayLoad>; } | _(Optional)_ Configurations for Chat analytics. | -| [saveToSessionStorage?](./chat-headless.headlessconfig.savetosessionstorage.md) | | boolean | _(Optional)_ Whether to save the instance's [ConversationState](./chat-headless.conversationstate.md) to session storage. Defaults to true. | +| [saveToLocalStorage?](./chat-headless.headlessconfig.savetolocalstorage.md) | | boolean | _(Optional)_ Whether to save the instance's [ConversationState](./chat-headless.conversationstate.md) to local storage. Defaults to true. | diff --git a/packages/chat-headless/docs/chat-headless.headlessconfig.savetosessionstorage.md b/packages/chat-headless/docs/chat-headless.headlessconfig.savetolocalstorage.md similarity index 56% rename from packages/chat-headless/docs/chat-headless.headlessconfig.savetosessionstorage.md rename to packages/chat-headless/docs/chat-headless.headlessconfig.savetolocalstorage.md index 08cba97..8c19dd9 100644 --- a/packages/chat-headless/docs/chat-headless.headlessconfig.savetosessionstorage.md +++ b/packages/chat-headless/docs/chat-headless.headlessconfig.savetolocalstorage.md @@ -1,13 +1,13 @@ -[Home](./index.md) > [@yext/chat-headless](./chat-headless.md) > [HeadlessConfig](./chat-headless.headlessconfig.md) > [saveToSessionStorage](./chat-headless.headlessconfig.savetosessionstorage.md) +[Home](./index.md) > [@yext/chat-headless](./chat-headless.md) > [HeadlessConfig](./chat-headless.headlessconfig.md) > [saveToLocalStorage](./chat-headless.headlessconfig.savetolocalstorage.md) -## HeadlessConfig.saveToSessionStorage property +## HeadlessConfig.saveToLocalStorage property -Whether to save the instance's [ConversationState](./chat-headless.conversationstate.md) to session storage. Defaults to true. +Whether to save the instance's [ConversationState](./chat-headless.conversationstate.md) to local storage. Defaults to true. **Signature:** ```typescript -saveToSessionStorage?: boolean; +saveToLocalStorage?: boolean; ``` diff --git a/packages/chat-headless/etc/chat-headless.api.md b/packages/chat-headless/etc/chat-headless.api.md index 4dfdc73..6ed7f3c 100644 --- a/packages/chat-headless/etc/chat-headless.api.md +++ b/packages/chat-headless/etc/chat-headless.api.md @@ -47,7 +47,7 @@ export interface ChatHeadless { addListener(listener: StateListener): Unsubscribe; addMessage(message: Message): void; getNextMessage(text?: string, source?: MessageSource): Promise; - initSessionStorage(): void; + initLocalStorage(): void; report(eventPayload: Omit & DeepPartial>): Promise; restartConversation(): void; setCanSendMessage(canSendMessage: boolean): void; @@ -84,7 +84,7 @@ export interface HeadlessConfig extends ChatConfig { analyticsConfig?: Omit & { baseEventPayload?: DeepPartial; }; - saveToSessionStorage?: boolean; + saveToLocalStorage?: boolean; } export { InternalConfig } diff --git a/packages/chat-headless/package.json b/packages/chat-headless/package.json index f4ca229..6501f2f 100644 --- a/packages/chat-headless/package.json +++ b/packages/chat-headless/package.json @@ -1,6 +1,6 @@ { "name": "@yext/chat-headless", - "version": "0.8.0", + "version": "0.9.0", "description": "A state manager library powered by Redux for Yext Chat integrations", "main": "./dist/commonjs/src/index.js", "module": "./dist/esm/src/index.mjs", diff --git a/packages/chat-headless/src/ChatHeadlessImpl.ts b/packages/chat-headless/src/ChatHeadlessImpl.ts index eb95439..5eadb2a 100644 --- a/packages/chat-headless/src/ChatHeadlessImpl.ts +++ b/packages/chat-headless/src/ChatHeadlessImpl.ts @@ -55,7 +55,7 @@ export class ChatHeadlessImpl implements ChatHeadless { */ constructor(config: HeadlessConfig, chatClient?: ChatClient) { const defaultConfig: Partial = { - saveToSessionStorage: true, + saveToLocalStorage: true, }; this.config = { ...defaultConfig, ...config }; this.chatClient = chatClient ?? provideChatCore(this.config); @@ -66,8 +66,8 @@ export class ChatHeadlessImpl implements ChatHeadless { region: this.config.region, ...this.config.analyticsConfig, }); - if (this.config.saveToSessionStorage) { - this.initSessionStorage(); + if (this.config.saveToLocalStorage) { + this.initLocalStorage(); } } @@ -100,7 +100,7 @@ export class ChatHeadlessImpl implements ChatHeadless { }; } - initSessionStorage() { + initLocalStorage() { this.setState({ ...this.state, conversation: loadSessionState(this.config.botId), diff --git a/packages/chat-headless/src/models/ChatHeadless.ts b/packages/chat-headless/src/models/ChatHeadless.ts index 0a6c2b0..443c691 100644 --- a/packages/chat-headless/src/models/ChatHeadless.ts +++ b/packages/chat-headless/src/models/ChatHeadless.ts @@ -98,17 +98,17 @@ export interface ChatHeadless { */ addClientSdk(additionalClientSdk: Record): void; /** - * Loads the {@link ConversationState} from session storage, if present, + * Loads the {@link ConversationState} from local storage, if present, * and adds a listener to keep the conversation state in sync with the stored * state * * @remarks - * This is called by default if {@link HeadlessConfig.saveToSessionStorage} is + * This is called by default if {@link HeadlessConfig.saveToLocalStorage} is * true. * * @public */ - initSessionStorage(): void; + initLocalStorage(): void; /** * Resets all fields within {@link ConversationState} * diff --git a/packages/chat-headless/src/models/HeadlessConfig.ts b/packages/chat-headless/src/models/HeadlessConfig.ts index 00aeb7c..7f3b7f7 100644 --- a/packages/chat-headless/src/models/HeadlessConfig.ts +++ b/packages/chat-headless/src/models/HeadlessConfig.ts @@ -8,8 +8,8 @@ import { ChatConfig } from "@yext/chat-core"; * @public */ export interface HeadlessConfig extends ChatConfig { - /** Whether to save the instance's {@link ConversationState} to session storage. Defaults to true. */ - saveToSessionStorage?: boolean; + /** Whether to save the instance's {@link ConversationState} to local storage. Defaults to true. */ + saveToLocalStorage?: boolean; /** Configurations for Chat analytics. */ analyticsConfig?: Omit & { /** Base payload to include for requests to the Analytics Events API. */ diff --git a/packages/chat-headless/src/slices/conversation.ts b/packages/chat-headless/src/slices/conversation.ts index 401a494..e852fb0 100644 --- a/packages/chat-headless/src/slices/conversation.ts +++ b/packages/chat-headless/src/slices/conversation.ts @@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { ConversationState } from "../models/slices/ConversationState"; import { Message, MessageNotes } from "@yext/chat-core"; -const BASE_STATE_SESSION_STORAGE_KEY = "yext_chat_state"; +const BASE_STATE_LOCAL_STORAGE_KEY = "yext_chat_state"; export const initialState: ConversationState = { messages: [], @@ -10,40 +10,64 @@ export const initialState: ConversationState = { canSendMessage: true, }; -export function getStateSessionStorageKey( +export function getStateLocalStorageKey( hostname: string, botId: string ): string { - return `${BASE_STATE_SESSION_STORAGE_KEY}__${hostname}__${botId}`; + return `${BASE_STATE_LOCAL_STORAGE_KEY}__${hostname}__${botId}`; } /** - * Loads the {@link ConversationState} from session storage. + * Loads the {@link ConversationState} from local storage. */ export const loadSessionState = (botId: string): ConversationState => { - if (!sessionStorage) { + if (!localStorage) { console.warn( - "Session storage is not available. State will not be persisted across page refreshes." + "Local storage is not available. State will not be persisted while navigating across pages." ); return initialState; } const hostname = window?.location?.hostname; if (!hostname) { console.warn( - "Unable to get hostname of current page. State will not be persisted across page refreshes." + "Unable to get hostname of current page. State will not be persisted while navigating across pages." ); return initialState; } - const savedState = sessionStorage.getItem( - getStateSessionStorageKey(hostname, botId) + const savedState = localStorage.getItem( + getStateLocalStorageKey(hostname, botId) ); - return savedState ? JSON.parse(savedState) : initialState; + + if (savedState) { + try { + const parsedState: ConversationState = JSON.parse(savedState); + if (parsedState.messages.length > 0) { + const lastTimestamp = + parsedState.messages[parsedState.messages.length - 1].timestamp; + const currentDate = new Date(); + const lastDate = new Date(lastTimestamp || 0); + const diff = currentDate.getTime() - lastDate.getTime(); + // If the last message was sent within the last day, we consider the session to be active + if (diff < 24 * 60 * 60 * 1000) { + return parsedState; + } + localStorage.removeItem(getStateLocalStorageKey(hostname, botId)); + } + } catch (e) { + console.warn( + "Unabled to load saved state: error parsing state. Starting with a fresh state." + ); + localStorage.removeItem(getStateLocalStorageKey(hostname, botId)); + } + } + + return initialState; }; export const saveSessionState = (botId: string, state: ConversationState) => { - if (!sessionStorage) { + if (!localStorage) { console.warn( - "Session storage is not available. State will not be persisted across page refreshes." + "Local storage is not available. State will not be persisted while navigating across pages." ); return initialState; } @@ -54,8 +78,8 @@ export const saveSessionState = (botId: string, state: ConversationState) => { ); return initialState; } - sessionStorage.setItem( - getStateSessionStorageKey(hostname, botId), + localStorage.setItem( + getStateLocalStorageKey(hostname, botId), JSON.stringify(state) ); }; diff --git a/packages/chat-headless/tests/chatheadless.chatapi.test.ts b/packages/chat-headless/tests/chatheadless.chatapi.test.ts index 54f7496..b73ceec 100644 --- a/packages/chat-headless/tests/chatheadless.chatapi.test.ts +++ b/packages/chat-headless/tests/chatheadless.chatapi.test.ts @@ -38,7 +38,7 @@ function mockChatCore(spy?: jest.Mock) { } beforeEach(() => { - sessionStorage.clear(); + localStorage.clear(); jest.spyOn(analyticsLib, "provideChatAnalytics").mockReturnValue({ report: jest.fn(), }); diff --git a/packages/chat-headless/tests/chatheadless.test.ts b/packages/chat-headless/tests/chatheadless.test.ts index c4344b6..bdc26aa 100644 --- a/packages/chat-headless/tests/chatheadless.test.ts +++ b/packages/chat-headless/tests/chatheadless.test.ts @@ -11,7 +11,7 @@ import { import coreLib from "@yext/chat-core"; import { ReduxStateManager } from "../src/ReduxStateManager"; import { - getStateSessionStorageKey, + getStateLocalStorageKey, initialState, } from "../src/slices/conversation"; @@ -29,7 +29,7 @@ const mockedMetaState: MetaState = { beforeEach(() => { jest.spyOn(coreLib, "provideChatCore").mockImplementation(); - sessionStorage.clear(); + localStorage.clear(); }); describe("setters work as expected", () => { @@ -277,7 +277,7 @@ describe("loadSessionState works as expected", () => { { text: "How can I help you?", source: MessageSource.BOT, - timestamp: "2023-05-15T17:39:58.019Z", + timestamp: new Date().toISOString(), }, ], notes: { @@ -286,9 +286,10 @@ describe("loadSessionState works as expected", () => { isLoading: true, canSendMessage: true, }; - it("loads valid state from session storage", () => { - sessionStorage.setItem( - getStateSessionStorageKey(jestHostname, config.botId), + + it("loads valid state from local storage", () => { + localStorage.setItem( + getStateLocalStorageKey(jestHostname, config.botId), JSON.stringify(expectedState) ); const chatHeadless = provideChatHeadless(config); @@ -298,14 +299,31 @@ describe("loadSessionState works as expected", () => { }); }); + it("handles invalid state in local storage", () => { + localStorage.setItem( + getStateLocalStorageKey(jestHostname, config.botId), + JSON.stringify({ hello: "world" }) + ); + const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + const chatHeadless = provideChatHeadless(config); + expect(chatHeadless.state).toEqual({ + conversation: initialState, + meta: {}, + }); + expect(consoleWarnSpy).toBeCalledTimes(1); + expect(consoleWarnSpy).toBeCalledWith( + "Unabled to load saved state: error parsing state. Starting with a fresh state." + ); + }); + it("does not persist or load state when toggle is off", () => { - sessionStorage.setItem( - getStateSessionStorageKey(jestHostname, config.botId), + localStorage.setItem( + getStateLocalStorageKey(jestHostname, config.botId), JSON.stringify(expectedState) ); const chatHeadless = provideChatHeadless({ ...config, - saveToSessionStorage: false, + saveToLocalStorage: false, }); expect(chatHeadless.state).toEqual({ conversation: initialState, @@ -321,9 +339,34 @@ describe("loadSessionState works as expected", () => { ]; chatHeadless.setMessages(modifiedMessages); expect( - sessionStorage.getItem( - getStateSessionStorageKey(jestHostname, config.botId) - ) + localStorage.getItem(getStateLocalStorageKey(jestHostname, config.botId)) ).toEqual(JSON.stringify(expectedState)); }); + + const oldState: ConversationState = { + conversationId: "dummy-id", + messages: [ + { + text: "How can I help you?", + source: MessageSource.BOT, + timestamp: "2023-05-15T17:39:58.019Z", + }, + ], + notes: { + currentGoal: "GOAL", + }, + isLoading: true, + canSendMessage: true, + }; + + it("ignores and removes state when older than 24 hours", () => { + const oldStateKey = getStateLocalStorageKey(jestHostname, config.botId); + localStorage.setItem(oldStateKey, JSON.stringify(oldState)); + const chatHeadless = provideChatHeadless(config); + expect(chatHeadless.state).toEqual({ + conversation: initialState, + meta: {}, + }); + expect(localStorage.getItem(oldStateKey)).toBeNull(); + }); });