@doenet/doenetml-iframe 0.7.20-dev.326 → 0.7.20-dev.328

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,13 +102,15 @@ inner viewer.
102
102
 
103
103
  Pages that embed many viewers (assignment pages, textbook chapters) pay
104
104
  memory for every mounted iframe, whether or not the student can see it. The
105
- opt-in `mountPolicy` prop bounds this: at most `maxLiveViewers` viewers stay
106
- live page-wide, and off-screen viewers beyond the budget are **parked** —
107
- their state is flushed (via the `SPLICE.flushState` machinery) and their
108
- iframe is replaced by a placeholder of the same height, so the page layout
109
- doesn't shift. Scrolling a parked viewer back near the viewport restores it,
110
- seeded with the flushed state: typed work survives the round trip with no
111
- user interaction.
105
+ opt-in `mountPolicy` prop bounds this: windowed viewers **start as
106
+ placeholders and only create their iframe when they come near the viewport**
107
+ (an off-screen viewer never boots at all), simultaneous boots are capped
108
+ page-wide, and at most `maxLiveViewers` viewers stay live. Off-screen
109
+ viewers beyond the budget are **parked** their state is flushed (via the
110
+ `SPLICE.flushState` machinery) and their iframe is replaced by a placeholder
111
+ of the same height, so the page layout doesn't shift. Scrolling a parked
112
+ viewer back near the viewport restores it, seeded with the flushed state:
113
+ typed work survives the round trip with no user interaction.
112
114
 
113
115
  ```tsx
114
116
  <DoenetViewer
@@ -127,12 +129,18 @@ user interaction.
127
129
  before it may be parked (debounce against scroll flicker).
128
130
  - `flushTimeoutMs` (default 5000) — how long to wait for the pre-park state
129
131
  flush to be acknowledged before parking anyway.
132
+ - `maxConcurrentBoots` (default 2) — page-wide cap on how many windowed
133
+ viewers may be booting their iframe realm at once (each boot parses the
134
+ multi-MB standalone bundle and starts a core worker). Additional viewers
135
+ wait for a slot, visible-first — this removes the initialization stampede
136
+ when a page with many activities loads.
130
137
 
131
138
  **Parking requires a persistence path** so no student work can be lost:
132
139
  either `flags.allowSaveState` (the wrapper snapshots the flushed
133
140
  `reportScoreAndState` and seeds `initialState` on restore) or
134
141
  `flags.allowLocalState` (IndexedDB restores on reboot). Windowed viewers
135
- with neither flag always stay live (a console warning points this out).
142
+ with neither flag still mount lazily but, once booted, always stay live
143
+ (a console warning points this out).
136
144
 
137
145
  Notes:
138
146
 
package/index.d.ts CHANGED
@@ -124,16 +124,20 @@ export declare type DoenetViewerIframeProps = DoenetViewerProps & {
124
124
  useSharedCoreWorker?: boolean;
125
125
  /**
126
126
  * Opt-in windowed mounting (#1441, stream B): keep at most
127
- * `maxLiveViewers` viewers live on the page. Off-screen viewers beyond
128
- * the budget are *parked* their state is flushed (`SPLICE.flushState`)
129
- * and their iframe is replaced by a fixed-height placeholder and
130
- * restored when scrolled back near the viewport. Parking requires a
131
- * persistence path: it only activates for viewers with
132
- * `flags.allowSaveState` (the wrapper snapshots the flushed
133
- * `reportScoreAndState` and seeds `initialState` on restore) or
127
+ * `maxLiveViewers` viewers live on the page. Windowed viewers start as
128
+ * fixed-height placeholders and only create their iframe when they come
129
+ * near the viewport AND a boot slot is free (`maxConcurrentBoots` caps
130
+ * simultaneous realm boots page-wide an off-screen viewer never boots
131
+ * at all). Off-screen viewers beyond the budget are *parked* — their
132
+ * state is flushed (`SPLICE.flushState`) and their iframe is replaced
133
+ * by the placeholder again — and restored when scrolled back near the
134
+ * viewport. Parking requires a persistence path: it only activates for
135
+ * viewers with `flags.allowSaveState` (the wrapper snapshots the
136
+ * flushed `reportScoreAndState` and seeds `initialState` on restore) or
134
137
  * `flags.allowLocalState` (IndexedDB restores on reboot); otherwise the
135
- * viewer always stays live. The policy is read at mount; changing it
136
- * afterwards is not supported. Default off (no prop = today's behavior).
138
+ * viewer boots on visibility but then always stays live. The policy is
139
+ * read at mount; changing it afterwards is not supported. Default off
140
+ * (no prop = today's behavior).
137
141
  */
138
142
  mountPolicy?: MountPolicy;
139
143
  };
@@ -152,6 +156,8 @@ export declare function getViewerLifecycleStats(): {
152
156
  live: number;
153
157
  parking: number;
154
158
  parked: number;
159
+ booting: number;
160
+ bootQueue: number;
155
161
  };
156
162
 
157
163
  export { mathjaxConfig }
@@ -175,8 +181,13 @@ export { mediaLicenses }
175
181
  * a placeholder), so idle memory tracks what the user can see rather than
176
182
  * how many documents the page embeds.
177
183
  *
178
- * This module is pure policy: *when* to park. The mechanics of parking
179
- * (flushing state, swapping the iframe for a placeholder, restoring) live in
184
+ * It also gates *when* a lazily-mounted viewer may boot its iframe realm: a
185
+ * page-wide boot-slot semaphore (`maxConcurrentBoots`) caps how many viewers
186
+ * evaluate the multi-MB standalone bundle at once, serving visible viewers
187
+ * first (see `requestBootSlot`).
188
+ *
189
+ * This module is pure policy: *when* to park and *when* to boot. The
190
+ * mechanics (flushing state, creating/swapping the iframe, restoring) live in
180
191
  * the `DoenetViewer` wrapper, which registers callbacks here. Module-level
181
192
  * state is intentional — the budget is shared by every windowed viewer on
182
193
  * the page (same pattern as `shared-core-pool.ts`).
@@ -214,6 +225,14 @@ export declare type MountPolicy = {
214
225
  * parked. Default 2000.
215
226
  */
216
227
  parkDelayMs?: number;
228
+ /**
229
+ * Page-wide cap on how many windowed viewers may be *booting* their
230
+ * iframe realm at once (each boot parses the multi-MB standalone bundle
231
+ * and starts a core worker — the initialization stampede #1439
232
+ * targets). Additional viewers wait for a slot, visible-first. As with
233
+ * `maxLiveViewers`, the smallest value across viewers wins. Default 2.
234
+ */
235
+ maxConcurrentBoots?: number;
217
236
  };
218
237
 
219
238
  export declare const version: string;
package/index.js CHANGED
@@ -25858,6 +25858,9 @@ function createIframeBodyOpenTag(darkMode) {
25858
25858
  }
25859
25859
  function createHtmlForDoenetViewer(id2, doenetML, doenetViewerProps, standaloneUrl, cssUrl, useSharedCoreWorker = false) {
25860
25860
  const doenetViewerPropsSpecified = Object.keys(doenetViewerProps);
25861
+ if (!doenetViewerPropsSpecified.includes("initializedCallback")) {
25862
+ doenetViewerPropsSpecified.push("initializedCallback");
25863
+ }
25861
25864
  return `
25862
25865
  <html style="overflow:hidden">
25863
25866
  ${createIframeHead(standaloneUrl, cssUrl)}
@@ -26030,19 +26033,27 @@ const DEFAULT_MAX_LIVE_VIEWERS = 3;
26030
26033
  const DEFAULT_VISIBLE_MARGIN = "1000px";
26031
26034
  const DEFAULT_FLUSH_TIMEOUT_MS = 5e3;
26032
26035
  const DEFAULT_PARK_DELAY_MS = 2e3;
26036
+ const DEFAULT_MAX_CONCURRENT_BOOTS = 2;
26037
+ const BOOT_SLOT_WATCHDOG_MS = 9e4;
26033
26038
  const records = /* @__PURE__ */ new Map();
26034
26039
  let visibleOrderCounter = 0;
26035
26040
  let effectiveMaxLive = null;
26036
26041
  let warnedMixedBudgets = false;
26037
26042
  let evaluateTimerId = null;
26038
26043
  let evaluateTimerAt = Infinity;
26044
+ let effectiveMaxBoots = null;
26045
+ const bootSlotHolders = /* @__PURE__ */ new Set();
26046
+ let bootRequestCounter = 0;
26047
+ const bootQueue = [];
26039
26048
  function registerWindowedViewer({
26040
26049
  id: id2,
26041
26050
  maxLiveViewers,
26051
+ maxConcurrentBoots,
26042
26052
  parkDelayMs,
26043
26053
  canPark,
26044
26054
  requestPark
26045
26055
  }) {
26056
+ effectiveMaxBoots = effectiveMaxBoots === null ? maxConcurrentBoots : Math.min(effectiveMaxBoots, maxConcurrentBoots);
26046
26057
  if (effectiveMaxLive !== null && effectiveMaxLive !== maxLiveViewers && !warnedMixedBudgets) {
26047
26058
  warnedMixedBudgets = true;
26048
26059
  console.warn(
@@ -26063,9 +26074,47 @@ function registerWindowedViewer({
26063
26074
  scheduleEvaluate(parkDelayMs);
26064
26075
  return () => {
26065
26076
  records.delete(id2);
26077
+ cancelBootRequest(id2);
26078
+ releaseBootSlot(id2);
26066
26079
  scheduleEvaluate(0);
26067
26080
  };
26068
26081
  }
26082
+ function requestBootSlot(id2, grant) {
26083
+ if (bootSlotHolders.has(id2) || bootQueue.some((r2) => r2.id === id2)) {
26084
+ return;
26085
+ }
26086
+ bootQueue.push({ id: id2, requestOrder: ++bootRequestCounter, grant });
26087
+ pumpBootQueue();
26088
+ }
26089
+ function cancelBootRequest(id2) {
26090
+ const idx = bootQueue.findIndex((r2) => r2.id === id2);
26091
+ if (idx !== -1) {
26092
+ bootQueue.splice(idx, 1);
26093
+ }
26094
+ }
26095
+ function releaseBootSlot(id2) {
26096
+ if (bootSlotHolders.delete(id2)) {
26097
+ pumpBootQueue();
26098
+ }
26099
+ }
26100
+ function pumpBootQueue() {
26101
+ const max2 = effectiveMaxBoots ?? Infinity;
26102
+ while (bootQueue.length > 0 && bootSlotHolders.size < max2) {
26103
+ let best = 0;
26104
+ for (let i2 = 1; i2 < bootQueue.length; i2++) {
26105
+ const bestVisible = Boolean(
26106
+ records.get(bootQueue[best].id)?.visible
26107
+ );
26108
+ const thisVisible = Boolean(records.get(bootQueue[i2].id)?.visible);
26109
+ if (thisVisible && !bestVisible || thisVisible === bestVisible && bootQueue[i2].requestOrder < bootQueue[best].requestOrder) {
26110
+ best = i2;
26111
+ }
26112
+ }
26113
+ const [request] = bootQueue.splice(best, 1);
26114
+ bootSlotHolders.add(request.id);
26115
+ request.grant();
26116
+ }
26117
+ }
26069
26118
  function setViewerVisibility(id2, visible) {
26070
26119
  const record = records.get(id2);
26071
26120
  if (!record || record.visible === visible) {
@@ -26099,7 +26148,14 @@ function getViewerLifecycleStats() {
26099
26148
  parked++;
26100
26149
  }
26101
26150
  }
26102
- return { registered: records.size, live, parking, parked };
26151
+ return {
26152
+ registered: records.size,
26153
+ live,
26154
+ parking,
26155
+ parked,
26156
+ booting: bootSlotHolders.size,
26157
+ bootQueue: bootQueue.length
26158
+ };
26103
26159
  }
26104
26160
  function scheduleEvaluate(delayMs) {
26105
26161
  const at = Date.now() + delayMs;
@@ -63306,7 +63362,7 @@ function ExternalVirtualKeyboard({
63306
63362
  }
63307
63363
  );
63308
63364
  }
63309
- const version = "0.7.20-dev.326";
63365
+ const version = "0.7.20-dev.328";
63310
63366
  const latestDoenetmlVersion = version;
63311
63367
  function subscribeToPinnedTheme() {
63312
63368
  return () => {
@@ -63361,9 +63417,9 @@ function DoenetViewer({
63361
63417
  const doenetMLRef = React__default.useRef(doenetML);
63362
63418
  doenetMLRef.current = doenetML;
63363
63419
  const windowed = mountPolicy?.mode === "windowed";
63364
- const [parked, setParked] = React__default.useState(false);
63420
+ const [parked, setParked] = React__default.useState(windowed);
63365
63421
  const [parkGeneration, setParkGeneration] = React__default.useState(0);
63366
- const parkedRef = React__default.useRef(false);
63422
+ const parkedRef = React__default.useRef(windowed);
63367
63423
  const parkingRef = React__default.useRef(false);
63368
63424
  const visibleRef = React__default.useRef(false);
63369
63425
  const containerRef = React__default.useRef(null);
@@ -63379,6 +63435,40 @@ function DoenetViewer({
63379
63435
  if (windowed && pinnedVariantIndexRef.current === null) {
63380
63436
  pinnedVariantIndexRef.current = doenetViewerProps.requestedVariantIndex ?? Math.floor(Math.random() * 1e6) + 1;
63381
63437
  }
63438
+ const bootWatchdogRef = React__default.useRef(
63439
+ null
63440
+ );
63441
+ function clearBootWatchdog() {
63442
+ if (bootWatchdogRef.current !== null) {
63443
+ clearTimeout(bootWatchdogRef.current);
63444
+ bootWatchdogRef.current = null;
63445
+ }
63446
+ }
63447
+ function relinquishBootSlot() {
63448
+ clearBootWatchdog();
63449
+ releaseBootSlot(id2);
63450
+ }
63451
+ const composedInitializedCallbackRef = React__default.useRef(null);
63452
+ if (composedInitializedCallbackRef.current === null) {
63453
+ composedInitializedCallbackRef.current = (arg) => {
63454
+ relinquishBootSlot();
63455
+ doenetViewerPropsRef.current.initializedCallback?.(arg);
63456
+ };
63457
+ }
63458
+ function functionPropToProxy(key, prop) {
63459
+ if (windowed && key === "initializedCallback") {
63460
+ return composedInitializedCallbackRef.current;
63461
+ }
63462
+ return prop;
63463
+ }
63464
+ function appendComposedInitializedCallback(proxiedFunctions, hostFunctions) {
63465
+ if (windowed && !("initializedCallback" in hostFunctions)) {
63466
+ proxiedFunctions.push("initializedCallback");
63467
+ proxiedFunctions.push(
63468
+ proxy(composedInitializedCallbackRef.current)
63469
+ );
63470
+ }
63471
+ }
63382
63472
  const [height, setHeight] = React__default.useState("500px");
63383
63473
  const [inErrorState, setInErrorState] = React__default.useState(null);
63384
63474
  const [ignoreDetectedVersion, setIgnoreDetectedVersion] = React__default.useState(false);
@@ -63540,6 +63630,9 @@ function DoenetViewer({
63540
63630
  });
63541
63631
  }
63542
63632
  if (data.error) {
63633
+ if (windowed) {
63634
+ relinquishBootSlot();
63635
+ }
63543
63636
  return setInErrorState(data.error);
63544
63637
  } else if (data.iframeReady) {
63545
63638
  if (ref.current) {
@@ -63553,9 +63646,15 @@ function DoenetViewer({
63553
63646
  if (typeof prop === "function") {
63554
63647
  registeredFunctions[key] = prop;
63555
63648
  proxiedFunctions.push(key);
63556
- proxiedFunctions.push(proxy(prop));
63649
+ proxiedFunctions.push(
63650
+ proxy(functionPropToProxy(key, prop))
63651
+ );
63557
63652
  }
63558
63653
  }
63654
+ appendComposedInitializedCallback(
63655
+ proxiedFunctions,
63656
+ registeredFunctions
63657
+ );
63559
63658
  viewerIframe.renderViewerWithFunctionProps(...proxiedFunctions).catch(
63560
63659
  logComlinkError(
63561
63660
  "DoenetViewer",
@@ -63572,9 +63671,7 @@ function DoenetViewer({
63572
63671
  }
63573
63672
  }
63574
63673
  };
63575
- if (ref.current) {
63576
- window.addEventListener("message", listener);
63577
- }
63674
+ window.addEventListener("message", listener);
63578
63675
  return () => {
63579
63676
  window.removeEventListener("message", listener);
63580
63677
  };
@@ -63632,8 +63729,9 @@ function DoenetViewer({
63632
63729
  const proxiedFunctions = [];
63633
63730
  for (const [key, val] of Object.entries(current)) {
63634
63731
  proxiedFunctions.push(key);
63635
- proxiedFunctions.push(proxy(val));
63732
+ proxiedFunctions.push(proxy(functionPropToProxy(key, val)));
63636
63733
  }
63734
+ appendComposedInitializedCallback(proxiedFunctions, current);
63637
63735
  const action = (remote) => {
63638
63736
  remote.updateViewerFunctionProps(...proxiedFunctions).catch(
63639
63737
  logComlinkError(
@@ -63717,6 +63815,7 @@ function DoenetViewer({
63717
63815
  function finishPark() {
63718
63816
  viewerIframeRef.current = null;
63719
63817
  destroySharedCoresForViewer(id2);
63818
+ relinquishBootSlot();
63720
63819
  parkedRef.current = true;
63721
63820
  setParked(true);
63722
63821
  notifyViewerState(id2, "parked");
@@ -63725,10 +63824,21 @@ function DoenetViewer({
63725
63824
  if (!parkedRef.current) {
63726
63825
  return;
63727
63826
  }
63728
- parkedRef.current = false;
63729
- setParked(false);
63730
- setParkGeneration((generation) => generation + 1);
63731
- notifyViewerState(id2, "live");
63827
+ requestBootSlot(id2, () => {
63828
+ if (!parkedRef.current || !visibleRef.current) {
63829
+ releaseBootSlot(id2);
63830
+ return;
63831
+ }
63832
+ parkedRef.current = false;
63833
+ setParked(false);
63834
+ setParkGeneration((generation) => generation + 1);
63835
+ notifyViewerState(id2, "live");
63836
+ clearBootWatchdog();
63837
+ bootWatchdogRef.current = setTimeout(() => {
63838
+ bootWatchdogRef.current = null;
63839
+ releaseBootSlot(id2);
63840
+ }, BOOT_SLOT_WATCHDOG_MS);
63841
+ });
63732
63842
  }
63733
63843
  React__default.useEffect(() => {
63734
63844
  if (!windowed || !containerRef.current) {
@@ -63741,6 +63851,8 @@ function DoenetViewer({
63741
63851
  setViewerVisibility(id2, entry.isIntersecting);
63742
63852
  if (entry.isIntersecting && parkedRef.current) {
63743
63853
  unpark();
63854
+ } else if (!entry.isIntersecting) {
63855
+ cancelBootRequest(id2);
63744
63856
  }
63745
63857
  }
63746
63858
  },
@@ -63765,12 +63877,17 @@ function DoenetViewer({
63765
63877
  const unregister = registerWindowedViewer({
63766
63878
  id: id2,
63767
63879
  maxLiveViewers: mountPolicy?.maxLiveViewers ?? DEFAULT_MAX_LIVE_VIEWERS,
63880
+ maxConcurrentBoots: mountPolicy?.maxConcurrentBoots ?? DEFAULT_MAX_CONCURRENT_BOOTS,
63768
63881
  parkDelayMs: mountPolicy?.parkDelayMs ?? DEFAULT_PARK_DELAY_MS,
63769
63882
  canPark,
63770
63883
  requestPark: beginPark
63771
63884
  });
63885
+ if (parkedRef.current) {
63886
+ notifyViewerState(id2, "parked");
63887
+ }
63772
63888
  return () => {
63773
63889
  parkFlushCleanupRef.current?.();
63890
+ clearBootWatchdog();
63774
63891
  unregister();
63775
63892
  };
63776
63893
  }, [windowed]);