@ninetailed/experience.js-react
Version:
Ninetailed SDK for React
733 lines (703 loc) • 21.9 kB
JavaScript
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 };