Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 129 additions & 1 deletion src/GleapFrameManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import { bootstrapGleapFrame, loadFromGleapCache, runFunctionWhenDomIsReady, sav
import { frameTargetOrigin, isSafeNavigationUrl, isScriptUrl } from './GleapMessageSecurity';
import { widgetExpandMinWidth, widgetLoaderMarkup, widgetMaxHeight } from './UI';

// How long an injected messenger frame may take to ping before the watchdog steps in (per stage).
export const FRAME_LOAD_TIMEOUT_MS = 10000;

export default class GleapFrameManager {
frameUrl = 'https://messenger-app.gleap.io';
gleapFrameContainer = null;
Expand Down Expand Up @@ -55,6 +58,8 @@ export default class GleapFrameManager {
surveyRevealTimeout = null;
// A freshly injected frame opens shortly after its first ping, unless it was closed meanwhile.
openAfterPingTimeout = null;
// Watches an injected frame until its first ping (see armFrameLoadWatchdog).
frameLoadTimeout = null;
// The messenger fell back to a legacy (pre Surveys 2.0) survey: use the old survey chrome.
surveyLegacy = false;
// Surveys 2.0 corners reported by the messenger (survey-theme): 'sharp' | 'pill', null = rounded.
Expand Down Expand Up @@ -100,6 +105,8 @@ export default class GleapFrameManager {
appHeight();
// Survey page: forward answer keys typed on the host page into the frame.
document.addEventListener('keydown', (event) => this.forwardSurveyKey(event));
// Back/forward cache (iOS Safari restores pages on back/forward and when reopening a tab).
window.addEventListener('pageshow', (event) => this.handlePageShow(event));
// Phones: keep the card survey sheet above the on-screen keyboard (iOS Safari
// draws the keyboard over fixed elements; visualViewport shrinks instead).
if (window.visualViewport) {
Expand Down Expand Up @@ -551,6 +558,9 @@ export default class GleapFrameManager {
}
this.injectedFrame = false;
this.widgetOpened = false;
// The next frame is a new document: nothing reaches it (and the queue waits) until it pings.
this.comReady = false;
this.clearFrameLoadWatchdog();
this.markerManager = undefined;
if (this.surveyRevealTimeout) {
clearTimeout(this.surveyRevealTimeout);
Expand Down Expand Up @@ -696,6 +706,7 @@ export default class GleapFrameManager {

// Bootstrap the iframe content from the Gleap origin via about:blank + doc.write.
bootstrapGleapFrame(this.gleapFrame, this.frameUrl);
this.armFrameLoadWatchdog(this.gleapFrame);

this.updateFrameStyle();

Expand All @@ -707,6 +718,116 @@ export default class GleapFrameManager {
});
};

/**
* The messenger frame can fail to load without any signal reaching us: the bootstrap fetch or
* the bundle inside the frame stalls on a flaky mobile connection, or iOS Safari cancels them
* when the page goes into the back/forward cache mid-load. Then `ping` never arrives, and on a
* phone the loader covers the whole screen with no way to close it, so the launcher is dead until
* a reload. Stage 1: load the messenger directly into the frame (the cross-origin path). Stage 2:
* still nothing — take the frame down, so the next launcher tap injects a fresh one.
*/
armFrameLoadWatchdog(iframe) {
this.clearFrameLoadWatchdog();
if (!iframe) {
return;
}

this.frameLoadTimeout = setTimeout(() => {
this.frameLoadTimeout = null;
if (this.comReady || this.gleapFrame !== iframe) {
return;
}

try {
// The bootstrap may already have fallen back to a (slow) direct load; don't restart it.
if (iframe.getAttribute('src') !== this.frameUrl) {
iframe.src = this.frameUrl;
}
} catch (e) {}

this.frameLoadTimeout = setTimeout(() => {
this.frameLoadTimeout = null;
if (this.comReady || this.gleapFrame !== iframe) {
return;
}
try {
console.warn(
'Gleap: the messenger (' +
this.frameUrl +
') did not load and was closed. If this keeps happening, check that your ' +
'Content-Security-Policy allows frame-src, script-src and style-src for https://*.gleap.io.'
);
} catch (e) {}
this.resetUnloadedFrame(false);
}, FRAME_LOAD_TIMEOUT_MS);
}, FRAME_LOAD_TIMEOUT_MS);
}

clearFrameLoadWatchdog() {
if (this.frameLoadTimeout) {
clearTimeout(this.frameLoadTimeout);
this.frameLoadTimeout = null;
}
}

/**
* Removes a frame whose messenger never connected. With `reopen`, a widget that was showing
* (its loader) is injected again right away; otherwise the next open injects a fresh frame.
*/
resetUnloadedFrame(reopen) {
const wasOpened = this.widgetOpened;
this.unregisterEscListener();
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
this.closeTimeout = null;
}
this.destroy();
try {
GleapFeedbackButtonManager.getInstance().updateFeedbackButtonState();
} catch (e) {}
if (reopen && wasOpened) {
this.showWidget();
}
}

/**
* A page restored from the back/forward cache (iOS Safari: back/forward, reopening a tab) comes
* back with this manager's state but loads that were in flight may have been cancelled, and
* timers or animations may have been cut short. Re-sync so the launcher keeps working.
*/
handlePageShow(event) {
if (!event || !event.persisted) {
return;
}

try {
if (this.injectedFrame && this.gleapFrameContainer && !this.comReady) {
// The frame was still loading when the page was frozen: start it over.
this.resetUnloadedFrame(true);
return;
}

const container = this.gleapFrameContainer;
if (container) {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
this.closeTimeout = null;
}
container.classList.remove('gleap-frame-container--closing');
if (this.widgetOpened) {
container.classList.remove('gleap-frame-container--hidden');
container.classList.add('gleap-frame-container--animate');
} else if (!container.classList.contains('gleap-frame-container--measuring')) {
container.classList.add('gleap-frame-container--hidden');
container.classList.remove('gleap-frame-container--animate');
}
this.updateWidgetStatus();
}

GleapFeedbackButtonManager.getInstance().updateFeedbackButtonState();
} catch (e) {}
}

showImage = (url) => {
runFunctionWhenDomIsReady(() => {
var elem = document.createElement('div');
Expand Down Expand Up @@ -938,7 +1059,13 @@ export default class GleapFrameManager {

showWidget() {
setTimeout(() => {
if (this.gleapFrameContainer) {
if (this.gleapFrameContainer && !this.comReady) {
// Injected but the messenger hasn't pinged yet: keep the loader up (not a blank frame);
// the ping opens it.
if (this.appMode === 'widget') {
this.showFrameContainer(true);
}
} else if (this.gleapFrameContainer) {
this.runWidgetShouldOpenCallback();
} else {
GleapFrameManager.getInstance().injectFrame();
Expand Down Expand Up @@ -1125,6 +1252,7 @@ export default class GleapFrameManager {
this.addMessageListener((data) => {
if (data.name === 'ping') {
this.comReady = true;
this.clearFrameLoadWatchdog();
this.sendConfigUpdate();
this.sendSessionUpdate();
this.sendCaptureCapabilities();
Expand Down
114 changes: 113 additions & 1 deletion src/GleapFrameManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @jest-environment jsdom
*/
import { loadFromGleapCache, saveToGleapCache } from './GleapHelper';
import GleapFrameManager from './GleapFrameManager';
import GleapFrameManager, { FRAME_LOAD_TIMEOUT_MS } from './GleapFrameManager';

// Stub the barrel so the real GleapFrameManager loads without the SDK_VERSION
// Webpack global. flowConfig is a mutable object so tests can flip
Expand Down Expand Up @@ -317,3 +317,115 @@ describe('GleapFrameManager url handler', () => {
expect(window.location.href).toBe(before);
});
});

describe('GleapFrameManager opens reliably (iOS: never stuck until a reload)', () => {
const HIDDEN = 'gleap-frame-container--hidden';
const LOADING = 'gleap-frame-container--loading';
const CLOSING = 'gleap-frame-container--closing';
let warn;

// A frame that was injected but whose messenger hasn't pinged yet.
const setupLoading = () => {
const fm = setup();
fm.comReady = false;
fm.injectedFrame = true;
fm.gleapFrame = document.querySelector('.gleap-frame');
fm.gleapFrameContainer.classList.add(HIDDEN);
return fm;
};

beforeEach(() => {
jest.useFakeTimers();
warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
jest.useRealTimers();
warn.mockRestore();
});

it('keeps the loader up when opened again before the messenger pinged, and opens on the ping', () => {
const fm = setupLoading();
fm.showWidget();
jest.advanceTimersByTime(0);
const container = fm.gleapFrameContainer;
expect(container.classList.contains(HIDDEN)).toBe(false);
expect(container.classList.contains(LOADING)).toBe(true);
expect(fm.widgetOpened).toBe(true);

dispatch(fm, { name: 'ping' });
jest.advanceTimersByTime(300);
expect(container.classList.contains(LOADING)).toBe(false);
});

it('loads the messenger directly when the bootstrapped frame never pings, then takes the frame down', () => {
const fm = setupLoading();
const frame = fm.gleapFrame;
fm.showFrameContainer(true);
fm.armFrameLoadWatchdog(frame);

jest.advanceTimersByTime(FRAME_LOAD_TIMEOUT_MS);
expect(frame.getAttribute('src')).toBe(fm.frameUrl);
expect(fm.gleapFrameContainer).not.toBeNull();

jest.advanceTimersByTime(FRAME_LOAD_TIMEOUT_MS);
expect(fm.gleapFrameContainer).toBeNull();
expect(fm.injectedFrame).toBe(false);
expect(fm.widgetOpened).toBe(false);
expect(document.querySelector('.gleap-frame-container')).toBeNull();
expect(warn).toHaveBeenCalled();
});

it('stands the watchdog down on the first ping', () => {
const fm = setupLoading();
fm.armFrameLoadWatchdog(fm.gleapFrame);
dispatch(fm, { name: 'ping' });
jest.advanceTimersByTime(FRAME_LOAD_TIMEOUT_MS * 2);
expect(fm.gleapFrame.getAttribute('src')).toBeNull();
expect(fm.gleapFrameContainer).not.toBeNull();
});

it('forgets the connection on destroy, so messages for the next frame wait for its ping', () => {
const fm = setup();
fm.destroy();
expect(fm.comReady).toBe(false);
document.body.innerHTML = '<iframe class="gleap-frame"></iframe>';
fm.queue = [];
fm.sendMessage({ name: 'open-conversation', data: {} }, true);
expect(fm.queue).toEqual([{ name: 'open-conversation', data: {} }]);
});

it('restarts a frame that was still loading when the page went into the back/forward cache', () => {
const fm = setupLoading();
fm.showFrameContainer(true);
const inject = jest.spyOn(fm, 'injectFrame').mockImplementation(() => {});
const instance = jest.spyOn(GleapFrameManager, 'getInstance').mockReturnValue(fm);

fm.handlePageShow({ persisted: true });
expect(document.querySelector('.gleap-frame-container')).toBeNull();
expect(fm.injectedFrame).toBe(false);

jest.advanceTimersByTime(0);
expect(inject).toHaveBeenCalledTimes(1);
instance.mockRestore();
});

it('finishes a close that was cut short by the back/forward cache and re-sends the state', () => {
const fm = setup();
const container = fm.gleapFrameContainer;
container.classList.add(CLOSING);
fm.widgetOpened = false;
postMessage.mockClear();

fm.handlePageShow({ persisted: true });
expect(container.classList.contains(CLOSING)).toBe(false);
expect(container.classList.contains(HIDDEN)).toBe(true);
const status = postMessage.mock.calls.map(([raw]) => JSON.parse(raw)).find((m) => m.name === 'widget-status-update');
expect(status.data).toEqual({ isWidgetOpen: false });
});

it('ignores a regular page load', () => {
const fm = setupLoading();
fm.handlePageShow({ persisted: false });
expect(fm.gleapFrameContainer).not.toBeNull();
});
});
Loading