@ops-ai/react-router-toggly 1.0.1 → 1.2.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
@@ -2,7 +2,10 @@
2
2
 
3
3
  var togglyHooksTypes = require('@ops-ai/toggly-hooks-types');
4
4
  require('@ops-ai/toggly-eval');
5
+ var togglySignedDefs = require('@ops-ai/toggly-signed-defs');
5
6
  var react = require('react');
7
+ var togglyClientTelemetry = require('@ops-ai/toggly-client-telemetry');
8
+ var browser = require('@ops-ai/toggly-client-telemetry/browser');
6
9
  var togglyLocalGates = require('@ops-ai/toggly-local-gates');
7
10
  var jsxRuntime = require('react/jsx-runtime');
8
11
  var reactRouter = require('react-router');
@@ -50,12 +53,18 @@ function mergeConfig(config) {
50
53
  };
51
54
  }
52
55
  function buildDefinitionsUrl(config, context) {
53
- const { baseUrl, appKey, environment, groups, claims, evaluationMode } = mergeConfig(config);
56
+ const { baseUrl, appKey, environment, groups, claims, evaluationMode, enableVariants } = mergeConfig(config);
54
57
  if (!appKey) {
55
58
  throw new Error("appKey is required");
56
59
  }
57
60
  const mode = evaluationMode ?? "remote";
58
- const pathSegment = mode === "local" ? "definitions-signed" : "evaluated-signed";
61
+ const useVariants = mode !== "local" && enableVariants === true;
62
+ let pathSegment = "evaluated-signed";
63
+ if (mode === "local") {
64
+ pathSegment = "definitions-signed";
65
+ } else if (useVariants) {
66
+ pathSegment = "evaluated-variants-signed";
67
+ }
59
68
  const url = new URL(`${baseUrl}/${pathSegment}/${appKey}/${environment}`);
60
69
  if (mode === "local") {
61
70
  return url.toString();
@@ -68,10 +77,25 @@ function buildDefinitionsUrl(config, context) {
68
77
  groups: fromParam?.groups ?? groups,
69
78
  claims: fromParam?.claims ?? claims
70
79
  },
71
- "evaluated"
80
+ useVariants ? "variants" : "evaluated"
72
81
  );
73
82
  return url.toString();
74
83
  }
84
+ function parseVariantDefsPayload(parsed) {
85
+ return togglySignedDefs.asVariantDefsRecord(togglySignedDefs.unwrapDefsPayload(parsed));
86
+ }
87
+ function variantDefsToFlags(defs) {
88
+ const out = {};
89
+ for (const key of Object.keys(defs)) {
90
+ out[key] = defs[key]?.enabled === true;
91
+ }
92
+ return out;
93
+ }
94
+ function decodeVariantValue(value, isT) {
95
+ if (value === null || value === void 0) return null;
96
+ if (isT) return isT(value) ? value : null;
97
+ return value;
98
+ }
75
99
  function isFeatureEnabled(flags, featureKey, defaultValue = false, entityContext) {
76
100
  if (!flags || Object.keys(flags).length === 0) {
77
101
  return defaultValue;
@@ -136,16 +160,194 @@ var HEADERS = {
136
160
  CACHE_CONTROL: "cache-control"
137
161
  };
138
162
  var TOGGLY_LOADER_KEY = "__toggly";
163
+ function checkVariantLabel(result) {
164
+ if (typeof result === "string") {
165
+ return result;
166
+ }
167
+ return result ? "enabled" : "disabled";
168
+ }
169
+ function createBrowserTelemetry(config) {
170
+ const enabled = globalThis.window !== void 0 && typeof document !== "undefined" && !!config.appKey?.trim() && config.enableTelemetry !== false;
171
+ const usage = enabled && config.enableUsageTracking !== false;
172
+ const metrics = enabled && config.enableMetrics !== false;
173
+ let context = { identity: config.identity, instanceId: config.instanceId };
174
+ let partition = 0;
175
+ let active = false;
176
+ let disposed = false;
177
+ let reporter;
178
+ let detach;
179
+ let staged = /* @__PURE__ */ new Map();
180
+ let diagnostics = 0;
181
+ const diagnostic = () => {
182
+ if (diagnostics++ >= 10) return;
183
+ try {
184
+ config.onError?.("Frontend telemetry pre-commit event rejected.");
185
+ } catch {
186
+ }
187
+ };
188
+ const getReporter = () => {
189
+ if (!reporter) {
190
+ reporter = togglyClientTelemetry.createTelemetryReporter({
191
+ appKey: config.appKey,
192
+ environment: config.environment,
193
+ ...context,
194
+ metricsBaseUrl: config.metricsBaseUrl,
195
+ telemetryFlushIntervalMs: config.telemetryFlushIntervalMs,
196
+ fetch: config.telemetryFetch,
197
+ onDiagnostic: (code) => {
198
+ try {
199
+ config.onError?.(code);
200
+ } catch {
201
+ }
202
+ }
203
+ });
204
+ detach = browser.attachBrowserLifecycle(reporter);
205
+ }
206
+ return reporter;
207
+ };
208
+ function replay(entry) {
209
+ const target = getReporter();
210
+ target.setContext(entry.context);
211
+ if (entry.kind === "feature") {
212
+ const methods = ["recordCheck", "recordUsage", "recordView"];
213
+ entry.values.forEach((count, index) => {
214
+ for (let i = 0; i < count; i++) target[methods[index]](entry.key, entry.variant);
215
+ });
216
+ } else if (entry.kind === "gauge") target.setGauge(entry.key, entry.values[0]);
217
+ else {
218
+ let remaining = entry.values[0];
219
+ do {
220
+ const value = Math.min(remaining, 1e6);
221
+ target.incrementCounter(entry.key, value);
222
+ remaining -= value;
223
+ } while (remaining > 0);
224
+ }
225
+ target.setContext(context);
226
+ }
227
+ function isValidEntry(entry) {
228
+ 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)));
229
+ }
230
+ function fitsAdmissionBudget(entries, entry) {
231
+ let operations = 0;
232
+ let bytes = 0;
233
+ let variants = 0;
234
+ for (const value of entries) {
235
+ if (value.kind === "feature" && value.key === entry.key && value.partition === entry.partition) variants++;
236
+ const count = value.kind === "feature" ? value.values.reduce((a, b) => a + b, 0) : Math.max(1, Math.ceil(value.values[0] / 1e6));
237
+ operations += count;
238
+ const serialized = JSON.stringify({ k: config.appKey, e: config.environment ?? "Production", ...value });
239
+ const size = new TextEncoder().encode(serialized).byteLength;
240
+ if (size > 49152) return false;
241
+ bytes += size * count;
242
+ }
243
+ return entries.length <= 2e3 && operations <= 2e3 && bytes <= 262144 && variants <= 16;
244
+ }
245
+ function event(entry) {
246
+ if (disposed || (entry.kind === "feature" ? !usage : !metrics)) return;
247
+ if (!isValidEntry(entry)) {
248
+ diagnostic();
249
+ return;
250
+ }
251
+ if (active) {
252
+ replay(entry);
253
+ return;
254
+ }
255
+ const id = JSON.stringify([entry.partition, entry.kind === "feature" ? "f" : "m", entry.key, entry.variant]);
256
+ const previous = staged.get(id);
257
+ if (previous && previous.kind !== entry.kind) {
258
+ diagnostic();
259
+ return;
260
+ }
261
+ if (previous && entry.kind !== "gauge") entry.values = entry.values.map((value, index) => value + previous.values[index]);
262
+ const entries = [...staged.entries()].filter(([key]) => key !== id).map(([, value]) => value);
263
+ entries.push(entry);
264
+ if (!fitsAdmissionBudget(entries, entry)) {
265
+ diagnostic();
266
+ return;
267
+ }
268
+ staged.set(id, entry);
269
+ }
270
+ function feature(key, variant, index, attribution = context, capturedPartition = partition) {
271
+ const values = [0, 0, 0];
272
+ values[index] = 1;
273
+ event({ partition: capturedPartition, context: attribution, key, variant, kind: "feature", values });
274
+ }
275
+ const api = {
276
+ recordUsage: (key, variant = "enabled") => feature(key, variant, 1),
277
+ recordView: (key, variant = "enabled") => feature(key, variant, 2),
278
+ incrementCounter: (key, value = 1) => event({ partition, context, key, kind: "counter", values: [value] }),
279
+ setGauge: (key, value) => event({ partition, context, key, kind: "gauge", values: [value] }),
280
+ flushTelemetry: async () => {
281
+ await reporter?.flush();
282
+ }
283
+ };
284
+ return {
285
+ api,
286
+ setContext(next) {
287
+ if (disposed) return;
288
+ if (context.identity !== next.identity || context.instanceId !== next.instanceId) partition++;
289
+ context = { identity: next.identity, instanceId: next.instanceId };
290
+ reporter?.setContext(context);
291
+ },
292
+ captureCheck() {
293
+ if (disposed || !usage) return () => {
294
+ };
295
+ if (active) {
296
+ const record = getReporter().captureCheck();
297
+ return (key, result) => record(key, checkVariantLabel(result));
298
+ }
299
+ const attribution = context;
300
+ const capturedPartition = partition;
301
+ return (key, result) => feature(key, checkVariantLabel(result), 0, attribution, capturedPartition);
302
+ },
303
+ recordCheck: (key, result) => feature(key, checkVariantLabel(result), 0),
304
+ activate() {
305
+ if (disposed || active) return;
306
+ active = true;
307
+ const pending = staged;
308
+ staged = /* @__PURE__ */ new Map();
309
+ for (const entry of pending.values()) replay(entry);
310
+ pending.clear();
311
+ },
312
+ dispose(options) {
313
+ disposed = true;
314
+ staged.clear();
315
+ detach?.();
316
+ reporter?.dispose(options);
317
+ }
318
+ };
319
+ }
139
320
 
140
321
  // src/client/sdk-identity.ts
141
322
  var SDK_ID = "react-router";
142
- var SDK_VERSION = "1.0.1";
323
+ var SDK_VERSION = "1.2.0";
143
324
  function appendSdkQueryParams(params) {
144
325
  params.set("sdk", SDK_ID);
145
326
  params.set("sdkVersion", SDK_VERSION);
146
327
  }
147
328
  var TogglyContext = react.createContext(void 0);
148
- function TogglyProvider({
329
+ var transportIds = /* @__PURE__ */ new WeakMap();
330
+ var nextTransportId = 0;
331
+ function transportId(value) {
332
+ if (!value) return 0;
333
+ let id = transportIds.get(value);
334
+ if (id === void 0) {
335
+ id = ++nextTransportId;
336
+ transportIds.set(value, id);
337
+ }
338
+ return id;
339
+ }
340
+ var useCommittedContextEffect = globalThis.window === void 0 ? react.useEffect : react.useLayoutEffect;
341
+ function TogglyProvider(props) {
342
+ const config = props.config ?? (props.serverContext ? { appKey: props.serverContext.appKey, environment: props.serverContext.environment } : void 0);
343
+ const committedOwner = react.useRef();
344
+ const ownerKey = JSON.stringify([transportId(config?.telemetryFetch), config?.appKey ?? "", config?.environment ?? "Production", config?.enableTelemetry, config?.enableUsageTracking, config?.enableMetrics, config?.metricsBaseUrl, config?.telemetryFlushIntervalMs]);
345
+ const snapshot = props.serverContext;
346
+ const matchesOwner = (!snapshot?.appKey || snapshot.appKey === config?.appKey) && (!snapshot?.environment || snapshot.environment === (config?.environment ?? "Production"));
347
+ return /* @__PURE__ */ jsxRuntime.jsx(TogglyProviderOwner, { ...props, committedOwner, config, serverContext: matchesOwner ? snapshot : void 0 }, ownerKey);
348
+ }
349
+ function TogglyProviderOwner({
350
+ committedOwner,
149
351
  children,
150
352
  serverContext,
151
353
  config,
@@ -161,6 +363,48 @@ function TogglyProvider({
161
363
  () => createLogger(mergedConfig?.debug ?? false),
162
364
  [mergedConfig?.debug]
163
365
  );
366
+ const contextRef = react.useRef({
367
+ identity: serverContext ? serverContext.identity : mergedConfig?.identity,
368
+ instanceId: mergedConfig?.instanceId?.trim() || void 0,
369
+ groups: [...mergedConfig?.groups ?? []],
370
+ claims: { ...mergedConfig?.claims }
371
+ });
372
+ const committedTargetingProps = react.useRef({
373
+ groups: [...mergedConfig?.groups ?? []],
374
+ claims: { ...mergedConfig?.claims }
375
+ });
376
+ const flagsRef = react.useRef(serverContext?.flags ?? mergedConfig?.featureDefaults ?? {});
377
+ const variantsRef = react.useRef(null);
378
+ const generationRef = react.useRef(0);
379
+ const updateRevisionRef = react.useRef(0);
380
+ const [telemetry] = react.useState(() => createBrowserTelemetry({ ...config, ...contextRef.current }));
381
+ const mountedRef = react.useRef(true);
382
+ react.useEffect(() => {
383
+ mountedRef.current = true;
384
+ const transport = JSON.stringify([
385
+ config?.metricsBaseUrl ?? "https://metrics.toggly.io",
386
+ config?.telemetryFlushIntervalMs ?? 45e3,
387
+ config?.enableTelemetry !== false,
388
+ config?.enableUsageTracking !== false,
389
+ config?.enableMetrics !== false
390
+ ]);
391
+ const previous = committedOwner.current;
392
+ if (previous && previous.telemetry !== telemetry) {
393
+ previous.telemetry.dispose({ flush: previous.transport === transport && previous.fetch === config?.telemetryFetch });
394
+ }
395
+ committedOwner.current = { telemetry, transport, fetch: config?.telemetryFetch };
396
+ telemetry.activate();
397
+ return () => {
398
+ mountedRef.current = false;
399
+ generationRef.current++;
400
+ queueMicrotask(() => {
401
+ if (!mountedRef.current) {
402
+ if (committedOwner.current?.telemetry === telemetry) committedOwner.current = void 0;
403
+ telemetry.dispose();
404
+ }
405
+ });
406
+ };
407
+ }, [telemetry]);
164
408
  const [flags, setFlags] = react.useState(
165
409
  serverContext?.flags ?? mergedConfig?.featureDefaults ?? {}
166
410
  );
@@ -175,23 +419,24 @@ function TogglyProvider({
175
419
  togglyLocalGates.buildFlagGateIndex(localGatesRef.current)
176
420
  );
177
421
  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
- );
422
+ const captureEvaluation = react.useCallback((featureKeys) => {
423
+ const selectedKeys = new Set(featureKeys);
424
+ const selectedFlags = Object.fromEntries([...selectedKeys].map((key) => [key, flagsRef.current[key]]));
425
+ const capturedFlags = JSON.parse(JSON.stringify(selectedFlags));
426
+ const applicableGates = localGatesRef.current.filter((gate) => gate.flagKeys.some((key) => selectedKeys.has(key)));
427
+ const index = togglyLocalGates.buildFlagGateIndex(applicableGates);
428
+ const gates = [...new Set(index.values())].map((id) => {
429
+ const gate = localGatesRef.current.find((candidate) => candidate.id === id);
430
+ return { ...gate, flagKeys: [...gate.flagKeys] };
431
+ });
432
+ const record = telemetry.captureCheck();
433
+ return (featureKey, defaultValue = false, entityContext) => {
434
+ const remote = isFeatureEnabled(capturedFlags, featureKey, defaultValue, entityContext);
435
+ const result = togglyLocalGates.applyLocalGate(remote, featureKey, gates, index);
436
+ record(featureKey, result);
437
+ return result;
438
+ };
439
+ }, [flags, localGatesRevision, telemetry]);
195
440
  const registerContext2 = react.useCallback(
196
441
  (kind, mapper) => {
197
442
  togglyHooksTypes.registerContext(kind, mapper);
@@ -227,8 +472,9 @@ function TogglyProvider({
227
472
  }
228
473
  }, [mergedConfig?.localGates, setLocalGates]);
229
474
  const executeAfterRefresh = react.useCallback(
230
- async (newFlags) => {
475
+ async (newFlags, generation, revision) => {
231
476
  for (const hook of hooks) {
477
+ if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
232
478
  if (hook.afterRefresh) {
233
479
  try {
234
480
  await hook.afterRefresh(newFlags);
@@ -244,46 +490,66 @@ function TogglyProvider({
244
490
  [hooks, logger]
245
491
  );
246
492
  const fetchFlags = react.useCallback(
247
- async (userIdentity) => {
493
+ async (target = contextRef.current) => {
494
+ if (!mountedRef.current) return flagsRef.current;
248
495
  if (!mergedConfig?.appKey) {
249
496
  logger.debug("No appKey, using current flags.");
250
- return flags;
497
+ return flagsRef.current;
251
498
  }
252
499
  try {
253
- const url = buildDefinitionsUrl(mergedConfig, userIdentity);
254
- logger.debug(`Fetching flags from: ${url}`);
500
+ const parsedUrl = new URL(buildDefinitionsUrl(mergedConfig, target));
501
+ if (target.instanceId) {
502
+ const capturedKeys = [...parsedUrl.searchParams.keys()];
503
+ for (const key of capturedKeys) {
504
+ if (key === "u" || key === "userId" || key === "g" || key.startsWith("claim.")) parsedUrl.searchParams.delete(key);
505
+ }
506
+ parsedUrl.searchParams.set("i", target.instanceId);
507
+ }
508
+ const url = parsedUrl.toString();
509
+ logger.debug("Fetching feature flags");
255
510
  const response = await fetchWithTimeout(url, {}, mergedConfig.timeout);
256
511
  if (!response.ok) {
257
512
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
258
513
  }
259
514
  const payload = await response.json();
515
+ if (mergedConfig.enableVariants === true) {
516
+ const defs = parseVariantDefsPayload(payload);
517
+ variantsRef.current = defs;
518
+ const newFlags2 = variantDefsToFlags(defs);
519
+ logger.debug(`Fetched ${Object.keys(newFlags2).length} flags (variants).`);
520
+ return newFlags2;
521
+ }
522
+ variantsRef.current = null;
260
523
  const newFlags = payload && typeof payload === "object" ? payload : {};
261
524
  logger.debug(`Fetched ${Object.keys(newFlags).length} flags.`);
262
525
  return newFlags;
263
526
  } catch (error) {
264
527
  logger.warn("Failed to fetch flags:", error);
265
- return flags;
528
+ return flagsRef.current;
266
529
  }
267
530
  },
268
- [mergedConfig, flags, logger]
531
+ [mergedConfig, logger]
269
532
  );
270
533
  const updateFlags = react.useCallback(
271
- async (newFlags) => {
534
+ async (newFlags, generation, revision) => {
535
+ if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
536
+ flagsRef.current = newFlags;
272
537
  setFlags(newFlags);
273
- await executeAfterRefresh(newFlags);
274
- onFlagsChange?.(newFlags);
538
+ await executeAfterRefresh(newFlags, generation, revision);
539
+ if (mountedRef.current && generation === generationRef.current && revision === updateRevisionRef.current) onFlagsChange?.(newFlags);
275
540
  },
276
541
  [executeAfterRefresh, onFlagsChange]
277
542
  );
278
543
  const isEnabled = react.useCallback(
279
544
  (featureKey, defaultValue = false, entity, kind) => {
280
- return getEffectiveFlag(
545
+ const evaluate = captureEvaluation([featureKey]);
546
+ return evaluate(
281
547
  featureKey,
282
548
  defaultValue,
283
549
  togglyHooksTypes.normalizeEntityContext(entity, kind)
284
550
  );
285
551
  },
286
- [getEffectiveFlag]
552
+ [captureEvaluation]
287
553
  );
288
554
  const isDisabled = react.useCallback(
289
555
  (featureKey, defaultValue = true, entity, kind) => {
@@ -296,67 +562,135 @@ function TogglyProvider({
296
562
  if (featureKeys.length === 0) {
297
563
  return !negate;
298
564
  }
565
+ const selectedKeys = [...featureKeys];
566
+ const evaluate = captureEvaluation(selectedKeys);
299
567
  const entityContext = togglyHooksTypes.normalizeEntityContext(entity, kind);
300
568
  let result;
301
569
  if (requirement === "any") {
302
- result = featureKeys.some(
303
- (key) => getEffectiveFlag(key, false, entityContext)
570
+ result = selectedKeys.some(
571
+ (key) => evaluate(key, false, entityContext)
304
572
  );
305
573
  } else {
306
- result = featureKeys.every(
307
- (key) => getEffectiveFlag(key, false, entityContext)
574
+ result = selectedKeys.every(
575
+ (key) => evaluate(key, false, entityContext)
308
576
  );
309
577
  }
310
578
  return negate ? !result : result;
311
579
  },
312
- [getEffectiveFlag]
580
+ [captureEvaluation]
581
+ );
582
+ const getVariant = react.useCallback(
583
+ (featureKey) => {
584
+ if (!mergedConfig?.enableVariants) return null;
585
+ const entry = variantsRef.current?.[featureKey];
586
+ const enabled = togglyLocalGates.applyLocalGate(
587
+ entry?.enabled === true,
588
+ featureKey,
589
+ localGatesRef.current,
590
+ localGateIndexRef.current
591
+ );
592
+ const record = telemetry.captureCheck();
593
+ record(featureKey, enabled && entry?.variant ? entry.variant : "disabled");
594
+ if (!enabled || !entry?.variant) return null;
595
+ return { name: entry.variant, configurationValue: entry.configurationValue };
596
+ },
597
+ [mergedConfig?.enableVariants, localGatesRevision, telemetry, flags]
598
+ );
599
+ const getVariantValue = react.useCallback(
600
+ (featureKey, isT) => {
601
+ return decodeVariantValue(getVariant(featureKey)?.configurationValue, isT);
602
+ },
603
+ [getVariant]
313
604
  );
605
+ const installContext = react.useCallback((target) => {
606
+ contextRef.current = target;
607
+ telemetry.setContext(target);
608
+ flagsRef.current = mergedConfig?.featureDefaults ?? {};
609
+ variantsRef.current = null;
610
+ setFlags(flagsRef.current);
611
+ setIdentity(target.identity);
612
+ }, [mergedConfig?.featureDefaults, telemetry]);
613
+ const isCurrentGeneration = react.useCallback((generation) => mountedRef.current && generation === generationRef.current, []);
614
+ const completeIdentification = react.useCallback((generation) => {
615
+ if (isCurrentGeneration(generation)) setIsReady(true);
616
+ }, [isCurrentGeneration]);
617
+ const currentIdentifyHooks = react.useCallback(function* (generation, reverse = false) {
618
+ let index = reverse ? hooks.length - 1 : 0;
619
+ while (reverse ? index >= 0 : index < hooks.length) {
620
+ if (!isCurrentGeneration(generation)) return;
621
+ yield hooks[index];
622
+ index += reverse ? -1 : 1;
623
+ }
624
+ }, [hooks, isCurrentGeneration]);
314
625
  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
- }
626
+ async (newIdentity, context) => {
627
+ if (!mountedRef.current) return;
628
+ const generation = ++generationRef.current;
629
+ logger.debug("Identifying user");
630
+ for (const hook of currentIdentifyHooks(generation)) {
631
+ if (!hook.beforeIdentify) continue;
632
+ try {
633
+ await hook.beforeIdentify(newIdentity);
634
+ } catch (error) {
635
+ logger.error(`Error in hook "${hook.getMetadata().name}.beforeIdentify":`, error);
327
636
  }
328
637
  }
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
- }
638
+ if (!isCurrentGeneration(generation)) return;
639
+ const target = {
640
+ identity: newIdentity,
641
+ instanceId: context?.instanceId?.trim() || void 0,
642
+ groups: [...context?.groups ?? contextRef.current.groups],
643
+ claims: { ...context?.claims ?? contextRef.current.claims }
644
+ };
645
+ installContext(target);
646
+ const revision = ++updateRevisionRef.current;
647
+ await updateFlags(await fetchFlags(target), generation, revision);
648
+ for (const hook of currentIdentifyHooks(generation, true)) {
649
+ if (!hook.afterIdentify) continue;
650
+ try {
651
+ await hook.afterIdentify(newIdentity, void 0);
652
+ } catch (error) {
653
+ logger.error(`Error in hook "${hook.getMetadata().name}.afterIdentify":`, error);
343
654
  }
344
655
  }
345
- setIsReady(true);
656
+ completeIdentification(generation);
346
657
  },
347
- [hooks, fetchFlags, updateFlags, logger]
658
+ [hooks, fetchFlags, updateFlags, installContext, logger, isCurrentGeneration, completeIdentification, currentIdentifyHooks]
348
659
  );
660
+ useCommittedContextEffect(() => {
661
+ const previous = committedTargetingProps.current;
662
+ const groups = [...mergedConfig?.groups ?? []];
663
+ const claims = { ...mergedConfig?.claims };
664
+ const groupsChanged = previous.groups.length !== groups.length || groups.some((group, index) => group !== previous.groups[index]);
665
+ const claimKeys = Object.keys(claims);
666
+ const claimsChanged = Object.keys(previous.claims).length !== claimKeys.length || claimKeys.some((key) => claims[key] !== previous.claims[key]);
667
+ if (!groupsChanged && !claimsChanged) return;
668
+ committedTargetingProps.current = { groups, claims };
669
+ generationRef.current++;
670
+ updateRevisionRef.current++;
671
+ installContext({
672
+ ...contextRef.current,
673
+ groups: groupsChanged ? groups : contextRef.current.groups,
674
+ claims: claimsChanged ? claims : contextRef.current.claims
675
+ });
676
+ setIsReady(false);
677
+ }, [mergedConfig?.groups, mergedConfig?.claims, installContext]);
349
678
  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]);
679
+ if (!mountedRef.current) return;
680
+ const generation = ++generationRef.current;
681
+ const target = { ...contextRef.current, identity: void 0, instanceId: void 0 };
682
+ installContext(target);
683
+ const revision = ++updateRevisionRef.current;
684
+ await updateFlags(await fetchFlags(target), generation, revision);
685
+ if (mountedRef.current && generation === generationRef.current) setIsReady(true);
686
+ }, [fetchFlags, updateFlags, installContext]);
355
687
  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]);
688
+ if (!mountedRef.current) return;
689
+ const generation = generationRef.current;
690
+ const revision = ++updateRevisionRef.current;
691
+ await updateFlags(await fetchFlags(contextRef.current), generation, revision);
692
+ if (mountedRef.current && generation === generationRef.current && revision === updateRevisionRef.current) setIsReady(true);
693
+ }, [fetchFlags, updateFlags]);
360
694
  const addHook = react.useCallback(
361
695
  (hook) => {
362
696
  setHooks((currentHooks) => {
@@ -434,6 +768,7 @@ function TogglyProvider({
434
768
  if (typeof WebSocket === "undefined") {
435
769
  return;
436
770
  }
771
+ let live = true;
437
772
  function buildWebSocketUrl() {
438
773
  const baseUrl = mergedConfig.baseUrl;
439
774
  const wsUrl = baseUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
@@ -443,7 +778,7 @@ function TogglyProvider({
443
778
  return `${wsUrl.replace(/\/$/, "")}/${mergedConfig.appKey}/ws${query ? `?${query}` : ""}`;
444
779
  }
445
780
  function connect() {
446
- if (wsRef.current) {
781
+ if (!live || !mountedRef.current || wsRef.current) {
447
782
  return;
448
783
  }
449
784
  const wsUrl = buildWebSocketUrl();
@@ -452,11 +787,13 @@ function TogglyProvider({
452
787
  const socket = new WebSocket(wsUrl);
453
788
  wsRef.current = socket;
454
789
  socket.onopen = () => {
790
+ if (!live || wsRef.current !== socket) return;
455
791
  wsConnectedRef.current = true;
456
792
  lastFallbackRefreshRef.current = Date.now();
457
793
  logger.debug("WebSocket connected");
458
794
  };
459
795
  socket.onmessage = (event) => {
796
+ if (!live || wsRef.current !== socket) return;
460
797
  const text = typeof event.data === "string" ? event.data : "";
461
798
  try {
462
799
  const msg = JSON.parse(text);
@@ -474,12 +811,14 @@ function TogglyProvider({
474
811
  }
475
812
  };
476
813
  socket.onclose = () => {
814
+ if (!live || wsRef.current !== socket) return;
477
815
  wsConnectedRef.current = false;
478
816
  wsRef.current = null;
479
817
  logger.debug("WebSocket disconnected, reconnecting in 5s");
480
818
  scheduleReconnect();
481
819
  };
482
820
  socket.onerror = () => {
821
+ if (!live || wsRef.current !== socket) return;
483
822
  logger.warn("WebSocket error");
484
823
  };
485
824
  } catch (error) {
@@ -499,6 +838,7 @@ function TogglyProvider({
499
838
  }
500
839
  connect();
501
840
  return () => {
841
+ live = false;
502
842
  if (wsReconnectTimerRef.current) {
503
843
  clearTimeout(wsReconnectTimerRef.current);
504
844
  wsReconnectTimerRef.current = null;
@@ -517,7 +857,11 @@ function TogglyProvider({
517
857
  react.useEffect(() => {
518
858
  if (!serverContext && mergedConfig?.appKey) {
519
859
  logger.debug("No server context, initializing client-side");
520
- fetchFlags(identity).then((newFlags) => {
860
+ const generation = generationRef.current;
861
+ const revision = ++updateRevisionRef.current;
862
+ fetchFlags(contextRef.current).then((newFlags) => {
863
+ if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
864
+ flagsRef.current = newFlags;
521
865
  setFlags(newFlags);
522
866
  setIsReady(true);
523
867
  });
@@ -525,6 +869,7 @@ function TogglyProvider({
525
869
  }, []);
526
870
  const contextValue = react.useMemo(
527
871
  () => ({
872
+ ...telemetry.api,
528
873
  flags,
529
874
  isReady,
530
875
  identity,
@@ -532,6 +877,8 @@ function TogglyProvider({
532
877
  isDisabled,
533
878
  evaluateGate,
534
879
  registerContext: registerContext2,
880
+ getVariant,
881
+ getVariantValue,
535
882
  identify,
536
883
  reset,
537
884
  refresh,
@@ -542,6 +889,7 @@ function TogglyProvider({
542
889
  subscribeLocalGatesChanged
543
890
  }),
544
891
  [
892
+ telemetry,
545
893
  flags,
546
894
  isReady,
547
895
  identity,
@@ -549,6 +897,8 @@ function TogglyProvider({
549
897
  isDisabled,
550
898
  evaluateGate,
551
899
  registerContext2,
900
+ getVariant,
901
+ getVariantValue,
552
902
  identify,
553
903
  reset,
554
904
  refresh,
@@ -652,15 +1002,14 @@ function Feature({
652
1002
  contextKind
653
1003
  }) {
654
1004
  const keys = featureKeys ?? (featureKey ? [featureKey] : []);
655
- const singleEnabled = useFeature(keys[0] ?? "", defaultValue, context, contextKind);
656
- const gateEnabled = useFeatureGate(keys, requirement, false, context, contextKind);
1005
+ const { isEnabled, evaluateGate } = useTogglyContext();
657
1006
  let enabled;
658
1007
  if (keys.length === 0) {
659
1008
  enabled = defaultValue;
660
1009
  } else if (keys.length === 1) {
661
- enabled = singleEnabled;
1010
+ enabled = isEnabled(keys[0], defaultValue, context, contextKind);
662
1011
  } else {
663
- enabled = gateEnabled;
1012
+ enabled = evaluateGate(keys, requirement, false, context, contextKind);
664
1013
  }
665
1014
  if (negate) {
666
1015
  enabled = !enabled;