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