);
});
diff --git a/src/Viewer/Viewer.tsx b/src/Viewer/Viewer.tsx
index 5e42cba..8e48b7a 100644
--- a/src/Viewer/Viewer.tsx
+++ b/src/Viewer/Viewer.tsx
@@ -23,6 +23,7 @@ import {
getNumItems,
createSourceHash,
} from "../Activity/activityState";
+import type { MountPolicy } from "@doenet/doenetml-iframe";
import { Activity } from "../Activity/Activity";
import { activityDoenetStateReducer } from "../Activity/activityStateReducer";
import { useContentStable } from "../utils/hooks";
@@ -45,12 +46,18 @@ export function Viewer({
addVirtualKeyboard: _addVirtualKeyboard = true,
externalVirtualKeyboardProvided: _externalVirtualKeyboardProvided = false,
doenetViewerUrl,
+ doenetMediaUrl,
+ standaloneUrl,
+ cssUrl,
+ doenetmlVersion,
fetchExternalDoenetML,
darkMode = "light",
showAnswerResponseMenu = false,
answerResponseCountsByItem = [],
showTitle = true,
itemWord = "item",
+ mountPolicy,
+ useSharedCoreWorker = false,
}: {
source: ActivitySource;
flags: DoenetMLFlags;
@@ -69,12 +76,18 @@ export function Viewer({
addVirtualKeyboard?: boolean;
externalVirtualKeyboardProvided?: boolean;
doenetViewerUrl?: string;
+ doenetMediaUrl?: string;
+ standaloneUrl?: string;
+ cssUrl?: string;
+ doenetmlVersion?: string;
fetchExternalDoenetML?: (arg: string) => Promise;
darkMode?: "dark" | "light";
showAnswerResponseMenu?: boolean;
answerResponseCountsByItem?: Record[];
showTitle?: boolean;
itemWord?: string;
+ mountPolicy: MountPolicy;
+ useSharedCoreWorker?: boolean;
}) {
const initialPass = useRef(true);
@@ -131,7 +144,7 @@ export function Viewer({
// Content-stable: every reducer action (each score report included)
// rebuilds `activityState`, but the sequence of item ids rarely changes.
// Keeping the previous identity when the ids match lets everything
- // derived from it (`itemIdsToRender`, `checkRender`, the memoized item
+ // derived from it (`itemIndexById`, the callbacks, the memoized item
// subtrees) stay stable across reports.
const computedItemSequence = useMemo(
() => getItemSequence(activityState),
@@ -152,7 +165,6 @@ export function Viewer({
const currentItemId = itemSequence[currentItemIdx];
const [itemsRendered, setItemsRendered] = useState([]);
- const [itemsVisible, setItemsVisible] = useState([]);
const [newAttemptNum, setNewAttemptNum] = useState(0);
const dialogRef = useRef(null);
@@ -160,64 +172,23 @@ export function Viewer({
const attemptNumber = activityState.attemptNumber;
- // The items allowed to mount their viewer, *derived* from which items
- // have finished rendering: everything already rendered plus (at most)
- // one in-flight item — in paginated mode the current item, then a
- // prefetch of the next and previous; in scroll mode the first visible
- // unrendered item. Deriving (rather than accumulating in state) keeps
- // the schedule consistent through attempt resets and item regeneration,
- // which simply remove ids from `itemsRendered`.
- const itemIdsToRender = useMemo(() => {
- const toRender = new Set(itemsRendered);
- if (paginate) {
- const currentItemId = itemSequence[currentItemIdx];
- toRender.add(currentItemId);
- if (itemsRendered.includes(currentItemId)) {
- const nextItemId = itemSequence[currentItemIdx + 1];
- if (currentItemIdx < numItems - 1) {
- // prefetch the next item once the current one rendered
- toRender.add(nextItemId);
- }
- if (
- currentItemIdx > 0 &&
- (currentItemIdx >= numItems - 1 ||
- itemsRendered.includes(nextItemId))
- ) {
- // then the previous item
- toRender.add(itemSequence[currentItemIdx - 1]);
- }
- }
- } else {
- const visibleSet = new Set(itemsVisible);
- for (const id of itemSequence) {
- // `toRender` still equals the rendered set here (nothing was
- // added in this branch), so it doubles as the fast
- // membership test.
- if (!toRender.has(id) && visibleSet.has(id)) {
- toRender.add(id);
- break;
- }
- }
- }
- return toRender;
- }, [
- paginate,
- itemsRendered,
- itemsVisible,
- itemSequence,
- currentItemIdx,
- numItems,
- ]);
-
- const checkRender = useCallback(
+ // Every item's viewer is always mounted; the windowed mounting policy
+ // (`mountPolicy`) decides which of them are actually booted, parking the
+ // rest as placeholders. In paginated mode the current page and its
+ // neighbors are marked `keepLive` so they boot eagerly (hidden pages
+ // never intersect the viewport) and page flips within the window are
+ // instant; in scroll mode visibility alone governs.
+ const checkKeepLive = useCallback(
(state: ActivityState) => {
- if (state.type === "singleDoc") {
- return itemIdsToRender.has(state.id);
- } else {
- return true;
+ if (!paginate || state.type !== "singleDoc") {
+ return false;
}
+ const itemIdx = itemIndexById.get(state.id);
+ return (
+ itemIdx !== undefined && Math.abs(itemIdx - currentItemIdx) <= 1
+ );
},
- [itemIdsToRender],
+ [paginate, itemIndexById, currentItemIdx],
);
const checkHidden = useCallback(
@@ -402,25 +373,6 @@ export function Viewer({
setItemsRendered((was) => (was.includes(id) ? was : [...was, id]));
}, []);
- const reportVisibilityCallback = useCallback(
- (id: string, isVisible: boolean) => {
- setItemsVisible((was) => {
- if (isVisible) {
- return was.includes(id) ? was : [...was, id];
- } else {
- const idx = was.indexOf(id);
- if (idx === -1) {
- return was;
- }
- const obj = [...was];
- obj.splice(idx, 1);
- return obj;
- }
- });
- },
- [],
- );
-
function generateActivityAttempt() {
setItemsRendered([]);
setCurrentItemIdx(0);
@@ -628,6 +580,10 @@ export function Viewer({
forceShowSolution={forceShowSolution}
forceUnsuppressCheckwork={forceUnsuppressCheckwork}
doenetViewerUrl={doenetViewerUrl}
+ doenetMediaUrl={doenetMediaUrl}
+ standaloneUrl={standaloneUrl}
+ cssUrl={cssUrl}
+ doenetmlVersion={doenetmlVersion}
fetchExternalDoenetML={fetchExternalDoenetML}
darkMode={darkMode}
showAnswerResponseMenu={showAnswerResponseMenu}
@@ -635,14 +591,14 @@ export function Viewer({
state={activityState}
doenetStates={activityDoenetState.doenetStates}
stateVersion={stateVersion}
+ mountPolicy={mountPolicy}
+ useSharedCoreWorker={useSharedCoreWorker}
reportScoreAndStateCallback={reportScoreAndStateCallback}
- checkRender={checkRender}
checkHidden={checkHidden}
+ checkKeepLive={checkKeepLive}
allowItemAttemptButtons={itemLevelAttempts}
generateNewItemAttempt={generateNewItemAttemptPrompt}
hasRenderedCallback={hasRenderedCallback}
- reportVisibility={!paginate}
- reportVisibilityCallback={reportVisibilityCallback}
itemAttemptNumbers={activityDoenetState.itemAttemptNumbers}
itemIndexById={itemIndexById}
itemWord={itemWord}
diff --git a/src/activity-viewer.tsx b/src/activity-viewer.tsx
index b9b4bae..5857f7b 100644
--- a/src/activity-viewer.tsx
+++ b/src/activity-viewer.tsx
@@ -9,6 +9,7 @@ import {
useState,
} from "react";
import seedrandom from "seedrandom";
+import type { MountPolicy } from "@doenet/doenetml-iframe";
import { Viewer } from "./Viewer/Viewer";
import { DoenetMLFlags } from "./types";
import {
@@ -17,6 +18,7 @@ import {
} from "./Activity/activityState";
import { useResolvedTheme } from "./utils/theme";
import type { ThemeSetting } from "./utils/theme";
+import { useContentStable } from "./utils/hooks";
/**
* A condition in the provided activity worth surfacing to the user — passed
@@ -80,6 +82,10 @@ export function ActivityViewer({
addVirtualKeyboard = true,
externalVirtualKeyboardProvided = false,
doenetViewerUrl,
+ doenetMediaUrl,
+ standaloneUrl,
+ cssUrl,
+ doenetmlVersion,
fetchExternalDoenetML,
darkMode = "system",
showAnswerResponseMenu = false,
@@ -88,6 +94,8 @@ export function ActivityViewer({
showTitle = true,
itemWord = "item",
reportWarningsCallback,
+ mountPolicy,
+ useSharedCoreWorker = false,
}: {
source: ActivitySource;
flags?: DoenetMLFlagsSubset;
@@ -105,7 +113,30 @@ export function ActivityViewer({
forceUnsuppressCheckwork?: boolean;
addVirtualKeyboard?: boolean;
externalVirtualKeyboardProvided?: boolean;
+ /**
+ * URL the `` renderer uses to build links to other Doenet
+ * activities. Forwarded to each document's viewer, which defaults it to
+ * `https://doenet.org/activityViewer`.
+ */
doenetViewerUrl?: string;
+ /**
+ * URL used to resolve `` media references.
+ * Forwarded to each document's viewer, which defaults it to
+ * `https://doenet.org/api/media`.
+ */
+ doenetMediaUrl?: string;
+ /**
+ * URL of a standalone DoenetML bundle to use for every document,
+ * instead of the CDN bundle for each document's `version`.
+ */
+ standaloneUrl?: string;
+ /** URL of the CSS file that styles the standalone bundle. */
+ cssUrl?: string;
+ /**
+ * Render every document with this DoenetML version, overriding each
+ * document's own `version`.
+ */
+ doenetmlVersion?: string;
fetchExternalDoenetML?: (arg: string) => Promise;
darkMode?: ThemeSetting;
showAnswerResponseMenu?: boolean;
@@ -122,6 +153,21 @@ export function ActivityViewer({
* developers.
*/
reportWarningsCallback?: (warnings: ActivityViewerWarning[]) => void;
+ /**
+ * Overrides for the windowed mounting policy every document's viewer
+ * registers with (see `MountPolicy` in `@doenet/doenetml-iframe`):
+ * at most `maxLiveViewers` viewers stay booted, the rest are parked
+ * losslessly as placeholders and restored near the viewport. Parking
+ * requires `flags.allowSaveState` or `flags.allowLocalState`; without
+ * them viewers still mount lazily but stay live once booted.
+ */
+ mountPolicy?: Partial>;
+ /**
+ * Serve all documents' cores from a shared worker pool instead of one
+ * dedicated ~100 MB worker per document (see `useSharedCoreWorker` in
+ * `@doenet/doenetml-iframe`). Default off.
+ */
+ useSharedCoreWorker?: boolean;
}) {
const [initialVariantIndex, setInitialVariantIndex] = useState<
number | null
@@ -189,6 +235,15 @@ export function ActivityViewer({
[specifiedFlags],
);
+ // Windowed mounting is the default: memory tracks what the student can
+ // see (the pagination window / viewport) instead of assignment length.
+ // Content-stable so a consumer passing an inline `mountPolicy` object
+ // doesn't hand the memoized item subtrees a fresh identity every render.
+ const resolvedMountPolicy = useContentStable(
+ useMemo(() => ({ mode: "windowed", ...mountPolicy }), [mountPolicy]),
+ JSON.stringify(mountPolicy ?? {}),
+ );
+
// Normalize variant index to an integer.
// Generate a random variant index if the requested variant index is undefined.
// To preserve the generated variant index on rerender, regenerate only
@@ -235,12 +290,18 @@ export function ActivityViewer({
externalVirtualKeyboardProvided
}
doenetViewerUrl={doenetViewerUrl}
+ doenetMediaUrl={doenetMediaUrl}
+ standaloneUrl={standaloneUrl}
+ cssUrl={cssUrl}
+ doenetmlVersion={doenetmlVersion}
fetchExternalDoenetML={fetchExternalDoenetML}
darkMode={resolvedTheme}
showAnswerResponseMenu={showAnswerResponseMenu}
answerResponseCountsByItem={answerResponseCountsByItem}
showTitle={showTitle}
itemWord={itemWord}
+ mountPolicy={resolvedMountPolicy}
+ useSharedCoreWorker={useSharedCoreWorker}
/>
diff --git a/test/cypress/component/ActivityViewer.viewerUrls.cy.tsx b/test/cypress/component/ActivityViewer.viewerUrls.cy.tsx
new file mode 100644
index 0000000..8216495
--- /dev/null
+++ b/test/cypress/component/ActivityViewer.viewerUrls.cy.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+import { ActivityViewer } from "../../../src/activity-viewer";
+import type { ActivitySource } from "../../../src/Activity/activityState";
+import {
+ STANDALONE_URL,
+ STANDALONE_CSS_URL,
+ IFRAME_READY_TIMEOUT,
+} from "./helpers";
+
+// `doenetViewerUrl` (the `` renderer's activity-link base) and
+// `doenetMediaUrl` (the `` base, DoenetML#1457) are
+// props of the inner DoenetViewer. ActivityViewer must thread them through
+// Viewer → Activity → SingleDocActivity to each embedded ``.
+// The iframe wrapper bakes a booted viewer's props into the iframe `srcdoc`,
+// so a forwarded URL shows up there — a stable check that does not depend on
+// the bundle rendering a ref/image.
+
+const VIEWER_URL = "https://viewer.example.test/activityViewer";
+const MEDIA_URL = "https://media.example.test/api/media";
+
+const SOURCE: ActivitySource = {
+ type: "singleDoc",
+ id: "doc",
+ doenetML: "
hello
",
+ version: "0.7.4",
+ isDescription: false,
+ numVariants: 1,
+} as ActivitySource;
+
+describe("ActivityViewer — forwards doenetViewerUrl and doenetMediaUrl to the viewer", () => {
+ it("bakes both URLs into the embedded DoenetViewer's iframe", () => {
+ cy.mount(
+ ,
+ );
+
+ cy.get("iframe", { timeout: IFRAME_READY_TIMEOUT })
+ .should("have.attr", "srcdoc")
+ .and("include", VIEWER_URL)
+ .and("include", MEDIA_URL);
+ });
+});
diff --git a/test/cypress/component/ActivityViewer.windowed.cy.tsx b/test/cypress/component/ActivityViewer.windowed.cy.tsx
new file mode 100644
index 0000000..8ebaaa9
--- /dev/null
+++ b/test/cypress/component/ActivityViewer.windowed.cy.tsx
@@ -0,0 +1,152 @@
+import React from "react";
+import { ActivityViewer } from "../../../src/activity-viewer";
+import type { ActivitySource } from "../../../src/Activity/activityState";
+import type { SingleDocSource } from "../../../src/Activity/singleDocState";
+import {
+ WINDOWED_STANDALONE_URL,
+ WINDOWED_STANDALONE_CSS_URL,
+ IFRAME_READY_TIMEOUT,
+ PARK_TIMEOUT,
+} from "./helpers";
+
+// Windowed mounting (#35, #36, #37): every item's DoenetViewer is mounted,
+// but the wrapper's mountPolicy decides which are booted — memory tracks the
+// pagination window / viewport, not assignment length. Parking is lossless:
+// state is flushed before the iframe is detached and restored on return.
+
+function mkDoc(id: string, label: string): SingleDocSource {
+ return {
+ id,
+ type: "singleDoc",
+ isDescription: false,
+ doenetML: `
${label}:
typed: $ti.value
`,
+ version: "0.7.4",
+ numVariants: 1,
+ };
+}
+
+function mkSequence(numDocs: number): ActivitySource {
+ return {
+ type: "sequence",
+ id: "seq",
+ title: "windowed",
+ shuffle: false,
+ items: Array.from({ length: numDocs }, (_, i) =>
+ mkDoc(`doc${(i + 1).toString()}`, `Doc ${(i + 1).toString()}`),
+ ),
+ } as ActivitySource;
+}
+
+/**
+ * The iframe of the item with the given id: the DoenetViewer bakes its
+ * props (including `docId`) into the iframe's srcdoc. A windowed viewer
+ * only has an iframe while booted — parked viewers are placeholders.
+ */
+function itemIframe(id: string, options?: { timeout?: number }) {
+ return cy.get(`iframe[srcdoc*='"docId":"${id}"']`, options);
+}
+
+/** Assert rendered (script-stripped) iframe content for an item. */
+function assertItemContent(id: string, text: string) {
+ itemIframe(id)
+ .its("0.contentDocument.body", { timeout: IFRAME_READY_TIMEOUT })
+ .should((body: HTMLElement) => {
+ const clone = body.cloneNode(true) as HTMLElement;
+ clone.querySelectorAll("script").forEach((s) => {
+ s.remove();
+ });
+ expect(clone.textContent).to.contain(text);
+ });
+}
+
+describe("ActivityViewer — windowed mounting", () => {
+ it("paginated: keeps the window live, parks beyond it, and restores typed work", () => {
+ cy.viewport(900, 700);
+ cy.mount(
+ ,
+ );
+
+ // Page 1 (current, keepLive) boots and renders; its neighbor
+ // prefetches in the background. Pages beyond the window never boot.
+ assertItemContent("doc1", "typed:");
+ itemIframe("doc2", { timeout: IFRAME_READY_TIMEOUT }).should("exist");
+ cy.get("iframe").should("have.length.at.most", 3);
+ itemIframe("doc4").should("not.exist");
+ itemIframe("doc5").should("not.exist");
+
+ // Type into page 1 and commit with Enter.
+ itemIframe("doc1")
+ .its("0.contentDocument.body")
+ .find("input:not([type=checkbox])")
+ .then(($el) => cy.wrap($el))
+ .type("windowed work{enter}");
+ assertItemContent("doc1", "typed: windowed work");
+
+ // Page forward to 4: the window moves; doc1 leaves it and parks
+ // (iframe detached), and the live-iframe count stays bounded.
+ cy.contains("button", "Next").click();
+ cy.contains("button", "Next").click();
+ cy.contains("button", "Next").click();
+ assertItemContent("doc4", "typed:");
+ itemIframe("doc1", { timeout: PARK_TIMEOUT }).should("not.exist");
+ cy.get("iframe", { timeout: PARK_TIMEOUT }).should(
+ "have.length.at.most",
+ 3,
+ );
+
+ // Page back to 1: it restores — typed work intact, no interaction.
+ cy.contains("button", "Previous").click();
+ cy.contains("button", "Previous").click();
+ cy.contains("button", "Previous").click();
+ assertItemContent("doc1", "typed: windowed work");
+ itemIframe("doc1")
+ .its("0.contentDocument.body", { timeout: IFRAME_READY_TIMEOUT })
+ .find("input:not([type=checkbox])")
+ .should("have.value", "windowed work");
+ });
+
+ it("scroll mode: off-screen items never boot; scrolling boots them and parks the ones left behind", () => {
+ cy.viewport(900, 600);
+ cy.mount(
+ ,
+ );
+
+ // The first item (in the viewport) boots; the last is far below the
+ // 100px margin (parked placeholders are ~500px tall) and never does.
+ assertItemContent("doc1", "typed:");
+ itemIframe("doc5").should("not.exist");
+
+ // Scroll to the bottom: the far items boot; the ones left behind
+ // exceed the budget of 2 and park (bounded iframe count).
+ cy.scrollTo("bottom", { duration: 500 });
+ assertItemContent("doc5", "typed:");
+ itemIframe("doc1", { timeout: PARK_TIMEOUT }).should("not.exist");
+ cy.get("iframe", { timeout: PARK_TIMEOUT }).should(
+ "have.length.at.most",
+ 2,
+ );
+ });
+});
diff --git a/test/cypress/component/helpers.ts b/test/cypress/component/helpers.ts
index 331c4b1..6ab3698 100644
--- a/test/cypress/component/helpers.ts
+++ b/test/cypress/component/helpers.ts
@@ -14,3 +14,14 @@ export const STANDALONE_CSS_URL = `${CDN}/style.css`;
// Budget enough time for the standalone bundle to evaluate inside the iframe
// on a cold CDN fetch.
export const IFRAME_READY_TIMEOUT = 20_000;
+
+// The windowed-mounting specs park viewers, which requires a standalone
+// bundle that acknowledges `SPLICE.flushState` — true of the bundle
+// published alongside the installed doenetml-iframe version (the wrapper
+// treats a host-specified standaloneUrl as modern).
+export const WINDOWED_STANDALONE_URL = STANDALONE_URL;
+export const WINDOWED_STANDALONE_CSS_URL = STANDALONE_CSS_URL;
+
+// Parking additionally waits for the off-screen viewer's realm to boot far
+// enough to acknowledge the flush.
+export const PARK_TIMEOUT = 40_000;