UNPKG

@ninetailed/experience.js-react

Version:
757 lines (731 loc) 23.6 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var jsxRuntime = require('react/jsx-runtime'); var React = require('react'); var experience_js = require('@ninetailed/experience.js'); var experience_jsShared = require('@ninetailed/experience.js-shared'); var radash = require('radash'); var reactIs = require('react-is'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var React__default = /*#__PURE__*/_interopDefaultLegacy(React); const NinetailedContext = /*#__PURE__*/React.createContext(undefined); const NinetailedProvider = props => { const ninetailed = React.useMemo(() => { if ('ninetailed' in props) { return props.ninetailed; } const { clientId, environment, preview, url, locale, requestTimeout, plugins = [], onLog, onError, buildClientContext, onInitProfileId, componentViewTrackingThreshold, storageImpl, useSDKEvaluation } = props; return new experience_js.Ninetailed({ clientId, environment, preview }, { url, plugins, locale, requestTimeout, onLog, onError, buildClientContext, onInitProfileId, componentViewTrackingThreshold, storageImpl, useSDKEvaluation }); }, []); const { children } = props; return jsxRuntime.jsx(NinetailedContext.Provider, Object.assign({ value: ninetailed }, { children: children })); }; const useNinetailed = () => { const ninetailed = React.useContext(NinetailedContext); if (ninetailed === undefined) { throw new Error('The component using the the context must be a descendant of the NinetailedProvider'); } return ninetailed; }; /****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ function __rest(s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; } function formatProfileForHook(profile) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const profileStateWithoutExperiences = __rest(profile, ["experiences"]); return Object.assign(Object.assign({}, profileStateWithoutExperiences), { loading: profile.status === 'loading' }); } /** * Custom hook that provides access to the Ninetailed profile state * with the 'experiences' property removed to prevent unnecessary re-renders. * * This hook handles profile state changes efficiently by: * 1. Only updating state when actual changes occur * 2. Removing the large 'experiences' object from the state * 3. Properly cleaning up subscriptions when components unmount * * @returns The profile state without the 'experiences' property */ const useProfile = () => { const ninetailed = useNinetailed(); const [strippedProfileState, setStrippedProfileState] = React.useState(formatProfileForHook(ninetailed.profileState)); // Reference to track the previous profile state for comparison const profileStateRef = React.useRef(ninetailed.profileState); React.useEffect(() => { const unsubscribe = ninetailed.onProfileChange(changedProfileState => { // Skip update if the profile hasn't actually changed // Here we compare the entire profile including experiences and changes if (radash.isEqual(changedProfileState, profileStateRef.current)) { experience_jsShared.logger.debug('Profile State Did Not Change', changedProfileState); return; } profileStateRef.current = changedProfileState; experience_jsShared.logger.debug('Profile State Changed', changedProfileState); setStrippedProfileState(formatProfileForHook(changedProfileState)); }); // Clean up subscription when component unmounts return () => { if (typeof unsubscribe === 'function') { unsubscribe(); experience_jsShared.logger.debug('Unsubscribed from profile state changes'); } }; }, []); return strippedProfileState; }; const usePersonalize = (baseline, variants, options = { holdout: -1 }) => { const profile = useProfile(); return experience_js.selectVariant(baseline, variants, profile, options); }; /** * Hook to access a Ninetailed variable flag with manual tracking control. */ function useFlagWithManualTracking(flagKey, defaultValue) { const ninetailed = useNinetailed(); const flagKeyRef = React.useRef(flagKey); const defaultValueRef = React.useRef(defaultValue); const lastProcessedState = React.useRef(null); const changeRef = React.useRef(null); const [result, setResult] = React.useState({ value: defaultValue, status: 'loading', error: null }); // Reset if inputs change React.useEffect(() => { if (!radash.isEqual(defaultValueRef.current, defaultValue) || flagKeyRef.current !== flagKey) { defaultValueRef.current = defaultValue; flagKeyRef.current = flagKey; lastProcessedState.current = null; changeRef.current = null; setResult({ value: defaultValue, status: 'loading', error: null }); } }, [flagKey, defaultValue]); // Listen for personalization state changes React.useEffect(() => { const unsubscribe = ninetailed.onChangesChange(changesState => { if (lastProcessedState.current && radash.isEqual(lastProcessedState.current, changesState)) { return; } lastProcessedState.current = changesState; if (changesState.status === 'loading') { setResult({ value: defaultValueRef.current, status: 'loading', error: null }); return; } if (changesState.status === 'error') { setResult({ value: defaultValueRef.current, status: 'error', error: changesState.error }); return; } // Find relevant change for this flag const change = changesState.changes.find(c => c.key === flagKeyRef.current && c.type === experience_jsShared.ChangeTypes.Variable); if (change) { changeRef.current = change; const rawValue = change.value; // Unwrap { value: ... } structure if present const actualValue = rawValue && typeof rawValue === 'object' && rawValue !== null && 'value' in rawValue ? rawValue['value'] : rawValue; setResult({ value: actualValue, status: 'success', error: null }); } else { changeRef.current = null; setResult({ value: defaultValueRef.current, status: 'success', error: null }); } }); return unsubscribe; }, [ninetailed]); // Manual tracking function const track = React.useCallback(() => { const change = changeRef.current; if (!change) return; ninetailed.trackVariableComponentView({ variable: change.value, variant: { id: `Variable-${flagKeyRef.current}` }, componentType: 'Variable', variantIndex: change.meta.variantIndex, experienceId: change.meta.experienceId }); }, [ninetailed]); return [result, track]; } /** * Hook to access a Ninetailed variable flag with built-in auto-tracking. * * @remarks * For manual control over tracking behavior, consider using {@link useFlagWithManualTracking}. */ function useFlag(flagKey, defaultValue, options = {}) { const [result, track] = useFlagWithManualTracking(flagKey, defaultValue); React.useEffect(() => { const shouldAutoTrack = typeof options.shouldAutoTrack === 'function' ? options.shouldAutoTrack() : options.shouldAutoTrack !== false; if (result.status === 'success' && shouldAutoTrack) { track(); } }, [result.status, track, options.shouldAutoTrack]); return result; } const TrackHasSeenComponent = ({ children, variant, audience, isPersonalized }) => { const ninetailed = useNinetailed(); const [inView, setInView] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { setInView(true); // Disconnect the observer since we only want to track // the first time the component is seen observer.disconnect(); } }); if (ref.current) { observer.observe(ref.current); } return () => { observer.disconnect(); }; }, []); React.useEffect(() => { if (experience_jsShared.isBrowser() && inView) { ninetailed.trackHasSeenComponent({ variant, audience, isPersonalized }); } }, [inView]); return jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { ref: ref }), children] }); }; const Personalize = _a => { var { component: Component, loadingComponent: LoadingComponent, variants = [], holdout = -1 } = _a, baseline = __rest(_a, ["component", "loadingComponent", "variants", "holdout"]); const { loading, variant, isPersonalized, audience } = usePersonalize(baseline, variants, { holdout }); const hasVariants = variants.length > 0; if (!hasVariants) { return jsxRuntime.jsx(Component, Object.assign({}, baseline, { ninetailed: { isPersonalized, audience } })); } if (loading) { if (LoadingComponent) { return jsxRuntime.jsx(LoadingComponent, {}); } return jsxRuntime.jsx("div", Object.assign({ style: { opacity: 0 } }, { children: jsxRuntime.jsx(Component, Object.assign({}, variant, { ninetailed: { isPersonalized, audience } })) }), "hide"); } return jsxRuntime.jsx(TrackHasSeenComponent, Object.assign({ variant: variant, audience: audience, isPersonalized: isPersonalized }, { children: /*#__PURE__*/React.createElement(Component, Object.assign({}, variant, { key: `${audience.id}-${variant.id}`, ninetailed: { isPersonalized, audience } })) })); }; const generateSelectors = id => { return id.split('_').map((path, index, paths) => { const dotPath = paths.slice(0, index).join('.'); const underScorePath = paths.slice(index).join('_'); return [dotPath, underScorePath].filter(path => path !== '').join('.'); }); }; const selectValueFromProfile = (profile, id) => { const selectors = generateSelectors(id); const matchingSelector = selectors.find(selector => radash.get(profile, selector)); if (!matchingSelector) { return null; } return radash.get(profile, matchingSelector); }; const MergeTag = ({ id, fallback }) => { const { profile, loading } = useProfile(); if (loading || !profile) { return null; } const value = selectValueFromProfile(profile, id) || fallback; return value ? jsxRuntime.jsx(jsxRuntime.Fragment, { children: value }) : null; }; const useExperience = ({ baseline, experiences }) => { const ninetailed = useNinetailed(); const hasVariants = experiences.map(experience => experience_js.selectHasExperienceVariants(experience, baseline)).reduce((acc, curr) => acc || curr, false); const [experience, setExperience] = React.useState({ hasVariants, baseline, error: null, loading: true, status: 'loading', experience: null, variant: baseline, variantIndex: 0, audience: null, isPersonalized: false, profile: null }); React.useEffect(() => { return ninetailed.onSelectVariant({ baseline, experiences }, state => { setExperience(state); }); }, [experience_jsShared.circularJsonStringify(baseline), experience_jsShared.circularJsonStringify(experiences)]); return experience; }; const ComponentMarker = /*#__PURE__*/React.forwardRef((_, ref) => { const { logger } = useNinetailed(); const markerRef = React.useRef(null); React.useEffect(() => { var _a; /* Due to React's limitation on setting !important styles during rendering, we set the display property on the DOM element directly. See: https://github.com/facebook/react/issues/1881 */ (_a = markerRef.current) === null || _a === void 0 ? void 0 : _a.style.setProperty('display', 'none', 'important'); }, []); React.useEffect(() => { logger.debug('Using fallback mechanism to detect when experiences are seen. This can lead to inaccurate results. Consider using a forwardRef instead. See: https://docs.ninetailed.io/for-developers/experience-sdk/rendering-experiences#tracking-impressions-of-experiences'); }, [logger]); React.useEffect(() => { if (markerRef.current) { const observableElement = getObservableElement(markerRef.current); if (ref) { if (typeof ref === 'function') { ref(observableElement); } else { ref.current = observableElement; } } } }, []); return jsxRuntime.jsx("div", { className: "nt-cmp-marker", style: { display: 'none' }, ref: markerRef }); }); const getObservableSibling = element => { const nextSibling = element.nextElementSibling; if (!nextSibling) { return null; } const nextSiblingStyle = getComputedStyle(nextSibling); // Elements with display: none are not observable by the IntersectionObserver if (nextSiblingStyle.display !== 'none') { return nextSibling; } return getObservableSibling(nextSibling); }; const getObservableElement = element => { const observableSibling = getObservableSibling(element); if (observableSibling) { return observableSibling; } const { parentElement } = element; if (!parentElement) { return null; } return getObservableElement(parentElement); }; const DefaultExperienceLoadingComponent = _a => { var { component: Component, unhideAfterMs = 5000, passthroughProps } = _a, baseline = __rest(_a, ["component", "unhideAfterMs", "passthroughProps"]); const { logger } = useNinetailed(); const [hidden, setHidden] = React.useState(true); React.useEffect(() => { const timer = setTimeout(() => { setHidden(false); logger.error(new Error(`The experience was still in loading state after ${unhideAfterMs}ms. That happens when no events are sent to the Ninetailed API. The baseline is now shown instead.`)); }, unhideAfterMs); return () => { clearTimeout(timer); }; }, []); if (hidden) { return jsxRuntime.jsx("div", Object.assign({ style: { opacity: 0 } }, { children: jsxRuntime.jsx(Component, Object.assign({}, passthroughProps, baseline, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })) }), "hide"); } return jsxRuntime.jsx(Component, Object.assign({}, passthroughProps, baseline, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })); }; const Experience = _a => { var { experiences, component: Component, loadingComponent: LoadingComponent = DefaultExperienceLoadingComponent, passthroughProps } = _a, baseline = __rest(_a, ["experiences", "component", "loadingComponent", "passthroughProps"]); const { observeElement, unobserveElement, logger } = useNinetailed(); const { status, hasVariants, experience, variant, variantIndex, audience, isPersonalized } = useExperience({ baseline, experiences }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const isComponentForwardRef = reactIs.isForwardRef(jsxRuntime.jsx(Component, {})); const componentRef = React.useRef(null); const isVariantHidden = 'hidden' in variant && variant.hidden; React.useEffect(() => { const componentElement = componentRef.current; if (componentElement && !(componentElement instanceof Element)) { const isObject = typeof componentElement === 'object' && componentElement !== null; const constructorName = isObject ? // eslint-disable-next-line @typescript-eslint/no-explicit-any componentElement.constructor.name : ''; const isConstructorNameNotObject = constructorName && constructorName !== 'Object'; logger.warn(`The component ref being in Experience is an invalid element. Expected an Element but got ${typeof componentElement}${isConstructorNameNotObject ? ` of type ${constructorName}` : ''}. This component won't be observed.`); return () => { // noop }; } if (componentElement) { observeElement({ element: componentElement, experience, componentType: 'Entry', audience, variant: isVariantHidden ? Object.assign(Object.assign({}, variant), { id: `${baseline.id}-hidden` }) : variant, variantIndex }); return () => { if (componentElement) { unobserveElement(componentElement); } }; } return () => { // noop }; }, [observeElement, unobserveElement, experience, baseline, variant, variantIndex, audience, isVariantHidden]); if (!hasVariants) { return jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [!isComponentForwardRef && jsxRuntime.jsx(ComponentMarker, { ref: componentRef }, `marker-no-variants-${(experience === null || experience === void 0 ? void 0 : experience.id) || 'baseline'}-${variant.id}`), /*#__PURE__*/React.createElement(Component, Object.assign({}, passthroughProps, baseline, { key: baseline.id }, isComponentForwardRef ? { ref: componentRef } : {}))] }); } if (status === 'loading') { return /*#__PURE__*/React.createElement(LoadingComponent, Object.assign({}, baseline, { key: baseline.id, passthroughProps: passthroughProps, experiences: experiences, component: Component })); } if (isVariantHidden) { return jsxRuntime.jsx(ComponentMarker, { ref: componentRef }, `marker-hidden-${(experience === null || experience === void 0 ? void 0 : experience.id) || 'baseline'}-${variant.id}`); } return jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [!isComponentForwardRef && jsxRuntime.jsx(ComponentMarker, { ref: componentRef }, `marker-${(experience === null || experience === void 0 ? void 0 : experience.id) || 'baseline'}-${variant.id}`), /*#__PURE__*/React.createElement(Component, Object.assign({}, Object.assign(Object.assign({}, passthroughProps), variant), { key: `${(experience === null || experience === void 0 ? void 0 : experience.id) || 'baseline'}-${variant.id}`, ninetailed: { isPersonalized, audience: { id: (audience === null || audience === void 0 ? void 0 : audience.id) || 'all visitors' } } }, isComponentForwardRef ? { ref: componentRef } : {}))] }); }; const ESRContext = /*#__PURE__*/React__default["default"].createContext(undefined); const ESRProvider = ({ experienceVariantsMap, children }) => { return jsxRuntime.jsx(ESRContext.Provider, Object.assign({ value: { experienceVariantsMap } }, { children: children })); }; const useESR = () => { const context = React__default["default"].useContext(ESRContext); if (context === undefined) { throw new Error('The component using the the context must be a descendant of the ESRProvider'); } return { experienceVariantsMap: context.experienceVariantsMap }; }; const ESRLoadingComponent = _a => { var { experiences, component: Component, passthroughProps } = _a, baseline = __rest(_a, ["experiences", "component", "passthroughProps"]); const { experienceVariantsMap } = useESR(); const experience = experiences.find(experience => Object.prototype.hasOwnProperty.call(experienceVariantsMap, experience.id)); if (!experience) { return jsxRuntime.jsx(Component, Object.assign({}, passthroughProps, baseline, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })); } const component = experience.components.find(component => { if (!('id' in component.baseline)) { return false; } if (!('id' in baseline)) { return component.baseline.id === undefined; } return component.baseline.id === baseline.id; }); if (!component) { return jsxRuntime.jsx(Component, Object.assign({}, passthroughProps, baseline, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })); } if (experienceVariantsMap[experience.id] === 0) { return jsxRuntime.jsx(Component, Object.assign({}, passthroughProps, baseline, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })); } const variant = component.variants[experienceVariantsMap[experience.id] - 1]; if (!variant) { return jsxRuntime.jsx(Component, Object.assign({}, passthroughProps, baseline, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })); } return jsxRuntime.jsx(Component, Object.assign({}, passthroughProps, variant, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })); }; const EntryAnalytics = _a => { var { component: Component, passthroughProps } = _a, entry = __rest(_a, ["component", "passthroughProps"]); return jsxRuntime.jsx(Experience, Object.assign({}, passthroughProps, entry, { id: entry.id, component: Component, experiences: [] })); }; exports.DefaultExperienceLoadingComponent = DefaultExperienceLoadingComponent; exports.ESRLoadingComponent = ESRLoadingComponent; exports.ESRProvider = ESRProvider; exports.EntryAnalytics = EntryAnalytics; exports.Experience = Experience; exports.MergeTag = MergeTag; exports.NinetailedProvider = NinetailedProvider; exports.Personalize = Personalize; exports.TrackHasSeenComponent = TrackHasSeenComponent; exports.useExperience = useExperience; exports.useFlag = useFlag; exports.useFlagWithManualTracking = useFlagWithManualTracking; exports.useNinetailed = useNinetailed; exports.usePersonalize = usePersonalize; exports.useProfile = useProfile;