@lessonkit/react 1.3.0 → 1.3.1

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/dist/index.js CHANGED
@@ -28,6 +28,44 @@ function createXapiQueueFromObservability(observability) {
28
28
  }
29
29
  return createInMemoryXAPIQueue(opts);
30
30
  }
31
+ function wrapBatchSink(batchSink, observability) {
32
+ if (!batchSink || !observability?.onTelemetrySinkError) return batchSink;
33
+ const onError = observability.onTelemetrySinkError;
34
+ return async (events) => {
35
+ try {
36
+ await batchSink(events);
37
+ } catch (err) {
38
+ onError(err, { sinkId: "tracking-batch" });
39
+ throw err;
40
+ }
41
+ };
42
+ }
43
+ function warnMissingProductionObservability(observability, opts) {
44
+ let isProduction = false;
45
+ try {
46
+ isProduction = import.meta.env?.PROD === true;
47
+ } catch {
48
+ }
49
+ if (!isProduction) {
50
+ const g = globalThis;
51
+ isProduction = typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
52
+ }
53
+ if (!isProduction) return;
54
+ if (!opts.trackingEnabled && !opts.xapiEnabled) return;
55
+ const hooks = [
56
+ observability?.onTelemetrySinkError,
57
+ observability?.onTelemetryBufferDrop,
58
+ observability?.onXapiQueueDepth,
59
+ observability?.onXapiQueueCap,
60
+ observability?.onLxpackBridgeMiss
61
+ ];
62
+ if (hooks.some(Boolean)) return;
63
+ if (typeof console !== "undefined") {
64
+ console.warn(
65
+ "[lessonkit] Production deployment without observability hooks \u2014 telemetry/xAPI failures and buffer drops will be silent. See https://lessonkit.readthedocs.io/en/latest/guides/react-developers/production-checklist.html"
66
+ );
67
+ }
68
+ }
31
69
  function wrapTrackingSink(sink, observability) {
32
70
  if (!sink || !observability?.onTelemetrySinkError) return sink;
33
71
  const onError = observability.onTelemetrySinkError;
@@ -169,6 +207,7 @@ function createXapiClientFromConfig(config, queue) {
169
207
  return createXAPIClient({
170
208
  courseId: config.courseId,
171
209
  transport: config.xapi?.transport,
210
+ exitTransport: config.xapi?.exitTransport,
172
211
  queue
173
212
  });
174
213
  }
@@ -228,6 +267,7 @@ async function emitCourseStartedNonTrackingPipeline(opts) {
228
267
  const statement = telemetryEventToXAPIStatement2(opts.event);
229
268
  if (statement) {
230
269
  opts.xapi.send(statement);
270
+ await opts.xapi.flush();
231
271
  xapiStatementSent = true;
232
272
  }
233
273
  }
@@ -263,7 +303,7 @@ function emitTelemetryWithPlugins(opts) {
263
303
  }
264
304
 
265
305
  // src/provider/courseStarted/emit.ts
266
- var courseStartedTrackingFlightKey = null;
306
+ var courseStartedTrackingFlights = /* @__PURE__ */ new Map();
267
307
  function isTrackingActive(tracking) {
268
308
  return tracking?.enabled !== false;
269
309
  }
@@ -291,25 +331,40 @@ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseI
291
331
  if (hasCourseStartedEmittedToTracking(storage, sessionId, courseId)) {
292
332
  return true;
293
333
  }
294
- if (courseStartedTrackingFlightKey === flightKey) {
295
- return false;
334
+ const existing = courseStartedTrackingFlights.get(flightKey);
335
+ if (existing) {
336
+ const settled = await existing;
337
+ if (settled) return true;
296
338
  }
297
- courseStartedTrackingFlightKey = flightKey;
298
- try {
299
- if (shouldCommit && !shouldCommit()) return false;
300
- tracking.track(event);
301
- markCourseStartedEmittedToTracking(storage, sessionId, courseId);
302
- const delivered = await tracking.flush?.();
303
- if (delivered === false) return false;
304
- if (shouldCommit && !shouldCommit()) return false;
305
- return true;
306
- } catch {
307
- return false;
308
- } finally {
309
- if (courseStartedTrackingFlightKey === flightKey) {
310
- courseStartedTrackingFlightKey = null;
339
+ let resolveFlight;
340
+ const flight = new Promise((resolve) => {
341
+ resolveFlight = resolve;
342
+ });
343
+ courseStartedTrackingFlights.set(flightKey, flight);
344
+ void (async () => {
345
+ try {
346
+ if (shouldCommit && !shouldCommit()) {
347
+ resolveFlight(false);
348
+ return;
349
+ }
350
+ tracking.track(event);
351
+ const delivered = await tracking.flush?.();
352
+ if (delivered === false) {
353
+ resolveFlight(false);
354
+ return;
355
+ }
356
+ if (markCourseStartedEmittedToTracking(storage, sessionId, courseId) === false) {
357
+ resolveFlight(false);
358
+ return;
359
+ }
360
+ resolveFlight(true);
361
+ } catch {
362
+ resolveFlight(false);
363
+ } finally {
364
+ courseStartedTrackingFlights.delete(flightKey);
311
365
  }
312
- }
366
+ })();
367
+ return flight;
313
368
  }
314
369
  async function emitCourseStartedPipelineOnly(opts) {
315
370
  try {
@@ -323,8 +378,10 @@ async function emitCourseStartedPipelineOnly(opts) {
323
378
  skipXapi: opts.skipXapi
324
379
  });
325
380
  if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
326
- markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
327
- markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId);
381
+ if (markCourseStarted(opts.storage, opts.sessionId, opts.courseId) === false) return "failed";
382
+ if (markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId) === false) {
383
+ return "failed";
384
+ }
328
385
  if (xapiStatementSent) {
329
386
  opts.onXapiStatementSent?.();
330
387
  }
@@ -375,7 +432,9 @@ async function emitCourseStartedToTrackingOnly(opts) {
375
432
  extraSinks: opts.extraSinks,
376
433
  skipXapi: true
377
434
  });
378
- markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId);
435
+ if (markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId) === false) {
436
+ return "failed";
437
+ }
379
438
  return "emitted";
380
439
  } catch {
381
440
  return "failed";
@@ -435,6 +494,7 @@ function createTrackingClientFromConfig(config, observability) {
435
494
  sink: config.tracking?.sink,
436
495
  batchSink: config.tracking?.batchSink,
437
496
  batch: config.tracking?.batch,
497
+ exitBatchSink: config.tracking?.exitBatchSink,
438
498
  onBufferDrop: observability?.onTelemetryBufferDrop
439
499
  });
440
500
  }
@@ -510,6 +570,7 @@ function useLessonkitProviderRuntime(config) {
510
570
  const pendingCourseIdResetRef = useRef(false);
511
571
  const prevUseV2RuntimeRef = useRef(useV2Runtime);
512
572
  const xapiCourseStartedSentOnClientRef = useRef(false);
573
+ const xapiBootstrapSendRef = useRef(false);
513
574
  if (prevUseV2RuntimeRef.current !== useV2Runtime) {
514
575
  prevUseV2RuntimeRef.current = useV2Runtime;
515
576
  if (useV2Runtime) {
@@ -582,19 +643,22 @@ function useLessonkitProviderRuntime(config) {
582
643
  xapiQueueRef.current = createXapiQueueFromObservability(observabilityRef.current);
583
644
  prevXapiCourseIdRef.current = courseId;
584
645
  xapiCourseStartedSentOnClientRef.current = false;
646
+ xapiBootstrapSendRef.current = false;
585
647
  }
586
648
  const prev = xapiRef.current;
587
649
  const next = createXapiClientFromConfig(normalizedConfig, xapiQueueRef.current);
588
650
  xapiRef.current = next;
589
651
  setXapi(next);
652
+ let bootstrapSent = false;
653
+ let bootstrapAlreadyStarted = false;
590
654
  if (next) {
591
655
  const sessionId = sessionIdRef.current;
592
656
  const cid = courseIdRef.current;
593
657
  const trackingActive = isTrackingActive(normalizedConfig.tracking);
594
- const alreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
658
+ bootstrapAlreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
595
659
  const clientChanged = !prev || prev !== next;
596
- const skipBootstrap = trackingActive && !alreadyStarted;
597
- const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && (!alreadyStarted || clientChanged);
660
+ const skipBootstrap = trackingActive && !bootstrapAlreadyStarted;
661
+ const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && !xapiBootstrapSendRef.current && (!bootstrapAlreadyStarted || clientChanged);
598
662
  if (needsBootstrap) {
599
663
  try {
600
664
  const event = buildCourseStartedEvent({
@@ -605,15 +669,12 @@ function useLessonkitProviderRuntime(config) {
605
669
  user: userRef.current,
606
670
  lxpackBridge: lxpackBridgeModeRef.current
607
671
  });
608
- if (event === null) {
609
- } else {
672
+ if (event !== null) {
610
673
  const statement = telemetryEventToXAPIStatement3(event);
611
674
  if (statement) {
612
675
  next.send(statement);
613
- if (!alreadyStarted) {
614
- markCourseStarted(defaultStorage, sessionId, cid);
615
- }
616
- xapiCourseStartedSentOnClientRef.current = true;
676
+ xapiBootstrapSendRef.current = true;
677
+ bootstrapSent = true;
617
678
  }
618
679
  }
619
680
  } catch {
@@ -631,6 +692,12 @@ function useLessonkitProviderRuntime(config) {
631
692
  if (cancelled) return;
632
693
  try {
633
694
  await next?.flush();
695
+ if (bootstrapSent && !cancelled) {
696
+ if (!bootstrapAlreadyStarted) {
697
+ markCourseStarted(defaultStorage, sessionIdRef.current, courseIdRef.current);
698
+ }
699
+ xapiCourseStartedSentOnClientRef.current = true;
700
+ }
634
701
  } catch {
635
702
  }
636
703
  })();
@@ -659,7 +726,10 @@ function useLessonkitProviderRuntime(config) {
659
726
  useIsoLayoutEffect(() => {
660
727
  const prev = trackingRef.current;
661
728
  const baseSink = wrapTrackingSink(normalizedConfig.tracking?.sink, observabilityRef.current);
662
- const userBatchSink = normalizedConfig.tracking?.batchSink;
729
+ const userBatchSink = wrapBatchSink(
730
+ normalizedConfig.tracking?.batchSink,
731
+ observabilityRef.current
732
+ );
663
733
  assertTrackingSinkConfig(normalizedConfig.tracking);
664
734
  const sink = pluginHostRef.current && baseSink ? (
665
735
  /* v8 ignore next -- composeTrackingSink may return null; fall back to base sink */
@@ -813,7 +883,11 @@ function useLessonkitProviderRuntime(config) {
813
883
  user: userRef.current,
814
884
  lxpackBridge: lxpackBridgeModeRef.current,
815
885
  onLxpackBridgeMiss,
816
- extraSinks: extraSinksRef.current
886
+ extraSinks: extraSinksRef.current,
887
+ skipXapi: xapiCourseStartedSentOnClientRef.current,
888
+ onXapiStatementSent: () => {
889
+ xapiCourseStartedSentOnClientRef.current = true;
890
+ }
817
891
  });
818
892
  courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
819
893
  }
@@ -869,20 +943,39 @@ function useLessonkitProviderRuntime(config) {
869
943
  }, []);
870
944
  useEffect(() => {
871
945
  if (typeof document === "undefined") return;
872
- const flushOnExit = () => {
873
- void xapiRef.current?.flush();
874
- void trackingRef.current?.flush?.();
946
+ const flushOnPageExit = () => {
947
+ try {
948
+ xapiRef.current?.flushOnExit?.();
949
+ trackingRef.current?.flushOnExit?.();
950
+ } finally {
951
+ void xapiRef.current?.flush();
952
+ void trackingRef.current?.flush?.();
953
+ }
875
954
  };
876
955
  const onVisibilityChange = () => {
877
- if (document.visibilityState === "hidden") flushOnExit();
956
+ if (document.visibilityState === "hidden") flushOnPageExit();
878
957
  };
879
958
  document.addEventListener("visibilitychange", onVisibilityChange);
880
- window.addEventListener("pagehide", flushOnExit);
959
+ window.addEventListener("pagehide", flushOnPageExit);
881
960
  return () => {
882
961
  document.removeEventListener("visibilitychange", onVisibilityChange);
883
- window.removeEventListener("pagehide", flushOnExit);
962
+ window.removeEventListener("pagehide", flushOnPageExit);
884
963
  };
885
964
  }, []);
965
+ useEffect(() => {
966
+ warnMissingProductionObservability(observabilityRef.current, {
967
+ trackingEnabled: isTrackingActive(normalizedConfig.tracking),
968
+ xapiEnabled: normalizedConfig.xapi?.enabled !== false && Boolean(
969
+ normalizedConfig.xapi?.client || normalizedConfig.xapi?.transport || normalizedConfig.xapi?.enabled === true
970
+ )
971
+ });
972
+ }, [
973
+ normalizedConfig.tracking,
974
+ normalizedConfig.xapi?.enabled,
975
+ normalizedConfig.xapi?.client,
976
+ normalizedConfig.xapi?.transport,
977
+ normalizedConfig.observability
978
+ ]);
886
979
  const setActiveLesson = useCallback(
887
980
  (lessonId) => {
888
981
  if (useV2Runtime && headlessRef.current) {
@@ -2155,9 +2248,13 @@ function FillInTheBlanksInner(props, ref) {
2155
2248
  const [submitted, setSubmitted] = useState7(false);
2156
2249
  const completedRef = useRef8(false);
2157
2250
  const answeredRef = useRef8(false);
2251
+ const checkSnapshotRef = useRef8(null);
2252
+ const telemetryReplayedRef = useRef8(false);
2158
2253
  const reset = () => {
2159
2254
  completedRef.current = false;
2160
2255
  answeredRef.current = false;
2256
+ checkSnapshotRef.current = null;
2257
+ telemetryReplayedRef.current = false;
2161
2258
  setPassed(false);
2162
2259
  setValues(Object.fromEntries(blanks.map((b) => [b.id, ""])));
2163
2260
  setShowSolutions(false);
@@ -2174,6 +2271,31 @@ function FillInTheBlanksInner(props, ref) {
2174
2271
  });
2175
2272
  const maxScore = blanks.length;
2176
2273
  const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
2274
+ const replayTelemetry = (nextValues, nextPassed, nextSubmitted, nextScore, nextMaxScore) => {
2275
+ if (telemetryReplayedRef.current || !nextSubmitted && !nextPassed) return;
2276
+ telemetryReplayedRef.current = true;
2277
+ const nextPassedThreshold = meetsPassingThreshold(
2278
+ nextScore,
2279
+ nextMaxScore || 1,
2280
+ props.passingScore
2281
+ );
2282
+ assessment.answer({
2283
+ checkId,
2284
+ interactionType: INTERACTION3,
2285
+ question: props.template,
2286
+ response: nextValues,
2287
+ correct: nextPassedThreshold
2288
+ });
2289
+ if (nextPassed || nextPassedThreshold) {
2290
+ assessment.complete({
2291
+ checkId,
2292
+ interactionType: INTERACTION3,
2293
+ score: nextScore,
2294
+ maxScore: nextMaxScore,
2295
+ passingScore: props.passingScore ?? nextMaxScore
2296
+ });
2297
+ }
2298
+ };
2177
2299
  const handle = useMemo9(
2178
2300
  () => buildAssessmentHandle({
2179
2301
  checkId,
@@ -2193,20 +2315,33 @@ function FillInTheBlanksInner(props, ref) {
2193
2315
  getCurrentState: () => ({ values, passed, showSolutions, submitted }),
2194
2316
  resume: (state) => {
2195
2317
  const raw = state.values;
2196
- if (raw && typeof raw === "object") setValues({ ...raw });
2318
+ let nextValues = values;
2319
+ if (raw && typeof raw === "object") {
2320
+ nextValues = { ...raw };
2321
+ setValues(nextValues);
2322
+ }
2323
+ let nextPassed = passed;
2324
+ let nextSubmitted = submitted;
2197
2325
  readBooleanStateField(state, "passed", (value) => {
2326
+ nextPassed = value;
2198
2327
  setPassed(value);
2199
2328
  completedRef.current = value;
2200
2329
  answeredRef.current = value;
2201
2330
  });
2202
2331
  readBooleanStateField(state, "showSolutions", setShowSolutions);
2203
2332
  readBooleanStateField(state, "submitted", (value) => {
2333
+ nextSubmitted = value;
2204
2334
  setSubmitted(value);
2205
2335
  if (value) answeredRef.current = true;
2206
2336
  });
2337
+ let nextScore = 0;
2338
+ blanks.forEach((b) => {
2339
+ if ((nextValues[b.id] ?? "").trim().toLowerCase() === b.answer.toLowerCase()) nextScore += 1;
2340
+ });
2341
+ replayTelemetry(nextValues, nextPassed, nextSubmitted, nextScore, blanks.length);
2207
2342
  }
2208
2343
  }),
2209
- [allFilled, checkId, maxScore, passed, passedThreshold, score, showSolutions, submitted, values]
2344
+ [allFilled, assessment, blanks, checkId, maxScore, passed, passedThreshold, props.passingScore, props.template, score, showSolutions, submitted, values]
2210
2345
  );
2211
2346
  useAssessmentHandleRegistration(checkId, handle, ref);
2212
2347
  const check = () => {
@@ -2217,7 +2352,10 @@ function FillInTheBlanksInner(props, ref) {
2217
2352
  return;
2218
2353
  }
2219
2354
  if (!allFilled) return;
2220
- if (answeredRef.current || submitted) return;
2355
+ if (passed) return;
2356
+ const snapshot = JSON.stringify(values);
2357
+ if (checkSnapshotRef.current === snapshot) return;
2358
+ checkSnapshotRef.current = snapshot;
2221
2359
  answeredRef.current = true;
2222
2360
  setSubmitted(true);
2223
2361
  assessment.answer({
@@ -2242,12 +2380,13 @@ function FillInTheBlanksInner(props, ref) {
2242
2380
  useEffect7(() => {
2243
2381
  if (!allFilled) {
2244
2382
  answeredRef.current = false;
2383
+ checkSnapshotRef.current = null;
2245
2384
  setSubmitted(false);
2246
2385
  }
2247
2386
  }, [allFilled]);
2248
2387
  useEffect7(() => {
2249
- if (props.autoCheck && allFilled) check();
2250
- }, [allFilled, props.autoCheck, values, passedThreshold]);
2388
+ if (props.autoCheck && allFilled && !passed) check();
2389
+ }, [allFilled, props.autoCheck, values, passedThreshold, passed]);
2251
2390
  const reveal = showSolutions || passed && props.enableSolutionsButton;
2252
2391
  return /* @__PURE__ */ jsxs6("section", { "aria-label": "Fill in the Blanks", "data-lk-check-id": checkId, children: [
2253
2392
  /* @__PURE__ */ jsx10("p", { children: parsed.parts.map((part, i) => {
@@ -2306,9 +2445,13 @@ function DragTheWordsInner(props, ref) {
2306
2445
  const [submitted, setSubmitted] = useState8(false);
2307
2446
  const completedRef = useRef9(false);
2308
2447
  const answeredRef = useRef9(false);
2448
+ const checkSnapshotRef = useRef9(null);
2449
+ const telemetryReplayedRef = useRef9(false);
2309
2450
  const reset = () => {
2310
2451
  completedRef.current = false;
2311
2452
  answeredRef.current = false;
2453
+ checkSnapshotRef.current = null;
2454
+ telemetryReplayedRef.current = false;
2312
2455
  setPassed(false);
2313
2456
  setSubmitted(false);
2314
2457
  setZones(Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""])));
@@ -2326,6 +2469,31 @@ function DragTheWordsInner(props, ref) {
2326
2469
  });
2327
2470
  const maxScore = answers.length;
2328
2471
  const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
2472
+ const replayTelemetry = (nextZones, nextPassed, nextSubmitted, nextScore, nextMaxScore) => {
2473
+ if (telemetryReplayedRef.current || !nextSubmitted && !nextPassed) return;
2474
+ telemetryReplayedRef.current = true;
2475
+ const nextPassedThreshold = meetsPassingThreshold(
2476
+ nextScore,
2477
+ nextMaxScore || 1,
2478
+ props.passingScore
2479
+ );
2480
+ assessment.answer({
2481
+ checkId,
2482
+ interactionType: INTERACTION4,
2483
+ question: props.template,
2484
+ response: nextZones,
2485
+ correct: nextPassedThreshold
2486
+ });
2487
+ if (nextPassed || nextPassedThreshold) {
2488
+ assessment.complete({
2489
+ checkId,
2490
+ interactionType: INTERACTION4,
2491
+ score: nextScore,
2492
+ maxScore: nextMaxScore,
2493
+ passingScore: props.passingScore ?? nextMaxScore
2494
+ });
2495
+ }
2496
+ };
2329
2497
  const handle = useMemo10(
2330
2498
  () => buildAssessmentHandle({
2331
2499
  checkId,
@@ -2346,22 +2514,35 @@ function DragTheWordsInner(props, ref) {
2346
2514
  getCurrentState: () => ({ zones, pool, passed, keyboardWord, submitted }),
2347
2515
  resume: (state) => {
2348
2516
  const rawZones = state.zones;
2349
- if (rawZones && typeof rawZones === "object") setZones({ ...rawZones });
2517
+ let nextZones = zones;
2518
+ if (rawZones && typeof rawZones === "object") {
2519
+ nextZones = { ...rawZones };
2520
+ setZones(nextZones);
2521
+ }
2350
2522
  if (Array.isArray(state.pool)) setPool([...state.pool]);
2523
+ let nextPassed = passed;
2524
+ let nextSubmitted = submitted;
2351
2525
  readBooleanStateField(state, "passed", (value) => {
2526
+ nextPassed = value;
2352
2527
  setPassed(value);
2353
2528
  completedRef.current = value;
2354
2529
  answeredRef.current = value;
2355
2530
  });
2356
2531
  readBooleanStateField(state, "submitted", (value) => {
2532
+ nextSubmitted = value;
2357
2533
  setSubmitted(value);
2358
2534
  if (value) answeredRef.current = true;
2359
2535
  });
2360
2536
  const kw = state.keyboardWord;
2361
2537
  if (kw === null || typeof kw === "string") setKeyboardWord(kw ?? null);
2538
+ let nextScore = 0;
2539
+ answers.forEach((ans, i) => {
2540
+ if ((nextZones[`zone-${i}`] ?? "").trim().toLowerCase() === ans.toLowerCase()) nextScore += 1;
2541
+ });
2542
+ replayTelemetry(nextZones, nextPassed, nextSubmitted, nextScore, answers.length);
2362
2543
  }
2363
2544
  }),
2364
- [allFilled, checkId, keyboardWord, maxScore, passed, passedThreshold, pool, score, submitted, zones]
2545
+ [allFilled, answers, assessment, checkId, keyboardWord, maxScore, passed, passedThreshold, pool, props.passingScore, props.template, score, submitted, zones]
2365
2546
  );
2366
2547
  useAssessmentHandleRegistration(checkId, handle, ref);
2367
2548
  const placeInZone = (zoneId, word) => {
@@ -2391,7 +2572,10 @@ function DragTheWordsInner(props, ref) {
2391
2572
  return;
2392
2573
  }
2393
2574
  if (!allFilled) return;
2394
- if (answeredRef.current || submitted) return;
2575
+ if (passed) return;
2576
+ const snapshot = JSON.stringify(zones);
2577
+ if (checkSnapshotRef.current === snapshot) return;
2578
+ checkSnapshotRef.current = snapshot;
2395
2579
  answeredRef.current = true;
2396
2580
  setSubmitted(true);
2397
2581
  assessment.answer({
@@ -2416,12 +2600,13 @@ function DragTheWordsInner(props, ref) {
2416
2600
  useEffect8(() => {
2417
2601
  if (!allFilled) {
2418
2602
  answeredRef.current = false;
2603
+ checkSnapshotRef.current = null;
2419
2604
  setSubmitted(false);
2420
2605
  }
2421
2606
  }, [allFilled]);
2422
2607
  useEffect8(() => {
2423
- if (props.autoCheck && allFilled) check();
2424
- }, [allFilled, props.autoCheck, zones, passedThreshold]);
2608
+ if (props.autoCheck && allFilled && !passed) check();
2609
+ }, [allFilled, props.autoCheck, zones, passedThreshold, passed]);
2425
2610
  return /* @__PURE__ */ jsxs7("section", { "aria-label": "Drag the Words", "data-lk-check-id": checkId, children: [
2426
2611
  /* @__PURE__ */ jsx11("p", { children: "Drag words into the blanks (or select a word, then activate a blank)." }),
2427
2612
  /* @__PURE__ */ jsx11("div", { role: "list", "aria-label": "Word bank", "data-testid": "word-bank", children: pool.map((word) => /* @__PURE__ */ jsx11(
@@ -2826,6 +3011,8 @@ function useCompoundPersistence(opts) {
2826
3011
  }, [ctx, opts.index, opts.pageCount]);
2827
3012
  const buildStateRef = useRef12(buildState);
2828
3013
  buildStateRef.current = buildState;
3014
+ const persistNowRef = useRef12(() => {
3015
+ });
2829
3016
  const finalizeHydration = useCallback6(
2830
3017
  (childStates) => {
2831
3018
  loadedChildStatesRef.current = {
@@ -2834,6 +3021,7 @@ function useCompoundPersistence(opts) {
2834
3021
  };
2835
3022
  skipSaveUntilHydratedRef.current = false;
2836
3023
  pendingChildResumeRef.current = null;
3024
+ queueMicrotask(() => persistNowRef.current());
2837
3025
  },
2838
3026
  []
2839
3027
  );
@@ -2846,6 +3034,14 @@ function useCompoundPersistence(opts) {
2846
3034
  alreadyResumed: resumedChildKeysRef.current
2847
3035
  });
2848
3036
  if (!applied) {
3037
+ if (handles.size === 0) {
3038
+ const registeredOnly2 = stripOrphanChildStates(handles, pending.childStates);
3039
+ resumeChildHandles(handles, registeredOnly2, {
3040
+ alreadyResumed: resumedChildKeysRef.current
3041
+ });
3042
+ finalizeHydration(registeredOnly2);
3043
+ return;
3044
+ }
2849
3045
  const handlesAtWait = handles.size;
2850
3046
  queueMicrotask(() => {
2851
3047
  if (pendingChildResumeRef.current !== pending) return;
@@ -2879,8 +3075,12 @@ function useCompoundPersistence(opts) {
2879
3075
  });
2880
3076
  const persistNow = useCallback6(() => {
2881
3077
  if (!opts.enabled || !opts.courseId) return;
3078
+ if (skipSaveUntilHydratedRef.current) return;
2882
3079
  saveResume(buildStateRef.current());
2883
3080
  }, [opts.enabled, opts.courseId, saveResume]);
3081
+ useEffect11(() => {
3082
+ persistNowRef.current = persistNow;
3083
+ }, [persistNow]);
2884
3084
  const notifyImperativeResume = useCallback6(
2885
3085
  (state) => {
2886
3086
  const clamped = clampCompoundPageIndex2(state.activePageIndex, opts.pageCount);
@@ -2902,12 +3102,12 @@ function useCompoundPersistence(opts) {
2902
3102
  }
2903
3103
  };
2904
3104
  }, [bridgeRef, notifyImperativeResume]);
2905
- useEffect11(() => {
2906
- persistNow();
2907
- }, [persistNow, opts.index, opts.pageCount, handlesVersion]);
2908
3105
  useEffect11(() => {
2909
3106
  applyPendingChildResume();
2910
3107
  }, [opts.index, handlesVersion, applyPendingChildResume]);
3108
+ useEffect11(() => {
3109
+ persistNow();
3110
+ }, [persistNow, opts.index, opts.pageCount, handlesVersion]);
2911
3111
  useEffect11(() => {
2912
3112
  if (!opts.enabled || !opts.courseId || typeof document === "undefined") return;
2913
3113
  const flushOnExit = () => {
@@ -3847,20 +4047,41 @@ function ImageSlider(props) {
3847
4047
  setLessonkitBlockType(ImageSlider, "ImageSlider");
3848
4048
 
3849
4049
  // src/blocks/FindHotspot.tsx
3850
- import { forwardRef as forwardRef10, useEffect as useEffect18, useMemo as useMemo16, useState as useState18 } from "react";
4050
+ import { forwardRef as forwardRef10, useEffect as useEffect18, useMemo as useMemo16, useRef as useRef15, useState as useState18 } from "react";
3851
4051
  import { jsx as jsx26, jsxs as jsxs19 } from "react/jsx-runtime";
3852
4052
  var INTERACTION6 = "findHotspot";
3853
4053
  function FindHotspotInner(props, ref) {
3854
4054
  const checkId = useMemo16(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
3855
4055
  const [selected, setSelected] = useState18(null);
3856
4056
  const [checked, setChecked] = useState18(false);
4057
+ const telemetryReplayedRef = useRef15(false);
3857
4058
  const assessment = useAssessmentState(props.enclosingLessonId);
3858
4059
  const targetIdsKey = props.targets.map((t) => t.id).join("\0");
3859
4060
  useEffect18(() => {
3860
4061
  setSelected(null);
3861
4062
  setChecked(false);
4063
+ telemetryReplayedRef.current = false;
3862
4064
  }, [checkId, props.correctTargetId, targetIdsKey]);
3863
4065
  const correct = selected === props.correctTargetId;
4066
+ const replayTelemetry = (nextSelected, nextChecked, nextCorrect) => {
4067
+ if (telemetryReplayedRef.current || !nextChecked || nextSelected === null) return;
4068
+ telemetryReplayedRef.current = true;
4069
+ assessment.answer({
4070
+ checkId,
4071
+ interactionType: INTERACTION6,
4072
+ response: nextSelected,
4073
+ correct: nextCorrect
4074
+ });
4075
+ if (nextCorrect) {
4076
+ assessment.complete({
4077
+ checkId,
4078
+ interactionType: INTERACTION6,
4079
+ score: 1,
4080
+ maxScore: 1,
4081
+ passingScore: props.passingScore ?? 1
4082
+ });
4083
+ }
4084
+ };
3864
4085
  const handle = useMemo16(
3865
4086
  () => buildAssessmentHandle({
3866
4087
  checkId,
@@ -3870,6 +4091,7 @@ function FindHotspotInner(props, ref) {
3870
4091
  resetTask: () => {
3871
4092
  setSelected(null);
3872
4093
  setChecked(false);
4094
+ telemetryReplayedRef.current = false;
3873
4095
  },
3874
4096
  showSolutions: () => setSelected(props.correctTargetId),
3875
4097
  getXAPIData: () => ({
@@ -3882,15 +4104,23 @@ function FindHotspotInner(props, ref) {
3882
4104
  }),
3883
4105
  getCurrentState: () => ({ selected, checked }),
3884
4106
  resume: (state) => {
3885
- const nextSelected = readStringField(state, "selected");
3886
- if (typeof nextSelected === "string" || nextSelected === null) {
3887
- const valid = nextSelected === null || props.targets.some((t) => t.id === nextSelected);
3888
- setSelected(valid ? nextSelected : null);
4107
+ let nextSelected = selected;
4108
+ const rawSelected = readStringField(state, "selected");
4109
+ if (typeof rawSelected === "string" || rawSelected === null) {
4110
+ const valid = rawSelected === null || props.targets.some((t) => t.id === rawSelected);
4111
+ nextSelected = valid ? rawSelected : null;
4112
+ setSelected(nextSelected);
3889
4113
  }
3890
- readBooleanStateField(state, "checked", setChecked);
4114
+ let nextChecked = checked;
4115
+ readBooleanStateField(state, "checked", (value) => {
4116
+ nextChecked = value;
4117
+ setChecked(value);
4118
+ });
4119
+ const nextCorrect = nextSelected === props.correctTargetId;
4120
+ replayTelemetry(nextSelected, nextChecked, nextCorrect);
3891
4121
  }
3892
4122
  }),
3893
- [checkId, selected, checked, correct, props.correctTargetId, props.targets]
4123
+ [assessment, checkId, checked, correct, props.correctTargetId, props.passingScore, props.targets, selected]
3894
4124
  );
3895
4125
  useAssessmentHandleRegistration(checkId, handle, ref);
3896
4126
  const selectTarget = (id) => {
@@ -4067,7 +4297,7 @@ import React29, {
4067
4297
  useContext as useContext8,
4068
4298
  useLayoutEffect as useLayoutEffect2,
4069
4299
  useMemo as useMemo18,
4070
- useRef as useRef15,
4300
+ useRef as useRef16,
4071
4301
  useState as useState20
4072
4302
  } from "react";
4073
4303
  import {
@@ -4139,8 +4369,8 @@ function ThemeProvider(props) {
4139
4369
  const base = preset === "default" ? modeBase : preset === "brand" ? mergeThemes(modeBase, brandThemeOverrides) : mergeThemes(modeBase, getPresetTheme(preset));
4140
4370
  return mergeThemes(base, props.theme ?? {});
4141
4371
  }, [preset, mode, dataTheme, props.theme]);
4142
- const hostRef = useRef15(null);
4143
- const appliedKeysRef = useRef15(/* @__PURE__ */ new Set());
4372
+ const hostRef = useRef16(null);
4373
+ const appliedKeysRef = useRef16(/* @__PURE__ */ new Set());
4144
4374
  useIsoLayoutEffect2(() => {
4145
4375
  if (targetKind === "document" && typeof document !== "undefined") {
4146
4376
  document.documentElement.setAttribute("data-lk-theme", dataTheme);