diff --git a/dev-server/documents/html/vitalsource-epub.mustache b/dev-server/documents/html/vitalsource-epub.mustache index 7fbaae6101c..b0128605365 100644 --- a/dev-server/documents/html/vitalsource-epub.mustache +++ b/dev-server/documents/html/vitalsource-epub.mustache @@ -27,7 +27,7 @@ let chapterIndex = 0; - const setChapter = index => { + const setChapter = (index, { initialLoad = false } = {}) => { if (index < 0 || index >= chapterURLs.length) { return; } @@ -38,8 +38,33 @@ // does. The client should be robust to either approach. this.contentFrame?.remove(); this.contentFrame = document.createElement('iframe'); - this.contentFrame.src = chapterURLs[chapterIndex]; this.shadowRoot.append(this.contentFrame); + + const chapterURL = chapterURLs[chapterIndex]; + + if (initialLoad) { + // Simulate client loading after VS chapter content has already + // loaded. + this.contentFrame.src = chapterURL; + } else { + // Simulate chapter navigation after client is injected. These + // navigations happen in several stages: + // + // 1. The previous chapter's iframe is removed + // 2. A new iframe is created. The initial HTML is a "blank" page + // containing (invisible) content data for the new chapter as + // text in the page. + // 3. The content data is posted to the server via a form + // submission, which returns the decoded HTML. + // + // The client should only inject into the new frame after step 3. + this.contentFrame.src = 'about:blank'; + setTimeout(() => { + // Set the final URL in a way that doesn't update the `src` attribute + // of the iframe, to make sure the client isn't relying on that. + this.contentFrame.contentWindow.location.href = chapterURL; + }, 50); + } }; const styles = document.createElement('style'); @@ -66,7 +91,7 @@ this.nextButton.onclick = () => setChapter(chapterIndex + 1); controlBar.append(this.nextButton); - setChapter(0); + setChapter(0, { initialLoad: true }); } } customElements.define('mosaic-book', MosaicElement); diff --git a/src/annotator/integrations/test/vitalsource-test.js b/src/annotator/integrations/test/vitalsource-test.js index cf1a13b3e29..4aab956ebe7 100644 --- a/src/annotator/integrations/test/vitalsource-test.js +++ b/src/annotator/integrations/test/vitalsource-test.js @@ -15,20 +15,41 @@ class FakeVitalSourceViewer { this.bookElement.shadowRoot.append(this.contentFrame); document.body.append(this.bookElement); + + this.contentFrame.contentDocument.body.innerHTML = '
Initial content
'; } destroy() { this.bookElement.remove(); } - /** Simulate navigation to a different chapter of the book. */ - loadNextChapter() { + /** + * Simulate navigation to a different chapter of the book. + * + * This process happens in two steps. This method simulates the first step. + * `finishChapterLoad` simulates the second step. + */ + beginChapterLoad() { this.contentFrame.remove(); // VS handles navigations by removing the frame and creating a new one, // rather than navigating the existing frame. this.contentFrame = document.createElement('iframe'); this.bookElement.shadowRoot.append(this.contentFrame); + + // When the new frame initially loads, it will contain some encoded/encrypted + // data for the new chapter. VS will then make a form submission to get the + // decoded HTML. + // + // The integration should not inject the client if the frame contains this + // data content. + this.contentFrame.contentDocument.body.innerHTML = + 'New content
'; + this.contentFrame.dispatchEvent(new Event('load')); } } @@ -107,12 +128,40 @@ describe('annotator/integrations/vitalsource', () => { it('re-injects client when content frame is changed', async () => { fakeGuest.injectClient.resetHistory(); - fakeViewer.loadNextChapter(); + fakeViewer.beginChapterLoad(); await delay(0); + assert.notCalled(fakeGuest.injectClient); + fakeViewer.finishChapterLoad(); + await delay(0); assert.calledWith(fakeGuest.injectClient, fakeViewer.contentFrame); }); + it("doesn't re-inject if content frame is removed", async () => { + fakeGuest.injectClient.resetHistory(); + + // Remove the content frame. This will trigger a re-injection check, but + // do nothing as there is no content frame. + fakeViewer.contentFrame.remove(); + await delay(0); + + assert.notCalled(fakeGuest.injectClient); + }); + + it("doesn't re-inject if content frame siblings change", async () => { + fakeGuest.injectClient.resetHistory(); + + // Modify the DOM tree. This will trigger a re-injection check, but do + // nothing as we've already handled the current frame. + fakeViewer.contentFrame.insertAdjacentElement( + 'afterend', + document.createElement('div') + ); + await delay(0); + + assert.notCalled(fakeGuest.injectClient); + }); + it('does not allow annotation in the container frame', async () => { assert.equal(integration.canAnnotate(), false); diff --git a/src/annotator/integrations/vitalsource.js b/src/annotator/integrations/vitalsource.js index 884929e14d0..fe2907348d0 100644 --- a/src/annotator/integrations/vitalsource.js +++ b/src/annotator/integrations/vitalsource.js @@ -55,20 +55,45 @@ export class VitalSourceContainerIntegration { throw new Error('Book container element not found'); } + /** @type {WeakSet