@schematichq/schematic-react 1.2.5 → 1.2.7

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023-2025 Schematic, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -134,6 +134,8 @@ const MyComponent = () => {
134
134
  };
135
135
  ```
136
136
 
137
+ *Note: `useSchematicIsPending` is checking if entitlement data has been loaded, typically via `identify`. It should, therefore, be used to wrap flag and entitlement checks, but never the initial call to `identify`.*
138
+
137
139
  ## Troubleshooting
138
140
 
139
141
  For debugging and development, Schematic supports two special modes:
@@ -72,20 +72,20 @@ var __toESM2 = (mod, isNodeMode, target) => (target = mod != null ? __create2(__
72
72
  var require_browser_polyfill = __commonJS({
73
73
  "node_modules/cross-fetch/dist/browser-polyfill.js"(exports) {
74
74
  (function(self2) {
75
- var irrelevant = function(exports2) {
75
+ var irrelevant = (function(exports2) {
76
76
  var g = typeof globalThis !== "undefined" && globalThis || typeof self2 !== "undefined" && self2 || // eslint-disable-next-line no-undef
77
77
  typeof global !== "undefined" && global || {};
78
78
  var support = {
79
79
  searchParams: "URLSearchParams" in g,
80
80
  iterable: "Symbol" in g && "iterator" in Symbol,
81
- blob: "FileReader" in g && "Blob" in g && function() {
81
+ blob: "FileReader" in g && "Blob" in g && (function() {
82
82
  try {
83
83
  new Blob();
84
84
  return true;
85
85
  } catch (e) {
86
86
  return false;
87
87
  }
88
- }(),
88
+ })(),
89
89
  formData: "FormData" in g,
90
90
  arrayBuffer: "ArrayBuffer" in g
91
91
  };
@@ -387,12 +387,12 @@ var require_browser_polyfill = __commonJS({
387
387
  }
388
388
  this.method = normalizeMethod(options.method || this.method || "GET");
389
389
  this.mode = options.mode || this.mode || null;
390
- this.signal = options.signal || this.signal || function() {
390
+ this.signal = options.signal || this.signal || (function() {
391
391
  if ("AbortController" in g) {
392
392
  var ctrl = new AbortController();
393
393
  return ctrl.signal;
394
394
  }
395
- }();
395
+ })();
396
396
  this.referrer = null;
397
397
  if ((this.method === "GET" || this.method === "HEAD") && body) {
398
398
  throw new TypeError("Body not allowed for GET or HEAD requests");
@@ -599,7 +599,7 @@ var require_browser_polyfill = __commonJS({
599
599
  exports2.Response = Response;
600
600
  exports2.fetch = fetch2;
601
601
  return exports2;
602
- }({});
602
+ })({});
603
603
  })(typeof self !== "undefined" ? self : exports);
604
604
  }
605
605
  });
@@ -623,10 +623,7 @@ function rng() {
623
623
  }
624
624
  var randomUUID = typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID.bind(crypto);
625
625
  var native_default = { randomUUID };
626
- function v4(options, buf, offset) {
627
- if (native_default.randomUUID && !buf && !options) {
628
- return native_default.randomUUID();
629
- }
626
+ function _v4(options, buf, offset) {
630
627
  options = options || {};
631
628
  const rnds = options.random ?? options.rng?.() ?? rng();
632
629
  if (rnds.length < 16) {
@@ -646,6 +643,12 @@ function v4(options, buf, offset) {
646
643
  }
647
644
  return unsafeStringify(rnds);
648
645
  }
646
+ function v4(options, buf, offset) {
647
+ if (native_default.randomUUID && !buf && !options) {
648
+ return native_default.randomUUID();
649
+ }
650
+ return _v4(options, buf, offset);
651
+ }
649
652
  var v4_default = v4;
650
653
  var import_polyfill = __toESM2(require_browser_polyfill());
651
654
  function CheckFlagResponseDataFromJSON(json) {
@@ -796,7 +799,7 @@ function contextString(context) {
796
799
  }, {});
797
800
  return JSON.stringify(sortedContext);
798
801
  }
799
- var version = "1.2.3";
802
+ var version = "1.2.7";
800
803
  var anonymousIdKey = "schematicId";
801
804
  var Schematic = class {
802
805
  additionalHeaders = {};
@@ -807,6 +810,7 @@ var Schematic = class {
807
810
  debugEnabled = false;
808
811
  offlineEnabled = false;
809
812
  eventQueue;
813
+ contextDependentEventQueue;
810
814
  eventUrl = "https://c.schematichq.com";
811
815
  flagCheckListeners = {};
812
816
  flagValueListeners = {};
@@ -817,9 +821,18 @@ var Schematic = class {
817
821
  checks = {};
818
822
  featureUsageEventMap = {};
819
823
  webSocketUrl = "wss://api.schematichq.com";
824
+ webSocketConnectionTimeout = 1e4;
825
+ webSocketReconnect = true;
826
+ webSocketMaxReconnectAttempts = 7;
827
+ webSocketInitialRetryDelay = 1e3;
828
+ webSocketMaxRetryDelay = 3e4;
829
+ wsReconnectAttempts = 0;
830
+ wsReconnectTimer = null;
831
+ wsIntentionalDisconnect = false;
820
832
  constructor(apiKey, options) {
821
833
  this.apiKey = apiKey;
822
834
  this.eventQueue = [];
835
+ this.contextDependentEventQueue = [];
823
836
  this.useWebSocket = options?.useWebSocket ?? false;
824
837
  this.debugEnabled = options?.debug ?? false;
825
838
  this.offlineEnabled = options?.offline ?? false;
@@ -859,10 +872,36 @@ var Schematic = class {
859
872
  if (options?.webSocketUrl !== void 0) {
860
873
  this.webSocketUrl = options.webSocketUrl;
861
874
  }
875
+ if (options?.webSocketConnectionTimeout !== void 0) {
876
+ this.webSocketConnectionTimeout = options.webSocketConnectionTimeout;
877
+ }
878
+ if (options?.webSocketReconnect !== void 0) {
879
+ this.webSocketReconnect = options.webSocketReconnect;
880
+ }
881
+ if (options?.webSocketMaxReconnectAttempts !== void 0) {
882
+ this.webSocketMaxReconnectAttempts = options.webSocketMaxReconnectAttempts;
883
+ }
884
+ if (options?.webSocketInitialRetryDelay !== void 0) {
885
+ this.webSocketInitialRetryDelay = options.webSocketInitialRetryDelay;
886
+ }
887
+ if (options?.webSocketMaxRetryDelay !== void 0) {
888
+ this.webSocketMaxRetryDelay = options.webSocketMaxRetryDelay;
889
+ }
862
890
  if (typeof window !== "undefined" && window?.addEventListener) {
863
891
  window.addEventListener("beforeunload", () => {
864
892
  this.flushEventQueue();
893
+ this.flushContextDependentEventQueue();
865
894
  });
895
+ if (this.useWebSocket) {
896
+ window.addEventListener("offline", () => {
897
+ this.debug("Browser went offline, closing WebSocket connection");
898
+ this.handleNetworkOffline();
899
+ });
900
+ window.addEventListener("online", () => {
901
+ this.debug("Browser came online, attempting to reconnect WebSocket");
902
+ this.handleNetworkOnline();
903
+ });
904
+ }
866
905
  }
867
906
  if (this.offlineEnabled) {
868
907
  this.debug(
@@ -1120,12 +1159,20 @@ var Schematic = class {
1120
1159
  setContext = async (context) => {
1121
1160
  if (this.isOffline() || !this.useWebSocket) {
1122
1161
  this.context = context;
1162
+ this.flushContextDependentEventQueue();
1123
1163
  this.setIsPending(false);
1124
1164
  return Promise.resolve();
1125
1165
  }
1126
1166
  try {
1127
1167
  this.setIsPending(true);
1128
1168
  if (!this.conn) {
1169
+ if (this.wsReconnectTimer !== null) {
1170
+ this.debug(
1171
+ `Cancelling scheduled reconnection, connecting immediately`
1172
+ );
1173
+ clearTimeout(this.wsReconnectTimer);
1174
+ this.wsReconnectTimer = null;
1175
+ }
1129
1176
  this.conn = this.wsConnect();
1130
1177
  }
1131
1178
  const socket = await this.conn;
@@ -1142,6 +1189,25 @@ var Schematic = class {
1142
1189
  */
1143
1190
  track = (body) => {
1144
1191
  const { company, user, event, traits, quantity = 1 } = body;
1192
+ if (!this.hasContext(company, user)) {
1193
+ this.debug(`track: queuing event "${event}" until context is available`);
1194
+ const queuedEvent = {
1195
+ api_key: this.apiKey,
1196
+ body: {
1197
+ company,
1198
+ event,
1199
+ traits: traits ?? {},
1200
+ user,
1201
+ quantity
1202
+ },
1203
+ sent_at: (/* @__PURE__ */ new Date()).toISOString(),
1204
+ tracker_event_id: v4_default(),
1205
+ tracker_user_id: this.getAnonymousId(),
1206
+ type: "track"
1207
+ };
1208
+ this.contextDependentEventQueue.push(queuedEvent);
1209
+ return Promise.resolve();
1210
+ }
1145
1211
  const trackData = {
1146
1212
  company: company ?? this.context.company,
1147
1213
  event,
@@ -1204,6 +1270,38 @@ var Schematic = class {
1204
1270
  /**
1205
1271
  * Event processing
1206
1272
  */
1273
+ hasContext = (company, user) => {
1274
+ const hasProvidedContext = company !== void 0 && company !== null && Object.keys(company).length > 0 || user !== void 0 && user !== null && Object.keys(user).length > 0;
1275
+ const hasInstanceContext = this.context.company !== void 0 && this.context.company !== null && Object.keys(this.context.company).length > 0 || this.context.user !== void 0 && this.context.user !== null && Object.keys(this.context.user).length > 0;
1276
+ return hasProvidedContext || hasInstanceContext;
1277
+ };
1278
+ flushContextDependentEventQueue = () => {
1279
+ this.debug(
1280
+ `flushing ${this.contextDependentEventQueue.length} context-dependent events`
1281
+ );
1282
+ while (this.contextDependentEventQueue.length > 0) {
1283
+ const event = this.contextDependentEventQueue.shift();
1284
+ if (event) {
1285
+ if (event.type === "track" && typeof event.body === "object" && event.body !== null) {
1286
+ const trackBody = event.body;
1287
+ const updatedBody = {
1288
+ ...trackBody,
1289
+ company: trackBody.company ?? this.context.company,
1290
+ user: trackBody.user ?? this.context.user
1291
+ };
1292
+ const updatedEvent = {
1293
+ ...event,
1294
+ body: updatedBody,
1295
+ sent_at: (/* @__PURE__ */ new Date()).toISOString()
1296
+ // Update timestamp to actual send time
1297
+ };
1298
+ this.sendEvent(updatedEvent);
1299
+ } else {
1300
+ this.sendEvent(event);
1301
+ }
1302
+ }
1303
+ }
1304
+ };
1207
1305
  flushEventQueue = () => {
1208
1306
  while (this.eventQueue.length > 0) {
1209
1307
  const event = this.eventQueue.shift();
@@ -1233,7 +1331,7 @@ var Schematic = class {
1233
1331
  tracker_user_id: this.getAnonymousId(),
1234
1332
  type: eventType
1235
1333
  };
1236
- if (document?.hidden) {
1334
+ if (typeof document !== "undefined" && document?.hidden) {
1237
1335
  return this.storeEvent(event);
1238
1336
  } else {
1239
1337
  return this.sendEvent(event);
@@ -1281,6 +1379,11 @@ var Schematic = class {
1281
1379
  this.debug("cleanup: skipped (offline mode)");
1282
1380
  return Promise.resolve();
1283
1381
  }
1382
+ this.wsIntentionalDisconnect = true;
1383
+ if (this.wsReconnectTimer !== null) {
1384
+ clearTimeout(this.wsReconnectTimer);
1385
+ this.wsReconnectTimer = null;
1386
+ }
1284
1387
  if (this.conn) {
1285
1388
  try {
1286
1389
  const socket = await this.conn;
@@ -1292,6 +1395,91 @@ var Schematic = class {
1292
1395
  }
1293
1396
  }
1294
1397
  };
1398
+ /**
1399
+ * Calculate the delay for the next reconnection attempt using exponential backoff with jitter.
1400
+ * This helps prevent dogpiling when the server recovers from an outage.
1401
+ */
1402
+ calculateReconnectDelay = () => {
1403
+ const exponentialDelay = this.webSocketInitialRetryDelay * Math.pow(2, this.wsReconnectAttempts);
1404
+ const cappedDelay = Math.min(exponentialDelay, this.webSocketMaxRetryDelay);
1405
+ const jitter = Math.random() * cappedDelay * 0.5;
1406
+ const totalDelay = cappedDelay + jitter;
1407
+ this.debug(
1408
+ `Reconnect delay calculated: ${totalDelay.toFixed(0)}ms (attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts})`
1409
+ );
1410
+ return totalDelay;
1411
+ };
1412
+ /**
1413
+ * Handle browser going offline
1414
+ */
1415
+ handleNetworkOffline = async () => {
1416
+ if (this.conn !== null) {
1417
+ try {
1418
+ const socket = await this.conn;
1419
+ socket.close();
1420
+ } catch (error) {
1421
+ this.debug("Error closing connection on offline:", error);
1422
+ }
1423
+ this.conn = null;
1424
+ }
1425
+ if (this.wsReconnectTimer !== null) {
1426
+ clearTimeout(this.wsReconnectTimer);
1427
+ this.wsReconnectTimer = null;
1428
+ }
1429
+ };
1430
+ /**
1431
+ * Handle browser coming back online
1432
+ */
1433
+ handleNetworkOnline = () => {
1434
+ if (this.context.company === void 0 && this.context.user === void 0) {
1435
+ this.debug("No context set, skipping reconnection");
1436
+ return;
1437
+ }
1438
+ this.wsReconnectAttempts = 0;
1439
+ if (this.wsReconnectTimer !== null) {
1440
+ clearTimeout(this.wsReconnectTimer);
1441
+ this.wsReconnectTimer = null;
1442
+ }
1443
+ this.debug("Network online, reconnecting immediately");
1444
+ this.attemptReconnect();
1445
+ };
1446
+ /**
1447
+ * Attempt to reconnect the WebSocket connection with exponential backoff.
1448
+ * Called automatically when the connection closes unexpectedly.
1449
+ */
1450
+ attemptReconnect = () => {
1451
+ if (this.wsReconnectAttempts >= this.webSocketMaxReconnectAttempts) {
1452
+ this.debug(
1453
+ `Maximum reconnection attempts (${this.webSocketMaxReconnectAttempts}) reached, giving up`
1454
+ );
1455
+ return;
1456
+ }
1457
+ if (this.wsReconnectTimer !== null) {
1458
+ clearTimeout(this.wsReconnectTimer);
1459
+ }
1460
+ const delay = this.calculateReconnectDelay();
1461
+ this.debug(
1462
+ `Scheduling reconnection attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts} in ${delay.toFixed(0)}ms`
1463
+ );
1464
+ this.wsReconnectTimer = setTimeout(async () => {
1465
+ this.wsReconnectTimer = null;
1466
+ this.wsReconnectAttempts++;
1467
+ this.debug(
1468
+ `Attempting to reconnect (attempt ${this.wsReconnectAttempts}/${this.webSocketMaxReconnectAttempts})`
1469
+ );
1470
+ try {
1471
+ this.conn = this.wsConnect();
1472
+ const socket = await this.conn;
1473
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1474
+ this.debug(`Reconnected, re-sending context`);
1475
+ await this.wsSendMessage(socket, this.context);
1476
+ }
1477
+ this.debug(`Reconnection successful`);
1478
+ } catch (error) {
1479
+ this.debug(`Reconnection attempt failed:`, error);
1480
+ }
1481
+ }, delay);
1482
+ };
1295
1483
  // Open a websocket connection
1296
1484
  wsConnect = () => {
1297
1485
  if (this.isOffline()) {
@@ -1304,17 +1492,45 @@ var Schematic = class {
1304
1492
  const wsUrl = `${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;
1305
1493
  this.debug(`connecting to WebSocket:`, wsUrl);
1306
1494
  const webSocket = new WebSocket(wsUrl);
1495
+ let timeoutId = null;
1496
+ let isResolved = false;
1497
+ timeoutId = setTimeout(() => {
1498
+ if (!isResolved) {
1499
+ this.debug(
1500
+ `WebSocket connection timeout after ${this.webSocketConnectionTimeout}ms`
1501
+ );
1502
+ webSocket.close();
1503
+ reject(new Error("WebSocket connection timeout"));
1504
+ }
1505
+ }, this.webSocketConnectionTimeout);
1307
1506
  webSocket.onopen = () => {
1507
+ isResolved = true;
1508
+ if (timeoutId !== null) {
1509
+ clearTimeout(timeoutId);
1510
+ }
1511
+ this.wsReconnectAttempts = 0;
1512
+ this.wsIntentionalDisconnect = false;
1308
1513
  this.debug(`WebSocket connection opened`);
1309
1514
  resolve(webSocket);
1310
1515
  };
1311
1516
  webSocket.onerror = (error) => {
1517
+ isResolved = true;
1518
+ if (timeoutId !== null) {
1519
+ clearTimeout(timeoutId);
1520
+ }
1312
1521
  this.debug(`WebSocket connection error:`, error);
1313
1522
  reject(error);
1314
1523
  };
1315
1524
  webSocket.onclose = () => {
1525
+ isResolved = true;
1526
+ if (timeoutId !== null) {
1527
+ clearTimeout(timeoutId);
1528
+ }
1316
1529
  this.debug(`WebSocket connection closed`);
1317
1530
  this.conn = null;
1531
+ if (!this.wsIntentionalDisconnect && this.webSocketReconnect) {
1532
+ this.attemptReconnect();
1533
+ }
1318
1534
  };
1319
1535
  });
1320
1536
  };
@@ -1362,6 +1578,7 @@ var Schematic = class {
1362
1578
  this.notifyFlagCheckListeners(flag.flag, flagCheck);
1363
1579
  this.notifyFlagValueListeners(flag.flag, flagCheck.value);
1364
1580
  });
1581
+ this.flushContextDependentEventQueue();
1365
1582
  this.setIsPending(false);
1366
1583
  if (!resolved) {
1367
1584
  resolved = true;
@@ -1505,7 +1722,7 @@ var notifyFlagValueListener = (listener, value) => {
1505
1722
  var import_react = __toESM(require("react"));
1506
1723
 
1507
1724
  // src/version.ts
1508
- var version2 = "1.2.5";
1725
+ var version2 = "1.2.7";
1509
1726
 
1510
1727
  // src/context/schematic.tsx
1511
1728
  var import_jsx_runtime = require("react/jsx-runtime");
@@ -11,6 +11,7 @@ import { Schematic } from '@schematichq/schematic-js';
11
11
  import { SchematicContext } from '@schematichq/schematic-js';
12
12
  import * as SchematicJS from '@schematichq/schematic-js';
13
13
  import { SchematicOptions } from '@schematichq/schematic-js';
14
+ import { StoragePersister } from '@schematichq/schematic-js';
14
15
  import { Traits } from '@schematichq/schematic-js';
15
16
  import { UsagePeriod } from '@schematichq/schematic-js';
16
17
 
@@ -62,6 +63,8 @@ declare type SchematicProviderPropsWithPublishableKey = BaseSchematicProviderPro
62
63
  publishableKey: string;
63
64
  };
64
65
 
66
+ export { StoragePersister }
67
+
65
68
  export { Traits }
66
69
 
67
70
  export { UsagePeriod }
@@ -27,20 +27,20 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
27
27
  var require_browser_polyfill = __commonJS({
28
28
  "node_modules/cross-fetch/dist/browser-polyfill.js"(exports) {
29
29
  (function(self2) {
30
- var irrelevant = function(exports2) {
30
+ var irrelevant = (function(exports2) {
31
31
  var g = typeof globalThis !== "undefined" && globalThis || typeof self2 !== "undefined" && self2 || // eslint-disable-next-line no-undef
32
32
  typeof global !== "undefined" && global || {};
33
33
  var support = {
34
34
  searchParams: "URLSearchParams" in g,
35
35
  iterable: "Symbol" in g && "iterator" in Symbol,
36
- blob: "FileReader" in g && "Blob" in g && function() {
36
+ blob: "FileReader" in g && "Blob" in g && (function() {
37
37
  try {
38
38
  new Blob();
39
39
  return true;
40
40
  } catch (e) {
41
41
  return false;
42
42
  }
43
- }(),
43
+ })(),
44
44
  formData: "FormData" in g,
45
45
  arrayBuffer: "ArrayBuffer" in g
46
46
  };
@@ -342,12 +342,12 @@ var require_browser_polyfill = __commonJS({
342
342
  }
343
343
  this.method = normalizeMethod(options.method || this.method || "GET");
344
344
  this.mode = options.mode || this.mode || null;
345
- this.signal = options.signal || this.signal || function() {
345
+ this.signal = options.signal || this.signal || (function() {
346
346
  if ("AbortController" in g) {
347
347
  var ctrl = new AbortController();
348
348
  return ctrl.signal;
349
349
  }
350
- }();
350
+ })();
351
351
  this.referrer = null;
352
352
  if ((this.method === "GET" || this.method === "HEAD") && body) {
353
353
  throw new TypeError("Body not allowed for GET or HEAD requests");
@@ -554,7 +554,7 @@ var require_browser_polyfill = __commonJS({
554
554
  exports2.Response = Response;
555
555
  exports2.fetch = fetch2;
556
556
  return exports2;
557
- }({});
557
+ })({});
558
558
  })(typeof self !== "undefined" ? self : exports);
559
559
  }
560
560
  });
@@ -578,10 +578,7 @@ function rng() {
578
578
  }
579
579
  var randomUUID = typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID.bind(crypto);
580
580
  var native_default = { randomUUID };
581
- function v4(options, buf, offset) {
582
- if (native_default.randomUUID && !buf && !options) {
583
- return native_default.randomUUID();
584
- }
581
+ function _v4(options, buf, offset) {
585
582
  options = options || {};
586
583
  const rnds = options.random ?? options.rng?.() ?? rng();
587
584
  if (rnds.length < 16) {
@@ -601,6 +598,12 @@ function v4(options, buf, offset) {
601
598
  }
602
599
  return unsafeStringify(rnds);
603
600
  }
601
+ function v4(options, buf, offset) {
602
+ if (native_default.randomUUID && !buf && !options) {
603
+ return native_default.randomUUID();
604
+ }
605
+ return _v4(options, buf, offset);
606
+ }
604
607
  var v4_default = v4;
605
608
  var import_polyfill = __toESM(require_browser_polyfill());
606
609
  function CheckFlagResponseDataFromJSON(json) {
@@ -751,7 +754,7 @@ function contextString(context) {
751
754
  }, {});
752
755
  return JSON.stringify(sortedContext);
753
756
  }
754
- var version = "1.2.3";
757
+ var version = "1.2.7";
755
758
  var anonymousIdKey = "schematicId";
756
759
  var Schematic = class {
757
760
  additionalHeaders = {};
@@ -762,6 +765,7 @@ var Schematic = class {
762
765
  debugEnabled = false;
763
766
  offlineEnabled = false;
764
767
  eventQueue;
768
+ contextDependentEventQueue;
765
769
  eventUrl = "https://c.schematichq.com";
766
770
  flagCheckListeners = {};
767
771
  flagValueListeners = {};
@@ -772,9 +776,18 @@ var Schematic = class {
772
776
  checks = {};
773
777
  featureUsageEventMap = {};
774
778
  webSocketUrl = "wss://api.schematichq.com";
779
+ webSocketConnectionTimeout = 1e4;
780
+ webSocketReconnect = true;
781
+ webSocketMaxReconnectAttempts = 7;
782
+ webSocketInitialRetryDelay = 1e3;
783
+ webSocketMaxRetryDelay = 3e4;
784
+ wsReconnectAttempts = 0;
785
+ wsReconnectTimer = null;
786
+ wsIntentionalDisconnect = false;
775
787
  constructor(apiKey, options) {
776
788
  this.apiKey = apiKey;
777
789
  this.eventQueue = [];
790
+ this.contextDependentEventQueue = [];
778
791
  this.useWebSocket = options?.useWebSocket ?? false;
779
792
  this.debugEnabled = options?.debug ?? false;
780
793
  this.offlineEnabled = options?.offline ?? false;
@@ -814,10 +827,36 @@ var Schematic = class {
814
827
  if (options?.webSocketUrl !== void 0) {
815
828
  this.webSocketUrl = options.webSocketUrl;
816
829
  }
830
+ if (options?.webSocketConnectionTimeout !== void 0) {
831
+ this.webSocketConnectionTimeout = options.webSocketConnectionTimeout;
832
+ }
833
+ if (options?.webSocketReconnect !== void 0) {
834
+ this.webSocketReconnect = options.webSocketReconnect;
835
+ }
836
+ if (options?.webSocketMaxReconnectAttempts !== void 0) {
837
+ this.webSocketMaxReconnectAttempts = options.webSocketMaxReconnectAttempts;
838
+ }
839
+ if (options?.webSocketInitialRetryDelay !== void 0) {
840
+ this.webSocketInitialRetryDelay = options.webSocketInitialRetryDelay;
841
+ }
842
+ if (options?.webSocketMaxRetryDelay !== void 0) {
843
+ this.webSocketMaxRetryDelay = options.webSocketMaxRetryDelay;
844
+ }
817
845
  if (typeof window !== "undefined" && window?.addEventListener) {
818
846
  window.addEventListener("beforeunload", () => {
819
847
  this.flushEventQueue();
848
+ this.flushContextDependentEventQueue();
820
849
  });
850
+ if (this.useWebSocket) {
851
+ window.addEventListener("offline", () => {
852
+ this.debug("Browser went offline, closing WebSocket connection");
853
+ this.handleNetworkOffline();
854
+ });
855
+ window.addEventListener("online", () => {
856
+ this.debug("Browser came online, attempting to reconnect WebSocket");
857
+ this.handleNetworkOnline();
858
+ });
859
+ }
821
860
  }
822
861
  if (this.offlineEnabled) {
823
862
  this.debug(
@@ -1075,12 +1114,20 @@ var Schematic = class {
1075
1114
  setContext = async (context) => {
1076
1115
  if (this.isOffline() || !this.useWebSocket) {
1077
1116
  this.context = context;
1117
+ this.flushContextDependentEventQueue();
1078
1118
  this.setIsPending(false);
1079
1119
  return Promise.resolve();
1080
1120
  }
1081
1121
  try {
1082
1122
  this.setIsPending(true);
1083
1123
  if (!this.conn) {
1124
+ if (this.wsReconnectTimer !== null) {
1125
+ this.debug(
1126
+ `Cancelling scheduled reconnection, connecting immediately`
1127
+ );
1128
+ clearTimeout(this.wsReconnectTimer);
1129
+ this.wsReconnectTimer = null;
1130
+ }
1084
1131
  this.conn = this.wsConnect();
1085
1132
  }
1086
1133
  const socket = await this.conn;
@@ -1097,6 +1144,25 @@ var Schematic = class {
1097
1144
  */
1098
1145
  track = (body) => {
1099
1146
  const { company, user, event, traits, quantity = 1 } = body;
1147
+ if (!this.hasContext(company, user)) {
1148
+ this.debug(`track: queuing event "${event}" until context is available`);
1149
+ const queuedEvent = {
1150
+ api_key: this.apiKey,
1151
+ body: {
1152
+ company,
1153
+ event,
1154
+ traits: traits ?? {},
1155
+ user,
1156
+ quantity
1157
+ },
1158
+ sent_at: (/* @__PURE__ */ new Date()).toISOString(),
1159
+ tracker_event_id: v4_default(),
1160
+ tracker_user_id: this.getAnonymousId(),
1161
+ type: "track"
1162
+ };
1163
+ this.contextDependentEventQueue.push(queuedEvent);
1164
+ return Promise.resolve();
1165
+ }
1100
1166
  const trackData = {
1101
1167
  company: company ?? this.context.company,
1102
1168
  event,
@@ -1159,6 +1225,38 @@ var Schematic = class {
1159
1225
  /**
1160
1226
  * Event processing
1161
1227
  */
1228
+ hasContext = (company, user) => {
1229
+ const hasProvidedContext = company !== void 0 && company !== null && Object.keys(company).length > 0 || user !== void 0 && user !== null && Object.keys(user).length > 0;
1230
+ const hasInstanceContext = this.context.company !== void 0 && this.context.company !== null && Object.keys(this.context.company).length > 0 || this.context.user !== void 0 && this.context.user !== null && Object.keys(this.context.user).length > 0;
1231
+ return hasProvidedContext || hasInstanceContext;
1232
+ };
1233
+ flushContextDependentEventQueue = () => {
1234
+ this.debug(
1235
+ `flushing ${this.contextDependentEventQueue.length} context-dependent events`
1236
+ );
1237
+ while (this.contextDependentEventQueue.length > 0) {
1238
+ const event = this.contextDependentEventQueue.shift();
1239
+ if (event) {
1240
+ if (event.type === "track" && typeof event.body === "object" && event.body !== null) {
1241
+ const trackBody = event.body;
1242
+ const updatedBody = {
1243
+ ...trackBody,
1244
+ company: trackBody.company ?? this.context.company,
1245
+ user: trackBody.user ?? this.context.user
1246
+ };
1247
+ const updatedEvent = {
1248
+ ...event,
1249
+ body: updatedBody,
1250
+ sent_at: (/* @__PURE__ */ new Date()).toISOString()
1251
+ // Update timestamp to actual send time
1252
+ };
1253
+ this.sendEvent(updatedEvent);
1254
+ } else {
1255
+ this.sendEvent(event);
1256
+ }
1257
+ }
1258
+ }
1259
+ };
1162
1260
  flushEventQueue = () => {
1163
1261
  while (this.eventQueue.length > 0) {
1164
1262
  const event = this.eventQueue.shift();
@@ -1188,7 +1286,7 @@ var Schematic = class {
1188
1286
  tracker_user_id: this.getAnonymousId(),
1189
1287
  type: eventType
1190
1288
  };
1191
- if (document?.hidden) {
1289
+ if (typeof document !== "undefined" && document?.hidden) {
1192
1290
  return this.storeEvent(event);
1193
1291
  } else {
1194
1292
  return this.sendEvent(event);
@@ -1236,6 +1334,11 @@ var Schematic = class {
1236
1334
  this.debug("cleanup: skipped (offline mode)");
1237
1335
  return Promise.resolve();
1238
1336
  }
1337
+ this.wsIntentionalDisconnect = true;
1338
+ if (this.wsReconnectTimer !== null) {
1339
+ clearTimeout(this.wsReconnectTimer);
1340
+ this.wsReconnectTimer = null;
1341
+ }
1239
1342
  if (this.conn) {
1240
1343
  try {
1241
1344
  const socket = await this.conn;
@@ -1247,6 +1350,91 @@ var Schematic = class {
1247
1350
  }
1248
1351
  }
1249
1352
  };
1353
+ /**
1354
+ * Calculate the delay for the next reconnection attempt using exponential backoff with jitter.
1355
+ * This helps prevent dogpiling when the server recovers from an outage.
1356
+ */
1357
+ calculateReconnectDelay = () => {
1358
+ const exponentialDelay = this.webSocketInitialRetryDelay * Math.pow(2, this.wsReconnectAttempts);
1359
+ const cappedDelay = Math.min(exponentialDelay, this.webSocketMaxRetryDelay);
1360
+ const jitter = Math.random() * cappedDelay * 0.5;
1361
+ const totalDelay = cappedDelay + jitter;
1362
+ this.debug(
1363
+ `Reconnect delay calculated: ${totalDelay.toFixed(0)}ms (attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts})`
1364
+ );
1365
+ return totalDelay;
1366
+ };
1367
+ /**
1368
+ * Handle browser going offline
1369
+ */
1370
+ handleNetworkOffline = async () => {
1371
+ if (this.conn !== null) {
1372
+ try {
1373
+ const socket = await this.conn;
1374
+ socket.close();
1375
+ } catch (error) {
1376
+ this.debug("Error closing connection on offline:", error);
1377
+ }
1378
+ this.conn = null;
1379
+ }
1380
+ if (this.wsReconnectTimer !== null) {
1381
+ clearTimeout(this.wsReconnectTimer);
1382
+ this.wsReconnectTimer = null;
1383
+ }
1384
+ };
1385
+ /**
1386
+ * Handle browser coming back online
1387
+ */
1388
+ handleNetworkOnline = () => {
1389
+ if (this.context.company === void 0 && this.context.user === void 0) {
1390
+ this.debug("No context set, skipping reconnection");
1391
+ return;
1392
+ }
1393
+ this.wsReconnectAttempts = 0;
1394
+ if (this.wsReconnectTimer !== null) {
1395
+ clearTimeout(this.wsReconnectTimer);
1396
+ this.wsReconnectTimer = null;
1397
+ }
1398
+ this.debug("Network online, reconnecting immediately");
1399
+ this.attemptReconnect();
1400
+ };
1401
+ /**
1402
+ * Attempt to reconnect the WebSocket connection with exponential backoff.
1403
+ * Called automatically when the connection closes unexpectedly.
1404
+ */
1405
+ attemptReconnect = () => {
1406
+ if (this.wsReconnectAttempts >= this.webSocketMaxReconnectAttempts) {
1407
+ this.debug(
1408
+ `Maximum reconnection attempts (${this.webSocketMaxReconnectAttempts}) reached, giving up`
1409
+ );
1410
+ return;
1411
+ }
1412
+ if (this.wsReconnectTimer !== null) {
1413
+ clearTimeout(this.wsReconnectTimer);
1414
+ }
1415
+ const delay = this.calculateReconnectDelay();
1416
+ this.debug(
1417
+ `Scheduling reconnection attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts} in ${delay.toFixed(0)}ms`
1418
+ );
1419
+ this.wsReconnectTimer = setTimeout(async () => {
1420
+ this.wsReconnectTimer = null;
1421
+ this.wsReconnectAttempts++;
1422
+ this.debug(
1423
+ `Attempting to reconnect (attempt ${this.wsReconnectAttempts}/${this.webSocketMaxReconnectAttempts})`
1424
+ );
1425
+ try {
1426
+ this.conn = this.wsConnect();
1427
+ const socket = await this.conn;
1428
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1429
+ this.debug(`Reconnected, re-sending context`);
1430
+ await this.wsSendMessage(socket, this.context);
1431
+ }
1432
+ this.debug(`Reconnection successful`);
1433
+ } catch (error) {
1434
+ this.debug(`Reconnection attempt failed:`, error);
1435
+ }
1436
+ }, delay);
1437
+ };
1250
1438
  // Open a websocket connection
1251
1439
  wsConnect = () => {
1252
1440
  if (this.isOffline()) {
@@ -1259,17 +1447,45 @@ var Schematic = class {
1259
1447
  const wsUrl = `${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;
1260
1448
  this.debug(`connecting to WebSocket:`, wsUrl);
1261
1449
  const webSocket = new WebSocket(wsUrl);
1450
+ let timeoutId = null;
1451
+ let isResolved = false;
1452
+ timeoutId = setTimeout(() => {
1453
+ if (!isResolved) {
1454
+ this.debug(
1455
+ `WebSocket connection timeout after ${this.webSocketConnectionTimeout}ms`
1456
+ );
1457
+ webSocket.close();
1458
+ reject(new Error("WebSocket connection timeout"));
1459
+ }
1460
+ }, this.webSocketConnectionTimeout);
1262
1461
  webSocket.onopen = () => {
1462
+ isResolved = true;
1463
+ if (timeoutId !== null) {
1464
+ clearTimeout(timeoutId);
1465
+ }
1466
+ this.wsReconnectAttempts = 0;
1467
+ this.wsIntentionalDisconnect = false;
1263
1468
  this.debug(`WebSocket connection opened`);
1264
1469
  resolve(webSocket);
1265
1470
  };
1266
1471
  webSocket.onerror = (error) => {
1472
+ isResolved = true;
1473
+ if (timeoutId !== null) {
1474
+ clearTimeout(timeoutId);
1475
+ }
1267
1476
  this.debug(`WebSocket connection error:`, error);
1268
1477
  reject(error);
1269
1478
  };
1270
1479
  webSocket.onclose = () => {
1480
+ isResolved = true;
1481
+ if (timeoutId !== null) {
1482
+ clearTimeout(timeoutId);
1483
+ }
1271
1484
  this.debug(`WebSocket connection closed`);
1272
1485
  this.conn = null;
1486
+ if (!this.wsIntentionalDisconnect && this.webSocketReconnect) {
1487
+ this.attemptReconnect();
1488
+ }
1273
1489
  };
1274
1490
  });
1275
1491
  };
@@ -1317,6 +1533,7 @@ var Schematic = class {
1317
1533
  this.notifyFlagCheckListeners(flag.flag, flagCheck);
1318
1534
  this.notifyFlagValueListeners(flag.flag, flagCheck.value);
1319
1535
  });
1536
+ this.flushContextDependentEventQueue();
1320
1537
  this.setIsPending(false);
1321
1538
  if (!resolved) {
1322
1539
  resolved = true;
@@ -1460,7 +1677,7 @@ var notifyFlagValueListener = (listener, value) => {
1460
1677
  import React, { createContext, useEffect, useMemo, useRef } from "react";
1461
1678
 
1462
1679
  // src/version.ts
1463
- var version2 = "1.2.5";
1680
+ var version2 = "1.2.7";
1464
1681
 
1465
1682
  // src/context/schematic.tsx
1466
1683
  import { jsx } from "react/jsx-runtime";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schematichq/schematic-react",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "main": "dist/schematic-react.cjs.js",
5
5
  "module": "dist/schematic-react.esm.js",
6
6
  "types": "dist/schematic-react.d.ts",
@@ -24,35 +24,38 @@
24
24
  "clean": "rm -rf dist",
25
25
  "format": "prettier --write \"src/**/*.{ts,tsx}\"",
26
26
  "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --fix",
27
- "test": "jest --config jest.config.js",
27
+ "test": "vitest run",
28
+ "test:reactnative": "vitest run --config vitest.config.reactnative.ts",
29
+ "test:watch": "vitest",
28
30
  "tsc": "npx tsc",
29
31
  "prepare": "husky"
30
32
  },
31
33
  "dependencies": {
32
- "@schematichq/schematic-js": "^1.2.3"
34
+ "@schematichq/schematic-js": "^1.2.7"
33
35
  },
34
36
  "devDependencies": {
35
- "@eslint/js": "^9.24.0",
36
- "@microsoft/api-extractor": "^7.52.2",
37
- "@types/jest": "^29.5.14",
38
- "@types/react": "^19.1.1",
39
- "esbuild": "^0.25.2",
40
- "esbuild-jest": "^0.5.0",
41
- "eslint": "^9.24.0",
42
- "eslint-plugin-import": "^2.31.0",
37
+ "@eslint/js": "^9.39.1",
38
+ "@microsoft/api-extractor": "^7.55.0",
39
+ "@testing-library/dom": "^10.4.1",
40
+ "@testing-library/jest-dom": "^6.9.1",
41
+ "@testing-library/react": "^16.3.0",
42
+ "@types/react": "^19.2.3",
43
+ "@vitest/browser": "^4.0.8",
44
+ "esbuild": "^0.27.0",
45
+ "eslint": "^9.39.1",
46
+ "eslint-plugin-import": "^2.32.0",
43
47
  "eslint-plugin-react": "^7.37.5",
44
- "eslint-plugin-react-hooks": "^5.1.0",
45
- "globals": "^16.0.0",
48
+ "eslint-plugin-react-hooks": "^7.0.1",
49
+ "globals": "^16.5.0",
50
+ "happy-dom": "^20.0.10",
46
51
  "husky": "^9.1.7",
47
- "jest": "^29.7.0",
48
- "jest-environment-jsdom": "^29.7.0",
49
- "jest-esbuild": "^0.3.0",
50
- "jest-fetch-mock": "^3.0.3",
51
- "prettier": "^3.4.2",
52
- "react": "^19.1.0",
53
- "ts-jest": "^29.3.0",
54
- "typescript": "^5.7.3",
55
- "typescript-eslint": "^8.29.1"
52
+ "jsdom": "^27.2.0",
53
+ "prettier": "^3.6.2",
54
+ "react": "^19.2.0",
55
+ "react-dom": "^19.2.0",
56
+ "typescript": "^5.9.3",
57
+ "typescript-eslint": "^8.46.4",
58
+ "vitest": "^4.0.8"
56
59
  },
57
60
  "peerDependencies": {
58
61
  "react": ">=18"