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