diff --git a/packages/extension-chakra-store-locator/src/hooks/use-extension-store.ts b/packages/extension-chakra-store-locator/src/hooks/use-extension-store.ts new file mode 100644 index 0000000000..44ddc18d83 --- /dev/null +++ b/packages/extension-chakra-store-locator/src/hooks/use-extension-store.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {useApplicationExtensionsStore} from '@salesforce/pwa-kit-extension-sdk/react' +import extensionMeta from '../../extension-meta.json' + +/** + * This hook returns the store for the current application extension. + */ +export const useExtensionStore = (defaultValue: any = {}) => { + const state = useApplicationExtensionsStore((state: Record) => state.state[extensionMeta.id] || defaultValue) + + if (state === undefined) { + throw new Error( + `'useExtensionStore' could not find your current application extension state! Make sure you have added the store to your extension.` + ) + } + + return state +} \ No newline at end of file diff --git a/packages/extension-chakra-store-locator/src/pages/store-locator/index.tsx b/packages/extension-chakra-store-locator/src/pages/store-locator/index.tsx index 8c509374b2..090e2d3ce4 100644 --- a/packages/extension-chakra-store-locator/src/pages/store-locator/index.tsx +++ b/packages/extension-chakra-store-locator/src/pages/store-locator/index.tsx @@ -7,11 +7,22 @@ import React from 'react' import {Box, Container} from '@chakra-ui/react' + import {StoreLocatorContent} from '../../components/content' +import {useExtensionStore} from '../../hooks/use-extension-store' const StoreLocatorPage = () => { + const {incrementCounter, decrementCounter, counter} = useExtensionStore() + return ( + Count: {counter}
+ + { static readonly id = extensionMeta.id @@ -37,11 +38,28 @@ class StoreLocatorExtension extends ApplicationExtension { ) } + const withApplicationExtensionStoreOptions = { + id: extensionMeta.id, + sliceInitializer: (set: any, get: any) => ({ + // TODO: Kevin, this is where you are going to place your initial state and actions. E.g. "modalOpen: false" etc. + counter: 0, + incrementCounter: () => + set((state: any) => ({ + counter: state.counter + 1 + })), + decrementCounter: () => + set((state: any) => ({ + counter: state.counter - 1 + })) + }) + } + const HOCs = [ (component: React.ComponentType) => withStoreLocator(component, config), (component: React.ComponentType) => withOptionalCommerceSdkReactProvider(component, config), - (component: React.ComponentType) => withOptionalChakra(component) + (component: React.ComponentType) => withOptionalChakra(component), + (component: React.ComponentType) => withApplicationExtensionStore(component, withApplicationExtensionStoreOptions) ] return applyHOCs(App, HOCs) diff --git a/packages/extension-starter/README.md b/packages/extension-starter/README.md index 1726cb5974..ddb1884d32 100644 --- a/packages/extension-starter/README.md +++ b/packages/extension-starter/README.md @@ -62,6 +62,39 @@ the extension as they like. > Congratulations! The Sample extension was successfully installed! Please visit https://www.npmjs.com/package/@salesforce/extension-starter for more information on how to use this extension. ``` +# State Management + +By default all extensions are enhanced with state management using the `withApplicationExtensionStore` higher-order component. Under the hood +the state is provided using [Zustand](https://www.npmjs.com/package/zustand) as a global store for the entire PWA-Kit application. +Each Application Extension inserts a "slice" into this global store following the +[slicing pattern](https://github.com/pmndrs/zustand/blob/37e1e3f193a5e5dec6fbd0f07514aec59a187e01/docs/guides/slices-pattern.md). +This allows you to have data separation from one extension to the other when it's important, but also allows you to access state its +associated actions of other extensions when needed. + +An examples of why you might want to access state and action from another extension would be opening a store-locator map modal provided via +the store-locator extension from other pages like the storefronts toolbar or the base project. + +This is how you would do something like this. + +``` +// /base-project/app/components/my-component.jsx +import {useExtensionsStore} from '@salesforce/pwa-kit-extension-sdk/react' + +export MyComponent = () => { + // Grab the slice of the extension state for "extension-a" + const {toggleMapsModal} = useExtensionsStore( + (state) => + state.state['@salesforce/extension-store-locator'] || {} + ) + + return ( +
+
+ ) +} +``` + # Advanced Usage In order to customize this Application Extension to your particular needs we suggest that you refer to the section titled diff --git a/packages/extension-starter/src/hooks/use-extension-store.ts b/packages/extension-starter/src/hooks/use-extension-store.ts new file mode 100644 index 0000000000..9b71c2272d --- /dev/null +++ b/packages/extension-starter/src/hooks/use-extension-store.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {useApplicationExtensionsStore} from '@salesforce/pwa-kit-extension-sdk/react' +import extensionMeta from '../../extension-meta.json' + +/** + * This hook returns the store for the current application extension. + */ +export const useExtensionStore = () => { + const state = useApplicationExtensionsStore((state: Record) => state.state[extensionMeta.id]) + + if (state === undefined) { + throw new Error( + `'useExtensionStore' could not find your current application extension state! Make sure you have added the store to your extension.` + ) + } + + return state +} \ No newline at end of file diff --git a/packages/extension-starter/src/setup-app.ts b/packages/extension-starter/src/setup-app.ts index 31fadd111b..69815b839c 100644 --- a/packages/extension-starter/src/setup-app.ts +++ b/packages/extension-starter/src/setup-app.ts @@ -10,7 +10,8 @@ import React from 'react' import {RouteProps} from 'react-router-dom' // Platform Imports -import {ApplicationExtension} from '@salesforce/pwa-kit-extension-sdk/react' +import {ApplicationExtension, withApplicationExtensionStore} from '@salesforce/pwa-kit-extension-sdk/react' +import {applyHOCs} from '@salesforce/pwa-kit-extension-sdk/react/utils' // Local Imports import {Config} from './types' @@ -36,7 +37,31 @@ class Sample extends ApplicationExtension { extendApp>( App: React.ComponentType ): React.ComponentType { - return sampleHOC(App) + + const HOCs = [ + sampleHOC, + // NOTE: Add state management to the application extension. If your extension does not use state management, you can remove + // the use of `withApplicationExtensionStore`. + // Please refer to the "state management" section of the readme for more information on how to use this feature. + (component: React.ComponentType) => + withApplicationExtensionStore( + component, + { + id: extensionMeta.id, + sliceInitializer: (set: any, get: any) => ({ + sampleStateValue: 0, + sampleAction: () => + { + set((state: any) => ({ + counter: state.sampleStateValue + 1 + })) + } + }) + } + ) + ] + + return applyHOCs(App, HOCs) } /** diff --git a/packages/pwa-kit-extension-sdk/package-lock.json b/packages/pwa-kit-extension-sdk/package-lock.json index 4f9808a3f3..bf76d6798e 100644 --- a/packages/pwa-kit-extension-sdk/package-lock.json +++ b/packages/pwa-kit-extension-sdk/package-lock.json @@ -15,7 +15,8 @@ "handlebars": "^4.7.8", "hoist-non-react-statics": "^3.3.2", "lodash.merge": "^4.6.2", - "resolve": "^1.22.8" + "resolve": "^1.22.8", + "zustand": "5.0.3" }, "devDependencies": { "@babel/core": "^7.21.3", @@ -2048,7 +2049,7 @@ "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", - "dev": true + "devOptional": true }, "node_modules/@types/qs": { "version": "6.9.17", @@ -2066,7 +2067,7 @@ "version": "18.3.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2916,7 +2917,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/debug": { "version": "4.4.0", @@ -4310,7 +4311,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "devOptional": true }, "node_modules/jsesc": { "version": "3.1.0", @@ -4389,7 +4390,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, + "devOptional": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -4829,7 +4830,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, + "devOptional": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5972,6 +5973,34 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true + }, + "node_modules/zustand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", + "integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/packages/pwa-kit-extension-sdk/package.json b/packages/pwa-kit-extension-sdk/package.json index ff16085c6f..eb9351f231 100644 --- a/packages/pwa-kit-extension-sdk/package.json +++ b/packages/pwa-kit-extension-sdk/package.json @@ -43,7 +43,8 @@ "handlebars": "^4.7.8", "hoist-non-react-statics": "^3.3.2", "lodash.merge": "^4.6.2", - "resolve": "^1.22.8" + "resolve": "^1.22.8", + "zustand": "5.0.3" }, "devDependencies": { "@babel/core": "^7.21.3", diff --git a/packages/pwa-kit-extension-sdk/src/react/classes/ApplicationExtension.ts b/packages/pwa-kit-extension-sdk/src/react/classes/ApplicationExtension.ts index 4e85290ffa..53360b1ce7 100644 --- a/packages/pwa-kit-extension-sdk/src/react/classes/ApplicationExtension.ts +++ b/packages/pwa-kit-extension-sdk/src/react/classes/ApplicationExtension.ts @@ -65,4 +65,8 @@ export class ApplicationExtension< public beforeRouteMatch(routes: RouteProps[]): RouteProps[] { return routes } + + public getSliceInitializer(): any { + return () => ({}) + } } diff --git a/packages/pwa-kit-extension-sdk/src/react/components/index.ts b/packages/pwa-kit-extension-sdk/src/react/components/index.ts index 67c056d1d0..93a82bba23 100644 --- a/packages/pwa-kit-extension-sdk/src/react/components/index.ts +++ b/packages/pwa-kit-extension-sdk/src/react/components/index.ts @@ -1,8 +1,9 @@ /* - * Copyright (c) 2024, salesforce.com, inc. + * Copyright (c) 2025, salesforce.com, inc. * All rights reserved. * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ export {default as withApplicationExtensions} from './withApplicationExtensions' +export {default as withApplicationExtensionStore} from './withApplicationExtensionStore' diff --git a/packages/pwa-kit-extension-sdk/src/react/components/withApplicationExtensionStore.tsx b/packages/pwa-kit-extension-sdk/src/react/components/withApplicationExtensionStore.tsx new file mode 100644 index 0000000000..4f804c278e --- /dev/null +++ b/packages/pwa-kit-extension-sdk/src/react/components/withApplicationExtensionStore.tsx @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +// Third-Party +import React from 'react' + +// Local +import {useApplicationExtensionsStore} from '../hooks/useApplicationExtensionsStore' + +// Local Types +import {SliceInitializer} from '../hooks/useApplicationExtensionsStore' + +// Local Types +type withStoreOptions = { + id: string + sliceInitializer: SliceInitializer +} + +/** + * This HOC is used to add a slice to the global store for an extension. + * + * @param WrappedComponent + * @param options + * @returns + */ +const withApplicationExtensionStore = ( + WrappedComponent: React.ComponentType, + options: withStoreOptions +) => { + const {id, sliceInitializer} = options + + // Because there extensions have unique slice names, we can safely add them to the global store. + useApplicationExtensionsStore.getState().addSlice(id, sliceInitializer) + + // Return the original component as we aren't modifying it in any way... yet. + return WrappedComponent +} + +export default withApplicationExtensionStore diff --git a/packages/pwa-kit-extension-sdk/src/react/components/withApplicationExtensions.tsx b/packages/pwa-kit-extension-sdk/src/react/components/withApplicationExtensions.tsx index a063c2ff36..83da59a7fd 100644 --- a/packages/pwa-kit-extension-sdk/src/react/components/withApplicationExtensions.tsx +++ b/packages/pwa-kit-extension-sdk/src/react/components/withApplicationExtensions.tsx @@ -45,12 +45,16 @@ const withApplicationExtensions = ( WrappedComponent: React.ComponentType, options: withApplicationExtensionsOptions ) => { - const hocs: GenericHocType[] = options.applicationExtensions + const {applicationExtensions} = options + + // Get all application extension higher-order components (HOCs) + const hocs: GenericHocType[] = applicationExtensions .filter((applicationExtension) => applicationExtension.isEnabled()) // It's counterintuitive: reversing the list is necessary, as we build the React tree from innermost .reverse() .map((extension) => extension.extendApp.bind(extension) as GenericHocType) .filter(Boolean) + const withApplicationExtensionsProvider: GenericHocType = (WrappedComponent) => { const WithApplicationExtensionsProvider = (props: any) => ( diff --git a/packages/pwa-kit-extension-sdk/src/react/hooks/index.ts b/packages/pwa-kit-extension-sdk/src/react/hooks/index.ts index d8d6a0eda4..347cb47412 100644 --- a/packages/pwa-kit-extension-sdk/src/react/hooks/index.ts +++ b/packages/pwa-kit-extension-sdk/src/react/hooks/index.ts @@ -6,3 +6,4 @@ */ export * from './useApplicationExtensions' +export * from './useApplicationExtensionsStore' diff --git a/packages/pwa-kit-extension-sdk/src/react/hooks/useApplicationExtensionsStore.tsx b/packages/pwa-kit-extension-sdk/src/react/hooks/useApplicationExtensionsStore.tsx new file mode 100644 index 0000000000..6ab83a58de --- /dev/null +++ b/packages/pwa-kit-extension-sdk/src/react/hooks/useApplicationExtensionsStore.tsx @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {create} from 'zustand' +import {devtools} from 'zustand/middleware' + +export type SliceInitializer = (set: (partial: any) => void, get: () => BaseStore) => T + +interface BaseStore { + state: Record + addSlice: (sliceName: string, sliceInitializer: SliceInitializer) => void + getSlice: (sliceName: string) => T | undefined +} + +export const useApplicationExtensionsStore = create()( + devtools((set, get) => ({ + state: {}, + + // Dynamically add a slice + addSlice: (sliceName: string, sliceInitializer: SliceInitializer) => { + set((state) => ({ + state: { + ...state.state, + [sliceName]: sliceInitializer( + // Narrowed version of set. Which allows setting state of the current slice only. + (action: any) => { + set((state: any) => ({ + state: { + ...state.state, + [sliceName]: { + ...state.state[sliceName], + ...action(state.state[sliceName]) + } + } + })) + }, + // Narrowed version of get. Which returns state of the current slice. + () => get().state[sliceName] + ) + } + })) + }, + + // Retrieve a slice + getSlice: (sliceName: string): T | undefined => { + return get().state[sliceName] + } + })) +) diff --git a/packages/template-typescript-minimal/app/pages/home.tsx b/packages/template-typescript-minimal/app/pages/home.tsx index c14110a188..c9e3fd3d38 100644 --- a/packages/template-typescript-minimal/app/pages/home.tsx +++ b/packages/template-typescript-minimal/app/pages/home.tsx @@ -5,9 +5,13 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import React, {useEffect, useState} from 'react' +import {Link} from 'react-router-dom' import {useQuery} from '@tanstack/react-query' -import {useApplicationExtensions} from '@salesforce/pwa-kit-extension-sdk/react' +import { + useApplicationExtensions, + useApplicationExtensionsStore +} from '@salesforce/pwa-kit-extension-sdk/react' import HelloTS from '../components/hello-typescript' import HelloJS from '../components/hello-javascript' @@ -85,6 +89,14 @@ h1 { const Home = ({value}: Props) => { const [counter, setCounter] = useState(0) const applicationExtensions = useApplicationExtensions() + const { + counter: myCounter, + incrementCounter, + decrementCounter + } = useApplicationExtensionsStore( + (state: Record) => + state.state['@salesforce/extension-chakra-store-locator'] || {} + ) useEffect(() => { const interval = setInterval(() => { @@ -114,6 +126,10 @@ const Home = ({value}: Props) => {
Support! + Counter: {myCounter} + + + /store-locator