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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
# Doenet assignment viewer

View assignments from questions written in DoenetML

## Mixed DoenetML versions

Each document in an assignment carries its own DoenetML `version`, and every
embedded viewer downloads and parses the multi-MB standalone bundle for its
document's version — so an assignment mixing versions multiplies that cost
by the number of distinct versions on the page.

The viewer does not normalize versions itself (that would change how the
older documents behave). Instead, when an assignment mixes versions, it
reports the condition to the containing page — a console warning alone would
be invisible to the site's visitors — via the `reportWarningsCallback` prop,
and the page decides how to display it:

```tsx
<ActivityViewer
source={source}
reportWarningsCallback={(warnings) => {
for (const warning of warnings) {
if (warning.type === "mixedDoenetmlVersions") {
showBanner(
`This assignment mixes DoenetML versions (${warning.versions.join(", ")}), ` +
"which slows loading. Consider updating its documents to one version.",
);
}
}
}}
/>
```

To consolidate, normalize the documents' `version` fields in the assignment
source. Saved student state survives such a change: it is keyed on a source
hash that deliberately ignores `version`.
21 changes: 21 additions & 0 deletions src/Activity/activityState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,27 @@ export function isExportedActivityState(
);
}

/**
* Collect the distinct DoenetML `version` values of all documents in
* `source`, in first-appearance order. An assignment mixing versions makes
* the embedded viewers download and parse a separate multi-MB standalone
* bundle per distinct version.
*/
export function collectDoenetmlVersions(source: ActivitySource): string[] {
if (source.type === "singleDoc") {
return [source.version];
}
const versions: string[] = [];
for (const item of source.items) {
for (const version of collectDoenetmlVersions(item)) {
if (!versions.includes(version)) {
versions.push(version);
}
}
}
return versions;
}

/**
* Initialize activity state from `source` so that it is ready to generate attempts.
*
Expand Down
90 changes: 88 additions & 2 deletions src/activity-viewer.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
import "./assignment-viewer.css";
import { Component, ErrorInfo, ReactNode, useMemo, useState } from "react";
import {
Component,
ErrorInfo,
ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import seedrandom from "seedrandom";
import { Viewer } from "./Viewer/Viewer";
import { DoenetMLFlags } from "./types";
import { ActivitySource } from "./Activity/activityState";
import {
ActivitySource,
collectDoenetmlVersions,
} from "./Activity/activityState";
import { useResolvedTheme } from "./utils/theme";
import type { ThemeSetting } from "./utils/theme";

/**
* A condition in the provided activity worth surfacing to the user — passed
* to `reportWarningsCallback`, since a console warning is invisible to the
* site's visitors. How (and whether) to display it is the containing page's
* decision.
*/
export type ActivityViewerWarning = {
type: "mixedDoenetmlVersions";
/**
* The distinct DoenetML versions the assignment's documents request, in
* first-appearance order. Every embedded viewer downloads and parses
* the multi-MB standalone bundle for its document's version, so mixing
* versions multiplies that cost by the number of distinct versions.
* (Normalizing the `version` fields in the source avoids it; saved
* student state is keyed on a hash that ignores `version`, so it
* survives such a change.)
*/
versions: string[];
};

type DoenetMLFlagsSubset = Partial<DoenetMLFlags>;

const defaultFlags: DoenetMLFlags = {
Expand Down Expand Up @@ -56,6 +87,7 @@ export function ActivityViewer({
includeVariantSelector: _includeVariantSelector = false,
showTitle = true,
itemWord = "item",
reportWarningsCallback,
}: {
source: ActivitySource;
flags?: DoenetMLFlagsSubset;
Expand All @@ -81,13 +113,67 @@ export function ActivityViewer({
includeVariantSelector?: boolean;
showTitle?: boolean;
itemWord?: string;
/**
* Called with conditions in `source` worth surfacing to the user, e.g.
* an assignment mixing DoenetML versions. Invoked once per distinct set
* of warnings — not on every render, and not again when the consumer
* passes a fresh-but-equal `source` each render. The containing page
* decides how to display them; a `console.warn` is also emitted for
* developers.
*/
reportWarningsCallback?: (warnings: ActivityViewerWarning[]) => void;
}) {
const [initialVariantIndex, setInitialVariantIndex] = useState<
number | null
>(null);

const resolvedTheme = useResolvedTheme(darkMode);

const warnings = useMemo<ActivityViewerWarning[]>(() => {
let versions: string[];
try {
versions = collectDoenetmlVersions(source);
} catch {
// An unwalkable source produces its own error screen.
return [];
}
if (versions.length > 1) {
return [{ type: "mixedDoenetmlVersions", versions }];
}
return [];
}, [source]);

// Report each distinct set of warnings at most once — not once per
// render. `warnings` is a fresh array whenever the consumer passes a
// new-but-equal `source` object each render (the same pattern
// `propSetKey` below is built to tolerate), so dedupe on the serialized
// content rather than on the array identity. The callback is read through
// a ref so a change in its identity alone never triggers a re-report.
const reportWarningsCallbackRef = useRef(reportWarningsCallback);
useEffect(() => {
reportWarningsCallbackRef.current = reportWarningsCallback;
});
const lastReportedWarningsKey = useRef<string | null>(null);
useEffect(() => {
const warningsKey = JSON.stringify(warnings);
if (warningsKey === lastReportedWarningsKey.current) {
return;
}
lastReportedWarningsKey.current = warningsKey;
if (warnings.length === 0) {
return;
}
for (const warning of warnings) {
// `mixedDoenetmlVersions` is currently the only type.
console.warn(
`ActivityViewer: this assignment mixes DoenetML versions (${warning.versions.join(
", ",
)}). Each distinct version loads its own multi-MB standalone bundle; normalize the documents' \`version\` fields to avoid the multiplied download/parse cost (saved state survives, as its hash ignores \`version\`).`,
);
}
reportWarningsCallbackRef.current?.(warnings);
}, [warnings]);

// Serializing the source is how prop "sameness" is detected for
// consumers that pass a fresh `source` object each render; memoize it so
// the (potentially large) assignment is only serialized when the
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { ActivityViewer } from "./activity-viewer";
export type { ActivityViewerWarning } from "./activity-viewer";

export type { ActivitySource } from "./Activity/activityState";
export { isActivitySource } from "./Activity/activityState";
Expand Down
64 changes: 64 additions & 0 deletions src/test/collectDoenetmlVersions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { SequenceSource } from "../Activity/sequenceState";
import { SelectSource } from "../Activity/selectState";
import { SingleDocSource } from "../Activity/singleDocState";
import { collectDoenetmlVersions } from "../Activity/activityState";
import doc from "./testSources/doc.json";
import seq2sel from "./testSources/seq2sel.json";

function mkDoc(id: string, version: string): SingleDocSource {
return {
id,
type: "singleDoc",
isDescription: false,
doenetML: "<p>hi</p>",
version,
};
}

describe("collectDoenetmlVersions", () => {
it("single document", () => {
expect(collectDoenetmlVersions(doc as SingleDocSource)).eqls(["0.7.4"]);
});

it("uniform versions collapse to one entry", () => {
expect(
collectDoenetmlVersions(seq2sel as SequenceSource),
).to.have.length(1);
});

it("mixed versions are reported in first-appearance order", () => {
const source: SequenceSource = {
id: "seq",
type: "sequence",
title: "mixed",
shuffle: false,
items: [
mkDoc("a", "0.7.4"),
{
id: "sel",
type: "select",
title: "sel",
numToSelect: 1,
selectByVariant: false,
items: [mkDoc("b", "0.6.5"), mkDoc("c", "0.7.4")],
} as SelectSource,
mkDoc("d", "0.7"),
],
} as SequenceSource;

expect(collectDoenetmlVersions(source)).eqls(["0.7.4", "0.6.5", "0.7"]);
});

it("a container with no documents yields no versions", () => {
const source: SequenceSource = {
id: "seq",
type: "sequence",
title: "empty",
shuffle: false,
items: [],
} as SequenceSource;

expect(collectDoenetmlVersions(source)).eqls([]);
});
});
Loading