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