flipflag-sdk 1.1.21 → 1.1.23

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/dist/react.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, useState, useCallback, useEffect } from 'react';
1
+ import { createContext, useContext, useState, useMemo, useCallback, useEffect } from 'react';
2
2
  import { jsx } from 'react/jsx-runtime';
3
3
 
4
4
  class FlipFlagSDK {
@@ -6,6 +6,9 @@ class FlipFlagSDK {
6
6
  this.cache = new Map();
7
7
  this.defaultBaseUrl = "https://app.flipflag.ru";
8
8
  this.stateListeners = new Set();
9
+ this.maxListeners = 100; // Maximum number of listeners to prevent memory leaks
10
+ // Validate required configuration
11
+ this.validateConfig(config);
9
12
  this.config = {
10
13
  baseUrl: this.defaultBaseUrl,
11
14
  cacheTimeout: 30000, // 30 seconds
@@ -30,9 +33,18 @@ class FlipFlagSDK {
30
33
  * Identify user for A/B testing and user-specific flags
31
34
  */
32
35
  identifyUser(options) {
36
+ if (!options || typeof options !== "object") {
37
+ throw new Error("options must be an object");
38
+ }
39
+ if (!options.userId || typeof options.userId !== "string") {
40
+ throw new Error("userId is required and must be a string");
41
+ }
42
+ if (options.userProperties && typeof options.userProperties !== "object") {
43
+ throw new Error("userProperties must be an object");
44
+ }
33
45
  this.config.userId = options.userId;
34
46
  // Clear A/B test cache since user changed
35
- this.clearABTestCache();
47
+ this.clearAllABTestCache();
36
48
  // If we have a user, we might want to refresh immediately
37
49
  if (this.config.pullInterval && this.config.pullInterval > 0) {
38
50
  this.pullData();
@@ -48,6 +60,9 @@ class FlipFlagSDK {
48
60
  * Subscribe to state changes
49
61
  */
50
62
  subscribe(callback) {
63
+ if (this.stateListeners.size >= this.maxListeners) {
64
+ console.warn(`Maximum number of listeners (${this.maxListeners}) reached. This may cause memory leaks.`);
65
+ }
51
66
  this.stateListeners.add(callback);
52
67
  // Return unsubscribe function
53
68
  return () => {
@@ -158,6 +173,8 @@ class FlipFlagSDK {
158
173
  }
159
174
  catch (error) {
160
175
  console.error("State listener error:", error);
176
+ // Remove faulty listener to prevent repeated errors
177
+ this.stateListeners.delete(callback);
161
178
  }
162
179
  });
163
180
  }
@@ -194,9 +211,9 @@ class FlipFlagSDK {
194
211
  }
195
212
  }
196
213
  /**
197
- * Clear A/B test cache
214
+ * Clear all A/B test cache (private method)
198
215
  */
199
- clearABTestCache() {
216
+ clearAllABTestCache() {
200
217
  const abTestKeys = Array.from(this.cache.keys()).filter((key) => key.startsWith("abtest:"));
201
218
  abTestKeys.forEach((key) => this.cache.delete(key));
202
219
  }
@@ -222,6 +239,15 @@ class FlipFlagSDK {
222
239
  */
223
240
  async getFlag(flagName, projectId = this.config.projectId, environment) {
224
241
  var _a, _b;
242
+ if (!flagName || typeof flagName !== "string") {
243
+ throw new Error("flagName is required and must be a string");
244
+ }
245
+ if (!projectId || typeof projectId !== "string") {
246
+ throw new Error("projectId must be a string");
247
+ }
248
+ if (environment && typeof environment !== "string") {
249
+ throw new Error("environment must be a string");
250
+ }
225
251
  // If we have the flag in state, return it immediately
226
252
  if (this.state.flags[flagName] !== undefined) {
227
253
  return {
@@ -233,20 +259,18 @@ class FlipFlagSDK {
233
259
  };
234
260
  }
235
261
  // Check if we're currently loading data (pullData in progress)
236
- // In this case, wait a bit and check state again
262
+ // In this case, wait for loading to complete using Promise
237
263
  if (this.state.isLoading) {
238
- // Wait up to 5 seconds for loading to complete
239
- for (let i = 0; i < 50; i++) {
240
- await new Promise((resolve) => setTimeout(resolve, 100));
241
- if (!this.state.isLoading && this.state.flags[flagName] !== undefined) {
242
- return {
243
- projectId: this.config.projectId,
244
- flagName,
245
- environment: this.config.environment || "all",
246
- value: this.state.flags[flagName],
247
- timestamp: ((_b = this.state.lastFetch) === null || _b === void 0 ? void 0 : _b.toISOString()) || new Date().toISOString(),
248
- };
249
- }
264
+ await this.waitForLoadingComplete();
265
+ // After waiting, check state again
266
+ if (this.state.flags[flagName] !== undefined) {
267
+ return {
268
+ projectId: this.config.projectId,
269
+ flagName,
270
+ environment: this.config.environment || "all",
271
+ value: this.state.flags[flagName],
272
+ timestamp: ((_b = this.state.lastFetch) === null || _b === void 0 ? void 0 : _b.toISOString()) || new Date().toISOString(),
273
+ };
250
274
  }
251
275
  }
252
276
  const cacheKey = `flag:${projectId}:${flagName}:${environment || "all"}`;
@@ -262,10 +286,76 @@ class FlipFlagSDK {
262
286
  this.setCache(cacheKey, response, this.config.cacheTimeout);
263
287
  return response;
264
288
  }
289
+ /**
290
+ * Validate configuration
291
+ */
292
+ validateConfig(config) {
293
+ if (!config.apiKey || typeof config.apiKey !== "string") {
294
+ throw new Error("apiKey is required and must be a string");
295
+ }
296
+ if (!config.projectId || typeof config.projectId !== "string") {
297
+ throw new Error("projectId is required and must be a string");
298
+ }
299
+ if (config.baseUrl && typeof config.baseUrl !== "string") {
300
+ throw new Error("baseUrl must be a string");
301
+ }
302
+ if (config.environment && typeof config.environment !== "string") {
303
+ throw new Error("environment must be a string");
304
+ }
305
+ if (config.userId && typeof config.userId !== "string") {
306
+ throw new Error("userId must be a string");
307
+ }
308
+ if (config.cacheTimeout &&
309
+ (typeof config.cacheTimeout !== "number" || config.cacheTimeout < 0)) {
310
+ throw new Error("cacheTimeout must be a non-negative number");
311
+ }
312
+ if (config.retryAttempts &&
313
+ (typeof config.retryAttempts !== "number" || config.retryAttempts < 0)) {
314
+ throw new Error("retryAttempts must be a non-negative number");
315
+ }
316
+ if (config.retryDelay &&
317
+ (typeof config.retryDelay !== "number" || config.retryDelay < 0)) {
318
+ throw new Error("retryDelay must be a non-negative number");
319
+ }
320
+ if (config.pullInterval &&
321
+ (typeof config.pullInterval !== "number" || config.pullInterval < 0)) {
322
+ throw new Error("pullInterval must be a non-negative number");
323
+ }
324
+ }
325
+ /**
326
+ * Wait for loading to complete with timeout
327
+ */
328
+ async waitForLoadingComplete(timeout = 5000) {
329
+ const startTime = Date.now();
330
+ return new Promise((resolve, reject) => {
331
+ const checkLoading = () => {
332
+ if (!this.state.isLoading) {
333
+ resolve();
334
+ return;
335
+ }
336
+ if (Date.now() - startTime > timeout) {
337
+ reject(new Error("Timeout waiting for SDK loading to complete"));
338
+ return;
339
+ }
340
+ // Check again after a short delay
341
+ setTimeout(checkLoading, 50);
342
+ };
343
+ checkLoading();
344
+ });
345
+ }
265
346
  /**
266
347
  * Get A/B test variant for a user
267
348
  */
268
349
  async getAbTestVariant(testName, userId, projectId = this.config.projectId) {
350
+ if (!testName || typeof testName !== "string") {
351
+ throw new Error("testName is required and must be a string");
352
+ }
353
+ if (!userId || typeof userId !== "string") {
354
+ throw new Error("userId is required and must be a string");
355
+ }
356
+ if (!projectId || typeof projectId !== "string") {
357
+ throw new Error("projectId must be a string");
358
+ }
269
359
  // If we have the A/B test in state, return it immediately
270
360
  const stateVariant = this.state.abTests[testName];
271
361
  if (stateVariant && stateVariant.testName === testName) {
@@ -329,6 +419,34 @@ class FlipFlagSDK {
329
419
  this.cache.clear();
330
420
  }
331
421
  }
422
+ /**
423
+ * Clear cache for specific flag
424
+ */
425
+ clearFlagCache(flagName, environment) {
426
+ const cacheKey = `flag:${this.config.projectId}:${flagName}:${environment || "all"}`;
427
+ this.cache.delete(cacheKey);
428
+ }
429
+ /**
430
+ * Clear cache for all flags
431
+ */
432
+ clearFlagsCache(environment) {
433
+ const cacheKey = `flags:${this.config.projectId}:${environment || "all"}`;
434
+ this.cache.delete(cacheKey);
435
+ }
436
+ /**
437
+ * Clear cache for A/B test variant
438
+ */
439
+ clearABTestCache(testName, userId) {
440
+ if (testName && userId) {
441
+ const cacheKey = `abtest:${this.config.projectId}:${testName}:${userId}`;
442
+ this.cache.delete(cacheKey);
443
+ }
444
+ else {
445
+ // Clear all A/B test cache
446
+ const abTestKeys = Array.from(this.cache.keys()).filter((key) => key.startsWith("abtest:"));
447
+ abTestKeys.forEach((key) => this.cache.delete(key));
448
+ }
449
+ }
332
450
  /**
333
451
  * Update configuration
334
452
  */
@@ -437,37 +555,18 @@ function useFeatureFlag(flagName, options = {}) {
437
555
  const [loading, setLoading] = useState(true);
438
556
  const [error, setError] = useState(null);
439
557
  // Try to get SDK from context, otherwise create new instance
440
- let sdk;
441
- try {
442
- const context = useFeatureFlagContext();
443
- sdk = context.sdk;
444
- }
445
- catch (_b) {
446
- if (!options.config) {
447
- throw new Error("useFeatureFlag must be used within FeatureFlagProvider or config must be provided");
448
- }
449
- sdk = new FlipFlagSDK(options.config);
450
- }
451
- const updateFromState = useCallback(() => {
452
- if (!options.enabled && options.enabled !== undefined) {
453
- return;
454
- }
558
+ const sdk = useMemo(() => {
455
559
  try {
456
- const flagValue = sdk.getFlagValue(flagName);
457
- setValue(flagValue);
458
- setLoading(false);
459
- setError(null);
560
+ const context = useFeatureFlagContext();
561
+ return context.sdk;
460
562
  }
461
- catch (err) {
462
- const errorMessage = err instanceof Error ? err.message : "Unknown error";
463
- setError(errorMessage);
464
- // Use fallback value if provided
465
- if (options.fallbackValue !== undefined) {
466
- setValue(options.fallbackValue);
563
+ catch (_a) {
564
+ if (!options.config) {
565
+ throw new Error("useFeatureFlag must be used within FeatureFlagProvider or config must be provided");
467
566
  }
468
- setLoading(false);
567
+ return new FlipFlagSDK(options.config);
469
568
  }
470
- }, [sdk, flagName, options.fallbackValue, options.enabled]);
569
+ }, [options.config]);
471
570
  const refetch = useCallback(async () => {
472
571
  // Clear cache for this specific flag and force refresh
473
572
  sdk.clearCache(`flag:${sdk["config"].projectId}:${flagName}:${sdk["config"].environment || "all"}`);
@@ -498,24 +597,52 @@ function useFeatureFlag(flagName, options = {}) {
498
597
  }
499
598
  }, [sdk, flagName, options.fallbackValue]);
500
599
  useEffect(() => {
501
- // Subscribe to state changes
600
+ // Subscribe to state changes with proper tracking to avoid infinite loops
601
+ let lastFetchTime = null;
502
602
  const unsubscribe = sdk.subscribe((state) => {
503
603
  if (!options.enabled && options.enabled !== undefined) {
504
604
  return;
505
605
  }
606
+ // Only update if this is a new fetch or if the flag value actually changed
506
607
  const flagValue = state.flags[flagName];
507
- if (flagValue !== undefined) {
608
+ const shouldUpdate = (state.lastFetch && state.lastFetch !== lastFetchTime) ||
609
+ (flagValue !== undefined && flagValue !== value);
610
+ if (shouldUpdate) {
611
+ if (state.lastFetch) {
612
+ lastFetchTime = state.lastFetch;
613
+ }
508
614
  setValue(flagValue);
509
615
  setLoading(false);
510
616
  setError(state.error);
511
617
  }
512
618
  });
513
619
  return unsubscribe;
514
- }, [sdk, flagName, options.enabled]);
515
- // Separate effect for initial update
620
+ }, [sdk, flagName, options.enabled, value]);
621
+ // Initial load effect
516
622
  useEffect(() => {
517
- updateFromState();
518
- }, [updateFromState]);
623
+ if (options.enabled !== false) {
624
+ try {
625
+ const flagValue = sdk.getFlagValue(flagName);
626
+ if (flagValue !== undefined) {
627
+ setValue(flagValue);
628
+ setLoading(false);
629
+ setError(null);
630
+ }
631
+ else {
632
+ // If not in state, trigger a fetch
633
+ refetch();
634
+ }
635
+ }
636
+ catch (err) {
637
+ const errorMessage = err instanceof Error ? err.message : "Unknown error";
638
+ setError(errorMessage);
639
+ if (options.fallbackValue !== undefined) {
640
+ setValue(options.fallbackValue);
641
+ }
642
+ setLoading(false);
643
+ }
644
+ }
645
+ }, [sdk, flagName, options.fallbackValue, options.enabled, refetch]);
519
646
  return {
520
647
  value,
521
648
  loading,
@@ -524,6 +651,27 @@ function useFeatureFlag(flagName, options = {}) {
524
651
  };
525
652
  }
526
653
 
654
+ /**
655
+ * Wait for SDK loading to complete with timeout
656
+ */
657
+ function waitForSDKLoading(sdk, timeout = 5000) {
658
+ const startTime = Date.now();
659
+ return new Promise((resolve, reject) => {
660
+ const checkLoading = () => {
661
+ if (!sdk.getState().isLoading) {
662
+ resolve();
663
+ return;
664
+ }
665
+ if (Date.now() - startTime > timeout) {
666
+ reject(new Error("Timeout waiting for SDK loading to complete"));
667
+ return;
668
+ }
669
+ // Check again after a short delay
670
+ setTimeout(checkLoading, 50);
671
+ };
672
+ checkLoading();
673
+ });
674
+ }
527
675
  function useFeatureFlags(flagNames, options = {}) {
528
676
  const [flags, setFlags] = useState(() => {
529
677
  // Initialize with fallback values
@@ -538,25 +686,22 @@ function useFeatureFlags(flagNames, options = {}) {
538
686
  const [error, setError] = useState(null);
539
687
  const [isInitialized, setIsInitialized] = useState(false);
540
688
  // Try to get SDK from context, otherwise create new instance
541
- let sdk;
542
- try {
543
- const context = useFeatureFlagContext();
544
- sdk = context.sdk;
545
- }
546
- catch (_a) {
547
- if (!options.config) {
548
- throw new Error("useFeatureFlags must be used within FeatureFlagProvider or config must be provided");
689
+ const sdk = useMemo(() => {
690
+ try {
691
+ const context = useFeatureFlagContext();
692
+ return context.sdk;
549
693
  }
550
- sdk = new FlipFlagSDK(options.config);
551
- }
694
+ catch (_a) {
695
+ if (!options.config) {
696
+ throw new Error("useFeatureFlags must be used within FeatureFlagProvider or config must be provided");
697
+ }
698
+ return new FlipFlagSDK(options.config);
699
+ }
700
+ }, [options.config]);
552
701
  const fetchFlags = useCallback(async () => {
553
702
  if (!options.enabled && options.enabled !== undefined) {
554
703
  return;
555
704
  }
556
- // Prevent multiple simultaneous calls
557
- if (isInitialized && loading) {
558
- return;
559
- }
560
705
  try {
561
706
  setLoading(true);
562
707
  setError(null);
@@ -576,34 +721,35 @@ function useFeatureFlags(flagNames, options = {}) {
576
721
  if (hasAllFlags) {
577
722
  setFlags(stateFlags);
578
723
  setLoading(false);
724
+ setIsInitialized(true);
579
725
  return;
580
726
  }
581
727
  // If SDK is currently loading, wait for it to complete
582
728
  if (sdk.getState().isLoading) {
583
- // Wait up to 5 seconds for loading to complete
584
- for (let i = 0; i < 50; i++) {
585
- await new Promise((resolve) => setTimeout(resolve, 100));
586
- const currentState = sdk.getState();
587
- if (!currentState.isLoading) {
588
- // Check again if we now have all flags
589
- const updatedStateFlags = {};
590
- let updatedHasAllFlags = true;
591
- flagNames.forEach((flagName) => {
592
- const stateValue = sdk.getFlagValue(flagName);
593
- if (stateValue !== undefined) {
594
- updatedStateFlags[flagName] = stateValue;
595
- }
596
- else {
597
- updatedHasAllFlags = false;
598
- }
599
- });
600
- if (updatedHasAllFlags) {
601
- setFlags(updatedStateFlags);
602
- setLoading(false);
603
- return;
729
+ try {
730
+ await waitForSDKLoading(sdk);
731
+ // Check again if we now have all flags
732
+ const updatedStateFlags = {};
733
+ let updatedHasAllFlags = true;
734
+ flagNames.forEach((flagName) => {
735
+ const stateValue = sdk.getFlagValue(flagName);
736
+ if (stateValue !== undefined) {
737
+ updatedStateFlags[flagName] = stateValue;
738
+ }
739
+ else {
740
+ updatedHasAllFlags = false;
604
741
  }
742
+ });
743
+ if (updatedHasAllFlags) {
744
+ setFlags(updatedStateFlags);
745
+ setLoading(false);
746
+ setIsInitialized(true);
747
+ return;
605
748
  }
606
749
  }
750
+ catch (error) {
751
+ console.warn("Timeout waiting for SDK loading, proceeding with API call");
752
+ }
607
753
  }
608
754
  // If we still don't have all flags, make HTTP request
609
755
  const response = await sdk.getFlags();
@@ -631,14 +777,7 @@ function useFeatureFlags(flagNames, options = {}) {
631
777
  setLoading(false);
632
778
  setIsInitialized(true);
633
779
  }
634
- }, [
635
- sdk,
636
- flagNames,
637
- options.fallbackValues,
638
- options.enabled,
639
- isInitialized,
640
- loading,
641
- ]);
780
+ }, [sdk, flagNames, options.fallbackValues, options.enabled]);
642
781
  const refetch = useCallback(async () => {
643
782
  // Clear cache for flags
644
783
  sdk.clearCache(`flags:${sdk["config"].projectId}:${sdk["config"].environment || "all"}`);
@@ -646,27 +785,32 @@ function useFeatureFlags(flagNames, options = {}) {
646
785
  }, [sdk, fetchFlags]);
647
786
  useEffect(() => {
648
787
  fetchFlags();
649
- }, []); // Remove fetchFlags dependency to prevent infinite loop
788
+ }, [fetchFlags]); // Include fetchFlags dependency
650
789
  // Subscribe to state changes for automatic updates
651
790
  useEffect(() => {
791
+ let lastFetchTime = null;
652
792
  const unsubscribe = sdk.subscribe((state) => {
653
793
  if (!options.enabled && options.enabled !== undefined) {
654
794
  return;
655
795
  }
656
- // Update flags from state if available
657
- const newFlags = {};
658
- let hasUpdates = false;
659
- flagNames.forEach((flagName) => {
660
- const stateValue = state.flags[flagName];
661
- if (stateValue !== undefined) {
662
- newFlags[flagName] = stateValue;
663
- hasUpdates = true;
664
- }
665
- });
666
- if (hasUpdates) {
667
- setFlags(newFlags);
668
- setLoading(false);
669
- if (state.error) {
796
+ // Only update if this is a new fetch and has relevant flag data
797
+ if (state.lastFetch &&
798
+ state.lastFetch !== lastFetchTime &&
799
+ Object.keys(state.flags).length > 0) {
800
+ lastFetchTime = state.lastFetch;
801
+ // Update flags from state if available
802
+ const newFlags = {};
803
+ let hasUpdates = false;
804
+ flagNames.forEach((flagName) => {
805
+ const stateValue = state.flags[flagName];
806
+ if (stateValue !== undefined) {
807
+ newFlags[flagName] = stateValue;
808
+ hasUpdates = true;
809
+ }
810
+ });
811
+ if (hasUpdates) {
812
+ setFlags(newFlags);
813
+ setLoading(false);
670
814
  setError(state.error);
671
815
  }
672
816
  }
@@ -687,17 +831,18 @@ function useABTest(testName, options) {
687
831
  const [loading, setLoading] = useState(true);
688
832
  const [error, setError] = useState(null);
689
833
  // Try to get SDK from context, otherwise create new instance
690
- let sdk;
691
- try {
692
- const context = useFeatureFlagContext();
693
- sdk = context.sdk;
694
- }
695
- catch (_a) {
696
- if (!options.config) {
697
- throw new Error("useABTest must be used within FeatureFlagProvider or config must be provided");
834
+ const sdk = useMemo(() => {
835
+ try {
836
+ const context = useFeatureFlagContext();
837
+ return context.sdk;
698
838
  }
699
- sdk = new FlipFlagSDK(options.config);
700
- }
839
+ catch (_a) {
840
+ if (!options.config) {
841
+ throw new Error("useABTest must be used within FeatureFlagProvider or config must be provided");
842
+ }
843
+ return new FlipFlagSDK(options.config);
844
+ }
845
+ }, [options.config]);
701
846
  const fetchVariant = useCallback(async () => {
702
847
  if (!options.enabled && options.enabled !== undefined) {
703
848
  return;
@@ -718,7 +863,7 @@ function useABTest(testName, options) {
718
863
  finally {
719
864
  setLoading(false);
720
865
  }
721
- }, [sdk, testName, options.userId, options.enabled]);
866
+ }, [sdk, testName, options.userId]);
722
867
  const recordEvent = useCallback(async (event, eventData) => {
723
868
  if (!variantId) {
724
869
  console.warn("Cannot record event: no variant assigned yet");
@@ -731,9 +876,35 @@ function useABTest(testName, options) {
731
876
  console.error("Failed to record A/B test event:", err);
732
877
  }
733
878
  }, [sdk, testName, options.userId, variantId]);
879
+ // Subscribe to SDK state changes for automatic updates
880
+ useEffect(() => {
881
+ let lastFetchTime = null;
882
+ const unsubscribe = sdk.subscribe((state) => {
883
+ if (!options.enabled && options.enabled !== undefined) {
884
+ return;
885
+ }
886
+ // Only update if this is a new fetch and has A/B test data
887
+ if (state.lastFetch &&
888
+ state.lastFetch !== lastFetchTime &&
889
+ state.abTests[testName]) {
890
+ lastFetchTime = state.lastFetch;
891
+ const testVariant = state.abTests[testName];
892
+ if (testVariant.variantId !== variantId) {
893
+ setVariant(testVariant.variant.value);
894
+ setVariantId(testVariant.variantId);
895
+ setLoading(false);
896
+ setError(state.error);
897
+ }
898
+ }
899
+ });
900
+ return unsubscribe;
901
+ }, [sdk, testName, options.enabled, variantId]);
902
+ // Initial fetch effect
734
903
  useEffect(() => {
735
- fetchVariant();
736
- }, [fetchVariant]);
904
+ if (options.enabled !== false) {
905
+ fetchVariant();
906
+ }
907
+ }, [options.enabled, fetchVariant]);
737
908
  return {
738
909
  variant,
739
910
  variantId,