@schematichq/schematic-react 1.2.6 → 1.2.8

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:
@@ -799,7 +799,7 @@ function contextString(context) {
799
799
  }, {});
800
800
  return JSON.stringify(sortedContext);
801
801
  }
802
- var version = "1.2.6";
802
+ var version = "1.2.8";
803
803
  var anonymousIdKey = "schematicId";
804
804
  var Schematic = class {
805
805
  additionalHeaders = {};
@@ -821,6 +821,23 @@ var Schematic = class {
821
821
  checks = {};
822
822
  featureUsageEventMap = {};
823
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;
832
+ maxEventQueueSize = 100;
833
+ // Prevent memory issues with very long network outages
834
+ maxEventRetries = 5;
835
+ // Maximum retry attempts for failed events
836
+ eventRetryInitialDelay = 1e3;
837
+ // Initial retry delay in ms
838
+ eventRetryMaxDelay = 3e4;
839
+ // Maximum retry delay in ms
840
+ retryTimer = null;
824
841
  constructor(apiKey, options) {
825
842
  this.apiKey = apiKey;
826
843
  this.eventQueue = [];
@@ -864,11 +881,48 @@ var Schematic = class {
864
881
  if (options?.webSocketUrl !== void 0) {
865
882
  this.webSocketUrl = options.webSocketUrl;
866
883
  }
884
+ if (options?.webSocketConnectionTimeout !== void 0) {
885
+ this.webSocketConnectionTimeout = options.webSocketConnectionTimeout;
886
+ }
887
+ if (options?.webSocketReconnect !== void 0) {
888
+ this.webSocketReconnect = options.webSocketReconnect;
889
+ }
890
+ if (options?.webSocketMaxReconnectAttempts !== void 0) {
891
+ this.webSocketMaxReconnectAttempts = options.webSocketMaxReconnectAttempts;
892
+ }
893
+ if (options?.webSocketInitialRetryDelay !== void 0) {
894
+ this.webSocketInitialRetryDelay = options.webSocketInitialRetryDelay;
895
+ }
896
+ if (options?.webSocketMaxRetryDelay !== void 0) {
897
+ this.webSocketMaxRetryDelay = options.webSocketMaxRetryDelay;
898
+ }
899
+ if (options?.maxEventQueueSize !== void 0) {
900
+ this.maxEventQueueSize = options.maxEventQueueSize;
901
+ }
902
+ if (options?.maxEventRetries !== void 0) {
903
+ this.maxEventRetries = options.maxEventRetries;
904
+ }
905
+ if (options?.eventRetryInitialDelay !== void 0) {
906
+ this.eventRetryInitialDelay = options.eventRetryInitialDelay;
907
+ }
908
+ if (options?.eventRetryMaxDelay !== void 0) {
909
+ this.eventRetryMaxDelay = options.eventRetryMaxDelay;
910
+ }
867
911
  if (typeof window !== "undefined" && window?.addEventListener) {
868
912
  window.addEventListener("beforeunload", () => {
869
913
  this.flushEventQueue();
870
914
  this.flushContextDependentEventQueue();
871
915
  });
916
+ if (this.useWebSocket) {
917
+ window.addEventListener("offline", () => {
918
+ this.debug("Browser went offline, closing WebSocket connection");
919
+ this.handleNetworkOffline();
920
+ });
921
+ window.addEventListener("online", () => {
922
+ this.debug("Browser came online, attempting to reconnect WebSocket");
923
+ this.handleNetworkOnline();
924
+ });
925
+ }
872
926
  }
873
927
  if (this.offlineEnabled) {
874
928
  this.debug(
@@ -1133,6 +1187,13 @@ var Schematic = class {
1133
1187
  try {
1134
1188
  this.setIsPending(true);
1135
1189
  if (!this.conn) {
1190
+ if (this.wsReconnectTimer !== null) {
1191
+ this.debug(
1192
+ `Cancelling scheduled reconnection, connecting immediately`
1193
+ );
1194
+ clearTimeout(this.wsReconnectTimer);
1195
+ this.wsReconnectTimer = null;
1196
+ }
1136
1197
  this.conn = this.wsConnect();
1137
1198
  }
1138
1199
  const socket = await this.conn;
@@ -1262,11 +1323,53 @@ var Schematic = class {
1262
1323
  }
1263
1324
  }
1264
1325
  };
1265
- flushEventQueue = () => {
1266
- while (this.eventQueue.length > 0) {
1267
- const event = this.eventQueue.shift();
1268
- if (event) {
1269
- this.sendEvent(event);
1326
+ startRetryTimer = () => {
1327
+ if (this.retryTimer !== null) {
1328
+ return;
1329
+ }
1330
+ this.retryTimer = setInterval(() => {
1331
+ this.flushEventQueue().catch((error) => {
1332
+ this.debug("Error in retry timer flush:", error);
1333
+ });
1334
+ if (this.eventQueue.length === 0) {
1335
+ this.stopRetryTimer();
1336
+ }
1337
+ }, 5e3);
1338
+ this.debug("Started retry timer");
1339
+ };
1340
+ stopRetryTimer = () => {
1341
+ if (this.retryTimer !== null) {
1342
+ clearInterval(this.retryTimer);
1343
+ this.retryTimer = null;
1344
+ this.debug("Stopped retry timer");
1345
+ }
1346
+ };
1347
+ flushEventQueue = async () => {
1348
+ if (this.eventQueue.length === 0) {
1349
+ return;
1350
+ }
1351
+ const now = Date.now();
1352
+ const readyEvents = [];
1353
+ const notReadyEvents = [];
1354
+ for (const event of this.eventQueue) {
1355
+ if (event.next_retry_at === void 0 || event.next_retry_at <= now) {
1356
+ readyEvents.push(event);
1357
+ } else {
1358
+ notReadyEvents.push(event);
1359
+ }
1360
+ }
1361
+ if (readyEvents.length === 0) {
1362
+ this.debug(`No events ready for retry yet (${notReadyEvents.length} still in backoff)`);
1363
+ return;
1364
+ }
1365
+ this.debug(`Flushing event queue: ${readyEvents.length} ready, ${notReadyEvents.length} waiting`);
1366
+ this.eventQueue = notReadyEvents;
1367
+ for (const event of readyEvents) {
1368
+ try {
1369
+ await this.sendEvent(event);
1370
+ this.debug(`Queued event sent successfully:`, event.type);
1371
+ } catch (error) {
1372
+ this.debug(`Failed to send queued event:`, error);
1270
1373
  }
1271
1374
  }
1272
1375
  };
@@ -1314,12 +1417,37 @@ var Schematic = class {
1314
1417
  },
1315
1418
  body: payload
1316
1419
  });
1420
+ if (!response.ok) {
1421
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1422
+ }
1317
1423
  this.debug(`event sent:`, {
1318
1424
  status: response.status,
1319
1425
  statusText: response.statusText
1320
1426
  });
1321
1427
  } catch (error) {
1322
- console.error("Error sending Schematic event: ", error);
1428
+ const retryCount = (event.retry_count ?? 0) + 1;
1429
+ if (retryCount <= this.maxEventRetries) {
1430
+ this.debug(`Event failed to send (attempt ${retryCount}/${this.maxEventRetries}), queueing for retry:`, error);
1431
+ const baseDelay = this.eventRetryInitialDelay * Math.pow(2, retryCount - 1);
1432
+ const jitterDelay = Math.min(baseDelay, this.eventRetryMaxDelay);
1433
+ const nextRetryAt = Date.now() + jitterDelay;
1434
+ const retryEvent = {
1435
+ ...event,
1436
+ retry_count: retryCount,
1437
+ next_retry_at: nextRetryAt
1438
+ };
1439
+ if (this.eventQueue.length < this.maxEventQueueSize) {
1440
+ this.eventQueue.push(retryEvent);
1441
+ this.debug(`Event queued for retry in ${jitterDelay}ms (${this.eventQueue.length}/${this.maxEventQueueSize})`);
1442
+ } else {
1443
+ this.debug(`Event queue full (${this.maxEventQueueSize}), dropping oldest event`);
1444
+ this.eventQueue.shift();
1445
+ this.eventQueue.push(retryEvent);
1446
+ }
1447
+ this.startRetryTimer();
1448
+ } else {
1449
+ this.debug(`Event failed permanently after ${this.maxEventRetries} attempts, dropping:`, error);
1450
+ }
1323
1451
  }
1324
1452
  return Promise.resolve();
1325
1453
  };
@@ -1339,6 +1467,12 @@ var Schematic = class {
1339
1467
  this.debug("cleanup: skipped (offline mode)");
1340
1468
  return Promise.resolve();
1341
1469
  }
1470
+ this.wsIntentionalDisconnect = true;
1471
+ if (this.wsReconnectTimer !== null) {
1472
+ clearTimeout(this.wsReconnectTimer);
1473
+ this.wsReconnectTimer = null;
1474
+ }
1475
+ this.stopRetryTimer();
1342
1476
  if (this.conn) {
1343
1477
  try {
1344
1478
  const socket = await this.conn;
@@ -1350,6 +1484,100 @@ var Schematic = class {
1350
1484
  }
1351
1485
  }
1352
1486
  };
1487
+ /**
1488
+ * Calculate the delay for the next reconnection attempt using exponential backoff with jitter.
1489
+ * This helps prevent dogpiling when the server recovers from an outage.
1490
+ */
1491
+ calculateReconnectDelay = () => {
1492
+ const exponentialDelay = this.webSocketInitialRetryDelay * Math.pow(2, this.wsReconnectAttempts);
1493
+ const cappedDelay = Math.min(exponentialDelay, this.webSocketMaxRetryDelay);
1494
+ const jitter = Math.random() * cappedDelay * 0.5;
1495
+ const totalDelay = cappedDelay + jitter;
1496
+ this.debug(
1497
+ `Reconnect delay calculated: ${totalDelay.toFixed(0)}ms (attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts})`
1498
+ );
1499
+ return totalDelay;
1500
+ };
1501
+ /**
1502
+ * Handle browser going offline
1503
+ */
1504
+ handleNetworkOffline = async () => {
1505
+ if (this.conn !== null) {
1506
+ try {
1507
+ const socket = await this.conn;
1508
+ socket.close();
1509
+ } catch (error) {
1510
+ this.debug("Error closing connection on offline:", error);
1511
+ }
1512
+ this.conn = null;
1513
+ }
1514
+ if (this.wsReconnectTimer !== null) {
1515
+ clearTimeout(this.wsReconnectTimer);
1516
+ this.wsReconnectTimer = null;
1517
+ }
1518
+ };
1519
+ /**
1520
+ * Handle browser coming back online
1521
+ */
1522
+ handleNetworkOnline = () => {
1523
+ this.debug("Network online, attempting reconnection and flushing queued events");
1524
+ this.wsReconnectAttempts = 0;
1525
+ if (this.wsReconnectTimer !== null) {
1526
+ clearTimeout(this.wsReconnectTimer);
1527
+ this.wsReconnectTimer = null;
1528
+ }
1529
+ this.flushEventQueue().catch((error) => {
1530
+ this.debug("Error flushing event queue on network online:", error);
1531
+ });
1532
+ this.attemptReconnect();
1533
+ };
1534
+ /**
1535
+ * Attempt to reconnect the WebSocket connection with exponential backoff.
1536
+ * Called automatically when the connection closes unexpectedly.
1537
+ */
1538
+ attemptReconnect = () => {
1539
+ if (this.wsReconnectAttempts >= this.webSocketMaxReconnectAttempts) {
1540
+ this.debug(
1541
+ `Maximum reconnection attempts (${this.webSocketMaxReconnectAttempts}) reached, giving up`
1542
+ );
1543
+ return;
1544
+ }
1545
+ if (this.wsReconnectTimer !== null) {
1546
+ clearTimeout(this.wsReconnectTimer);
1547
+ }
1548
+ const delay = this.calculateReconnectDelay();
1549
+ this.debug(
1550
+ `Scheduling reconnection attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts} in ${delay.toFixed(0)}ms`
1551
+ );
1552
+ this.wsReconnectTimer = setTimeout(async () => {
1553
+ this.wsReconnectTimer = null;
1554
+ this.wsReconnectAttempts++;
1555
+ this.debug(
1556
+ `Attempting to reconnect (attempt ${this.wsReconnectAttempts}/${this.webSocketMaxReconnectAttempts})`
1557
+ );
1558
+ try {
1559
+ this.conn = this.wsConnect();
1560
+ const socket = await this.conn;
1561
+ this.debug(`Reconnection context check:`, {
1562
+ hasCompany: this.context.company !== void 0,
1563
+ hasUser: this.context.user !== void 0,
1564
+ context: this.context
1565
+ });
1566
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1567
+ this.debug(`Reconnected, force re-sending context`);
1568
+ await this.wsSendContextAfterReconnection(socket, this.context);
1569
+ } else {
1570
+ this.debug(`No context to re-send after reconnection - websocket ready for new context`);
1571
+ }
1572
+ this.flushEventQueue().catch((error) => {
1573
+ this.debug("Error flushing event queue after websocket reconnection:", error);
1574
+ });
1575
+ this.debug(`Reconnection successful`);
1576
+ } catch (error) {
1577
+ this.debug(`Reconnection attempt failed:`, error);
1578
+ }
1579
+ }, delay);
1580
+ };
1353
1581
  // Open a websocket connection
1354
1582
  wsConnect = () => {
1355
1583
  if (this.isOffline()) {
@@ -1362,18 +1590,101 @@ var Schematic = class {
1362
1590
  const wsUrl = `${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;
1363
1591
  this.debug(`connecting to WebSocket:`, wsUrl);
1364
1592
  const webSocket = new WebSocket(wsUrl);
1593
+ let timeoutId = null;
1594
+ let isResolved = false;
1595
+ timeoutId = setTimeout(() => {
1596
+ if (!isResolved) {
1597
+ this.debug(
1598
+ `WebSocket connection timeout after ${this.webSocketConnectionTimeout}ms`
1599
+ );
1600
+ webSocket.close();
1601
+ reject(new Error("WebSocket connection timeout"));
1602
+ }
1603
+ }, this.webSocketConnectionTimeout);
1365
1604
  webSocket.onopen = () => {
1605
+ isResolved = true;
1606
+ if (timeoutId !== null) {
1607
+ clearTimeout(timeoutId);
1608
+ }
1609
+ this.wsReconnectAttempts = 0;
1610
+ this.wsIntentionalDisconnect = false;
1366
1611
  this.debug(`WebSocket connection opened`);
1367
1612
  resolve(webSocket);
1368
1613
  };
1369
1614
  webSocket.onerror = (error) => {
1615
+ isResolved = true;
1616
+ if (timeoutId !== null) {
1617
+ clearTimeout(timeoutId);
1618
+ }
1370
1619
  this.debug(`WebSocket connection error:`, error);
1371
1620
  reject(error);
1372
1621
  };
1373
1622
  webSocket.onclose = () => {
1623
+ isResolved = true;
1624
+ if (timeoutId !== null) {
1625
+ clearTimeout(timeoutId);
1626
+ }
1374
1627
  this.debug(`WebSocket connection closed`);
1375
1628
  this.conn = null;
1629
+ if (!this.wsIntentionalDisconnect && this.webSocketReconnect) {
1630
+ this.attemptReconnect();
1631
+ }
1632
+ };
1633
+ });
1634
+ };
1635
+ // Send a message on the websocket after reconnection, forcing the send even if context appears unchanged
1636
+ // because the server has lost all state and needs the initial context
1637
+ wsSendContextAfterReconnection = (socket, context) => {
1638
+ if (this.isOffline()) {
1639
+ this.debug("wsSendContextAfterReconnection: skipped (offline mode)");
1640
+ this.setIsPending(false);
1641
+ return Promise.resolve();
1642
+ }
1643
+ return new Promise((resolve) => {
1644
+ this.debug(`WebSocket force sending context after reconnection:`, context);
1645
+ this.context = context;
1646
+ const sendMessage = () => {
1647
+ let resolved = false;
1648
+ const messageHandler = (event) => {
1649
+ const message = JSON.parse(event.data);
1650
+ this.debug(`WebSocket message received after reconnection:`, message);
1651
+ if (!(contextString(context) in this.checks)) {
1652
+ this.checks[contextString(context)] = {};
1653
+ }
1654
+ (message.flags ?? []).forEach((flag) => {
1655
+ const flagCheck = CheckFlagReturnFromJSON(flag);
1656
+ const contextStr = contextString(context);
1657
+ if (this.checks[contextStr] === void 0) {
1658
+ this.checks[contextStr] = {};
1659
+ }
1660
+ this.checks[contextStr][flagCheck.flag] = flagCheck;
1661
+ });
1662
+ this.useWebSocket = true;
1663
+ socket.removeEventListener("message", messageHandler);
1664
+ if (!resolved) {
1665
+ resolved = true;
1666
+ resolve(this.setIsPending(false));
1667
+ }
1668
+ };
1669
+ socket.addEventListener("message", messageHandler);
1670
+ const clientVersion = this.additionalHeaders["X-Schematic-Client-Version"] ?? `schematic-js@${version}`;
1671
+ const messagePayload = {
1672
+ apiKey: this.apiKey,
1673
+ clientVersion,
1674
+ data: context
1675
+ };
1676
+ this.debug(`WebSocket sending forced message after reconnection:`, messagePayload);
1677
+ socket.send(JSON.stringify(messagePayload));
1376
1678
  };
1679
+ if (socket.readyState === WebSocket.OPEN) {
1680
+ this.debug(`WebSocket already open, sending forced message after reconnection`);
1681
+ sendMessage();
1682
+ } else {
1683
+ socket.addEventListener("open", () => {
1684
+ this.debug(`WebSocket opened, sending forced message after reconnection`);
1685
+ sendMessage();
1686
+ });
1687
+ }
1377
1688
  });
1378
1689
  };
1379
1690
  // Send a message on the websocket indicating interest in a particular evaluation context
@@ -1564,7 +1875,7 @@ var notifyFlagValueListener = (listener, value) => {
1564
1875
  var import_react = __toESM(require("react"));
1565
1876
 
1566
1877
  // src/version.ts
1567
- var version2 = "1.2.6";
1878
+ var version2 = "1.2.8";
1568
1879
 
1569
1880
  // src/context/schematic.tsx
1570
1881
  var import_jsx_runtime = require("react/jsx-runtime");
@@ -754,7 +754,7 @@ function contextString(context) {
754
754
  }, {});
755
755
  return JSON.stringify(sortedContext);
756
756
  }
757
- var version = "1.2.6";
757
+ var version = "1.2.8";
758
758
  var anonymousIdKey = "schematicId";
759
759
  var Schematic = class {
760
760
  additionalHeaders = {};
@@ -776,6 +776,23 @@ var Schematic = class {
776
776
  checks = {};
777
777
  featureUsageEventMap = {};
778
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;
787
+ maxEventQueueSize = 100;
788
+ // Prevent memory issues with very long network outages
789
+ maxEventRetries = 5;
790
+ // Maximum retry attempts for failed events
791
+ eventRetryInitialDelay = 1e3;
792
+ // Initial retry delay in ms
793
+ eventRetryMaxDelay = 3e4;
794
+ // Maximum retry delay in ms
795
+ retryTimer = null;
779
796
  constructor(apiKey, options) {
780
797
  this.apiKey = apiKey;
781
798
  this.eventQueue = [];
@@ -819,11 +836,48 @@ var Schematic = class {
819
836
  if (options?.webSocketUrl !== void 0) {
820
837
  this.webSocketUrl = options.webSocketUrl;
821
838
  }
839
+ if (options?.webSocketConnectionTimeout !== void 0) {
840
+ this.webSocketConnectionTimeout = options.webSocketConnectionTimeout;
841
+ }
842
+ if (options?.webSocketReconnect !== void 0) {
843
+ this.webSocketReconnect = options.webSocketReconnect;
844
+ }
845
+ if (options?.webSocketMaxReconnectAttempts !== void 0) {
846
+ this.webSocketMaxReconnectAttempts = options.webSocketMaxReconnectAttempts;
847
+ }
848
+ if (options?.webSocketInitialRetryDelay !== void 0) {
849
+ this.webSocketInitialRetryDelay = options.webSocketInitialRetryDelay;
850
+ }
851
+ if (options?.webSocketMaxRetryDelay !== void 0) {
852
+ this.webSocketMaxRetryDelay = options.webSocketMaxRetryDelay;
853
+ }
854
+ if (options?.maxEventQueueSize !== void 0) {
855
+ this.maxEventQueueSize = options.maxEventQueueSize;
856
+ }
857
+ if (options?.maxEventRetries !== void 0) {
858
+ this.maxEventRetries = options.maxEventRetries;
859
+ }
860
+ if (options?.eventRetryInitialDelay !== void 0) {
861
+ this.eventRetryInitialDelay = options.eventRetryInitialDelay;
862
+ }
863
+ if (options?.eventRetryMaxDelay !== void 0) {
864
+ this.eventRetryMaxDelay = options.eventRetryMaxDelay;
865
+ }
822
866
  if (typeof window !== "undefined" && window?.addEventListener) {
823
867
  window.addEventListener("beforeunload", () => {
824
868
  this.flushEventQueue();
825
869
  this.flushContextDependentEventQueue();
826
870
  });
871
+ if (this.useWebSocket) {
872
+ window.addEventListener("offline", () => {
873
+ this.debug("Browser went offline, closing WebSocket connection");
874
+ this.handleNetworkOffline();
875
+ });
876
+ window.addEventListener("online", () => {
877
+ this.debug("Browser came online, attempting to reconnect WebSocket");
878
+ this.handleNetworkOnline();
879
+ });
880
+ }
827
881
  }
828
882
  if (this.offlineEnabled) {
829
883
  this.debug(
@@ -1088,6 +1142,13 @@ var Schematic = class {
1088
1142
  try {
1089
1143
  this.setIsPending(true);
1090
1144
  if (!this.conn) {
1145
+ if (this.wsReconnectTimer !== null) {
1146
+ this.debug(
1147
+ `Cancelling scheduled reconnection, connecting immediately`
1148
+ );
1149
+ clearTimeout(this.wsReconnectTimer);
1150
+ this.wsReconnectTimer = null;
1151
+ }
1091
1152
  this.conn = this.wsConnect();
1092
1153
  }
1093
1154
  const socket = await this.conn;
@@ -1217,11 +1278,53 @@ var Schematic = class {
1217
1278
  }
1218
1279
  }
1219
1280
  };
1220
- flushEventQueue = () => {
1221
- while (this.eventQueue.length > 0) {
1222
- const event = this.eventQueue.shift();
1223
- if (event) {
1224
- this.sendEvent(event);
1281
+ startRetryTimer = () => {
1282
+ if (this.retryTimer !== null) {
1283
+ return;
1284
+ }
1285
+ this.retryTimer = setInterval(() => {
1286
+ this.flushEventQueue().catch((error) => {
1287
+ this.debug("Error in retry timer flush:", error);
1288
+ });
1289
+ if (this.eventQueue.length === 0) {
1290
+ this.stopRetryTimer();
1291
+ }
1292
+ }, 5e3);
1293
+ this.debug("Started retry timer");
1294
+ };
1295
+ stopRetryTimer = () => {
1296
+ if (this.retryTimer !== null) {
1297
+ clearInterval(this.retryTimer);
1298
+ this.retryTimer = null;
1299
+ this.debug("Stopped retry timer");
1300
+ }
1301
+ };
1302
+ flushEventQueue = async () => {
1303
+ if (this.eventQueue.length === 0) {
1304
+ return;
1305
+ }
1306
+ const now = Date.now();
1307
+ const readyEvents = [];
1308
+ const notReadyEvents = [];
1309
+ for (const event of this.eventQueue) {
1310
+ if (event.next_retry_at === void 0 || event.next_retry_at <= now) {
1311
+ readyEvents.push(event);
1312
+ } else {
1313
+ notReadyEvents.push(event);
1314
+ }
1315
+ }
1316
+ if (readyEvents.length === 0) {
1317
+ this.debug(`No events ready for retry yet (${notReadyEvents.length} still in backoff)`);
1318
+ return;
1319
+ }
1320
+ this.debug(`Flushing event queue: ${readyEvents.length} ready, ${notReadyEvents.length} waiting`);
1321
+ this.eventQueue = notReadyEvents;
1322
+ for (const event of readyEvents) {
1323
+ try {
1324
+ await this.sendEvent(event);
1325
+ this.debug(`Queued event sent successfully:`, event.type);
1326
+ } catch (error) {
1327
+ this.debug(`Failed to send queued event:`, error);
1225
1328
  }
1226
1329
  }
1227
1330
  };
@@ -1269,12 +1372,37 @@ var Schematic = class {
1269
1372
  },
1270
1373
  body: payload
1271
1374
  });
1375
+ if (!response.ok) {
1376
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1377
+ }
1272
1378
  this.debug(`event sent:`, {
1273
1379
  status: response.status,
1274
1380
  statusText: response.statusText
1275
1381
  });
1276
1382
  } catch (error) {
1277
- console.error("Error sending Schematic event: ", error);
1383
+ const retryCount = (event.retry_count ?? 0) + 1;
1384
+ if (retryCount <= this.maxEventRetries) {
1385
+ this.debug(`Event failed to send (attempt ${retryCount}/${this.maxEventRetries}), queueing for retry:`, error);
1386
+ const baseDelay = this.eventRetryInitialDelay * Math.pow(2, retryCount - 1);
1387
+ const jitterDelay = Math.min(baseDelay, this.eventRetryMaxDelay);
1388
+ const nextRetryAt = Date.now() + jitterDelay;
1389
+ const retryEvent = {
1390
+ ...event,
1391
+ retry_count: retryCount,
1392
+ next_retry_at: nextRetryAt
1393
+ };
1394
+ if (this.eventQueue.length < this.maxEventQueueSize) {
1395
+ this.eventQueue.push(retryEvent);
1396
+ this.debug(`Event queued for retry in ${jitterDelay}ms (${this.eventQueue.length}/${this.maxEventQueueSize})`);
1397
+ } else {
1398
+ this.debug(`Event queue full (${this.maxEventQueueSize}), dropping oldest event`);
1399
+ this.eventQueue.shift();
1400
+ this.eventQueue.push(retryEvent);
1401
+ }
1402
+ this.startRetryTimer();
1403
+ } else {
1404
+ this.debug(`Event failed permanently after ${this.maxEventRetries} attempts, dropping:`, error);
1405
+ }
1278
1406
  }
1279
1407
  return Promise.resolve();
1280
1408
  };
@@ -1294,6 +1422,12 @@ var Schematic = class {
1294
1422
  this.debug("cleanup: skipped (offline mode)");
1295
1423
  return Promise.resolve();
1296
1424
  }
1425
+ this.wsIntentionalDisconnect = true;
1426
+ if (this.wsReconnectTimer !== null) {
1427
+ clearTimeout(this.wsReconnectTimer);
1428
+ this.wsReconnectTimer = null;
1429
+ }
1430
+ this.stopRetryTimer();
1297
1431
  if (this.conn) {
1298
1432
  try {
1299
1433
  const socket = await this.conn;
@@ -1305,6 +1439,100 @@ var Schematic = class {
1305
1439
  }
1306
1440
  }
1307
1441
  };
1442
+ /**
1443
+ * Calculate the delay for the next reconnection attempt using exponential backoff with jitter.
1444
+ * This helps prevent dogpiling when the server recovers from an outage.
1445
+ */
1446
+ calculateReconnectDelay = () => {
1447
+ const exponentialDelay = this.webSocketInitialRetryDelay * Math.pow(2, this.wsReconnectAttempts);
1448
+ const cappedDelay = Math.min(exponentialDelay, this.webSocketMaxRetryDelay);
1449
+ const jitter = Math.random() * cappedDelay * 0.5;
1450
+ const totalDelay = cappedDelay + jitter;
1451
+ this.debug(
1452
+ `Reconnect delay calculated: ${totalDelay.toFixed(0)}ms (attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts})`
1453
+ );
1454
+ return totalDelay;
1455
+ };
1456
+ /**
1457
+ * Handle browser going offline
1458
+ */
1459
+ handleNetworkOffline = async () => {
1460
+ if (this.conn !== null) {
1461
+ try {
1462
+ const socket = await this.conn;
1463
+ socket.close();
1464
+ } catch (error) {
1465
+ this.debug("Error closing connection on offline:", error);
1466
+ }
1467
+ this.conn = null;
1468
+ }
1469
+ if (this.wsReconnectTimer !== null) {
1470
+ clearTimeout(this.wsReconnectTimer);
1471
+ this.wsReconnectTimer = null;
1472
+ }
1473
+ };
1474
+ /**
1475
+ * Handle browser coming back online
1476
+ */
1477
+ handleNetworkOnline = () => {
1478
+ this.debug("Network online, attempting reconnection and flushing queued events");
1479
+ this.wsReconnectAttempts = 0;
1480
+ if (this.wsReconnectTimer !== null) {
1481
+ clearTimeout(this.wsReconnectTimer);
1482
+ this.wsReconnectTimer = null;
1483
+ }
1484
+ this.flushEventQueue().catch((error) => {
1485
+ this.debug("Error flushing event queue on network online:", error);
1486
+ });
1487
+ this.attemptReconnect();
1488
+ };
1489
+ /**
1490
+ * Attempt to reconnect the WebSocket connection with exponential backoff.
1491
+ * Called automatically when the connection closes unexpectedly.
1492
+ */
1493
+ attemptReconnect = () => {
1494
+ if (this.wsReconnectAttempts >= this.webSocketMaxReconnectAttempts) {
1495
+ this.debug(
1496
+ `Maximum reconnection attempts (${this.webSocketMaxReconnectAttempts}) reached, giving up`
1497
+ );
1498
+ return;
1499
+ }
1500
+ if (this.wsReconnectTimer !== null) {
1501
+ clearTimeout(this.wsReconnectTimer);
1502
+ }
1503
+ const delay = this.calculateReconnectDelay();
1504
+ this.debug(
1505
+ `Scheduling reconnection attempt ${this.wsReconnectAttempts + 1}/${this.webSocketMaxReconnectAttempts} in ${delay.toFixed(0)}ms`
1506
+ );
1507
+ this.wsReconnectTimer = setTimeout(async () => {
1508
+ this.wsReconnectTimer = null;
1509
+ this.wsReconnectAttempts++;
1510
+ this.debug(
1511
+ `Attempting to reconnect (attempt ${this.wsReconnectAttempts}/${this.webSocketMaxReconnectAttempts})`
1512
+ );
1513
+ try {
1514
+ this.conn = this.wsConnect();
1515
+ const socket = await this.conn;
1516
+ this.debug(`Reconnection context check:`, {
1517
+ hasCompany: this.context.company !== void 0,
1518
+ hasUser: this.context.user !== void 0,
1519
+ context: this.context
1520
+ });
1521
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1522
+ this.debug(`Reconnected, force re-sending context`);
1523
+ await this.wsSendContextAfterReconnection(socket, this.context);
1524
+ } else {
1525
+ this.debug(`No context to re-send after reconnection - websocket ready for new context`);
1526
+ }
1527
+ this.flushEventQueue().catch((error) => {
1528
+ this.debug("Error flushing event queue after websocket reconnection:", error);
1529
+ });
1530
+ this.debug(`Reconnection successful`);
1531
+ } catch (error) {
1532
+ this.debug(`Reconnection attempt failed:`, error);
1533
+ }
1534
+ }, delay);
1535
+ };
1308
1536
  // Open a websocket connection
1309
1537
  wsConnect = () => {
1310
1538
  if (this.isOffline()) {
@@ -1317,18 +1545,101 @@ var Schematic = class {
1317
1545
  const wsUrl = `${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;
1318
1546
  this.debug(`connecting to WebSocket:`, wsUrl);
1319
1547
  const webSocket = new WebSocket(wsUrl);
1548
+ let timeoutId = null;
1549
+ let isResolved = false;
1550
+ timeoutId = setTimeout(() => {
1551
+ if (!isResolved) {
1552
+ this.debug(
1553
+ `WebSocket connection timeout after ${this.webSocketConnectionTimeout}ms`
1554
+ );
1555
+ webSocket.close();
1556
+ reject(new Error("WebSocket connection timeout"));
1557
+ }
1558
+ }, this.webSocketConnectionTimeout);
1320
1559
  webSocket.onopen = () => {
1560
+ isResolved = true;
1561
+ if (timeoutId !== null) {
1562
+ clearTimeout(timeoutId);
1563
+ }
1564
+ this.wsReconnectAttempts = 0;
1565
+ this.wsIntentionalDisconnect = false;
1321
1566
  this.debug(`WebSocket connection opened`);
1322
1567
  resolve(webSocket);
1323
1568
  };
1324
1569
  webSocket.onerror = (error) => {
1570
+ isResolved = true;
1571
+ if (timeoutId !== null) {
1572
+ clearTimeout(timeoutId);
1573
+ }
1325
1574
  this.debug(`WebSocket connection error:`, error);
1326
1575
  reject(error);
1327
1576
  };
1328
1577
  webSocket.onclose = () => {
1578
+ isResolved = true;
1579
+ if (timeoutId !== null) {
1580
+ clearTimeout(timeoutId);
1581
+ }
1329
1582
  this.debug(`WebSocket connection closed`);
1330
1583
  this.conn = null;
1584
+ if (!this.wsIntentionalDisconnect && this.webSocketReconnect) {
1585
+ this.attemptReconnect();
1586
+ }
1587
+ };
1588
+ });
1589
+ };
1590
+ // Send a message on the websocket after reconnection, forcing the send even if context appears unchanged
1591
+ // because the server has lost all state and needs the initial context
1592
+ wsSendContextAfterReconnection = (socket, context) => {
1593
+ if (this.isOffline()) {
1594
+ this.debug("wsSendContextAfterReconnection: skipped (offline mode)");
1595
+ this.setIsPending(false);
1596
+ return Promise.resolve();
1597
+ }
1598
+ return new Promise((resolve) => {
1599
+ this.debug(`WebSocket force sending context after reconnection:`, context);
1600
+ this.context = context;
1601
+ const sendMessage = () => {
1602
+ let resolved = false;
1603
+ const messageHandler = (event) => {
1604
+ const message = JSON.parse(event.data);
1605
+ this.debug(`WebSocket message received after reconnection:`, message);
1606
+ if (!(contextString(context) in this.checks)) {
1607
+ this.checks[contextString(context)] = {};
1608
+ }
1609
+ (message.flags ?? []).forEach((flag) => {
1610
+ const flagCheck = CheckFlagReturnFromJSON(flag);
1611
+ const contextStr = contextString(context);
1612
+ if (this.checks[contextStr] === void 0) {
1613
+ this.checks[contextStr] = {};
1614
+ }
1615
+ this.checks[contextStr][flagCheck.flag] = flagCheck;
1616
+ });
1617
+ this.useWebSocket = true;
1618
+ socket.removeEventListener("message", messageHandler);
1619
+ if (!resolved) {
1620
+ resolved = true;
1621
+ resolve(this.setIsPending(false));
1622
+ }
1623
+ };
1624
+ socket.addEventListener("message", messageHandler);
1625
+ const clientVersion = this.additionalHeaders["X-Schematic-Client-Version"] ?? `schematic-js@${version}`;
1626
+ const messagePayload = {
1627
+ apiKey: this.apiKey,
1628
+ clientVersion,
1629
+ data: context
1630
+ };
1631
+ this.debug(`WebSocket sending forced message after reconnection:`, messagePayload);
1632
+ socket.send(JSON.stringify(messagePayload));
1331
1633
  };
1634
+ if (socket.readyState === WebSocket.OPEN) {
1635
+ this.debug(`WebSocket already open, sending forced message after reconnection`);
1636
+ sendMessage();
1637
+ } else {
1638
+ socket.addEventListener("open", () => {
1639
+ this.debug(`WebSocket opened, sending forced message after reconnection`);
1640
+ sendMessage();
1641
+ });
1642
+ }
1332
1643
  });
1333
1644
  };
1334
1645
  // Send a message on the websocket indicating interest in a particular evaluation context
@@ -1519,7 +1830,7 @@ var notifyFlagValueListener = (listener, value) => {
1519
1830
  import React, { createContext, useEffect, useMemo, useRef } from "react";
1520
1831
 
1521
1832
  // src/version.ts
1522
- var version2 = "1.2.6";
1833
+ var version2 = "1.2.8";
1523
1834
 
1524
1835
  // src/context/schematic.tsx
1525
1836
  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.6",
3
+ "version": "1.2.8",
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,40 +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",
28
- "test:reactnative": "jest --config jest.config.reactnative.js",
27
+ "test": "vitest run",
28
+ "test:reactnative": "vitest run --config vitest.config.reactnative.ts",
29
+ "test:watch": "vitest",
29
30
  "tsc": "npx tsc",
30
31
  "prepare": "husky"
31
32
  },
32
33
  "dependencies": {
33
- "@schematichq/schematic-js": "^1.2.6"
34
+ "@schematichq/schematic-js": "^1.2.8"
34
35
  },
35
36
  "devDependencies": {
36
- "@eslint/js": "^9.24.0",
37
- "@microsoft/api-extractor": "^7.52.2",
37
+ "@eslint/js": "^9.39.1",
38
+ "@microsoft/api-extractor": "^7.55.0",
38
39
  "@testing-library/dom": "^10.4.1",
39
- "@testing-library/jest-dom": "^6.8.0",
40
+ "@testing-library/jest-dom": "^6.9.1",
40
41
  "@testing-library/react": "^16.3.0",
41
- "@types/jest": "^30.0.0",
42
- "@types/react": "^19.1.1",
43
- "esbuild": "^0.25.2",
44
- "esbuild-jest": "^0.5.0",
45
- "eslint": "^9.24.0",
42
+ "@types/react": "^19.2.6",
43
+ "@vitest/browser": "^4.0.8",
44
+ "esbuild": "^0.27.0",
45
+ "eslint": "^9.39.1",
46
46
  "eslint-plugin-import": "^2.32.0",
47
47
  "eslint-plugin-react": "^7.37.5",
48
- "eslint-plugin-react-hooks": "^5.2.0",
49
- "globals": "^16.0.0",
48
+ "eslint-plugin-react-hooks": "^7.0.1",
49
+ "globals": "^16.5.0",
50
+ "happy-dom": "^20.0.10",
50
51
  "husky": "^9.1.7",
51
- "jest": "^30.0.0",
52
- "jest-environment-jsdom": "^30.0.0",
53
- "jest-esbuild": "^0.4.0",
54
- "jest-fetch-mock": "^3.0.3",
52
+ "jsdom": "^27.2.0",
55
53
  "prettier": "^3.6.2",
56
- "react": "^19.1.1",
57
- "react-dom": "^19.1.1",
58
- "ts-jest": "^29.3.0",
59
- "typescript": "^5.9.2",
60
- "typescript-eslint": "^8.29.1"
54
+ "react": "^19.2.0",
55
+ "react-dom": "^19.2.0",
56
+ "typescript": "^5.9.3",
57
+ "typescript-eslint": "^8.47.0",
58
+ "vitest": "^4.0.8"
61
59
  },
62
60
  "peerDependencies": {
63
61
  "react": ">=18"