-
Notifications
You must be signed in to change notification settings - Fork 201
/
Copy pathframe-observer.ts
257 lines (230 loc) · 8 KB
/
frame-observer.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
import debounce from 'lodash.debounce';
export const DEBOUNCE_WAIT = 40;
type FrameCallback = (frame: HTMLIFrameElement) => void;
/**
* FrameObserver detects iframes added and deleted from the document.
*
* To enable annotation, an iframe must be opted-in by adding the
* `enable-annotation` attribute.
*
* We require the `enable-annotation` attribute to avoid the overhead of loading
* the client into frames which are not useful to annotate. See
* https://github.com/hypothesis/client/issues/530
*/
export class FrameObserver {
private _element: Element;
private _onFrameAdded: FrameCallback;
private _onFrameRemoved: FrameCallback;
private _annotatableFrames: Set<HTMLIFrameElement>;
private _isDisconnected: boolean;
private _mutationObserver: MutationObserver;
/**
* @param element - root of the DOM subtree to watch for the addition and
* removal of annotatable iframes
* @param onFrameAdded - callback fired when an annotatable iframe is added
* @param onFrameRemoved - callback triggered when the annotatable iframe is removed
*/
constructor(
element: Element,
onFrameAdded: FrameCallback,
onFrameRemoved: FrameCallback,
) {
this._element = element;
this._onFrameAdded = onFrameAdded;
this._onFrameRemoved = onFrameRemoved;
this._annotatableFrames = new Set<HTMLIFrameElement>();
this._isDisconnected = false;
this._mutationObserver = new MutationObserver(
debounce(() => {
this._discoverFrames();
}, DEBOUNCE_WAIT),
);
this._discoverFrames();
this._mutationObserver.observe(this._element, {
childList: true,
subtree: true,
attributeFilter: ['enable-annotation'],
});
}
disconnect() {
this._isDisconnected = true;
this._mutationObserver.disconnect();
}
private async _addFrame(frame: HTMLIFrameElement) {
this._annotatableFrames.add(frame);
try {
await onNextDocumentReady(frame);
if (this._isDisconnected) {
return;
}
const frameWindow = frame.contentWindow;
// This line raises an exception if the iframe is from a different origin
frameWindow!.addEventListener('unload', () => {
this._removeFrame(frame);
});
this._onFrameAdded(frame);
} catch (e) {
console.warn(
`Unable to inject the Hypothesis client (from '${document.location.href}' into a cross-origin frame '${frame.src}')`,
);
}
}
private _removeFrame(frame: HTMLIFrameElement) {
this._annotatableFrames.delete(frame);
this._onFrameRemoved(frame);
}
private _discoverFrames() {
const frames = new Set<HTMLIFrameElement>(
this._element.querySelectorAll('iframe[enable-annotation]'),
);
for (const frame of frames) {
if (!this._annotatableFrames.has(frame)) {
this._addFrame(frame);
}
}
for (const frame of this._annotatableFrames) {
if (!frames.has(frame)) {
this._removeFrame(frame);
}
}
}
}
/**
* Test if this is the empty document that a new iframe has before the URL
* specified by its `src` attribute loads.
*/
function hasBlankDocumentThatWillNavigate(frame: HTMLIFrameElement): boolean {
return (
frame.contentDocument?.location.href === 'about:blank' &&
// Do we expect the frame to navigate away from about:blank?
frame.hasAttribute('src') &&
frame.src !== 'about:blank'
);
}
/**
* Wrapper around {@link onDocumentReady} which returns a promise that resolves
* the first time that a document in `frame` becomes ready.
*
* See {@link onDocumentReady} for the definition of _ready_.
*/
export function onNextDocumentReady(
frame: HTMLIFrameElement,
): Promise<Document> {
return new Promise((resolve, reject) => {
const unsubscribe = onDocumentReady(frame, (err, doc) => {
unsubscribe();
if (doc) {
resolve(doc);
} else {
reject(err);
}
});
});
}
/**
* Register a callback that is invoked when the content document
* (`frame.contentDocument`) in a same-origin iframe becomes _ready_.
*
* A document is _ready_ when its `readyState` is either "interactive" or
* "complete". It must also not be the empty document with URL "about:blank"
* that iframes have before they navigate to the URL specified by their "src"
* attribute.
*
* The callback is fired both for the document that is in the frame when
* `onDocumentReady` is called, as well as for new documents that are
* subsequently loaded into the same frame.
*
* If at any time the frame navigates to an iframe that is cross-origin,
* the callback will fire with an error. It will fire again for subsequent
* navigations, but due to platform limitations, it will only fire after the
* next document fully loads (ie. when the frame's `load` event fires).
*
* @return Callback that unsubscribes from future changes
*/
export function onDocumentReady(
frame: HTMLIFrameElement,
callback: (err: Error | null, document?: Document) => void,
{ pollInterval = 10 }: { pollInterval?: number } = {},
): () => void {
let pollTimer: number | undefined;
// Two linting rules are conflicting here, so muting one of them.
// This should be fixable by refactoring the whole function, as there are
// crossed dependencies between local callbacks, that rely on each other
// having been called in a specific order.
// eslint-disable-next-line prefer-const
let pollForDocumentChange: () => void;
// Visited documents for which we have fired the callback or are waiting
// to become ready.
const documents = new WeakSet();
const cancelPoll = () => {
clearTimeout(pollTimer);
pollTimer = undefined;
};
// Begin polling for a document change when the current document is about
// to go away.
const pollOnUnload = () => {
if (frame.contentDocument) {
frame.contentWindow?.addEventListener('unload', pollForDocumentChange);
}
};
const checkForDocumentChange = () => {
const currentDocument = frame.contentDocument;
// `contentDocument` may be null if the frame navigated to a URL that is
// cross-origin, or if the `<iframe>` was removed from the document.
if (!currentDocument) {
cancelPoll();
const errorMessage = frame.isConnected
? 'Frame is cross-origin'
: 'Frame is disconnected';
callback(new Error(errorMessage));
return;
}
if (documents.has(currentDocument)) {
return;
}
documents.add(currentDocument);
cancelPoll();
if (!hasBlankDocumentThatWillNavigate(frame)) {
const isReady =
currentDocument.readyState === 'interactive' ||
currentDocument.readyState === 'complete';
if (isReady) {
callback(null, currentDocument);
} else {
currentDocument.addEventListener('DOMContentLoaded', () =>
callback(null, currentDocument),
);
}
}
// Poll for the next document change.
pollOnUnload();
};
let canceled = false;
pollForDocumentChange = () => {
cancelPoll();
if (!canceled) {
pollTimer = setInterval(checkForDocumentChange, pollInterval);
}
};
// Set up observers for signals that the document either has changed or will
// soon change. There are two signals with different trade-offs:
//
// - Polling after the current document is about to be unloaded. This allows
// us to detect the new document quickly, but may not fire in some
// situations (exact circumstances unclear, but eg. MDN warns about this).
//
// This is set up in the first call to `checkForDocumentChange`.
//
// - The iframe's "load" event. This is guaranteed to fire but only after the
// new document is fully loaded.
frame.addEventListener('load', checkForDocumentChange);
// Notify caller about the current document. This fires asynchronously so that
// the caller will have received the unsubscribe callback first.
const initialCheckTimer = setTimeout(checkForDocumentChange, 0);
return () => {
canceled = true;
clearTimeout(initialCheckTimer);
cancelPoll();
frame.removeEventListener('load', checkForDocumentChange);
};
}