@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.cjs CHANGED
@@ -99,6 +99,7 @@ var import_core8 = require("@lessonkit/core");
99
99
 
100
100
  // src/runtime/observability.ts
101
101
  var import_xapi = require("@lessonkit/xapi");
102
+ var import_meta = {};
102
103
  function createXapiQueueFromObservability(observability) {
103
104
  const opts = {};
104
105
  if (observability?.onXapiQueueDepth) {
@@ -109,6 +110,44 @@ function createXapiQueueFromObservability(observability) {
109
110
  }
110
111
  return (0, import_xapi.createInMemoryXAPIQueue)(opts);
111
112
  }
113
+ function wrapBatchSink(batchSink, observability) {
114
+ if (!batchSink || !observability?.onTelemetrySinkError) return batchSink;
115
+ const onError = observability.onTelemetrySinkError;
116
+ return async (events) => {
117
+ try {
118
+ await batchSink(events);
119
+ } catch (err) {
120
+ onError(err, { sinkId: "tracking-batch" });
121
+ throw err;
122
+ }
123
+ };
124
+ }
125
+ function warnMissingProductionObservability(observability, opts) {
126
+ let isProduction = false;
127
+ try {
128
+ isProduction = import_meta.env?.PROD === true;
129
+ } catch {
130
+ }
131
+ if (!isProduction) {
132
+ const g = globalThis;
133
+ isProduction = typeof g.process !== "undefined" && g.process.env?.NODE_ENV === "production";
134
+ }
135
+ if (!isProduction) return;
136
+ if (!opts.trackingEnabled && !opts.xapiEnabled) return;
137
+ const hooks = [
138
+ observability?.onTelemetrySinkError,
139
+ observability?.onTelemetryBufferDrop,
140
+ observability?.onXapiQueueDepth,
141
+ observability?.onXapiQueueCap,
142
+ observability?.onLxpackBridgeMiss
143
+ ];
144
+ if (hooks.some(Boolean)) return;
145
+ if (typeof console !== "undefined") {
146
+ console.warn(
147
+ "[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"
148
+ );
149
+ }
150
+ }
112
151
  function wrapTrackingSink(sink, observability) {
113
152
  if (!sink || !observability?.onTelemetrySinkError) return sink;
114
153
  const onError = observability.onTelemetrySinkError;
@@ -235,6 +274,7 @@ function createXapiClientFromConfig(config, queue) {
235
274
  return (0, import_xapi3.createXAPIClient)({
236
275
  courseId: config.courseId,
237
276
  transport: config.xapi?.transport,
277
+ exitTransport: config.xapi?.exitTransport,
238
278
  queue
239
279
  });
240
280
  }
@@ -283,6 +323,7 @@ async function emitCourseStartedNonTrackingPipeline(opts) {
283
323
  const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(opts.event);
284
324
  if (statement) {
285
325
  opts.xapi.send(statement);
326
+ await opts.xapi.flush();
286
327
  xapiStatementSent = true;
287
328
  }
288
329
  }
@@ -318,7 +359,7 @@ function emitTelemetryWithPlugins(opts) {
318
359
  }
319
360
 
320
361
  // src/provider/courseStarted/emit.ts
321
- var courseStartedTrackingFlightKey = null;
362
+ var courseStartedTrackingFlights = /* @__PURE__ */ new Map();
322
363
  function isTrackingActive(tracking) {
323
364
  return tracking?.enabled !== false;
324
365
  }
@@ -346,25 +387,40 @@ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseI
346
387
  if ((0, import_core5.hasCourseStartedEmittedToTracking)(storage, sessionId, courseId)) {
347
388
  return true;
348
389
  }
349
- if (courseStartedTrackingFlightKey === flightKey) {
350
- return false;
390
+ const existing = courseStartedTrackingFlights.get(flightKey);
391
+ if (existing) {
392
+ const settled = await existing;
393
+ if (settled) return true;
351
394
  }
352
- courseStartedTrackingFlightKey = flightKey;
353
- try {
354
- if (shouldCommit && !shouldCommit()) return false;
355
- tracking.track(event);
356
- (0, import_core5.markCourseStartedEmittedToTracking)(storage, sessionId, courseId);
357
- const delivered = await tracking.flush?.();
358
- if (delivered === false) return false;
359
- if (shouldCommit && !shouldCommit()) return false;
360
- return true;
361
- } catch {
362
- return false;
363
- } finally {
364
- if (courseStartedTrackingFlightKey === flightKey) {
365
- courseStartedTrackingFlightKey = null;
395
+ let resolveFlight;
396
+ const flight = new Promise((resolve) => {
397
+ resolveFlight = resolve;
398
+ });
399
+ courseStartedTrackingFlights.set(flightKey, flight);
400
+ void (async () => {
401
+ try {
402
+ if (shouldCommit && !shouldCommit()) {
403
+ resolveFlight(false);
404
+ return;
405
+ }
406
+ tracking.track(event);
407
+ const delivered = await tracking.flush?.();
408
+ if (delivered === false) {
409
+ resolveFlight(false);
410
+ return;
411
+ }
412
+ if ((0, import_core5.markCourseStartedEmittedToTracking)(storage, sessionId, courseId) === false) {
413
+ resolveFlight(false);
414
+ return;
415
+ }
416
+ resolveFlight(true);
417
+ } catch {
418
+ resolveFlight(false);
419
+ } finally {
420
+ courseStartedTrackingFlights.delete(flightKey);
366
421
  }
367
- }
422
+ })();
423
+ return flight;
368
424
  }
369
425
  async function emitCourseStartedPipelineOnly(opts) {
370
426
  try {
@@ -378,8 +434,10 @@ async function emitCourseStartedPipelineOnly(opts) {
378
434
  skipXapi: opts.skipXapi
379
435
  });
380
436
  if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
381
- (0, import_core5.markCourseStarted)(opts.storage, opts.sessionId, opts.courseId);
382
- (0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId);
437
+ if ((0, import_core5.markCourseStarted)(opts.storage, opts.sessionId, opts.courseId) === false) return "failed";
438
+ if ((0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId) === false) {
439
+ return "failed";
440
+ }
383
441
  if (xapiStatementSent) {
384
442
  opts.onXapiStatementSent?.();
385
443
  }
@@ -430,7 +488,9 @@ async function emitCourseStartedToTrackingOnly(opts) {
430
488
  extraSinks: opts.extraSinks,
431
489
  skipXapi: true
432
490
  });
433
- (0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId);
491
+ if ((0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId) === false) {
492
+ return "failed";
493
+ }
434
494
  return "emitted";
435
495
  } catch {
436
496
  return "failed";
@@ -490,6 +550,7 @@ function createTrackingClientFromConfig(config, observability) {
490
550
  sink: config.tracking?.sink,
491
551
  batchSink: config.tracking?.batchSink,
492
552
  batch: config.tracking?.batch,
553
+ exitBatchSink: config.tracking?.exitBatchSink,
493
554
  onBufferDrop: observability?.onTelemetryBufferDrop
494
555
  });
495
556
  }
@@ -565,6 +626,7 @@ function useLessonkitProviderRuntime(config) {
565
626
  const pendingCourseIdResetRef = (0, import_react.useRef)(false);
566
627
  const prevUseV2RuntimeRef = (0, import_react.useRef)(useV2Runtime);
567
628
  const xapiCourseStartedSentOnClientRef = (0, import_react.useRef)(false);
629
+ const xapiBootstrapSendRef = (0, import_react.useRef)(false);
568
630
  if (prevUseV2RuntimeRef.current !== useV2Runtime) {
569
631
  prevUseV2RuntimeRef.current = useV2Runtime;
570
632
  if (useV2Runtime) {
@@ -637,19 +699,22 @@ function useLessonkitProviderRuntime(config) {
637
699
  xapiQueueRef.current = createXapiQueueFromObservability(observabilityRef.current);
638
700
  prevXapiCourseIdRef.current = courseId;
639
701
  xapiCourseStartedSentOnClientRef.current = false;
702
+ xapiBootstrapSendRef.current = false;
640
703
  }
641
704
  const prev = xapiRef.current;
642
705
  const next = createXapiClientFromConfig(normalizedConfig, xapiQueueRef.current);
643
706
  xapiRef.current = next;
644
707
  setXapi(next);
708
+ let bootstrapSent = false;
709
+ let bootstrapAlreadyStarted = false;
645
710
  if (next) {
646
711
  const sessionId = sessionIdRef.current;
647
712
  const cid = courseIdRef.current;
648
713
  const trackingActive = isTrackingActive(normalizedConfig.tracking);
649
- const alreadyStarted = (0, import_core5.hasCourseStarted)(defaultStorage, sessionId, cid);
714
+ bootstrapAlreadyStarted = (0, import_core5.hasCourseStarted)(defaultStorage, sessionId, cid);
650
715
  const clientChanged = !prev || prev !== next;
651
- const skipBootstrap = trackingActive && !alreadyStarted;
652
- const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && (!alreadyStarted || clientChanged);
716
+ const skipBootstrap = trackingActive && !bootstrapAlreadyStarted;
717
+ const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && !xapiBootstrapSendRef.current && (!bootstrapAlreadyStarted || clientChanged);
653
718
  if (needsBootstrap) {
654
719
  try {
655
720
  const event = buildCourseStartedEvent({
@@ -660,15 +725,12 @@ function useLessonkitProviderRuntime(config) {
660
725
  user: userRef.current,
661
726
  lxpackBridge: lxpackBridgeModeRef.current
662
727
  });
663
- if (event === null) {
664
- } else {
728
+ if (event !== null) {
665
729
  const statement = (0, import_xapi5.telemetryEventToXAPIStatement)(event);
666
730
  if (statement) {
667
731
  next.send(statement);
668
- if (!alreadyStarted) {
669
- (0, import_core5.markCourseStarted)(defaultStorage, sessionId, cid);
670
- }
671
- xapiCourseStartedSentOnClientRef.current = true;
732
+ xapiBootstrapSendRef.current = true;
733
+ bootstrapSent = true;
672
734
  }
673
735
  }
674
736
  } catch {
@@ -686,6 +748,12 @@ function useLessonkitProviderRuntime(config) {
686
748
  if (cancelled) return;
687
749
  try {
688
750
  await next?.flush();
751
+ if (bootstrapSent && !cancelled) {
752
+ if (!bootstrapAlreadyStarted) {
753
+ (0, import_core5.markCourseStarted)(defaultStorage, sessionIdRef.current, courseIdRef.current);
754
+ }
755
+ xapiCourseStartedSentOnClientRef.current = true;
756
+ }
689
757
  } catch {
690
758
  }
691
759
  })();
@@ -714,7 +782,10 @@ function useLessonkitProviderRuntime(config) {
714
782
  useIsoLayoutEffect(() => {
715
783
  const prev = trackingRef.current;
716
784
  const baseSink = wrapTrackingSink(normalizedConfig.tracking?.sink, observabilityRef.current);
717
- const userBatchSink = normalizedConfig.tracking?.batchSink;
785
+ const userBatchSink = wrapBatchSink(
786
+ normalizedConfig.tracking?.batchSink,
787
+ observabilityRef.current
788
+ );
718
789
  assertTrackingSinkConfig(normalizedConfig.tracking);
719
790
  const sink = pluginHostRef.current && baseSink ? (
720
791
  /* v8 ignore next -- composeTrackingSink may return null; fall back to base sink */
@@ -868,7 +939,11 @@ function useLessonkitProviderRuntime(config) {
868
939
  user: userRef.current,
869
940
  lxpackBridge: lxpackBridgeModeRef.current,
870
941
  onLxpackBridgeMiss,
871
- extraSinks: extraSinksRef.current
942
+ extraSinks: extraSinksRef.current,
943
+ skipXapi: xapiCourseStartedSentOnClientRef.current,
944
+ onXapiStatementSent: () => {
945
+ xapiCourseStartedSentOnClientRef.current = true;
946
+ }
872
947
  });
873
948
  courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
874
949
  }
@@ -924,20 +999,39 @@ function useLessonkitProviderRuntime(config) {
924
999
  }, []);
925
1000
  (0, import_react.useEffect)(() => {
926
1001
  if (typeof document === "undefined") return;
927
- const flushOnExit = () => {
928
- void xapiRef.current?.flush();
929
- void trackingRef.current?.flush?.();
1002
+ const flushOnPageExit = () => {
1003
+ try {
1004
+ xapiRef.current?.flushOnExit?.();
1005
+ trackingRef.current?.flushOnExit?.();
1006
+ } finally {
1007
+ void xapiRef.current?.flush();
1008
+ void trackingRef.current?.flush?.();
1009
+ }
930
1010
  };
931
1011
  const onVisibilityChange = () => {
932
- if (document.visibilityState === "hidden") flushOnExit();
1012
+ if (document.visibilityState === "hidden") flushOnPageExit();
933
1013
  };
934
1014
  document.addEventListener("visibilitychange", onVisibilityChange);
935
- window.addEventListener("pagehide", flushOnExit);
1015
+ window.addEventListener("pagehide", flushOnPageExit);
936
1016
  return () => {
937
1017
  document.removeEventListener("visibilitychange", onVisibilityChange);
938
- window.removeEventListener("pagehide", flushOnExit);
1018
+ window.removeEventListener("pagehide", flushOnPageExit);
939
1019
  };
940
1020
  }, []);
1021
+ (0, import_react.useEffect)(() => {
1022
+ warnMissingProductionObservability(observabilityRef.current, {
1023
+ trackingEnabled: isTrackingActive(normalizedConfig.tracking),
1024
+ xapiEnabled: normalizedConfig.xapi?.enabled !== false && Boolean(
1025
+ normalizedConfig.xapi?.client || normalizedConfig.xapi?.transport || normalizedConfig.xapi?.enabled === true
1026
+ )
1027
+ });
1028
+ }, [
1029
+ normalizedConfig.tracking,
1030
+ normalizedConfig.xapi?.enabled,
1031
+ normalizedConfig.xapi?.client,
1032
+ normalizedConfig.xapi?.transport,
1033
+ normalizedConfig.observability
1034
+ ]);
941
1035
  const setActiveLesson = (0, import_react.useCallback)(
942
1036
  (lessonId) => {
943
1037
  if (useV2Runtime && headlessRef.current) {
@@ -2210,9 +2304,13 @@ function FillInTheBlanksInner(props, ref) {
2210
2304
  const [submitted, setSubmitted] = (0, import_react16.useState)(false);
2211
2305
  const completedRef = (0, import_react16.useRef)(false);
2212
2306
  const answeredRef = (0, import_react16.useRef)(false);
2307
+ const checkSnapshotRef = (0, import_react16.useRef)(null);
2308
+ const telemetryReplayedRef = (0, import_react16.useRef)(false);
2213
2309
  const reset = () => {
2214
2310
  completedRef.current = false;
2215
2311
  answeredRef.current = false;
2312
+ checkSnapshotRef.current = null;
2313
+ telemetryReplayedRef.current = false;
2216
2314
  setPassed(false);
2217
2315
  setValues(Object.fromEntries(blanks.map((b) => [b.id, ""])));
2218
2316
  setShowSolutions(false);
@@ -2229,6 +2327,31 @@ function FillInTheBlanksInner(props, ref) {
2229
2327
  });
2230
2328
  const maxScore = blanks.length;
2231
2329
  const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
2330
+ const replayTelemetry = (nextValues, nextPassed, nextSubmitted, nextScore, nextMaxScore) => {
2331
+ if (telemetryReplayedRef.current || !nextSubmitted && !nextPassed) return;
2332
+ telemetryReplayedRef.current = true;
2333
+ const nextPassedThreshold = meetsPassingThreshold(
2334
+ nextScore,
2335
+ nextMaxScore || 1,
2336
+ props.passingScore
2337
+ );
2338
+ assessment.answer({
2339
+ checkId,
2340
+ interactionType: INTERACTION3,
2341
+ question: props.template,
2342
+ response: nextValues,
2343
+ correct: nextPassedThreshold
2344
+ });
2345
+ if (nextPassed || nextPassedThreshold) {
2346
+ assessment.complete({
2347
+ checkId,
2348
+ interactionType: INTERACTION3,
2349
+ score: nextScore,
2350
+ maxScore: nextMaxScore,
2351
+ passingScore: props.passingScore ?? nextMaxScore
2352
+ });
2353
+ }
2354
+ };
2232
2355
  const handle = (0, import_react16.useMemo)(
2233
2356
  () => buildAssessmentHandle({
2234
2357
  checkId,
@@ -2248,20 +2371,33 @@ function FillInTheBlanksInner(props, ref) {
2248
2371
  getCurrentState: () => ({ values, passed, showSolutions, submitted }),
2249
2372
  resume: (state) => {
2250
2373
  const raw = state.values;
2251
- if (raw && typeof raw === "object") setValues({ ...raw });
2374
+ let nextValues = values;
2375
+ if (raw && typeof raw === "object") {
2376
+ nextValues = { ...raw };
2377
+ setValues(nextValues);
2378
+ }
2379
+ let nextPassed = passed;
2380
+ let nextSubmitted = submitted;
2252
2381
  readBooleanStateField(state, "passed", (value) => {
2382
+ nextPassed = value;
2253
2383
  setPassed(value);
2254
2384
  completedRef.current = value;
2255
2385
  answeredRef.current = value;
2256
2386
  });
2257
2387
  readBooleanStateField(state, "showSolutions", setShowSolutions);
2258
2388
  readBooleanStateField(state, "submitted", (value) => {
2389
+ nextSubmitted = value;
2259
2390
  setSubmitted(value);
2260
2391
  if (value) answeredRef.current = true;
2261
2392
  });
2393
+ let nextScore = 0;
2394
+ blanks.forEach((b) => {
2395
+ if ((nextValues[b.id] ?? "").trim().toLowerCase() === b.answer.toLowerCase()) nextScore += 1;
2396
+ });
2397
+ replayTelemetry(nextValues, nextPassed, nextSubmitted, nextScore, blanks.length);
2262
2398
  }
2263
2399
  }),
2264
- [allFilled, checkId, maxScore, passed, passedThreshold, score, showSolutions, submitted, values]
2400
+ [allFilled, assessment, blanks, checkId, maxScore, passed, passedThreshold, props.passingScore, props.template, score, showSolutions, submitted, values]
2265
2401
  );
2266
2402
  useAssessmentHandleRegistration(checkId, handle, ref);
2267
2403
  const check = () => {
@@ -2272,7 +2408,10 @@ function FillInTheBlanksInner(props, ref) {
2272
2408
  return;
2273
2409
  }
2274
2410
  if (!allFilled) return;
2275
- if (answeredRef.current || submitted) return;
2411
+ if (passed) return;
2412
+ const snapshot = JSON.stringify(values);
2413
+ if (checkSnapshotRef.current === snapshot) return;
2414
+ checkSnapshotRef.current = snapshot;
2276
2415
  answeredRef.current = true;
2277
2416
  setSubmitted(true);
2278
2417
  assessment.answer({
@@ -2297,12 +2436,13 @@ function FillInTheBlanksInner(props, ref) {
2297
2436
  (0, import_react16.useEffect)(() => {
2298
2437
  if (!allFilled) {
2299
2438
  answeredRef.current = false;
2439
+ checkSnapshotRef.current = null;
2300
2440
  setSubmitted(false);
2301
2441
  }
2302
2442
  }, [allFilled]);
2303
2443
  (0, import_react16.useEffect)(() => {
2304
- if (props.autoCheck && allFilled) check();
2305
- }, [allFilled, props.autoCheck, values, passedThreshold]);
2444
+ if (props.autoCheck && allFilled && !passed) check();
2445
+ }, [allFilled, props.autoCheck, values, passedThreshold, passed]);
2306
2446
  const reveal = showSolutions || passed && props.enableSolutionsButton;
2307
2447
  return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("section", { "aria-label": "Fill in the Blanks", "data-lk-check-id": checkId, children: [
2308
2448
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { children: parsed.parts.map((part, i) => {
@@ -2361,9 +2501,13 @@ function DragTheWordsInner(props, ref) {
2361
2501
  const [submitted, setSubmitted] = (0, import_react17.useState)(false);
2362
2502
  const completedRef = (0, import_react17.useRef)(false);
2363
2503
  const answeredRef = (0, import_react17.useRef)(false);
2504
+ const checkSnapshotRef = (0, import_react17.useRef)(null);
2505
+ const telemetryReplayedRef = (0, import_react17.useRef)(false);
2364
2506
  const reset = () => {
2365
2507
  completedRef.current = false;
2366
2508
  answeredRef.current = false;
2509
+ checkSnapshotRef.current = null;
2510
+ telemetryReplayedRef.current = false;
2367
2511
  setPassed(false);
2368
2512
  setSubmitted(false);
2369
2513
  setZones(Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""])));
@@ -2381,6 +2525,31 @@ function DragTheWordsInner(props, ref) {
2381
2525
  });
2382
2526
  const maxScore = answers.length;
2383
2527
  const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
2528
+ const replayTelemetry = (nextZones, nextPassed, nextSubmitted, nextScore, nextMaxScore) => {
2529
+ if (telemetryReplayedRef.current || !nextSubmitted && !nextPassed) return;
2530
+ telemetryReplayedRef.current = true;
2531
+ const nextPassedThreshold = meetsPassingThreshold(
2532
+ nextScore,
2533
+ nextMaxScore || 1,
2534
+ props.passingScore
2535
+ );
2536
+ assessment.answer({
2537
+ checkId,
2538
+ interactionType: INTERACTION4,
2539
+ question: props.template,
2540
+ response: nextZones,
2541
+ correct: nextPassedThreshold
2542
+ });
2543
+ if (nextPassed || nextPassedThreshold) {
2544
+ assessment.complete({
2545
+ checkId,
2546
+ interactionType: INTERACTION4,
2547
+ score: nextScore,
2548
+ maxScore: nextMaxScore,
2549
+ passingScore: props.passingScore ?? nextMaxScore
2550
+ });
2551
+ }
2552
+ };
2384
2553
  const handle = (0, import_react17.useMemo)(
2385
2554
  () => buildAssessmentHandle({
2386
2555
  checkId,
@@ -2401,22 +2570,35 @@ function DragTheWordsInner(props, ref) {
2401
2570
  getCurrentState: () => ({ zones, pool, passed, keyboardWord, submitted }),
2402
2571
  resume: (state) => {
2403
2572
  const rawZones = state.zones;
2404
- if (rawZones && typeof rawZones === "object") setZones({ ...rawZones });
2573
+ let nextZones = zones;
2574
+ if (rawZones && typeof rawZones === "object") {
2575
+ nextZones = { ...rawZones };
2576
+ setZones(nextZones);
2577
+ }
2405
2578
  if (Array.isArray(state.pool)) setPool([...state.pool]);
2579
+ let nextPassed = passed;
2580
+ let nextSubmitted = submitted;
2406
2581
  readBooleanStateField(state, "passed", (value) => {
2582
+ nextPassed = value;
2407
2583
  setPassed(value);
2408
2584
  completedRef.current = value;
2409
2585
  answeredRef.current = value;
2410
2586
  });
2411
2587
  readBooleanStateField(state, "submitted", (value) => {
2588
+ nextSubmitted = value;
2412
2589
  setSubmitted(value);
2413
2590
  if (value) answeredRef.current = true;
2414
2591
  });
2415
2592
  const kw = state.keyboardWord;
2416
2593
  if (kw === null || typeof kw === "string") setKeyboardWord(kw ?? null);
2594
+ let nextScore = 0;
2595
+ answers.forEach((ans, i) => {
2596
+ if ((nextZones[`zone-${i}`] ?? "").trim().toLowerCase() === ans.toLowerCase()) nextScore += 1;
2597
+ });
2598
+ replayTelemetry(nextZones, nextPassed, nextSubmitted, nextScore, answers.length);
2417
2599
  }
2418
2600
  }),
2419
- [allFilled, checkId, keyboardWord, maxScore, passed, passedThreshold, pool, score, submitted, zones]
2601
+ [allFilled, answers, assessment, checkId, keyboardWord, maxScore, passed, passedThreshold, pool, props.passingScore, props.template, score, submitted, zones]
2420
2602
  );
2421
2603
  useAssessmentHandleRegistration(checkId, handle, ref);
2422
2604
  const placeInZone = (zoneId, word) => {
@@ -2446,7 +2628,10 @@ function DragTheWordsInner(props, ref) {
2446
2628
  return;
2447
2629
  }
2448
2630
  if (!allFilled) return;
2449
- if (answeredRef.current || submitted) return;
2631
+ if (passed) return;
2632
+ const snapshot = JSON.stringify(zones);
2633
+ if (checkSnapshotRef.current === snapshot) return;
2634
+ checkSnapshotRef.current = snapshot;
2450
2635
  answeredRef.current = true;
2451
2636
  setSubmitted(true);
2452
2637
  assessment.answer({
@@ -2471,12 +2656,13 @@ function DragTheWordsInner(props, ref) {
2471
2656
  (0, import_react17.useEffect)(() => {
2472
2657
  if (!allFilled) {
2473
2658
  answeredRef.current = false;
2659
+ checkSnapshotRef.current = null;
2474
2660
  setSubmitted(false);
2475
2661
  }
2476
2662
  }, [allFilled]);
2477
2663
  (0, import_react17.useEffect)(() => {
2478
- if (props.autoCheck && allFilled) check();
2479
- }, [allFilled, props.autoCheck, zones, passedThreshold]);
2664
+ if (props.autoCheck && allFilled && !passed) check();
2665
+ }, [allFilled, props.autoCheck, zones, passedThreshold, passed]);
2480
2666
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("section", { "aria-label": "Drag the Words", "data-lk-check-id": checkId, children: [
2481
2667
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("p", { children: "Drag words into the blanks (or select a word, then activate a blank)." }),
2482
2668
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { role: "list", "aria-label": "Word bank", "data-testid": "word-bank", children: pool.map((word) => /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
@@ -2876,6 +3062,8 @@ function useCompoundPersistence(opts) {
2876
3062
  }, [ctx, opts.index, opts.pageCount]);
2877
3063
  const buildStateRef = (0, import_react21.useRef)(buildState);
2878
3064
  buildStateRef.current = buildState;
3065
+ const persistNowRef = (0, import_react21.useRef)(() => {
3066
+ });
2879
3067
  const finalizeHydration = (0, import_react21.useCallback)(
2880
3068
  (childStates) => {
2881
3069
  loadedChildStatesRef.current = {
@@ -2884,6 +3072,7 @@ function useCompoundPersistence(opts) {
2884
3072
  };
2885
3073
  skipSaveUntilHydratedRef.current = false;
2886
3074
  pendingChildResumeRef.current = null;
3075
+ queueMicrotask(() => persistNowRef.current());
2887
3076
  },
2888
3077
  []
2889
3078
  );
@@ -2896,6 +3085,14 @@ function useCompoundPersistence(opts) {
2896
3085
  alreadyResumed: resumedChildKeysRef.current
2897
3086
  });
2898
3087
  if (!applied) {
3088
+ if (handles.size === 0) {
3089
+ const registeredOnly2 = stripOrphanChildStates(handles, pending.childStates);
3090
+ resumeChildHandles(handles, registeredOnly2, {
3091
+ alreadyResumed: resumedChildKeysRef.current
3092
+ });
3093
+ finalizeHydration(registeredOnly2);
3094
+ return;
3095
+ }
2899
3096
  const handlesAtWait = handles.size;
2900
3097
  queueMicrotask(() => {
2901
3098
  if (pendingChildResumeRef.current !== pending) return;
@@ -2929,8 +3126,12 @@ function useCompoundPersistence(opts) {
2929
3126
  });
2930
3127
  const persistNow = (0, import_react21.useCallback)(() => {
2931
3128
  if (!opts.enabled || !opts.courseId) return;
3129
+ if (skipSaveUntilHydratedRef.current) return;
2932
3130
  saveResume(buildStateRef.current());
2933
3131
  }, [opts.enabled, opts.courseId, saveResume]);
3132
+ (0, import_react21.useEffect)(() => {
3133
+ persistNowRef.current = persistNow;
3134
+ }, [persistNow]);
2934
3135
  const notifyImperativeResume = (0, import_react21.useCallback)(
2935
3136
  (state) => {
2936
3137
  const clamped = (0, import_core14.clampCompoundPageIndex)(state.activePageIndex, opts.pageCount);
@@ -2952,12 +3153,12 @@ function useCompoundPersistence(opts) {
2952
3153
  }
2953
3154
  };
2954
3155
  }, [bridgeRef, notifyImperativeResume]);
2955
- (0, import_react21.useEffect)(() => {
2956
- persistNow();
2957
- }, [persistNow, opts.index, opts.pageCount, handlesVersion]);
2958
3156
  (0, import_react21.useEffect)(() => {
2959
3157
  applyPendingChildResume();
2960
3158
  }, [opts.index, handlesVersion, applyPendingChildResume]);
3159
+ (0, import_react21.useEffect)(() => {
3160
+ persistNow();
3161
+ }, [persistNow, opts.index, opts.pageCount, handlesVersion]);
2961
3162
  (0, import_react21.useEffect)(() => {
2962
3163
  if (!opts.enabled || !opts.courseId || typeof document === "undefined") return;
2963
3164
  const flushOnExit = () => {
@@ -3900,13 +4101,34 @@ function FindHotspotInner(props, ref) {
3900
4101
  const checkId = (0, import_react36.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
3901
4102
  const [selected, setSelected] = (0, import_react36.useState)(null);
3902
4103
  const [checked, setChecked] = (0, import_react36.useState)(false);
4104
+ const telemetryReplayedRef = (0, import_react36.useRef)(false);
3903
4105
  const assessment = useAssessmentState(props.enclosingLessonId);
3904
4106
  const targetIdsKey = props.targets.map((t) => t.id).join("\0");
3905
4107
  (0, import_react36.useEffect)(() => {
3906
4108
  setSelected(null);
3907
4109
  setChecked(false);
4110
+ telemetryReplayedRef.current = false;
3908
4111
  }, [checkId, props.correctTargetId, targetIdsKey]);
3909
4112
  const correct = selected === props.correctTargetId;
4113
+ const replayTelemetry = (nextSelected, nextChecked, nextCorrect) => {
4114
+ if (telemetryReplayedRef.current || !nextChecked || nextSelected === null) return;
4115
+ telemetryReplayedRef.current = true;
4116
+ assessment.answer({
4117
+ checkId,
4118
+ interactionType: INTERACTION6,
4119
+ response: nextSelected,
4120
+ correct: nextCorrect
4121
+ });
4122
+ if (nextCorrect) {
4123
+ assessment.complete({
4124
+ checkId,
4125
+ interactionType: INTERACTION6,
4126
+ score: 1,
4127
+ maxScore: 1,
4128
+ passingScore: props.passingScore ?? 1
4129
+ });
4130
+ }
4131
+ };
3910
4132
  const handle = (0, import_react36.useMemo)(
3911
4133
  () => buildAssessmentHandle({
3912
4134
  checkId,
@@ -3916,6 +4138,7 @@ function FindHotspotInner(props, ref) {
3916
4138
  resetTask: () => {
3917
4139
  setSelected(null);
3918
4140
  setChecked(false);
4141
+ telemetryReplayedRef.current = false;
3919
4142
  },
3920
4143
  showSolutions: () => setSelected(props.correctTargetId),
3921
4144
  getXAPIData: () => ({
@@ -3928,15 +4151,23 @@ function FindHotspotInner(props, ref) {
3928
4151
  }),
3929
4152
  getCurrentState: () => ({ selected, checked }),
3930
4153
  resume: (state) => {
3931
- const nextSelected = readStringField(state, "selected");
3932
- if (typeof nextSelected === "string" || nextSelected === null) {
3933
- const valid = nextSelected === null || props.targets.some((t) => t.id === nextSelected);
3934
- setSelected(valid ? nextSelected : null);
4154
+ let nextSelected = selected;
4155
+ const rawSelected = readStringField(state, "selected");
4156
+ if (typeof rawSelected === "string" || rawSelected === null) {
4157
+ const valid = rawSelected === null || props.targets.some((t) => t.id === rawSelected);
4158
+ nextSelected = valid ? rawSelected : null;
4159
+ setSelected(nextSelected);
3935
4160
  }
3936
- readBooleanStateField(state, "checked", setChecked);
4161
+ let nextChecked = checked;
4162
+ readBooleanStateField(state, "checked", (value) => {
4163
+ nextChecked = value;
4164
+ setChecked(value);
4165
+ });
4166
+ const nextCorrect = nextSelected === props.correctTargetId;
4167
+ replayTelemetry(nextSelected, nextChecked, nextCorrect);
3937
4168
  }
3938
4169
  }),
3939
- [checkId, selected, checked, correct, props.correctTargetId, props.targets]
4170
+ [assessment, checkId, checked, correct, props.correctTargetId, props.passingScore, props.targets, selected]
3940
4171
  );
3941
4172
  useAssessmentHandleRegistration(checkId, handle, ref);
3942
4173
  const selectTarget = (id) => {
package/dist/index.d.cts CHANGED
@@ -4,6 +4,7 @@ import * as _lessonkit_core from '@lessonkit/core';
4
4
  import { TelemetryEvent, CourseId, TelemetryUser, TrackingClient, LessonkitPlugin, ProgressState, StoragePort, LessonId, TelemetryEventName, TelemetryDataFor, PluginHost, AssessmentHandle, BlockId, AssessmentBaseProps, AssessmentBehaviour, CompoundHandle, AssessmentAnsweredData, AssessmentCompletedData, CheckId } from '@lessonkit/core';
5
5
  export { AssessmentAnsweredData, AssessmentBaseProps, AssessmentBehaviour, AssessmentCompletedData, AssessmentHandle, AssessmentInteractionType, AssessmentScoreInput, AssessmentScoreResult, AssessmentXAPIData, CompoundBaseProps, CompoundHandle, CompoundResumeState, InteractionBlockRegistration, LessonkitPlugin, LessonkitPluginContext, LessonkitPluginKind, PluginHost, PluginRegistry, TelemetryPipelineSink, buildTelemetryEvent, createLessonkitRuntime, createPluginRegistry, createTelemetryPipeline, defineAssessmentPlugin, defineLifecyclePlugin, defineTelemetryPlugin } from '@lessonkit/core';
6
6
  import { McqAssessmentDescriptor } from '@lessonkit/lxpack';
7
+ import * as _lessonkit_xapi from '@lessonkit/xapi';
7
8
  import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
8
9
  import { LxpackBridgeMode } from '@lessonkit/lxpack/bridge';
9
10
  import { LessonkitThemeV1, ThemePresetName, PartialLessonkitThemeV1 } from '@lessonkit/themes';
@@ -37,6 +38,8 @@ type LessonkitConfig = {
37
38
  enabled?: boolean;
38
39
  sink?: (event: Parameters<TrackingClient["track"]>[0]) => void | Promise<void>;
39
40
  batchSink?: (events: Parameters<TrackingClient["track"]>[0][]) => void | Promise<void>;
41
+ /** Keepalive batch delivery for pagehide (e.g. from createFetchBatchSink). */
42
+ exitBatchSink?: (events: Parameters<TrackingClient["track"]>[0][]) => void | Promise<void>;
40
43
  batch?: {
41
44
  enabled?: boolean;
42
45
  flushIntervalMs?: number;
@@ -46,6 +49,8 @@ type LessonkitConfig = {
46
49
  xapi?: {
47
50
  enabled?: boolean;
48
51
  transport?: XAPITransport;
52
+ /** Keepalive transport for pagehide (e.g. from createFetchTransport). */
53
+ exitTransport?: _lessonkit_xapi.XAPIExitTransport;
49
54
  client?: XAPIClient;
50
55
  };
51
56
  lxpack?: {