@ninetailed/experience.js-react 7.11.0 → 7.12.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs.js CHANGED
@@ -99,32 +99,49 @@ function __rest(s, e) {
99
99
  return t;
100
100
  }
101
101
 
102
+ function formatProfileForHook(profile) {
103
+ const profileStateWithoutExperiences = __rest(profile, ["experiences"]);
104
+ return Object.assign(Object.assign({}, profileStateWithoutExperiences), {
105
+ loading: profile.status === 'loading'
106
+ });
107
+ }
108
+ /**
109
+ * Custom hook that provides access to the Ninetailed profile state
110
+ * with the 'experiences' property removed to prevent unnecessary re-renders.
111
+ *
112
+ * This hook handles profile state changes efficiently by:
113
+ * 1. Only updating state when actual changes occur
114
+ * 2. Removing the large 'experiences' object from the state
115
+ * 3. Properly cleaning up subscriptions when components unmount
116
+ *
117
+ * @returns The profile state without the 'experiences' property
118
+ */
102
119
  const useProfile = () => {
103
120
  const ninetailed = useNinetailed();
104
- const [profileState, setProfileState] = React.useState(ninetailed.profileState);
105
- const profileStateRef = React.useRef({});
106
- /**
107
- * This effect compares the old and new profile state before updating it.
108
- * We use a ref to avoid an infinite loop which can happen when an empty profile state was updated with no changes.
109
- * This behaviour occurred as the validation handling on the error property was not set properly in the "CreateProfile" and "UpdateProfile" endpoint types.
110
- * Furthermore, it was also observed, that it "only" occurred when the preview plugin was used in parallel.
111
- */
121
+ const [strippedProfileState, setStrippedProfileState] = React.useState(formatProfileForHook(ninetailed.profileState));
122
+ // Reference to track the previous profile state for comparison
123
+ const profileStateRef = React.useRef(ninetailed.profileState);
112
124
  React.useEffect(() => {
113
- ninetailed.onProfileChange(profileState => {
114
- if (radash.isEqual(profileState, profileStateRef.current)) {
115
- experience_jsShared.logger.debug('Profile State Did Not Change', profileState);
125
+ const unsubscribe = ninetailed.onProfileChange(changedProfileState => {
126
+ // Skip update if the profile hasn't actually changed
127
+ // Here we compare the entire profile including experiences and changes
128
+ if (radash.isEqual(changedProfileState, profileStateRef.current)) {
129
+ experience_jsShared.logger.debug('Profile State Did Not Change', changedProfileState);
116
130
  return;
117
- } else {
118
- setProfileState(profileState);
119
- profileStateRef.current = profileState;
120
- experience_jsShared.logger.debug('Profile State Changed', profileState);
121
131
  }
132
+ profileStateRef.current = changedProfileState;
133
+ experience_jsShared.logger.debug('Profile State Changed', changedProfileState);
134
+ setStrippedProfileState(formatProfileForHook(changedProfileState));
122
135
  });
136
+ // Clean up subscription when component unmounts
137
+ return () => {
138
+ if (typeof unsubscribe === 'function') {
139
+ unsubscribe();
140
+ experience_jsShared.logger.debug('Unsubscribed from profile state changes');
141
+ }
142
+ };
123
143
  }, []);
124
- const profileStateWithoutExperiences = __rest(profileState, ["experiences"]);
125
- return Object.assign(Object.assign({}, profileStateWithoutExperiences), {
126
- loading: profileState.status === 'loading'
127
- });
144
+ return strippedProfileState;
128
145
  };
129
146
 
130
147
  const usePersonalize = (baseline, variants, options = {
@@ -134,6 +151,83 @@ const usePersonalize = (baseline, variants, options = {
134
151
  return experience_js.selectVariant(baseline, variants, profile, options);
135
152
  };
136
153
 
154
+ /**
155
+ * Custom hook to retrieve a specific feature flag from Ninetailed changes.
156
+ *
157
+ * @param flagKey - The key of the feature flag to retrieve
158
+ * @param defaultValue - The default value to use if the flag is not found
159
+ * @returns An object containing the flag value and status information
160
+ */
161
+ function useFlag(flagKey, defaultValue) {
162
+ const ninetailed = useNinetailed();
163
+ const lastProcessedState = React.useRef(null);
164
+ const [result, setResult] = React.useState({
165
+ value: defaultValue,
166
+ status: 'loading',
167
+ error: null
168
+ });
169
+ React.useEffect(() => {
170
+ // Reset state when dependencies change
171
+ setResult({
172
+ value: defaultValue,
173
+ status: 'loading',
174
+ error: null
175
+ });
176
+ lastProcessedState.current = null;
177
+ const unsubscribe = ninetailed.onChangesChange(changesState => {
178
+ if (lastProcessedState.current && radash.isEqual(lastProcessedState.current, changesState)) {
179
+ experience_jsShared.logger.debug('Change State Did Not Change', changesState);
180
+ return;
181
+ }
182
+ lastProcessedState.current = changesState;
183
+ if (changesState.status === 'loading') {
184
+ // Don't use a function updater here to avoid type issues
185
+ setResult({
186
+ value: defaultValue,
187
+ status: 'loading',
188
+ error: null
189
+ });
190
+ return;
191
+ }
192
+ if (changesState.status === 'error') {
193
+ setResult({
194
+ value: defaultValue,
195
+ status: 'error',
196
+ error: changesState.error
197
+ });
198
+ return;
199
+ }
200
+ try {
201
+ // Find the change with our flag key
202
+ const change = changesState.changes.find(change => change.key === flagKey);
203
+ if (change && change.type === experience_jsShared.ChangeTypes.Variable) {
204
+ const flagValue = change.value;
205
+ setResult({
206
+ value: flagValue,
207
+ status: 'success',
208
+ error: null
209
+ });
210
+ } else {
211
+ // Flag not found or wrong type, use default
212
+ setResult({
213
+ value: defaultValue,
214
+ status: 'success',
215
+ error: null
216
+ });
217
+ }
218
+ } catch (error) {
219
+ setResult({
220
+ value: defaultValue,
221
+ status: 'error',
222
+ error: error instanceof Error ? error : new Error(String(error))
223
+ });
224
+ }
225
+ });
226
+ return unsubscribe;
227
+ }, [ninetailed, flagKey, defaultValue]);
228
+ return result;
229
+ }
230
+
137
231
  const TrackHasSeenComponent = ({
138
232
  children,
139
233
  variant,
@@ -253,8 +347,8 @@ const MergeTag = ({
253
347
  fallback
254
348
  }) => {
255
349
  const {
256
- loading,
257
- profile
350
+ profile,
351
+ loading
258
352
  } = useProfile();
259
353
  if (loading || !profile) {
260
354
  return null;
@@ -598,6 +692,7 @@ exports.NinetailedProvider = NinetailedProvider;
598
692
  exports.Personalize = Personalize;
599
693
  exports.TrackHasSeenComponent = TrackHasSeenComponent;
600
694
  exports.useExperience = useExperience;
695
+ exports.useFlag = useFlag;
601
696
  exports.useNinetailed = useNinetailed;
602
697
  exports.usePersonalize = usePersonalize;
603
698
  exports.useProfile = useProfile;
package/index.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import React, { createContext, useMemo, useContext, useState, useRef, useEffect, createElement, forwardRef } from 'react';
2
2
  import { Ninetailed, selectVariant, selectHasExperienceVariants } from '@ninetailed/experience.js';
3
3
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
- import { logger, isBrowser } from '@ninetailed/experience.js-shared';
4
+ import { logger, ChangeTypes, isBrowser } from '@ninetailed/experience.js-shared';
5
5
  import { isEqual, get } from 'radash';
6
6
  import { isForwardRef } from 'react-is';
7
7
 
@@ -77,33 +77,52 @@ function _objectWithoutPropertiesLoose(source, excluded) {
77
77
  }
78
78
 
79
79
  const _excluded$3 = ["experiences"];
80
+ function formatProfileForHook(profile) {
81
+ const profileStateWithoutExperiences = _objectWithoutPropertiesLoose(profile, _excluded$3);
82
+ return Object.assign({}, profileStateWithoutExperiences, {
83
+ loading: profile.status === 'loading'
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Custom hook that provides access to the Ninetailed profile state
89
+ * with the 'experiences' property removed to prevent unnecessary re-renders.
90
+ *
91
+ * This hook handles profile state changes efficiently by:
92
+ * 1. Only updating state when actual changes occur
93
+ * 2. Removing the large 'experiences' object from the state
94
+ * 3. Properly cleaning up subscriptions when components unmount
95
+ *
96
+ * @returns The profile state without the 'experiences' property
97
+ */
80
98
  const useProfile = () => {
81
99
  const ninetailed = useNinetailed();
82
- const [profileState, setProfileState] = useState(ninetailed.profileState);
83
- const profileStateRef = useRef({});
100
+ const [strippedProfileState, setStrippedProfileState] = useState(formatProfileForHook(ninetailed.profileState));
84
101
 
85
- /**
86
- * This effect compares the old and new profile state before updating it.
87
- * We use a ref to avoid an infinite loop which can happen when an empty profile state was updated with no changes.
88
- * This behaviour occurred as the validation handling on the error property was not set properly in the "CreateProfile" and "UpdateProfile" endpoint types.
89
- * Furthermore, it was also observed, that it "only" occurred when the preview plugin was used in parallel.
90
- */
102
+ // Reference to track the previous profile state for comparison
103
+ const profileStateRef = useRef(ninetailed.profileState);
91
104
  useEffect(() => {
92
- ninetailed.onProfileChange(profileState => {
93
- if (isEqual(profileState, profileStateRef.current)) {
94
- logger.debug('Profile State Did Not Change', profileState);
105
+ const unsubscribe = ninetailed.onProfileChange(changedProfileState => {
106
+ // Skip update if the profile hasn't actually changed
107
+ // Here we compare the entire profile including experiences and changes
108
+ if (isEqual(changedProfileState, profileStateRef.current)) {
109
+ logger.debug('Profile State Did Not Change', changedProfileState);
95
110
  return;
96
- } else {
97
- setProfileState(profileState);
98
- profileStateRef.current = profileState;
99
- logger.debug('Profile State Changed', profileState);
100
111
  }
112
+ profileStateRef.current = changedProfileState;
113
+ logger.debug('Profile State Changed', changedProfileState);
114
+ setStrippedProfileState(formatProfileForHook(changedProfileState));
101
115
  });
116
+
117
+ // Clean up subscription when component unmounts
118
+ return () => {
119
+ if (typeof unsubscribe === 'function') {
120
+ unsubscribe();
121
+ logger.debug('Unsubscribed from profile state changes');
122
+ }
123
+ };
102
124
  }, []);
103
- const profileStateWithoutExperiences = _objectWithoutPropertiesLoose(profileState, _excluded$3);
104
- return Object.assign({}, profileStateWithoutExperiences, {
105
- loading: profileState.status === 'loading'
106
- });
125
+ return strippedProfileState;
107
126
  };
108
127
 
109
128
  const usePersonalize = (baseline, variants, options = {
@@ -113,6 +132,83 @@ const usePersonalize = (baseline, variants, options = {
113
132
  return selectVariant(baseline, variants, profile, options);
114
133
  };
115
134
 
135
+ /**
136
+ * Custom hook to retrieve a specific feature flag from Ninetailed changes.
137
+ *
138
+ * @param flagKey - The key of the feature flag to retrieve
139
+ * @param defaultValue - The default value to use if the flag is not found
140
+ * @returns An object containing the flag value and status information
141
+ */
142
+ function useFlag(flagKey, defaultValue) {
143
+ const ninetailed = useNinetailed();
144
+ const lastProcessedState = useRef(null);
145
+ const [result, setResult] = useState({
146
+ value: defaultValue,
147
+ status: 'loading',
148
+ error: null
149
+ });
150
+ useEffect(() => {
151
+ // Reset state when dependencies change
152
+ setResult({
153
+ value: defaultValue,
154
+ status: 'loading',
155
+ error: null
156
+ });
157
+ lastProcessedState.current = null;
158
+ const unsubscribe = ninetailed.onChangesChange(changesState => {
159
+ if (lastProcessedState.current && isEqual(lastProcessedState.current, changesState)) {
160
+ logger.debug('Change State Did Not Change', changesState);
161
+ return;
162
+ }
163
+ lastProcessedState.current = changesState;
164
+ if (changesState.status === 'loading') {
165
+ // Don't use a function updater here to avoid type issues
166
+ setResult({
167
+ value: defaultValue,
168
+ status: 'loading',
169
+ error: null
170
+ });
171
+ return;
172
+ }
173
+ if (changesState.status === 'error') {
174
+ setResult({
175
+ value: defaultValue,
176
+ status: 'error',
177
+ error: changesState.error
178
+ });
179
+ return;
180
+ }
181
+ try {
182
+ // Find the change with our flag key
183
+ const change = changesState.changes.find(change => change.key === flagKey);
184
+ if (change && change.type === ChangeTypes.Variable) {
185
+ const flagValue = change.value;
186
+ setResult({
187
+ value: flagValue,
188
+ status: 'success',
189
+ error: null
190
+ });
191
+ } else {
192
+ // Flag not found or wrong type, use default
193
+ setResult({
194
+ value: defaultValue,
195
+ status: 'success',
196
+ error: null
197
+ });
198
+ }
199
+ } catch (error) {
200
+ setResult({
201
+ value: defaultValue,
202
+ status: 'error',
203
+ error: error instanceof Error ? error : new Error(String(error))
204
+ });
205
+ }
206
+ });
207
+ return unsubscribe;
208
+ }, [ninetailed, flagKey, defaultValue]);
209
+ return result;
210
+ }
211
+
116
212
  const TrackHasSeenComponent = ({
117
213
  children,
118
214
  variant,
@@ -232,8 +328,8 @@ const MergeTag = ({
232
328
  fallback
233
329
  }) => {
234
330
  const {
235
- loading,
236
- profile
331
+ profile,
332
+ loading
237
333
  } = useProfile();
238
334
  if (loading || !profile) {
239
335
  return null;
@@ -571,4 +667,4 @@ const ESRLoadingComponent = _ref => {
571
667
  }));
572
668
  };
573
669
 
574
- export { DefaultExperienceLoadingComponent, ESRLoadingComponent, ESRProvider, Experience, MergeTag, NinetailedProvider, Personalize, TrackHasSeenComponent, useExperience, useNinetailed, usePersonalize, useProfile };
670
+ export { DefaultExperienceLoadingComponent, ESRLoadingComponent, ESRProvider, Experience, MergeTag, NinetailedProvider, Personalize, TrackHasSeenComponent, useExperience, useFlag, useNinetailed, usePersonalize, useProfile };
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@ninetailed/experience.js-react",
3
- "version": "7.11.0",
3
+ "version": "7.12.0-beta.0",
4
4
  "description": "Ninetailed SDK for React",
5
5
  "dependencies": {
6
- "@ninetailed/experience.js": "7.11.0",
7
- "@ninetailed/experience.js-shared": "7.11.0",
8
- "@ninetailed/experience.js-plugin-analytics": "7.11.0",
6
+ "@ninetailed/experience.js": "7.12.0-beta.0",
7
+ "@ninetailed/experience.js-shared": "7.12.0-beta.0",
8
+ "@ninetailed/experience.js-plugin-analytics": "7.12.0-beta.0",
9
9
  "radash": "10.9.0",
10
10
  "react-is": "18.2.0"
11
11
  },
@@ -5,6 +5,7 @@ export type { NinetailedProviderProps, NinetailedProviderInstantiationProps, } f
5
5
  export { useNinetailed } from './useNinetailed';
6
6
  export { useProfile } from './useProfile';
7
7
  export { usePersonalize } from './usePersonalize';
8
+ export { useFlag } from './useFlag';
8
9
  export { Personalize } from './Personalize';
9
10
  export type { PersonalizedComponent } from './Personalize';
10
11
  export { MergeTag } from './MergeTag';
@@ -0,0 +1,22 @@
1
+ import { AllowedVariableType } from '@ninetailed/experience.js-shared';
2
+ export type FlagResult<T> = {
3
+ status: 'loading';
4
+ value: T;
5
+ error: null;
6
+ } | {
7
+ status: 'success';
8
+ value: T;
9
+ error: null;
10
+ } | {
11
+ status: 'error';
12
+ value: T;
13
+ error: Error;
14
+ };
15
+ /**
16
+ * Custom hook to retrieve a specific feature flag from Ninetailed changes.
17
+ *
18
+ * @param flagKey - The key of the feature flag to retrieve
19
+ * @param defaultValue - The default value to use if the flag is not found
20
+ * @returns An object containing the flag value and status information
21
+ */
22
+ export declare function useFlag<T extends AllowedVariableType>(flagKey: string, defaultValue: T): FlagResult<T>;
@@ -1,53 +1,17 @@
1
- export declare const useProfile: () => {
1
+ import { ProfileState } from '@ninetailed/experience.js';
2
+ type UseProfileHookResult = Omit<ProfileState, 'experiences'> & {
2
3
  loading: boolean;
3
- from: "api" | "hydrated";
4
- status: "loading";
5
- profile: null;
6
- error: null;
7
- } | {
8
- loading: boolean;
9
- from: "api" | "hydrated";
10
- status: "success";
11
- profile: {
12
- id: string;
13
- stableId: string;
14
- random: number;
15
- audiences: string[];
16
- traits: import("@ninetailed/experience.js-shared").Properties;
17
- location: {
18
- coordinates?: {
19
- latitude: number;
20
- longitude: number;
21
- } | undefined;
22
- city?: string | undefined;
23
- postalCode?: string | undefined;
24
- region?: string | undefined;
25
- regionCode?: string | undefined;
26
- country?: string | undefined;
27
- countryCode?: string | undefined;
28
- continent?: string | undefined;
29
- timezone?: string | undefined;
30
- };
31
- session: {
32
- id: string;
33
- isReturningVisitor: boolean;
34
- landingPage: {
35
- path: string;
36
- url: string;
37
- query: Record<string, string>;
38
- referrer: string;
39
- search: string;
40
- };
41
- count: number;
42
- activeSessionLength: number;
43
- averageSessionLength: number;
44
- };
45
- };
46
- error: null;
47
- } | {
48
- loading: boolean;
49
- from: "api" | "hydrated";
50
- status: "error";
51
- profile: null;
52
- error: Error;
53
4
  };
5
+ /**
6
+ * Custom hook that provides access to the Ninetailed profile state
7
+ * with the 'experiences' property removed to prevent unnecessary re-renders.
8
+ *
9
+ * This hook handles profile state changes efficiently by:
10
+ * 1. Only updating state when actual changes occur
11
+ * 2. Removing the large 'experiences' object from the state
12
+ * 3. Properly cleaning up subscriptions when components unmount
13
+ *
14
+ * @returns The profile state without the 'experiences' property
15
+ */
16
+ export declare const useProfile: () => UseProfileHookResult;
17
+ export {};