UNPKG

@ninetailed/experience.js-react

Version:
733 lines (703 loc) 21.9 kB
import React, { createContext, useMemo, useContext, useState, useRef, useEffect, useCallback, createElement, forwardRef } from 'react'; import { Ninetailed, selectVariant, selectHasExperienceVariants } from '@ninetailed/experience.js'; import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import { logger, ChangeTypes, isBrowser, circularJsonStringify } from '@ninetailed/experience.js-shared'; import { isEqual, get } from 'radash'; import { isForwardRef } from 'react-is'; const NinetailedContext = /*#__PURE__*/createContext(undefined); const NinetailedProvider = props => { const ninetailed = 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 Ninetailed({ clientId, environment, preview }, { url, plugins, locale, requestTimeout, onLog, onError, buildClientContext, onInitProfileId, componentViewTrackingThreshold, storageImpl, useSDKEvaluation }); }, []); const { children } = props; return /*#__PURE__*/jsx(NinetailedContext.Provider, { value: ninetailed, children: children }); }; const useNinetailed = () => { const ninetailed = useContext(NinetailedContext); if (ninetailed === undefined) { throw new Error('The component using the the context must be a descendant of the NinetailedProvider'); } return ninetailed; }; function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; } const _excluded$4 = ["experiences"]; function formatProfileForHook(profile) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const profileStateWithoutExperiences = _objectWithoutPropertiesLoose(profile, _excluded$4); return 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] = useState(formatProfileForHook(ninetailed.profileState)); // Reference to track the previous profile state for comparison const profileStateRef = useRef(ninetailed.profileState); 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 (isEqual(changedProfileState, profileStateRef.current)) { logger.debug('Profile State Did Not Change', changedProfileState); return; } profileStateRef.current = changedProfileState; logger.debug('Profile State Changed', changedProfileState); setStrippedProfileState(formatProfileForHook(changedProfileState)); }); // Clean up subscription when component unmounts return () => { if (typeof unsubscribe === 'function') { unsubscribe(); logger.debug('Unsubscribed from profile state changes'); } }; }, []); return strippedProfileState; }; const usePersonalize = (baseline, variants, options = { holdout: -1 }) => { const profile = useProfile(); return 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 = useRef(flagKey); const defaultValueRef = useRef(defaultValue); const lastProcessedState = useRef(null); const changeRef = useRef(null); const [result, setResult] = useState({ value: defaultValue, status: 'loading', error: null }); // Reset if inputs change useEffect(() => { if (!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 useEffect(() => { const unsubscribe = ninetailed.onChangesChange(changesState => { if (lastProcessedState.current && 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 === 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 = 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); 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] = useState(false); const ref = useRef(null); 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(); }; }, []); useEffect(() => { if (isBrowser() && inView) { ninetailed.trackHasSeenComponent({ variant, audience, isPersonalized }); } }, [inView]); return /*#__PURE__*/jsxs(Fragment, { children: [/*#__PURE__*/jsx("div", { ref: ref }), children] }); }; const _excluded$3 = ["component", "loadingComponent", "variants", "holdout"]; const Personalize = _ref => { let { component: Component, loadingComponent: LoadingComponent, variants = [], holdout = -1 } = _ref, baseline = _objectWithoutPropertiesLoose(_ref, _excluded$3); const { loading, variant, isPersonalized, audience } = usePersonalize(baseline, variants, { holdout }); const hasVariants = variants.length > 0; if (!hasVariants) { return /*#__PURE__*/jsx(Component, Object.assign({}, baseline, { ninetailed: { isPersonalized, audience } })); } if (loading) { if (LoadingComponent) { return /*#__PURE__*/jsx(LoadingComponent, {}); } return /*#__PURE__*/jsx("div", { style: { opacity: 0 }, children: /*#__PURE__*/jsx(Component, Object.assign({}, variant, { ninetailed: { isPersonalized, audience } })) }, "hide"); } return /*#__PURE__*/jsx(TrackHasSeenComponent, { variant: variant, audience: audience, isPersonalized: isPersonalized, children: /*#__PURE__*/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 => get(profile, selector)); if (!matchingSelector) { return null; } return get(profile, matchingSelector); }; const MergeTag = ({ id, fallback }) => { const { profile, loading } = useProfile(); if (loading || !profile) { return null; } const value = selectValueFromProfile(profile, id) || fallback; return value ? /*#__PURE__*/jsx(Fragment, { children: value }) : null; }; const useExperience = ({ baseline, experiences }) => { const ninetailed = useNinetailed(); const hasVariants = experiences.map(experience => selectHasExperienceVariants(experience, baseline)).reduce((acc, curr) => acc || curr, false); const [experience, setExperience] = useState({ hasVariants, baseline, error: null, loading: true, status: 'loading', experience: null, variant: baseline, variantIndex: 0, audience: null, isPersonalized: false, profile: null }); useEffect(() => { return ninetailed.onSelectVariant({ baseline, experiences }, state => { setExperience(state); }); }, [circularJsonStringify(baseline), circularJsonStringify(experiences)]); return experience; }; const ComponentMarker = /*#__PURE__*/forwardRef((_, ref) => { const { logger } = useNinetailed(); const markerRef = useRef(null); useEffect(() => { var _markerRef$current; /* 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 */ (_markerRef$current = markerRef.current) == null || _markerRef$current.style.setProperty('display', 'none', 'important'); }, []); 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]); useEffect(() => { if (markerRef.current) { const observableElement = getObservableElement(markerRef.current); if (ref) { if (typeof ref === 'function') { ref(observableElement); } else { ref.current = observableElement; } } } }, []); return /*#__PURE__*/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 _excluded$2 = ["component", "unhideAfterMs", "passthroughProps"], _excluded2 = ["experiences", "component", "loadingComponent", "passthroughProps"]; const DefaultExperienceLoadingComponent = _ref => { let { component: Component, unhideAfterMs = 5000, passthroughProps } = _ref, baseline = _objectWithoutPropertiesLoose(_ref, _excluded$2); const { logger } = useNinetailed(); const [hidden, setHidden] = useState(true); 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 /*#__PURE__*/jsx("div", { style: { opacity: 0 }, children: /*#__PURE__*/jsx(Component, Object.assign({}, passthroughProps, baseline, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })) }, "hide"); } return /*#__PURE__*/jsx(Component, Object.assign({}, passthroughProps, baseline, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })); }; const Experience = _ref2 => { let { experiences, component: Component, loadingComponent: LoadingComponent = DefaultExperienceLoadingComponent, passthroughProps } = _ref2, baseline = _objectWithoutPropertiesLoose(_ref2, _excluded2); 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 = isForwardRef( /*#__PURE__*/jsx(Component, {})); const componentRef = useRef(null); const isVariantHidden = 'hidden' in variant && variant.hidden; 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({}, 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 /*#__PURE__*/jsxs(Fragment, { children: [!isComponentForwardRef && /*#__PURE__*/jsx(ComponentMarker, { ref: componentRef }, `marker-no-variants-${(experience == null ? void 0 : experience.id) || 'baseline'}-${variant.id}`), /*#__PURE__*/createElement(Component, Object.assign({}, passthroughProps, baseline, { key: baseline.id }, isComponentForwardRef ? { ref: componentRef } : {}))] }); } if (status === 'loading') { return /*#__PURE__*/createElement(LoadingComponent, Object.assign({}, baseline, { key: baseline.id, passthroughProps: passthroughProps, experiences: experiences, component: Component })); } if (isVariantHidden) { return /*#__PURE__*/jsx(ComponentMarker, { ref: componentRef }, `marker-hidden-${(experience == null ? void 0 : experience.id) || 'baseline'}-${variant.id}`); } return /*#__PURE__*/jsxs(Fragment, { children: [!isComponentForwardRef && /*#__PURE__*/jsx(ComponentMarker, { ref: componentRef }, `marker-${(experience == null ? void 0 : experience.id) || 'baseline'}-${variant.id}`), /*#__PURE__*/createElement(Component, Object.assign({}, Object.assign({}, passthroughProps, variant), { key: `${(experience == null ? void 0 : experience.id) || 'baseline'}-${variant.id}`, ninetailed: { isPersonalized, audience: { id: (audience == null ? void 0 : audience.id) || 'all visitors' } } }, isComponentForwardRef ? { ref: componentRef } : {}))] }); }; const _excluded$1 = ["experiences", "component", "passthroughProps"]; const ESRContext = /*#__PURE__*/React.createContext(undefined); const ESRProvider = ({ experienceVariantsMap, children }) => { return /*#__PURE__*/jsx(ESRContext.Provider, { value: { experienceVariantsMap }, children: children }); }; const useESR = () => { const context = React.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 = _ref => { let { experiences, component: Component, passthroughProps } = _ref, baseline = _objectWithoutPropertiesLoose(_ref, _excluded$1); const { experienceVariantsMap } = useESR(); const experience = experiences.find(experience => Object.prototype.hasOwnProperty.call(experienceVariantsMap, experience.id)); if (!experience) { return /*#__PURE__*/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 /*#__PURE__*/jsx(Component, Object.assign({}, passthroughProps, baseline, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })); } if (experienceVariantsMap[experience.id] === 0) { return /*#__PURE__*/jsx(Component, Object.assign({}, passthroughProps, baseline, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })); } const variant = component.variants[experienceVariantsMap[experience.id] - 1]; if (!variant) { return /*#__PURE__*/jsx(Component, Object.assign({}, passthroughProps, baseline, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })); } return /*#__PURE__*/jsx(Component, Object.assign({}, passthroughProps, variant, { ninetailed: { isPersonalized: false, audience: { id: 'baseline' } } })); }; const _excluded = ["component", "passthroughProps"]; const EntryAnalytics = _ref => { let { component: Component, passthroughProps } = _ref, entry = _objectWithoutPropertiesLoose(_ref, _excluded); return /*#__PURE__*/jsx(Experience, Object.assign({}, passthroughProps, entry, { id: entry.id, component: Component, experiences: [] })); }; export { DefaultExperienceLoadingComponent, ESRLoadingComponent, ESRProvider, EntryAnalytics, Experience, MergeTag, NinetailedProvider, Personalize, TrackHasSeenComponent, useExperience, useFlag, useFlagWithManualTracking, useNinetailed, usePersonalize, useProfile };