diff --git a/LICENSE b/LICENSE index b85203b..a5184aa 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ -Contains information from the language-subtag-registry JSON Database (https://github.com/mattcg/language-subtag-registry/tree/master/data/json) which is made available under the ODC Attribution License (https://github.com/mattcg/language-subtag-registry/blob/master/LICENSE.md). +Contains information from the language-subtag-registry JSON Database (https://github.com/mattcg/language-subtag-registry/tree/master/data/json) +which is made available under the ODC Attribution License (https://github.com/mattcg/language-subtag-registry/blob/master/LICENSE.md). The files listed in this repository are licensed under the below license. All other features and products are subject to separate agreements and certain functionality requires paid subscriptions to Yext products. diff --git a/package-lock.json b/package-lock.json index fc4e08a..8c751f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18472,7 +18472,7 @@ }, "packages/chat-headless": { "name": "@yext/chat-headless", - "version": "0.5.4", + "version": "0.5.5", "license": "BSD-3-Clause", "dependencies": { "@reduxjs/toolkit": "^1.9.5", diff --git a/packages/chat-headless-react/jest.config.json b/packages/chat-headless-react/jest.config.json index f3dc0eb..0dca6fe 100644 --- a/packages/chat-headless-react/jest.config.json +++ b/packages/chat-headless-react/jest.config.json @@ -1,14 +1,15 @@ { "bail": 0, "collectCoverage": true, - "collectCoverageFrom": ["src/**", "!src/models/**/*.ts"], + "collectCoverageFrom": ["src/**"], "verbose": true, "moduleFileExtensions": ["js", "ts", "tsx"], "moduleDirectories": ["node_modules", ""], "testEnvironment": "jsdom", - "testPathIgnorePatterns": ["./tests/mocks/*"], + "testPathIgnorePatterns": ["./tests/mocks/*", "./tests/jest-setup.js"], "resetMocks": true, "restoreMocks": true, "clearMocks": true, - "testMatch": ["/tests/**/*.(test).ts(x)?"] + "testMatch": ["/tests/**/*.[jt]s?(x)"], + "setupFiles": ["/tests/jest-setup.js"] } diff --git a/packages/chat-headless-react/src/ChatHeadlessProvider.tsx b/packages/chat-headless-react/src/ChatHeadlessProvider.tsx index eaa6d5b..c0bfefb 100644 --- a/packages/chat-headless-react/src/ChatHeadlessProvider.tsx +++ b/packages/chat-headless-react/src/ChatHeadlessProvider.tsx @@ -1,5 +1,5 @@ import { ChatHeadless, HeadlessConfig } from "@yext/chat-headless"; -import { PropsWithChildren, useMemo } from "react"; +import { PropsWithChildren, useMemo, useEffect, useState } from "react"; import { Provider } from "react-redux"; import { ChatHeadlessContext } from "./ChatHeadlessContext"; import { updateClientSdk } from "./utils/clientSdk"; @@ -25,14 +25,31 @@ export function ChatHeadlessProvider( props: ChatHeadlessProviderProps ): JSX.Element { const { children, config } = props; - const headless = useMemo( - () => new ChatHeadless(updateClientSdk(config)), - [config] - ); + // deferLoad is used with sessionStorage so that the children won't be + // immediately rendered and trigger the "load initial message" flow before + // the state can be loaded from session. + const [deferLoad, setDeferLoad] = useState(config.saveToSessionStorage); + + const headless = useMemo(() => { + const configWithoutSession = { ...config, saveToSessionStorage: false }; + const headless = new ChatHeadless(updateClientSdk(configWithoutSession)); + return headless; + }, [config]); + + // sessionStorage is overridden here so that it is compatible with server- + // side rendering, which cannot have browser api calls like session storage + // outside of hooks. + useEffect(() => { + if (!config.saveToSessionStorage || !headless) { + return; + } + headless.initSessionStorage(); + setDeferLoad(false); + }, [headless, config]); return ( - {children} + {deferLoad || {children}} ); } diff --git a/packages/chat-headless-react/tests/headlessProvider.test.tsx b/packages/chat-headless-react/tests/headlessProvider.test.tsx new file mode 100644 index 0000000..09e1947 --- /dev/null +++ b/packages/chat-headless-react/tests/headlessProvider.test.tsx @@ -0,0 +1,62 @@ +import { render } from "@testing-library/react"; +import { + ChatHeadlessProvider, + ConversationState, + HeadlessConfig, +} from "../src"; +import { renderToString } from "react-dom/server"; +import { useChatState } from "../src/useChatState"; + +it("only fetches session storage on client-side render", async () => { + const win = window; + Object.defineProperty(win, "sessionStorage", { + value: { + ...win.sessionStorage, + getItem: (_: string): string => { + return JSON.stringify({ + messages: [{ text: "foobar", source: "BOT" }], + isLoading: false, + canSendMessage: false, + } satisfies ConversationState); + }, + }, + }); + const windowSpy = jest + .spyOn(window, "window", "get") + .mockImplementation(() => win); + const config: HeadlessConfig = { + botId: "123", + apiKey: "1234", + saveToSessionStorage: true, + }; + const str = () => + renderToString( + + + + ); + const container = document.body.appendChild(document.createElement("div")); + container.innerHTML = str(); + expect(windowSpy).not.toHaveBeenCalled(); + expect(str()).not.toContain("foobar"); + + const view = render( + + + , + { container, hydrate: true } + ); + expect(await view.findByText("foobar")).toBeTruthy(); + expect(windowSpy).toHaveBeenCalled(); +}); + +const TestComponent = () => { + const messages = useChatState((state) => state.conversation.messages); + return ( +
+ {messages.map((msg, i) => ( + {msg.text} + ))} +
+ ); +}; diff --git a/packages/chat-headless-react/tests/jest-setup.js b/packages/chat-headless-react/tests/jest-setup.js new file mode 100644 index 0000000..575b7a7 --- /dev/null +++ b/packages/chat-headless-react/tests/jest-setup.js @@ -0,0 +1,8 @@ +import { TextDecoder, TextEncoder } from "util"; + +/** + * jest's jsdom doesn't have the following properties defined in global for the DOM. + * polyfill it with functions from NodeJS. This is to used in Chat Core. + */ +global.TextDecoder = TextDecoder; +global.TextEncoder = TextEncoder; diff --git a/packages/chat-headless/docs/chat-headless.chatheadless.initsessionstorage.md b/packages/chat-headless/docs/chat-headless.chatheadless.initsessionstorage.md new file mode 100644 index 0000000..653b536 --- /dev/null +++ b/packages/chat-headless/docs/chat-headless.chatheadless.initsessionstorage.md @@ -0,0 +1,21 @@ + + +[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 8b013a7..1f960af 100644 --- a/packages/chat-headless/docs/chat-headless.chatheadless.md +++ b/packages/chat-headless/docs/chat-headless.chatheadless.md @@ -31,6 +31,7 @@ export declare class 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 | | [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) | | [setChatLoadingStatus(isLoading)](./chat-headless.chatheadless.setchatloadingstatus.md) | | Sets [ConversationState.isLoading](./chat-headless.conversationstate.isloading.md) to the specified loading state | diff --git a/packages/chat-headless/etc/chat-headless.api.md b/packages/chat-headless/etc/chat-headless.api.md index 5cd97ee..bd6e4e6 100644 --- a/packages/chat-headless/etc/chat-headless.api.md +++ b/packages/chat-headless/etc/chat-headless.api.md @@ -37,6 +37,7 @@ export class ChatHeadless { addListener(listener: StateListener): Unsubscribe; addMessage(message: Message): void; getNextMessage(text?: string, source?: MessageSource): Promise; + initSessionStorage(): void; report(eventPayload: Omit & DeepPartial>): Promise; restartConversation(): void; setChatLoadingStatus(isLoading: boolean): void; diff --git a/packages/chat-headless/package.json b/packages/chat-headless/package.json index 2a47650..c730aee 100644 --- a/packages/chat-headless/package.json +++ b/packages/chat-headless/package.json @@ -1,6 +1,6 @@ { "name": "@yext/chat-headless", - "version": "0.5.4", + "version": "0.5.5", "description": "A library for powering UI components for Yext Chat integrations", "main": "./dist/commonjs/src/index.js", "module": "./dist/esm/src/index.js", diff --git a/packages/chat-headless/src/ChatHeadless.ts b/packages/chat-headless/src/ChatHeadless.ts index 5ec73e7..5793efd 100644 --- a/packages/chat-headless/src/ChatHeadless.ts +++ b/packages/chat-headless/src/ChatHeadless.ts @@ -64,18 +64,7 @@ export class ChatHeadless { ...this.config.analyticsConfig, }); if (this.config.saveToSessionStorage) { - this.setState({ - ...this.state, - conversation: loadSessionState(), - }); - this.addListener({ - valueAccessor: (s) => s.conversation, - callback: () => - sessionStorage.setItem( - STATE_SESSION_STORAGE_KEY, - JSON.stringify(this.state.conversation) - ), - }); + this.initSessionStorage(); } } @@ -136,6 +125,32 @@ export class ChatHeadless { }; } + /** + * Loads the {@link ConversationState} from session 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 + * true. + * + * @public + */ + initSessionStorage() { + this.setState({ + ...this.state, + conversation: loadSessionState(), + }); + this.addListener({ + valueAccessor: (s) => s.conversation, + callback: () => + sessionStorage.setItem( + STATE_SESSION_STORAGE_KEY, + JSON.stringify(this.state.conversation) + ), + }); + } + /** * Send Chat related analytics event to Yext Analytics API. *