@clianta/sdk 1.6.8 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.cjs.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Clianta SDK v1.6.8
2
+ * Clianta SDK v1.7.1
3
3
  * (c) 2026 Clianta
4
4
  * Released under the MIT License.
5
5
  */
@@ -14,7 +14,7 @@ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentS
14
14
  * @see SDK_VERSION in core/config.ts
15
15
  */
16
16
  /** SDK Version */
17
- const SDK_VERSION = '1.6.7';
17
+ const SDK_VERSION = '1.7.0';
18
18
  /** Default API endpoint — reads from env or falls back to localhost */
19
19
  const getDefaultApiEndpoint = () => {
20
20
  // Next.js (process.env)
@@ -40,6 +40,15 @@ const getDefaultApiEndpoint = () => {
40
40
  if (typeof process !== 'undefined' && process.env?.CLIANTA_API_ENDPOINT) {
41
41
  return process.env.CLIANTA_API_ENDPOINT;
42
42
  }
43
+ // No env var found — warn if we're not on localhost (likely a production misconfiguration)
44
+ const isLocalhost = typeof window !== 'undefined' &&
45
+ (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
46
+ if (!isLocalhost && typeof console !== 'undefined') {
47
+ console.warn('[Clianta] No API endpoint configured. ' +
48
+ 'Set NEXT_PUBLIC_CLIANTA_API_ENDPOINT (Next.js), VITE_CLIANTA_API_ENDPOINT (Vite), ' +
49
+ 'or pass apiEndpoint directly to clianta(). ' +
50
+ 'Falling back to localhost — tracking will not work in production.');
51
+ }
43
52
  return 'http://localhost:5000';
44
53
  };
45
54
  /** Core plugins enabled by default — all auto-track with zero config */
@@ -185,7 +194,9 @@ const logger = createLogger(false);
185
194
  */
186
195
  const DEFAULT_TIMEOUT = 10000; // 10 seconds
187
196
  const DEFAULT_MAX_RETRIES = 3;
188
- const DEFAULT_RETRY_DELAY = 1000; // 1 second
197
+ const DEFAULT_RETRY_DELAY = 1000; // 1 second base — doubles each attempt (exponential backoff)
198
+ /** fetch keepalive hard limit in browsers (64KB) */
199
+ const KEEPALIVE_SIZE_LIMIT = 60000; // leave 4KB margin
189
200
  /**
190
201
  * Transport class for sending data to the backend
191
202
  */
@@ -204,6 +215,11 @@ class Transport {
204
215
  async sendEvents(events) {
205
216
  const url = `${this.config.apiEndpoint}/api/public/track/event`;
206
217
  const payload = JSON.stringify({ events });
218
+ // keepalive has a 64KB hard limit — fall back to beacon if too large
219
+ if (payload.length > KEEPALIVE_SIZE_LIMIT) {
220
+ const sent = this.sendBeacon(events);
221
+ return sent ? { success: true } : this.send(url, payload, 1, false);
222
+ }
207
223
  return this.send(url, payload);
208
224
  }
209
225
  /**
@@ -268,6 +284,15 @@ class Transport {
268
284
  return false;
269
285
  }
270
286
  }
287
+ /**
288
+ * Send an arbitrary POST request through the transport (with timeout + retry).
289
+ * Used for one-off calls like alias() that don't fit the event-batch or identify shapes.
290
+ */
291
+ async sendPost(path, body) {
292
+ const url = `${this.config.apiEndpoint}${path}`;
293
+ const payload = JSON.stringify(body);
294
+ return this.send(url, payload);
295
+ }
271
296
  /**
272
297
  * Fetch data from the tracking API (GET request)
273
298
  * Used for read-back APIs (visitor profile, activity, etc.)
@@ -302,38 +327,44 @@ class Transport {
302
327
  }
303
328
  }
304
329
  /**
305
- * Internal send with retry logic
330
+ * Internal send with exponential backoff retry logic
306
331
  */
307
- async send(url, payload, attempt = 1) {
332
+ async send(url, payload, attempt = 1, useKeepalive = true) {
333
+ // Don't bother sending when offline — caller should re-queue
334
+ if (typeof navigator !== 'undefined' && !navigator.onLine) {
335
+ logger.warn('Device offline, skipping send');
336
+ return { success: false, error: new Error('offline') };
337
+ }
308
338
  try {
309
339
  const response = await this.fetchWithTimeout(url, {
310
340
  method: 'POST',
311
- headers: {
312
- 'Content-Type': 'application/json',
313
- },
341
+ headers: { 'Content-Type': 'application/json' },
314
342
  body: payload,
315
- keepalive: true,
343
+ keepalive: useKeepalive,
316
344
  });
317
345
  if (response.ok) {
318
346
  logger.debug('Request successful:', url);
319
347
  return { success: true, status: response.status };
320
348
  }
321
- // Server error - may retry
349
+ // Server error retry with exponential backoff
322
350
  if (response.status >= 500 && attempt < this.config.maxRetries) {
323
- logger.warn(`Server error (${response.status}), retrying...`);
324
- await this.delay(this.config.retryDelay * attempt);
325
- return this.send(url, payload, attempt + 1);
351
+ const backoff = this.config.retryDelay * Math.pow(2, attempt - 1);
352
+ logger.warn(`Server error (${response.status}), retrying in ${backoff}ms...`);
353
+ await this.delay(backoff);
354
+ return this.send(url, payload, attempt + 1, useKeepalive);
326
355
  }
327
- // Client error - don't retry
356
+ // 4xx don't retry (bad payload, auth failure, etc.)
328
357
  logger.error(`Request failed with status ${response.status}`);
329
358
  return { success: false, status: response.status };
330
359
  }
331
360
  catch (error) {
332
- // Network error - retry if possible
333
- if (attempt < this.config.maxRetries) {
334
- logger.warn(`Network error, retrying (${attempt}/${this.config.maxRetries})...`);
335
- await this.delay(this.config.retryDelay * attempt);
336
- return this.send(url, payload, attempt + 1);
361
+ // Network error retry with exponential backoff if still online
362
+ const isOnline = typeof navigator === 'undefined' || navigator.onLine;
363
+ if (isOnline && attempt < this.config.maxRetries) {
364
+ const backoff = this.config.retryDelay * Math.pow(2, attempt - 1);
365
+ logger.warn(`Network error, retrying in ${backoff}ms (${attempt}/${this.config.maxRetries})...`);
366
+ await this.delay(backoff);
367
+ return this.send(url, payload, attempt + 1, useKeepalive);
337
368
  }
338
369
  logger.error('Request failed after retries:', error);
339
370
  return { success: false, error: error };
@@ -682,12 +713,17 @@ class EventQueue {
682
713
  this.queue = [];
683
714
  this.flushTimer = null;
684
715
  this.isFlushing = false;
716
+ this.isOnline = true;
685
717
  /** Rate limiting: timestamps of recent events */
686
718
  this.eventTimestamps = [];
687
719
  /** Unload handler references for cleanup */
688
720
  this.boundBeforeUnload = null;
689
721
  this.boundVisibilityChange = null;
690
722
  this.boundPageHide = null;
723
+ this.boundOnline = null;
724
+ this.boundOffline = null;
725
+ /** Guards against double-flush on unload (beforeunload + pagehide + visibilitychange all fire) */
726
+ this.unloadFlushed = false;
691
727
  this.transport = transport;
692
728
  this.config = {
693
729
  batchSize: config.batchSize ?? 10,
@@ -696,6 +732,7 @@ class EventQueue {
696
732
  storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
697
733
  };
698
734
  this.persistMode = config.persistMode || 'session';
735
+ this.isOnline = typeof navigator === 'undefined' || navigator.onLine;
699
736
  // Restore persisted queue
700
737
  this.restoreQueue();
701
738
  // Start auto-flush timer
@@ -744,7 +781,7 @@ class EventQueue {
744
781
  * Flush the queue (send all events)
745
782
  */
746
783
  async flush() {
747
- if (this.isFlushing || this.queue.length === 0) {
784
+ if (this.isFlushing || this.queue.length === 0 || !this.isOnline) {
748
785
  return;
749
786
  }
750
787
  this.isFlushing = true;
@@ -775,11 +812,14 @@ class EventQueue {
775
812
  }
776
813
  }
777
814
  /**
778
- * Flush synchronously using sendBeacon (for page unload)
815
+ * Flush synchronously using sendBeacon (for page unload).
816
+ * Guarded: no-ops after the first call per navigation to prevent
817
+ * triple-flush from beforeunload + visibilitychange + pagehide.
779
818
  */
780
819
  flushSync() {
781
- if (this.queue.length === 0)
820
+ if (this.unloadFlushed || this.queue.length === 0)
782
821
  return;
822
+ this.unloadFlushed = true;
783
823
  const events = this.queue.splice(0, this.queue.length);
784
824
  logger.debug(`Sync flushing ${events.length} events via beacon`);
785
825
  const success = this.transport.sendBeacon(events);
@@ -817,17 +857,17 @@ class EventQueue {
817
857
  clearInterval(this.flushTimer);
818
858
  this.flushTimer = null;
819
859
  }
820
- // Remove unload handlers
821
860
  if (typeof window !== 'undefined') {
822
- if (this.boundBeforeUnload) {
861
+ if (this.boundBeforeUnload)
823
862
  window.removeEventListener('beforeunload', this.boundBeforeUnload);
824
- }
825
- if (this.boundVisibilityChange) {
863
+ if (this.boundVisibilityChange)
826
864
  window.removeEventListener('visibilitychange', this.boundVisibilityChange);
827
- }
828
- if (this.boundPageHide) {
865
+ if (this.boundPageHide)
829
866
  window.removeEventListener('pagehide', this.boundPageHide);
830
- }
867
+ if (this.boundOnline)
868
+ window.removeEventListener('online', this.boundOnline);
869
+ if (this.boundOffline)
870
+ window.removeEventListener('offline', this.boundOffline);
831
871
  }
832
872
  }
833
873
  /**
@@ -842,24 +882,38 @@ class EventQueue {
842
882
  }, this.config.flushInterval);
843
883
  }
844
884
  /**
845
- * Setup page unload handlers
885
+ * Setup page unload handlers and online/offline listeners
846
886
  */
847
887
  setupUnloadHandlers() {
848
888
  if (typeof window === 'undefined')
849
889
  return;
850
- // Flush on page unload
890
+ // All three unload events share the same guarded flushSync()
851
891
  this.boundBeforeUnload = () => this.flushSync();
852
892
  window.addEventListener('beforeunload', this.boundBeforeUnload);
853
- // Flush when page becomes hidden
854
893
  this.boundVisibilityChange = () => {
855
894
  if (document.visibilityState === 'hidden') {
856
895
  this.flushSync();
857
896
  }
897
+ else {
898
+ // Page became visible again (e.g. tab switch back) — reset guard
899
+ this.unloadFlushed = false;
900
+ }
858
901
  };
859
902
  window.addEventListener('visibilitychange', this.boundVisibilityChange);
860
- // Flush on page hide (iOS Safari)
861
903
  this.boundPageHide = () => this.flushSync();
862
904
  window.addEventListener('pagehide', this.boundPageHide);
905
+ // Pause queue when offline, resume + flush when back online
906
+ this.boundOnline = () => {
907
+ logger.info('Connection restored — flushing queued events');
908
+ this.isOnline = true;
909
+ this.flush();
910
+ };
911
+ this.boundOffline = () => {
912
+ logger.warn('Connection lost — pausing event queue');
913
+ this.isOnline = false;
914
+ };
915
+ window.addEventListener('online', this.boundOnline);
916
+ window.addEventListener('offline', this.boundOffline);
863
917
  }
864
918
  /**
865
919
  * Persist queue to storage based on persistMode
@@ -942,6 +996,8 @@ class BasePlugin {
942
996
  * Clianta SDK - Page View Plugin
943
997
  * @see SDK_VERSION in core/config.ts
944
998
  */
999
+ /** Sentinel flag to prevent double-wrapping history methods across multiple SDK instances */
1000
+ const WRAPPED_FLAG = '__clianta_pv_wrapped__';
945
1001
  /**
946
1002
  * Page View Plugin - Tracks page views
947
1003
  */
@@ -951,50 +1007,64 @@ class PageViewPlugin extends BasePlugin {
951
1007
  this.name = 'pageView';
952
1008
  this.originalPushState = null;
953
1009
  this.originalReplaceState = null;
1010
+ this.navHandler = null;
954
1011
  this.popstateHandler = null;
955
1012
  }
956
1013
  init(tracker) {
957
1014
  super.init(tracker);
958
1015
  // Track initial page view
959
1016
  this.trackPageView();
960
- // Track SPA navigation (History API)
961
- if (typeof window !== 'undefined') {
962
- // Store originals for cleanup
1017
+ if (typeof window === 'undefined')
1018
+ return;
1019
+ // Only wrap history methods once — guard against multiple SDK instances (e.g. microfrontends)
1020
+ // wrapping them repeatedly, which would cause duplicate navigation events and broken cleanup.
1021
+ if (!history.pushState[WRAPPED_FLAG]) {
963
1022
  this.originalPushState = history.pushState;
964
1023
  this.originalReplaceState = history.replaceState;
965
- // Intercept pushState and replaceState
966
- const self = this;
1024
+ const originalPush = this.originalPushState;
1025
+ const originalReplace = this.originalReplaceState;
967
1026
  history.pushState = function (...args) {
968
- self.originalPushState.apply(history, args);
969
- self.trackPageView();
970
- // Notify other plugins (e.g. ScrollPlugin) about navigation
1027
+ originalPush.apply(history, args);
1028
+ // Dispatch event so all listening instances track the navigation
971
1029
  window.dispatchEvent(new Event('clianta:navigation'));
972
1030
  };
1031
+ history.pushState[WRAPPED_FLAG] = true;
973
1032
  history.replaceState = function (...args) {
974
- self.originalReplaceState.apply(history, args);
975
- self.trackPageView();
1033
+ originalReplace.apply(history, args);
976
1034
  window.dispatchEvent(new Event('clianta:navigation'));
977
1035
  };
978
- // Handle back/forward navigation
979
- this.popstateHandler = () => this.trackPageView();
980
- window.addEventListener('popstate', this.popstateHandler);
1036
+ history.replaceState[WRAPPED_FLAG] = true;
981
1037
  }
1038
+ // Each instance listens to the shared navigation event rather than embedding
1039
+ // tracking directly in the pushState wrapper — decouples tracking from wrapping.
1040
+ this.navHandler = () => this.trackPageView();
1041
+ window.addEventListener('clianta:navigation', this.navHandler);
1042
+ // Handle back/forward navigation
1043
+ this.popstateHandler = () => this.trackPageView();
1044
+ window.addEventListener('popstate', this.popstateHandler);
982
1045
  }
983
1046
  destroy() {
984
- // Restore original history methods
1047
+ if (typeof window !== 'undefined') {
1048
+ if (this.navHandler) {
1049
+ window.removeEventListener('clianta:navigation', this.navHandler);
1050
+ this.navHandler = null;
1051
+ }
1052
+ if (this.popstateHandler) {
1053
+ window.removeEventListener('popstate', this.popstateHandler);
1054
+ this.popstateHandler = null;
1055
+ }
1056
+ }
1057
+ // Restore original history methods only if this instance was the one that wrapped them
985
1058
  if (this.originalPushState) {
986
1059
  history.pushState = this.originalPushState;
1060
+ delete history.pushState[WRAPPED_FLAG];
987
1061
  this.originalPushState = null;
988
1062
  }
989
1063
  if (this.originalReplaceState) {
990
1064
  history.replaceState = this.originalReplaceState;
1065
+ delete history.replaceState[WRAPPED_FLAG];
991
1066
  this.originalReplaceState = null;
992
1067
  }
993
- // Remove popstate listener
994
- if (this.popstateHandler && typeof window !== 'undefined') {
995
- window.removeEventListener('popstate', this.popstateHandler);
996
- this.popstateHandler = null;
997
- }
998
1068
  super.destroy();
999
1069
  }
1000
1070
  trackPageView() {
@@ -1123,6 +1193,7 @@ class FormsPlugin extends BasePlugin {
1123
1193
  this.trackedForms = new WeakSet();
1124
1194
  this.formInteractions = new Set();
1125
1195
  this.observer = null;
1196
+ this.observerTimer = null;
1126
1197
  this.listeners = [];
1127
1198
  }
1128
1199
  init(tracker) {
@@ -1131,13 +1202,21 @@ class FormsPlugin extends BasePlugin {
1131
1202
  return;
1132
1203
  // Track existing forms
1133
1204
  this.trackAllForms();
1134
- // Watch for dynamically added forms
1205
+ // Watch for dynamically added forms — debounced to avoid O(DOM) cost on every mutation
1135
1206
  if (typeof MutationObserver !== 'undefined') {
1136
- this.observer = new MutationObserver(() => this.trackAllForms());
1207
+ this.observer = new MutationObserver(() => {
1208
+ if (this.observerTimer)
1209
+ clearTimeout(this.observerTimer);
1210
+ this.observerTimer = setTimeout(() => this.trackAllForms(), 100);
1211
+ });
1137
1212
  this.observer.observe(document.body, { childList: true, subtree: true });
1138
1213
  }
1139
1214
  }
1140
1215
  destroy() {
1216
+ if (this.observerTimer) {
1217
+ clearTimeout(this.observerTimer);
1218
+ this.observerTimer = null;
1219
+ }
1141
1220
  if (this.observer) {
1142
1221
  this.observer.disconnect();
1143
1222
  this.observer = null;
@@ -1259,8 +1338,13 @@ class ClicksPlugin extends BasePlugin {
1259
1338
  super.destroy();
1260
1339
  }
1261
1340
  handleClick(e) {
1262
- const target = e.target;
1263
- if (!target || !isTrackableClickElement(target))
1341
+ // Walk up the DOM to find the nearest trackable ancestor.
1342
+ // Without this, clicks on <span> or <img> inside a <button> are silently dropped.
1343
+ let target = e.target;
1344
+ while (target && !isTrackableClickElement(target)) {
1345
+ target = target.parentElement;
1346
+ }
1347
+ if (!target)
1264
1348
  return;
1265
1349
  const buttonText = getElementText(target, 100);
1266
1350
  const elementInfo = getElementInfo(target);
@@ -1293,6 +1377,8 @@ class EngagementPlugin extends BasePlugin {
1293
1377
  this.engagementStartTime = 0;
1294
1378
  this.isEngaged = false;
1295
1379
  this.engagementTimeout = null;
1380
+ /** Guard: beforeunload + visibilitychange:hidden both fire on tab close — only report once */
1381
+ this.unloadReported = false;
1296
1382
  this.boundMarkEngaged = null;
1297
1383
  this.boundTrackTimeOnPage = null;
1298
1384
  this.boundVisibilityHandler = null;
@@ -1314,8 +1400,9 @@ class EngagementPlugin extends BasePlugin {
1314
1400
  this.trackTimeOnPage();
1315
1401
  }
1316
1402
  else {
1317
- // Reset engagement timer when page becomes visible again
1403
+ // Page is visible again reset both the time counter and the unload guard
1318
1404
  this.engagementStartTime = Date.now();
1405
+ this.unloadReported = false;
1319
1406
  }
1320
1407
  };
1321
1408
  ['mousemove', 'keydown', 'touchstart', 'scroll'].forEach((event) => {
@@ -1361,6 +1448,7 @@ class EngagementPlugin extends BasePlugin {
1361
1448
  this.pageLoadTime = Date.now();
1362
1449
  this.engagementStartTime = Date.now();
1363
1450
  this.isEngaged = false;
1451
+ this.unloadReported = false;
1364
1452
  if (this.engagementTimeout) {
1365
1453
  clearTimeout(this.engagementTimeout);
1366
1454
  this.engagementTimeout = null;
@@ -1382,6 +1470,10 @@ class EngagementPlugin extends BasePlugin {
1382
1470
  }, 30000); // 30 seconds of inactivity
1383
1471
  }
1384
1472
  trackTimeOnPage() {
1473
+ // Guard: beforeunload and visibilitychange:hidden both fire on tab close — only report once
1474
+ if (this.unloadReported)
1475
+ return;
1476
+ this.unloadReported = true;
1385
1477
  const timeSpent = Math.floor((Date.now() - this.engagementStartTime) / 1000);
1386
1478
  if (timeSpent > 0) {
1387
1479
  this.track('time_on_page', 'Time Spent', {
@@ -1540,12 +1632,16 @@ class ExitIntentPlugin extends BasePlugin {
1540
1632
  /**
1541
1633
  * Error Tracking Plugin - Tracks JavaScript errors
1542
1634
  */
1635
+ /** Max unique errors to track per page (prevents queue flooding from error loops) */
1636
+ const MAX_UNIQUE_ERRORS = 20;
1543
1637
  class ErrorsPlugin extends BasePlugin {
1544
1638
  constructor() {
1545
1639
  super(...arguments);
1546
1640
  this.name = 'errors';
1547
1641
  this.boundErrorHandler = null;
1548
1642
  this.boundRejectionHandler = null;
1643
+ /** Seen error fingerprints — deduplicates repeated identical errors */
1644
+ this.seenErrors = new Set();
1549
1645
  }
1550
1646
  init(tracker) {
1551
1647
  super.init(tracker);
@@ -1568,6 +1664,9 @@ class ErrorsPlugin extends BasePlugin {
1568
1664
  super.destroy();
1569
1665
  }
1570
1666
  handleError(e) {
1667
+ const fingerprint = `${e.message}:${e.filename}:${e.lineno}`;
1668
+ if (!this.dedup(fingerprint))
1669
+ return;
1571
1670
  this.track('error', 'JavaScript Error', {
1572
1671
  message: e.message,
1573
1672
  filename: e.filename,
@@ -1577,9 +1676,22 @@ class ErrorsPlugin extends BasePlugin {
1577
1676
  });
1578
1677
  }
1579
1678
  handleRejection(e) {
1580
- this.track('error', 'Unhandled Promise Rejection', {
1581
- reason: String(e.reason).substring(0, 200),
1582
- });
1679
+ const reason = String(e.reason).substring(0, 200);
1680
+ if (!this.dedup(reason))
1681
+ return;
1682
+ this.track('error', 'Unhandled Promise Rejection', { reason });
1683
+ }
1684
+ /**
1685
+ * Returns true if this error fingerprint is new (should be tracked).
1686
+ * Caps at MAX_UNIQUE_ERRORS to prevent queue flooding from error loops.
1687
+ */
1688
+ dedup(fingerprint) {
1689
+ if (this.seenErrors.has(fingerprint))
1690
+ return false;
1691
+ if (this.seenErrors.size >= MAX_UNIQUE_ERRORS)
1692
+ return false;
1693
+ this.seenErrors.add(fingerprint);
1694
+ return true;
1583
1695
  }
1584
1696
  }
1585
1697
 
@@ -3518,28 +3630,17 @@ class Tracker {
3518
3630
  }
3519
3631
  const prevId = previousId || this.visitorId;
3520
3632
  logger.info('Aliasing visitor:', { from: prevId, to: newId });
3521
- try {
3522
- const url = `${this.config.apiEndpoint}/api/public/track/alias`;
3523
- const response = await fetch(url, {
3524
- method: 'POST',
3525
- headers: { 'Content-Type': 'application/json' },
3526
- body: JSON.stringify({
3527
- workspaceId: this.workspaceId,
3528
- previousId: prevId,
3529
- newId,
3530
- }),
3531
- });
3532
- if (response.ok) {
3533
- logger.info('Alias successful');
3534
- return true;
3535
- }
3536
- logger.error('Alias failed:', response.status);
3537
- return false;
3538
- }
3539
- catch (error) {
3540
- logger.error('Alias request failed:', error);
3541
- return false;
3633
+ const result = await this.transport.sendPost('/api/public/track/alias', {
3634
+ workspaceId: this.workspaceId,
3635
+ previousId: prevId,
3636
+ newId,
3637
+ });
3638
+ if (result.success) {
3639
+ logger.info('Alias successful');
3640
+ return true;
3542
3641
  }
3642
+ logger.error('Alias failed:', result.error ?? result.status);
3643
+ return false;
3543
3644
  }
3544
3645
  /**
3545
3646
  * Track a screen view (for mobile-first PWAs and SPAs).
@@ -3902,13 +4003,24 @@ let globalInstance = null;
3902
4003
  * });
3903
4004
  */
3904
4005
  function clianta(workspaceId, config) {
3905
- // Return existing instance if same workspace
4006
+ // Return existing instance if same workspace and no config change
3906
4007
  if (globalInstance && globalInstance.getWorkspaceId() === workspaceId) {
4008
+ if (config && Object.keys(config).length > 0) {
4009
+ // Config was passed to an already-initialized instance — warn the developer
4010
+ // because the new config is ignored. They must call destroy() first to reconfigure.
4011
+ if (typeof console !== 'undefined') {
4012
+ console.warn('[Clianta] clianta() called with config on an already-initialized instance ' +
4013
+ 'for workspace "' + workspaceId + '". The new config was ignored. ' +
4014
+ 'Call tracker.destroy() first if you need to reconfigure.');
4015
+ }
4016
+ }
3907
4017
  return globalInstance;
3908
4018
  }
3909
- // Destroy existing instance if workspace changed
4019
+ // Destroy existing instance if workspace changed (fire-and-forget flush, then destroy)
3910
4020
  if (globalInstance) {
3911
- globalInstance.destroy();
4021
+ // Kick off async flush+destroy without blocking the new instance creation.
4022
+ // Using void to make the intentional fire-and-forget explicit.
4023
+ void globalInstance.destroy();
3912
4024
  }
3913
4025
  // Create new instance
3914
4026
  globalInstance = new Tracker(workspaceId, config);
@@ -3936,8 +4048,21 @@ if (typeof window !== 'undefined') {
3936
4048
  const projectId = script.getAttribute('data-project-id');
3937
4049
  if (!projectId)
3938
4050
  return;
3939
- const debug = script.hasAttribute('data-debug');
3940
- const instance = clianta(projectId, { debug });
4051
+ const initConfig = {
4052
+ debug: script.hasAttribute('data-debug'),
4053
+ };
4054
+ // Support additional config via script tag attributes:
4055
+ // data-api-endpoint="https://api.yourhost.com"
4056
+ // data-cookieless (boolean flag)
4057
+ // data-use-cookies (boolean flag)
4058
+ const apiEndpoint = script.getAttribute('data-api-endpoint');
4059
+ if (apiEndpoint)
4060
+ initConfig.apiEndpoint = apiEndpoint;
4061
+ if (script.hasAttribute('data-cookieless'))
4062
+ initConfig.cookielessMode = true;
4063
+ if (script.hasAttribute('data-use-cookies'))
4064
+ initConfig.useCookies = true;
4065
+ const instance = clianta(projectId, initConfig);
3941
4066
  // Expose the auto-initialized instance globally
3942
4067
  window.__clianta = instance;
3943
4068
  };
@@ -3999,15 +4124,12 @@ class CliantaErrorBoundary extends react.Component {
3999
4124
  function CliantaProvider({ projectId, apiEndpoint, debug, config, children, onError }) {
4000
4125
  const [tracker, setTracker] = react.useState(null);
4001
4126
  const [isReady, setIsReady] = react.useState(false);
4002
- const projectIdRef = react.useRef(projectId);
4127
+ const trackerRef = react.useRef(null);
4003
4128
  react.useEffect(() => {
4004
4129
  if (!projectId) {
4005
4130
  console.error('[Clianta] Missing projectId prop on CliantaProvider');
4006
4131
  return;
4007
4132
  }
4008
- if (projectIdRef.current !== projectId) {
4009
- projectIdRef.current = projectId;
4010
- }
4011
4133
  try {
4012
4134
  const options = {
4013
4135
  debug: debug ?? false,
@@ -4015,6 +4137,7 @@ function CliantaProvider({ projectId, apiEndpoint, debug, config, children, onEr
4015
4137
  ...(apiEndpoint ? { apiEndpoint } : {}),
4016
4138
  };
4017
4139
  const instance = clianta(projectId, options);
4140
+ trackerRef.current = instance;
4018
4141
  setTracker(instance);
4019
4142
  setIsReady(true);
4020
4143
  }
@@ -4023,7 +4146,8 @@ function CliantaProvider({ projectId, apiEndpoint, debug, config, children, onEr
4023
4146
  onError?.(error, { componentStack: '' });
4024
4147
  }
4025
4148
  return () => {
4026
- tracker?.flush();
4149
+ // Use ref so cleanup always flushes the live instance, not the stale closure value
4150
+ trackerRef.current?.flush();
4027
4151
  setIsReady(false);
4028
4152
  };
4029
4153
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -4049,12 +4173,13 @@ function useCliantaReady() {
4049
4173
  }
4050
4174
  /**
4051
4175
  * useCliantaTrack — Quick tracking hook
4176
+ * Stable function reference (useCallback) — safe to use as a dependency or pass as a prop.
4052
4177
  */
4053
4178
  function useCliantaTrack() {
4054
4179
  const tracker = useClianta();
4055
- return (eventType, eventName, properties) => {
4180
+ return react.useCallback((eventType, eventName, properties) => {
4056
4181
  tracker?.track(eventType, eventName, properties);
4057
- };
4182
+ }, [tracker]);
4058
4183
  }
4059
4184
 
4060
4185
  exports.CliantaProvider = CliantaProvider;