@ops-ai/react-router-toggly 1.0.0 → 1.1.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/dist/client.cjs CHANGED
@@ -3,6 +3,8 @@
3
3
  var togglyHooksTypes = require('@ops-ai/toggly-hooks-types');
4
4
  require('@ops-ai/toggly-eval');
5
5
  var react = require('react');
6
+ var togglyClientTelemetry = require('@ops-ai/toggly-client-telemetry');
7
+ var browser = require('@ops-ai/toggly-client-telemetry/browser');
6
8
  var togglyLocalGates = require('@ops-ai/toggly-local-gates');
7
9
  var jsxRuntime = require('react/jsx-runtime');
8
10
  var reactRouter = require('react-router');
@@ -136,16 +138,188 @@ var HEADERS = {
136
138
  CACHE_CONTROL: "cache-control"
137
139
  };
138
140
  var TOGGLY_LOADER_KEY = "__toggly";
141
+ function createBrowserTelemetry(config) {
142
+ const enabled = globalThis.window !== void 0 && typeof document !== "undefined" && !!config.appKey?.trim() && config.enableTelemetry !== false;
143
+ const usage = enabled && config.enableUsageTracking !== false;
144
+ const metrics = enabled && config.enableMetrics !== false;
145
+ let context = { identity: config.identity, instanceId: config.instanceId };
146
+ let partition = 0;
147
+ let active = false;
148
+ let disposed = false;
149
+ let reporter;
150
+ let detach;
151
+ let staged = /* @__PURE__ */ new Map();
152
+ let diagnostics = 0;
153
+ const diagnostic = () => {
154
+ if (diagnostics++ >= 10) return;
155
+ try {
156
+ config.onError?.("Frontend telemetry pre-commit event rejected.");
157
+ } catch {
158
+ }
159
+ };
160
+ const getReporter = () => {
161
+ if (!reporter) {
162
+ reporter = togglyClientTelemetry.createTelemetryReporter({
163
+ appKey: config.appKey,
164
+ environment: config.environment,
165
+ ...context,
166
+ metricsBaseUrl: config.metricsBaseUrl,
167
+ telemetryFlushIntervalMs: config.telemetryFlushIntervalMs,
168
+ fetch: config.telemetryFetch,
169
+ onDiagnostic: (code) => {
170
+ try {
171
+ config.onError?.(code);
172
+ } catch {
173
+ }
174
+ }
175
+ });
176
+ detach = browser.attachBrowserLifecycle(reporter);
177
+ }
178
+ return reporter;
179
+ };
180
+ function replay(entry) {
181
+ const target = getReporter();
182
+ target.setContext(entry.context);
183
+ if (entry.kind === "feature") {
184
+ const methods = ["recordCheck", "recordUsage", "recordView"];
185
+ entry.values.forEach((count, index) => {
186
+ for (let i = 0; i < count; i++) target[methods[index]](entry.key, entry.variant);
187
+ });
188
+ } else if (entry.kind === "gauge") target.setGauge(entry.key, entry.values[0]);
189
+ else {
190
+ let remaining = entry.values[0];
191
+ do {
192
+ const value = Math.min(remaining, 1e6);
193
+ target.incrementCounter(entry.key, value);
194
+ remaining -= value;
195
+ } while (remaining > 0);
196
+ }
197
+ target.setContext(context);
198
+ }
199
+ function isValidEntry(entry) {
200
+ return !(typeof entry.key !== "string" || !entry.key.trim() || entry.kind === "feature" && (typeof entry.variant !== "string" || !entry.variant.length || entry.variant.length > 64 || /[^A-Za-z0-9_-]/.test(entry.variant)) || entry.values.some((value) => !Number.isFinite(value) || value < 0 || value > 1e6 || entry.kind !== "gauge" && !Number.isInteger(value)));
201
+ }
202
+ function fitsAdmissionBudget(entries, entry) {
203
+ let operations = 0;
204
+ let bytes = 0;
205
+ let variants = 0;
206
+ for (const value of entries) {
207
+ if (value.kind === "feature" && value.key === entry.key && value.partition === entry.partition) variants++;
208
+ const count = value.kind === "feature" ? value.values.reduce((a, b) => a + b, 0) : Math.max(1, Math.ceil(value.values[0] / 1e6));
209
+ operations += count;
210
+ const serialized = JSON.stringify({ k: config.appKey, e: config.environment ?? "Production", ...value });
211
+ const size = new TextEncoder().encode(serialized).byteLength;
212
+ if (size > 49152) return false;
213
+ bytes += size * count;
214
+ }
215
+ return entries.length <= 2e3 && operations <= 2e3 && bytes <= 262144 && variants <= 16;
216
+ }
217
+ function event(entry) {
218
+ if (disposed || (entry.kind === "feature" ? !usage : !metrics)) return;
219
+ if (!isValidEntry(entry)) {
220
+ diagnostic();
221
+ return;
222
+ }
223
+ if (active) {
224
+ replay(entry);
225
+ return;
226
+ }
227
+ const id = JSON.stringify([entry.partition, entry.kind === "feature" ? "f" : "m", entry.key, entry.variant]);
228
+ const previous = staged.get(id);
229
+ if (previous && previous.kind !== entry.kind) {
230
+ diagnostic();
231
+ return;
232
+ }
233
+ if (previous && entry.kind !== "gauge") entry.values = entry.values.map((value, index) => value + previous.values[index]);
234
+ const entries = [...staged.entries()].filter(([key]) => key !== id).map(([, value]) => value);
235
+ entries.push(entry);
236
+ if (!fitsAdmissionBudget(entries, entry)) {
237
+ diagnostic();
238
+ return;
239
+ }
240
+ staged.set(id, entry);
241
+ }
242
+ function feature(key, variant, index, attribution = context, capturedPartition = partition) {
243
+ const values = [0, 0, 0];
244
+ values[index] = 1;
245
+ event({ partition: capturedPartition, context: attribution, key, variant, kind: "feature", values });
246
+ }
247
+ const api = {
248
+ recordUsage: (key, variant = "enabled") => feature(key, variant, 1),
249
+ recordView: (key, variant = "enabled") => feature(key, variant, 2),
250
+ incrementCounter: (key, value = 1) => event({ partition, context, key, kind: "counter", values: [value] }),
251
+ setGauge: (key, value) => event({ partition, context, key, kind: "gauge", values: [value] }),
252
+ flushTelemetry: async () => {
253
+ await reporter?.flush();
254
+ }
255
+ };
256
+ return {
257
+ api,
258
+ setContext(next) {
259
+ if (disposed) return;
260
+ if (context.identity !== next.identity || context.instanceId !== next.instanceId) partition++;
261
+ context = { identity: next.identity, instanceId: next.instanceId };
262
+ reporter?.setContext(context);
263
+ },
264
+ captureCheck() {
265
+ if (disposed || !usage) return () => {
266
+ };
267
+ if (active) {
268
+ const record = getReporter().captureCheck();
269
+ return (key, result) => record(key, result ? "enabled" : "disabled");
270
+ }
271
+ const attribution = context;
272
+ const capturedPartition = partition;
273
+ return (key, result) => feature(key, result ? "enabled" : "disabled", 0, attribution, capturedPartition);
274
+ },
275
+ recordCheck: (key, result) => feature(key, result ? "enabled" : "disabled", 0),
276
+ activate() {
277
+ if (disposed || active) return;
278
+ active = true;
279
+ const pending = staged;
280
+ staged = /* @__PURE__ */ new Map();
281
+ for (const entry of pending.values()) replay(entry);
282
+ pending.clear();
283
+ },
284
+ dispose(options) {
285
+ disposed = true;
286
+ staged.clear();
287
+ detach?.();
288
+ reporter?.dispose(options);
289
+ }
290
+ };
291
+ }
139
292
 
140
293
  // src/client/sdk-identity.ts
141
294
  var SDK_ID = "react-router";
142
- var SDK_VERSION = "1.0.0";
295
+ var SDK_VERSION = "1.1.0";
143
296
  function appendSdkQueryParams(params) {
144
297
  params.set("sdk", SDK_ID);
145
298
  params.set("sdkVersion", SDK_VERSION);
146
299
  }
147
300
  var TogglyContext = react.createContext(void 0);
148
- function TogglyProvider({
301
+ var transportIds = /* @__PURE__ */ new WeakMap();
302
+ var nextTransportId = 0;
303
+ function transportId(value) {
304
+ if (!value) return 0;
305
+ let id = transportIds.get(value);
306
+ if (id === void 0) {
307
+ id = ++nextTransportId;
308
+ transportIds.set(value, id);
309
+ }
310
+ return id;
311
+ }
312
+ var useCommittedContextEffect = globalThis.window === void 0 ? react.useEffect : react.useLayoutEffect;
313
+ function TogglyProvider(props) {
314
+ const config = props.config ?? (props.serverContext ? { appKey: props.serverContext.appKey, environment: props.serverContext.environment } : void 0);
315
+ const committedOwner = react.useRef();
316
+ const ownerKey = JSON.stringify([transportId(config?.telemetryFetch), config?.appKey ?? "", config?.environment ?? "Production", config?.enableTelemetry, config?.enableUsageTracking, config?.enableMetrics, config?.metricsBaseUrl, config?.telemetryFlushIntervalMs]);
317
+ const snapshot = props.serverContext;
318
+ const matchesOwner = (!snapshot?.appKey || snapshot.appKey === config?.appKey) && (!snapshot?.environment || snapshot.environment === (config?.environment ?? "Production"));
319
+ return /* @__PURE__ */ jsxRuntime.jsx(TogglyProviderOwner, { ...props, committedOwner, config, serverContext: matchesOwner ? snapshot : void 0 }, ownerKey);
320
+ }
321
+ function TogglyProviderOwner({
322
+ committedOwner,
149
323
  children,
150
324
  serverContext,
151
325
  config,
@@ -161,6 +335,47 @@ function TogglyProvider({
161
335
  () => createLogger(mergedConfig?.debug ?? false),
162
336
  [mergedConfig?.debug]
163
337
  );
338
+ const contextRef = react.useRef({
339
+ identity: serverContext ? serverContext.identity : mergedConfig?.identity,
340
+ instanceId: mergedConfig?.instanceId?.trim() || void 0,
341
+ groups: [...mergedConfig?.groups ?? []],
342
+ claims: { ...mergedConfig?.claims }
343
+ });
344
+ const committedTargetingProps = react.useRef({
345
+ groups: [...mergedConfig?.groups ?? []],
346
+ claims: { ...mergedConfig?.claims }
347
+ });
348
+ const flagsRef = react.useRef(serverContext?.flags ?? mergedConfig?.featureDefaults ?? {});
349
+ const generationRef = react.useRef(0);
350
+ const updateRevisionRef = react.useRef(0);
351
+ const [telemetry] = react.useState(() => createBrowserTelemetry({ ...config, ...contextRef.current }));
352
+ const mountedRef = react.useRef(true);
353
+ react.useEffect(() => {
354
+ mountedRef.current = true;
355
+ const transport = JSON.stringify([
356
+ config?.metricsBaseUrl ?? "https://metrics.toggly.io",
357
+ config?.telemetryFlushIntervalMs ?? 45e3,
358
+ config?.enableTelemetry !== false,
359
+ config?.enableUsageTracking !== false,
360
+ config?.enableMetrics !== false
361
+ ]);
362
+ const previous = committedOwner.current;
363
+ if (previous && previous.telemetry !== telemetry) {
364
+ previous.telemetry.dispose({ flush: previous.transport === transport && previous.fetch === config?.telemetryFetch });
365
+ }
366
+ committedOwner.current = { telemetry, transport, fetch: config?.telemetryFetch };
367
+ telemetry.activate();
368
+ return () => {
369
+ mountedRef.current = false;
370
+ generationRef.current++;
371
+ queueMicrotask(() => {
372
+ if (!mountedRef.current) {
373
+ if (committedOwner.current?.telemetry === telemetry) committedOwner.current = void 0;
374
+ telemetry.dispose();
375
+ }
376
+ });
377
+ };
378
+ }, [telemetry]);
164
379
  const [flags, setFlags] = react.useState(
165
380
  serverContext?.flags ?? mergedConfig?.featureDefaults ?? {}
166
381
  );
@@ -175,23 +390,24 @@ function TogglyProvider({
175
390
  togglyLocalGates.buildFlagGateIndex(localGatesRef.current)
176
391
  );
177
392
  const localGatesListenersRef = react.useRef(/* @__PURE__ */ new Set());
178
- const getEffectiveFlag = react.useCallback(
179
- (featureKey, defaultValue = false, entityContext) => {
180
- const remote = isFeatureEnabled(
181
- flags,
182
- featureKey,
183
- defaultValue,
184
- entityContext
185
- );
186
- return togglyLocalGates.applyLocalGate(
187
- remote,
188
- featureKey,
189
- localGatesRef.current,
190
- localGateIndexRef.current
191
- );
192
- },
193
- [flags, localGatesRevision]
194
- );
393
+ const captureEvaluation = react.useCallback((featureKeys) => {
394
+ const selectedKeys = new Set(featureKeys);
395
+ const selectedFlags = Object.fromEntries([...selectedKeys].map((key) => [key, flagsRef.current[key]]));
396
+ const capturedFlags = JSON.parse(JSON.stringify(selectedFlags));
397
+ const applicableGates = localGatesRef.current.filter((gate) => gate.flagKeys.some((key) => selectedKeys.has(key)));
398
+ const index = togglyLocalGates.buildFlagGateIndex(applicableGates);
399
+ const gates = [...new Set(index.values())].map((id) => {
400
+ const gate = localGatesRef.current.find((candidate) => candidate.id === id);
401
+ return { ...gate, flagKeys: [...gate.flagKeys] };
402
+ });
403
+ const record = telemetry.captureCheck();
404
+ return (featureKey, defaultValue = false, entityContext) => {
405
+ const remote = isFeatureEnabled(capturedFlags, featureKey, defaultValue, entityContext);
406
+ const result = togglyLocalGates.applyLocalGate(remote, featureKey, gates, index);
407
+ record(featureKey, result);
408
+ return result;
409
+ };
410
+ }, [flags, localGatesRevision, telemetry]);
195
411
  const registerContext2 = react.useCallback(
196
412
  (kind, mapper) => {
197
413
  togglyHooksTypes.registerContext(kind, mapper);
@@ -227,8 +443,9 @@ function TogglyProvider({
227
443
  }
228
444
  }, [mergedConfig?.localGates, setLocalGates]);
229
445
  const executeAfterRefresh = react.useCallback(
230
- async (newFlags) => {
446
+ async (newFlags, generation, revision) => {
231
447
  for (const hook of hooks) {
448
+ if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
232
449
  if (hook.afterRefresh) {
233
450
  try {
234
451
  await hook.afterRefresh(newFlags);
@@ -244,14 +461,23 @@ function TogglyProvider({
244
461
  [hooks, logger]
245
462
  );
246
463
  const fetchFlags = react.useCallback(
247
- async (userIdentity) => {
464
+ async (target = contextRef.current) => {
465
+ if (!mountedRef.current) return flagsRef.current;
248
466
  if (!mergedConfig?.appKey) {
249
467
  logger.debug("No appKey, using current flags.");
250
- return flags;
468
+ return flagsRef.current;
251
469
  }
252
470
  try {
253
- const url = buildDefinitionsUrl(mergedConfig, userIdentity);
254
- logger.debug(`Fetching flags from: ${url}`);
471
+ const parsedUrl = new URL(buildDefinitionsUrl(mergedConfig, target));
472
+ if (target.instanceId) {
473
+ const capturedKeys = [...parsedUrl.searchParams.keys()];
474
+ for (const key of capturedKeys) {
475
+ if (key === "u" || key === "userId" || key === "g" || key.startsWith("claim.")) parsedUrl.searchParams.delete(key);
476
+ }
477
+ parsedUrl.searchParams.set("i", target.instanceId);
478
+ }
479
+ const url = parsedUrl.toString();
480
+ logger.debug("Fetching feature flags");
255
481
  const response = await fetchWithTimeout(url, {}, mergedConfig.timeout);
256
482
  if (!response.ok) {
257
483
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -262,28 +488,31 @@ function TogglyProvider({
262
488
  return newFlags;
263
489
  } catch (error) {
264
490
  logger.warn("Failed to fetch flags:", error);
265
- return flags;
491
+ return flagsRef.current;
266
492
  }
267
493
  },
268
- [mergedConfig, flags, logger]
494
+ [mergedConfig, logger]
269
495
  );
270
496
  const updateFlags = react.useCallback(
271
- async (newFlags) => {
497
+ async (newFlags, generation, revision) => {
498
+ if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
499
+ flagsRef.current = newFlags;
272
500
  setFlags(newFlags);
273
- await executeAfterRefresh(newFlags);
274
- onFlagsChange?.(newFlags);
501
+ await executeAfterRefresh(newFlags, generation, revision);
502
+ if (mountedRef.current && generation === generationRef.current && revision === updateRevisionRef.current) onFlagsChange?.(newFlags);
275
503
  },
276
504
  [executeAfterRefresh, onFlagsChange]
277
505
  );
278
506
  const isEnabled = react.useCallback(
279
507
  (featureKey, defaultValue = false, entity, kind) => {
280
- return getEffectiveFlag(
508
+ const evaluate = captureEvaluation([featureKey]);
509
+ return evaluate(
281
510
  featureKey,
282
511
  defaultValue,
283
512
  togglyHooksTypes.normalizeEntityContext(entity, kind)
284
513
  );
285
514
  },
286
- [getEffectiveFlag]
515
+ [captureEvaluation]
287
516
  );
288
517
  const isDisabled = react.useCallback(
289
518
  (featureKey, defaultValue = true, entity, kind) => {
@@ -296,67 +525,111 @@ function TogglyProvider({
296
525
  if (featureKeys.length === 0) {
297
526
  return !negate;
298
527
  }
528
+ const selectedKeys = [...featureKeys];
529
+ const evaluate = captureEvaluation(selectedKeys);
299
530
  const entityContext = togglyHooksTypes.normalizeEntityContext(entity, kind);
300
531
  let result;
301
532
  if (requirement === "any") {
302
- result = featureKeys.some(
303
- (key) => getEffectiveFlag(key, false, entityContext)
533
+ result = selectedKeys.some(
534
+ (key) => evaluate(key, false, entityContext)
304
535
  );
305
536
  } else {
306
- result = featureKeys.every(
307
- (key) => getEffectiveFlag(key, false, entityContext)
537
+ result = selectedKeys.every(
538
+ (key) => evaluate(key, false, entityContext)
308
539
  );
309
540
  }
310
541
  return negate ? !result : result;
311
542
  },
312
- [getEffectiveFlag]
543
+ [captureEvaluation]
313
544
  );
545
+ const installContext = react.useCallback((target) => {
546
+ contextRef.current = target;
547
+ telemetry.setContext(target);
548
+ flagsRef.current = mergedConfig?.featureDefaults ?? {};
549
+ setFlags(flagsRef.current);
550
+ setIdentity(target.identity);
551
+ }, [mergedConfig?.featureDefaults, telemetry]);
552
+ const isCurrentGeneration = react.useCallback((generation) => mountedRef.current && generation === generationRef.current, []);
553
+ const completeIdentification = react.useCallback((generation) => {
554
+ if (isCurrentGeneration(generation)) setIsReady(true);
555
+ }, [isCurrentGeneration]);
556
+ const currentIdentifyHooks = react.useCallback(function* (generation, reverse = false) {
557
+ let index = reverse ? hooks.length - 1 : 0;
558
+ while (reverse ? index >= 0 : index < hooks.length) {
559
+ if (!isCurrentGeneration(generation)) return;
560
+ yield hooks[index];
561
+ index += reverse ? -1 : 1;
562
+ }
563
+ }, [hooks, isCurrentGeneration]);
314
564
  const identify = react.useCallback(
315
- async (newIdentity, _context) => {
316
- logger.debug(`Identifying user: ${newIdentity}`);
317
- for (const hook of hooks) {
318
- if (hook.beforeIdentify) {
319
- try {
320
- await hook.beforeIdentify(newIdentity);
321
- } catch (error) {
322
- logger.error(
323
- `Error in hook "${hook.getMetadata().name}.beforeIdentify":`,
324
- error
325
- );
326
- }
565
+ async (newIdentity, context) => {
566
+ if (!mountedRef.current) return;
567
+ const generation = ++generationRef.current;
568
+ logger.debug("Identifying user");
569
+ for (const hook of currentIdentifyHooks(generation)) {
570
+ if (!hook.beforeIdentify) continue;
571
+ try {
572
+ await hook.beforeIdentify(newIdentity);
573
+ } catch (error) {
574
+ logger.error(`Error in hook "${hook.getMetadata().name}.beforeIdentify":`, error);
327
575
  }
328
576
  }
329
- setIdentity(newIdentity);
330
- const newFlags = await fetchFlags(newIdentity);
331
- await updateFlags(newFlags);
332
- for (let i = hooks.length - 1; i >= 0; i--) {
333
- const hook = hooks[i];
334
- if (hook.afterIdentify) {
335
- try {
336
- await hook.afterIdentify(newIdentity, void 0);
337
- } catch (error) {
338
- logger.error(
339
- `Error in hook "${hook.getMetadata().name}.afterIdentify":`,
340
- error
341
- );
342
- }
577
+ if (!isCurrentGeneration(generation)) return;
578
+ const target = {
579
+ identity: newIdentity,
580
+ instanceId: context?.instanceId?.trim() || void 0,
581
+ groups: [...context?.groups ?? contextRef.current.groups],
582
+ claims: { ...context?.claims ?? contextRef.current.claims }
583
+ };
584
+ installContext(target);
585
+ const revision = ++updateRevisionRef.current;
586
+ await updateFlags(await fetchFlags(target), generation, revision);
587
+ for (const hook of currentIdentifyHooks(generation, true)) {
588
+ if (!hook.afterIdentify) continue;
589
+ try {
590
+ await hook.afterIdentify(newIdentity, void 0);
591
+ } catch (error) {
592
+ logger.error(`Error in hook "${hook.getMetadata().name}.afterIdentify":`, error);
343
593
  }
344
594
  }
345
- setIsReady(true);
595
+ completeIdentification(generation);
346
596
  },
347
- [hooks, fetchFlags, updateFlags, logger]
597
+ [hooks, fetchFlags, updateFlags, installContext, logger, isCurrentGeneration, completeIdentification, currentIdentifyHooks]
348
598
  );
599
+ useCommittedContextEffect(() => {
600
+ const previous = committedTargetingProps.current;
601
+ const groups = [...mergedConfig?.groups ?? []];
602
+ const claims = { ...mergedConfig?.claims };
603
+ const groupsChanged = previous.groups.length !== groups.length || groups.some((group, index) => group !== previous.groups[index]);
604
+ const claimKeys = Object.keys(claims);
605
+ const claimsChanged = Object.keys(previous.claims).length !== claimKeys.length || claimKeys.some((key) => claims[key] !== previous.claims[key]);
606
+ if (!groupsChanged && !claimsChanged) return;
607
+ committedTargetingProps.current = { groups, claims };
608
+ generationRef.current++;
609
+ updateRevisionRef.current++;
610
+ installContext({
611
+ ...contextRef.current,
612
+ groups: groupsChanged ? groups : contextRef.current.groups,
613
+ claims: claimsChanged ? claims : contextRef.current.claims
614
+ });
615
+ setIsReady(false);
616
+ }, [mergedConfig?.groups, mergedConfig?.claims, installContext]);
349
617
  const reset = react.useCallback(async () => {
350
- logger.debug("Resetting identity");
351
- setIdentity(void 0);
352
- const newFlags = await fetchFlags();
353
- await updateFlags(newFlags);
354
- }, [fetchFlags, updateFlags, logger]);
618
+ if (!mountedRef.current) return;
619
+ const generation = ++generationRef.current;
620
+ const target = { ...contextRef.current, identity: void 0, instanceId: void 0 };
621
+ installContext(target);
622
+ const revision = ++updateRevisionRef.current;
623
+ await updateFlags(await fetchFlags(target), generation, revision);
624
+ if (mountedRef.current && generation === generationRef.current) setIsReady(true);
625
+ }, [fetchFlags, updateFlags, installContext]);
355
626
  const refresh = react.useCallback(async () => {
356
- logger.debug("Refreshing flags");
357
- const newFlags = await fetchFlags(identity);
358
- await updateFlags(newFlags);
359
- }, [identity, fetchFlags, updateFlags, logger]);
627
+ if (!mountedRef.current) return;
628
+ const generation = generationRef.current;
629
+ const revision = ++updateRevisionRef.current;
630
+ await updateFlags(await fetchFlags(contextRef.current), generation, revision);
631
+ if (mountedRef.current && generation === generationRef.current && revision === updateRevisionRef.current) setIsReady(true);
632
+ }, [fetchFlags, updateFlags]);
360
633
  const addHook = react.useCallback(
361
634
  (hook) => {
362
635
  setHooks((currentHooks) => {
@@ -434,6 +707,7 @@ function TogglyProvider({
434
707
  if (typeof WebSocket === "undefined") {
435
708
  return;
436
709
  }
710
+ let live = true;
437
711
  function buildWebSocketUrl() {
438
712
  const baseUrl = mergedConfig.baseUrl;
439
713
  const wsUrl = baseUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
@@ -443,7 +717,7 @@ function TogglyProvider({
443
717
  return `${wsUrl.replace(/\/$/, "")}/${mergedConfig.appKey}/ws${query ? `?${query}` : ""}`;
444
718
  }
445
719
  function connect() {
446
- if (wsRef.current) {
720
+ if (!live || !mountedRef.current || wsRef.current) {
447
721
  return;
448
722
  }
449
723
  const wsUrl = buildWebSocketUrl();
@@ -452,11 +726,13 @@ function TogglyProvider({
452
726
  const socket = new WebSocket(wsUrl);
453
727
  wsRef.current = socket;
454
728
  socket.onopen = () => {
729
+ if (!live || wsRef.current !== socket) return;
455
730
  wsConnectedRef.current = true;
456
731
  lastFallbackRefreshRef.current = Date.now();
457
732
  logger.debug("WebSocket connected");
458
733
  };
459
734
  socket.onmessage = (event) => {
735
+ if (!live || wsRef.current !== socket) return;
460
736
  const text = typeof event.data === "string" ? event.data : "";
461
737
  try {
462
738
  const msg = JSON.parse(text);
@@ -474,12 +750,14 @@ function TogglyProvider({
474
750
  }
475
751
  };
476
752
  socket.onclose = () => {
753
+ if (!live || wsRef.current !== socket) return;
477
754
  wsConnectedRef.current = false;
478
755
  wsRef.current = null;
479
756
  logger.debug("WebSocket disconnected, reconnecting in 5s");
480
757
  scheduleReconnect();
481
758
  };
482
759
  socket.onerror = () => {
760
+ if (!live || wsRef.current !== socket) return;
483
761
  logger.warn("WebSocket error");
484
762
  };
485
763
  } catch (error) {
@@ -499,6 +777,7 @@ function TogglyProvider({
499
777
  }
500
778
  connect();
501
779
  return () => {
780
+ live = false;
502
781
  if (wsReconnectTimerRef.current) {
503
782
  clearTimeout(wsReconnectTimerRef.current);
504
783
  wsReconnectTimerRef.current = null;
@@ -517,7 +796,11 @@ function TogglyProvider({
517
796
  react.useEffect(() => {
518
797
  if (!serverContext && mergedConfig?.appKey) {
519
798
  logger.debug("No server context, initializing client-side");
520
- fetchFlags(identity).then((newFlags) => {
799
+ const generation = generationRef.current;
800
+ const revision = ++updateRevisionRef.current;
801
+ fetchFlags(contextRef.current).then((newFlags) => {
802
+ if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
803
+ flagsRef.current = newFlags;
521
804
  setFlags(newFlags);
522
805
  setIsReady(true);
523
806
  });
@@ -525,6 +808,7 @@ function TogglyProvider({
525
808
  }, []);
526
809
  const contextValue = react.useMemo(
527
810
  () => ({
811
+ ...telemetry.api,
528
812
  flags,
529
813
  isReady,
530
814
  identity,
@@ -542,6 +826,7 @@ function TogglyProvider({
542
826
  subscribeLocalGatesChanged
543
827
  }),
544
828
  [
829
+ telemetry,
545
830
  flags,
546
831
  isReady,
547
832
  identity,
@@ -652,15 +937,14 @@ function Feature({
652
937
  contextKind
653
938
  }) {
654
939
  const keys = featureKeys ?? (featureKey ? [featureKey] : []);
655
- const singleEnabled = useFeature(keys[0] ?? "", defaultValue, context, contextKind);
656
- const gateEnabled = useFeatureGate(keys, requirement, false, context, contextKind);
940
+ const { isEnabled, evaluateGate } = useTogglyContext();
657
941
  let enabled;
658
942
  if (keys.length === 0) {
659
943
  enabled = defaultValue;
660
944
  } else if (keys.length === 1) {
661
- enabled = singleEnabled;
945
+ enabled = isEnabled(keys[0], defaultValue, context, contextKind);
662
946
  } else {
663
- enabled = gateEnabled;
947
+ enabled = evaluateGate(keys, requirement, false, context, contextKind);
664
948
  }
665
949
  if (negate) {
666
950
  enabled = !enabled;