@schematichq/schematic-react 1.2.9 → 1.2.12

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/README.md CHANGED
@@ -136,6 +136,43 @@ const MyComponent = () => {
136
136
 
137
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
138
 
139
+ ## React Native
140
+
141
+ ### Handling app background/foreground
142
+
143
+ When a React Native app is backgrounded for an extended period, the WebSocket connection may be closed by the OS. When the app returns to the foreground, the connection will automatically reconnect, but this happens on an exponential backoff timer which may cause a delay before fresh flag values are available.
144
+
145
+ For cases where you need immediate flag updates when returning to the foreground (e.g., after an in-app purchase), you can use one of these methods to re-establish the connection:
146
+
147
+ - `forceReconnect()`: Always closes and re-establishes the WebSocket connection, guaranteeing fresh values
148
+ - `reconnectIfNeeded()`: Only reconnects if the current connection is unhealthy (more efficient for frequent foreground events)
149
+
150
+ ```tsx
151
+ import { useSchematic } from "@schematichq/schematic-react";
152
+ import { useEffect } from "react";
153
+ import { AppState } from "react-native";
154
+
155
+ const SchematicAppStateHandler = () => {
156
+ const { client } = useSchematic();
157
+
158
+ useEffect(() => {
159
+ const subscription = AppState.addEventListener("change", (state) => {
160
+ if (state === "active") {
161
+ // Use forceReconnect() for guaranteed fresh values
162
+ client.forceReconnect();
163
+ // Or use reconnectIfNeeded() to skip if connection is healthy
164
+ // client.reconnectIfNeeded();
165
+ }
166
+ });
167
+ return () => subscription.remove();
168
+ }, [client]);
169
+
170
+ return null;
171
+ };
172
+ ```
173
+
174
+ Render this inside your `SchematicProvider`.
175
+
139
176
  ## Troubleshooting
140
177
 
141
178
  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.9";
802
+ var version = "1.2.12";
803
803
  var anonymousIdKey = "schematicId";
804
804
  var Schematic = class {
805
805
  additionalHeaders = {};
@@ -829,6 +829,8 @@ var Schematic = class {
829
829
  wsReconnectAttempts = 0;
830
830
  wsReconnectTimer = null;
831
831
  wsIntentionalDisconnect = false;
832
+ currentWebSocket = null;
833
+ isConnecting = false;
832
834
  maxEventQueueSize = 100;
833
835
  // Prevent memory issues with very long network outages
834
836
  maxEventRetries = 5;
@@ -1106,6 +1108,49 @@ var Schematic = class {
1106
1108
  console.log(`[Schematic] ${message}`, ...args);
1107
1109
  }
1108
1110
  }
1111
+ /**
1112
+ * Create a persistent message handler for websocket flag updates
1113
+ */
1114
+ createPersistentMessageHandler(context) {
1115
+ return (event) => {
1116
+ const message = JSON.parse(event.data);
1117
+ this.debug(`WebSocket persistent message received:`, message);
1118
+ if (!(contextString(context) in this.checks)) {
1119
+ this.checks[contextString(context)] = {};
1120
+ }
1121
+ (message.flags ?? []).forEach((flag) => {
1122
+ const flagCheck = CheckFlagReturnFromJSON(flag);
1123
+ const contextStr = contextString(context);
1124
+ if (this.checks[contextStr] === void 0) {
1125
+ this.checks[contextStr] = {};
1126
+ }
1127
+ this.checks[contextStr][flagCheck.flag] = flagCheck;
1128
+ this.debug(`WebSocket flag update:`, {
1129
+ flag: flagCheck.flag,
1130
+ value: flagCheck.value,
1131
+ flagCheck
1132
+ });
1133
+ if (typeof flagCheck.featureUsageEvent === "string") {
1134
+ this.updateFeatureUsageEventMap(flagCheck);
1135
+ }
1136
+ if ((this.flagCheckListeners[flag.flag]?.size ?? 0) > 0 || (this.flagValueListeners[flag.flag]?.size ?? 0) > 0) {
1137
+ this.submitFlagCheckEvent(flagCheck.flag, flagCheck, context);
1138
+ }
1139
+ this.debug(`About to notify listeners for flag ${flag.flag}`, {
1140
+ flag: flag.flag,
1141
+ value: flagCheck.value
1142
+ });
1143
+ this.notifyFlagCheckListeners(flag.flag, flagCheck);
1144
+ this.notifyFlagValueListeners(flag.flag, flagCheck.value);
1145
+ this.debug(`Finished notifying listeners for flag ${flag.flag}`, {
1146
+ flag: flag.flag,
1147
+ value: flagCheck.value
1148
+ });
1149
+ });
1150
+ this.flushContextDependentEventQueue();
1151
+ this.setIsPending(false);
1152
+ };
1153
+ }
1109
1154
  /**
1110
1155
  * Helper function to check if client is in offline mode
1111
1156
  */
@@ -1257,6 +1302,19 @@ var Schematic = class {
1257
1302
  try {
1258
1303
  this.setIsPending(true);
1259
1304
  if (!this.conn) {
1305
+ if (this.isConnecting) {
1306
+ this.debug(
1307
+ `Connection already in progress, waiting for it to complete`
1308
+ );
1309
+ while (this.isConnecting && this.conn === null) {
1310
+ await new Promise((resolve) => setTimeout(resolve, 10));
1311
+ }
1312
+ if (this.conn !== null) {
1313
+ const socket2 = await this.conn;
1314
+ await this.wsSendMessage(socket2, context);
1315
+ return;
1316
+ }
1317
+ }
1260
1318
  if (this.wsReconnectTimer !== null) {
1261
1319
  this.debug(
1262
1320
  `Cancelling scheduled reconnection, connecting immediately`
@@ -1264,7 +1322,17 @@ var Schematic = class {
1264
1322
  clearTimeout(this.wsReconnectTimer);
1265
1323
  this.wsReconnectTimer = null;
1266
1324
  }
1267
- this.conn = this.wsConnect();
1325
+ this.isConnecting = true;
1326
+ try {
1327
+ this.conn = this.wsConnect();
1328
+ const socket2 = await this.conn;
1329
+ this.isConnecting = false;
1330
+ await this.wsSendMessage(socket2, context);
1331
+ return;
1332
+ } catch (error) {
1333
+ this.isConnecting = false;
1334
+ throw error;
1335
+ }
1268
1336
  }
1269
1337
  const socket = await this.conn;
1270
1338
  await this.wsSendMessage(socket, context);
@@ -1429,10 +1497,14 @@ var Schematic = class {
1429
1497
  }
1430
1498
  }
1431
1499
  if (readyEvents.length === 0) {
1432
- this.debug(`No events ready for retry yet (${notReadyEvents.length} still in backoff)`);
1500
+ this.debug(
1501
+ `No events ready for retry yet (${notReadyEvents.length} still in backoff)`
1502
+ );
1433
1503
  return;
1434
1504
  }
1435
- this.debug(`Flushing event queue: ${readyEvents.length} ready, ${notReadyEvents.length} waiting`);
1505
+ this.debug(
1506
+ `Flushing event queue: ${readyEvents.length} ready, ${notReadyEvents.length} waiting`
1507
+ );
1436
1508
  this.eventQueue = notReadyEvents;
1437
1509
  for (const event of readyEvents) {
1438
1510
  try {
@@ -1497,7 +1569,10 @@ var Schematic = class {
1497
1569
  } catch (error) {
1498
1570
  const retryCount = (event.retry_count ?? 0) + 1;
1499
1571
  if (retryCount <= this.maxEventRetries) {
1500
- this.debug(`Event failed to send (attempt ${retryCount}/${this.maxEventRetries}), queueing for retry:`, error);
1572
+ this.debug(
1573
+ `Event failed to send (attempt ${retryCount}/${this.maxEventRetries}), queueing for retry:`,
1574
+ error
1575
+ );
1501
1576
  const baseDelay = this.eventRetryInitialDelay * Math.pow(2, retryCount - 1);
1502
1577
  const jitterDelay = Math.min(baseDelay, this.eventRetryMaxDelay);
1503
1578
  const nextRetryAt = Date.now() + jitterDelay;
@@ -1508,15 +1583,22 @@ var Schematic = class {
1508
1583
  };
1509
1584
  if (this.eventQueue.length < this.maxEventQueueSize) {
1510
1585
  this.eventQueue.push(retryEvent);
1511
- this.debug(`Event queued for retry in ${jitterDelay}ms (${this.eventQueue.length}/${this.maxEventQueueSize})`);
1586
+ this.debug(
1587
+ `Event queued for retry in ${jitterDelay}ms (${this.eventQueue.length}/${this.maxEventQueueSize})`
1588
+ );
1512
1589
  } else {
1513
- this.debug(`Event queue full (${this.maxEventQueueSize}), dropping oldest event`);
1590
+ this.debug(
1591
+ `Event queue full (${this.maxEventQueueSize}), dropping oldest event`
1592
+ );
1514
1593
  this.eventQueue.shift();
1515
1594
  this.eventQueue.push(retryEvent);
1516
1595
  }
1517
1596
  this.startRetryTimer();
1518
1597
  } else {
1519
- this.debug(`Event failed permanently after ${this.maxEventRetries} attempts, dropping:`, error);
1598
+ this.debug(
1599
+ `Event failed permanently after ${this.maxEventRetries} attempts, dropping:`,
1600
+ error
1601
+ );
1520
1602
  }
1521
1603
  }
1522
1604
  return Promise.resolve();
@@ -1528,6 +1610,130 @@ var Schematic = class {
1528
1610
  /**
1529
1611
  * Websocket management
1530
1612
  */
1613
+ /**
1614
+ * Force an immediate WebSocket reconnection.
1615
+ * This is useful when the application returns from a background state (e.g., mobile app
1616
+ * coming back to foreground) and wants to immediately re-establish the connection
1617
+ * rather than waiting for the exponential backoff timer.
1618
+ *
1619
+ * This method will:
1620
+ * - Cancel any pending reconnection timer
1621
+ * - Reset the reconnection attempt counter
1622
+ * - Close any existing connection
1623
+ * - Immediately attempt to reconnect
1624
+ * - Re-send the current context to get fresh flag values
1625
+ *
1626
+ * Use this when you need guaranteed fresh values (e.g., after an in-app purchase).
1627
+ *
1628
+ * @example
1629
+ * ```typescript
1630
+ * // React Native example: reconnect when app comes to foreground
1631
+ * useEffect(() => {
1632
+ * const subscription = AppState.addEventListener("change", (state) => {
1633
+ * if (state === "active") {
1634
+ * client.forceReconnect();
1635
+ * }
1636
+ * });
1637
+ * return () => subscription.remove();
1638
+ * }, [client]);
1639
+ * ```
1640
+ */
1641
+ forceReconnect = async () => {
1642
+ return this.reconnect({ force: true });
1643
+ };
1644
+ /**
1645
+ * Reconnect the WebSocket connection only if the current connection is unhealthy.
1646
+ * This is useful when the application returns from a background state and wants to
1647
+ * ensure a healthy connection exists, but doesn't need to force a reconnection if
1648
+ * the connection is still active.
1649
+ *
1650
+ * This method will:
1651
+ * - Check if an existing connection is healthy (readyState === OPEN)
1652
+ * - If healthy, return immediately without reconnecting
1653
+ * - If unhealthy, perform the same reconnection logic as forceReconnect()
1654
+ *
1655
+ * Use this when you want efficient reconnection that avoids unnecessary disconnects.
1656
+ *
1657
+ * @example
1658
+ * ```typescript
1659
+ * // React Native example: reconnect only if needed when app comes to foreground
1660
+ * useEffect(() => {
1661
+ * const subscription = AppState.addEventListener("change", (state) => {
1662
+ * if (state === "active") {
1663
+ * client.reconnectIfNeeded();
1664
+ * }
1665
+ * });
1666
+ * return () => subscription.remove();
1667
+ * }, [client]);
1668
+ * ```
1669
+ */
1670
+ reconnectIfNeeded = async () => {
1671
+ return this.reconnect({ force: false });
1672
+ };
1673
+ /**
1674
+ * Internal method to handle reconnection logic for both forceReconnect and reconnectIfNeeded.
1675
+ */
1676
+ reconnect = async (options) => {
1677
+ const { force } = options;
1678
+ const methodName = force ? "forceReconnect" : "reconnectIfNeeded";
1679
+ if (this.isOffline()) {
1680
+ this.debug(`${methodName}: skipped (offline mode)`);
1681
+ return Promise.resolve();
1682
+ }
1683
+ if (!force && this.conn !== null) {
1684
+ try {
1685
+ const existingSocket = await this.conn;
1686
+ if (existingSocket.readyState === WebSocket.OPEN) {
1687
+ this.debug(`${methodName}: connection is healthy, skipping`);
1688
+ return Promise.resolve();
1689
+ }
1690
+ } catch {
1691
+ }
1692
+ }
1693
+ this.debug(
1694
+ `${methodName}: ${force ? "forcing immediate reconnection" : "reconnecting"}`
1695
+ );
1696
+ this.wsIntentionalDisconnect = false;
1697
+ if (this.wsReconnectTimer !== null) {
1698
+ this.debug(`${methodName}: cancelling pending reconnection timer`);
1699
+ clearTimeout(this.wsReconnectTimer);
1700
+ this.wsReconnectTimer = null;
1701
+ }
1702
+ this.wsReconnectAttempts = 0;
1703
+ if (this.conn !== null) {
1704
+ this.debug(`${methodName}: closing existing connection`);
1705
+ try {
1706
+ const existingSocket = await this.conn;
1707
+ if (this.currentWebSocket === existingSocket) {
1708
+ this.currentWebSocket = null;
1709
+ }
1710
+ if (existingSocket.readyState === WebSocket.OPEN || existingSocket.readyState === WebSocket.CONNECTING) {
1711
+ existingSocket.close();
1712
+ }
1713
+ } catch (error) {
1714
+ this.debug(`${methodName}: error closing existing connection:`, error);
1715
+ }
1716
+ this.conn = null;
1717
+ this.isConnecting = false;
1718
+ }
1719
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1720
+ this.debug(`${methodName}: reconnecting with existing context`);
1721
+ try {
1722
+ this.isConnecting = true;
1723
+ this.conn = this.wsConnect();
1724
+ const socket = await this.conn;
1725
+ this.isConnecting = false;
1726
+ await this.wsSendMessage(socket, this.context, true);
1727
+ this.debug(`${methodName}: reconnection successful`);
1728
+ } catch (error) {
1729
+ this.isConnecting = false;
1730
+ this.debug(`${methodName}: reconnection failed:`, error);
1731
+ }
1732
+ } else {
1733
+ this.debug(`${methodName}: no context set, skipping reconnection`);
1734
+ }
1735
+ return Promise.resolve();
1736
+ };
1531
1737
  /**
1532
1738
  * If using websocket mode, close the connection when done.
1533
1739
  * In offline mode, this is a no-op.
@@ -1546,11 +1752,17 @@ var Schematic = class {
1546
1752
  if (this.conn) {
1547
1753
  try {
1548
1754
  const socket = await this.conn;
1755
+ if (this.currentWebSocket === socket) {
1756
+ this.debug(`Cleaning up current websocket tracking`);
1757
+ this.currentWebSocket = null;
1758
+ }
1549
1759
  socket.close();
1550
1760
  } catch (error) {
1551
1761
  console.error("Error during cleanup:", error);
1552
1762
  } finally {
1553
1763
  this.conn = null;
1764
+ this.currentWebSocket = null;
1765
+ this.isConnecting = false;
1554
1766
  }
1555
1767
  }
1556
1768
  };
@@ -1575,7 +1787,9 @@ var Schematic = class {
1575
1787
  if (this.conn !== null) {
1576
1788
  try {
1577
1789
  const socket = await this.conn;
1578
- socket.close();
1790
+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
1791
+ socket.close();
1792
+ }
1579
1793
  } catch (error) {
1580
1794
  this.debug("Error closing connection on offline:", error);
1581
1795
  }
@@ -1590,7 +1804,9 @@ var Schematic = class {
1590
1804
  * Handle browser coming back online
1591
1805
  */
1592
1806
  handleNetworkOnline = () => {
1593
- this.debug("Network online, attempting reconnection and flushing queued events");
1807
+ this.debug(
1808
+ "Network online, attempting reconnection and flushing queued events"
1809
+ );
1594
1810
  this.wsReconnectAttempts = 0;
1595
1811
  if (this.wsReconnectTimer !== null) {
1596
1812
  clearTimeout(this.wsReconnectTimer);
@@ -1613,7 +1829,10 @@ var Schematic = class {
1613
1829
  return;
1614
1830
  }
1615
1831
  if (this.wsReconnectTimer !== null) {
1616
- clearTimeout(this.wsReconnectTimer);
1832
+ this.debug(
1833
+ `Reconnection attempt already scheduled, ignoring duplicate request`
1834
+ );
1835
+ return;
1617
1836
  }
1618
1837
  const delay = this.calculateReconnectDelay();
1619
1838
  this.debug(
@@ -1626,23 +1845,57 @@ var Schematic = class {
1626
1845
  `Attempting to reconnect (attempt ${this.wsReconnectAttempts}/${this.webSocketMaxReconnectAttempts})`
1627
1846
  );
1628
1847
  try {
1629
- this.conn = this.wsConnect();
1630
- const socket = await this.conn;
1631
- this.debug(`Reconnection context check:`, {
1632
- hasCompany: this.context.company !== void 0,
1633
- hasUser: this.context.user !== void 0,
1634
- context: this.context
1635
- });
1636
- if (this.context.company !== void 0 || this.context.user !== void 0) {
1637
- this.debug(`Reconnected, force re-sending context`);
1638
- await this.wsSendContextAfterReconnection(socket, this.context);
1639
- } else {
1640
- this.debug(`No context to re-send after reconnection - websocket ready for new context`);
1848
+ if (this.conn !== null) {
1849
+ this.debug(`Cleaning up existing connection before reconnection`);
1850
+ try {
1851
+ const existingSocket = await this.conn;
1852
+ if (this.currentWebSocket === existingSocket) {
1853
+ this.debug(`Existing websocket is current, will be replaced`);
1854
+ this.currentWebSocket = null;
1855
+ }
1856
+ if (existingSocket.readyState === WebSocket.OPEN || existingSocket.readyState === WebSocket.CONNECTING) {
1857
+ existingSocket.close();
1858
+ }
1859
+ } catch (error) {
1860
+ this.debug(`Error cleaning up existing connection:`, error);
1861
+ }
1862
+ this.conn = null;
1863
+ this.currentWebSocket = null;
1864
+ this.isConnecting = false;
1865
+ }
1866
+ this.isConnecting = true;
1867
+ try {
1868
+ this.conn = this.wsConnect();
1869
+ const socket = await this.conn;
1870
+ this.isConnecting = false;
1871
+ this.debug(`Reconnection context check:`, {
1872
+ hasCompany: this.context.company !== void 0,
1873
+ hasUser: this.context.user !== void 0,
1874
+ context: this.context
1875
+ });
1876
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1877
+ this.debug(`Reconnected, force re-sending context`);
1878
+ await this.wsSendMessage(socket, this.context, true);
1879
+ } else {
1880
+ this.debug(
1881
+ `No context to re-send after reconnection - websocket ready for new context`
1882
+ );
1883
+ this.debug(
1884
+ `Setting up tracking for reconnected websocket (no context to send)`
1885
+ );
1886
+ this.currentWebSocket = socket;
1887
+ }
1888
+ this.flushEventQueue().catch((error) => {
1889
+ this.debug(
1890
+ "Error flushing event queue after websocket reconnection:",
1891
+ error
1892
+ );
1893
+ });
1894
+ this.debug(`Reconnection successful`);
1895
+ } catch (error) {
1896
+ this.isConnecting = false;
1897
+ throw error;
1641
1898
  }
1642
- this.flushEventQueue().catch((error) => {
1643
- this.debug("Error flushing event queue after websocket reconnection:", error);
1644
- });
1645
- this.debug(`Reconnection successful`);
1646
1899
  } catch (error) {
1647
1900
  this.debug(`Reconnection attempt failed:`, error);
1648
1901
  }
@@ -1660,6 +1913,8 @@ var Schematic = class {
1660
1913
  const wsUrl = `${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;
1661
1914
  this.debug(`connecting to WebSocket:`, wsUrl);
1662
1915
  const webSocket = new WebSocket(wsUrl);
1916
+ const connectionId = Math.random().toString(36).substring(7);
1917
+ this.debug(`Creating WebSocket connection ${connectionId} to ${wsUrl}`);
1663
1918
  let timeoutId = null;
1664
1919
  let isResolved = false;
1665
1920
  timeoutId = setTimeout(() => {
@@ -1678,7 +1933,7 @@ var Schematic = class {
1678
1933
  }
1679
1934
  this.wsReconnectAttempts = 0;
1680
1935
  this.wsIntentionalDisconnect = false;
1681
- this.debug(`WebSocket connection opened`);
1936
+ this.debug(`WebSocket connection ${connectionId} opened successfully`);
1682
1937
  resolve(webSocket);
1683
1938
  };
1684
1939
  webSocket.onerror = (error) => {
@@ -1686,7 +1941,7 @@ var Schematic = class {
1686
1941
  if (timeoutId !== null) {
1687
1942
  clearTimeout(timeoutId);
1688
1943
  }
1689
- this.debug(`WebSocket connection error:`, error);
1944
+ this.debug(`WebSocket connection ${connectionId} error:`, error);
1690
1945
  reject(error);
1691
1946
  };
1692
1947
  webSocket.onclose = () => {
@@ -1694,121 +1949,48 @@ var Schematic = class {
1694
1949
  if (timeoutId !== null) {
1695
1950
  clearTimeout(timeoutId);
1696
1951
  }
1697
- this.debug(`WebSocket connection closed`);
1952
+ this.debug(`WebSocket connection ${connectionId} closed`);
1698
1953
  this.conn = null;
1954
+ if (this.currentWebSocket === webSocket) {
1955
+ this.currentWebSocket = null;
1956
+ this.isConnecting = false;
1957
+ }
1699
1958
  if (!this.wsIntentionalDisconnect && this.webSocketReconnect) {
1700
1959
  this.attemptReconnect();
1701
1960
  }
1702
1961
  };
1703
1962
  });
1704
1963
  };
1705
- // Send a message on the websocket after reconnection, forcing the send even if context appears unchanged
1706
- // because the server has lost all state and needs the initial context
1707
- wsSendContextAfterReconnection = (socket, context) => {
1708
- if (this.isOffline()) {
1709
- this.debug("wsSendContextAfterReconnection: skipped (offline mode)");
1710
- this.setIsPending(false);
1711
- return Promise.resolve();
1712
- }
1713
- return new Promise((resolve) => {
1714
- this.debug(`WebSocket force sending context after reconnection:`, context);
1715
- this.context = context;
1716
- const sendMessage = () => {
1717
- let resolved = false;
1718
- const messageHandler = (event) => {
1719
- const message = JSON.parse(event.data);
1720
- this.debug(`WebSocket message received after reconnection:`, message);
1721
- if (!(contextString(context) in this.checks)) {
1722
- this.checks[contextString(context)] = {};
1723
- }
1724
- (message.flags ?? []).forEach((flag) => {
1725
- const flagCheck = CheckFlagReturnFromJSON(flag);
1726
- const contextStr = contextString(context);
1727
- if (this.checks[contextStr] === void 0) {
1728
- this.checks[contextStr] = {};
1729
- }
1730
- this.checks[contextStr][flagCheck.flag] = flagCheck;
1731
- });
1732
- this.useWebSocket = true;
1733
- socket.removeEventListener("message", messageHandler);
1734
- if (!resolved) {
1735
- resolved = true;
1736
- resolve(this.setIsPending(false));
1737
- }
1738
- };
1739
- socket.addEventListener("message", messageHandler);
1740
- const clientVersion = this.additionalHeaders["X-Schematic-Client-Version"] ?? `schematic-js@${version}`;
1741
- const messagePayload = {
1742
- apiKey: this.apiKey,
1743
- clientVersion,
1744
- data: context
1745
- };
1746
- this.debug(`WebSocket sending forced message after reconnection:`, messagePayload);
1747
- socket.send(JSON.stringify(messagePayload));
1748
- };
1749
- if (socket.readyState === WebSocket.OPEN) {
1750
- this.debug(`WebSocket already open, sending forced message after reconnection`);
1751
- sendMessage();
1752
- } else {
1753
- socket.addEventListener("open", () => {
1754
- this.debug(`WebSocket opened, sending forced message after reconnection`);
1755
- sendMessage();
1756
- });
1757
- }
1758
- });
1759
- };
1760
1964
  // Send a message on the websocket indicating interest in a particular evaluation context
1761
1965
  // and wait for the initial set of flag values to be returned
1762
- wsSendMessage = (socket, context) => {
1966
+ wsSendMessage = (socket, context, forceContextSend = false) => {
1763
1967
  if (this.isOffline()) {
1764
1968
  this.debug("wsSendMessage: skipped (offline mode)");
1765
1969
  this.setIsPending(false);
1766
1970
  return Promise.resolve();
1767
1971
  }
1768
1972
  return new Promise((resolve, reject) => {
1769
- if (contextString(context) == contextString(this.context)) {
1973
+ if (!forceContextSend && contextString(context) == contextString(this.context)) {
1770
1974
  this.debug(`WebSocket context unchanged, skipping update`);
1771
1975
  return resolve(this.setIsPending(false));
1772
1976
  }
1773
- this.debug(`WebSocket context updated:`, context);
1977
+ this.debug(
1978
+ forceContextSend ? `WebSocket force sending context (reconnection):` : `WebSocket context updated:`,
1979
+ context
1980
+ );
1774
1981
  this.context = context;
1775
1982
  const sendMessage = () => {
1776
1983
  let resolved = false;
1984
+ const persistentMessageHandler = this.createPersistentMessageHandler(context);
1777
1985
  const messageHandler = (event) => {
1778
- const message = JSON.parse(event.data);
1779
- this.debug(`WebSocket message received:`, message);
1780
- if (!(contextString(context) in this.checks)) {
1781
- this.checks[contextString(context)] = {};
1782
- }
1783
- (message.flags ?? []).forEach((flag) => {
1784
- const flagCheck = CheckFlagReturnFromJSON(flag);
1785
- const contextStr = contextString(context);
1786
- if (this.checks[contextStr] === void 0) {
1787
- this.checks[contextStr] = {};
1788
- }
1789
- this.checks[contextStr][flagCheck.flag] = flagCheck;
1790
- this.debug(`WebSocket flag update:`, {
1791
- flag: flagCheck.flag,
1792
- value: flagCheck.value,
1793
- flagCheck
1794
- });
1795
- if (typeof flagCheck.featureUsageEvent === "string") {
1796
- this.updateFeatureUsageEventMap(flagCheck);
1797
- }
1798
- if (this.flagCheckListeners[flag.flag]?.size > 0 || this.flagValueListeners[flag.flag]?.size > 0) {
1799
- this.submitFlagCheckEvent(flagCheck.flag, flagCheck, context);
1800
- }
1801
- this.notifyFlagCheckListeners(flag.flag, flagCheck);
1802
- this.notifyFlagValueListeners(flag.flag, flagCheck.value);
1803
- });
1804
- this.flushContextDependentEventQueue();
1805
- this.setIsPending(false);
1986
+ persistentMessageHandler(event);
1806
1987
  if (!resolved) {
1807
1988
  resolved = true;
1808
1989
  resolve();
1809
1990
  }
1810
1991
  };
1811
1992
  socket.addEventListener("message", messageHandler);
1993
+ this.currentWebSocket = socket;
1812
1994
  const clientVersion = this.additionalHeaders["X-Schematic-Client-Version"] ?? `schematic-js@${version}`;
1813
1995
  const messagePayload = {
1814
1996
  apiKey: this.apiKey,
@@ -1916,7 +2098,17 @@ var Schematic = class {
1916
2098
  { value }
1917
2099
  );
1918
2100
  }
1919
- listeners.forEach((listener) => notifyFlagValueListener(listener, value));
2101
+ listeners.forEach((listener, index) => {
2102
+ this.debug(`Calling listener ${index} for flag ${flagKey}`, {
2103
+ flagKey,
2104
+ value
2105
+ });
2106
+ notifyFlagValueListener(listener, value);
2107
+ this.debug(`Listener ${index} for flag ${flagKey} completed`, {
2108
+ flagKey,
2109
+ value
2110
+ });
2111
+ });
1920
2112
  };
1921
2113
  };
1922
2114
  var notifyPendingListener = (listener, value) => {
@@ -1945,7 +2137,7 @@ var notifyFlagValueListener = (listener, value) => {
1945
2137
  var import_react = __toESM(require("react"));
1946
2138
 
1947
2139
  // src/version.ts
1948
- var version2 = "1.2.9";
2140
+ var version2 = "1.2.12";
1949
2141
 
1950
2142
  // src/context/schematic.tsx
1951
2143
  var import_jsx_runtime = require("react/jsx-runtime");
@@ -1960,7 +2152,7 @@ var SchematicProvider = ({
1960
2152
  }) => {
1961
2153
  const initialOptsRef = (0, import_react.useRef)({
1962
2154
  publishableKey,
1963
- useWebSocket: clientOpts.useWebSocket ?? true,
2155
+ useWebSocket: true,
1964
2156
  additionalHeaders: {
1965
2157
  "X-Schematic-Client-Version": `schematic-react@${version2}`
1966
2158
  },
@@ -15,7 +15,7 @@ import { StoragePersister } from '@schematichq/schematic-js';
15
15
  import { Traits } from '@schematichq/schematic-js';
16
16
  import { UsagePeriod } from '@schematichq/schematic-js';
17
17
 
18
- declare type BaseSchematicProviderProps = Omit<SchematicJS.SchematicOptions, "client" | "publishableKey"> & {
18
+ declare type BaseSchematicProviderProps = Omit<SchematicJS.SchematicOptions, "client" | "publishableKey" | "useWebSocket"> & {
19
19
  children: React_2.ReactNode;
20
20
  };
21
21
 
@@ -754,7 +754,7 @@ function contextString(context) {
754
754
  }, {});
755
755
  return JSON.stringify(sortedContext);
756
756
  }
757
- var version = "1.2.9";
757
+ var version = "1.2.12";
758
758
  var anonymousIdKey = "schematicId";
759
759
  var Schematic = class {
760
760
  additionalHeaders = {};
@@ -784,6 +784,8 @@ var Schematic = class {
784
784
  wsReconnectAttempts = 0;
785
785
  wsReconnectTimer = null;
786
786
  wsIntentionalDisconnect = false;
787
+ currentWebSocket = null;
788
+ isConnecting = false;
787
789
  maxEventQueueSize = 100;
788
790
  // Prevent memory issues with very long network outages
789
791
  maxEventRetries = 5;
@@ -1061,6 +1063,49 @@ var Schematic = class {
1061
1063
  console.log(`[Schematic] ${message}`, ...args);
1062
1064
  }
1063
1065
  }
1066
+ /**
1067
+ * Create a persistent message handler for websocket flag updates
1068
+ */
1069
+ createPersistentMessageHandler(context) {
1070
+ return (event) => {
1071
+ const message = JSON.parse(event.data);
1072
+ this.debug(`WebSocket persistent message received:`, message);
1073
+ if (!(contextString(context) in this.checks)) {
1074
+ this.checks[contextString(context)] = {};
1075
+ }
1076
+ (message.flags ?? []).forEach((flag) => {
1077
+ const flagCheck = CheckFlagReturnFromJSON(flag);
1078
+ const contextStr = contextString(context);
1079
+ if (this.checks[contextStr] === void 0) {
1080
+ this.checks[contextStr] = {};
1081
+ }
1082
+ this.checks[contextStr][flagCheck.flag] = flagCheck;
1083
+ this.debug(`WebSocket flag update:`, {
1084
+ flag: flagCheck.flag,
1085
+ value: flagCheck.value,
1086
+ flagCheck
1087
+ });
1088
+ if (typeof flagCheck.featureUsageEvent === "string") {
1089
+ this.updateFeatureUsageEventMap(flagCheck);
1090
+ }
1091
+ if ((this.flagCheckListeners[flag.flag]?.size ?? 0) > 0 || (this.flagValueListeners[flag.flag]?.size ?? 0) > 0) {
1092
+ this.submitFlagCheckEvent(flagCheck.flag, flagCheck, context);
1093
+ }
1094
+ this.debug(`About to notify listeners for flag ${flag.flag}`, {
1095
+ flag: flag.flag,
1096
+ value: flagCheck.value
1097
+ });
1098
+ this.notifyFlagCheckListeners(flag.flag, flagCheck);
1099
+ this.notifyFlagValueListeners(flag.flag, flagCheck.value);
1100
+ this.debug(`Finished notifying listeners for flag ${flag.flag}`, {
1101
+ flag: flag.flag,
1102
+ value: flagCheck.value
1103
+ });
1104
+ });
1105
+ this.flushContextDependentEventQueue();
1106
+ this.setIsPending(false);
1107
+ };
1108
+ }
1064
1109
  /**
1065
1110
  * Helper function to check if client is in offline mode
1066
1111
  */
@@ -1212,6 +1257,19 @@ var Schematic = class {
1212
1257
  try {
1213
1258
  this.setIsPending(true);
1214
1259
  if (!this.conn) {
1260
+ if (this.isConnecting) {
1261
+ this.debug(
1262
+ `Connection already in progress, waiting for it to complete`
1263
+ );
1264
+ while (this.isConnecting && this.conn === null) {
1265
+ await new Promise((resolve) => setTimeout(resolve, 10));
1266
+ }
1267
+ if (this.conn !== null) {
1268
+ const socket2 = await this.conn;
1269
+ await this.wsSendMessage(socket2, context);
1270
+ return;
1271
+ }
1272
+ }
1215
1273
  if (this.wsReconnectTimer !== null) {
1216
1274
  this.debug(
1217
1275
  `Cancelling scheduled reconnection, connecting immediately`
@@ -1219,7 +1277,17 @@ var Schematic = class {
1219
1277
  clearTimeout(this.wsReconnectTimer);
1220
1278
  this.wsReconnectTimer = null;
1221
1279
  }
1222
- this.conn = this.wsConnect();
1280
+ this.isConnecting = true;
1281
+ try {
1282
+ this.conn = this.wsConnect();
1283
+ const socket2 = await this.conn;
1284
+ this.isConnecting = false;
1285
+ await this.wsSendMessage(socket2, context);
1286
+ return;
1287
+ } catch (error) {
1288
+ this.isConnecting = false;
1289
+ throw error;
1290
+ }
1223
1291
  }
1224
1292
  const socket = await this.conn;
1225
1293
  await this.wsSendMessage(socket, context);
@@ -1384,10 +1452,14 @@ var Schematic = class {
1384
1452
  }
1385
1453
  }
1386
1454
  if (readyEvents.length === 0) {
1387
- this.debug(`No events ready for retry yet (${notReadyEvents.length} still in backoff)`);
1455
+ this.debug(
1456
+ `No events ready for retry yet (${notReadyEvents.length} still in backoff)`
1457
+ );
1388
1458
  return;
1389
1459
  }
1390
- this.debug(`Flushing event queue: ${readyEvents.length} ready, ${notReadyEvents.length} waiting`);
1460
+ this.debug(
1461
+ `Flushing event queue: ${readyEvents.length} ready, ${notReadyEvents.length} waiting`
1462
+ );
1391
1463
  this.eventQueue = notReadyEvents;
1392
1464
  for (const event of readyEvents) {
1393
1465
  try {
@@ -1452,7 +1524,10 @@ var Schematic = class {
1452
1524
  } catch (error) {
1453
1525
  const retryCount = (event.retry_count ?? 0) + 1;
1454
1526
  if (retryCount <= this.maxEventRetries) {
1455
- this.debug(`Event failed to send (attempt ${retryCount}/${this.maxEventRetries}), queueing for retry:`, error);
1527
+ this.debug(
1528
+ `Event failed to send (attempt ${retryCount}/${this.maxEventRetries}), queueing for retry:`,
1529
+ error
1530
+ );
1456
1531
  const baseDelay = this.eventRetryInitialDelay * Math.pow(2, retryCount - 1);
1457
1532
  const jitterDelay = Math.min(baseDelay, this.eventRetryMaxDelay);
1458
1533
  const nextRetryAt = Date.now() + jitterDelay;
@@ -1463,15 +1538,22 @@ var Schematic = class {
1463
1538
  };
1464
1539
  if (this.eventQueue.length < this.maxEventQueueSize) {
1465
1540
  this.eventQueue.push(retryEvent);
1466
- this.debug(`Event queued for retry in ${jitterDelay}ms (${this.eventQueue.length}/${this.maxEventQueueSize})`);
1541
+ this.debug(
1542
+ `Event queued for retry in ${jitterDelay}ms (${this.eventQueue.length}/${this.maxEventQueueSize})`
1543
+ );
1467
1544
  } else {
1468
- this.debug(`Event queue full (${this.maxEventQueueSize}), dropping oldest event`);
1545
+ this.debug(
1546
+ `Event queue full (${this.maxEventQueueSize}), dropping oldest event`
1547
+ );
1469
1548
  this.eventQueue.shift();
1470
1549
  this.eventQueue.push(retryEvent);
1471
1550
  }
1472
1551
  this.startRetryTimer();
1473
1552
  } else {
1474
- this.debug(`Event failed permanently after ${this.maxEventRetries} attempts, dropping:`, error);
1553
+ this.debug(
1554
+ `Event failed permanently after ${this.maxEventRetries} attempts, dropping:`,
1555
+ error
1556
+ );
1475
1557
  }
1476
1558
  }
1477
1559
  return Promise.resolve();
@@ -1483,6 +1565,130 @@ var Schematic = class {
1483
1565
  /**
1484
1566
  * Websocket management
1485
1567
  */
1568
+ /**
1569
+ * Force an immediate WebSocket reconnection.
1570
+ * This is useful when the application returns from a background state (e.g., mobile app
1571
+ * coming back to foreground) and wants to immediately re-establish the connection
1572
+ * rather than waiting for the exponential backoff timer.
1573
+ *
1574
+ * This method will:
1575
+ * - Cancel any pending reconnection timer
1576
+ * - Reset the reconnection attempt counter
1577
+ * - Close any existing connection
1578
+ * - Immediately attempt to reconnect
1579
+ * - Re-send the current context to get fresh flag values
1580
+ *
1581
+ * Use this when you need guaranteed fresh values (e.g., after an in-app purchase).
1582
+ *
1583
+ * @example
1584
+ * ```typescript
1585
+ * // React Native example: reconnect when app comes to foreground
1586
+ * useEffect(() => {
1587
+ * const subscription = AppState.addEventListener("change", (state) => {
1588
+ * if (state === "active") {
1589
+ * client.forceReconnect();
1590
+ * }
1591
+ * });
1592
+ * return () => subscription.remove();
1593
+ * }, [client]);
1594
+ * ```
1595
+ */
1596
+ forceReconnect = async () => {
1597
+ return this.reconnect({ force: true });
1598
+ };
1599
+ /**
1600
+ * Reconnect the WebSocket connection only if the current connection is unhealthy.
1601
+ * This is useful when the application returns from a background state and wants to
1602
+ * ensure a healthy connection exists, but doesn't need to force a reconnection if
1603
+ * the connection is still active.
1604
+ *
1605
+ * This method will:
1606
+ * - Check if an existing connection is healthy (readyState === OPEN)
1607
+ * - If healthy, return immediately without reconnecting
1608
+ * - If unhealthy, perform the same reconnection logic as forceReconnect()
1609
+ *
1610
+ * Use this when you want efficient reconnection that avoids unnecessary disconnects.
1611
+ *
1612
+ * @example
1613
+ * ```typescript
1614
+ * // React Native example: reconnect only if needed when app comes to foreground
1615
+ * useEffect(() => {
1616
+ * const subscription = AppState.addEventListener("change", (state) => {
1617
+ * if (state === "active") {
1618
+ * client.reconnectIfNeeded();
1619
+ * }
1620
+ * });
1621
+ * return () => subscription.remove();
1622
+ * }, [client]);
1623
+ * ```
1624
+ */
1625
+ reconnectIfNeeded = async () => {
1626
+ return this.reconnect({ force: false });
1627
+ };
1628
+ /**
1629
+ * Internal method to handle reconnection logic for both forceReconnect and reconnectIfNeeded.
1630
+ */
1631
+ reconnect = async (options) => {
1632
+ const { force } = options;
1633
+ const methodName = force ? "forceReconnect" : "reconnectIfNeeded";
1634
+ if (this.isOffline()) {
1635
+ this.debug(`${methodName}: skipped (offline mode)`);
1636
+ return Promise.resolve();
1637
+ }
1638
+ if (!force && this.conn !== null) {
1639
+ try {
1640
+ const existingSocket = await this.conn;
1641
+ if (existingSocket.readyState === WebSocket.OPEN) {
1642
+ this.debug(`${methodName}: connection is healthy, skipping`);
1643
+ return Promise.resolve();
1644
+ }
1645
+ } catch {
1646
+ }
1647
+ }
1648
+ this.debug(
1649
+ `${methodName}: ${force ? "forcing immediate reconnection" : "reconnecting"}`
1650
+ );
1651
+ this.wsIntentionalDisconnect = false;
1652
+ if (this.wsReconnectTimer !== null) {
1653
+ this.debug(`${methodName}: cancelling pending reconnection timer`);
1654
+ clearTimeout(this.wsReconnectTimer);
1655
+ this.wsReconnectTimer = null;
1656
+ }
1657
+ this.wsReconnectAttempts = 0;
1658
+ if (this.conn !== null) {
1659
+ this.debug(`${methodName}: closing existing connection`);
1660
+ try {
1661
+ const existingSocket = await this.conn;
1662
+ if (this.currentWebSocket === existingSocket) {
1663
+ this.currentWebSocket = null;
1664
+ }
1665
+ if (existingSocket.readyState === WebSocket.OPEN || existingSocket.readyState === WebSocket.CONNECTING) {
1666
+ existingSocket.close();
1667
+ }
1668
+ } catch (error) {
1669
+ this.debug(`${methodName}: error closing existing connection:`, error);
1670
+ }
1671
+ this.conn = null;
1672
+ this.isConnecting = false;
1673
+ }
1674
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1675
+ this.debug(`${methodName}: reconnecting with existing context`);
1676
+ try {
1677
+ this.isConnecting = true;
1678
+ this.conn = this.wsConnect();
1679
+ const socket = await this.conn;
1680
+ this.isConnecting = false;
1681
+ await this.wsSendMessage(socket, this.context, true);
1682
+ this.debug(`${methodName}: reconnection successful`);
1683
+ } catch (error) {
1684
+ this.isConnecting = false;
1685
+ this.debug(`${methodName}: reconnection failed:`, error);
1686
+ }
1687
+ } else {
1688
+ this.debug(`${methodName}: no context set, skipping reconnection`);
1689
+ }
1690
+ return Promise.resolve();
1691
+ };
1486
1692
  /**
1487
1693
  * If using websocket mode, close the connection when done.
1488
1694
  * In offline mode, this is a no-op.
@@ -1501,11 +1707,17 @@ var Schematic = class {
1501
1707
  if (this.conn) {
1502
1708
  try {
1503
1709
  const socket = await this.conn;
1710
+ if (this.currentWebSocket === socket) {
1711
+ this.debug(`Cleaning up current websocket tracking`);
1712
+ this.currentWebSocket = null;
1713
+ }
1504
1714
  socket.close();
1505
1715
  } catch (error) {
1506
1716
  console.error("Error during cleanup:", error);
1507
1717
  } finally {
1508
1718
  this.conn = null;
1719
+ this.currentWebSocket = null;
1720
+ this.isConnecting = false;
1509
1721
  }
1510
1722
  }
1511
1723
  };
@@ -1530,7 +1742,9 @@ var Schematic = class {
1530
1742
  if (this.conn !== null) {
1531
1743
  try {
1532
1744
  const socket = await this.conn;
1533
- socket.close();
1745
+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
1746
+ socket.close();
1747
+ }
1534
1748
  } catch (error) {
1535
1749
  this.debug("Error closing connection on offline:", error);
1536
1750
  }
@@ -1545,7 +1759,9 @@ var Schematic = class {
1545
1759
  * Handle browser coming back online
1546
1760
  */
1547
1761
  handleNetworkOnline = () => {
1548
- this.debug("Network online, attempting reconnection and flushing queued events");
1762
+ this.debug(
1763
+ "Network online, attempting reconnection and flushing queued events"
1764
+ );
1549
1765
  this.wsReconnectAttempts = 0;
1550
1766
  if (this.wsReconnectTimer !== null) {
1551
1767
  clearTimeout(this.wsReconnectTimer);
@@ -1568,7 +1784,10 @@ var Schematic = class {
1568
1784
  return;
1569
1785
  }
1570
1786
  if (this.wsReconnectTimer !== null) {
1571
- clearTimeout(this.wsReconnectTimer);
1787
+ this.debug(
1788
+ `Reconnection attempt already scheduled, ignoring duplicate request`
1789
+ );
1790
+ return;
1572
1791
  }
1573
1792
  const delay = this.calculateReconnectDelay();
1574
1793
  this.debug(
@@ -1581,23 +1800,57 @@ var Schematic = class {
1581
1800
  `Attempting to reconnect (attempt ${this.wsReconnectAttempts}/${this.webSocketMaxReconnectAttempts})`
1582
1801
  );
1583
1802
  try {
1584
- this.conn = this.wsConnect();
1585
- const socket = await this.conn;
1586
- this.debug(`Reconnection context check:`, {
1587
- hasCompany: this.context.company !== void 0,
1588
- hasUser: this.context.user !== void 0,
1589
- context: this.context
1590
- });
1591
- if (this.context.company !== void 0 || this.context.user !== void 0) {
1592
- this.debug(`Reconnected, force re-sending context`);
1593
- await this.wsSendContextAfterReconnection(socket, this.context);
1594
- } else {
1595
- this.debug(`No context to re-send after reconnection - websocket ready for new context`);
1803
+ if (this.conn !== null) {
1804
+ this.debug(`Cleaning up existing connection before reconnection`);
1805
+ try {
1806
+ const existingSocket = await this.conn;
1807
+ if (this.currentWebSocket === existingSocket) {
1808
+ this.debug(`Existing websocket is current, will be replaced`);
1809
+ this.currentWebSocket = null;
1810
+ }
1811
+ if (existingSocket.readyState === WebSocket.OPEN || existingSocket.readyState === WebSocket.CONNECTING) {
1812
+ existingSocket.close();
1813
+ }
1814
+ } catch (error) {
1815
+ this.debug(`Error cleaning up existing connection:`, error);
1816
+ }
1817
+ this.conn = null;
1818
+ this.currentWebSocket = null;
1819
+ this.isConnecting = false;
1820
+ }
1821
+ this.isConnecting = true;
1822
+ try {
1823
+ this.conn = this.wsConnect();
1824
+ const socket = await this.conn;
1825
+ this.isConnecting = false;
1826
+ this.debug(`Reconnection context check:`, {
1827
+ hasCompany: this.context.company !== void 0,
1828
+ hasUser: this.context.user !== void 0,
1829
+ context: this.context
1830
+ });
1831
+ if (this.context.company !== void 0 || this.context.user !== void 0) {
1832
+ this.debug(`Reconnected, force re-sending context`);
1833
+ await this.wsSendMessage(socket, this.context, true);
1834
+ } else {
1835
+ this.debug(
1836
+ `No context to re-send after reconnection - websocket ready for new context`
1837
+ );
1838
+ this.debug(
1839
+ `Setting up tracking for reconnected websocket (no context to send)`
1840
+ );
1841
+ this.currentWebSocket = socket;
1842
+ }
1843
+ this.flushEventQueue().catch((error) => {
1844
+ this.debug(
1845
+ "Error flushing event queue after websocket reconnection:",
1846
+ error
1847
+ );
1848
+ });
1849
+ this.debug(`Reconnection successful`);
1850
+ } catch (error) {
1851
+ this.isConnecting = false;
1852
+ throw error;
1596
1853
  }
1597
- this.flushEventQueue().catch((error) => {
1598
- this.debug("Error flushing event queue after websocket reconnection:", error);
1599
- });
1600
- this.debug(`Reconnection successful`);
1601
1854
  } catch (error) {
1602
1855
  this.debug(`Reconnection attempt failed:`, error);
1603
1856
  }
@@ -1615,6 +1868,8 @@ var Schematic = class {
1615
1868
  const wsUrl = `${this.webSocketUrl}/flags/bootstrap?apiKey=${this.apiKey}`;
1616
1869
  this.debug(`connecting to WebSocket:`, wsUrl);
1617
1870
  const webSocket = new WebSocket(wsUrl);
1871
+ const connectionId = Math.random().toString(36).substring(7);
1872
+ this.debug(`Creating WebSocket connection ${connectionId} to ${wsUrl}`);
1618
1873
  let timeoutId = null;
1619
1874
  let isResolved = false;
1620
1875
  timeoutId = setTimeout(() => {
@@ -1633,7 +1888,7 @@ var Schematic = class {
1633
1888
  }
1634
1889
  this.wsReconnectAttempts = 0;
1635
1890
  this.wsIntentionalDisconnect = false;
1636
- this.debug(`WebSocket connection opened`);
1891
+ this.debug(`WebSocket connection ${connectionId} opened successfully`);
1637
1892
  resolve(webSocket);
1638
1893
  };
1639
1894
  webSocket.onerror = (error) => {
@@ -1641,7 +1896,7 @@ var Schematic = class {
1641
1896
  if (timeoutId !== null) {
1642
1897
  clearTimeout(timeoutId);
1643
1898
  }
1644
- this.debug(`WebSocket connection error:`, error);
1899
+ this.debug(`WebSocket connection ${connectionId} error:`, error);
1645
1900
  reject(error);
1646
1901
  };
1647
1902
  webSocket.onclose = () => {
@@ -1649,121 +1904,48 @@ var Schematic = class {
1649
1904
  if (timeoutId !== null) {
1650
1905
  clearTimeout(timeoutId);
1651
1906
  }
1652
- this.debug(`WebSocket connection closed`);
1907
+ this.debug(`WebSocket connection ${connectionId} closed`);
1653
1908
  this.conn = null;
1909
+ if (this.currentWebSocket === webSocket) {
1910
+ this.currentWebSocket = null;
1911
+ this.isConnecting = false;
1912
+ }
1654
1913
  if (!this.wsIntentionalDisconnect && this.webSocketReconnect) {
1655
1914
  this.attemptReconnect();
1656
1915
  }
1657
1916
  };
1658
1917
  });
1659
1918
  };
1660
- // Send a message on the websocket after reconnection, forcing the send even if context appears unchanged
1661
- // because the server has lost all state and needs the initial context
1662
- wsSendContextAfterReconnection = (socket, context) => {
1663
- if (this.isOffline()) {
1664
- this.debug("wsSendContextAfterReconnection: skipped (offline mode)");
1665
- this.setIsPending(false);
1666
- return Promise.resolve();
1667
- }
1668
- return new Promise((resolve) => {
1669
- this.debug(`WebSocket force sending context after reconnection:`, context);
1670
- this.context = context;
1671
- const sendMessage = () => {
1672
- let resolved = false;
1673
- const messageHandler = (event) => {
1674
- const message = JSON.parse(event.data);
1675
- this.debug(`WebSocket message received after reconnection:`, message);
1676
- if (!(contextString(context) in this.checks)) {
1677
- this.checks[contextString(context)] = {};
1678
- }
1679
- (message.flags ?? []).forEach((flag) => {
1680
- const flagCheck = CheckFlagReturnFromJSON(flag);
1681
- const contextStr = contextString(context);
1682
- if (this.checks[contextStr] === void 0) {
1683
- this.checks[contextStr] = {};
1684
- }
1685
- this.checks[contextStr][flagCheck.flag] = flagCheck;
1686
- });
1687
- this.useWebSocket = true;
1688
- socket.removeEventListener("message", messageHandler);
1689
- if (!resolved) {
1690
- resolved = true;
1691
- resolve(this.setIsPending(false));
1692
- }
1693
- };
1694
- socket.addEventListener("message", messageHandler);
1695
- const clientVersion = this.additionalHeaders["X-Schematic-Client-Version"] ?? `schematic-js@${version}`;
1696
- const messagePayload = {
1697
- apiKey: this.apiKey,
1698
- clientVersion,
1699
- data: context
1700
- };
1701
- this.debug(`WebSocket sending forced message after reconnection:`, messagePayload);
1702
- socket.send(JSON.stringify(messagePayload));
1703
- };
1704
- if (socket.readyState === WebSocket.OPEN) {
1705
- this.debug(`WebSocket already open, sending forced message after reconnection`);
1706
- sendMessage();
1707
- } else {
1708
- socket.addEventListener("open", () => {
1709
- this.debug(`WebSocket opened, sending forced message after reconnection`);
1710
- sendMessage();
1711
- });
1712
- }
1713
- });
1714
- };
1715
1919
  // Send a message on the websocket indicating interest in a particular evaluation context
1716
1920
  // and wait for the initial set of flag values to be returned
1717
- wsSendMessage = (socket, context) => {
1921
+ wsSendMessage = (socket, context, forceContextSend = false) => {
1718
1922
  if (this.isOffline()) {
1719
1923
  this.debug("wsSendMessage: skipped (offline mode)");
1720
1924
  this.setIsPending(false);
1721
1925
  return Promise.resolve();
1722
1926
  }
1723
1927
  return new Promise((resolve, reject) => {
1724
- if (contextString(context) == contextString(this.context)) {
1928
+ if (!forceContextSend && contextString(context) == contextString(this.context)) {
1725
1929
  this.debug(`WebSocket context unchanged, skipping update`);
1726
1930
  return resolve(this.setIsPending(false));
1727
1931
  }
1728
- this.debug(`WebSocket context updated:`, context);
1932
+ this.debug(
1933
+ forceContextSend ? `WebSocket force sending context (reconnection):` : `WebSocket context updated:`,
1934
+ context
1935
+ );
1729
1936
  this.context = context;
1730
1937
  const sendMessage = () => {
1731
1938
  let resolved = false;
1939
+ const persistentMessageHandler = this.createPersistentMessageHandler(context);
1732
1940
  const messageHandler = (event) => {
1733
- const message = JSON.parse(event.data);
1734
- this.debug(`WebSocket message received:`, message);
1735
- if (!(contextString(context) in this.checks)) {
1736
- this.checks[contextString(context)] = {};
1737
- }
1738
- (message.flags ?? []).forEach((flag) => {
1739
- const flagCheck = CheckFlagReturnFromJSON(flag);
1740
- const contextStr = contextString(context);
1741
- if (this.checks[contextStr] === void 0) {
1742
- this.checks[contextStr] = {};
1743
- }
1744
- this.checks[contextStr][flagCheck.flag] = flagCheck;
1745
- this.debug(`WebSocket flag update:`, {
1746
- flag: flagCheck.flag,
1747
- value: flagCheck.value,
1748
- flagCheck
1749
- });
1750
- if (typeof flagCheck.featureUsageEvent === "string") {
1751
- this.updateFeatureUsageEventMap(flagCheck);
1752
- }
1753
- if (this.flagCheckListeners[flag.flag]?.size > 0 || this.flagValueListeners[flag.flag]?.size > 0) {
1754
- this.submitFlagCheckEvent(flagCheck.flag, flagCheck, context);
1755
- }
1756
- this.notifyFlagCheckListeners(flag.flag, flagCheck);
1757
- this.notifyFlagValueListeners(flag.flag, flagCheck.value);
1758
- });
1759
- this.flushContextDependentEventQueue();
1760
- this.setIsPending(false);
1941
+ persistentMessageHandler(event);
1761
1942
  if (!resolved) {
1762
1943
  resolved = true;
1763
1944
  resolve();
1764
1945
  }
1765
1946
  };
1766
1947
  socket.addEventListener("message", messageHandler);
1948
+ this.currentWebSocket = socket;
1767
1949
  const clientVersion = this.additionalHeaders["X-Schematic-Client-Version"] ?? `schematic-js@${version}`;
1768
1950
  const messagePayload = {
1769
1951
  apiKey: this.apiKey,
@@ -1871,7 +2053,17 @@ var Schematic = class {
1871
2053
  { value }
1872
2054
  );
1873
2055
  }
1874
- listeners.forEach((listener) => notifyFlagValueListener(listener, value));
2056
+ listeners.forEach((listener, index) => {
2057
+ this.debug(`Calling listener ${index} for flag ${flagKey}`, {
2058
+ flagKey,
2059
+ value
2060
+ });
2061
+ notifyFlagValueListener(listener, value);
2062
+ this.debug(`Listener ${index} for flag ${flagKey} completed`, {
2063
+ flagKey,
2064
+ value
2065
+ });
2066
+ });
1875
2067
  };
1876
2068
  };
1877
2069
  var notifyPendingListener = (listener, value) => {
@@ -1900,7 +2092,7 @@ var notifyFlagValueListener = (listener, value) => {
1900
2092
  import React, { createContext, useEffect, useMemo, useRef } from "react";
1901
2093
 
1902
2094
  // src/version.ts
1903
- var version2 = "1.2.9";
2095
+ var version2 = "1.2.12";
1904
2096
 
1905
2097
  // src/context/schematic.tsx
1906
2098
  import { jsx } from "react/jsx-runtime";
@@ -1915,7 +2107,7 @@ var SchematicProvider = ({
1915
2107
  }) => {
1916
2108
  const initialOptsRef = useRef({
1917
2109
  publishableKey,
1918
- useWebSocket: clientOpts.useWebSocket ?? true,
2110
+ useWebSocket: true,
1919
2111
  additionalHeaders: {
1920
2112
  "X-Schematic-Client-Version": `schematic-react@${version2}`
1921
2113
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schematichq/schematic-react",
3
- "version": "1.2.9",
3
+ "version": "1.2.12",
4
4
  "main": "dist/schematic-react.cjs.js",
5
5
  "module": "dist/schematic-react.esm.js",
6
6
  "types": "dist/schematic-react.d.ts",
@@ -31,7 +31,7 @@
31
31
  "prepare": "husky"
32
32
  },
33
33
  "dependencies": {
34
- "@schematichq/schematic-js": "^1.2.9"
34
+ "@schematichq/schematic-js": "^1.2.12"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@eslint/js": "^9.39.1",