@leanbase-giangnd/js 0.2.2 → 0.2.4

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/index.d.ts CHANGED
@@ -6127,6 +6127,10 @@ declare class Leanbase extends PostHogCore {
6127
6127
  consent: ConsentManager;
6128
6128
  sessionRecording?: SessionRecording$1;
6129
6129
  isRemoteConfigLoaded?: boolean;
6130
+ private _remoteConfigLoadAttempted;
6131
+ private _remoteConfigResolved;
6132
+ private _featureFlagsResolved;
6133
+ private _maybeStartedSessionRecording;
6130
6134
  personProcessingSetOncePropertiesSent: boolean;
6131
6135
  isLoaded: boolean;
6132
6136
  initialPageviewCaptured: boolean;
@@ -6137,6 +6141,7 @@ declare class Leanbase extends PostHogCore {
6137
6141
  capturePageLeave(): void;
6138
6142
  loadRemoteConfig(): Promise<void>;
6139
6143
  onRemoteConfig(config: RemoteConfig$1): void;
6144
+ private _maybeStartSessionRecording;
6140
6145
  fetch(url: string, options: PostHogFetchOptions): Promise<PostHogFetchResponse>;
6141
6146
  setConfig(config: Partial<LeanbaseConfig>): void;
6142
6147
  getLibraryId(): string;
package/dist/index.mjs CHANGED
@@ -1169,7 +1169,7 @@ const detectDeviceType = function (user_agent) {
1169
1169
  }
1170
1170
  };
1171
1171
 
1172
- var version = "0.2.2";
1172
+ var version = "0.2.4";
1173
1173
  var packageInfo = {
1174
1174
  version: version};
1175
1175
 
@@ -3845,34 +3845,49 @@ class MutationThrottler {
3845
3845
  }
3846
3846
  return [id, node];
3847
3847
  };
3848
- this._getNode = id => this._rrweb.mirror.getNode(id);
3848
+ this._getNode = id => {
3849
+ // eslint-disable-next-line posthog-js/no-direct-undefined-check, posthog-js/no-direct-null-check
3850
+ if (id === null || id === undefined) {
3851
+ return null;
3852
+ }
3853
+ try {
3854
+ return this._rrweb.mirror.getNode(id) ?? null;
3855
+ } catch {
3856
+ return null;
3857
+ }
3858
+ };
3849
3859
  this._numberOfChanges = data => {
3850
3860
  return (data.removes?.length ?? 0) + (data.attributes?.length ?? 0) + (data.texts?.length ?? 0) + (data.adds?.length ?? 0);
3851
3861
  };
3852
3862
  this.throttleMutations = event => {
3853
- if (event.type !== INCREMENTAL_SNAPSHOT_EVENT_TYPE || event.data.source !== MUTATION_SOURCE_TYPE) {
3863
+ try {
3864
+ if (event.type !== INCREMENTAL_SNAPSHOT_EVENT_TYPE || event.data.source !== MUTATION_SOURCE_TYPE) {
3865
+ return event;
3866
+ }
3867
+ const data = event.data;
3868
+ const initialMutationCount = this._numberOfChanges(data);
3869
+ if (data.attributes) {
3870
+ // Most problematic mutations come from attrs where the style or minor properties are changed rapidly
3871
+ data.attributes = data.attributes.filter(attr => {
3872
+ const id = attr?.id;
3873
+ if (typeof id !== 'number') {
3874
+ return true;
3875
+ }
3876
+ const [nodeId] = this._getNodeOrRelevantParent(id);
3877
+ const isRateLimited = this._rateLimiter.consumeRateLimit(nodeId);
3878
+ return !isRateLimited;
3879
+ });
3880
+ }
3881
+ // Check if every part of the mutation is empty in which case there is nothing to do
3882
+ const mutationCount = this._numberOfChanges(data);
3883
+ if (mutationCount === 0 && initialMutationCount !== mutationCount) {
3884
+ // If we have modified the mutation count and the remaining count is 0, then we don't need the event.
3885
+ return;
3886
+ }
3887
+ return event;
3888
+ } catch {
3854
3889
  return event;
3855
3890
  }
3856
- const data = event.data;
3857
- const initialMutationCount = this._numberOfChanges(data);
3858
- if (data.attributes) {
3859
- // Most problematic mutations come from attrs where the style or minor properties are changed rapidly
3860
- data.attributes = data.attributes.filter(attr => {
3861
- const [nodeId] = this._getNodeOrRelevantParent(attr.id);
3862
- const isRateLimited = this._rateLimiter.consumeRateLimit(nodeId);
3863
- if (isRateLimited) {
3864
- return false;
3865
- }
3866
- return attr;
3867
- });
3868
- }
3869
- // Check if every part of the mutation is empty in which case there is nothing to do
3870
- const mutationCount = this._numberOfChanges(data);
3871
- if (mutationCount === 0 && initialMutationCount !== mutationCount) {
3872
- // If we have modified the mutation count and the remaining count is 0, then we don't need the event.
3873
- return;
3874
- }
3875
- return event;
3876
3891
  };
3877
3892
  const configuredBucketSize = this._options.bucketSize ?? 100;
3878
3893
  const effectiveBucketSize = Math.max(configuredBucketSize - 1, 1);
@@ -4110,6 +4125,9 @@ class LazyLoadedSessionRecording {
4110
4125
  */
4111
4126
  this._queuedRRWebEvents = [];
4112
4127
  this._isIdle = 'unknown';
4128
+ // Replay should not process rrweb emits until all critical dependencies are ready.
4129
+ this._isFullyReady = false;
4130
+ this._loggedMissingEndpointFor = false;
4113
4131
  // we need to be able to check the state of the event and url triggers separately
4114
4132
  // as we make some decisions based on them without referencing LinkedFlag etc
4115
4133
  this._triggerMatching = new PendingTriggerMatching();
@@ -4290,7 +4308,11 @@ class LazyLoadedSessionRecording {
4290
4308
  }
4291
4309
  }
4292
4310
  _tryAddCustomEvent(tag, payload) {
4293
- return this._tryRRWebMethod(newQueuedEvent(() => getRRWebRecord().addCustomEvent(tag, payload)));
4311
+ const rrwebRecord = getRRWebRecord();
4312
+ if (!rrwebRecord || typeof rrwebRecord.addCustomEvent !== 'function') {
4313
+ return false;
4314
+ }
4315
+ return this._tryRRWebMethod(newQueuedEvent(() => rrwebRecord.addCustomEvent(tag, payload)));
4294
4316
  }
4295
4317
  _pageViewFallBack() {
4296
4318
  try {
@@ -4336,7 +4358,11 @@ class LazyLoadedSessionRecording {
4336
4358
  }
4337
4359
  }
4338
4360
  _tryTakeFullSnapshot() {
4339
- return this._tryRRWebMethod(newQueuedEvent(() => getRRWebRecord().takeFullSnapshot()));
4361
+ const rrwebRecord = getRRWebRecord();
4362
+ if (!rrwebRecord || typeof rrwebRecord.takeFullSnapshot !== 'function') {
4363
+ return false;
4364
+ }
4365
+ return this._tryRRWebMethod(newQueuedEvent(() => rrwebRecord.takeFullSnapshot()));
4340
4366
  }
4341
4367
  get _fullSnapshotIntervalMillis() {
4342
4368
  if (this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING && !['sampled', 'active'].includes(this.status)) {
@@ -4412,6 +4438,7 @@ class LazyLoadedSessionRecording {
4412
4438
  return parsedConfig;
4413
4439
  }
4414
4440
  async start(startReason) {
4441
+ this._isFullyReady = false;
4415
4442
  const config = this._remoteConfig;
4416
4443
  if (!config) {
4417
4444
  logger.info('remote config must be stored in persistence before recording can start');
@@ -4446,6 +4473,14 @@ class LazyLoadedSessionRecording {
4446
4473
  });
4447
4474
  this._makeSamplingDecision(this.sessionId);
4448
4475
  await this._startRecorder();
4476
+ // If rrweb failed to load/start, do not proceed further.
4477
+ // This prevents installing listeners that assume rrweb is active.
4478
+ if (!this.isStarted) {
4479
+ return;
4480
+ }
4481
+ // Only start processing rrweb emits once the ingestion endpoint is available.
4482
+ // If it isn't available, we must degrade to a no-op (never crash the host app).
4483
+ this._isFullyReady = this._canCaptureSnapshots();
4449
4484
  // calling addEventListener multiple times is safe and will not add duplicates
4450
4485
  addEventListener(win, 'beforeunload', this._onBeforeUnload);
4451
4486
  addEventListener(win, 'offline', this._onOffline);
@@ -4523,77 +4558,100 @@ class LazyLoadedSessionRecording {
4523
4558
  this._queuedRRWebEvents = [];
4524
4559
  this._stopRrweb?.();
4525
4560
  this._stopRrweb = undefined;
4561
+ this._isFullyReady = false;
4526
4562
  logger.info('stopped');
4527
4563
  }
4564
+ _snapshotIngestionUrl() {
4565
+ const endpointFor = this._instance?.requestRouter?.endpointFor;
4566
+ if (typeof endpointFor !== 'function') {
4567
+ return null;
4568
+ }
4569
+ try {
4570
+ return endpointFor('api', this._endpoint);
4571
+ } catch {
4572
+ return null;
4573
+ }
4574
+ }
4575
+ _canCaptureSnapshots() {
4576
+ return !!this._snapshotIngestionUrl();
4577
+ }
4528
4578
  onRRwebEmit(rawEvent) {
4529
- this._processQueuedEvents();
4530
- if (!rawEvent || !isObject(rawEvent)) {
4579
+ // Never process rrweb emits until we're fully ready.
4580
+ if (!this._isFullyReady || !this.isStarted) {
4531
4581
  return;
4532
4582
  }
4533
- if (rawEvent.type === EventType.Meta) {
4534
- const href = this._maskUrl(rawEvent.data.href);
4535
- this._lastHref = href;
4536
- if (!href) {
4583
+ try {
4584
+ this._processQueuedEvents();
4585
+ if (!rawEvent || !isObject(rawEvent)) {
4537
4586
  return;
4538
4587
  }
4539
- rawEvent.data.href = href;
4540
- } else {
4541
- this._pageViewFallBack();
4542
- }
4543
- // Check if the URL matches any trigger patterns
4544
- this._urlTriggerMatching.checkUrlTriggerConditions(() => this._pauseRecording(), () => this._resumeRecording(), triggerType => this._activateTrigger(triggerType));
4545
- // always have to check if the URL is blocked really early,
4546
- // or you risk getting stuck in a loop
4547
- if (this._urlTriggerMatching.urlBlocked && !isRecordingPausedEvent(rawEvent)) {
4548
- return;
4549
- }
4550
- // we're processing a full snapshot, so we should reset the timer
4551
- if (rawEvent.type === EventType.FullSnapshot) {
4552
- this._scheduleFullSnapshot();
4553
- // Full snapshots reset rrweb's node IDs, so clear any logged node tracking
4554
- this._mutationThrottler?.reset();
4555
- }
4556
- // Clear the buffer if waiting for a trigger and only keep data from after the current full snapshot
4557
- // we always start trigger pending so need to wait for flags before we know if we're really pending
4558
- if (rawEvent.type === EventType.FullSnapshot && this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING) {
4559
- this._clearBufferBeforeMostRecentMeta();
4560
- }
4561
- const throttledEvent = this._mutationThrottler ? this._mutationThrottler.throttleMutations(rawEvent) : rawEvent;
4562
- if (!throttledEvent) {
4563
- return;
4564
- }
4565
- // TODO: Re-add ensureMaxMessageSize once we are confident in it
4566
- const event = truncateLargeConsoleLogs(throttledEvent);
4567
- this._updateWindowAndSessionIds(event);
4568
- // When in an idle state we keep recording but don't capture the events,
4569
- // we don't want to return early if idle is 'unknown'
4570
- if (this._isIdle === true && !isSessionIdleEvent(event)) {
4571
- return;
4572
- }
4573
- if (isSessionIdleEvent(event)) {
4574
- // session idle events have a timestamp when rrweb sees them
4575
- // which can artificially lengthen a session
4576
- // we know when we detected it based on the payload and can correct the timestamp
4577
- const payload = event.data.payload;
4578
- if (payload) {
4579
- const lastActivity = payload.lastActivityTimestamp;
4580
- const threshold = payload.threshold;
4581
- event.timestamp = lastActivity + threshold;
4588
+ if (rawEvent.type === EventType.Meta) {
4589
+ const href = this._maskUrl(rawEvent.data.href);
4590
+ this._lastHref = href;
4591
+ if (!href) {
4592
+ return;
4593
+ }
4594
+ rawEvent.data.href = href;
4595
+ } else {
4596
+ this._pageViewFallBack();
4582
4597
  }
4598
+ // Check if the URL matches any trigger patterns
4599
+ this._urlTriggerMatching.checkUrlTriggerConditions(() => this._pauseRecording(), () => this._resumeRecording(), triggerType => this._activateTrigger(triggerType));
4600
+ // always have to check if the URL is blocked really early,
4601
+ // or you risk getting stuck in a loop
4602
+ if (this._urlTriggerMatching.urlBlocked && !isRecordingPausedEvent(rawEvent)) {
4603
+ return;
4604
+ }
4605
+ // we're processing a full snapshot, so we should reset the timer
4606
+ if (rawEvent.type === EventType.FullSnapshot) {
4607
+ this._scheduleFullSnapshot();
4608
+ // Full snapshots reset rrweb's node IDs, so clear any logged node tracking
4609
+ this._mutationThrottler?.reset();
4610
+ }
4611
+ // Clear the buffer if waiting for a trigger and only keep data from after the current full snapshot
4612
+ // we always start trigger pending so need to wait for flags before we know if we're really pending
4613
+ if (rawEvent.type === EventType.FullSnapshot && this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING) {
4614
+ this._clearBufferBeforeMostRecentMeta();
4615
+ }
4616
+ const throttledEvent = this._mutationThrottler ? this._mutationThrottler.throttleMutations(rawEvent) : rawEvent;
4617
+ if (!throttledEvent) {
4618
+ return;
4619
+ }
4620
+ // TODO: Re-add ensureMaxMessageSize once we are confident in it
4621
+ const event = truncateLargeConsoleLogs(throttledEvent);
4622
+ this._updateWindowAndSessionIds(event);
4623
+ // When in an idle state we keep recording but don't capture the events,
4624
+ // we don't want to return early if idle is 'unknown'
4625
+ if (this._isIdle === true && !isSessionIdleEvent(event)) {
4626
+ return;
4627
+ }
4628
+ if (isSessionIdleEvent(event)) {
4629
+ // session idle events have a timestamp when rrweb sees them
4630
+ // which can artificially lengthen a session
4631
+ // we know when we detected it based on the payload and can correct the timestamp
4632
+ const payload = event.data.payload;
4633
+ if (payload) {
4634
+ const lastActivity = payload.lastActivityTimestamp;
4635
+ const threshold = payload.threshold;
4636
+ event.timestamp = lastActivity + threshold;
4637
+ }
4638
+ }
4639
+ const eventToSend = this._instance.config.session_recording?.compress_events ?? true ? compressEvent(event) : event;
4640
+ const size = estimateSize(eventToSend);
4641
+ const properties = {
4642
+ $snapshot_bytes: size,
4643
+ $snapshot_data: eventToSend,
4644
+ $session_id: this._sessionId,
4645
+ $window_id: this._windowId
4646
+ };
4647
+ if (this.status === DISABLED) {
4648
+ this._clearBuffer();
4649
+ return;
4650
+ }
4651
+ this._captureSnapshotBuffered(properties);
4652
+ } catch (e) {
4653
+ logger.error('error processing rrweb event', e);
4583
4654
  }
4584
- const eventToSend = this._instance.config.session_recording?.compress_events ?? true ? compressEvent(event) : event;
4585
- const size = estimateSize(eventToSend);
4586
- const properties = {
4587
- $snapshot_bytes: size,
4588
- $snapshot_data: eventToSend,
4589
- $session_id: this._sessionId,
4590
- $window_id: this._windowId
4591
- };
4592
- if (this.status === DISABLED) {
4593
- this._clearBuffer();
4594
- return;
4595
- }
4596
- this._captureSnapshotBuffered(properties);
4597
4655
  }
4598
4656
  get status() {
4599
4657
  return this._statusMatcher({
@@ -4671,6 +4729,16 @@ class LazyLoadedSessionRecording {
4671
4729
  }, RECORDING_BUFFER_TIMEOUT);
4672
4730
  return this._buffer;
4673
4731
  }
4732
+ if (!this._canCaptureSnapshots()) {
4733
+ if (!this._loggedMissingEndpointFor) {
4734
+ this._loggedMissingEndpointFor = true;
4735
+ logger.warn('snapshot capture skipped because requestRouter.endpointFor is unavailable');
4736
+ }
4737
+ this._flushBufferTimer = setTimeout(() => {
4738
+ this._flushBuffer();
4739
+ }, RECORDING_BUFFER_TIMEOUT);
4740
+ return this._buffer;
4741
+ }
4674
4742
  if (this._buffer.data.length > 0) {
4675
4743
  const snapshotEvents = splitBuffer(this._buffer);
4676
4744
  snapshotEvents.forEach(snapshotBuffer => {
@@ -4703,13 +4771,25 @@ class LazyLoadedSessionRecording {
4703
4771
  }
4704
4772
  }
4705
4773
  _captureSnapshot(properties) {
4706
- // :TRICKY: Make sure we batch these requests, use a custom endpoint and don't truncate the strings.
4707
- this._instance.capture('$snapshot', properties, {
4708
- _url: this._instance.requestRouter.endpointFor('api', this._endpoint),
4709
- _noTruncate: true,
4710
- _batchKey: SESSION_RECORDING_BATCH_KEY,
4711
- skip_client_rate_limiting: true
4712
- });
4774
+ const url = this._snapshotIngestionUrl();
4775
+ if (!url) {
4776
+ if (!this._loggedMissingEndpointFor) {
4777
+ this._loggedMissingEndpointFor = true;
4778
+ logger.warn('snapshot capture skipped because requestRouter.endpointFor is unavailable');
4779
+ }
4780
+ return;
4781
+ }
4782
+ try {
4783
+ // :TRICKY: Make sure we batch these requests, use a custom endpoint and don't truncate the strings.
4784
+ this._instance.capture('$snapshot', properties, {
4785
+ _url: url,
4786
+ _noTruncate: true,
4787
+ _batchKey: SESSION_RECORDING_BATCH_KEY,
4788
+ skip_client_rate_limiting: true
4789
+ });
4790
+ } catch (e) {
4791
+ logger.error('failed to capture snapshot', e);
4792
+ }
4713
4793
  }
4714
4794
  _snapshotUrl() {
4715
4795
  const host = this._instance.config.host || '';
@@ -4979,13 +5059,23 @@ class LazyLoadedSessionRecording {
4979
5059
  }
4980
5060
  });
4981
5061
  const activePlugins = this._gatherRRWebPlugins();
4982
- this._stopRrweb = rrwebRecord({
4983
- emit: event => {
4984
- this.onRRwebEmit(event);
4985
- },
4986
- plugins: activePlugins,
4987
- ...sessionRecordingOptions
4988
- });
5062
+ try {
5063
+ this._stopRrweb = rrwebRecord({
5064
+ emit: event => {
5065
+ try {
5066
+ this.onRRwebEmit(event);
5067
+ } catch (e) {
5068
+ logger.error('error in rrweb emit handler', e);
5069
+ }
5070
+ },
5071
+ plugins: activePlugins,
5072
+ ...sessionRecordingOptions
5073
+ });
5074
+ } catch (e) {
5075
+ logger.error('failed to start rrweb recorder', e);
5076
+ this._stopRrweb = undefined;
5077
+ return;
5078
+ }
4989
5079
  // We reset the last activity timestamp, resetting the idle timer
4990
5080
  this._lastActivityTimestamp = Date.now();
4991
5081
  // stay unknown if we're not sure if we're idle or not
@@ -5171,18 +5261,25 @@ class SessionRecording {
5171
5261
  // If extensions provide an init function, use it. Otherwise, fall back to the local LazyLoadedSessionRecording
5172
5262
  if (assignableWindow.__PosthogExtensions__?.initSessionRecording) {
5173
5263
  if (!this._lazyLoadedSessionRecording) {
5174
- this._lazyLoadedSessionRecording = assignableWindow.__PosthogExtensions__?.initSessionRecording(this._instance);
5175
- this._lazyLoadedSessionRecording._forceAllowLocalhostNetworkCapture = this._forceAllowLocalhostNetworkCapture;
5264
+ const maybeRecording = assignableWindow.__PosthogExtensions__?.initSessionRecording(this._instance);
5265
+ if (maybeRecording && typeof maybeRecording.start === 'function') {
5266
+ this._lazyLoadedSessionRecording = maybeRecording;
5267
+ this._lazyLoadedSessionRecording._forceAllowLocalhostNetworkCapture = this._forceAllowLocalhostNetworkCapture;
5268
+ } else {
5269
+ log.warn('initSessionRecording was present but did not return a recorder instance; falling back to local recorder');
5270
+ }
5176
5271
  }
5177
- try {
5178
- const maybePromise = this._lazyLoadedSessionRecording.start(startReason);
5179
- if (maybePromise && typeof maybePromise.catch === 'function') {
5180
- maybePromise.catch(e => logger$2.error('error starting session recording', e));
5272
+ if (this._lazyLoadedSessionRecording) {
5273
+ try {
5274
+ const maybePromise = this._lazyLoadedSessionRecording.start(startReason);
5275
+ if (maybePromise && typeof maybePromise.catch === 'function') {
5276
+ maybePromise.catch(e => logger$2.error('error starting session recording', e));
5277
+ }
5278
+ } catch (e) {
5279
+ logger$2.error('error starting session recording', e);
5181
5280
  }
5182
- } catch (e) {
5183
- logger$2.error('error starting session recording', e);
5281
+ return;
5184
5282
  }
5185
- return;
5186
5283
  }
5187
5284
  if (!this._lazyLoadedSessionRecording) {
5188
5285
  this._lazyLoadedSessionRecording = new LazyLoadedSessionRecording(this._instance);
@@ -5297,6 +5394,10 @@ class Leanbase extends PostHogCore {
5297
5394
  token
5298
5395
  });
5299
5396
  super(token, mergedConfig);
5397
+ this._remoteConfigLoadAttempted = false;
5398
+ this._remoteConfigResolved = false;
5399
+ this._featureFlagsResolved = false;
5400
+ this._maybeStartedSessionRecording = false;
5300
5401
  this.personProcessingSetOncePropertiesSent = false;
5301
5402
  this.isLoaded = false;
5302
5403
  this.config = mergedConfig;
@@ -5320,10 +5421,20 @@ class Leanbase extends PostHogCore {
5320
5421
  this.replayAutocapture.startIfEnabled();
5321
5422
  if (this.sessionManager && this.config.cookieless_mode !== 'always') {
5322
5423
  this.sessionRecording = new SessionRecording(this);
5323
- this.sessionRecording.startIfEnabledOrStop();
5324
5424
  }
5425
+ // Start session recording only once flags + remote config have been resolved.
5426
+ // This matches the PostHog browser SDK where replay activation is driven by remote config and flags.
5325
5427
  if (this.config.preloadFeatureFlags !== false) {
5326
- this.reloadFeatureFlags();
5428
+ this.reloadFeatureFlags({
5429
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
5430
+ cb: _err => {
5431
+ this._featureFlagsResolved = true;
5432
+ this._maybeStartSessionRecording();
5433
+ }
5434
+ });
5435
+ } else {
5436
+ // If feature flags preload is explicitly disabled, treat this requirement as satisfied.
5437
+ this._featureFlagsResolved = true;
5327
5438
  }
5328
5439
  this.config.loaded?.(this);
5329
5440
  if (this.config.capture_pageview) {
@@ -5333,9 +5444,26 @@ class Leanbase extends PostHogCore {
5333
5444
  }
5334
5445
  }, 1);
5335
5446
  }
5336
- addEventListener(document, 'DOMContentLoaded', () => {
5337
- this.loadRemoteConfig();
5338
- });
5447
+ const triggerRemoteConfigLoad = reason => {
5448
+ logger$2.info(`remote config load triggered via ${reason}`);
5449
+ void this.loadRemoteConfig();
5450
+ };
5451
+ if (document) {
5452
+ if (document.readyState === 'loading') {
5453
+ logger$2.info('remote config load deferred until DOMContentLoaded');
5454
+ const onDomReady = () => {
5455
+ document?.removeEventListener('DOMContentLoaded', onDomReady);
5456
+ triggerRemoteConfigLoad('dom');
5457
+ };
5458
+ addEventListener(document, 'DOMContentLoaded', onDomReady, {
5459
+ once: true
5460
+ });
5461
+ } else {
5462
+ triggerRemoteConfigLoad('immediate');
5463
+ }
5464
+ } else {
5465
+ triggerRemoteConfigLoad('no-document');
5466
+ }
5339
5467
  addEventListener(window, 'onpagehide' in self ? 'pagehide' : 'unload', this.capturePageLeave.bind(this), {
5340
5468
  passive: false
5341
5469
  });
@@ -5372,11 +5500,19 @@ class Leanbase extends PostHogCore {
5372
5500
  }
5373
5501
  }
5374
5502
  async loadRemoteConfig() {
5375
- if (!this.isRemoteConfigLoaded) {
5503
+ if (this._remoteConfigLoadAttempted) {
5504
+ return;
5505
+ }
5506
+ this._remoteConfigLoadAttempted = true;
5507
+ try {
5376
5508
  const remoteConfig = await this.reloadRemoteConfigAsync();
5377
5509
  if (remoteConfig) {
5378
5510
  this.onRemoteConfig(remoteConfig);
5379
5511
  }
5512
+ } finally {
5513
+ // Regardless of success/failure, we consider remote config "resolved" so replay isn't blocked forever.
5514
+ this._remoteConfigResolved = true;
5515
+ this._maybeStartSessionRecording();
5380
5516
  }
5381
5517
  }
5382
5518
  onRemoteConfig(config) {
@@ -5389,6 +5525,26 @@ class Leanbase extends PostHogCore {
5389
5525
  this.isRemoteConfigLoaded = true;
5390
5526
  this.replayAutocapture?.onRemoteConfig(config);
5391
5527
  this.sessionRecording?.onRemoteConfig(config);
5528
+ // Remote config has been applied; allow replay start if flags are also ready.
5529
+ this._remoteConfigResolved = true;
5530
+ this._maybeStartSessionRecording();
5531
+ }
5532
+ _maybeStartSessionRecording() {
5533
+ if (this._maybeStartedSessionRecording) {
5534
+ return;
5535
+ }
5536
+ if (!this.sessionRecording) {
5537
+ return;
5538
+ }
5539
+ if (!this._featureFlagsResolved || !this._remoteConfigResolved) {
5540
+ return;
5541
+ }
5542
+ this._maybeStartedSessionRecording = true;
5543
+ try {
5544
+ this.sessionRecording.startIfEnabledOrStop();
5545
+ } catch (e) {
5546
+ logger$2.error('Failed to start session recording', e);
5547
+ }
5392
5548
  }
5393
5549
  fetch(url, options) {
5394
5550
  const fetchFn = getFetch();