@lessonkit/react 0.9.1 → 0.9.3

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
@@ -59,7 +59,7 @@ export default function App() {
59
59
  }
60
60
  ```
61
61
 
62
- ## API (0.9.1)
62
+ ## API (0.9.3)
63
63
 
64
64
  ### Block catalog
65
65
 
package/dist/index.cjs CHANGED
@@ -369,9 +369,15 @@ function createTrackingClientFromConfig(config) {
369
369
  batch: config.tracking?.batch
370
370
  });
371
371
  }
372
- function disposeTrackingClient(client) {
373
- client?.flush?.();
374
- client?.dispose?.();
372
+ async function disposeTrackingClient(client) {
373
+ try {
374
+ await client?.flush?.();
375
+ } catch {
376
+ }
377
+ try {
378
+ await client?.dispose?.();
379
+ } catch {
380
+ }
375
381
  }
376
382
 
377
383
  // src/context.tsx
@@ -382,6 +388,33 @@ var defaultStorage = createSessionStoragePort();
382
388
  function isTrackingActive(tracking) {
383
389
  return tracking?.enabled !== false;
384
390
  }
391
+ function emitCourseStarted(opts) {
392
+ const pluginCtx = buildPluginContext({
393
+ courseId: opts.courseId,
394
+ sessionId: opts.sessionId,
395
+ attemptId: opts.attemptId
396
+ });
397
+ try {
398
+ emitTelemetryWithPlugins({
399
+ pluginHost: opts.pluginHost,
400
+ tracking: opts.tracking,
401
+ xapi: opts.xapi,
402
+ event: buildTrackEvent({
403
+ name: "course_started",
404
+ courseId: opts.courseId,
405
+ sessionId: opts.sessionId,
406
+ attemptId: opts.attemptId,
407
+ user: opts.user
408
+ }),
409
+ pluginCtx,
410
+ lxpackBridge: opts.lxpackBridge
411
+ });
412
+ markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
413
+ return true;
414
+ } catch {
415
+ return false;
416
+ }
417
+ }
385
418
  function LessonkitProvider(props) {
386
419
  const config = props.config;
387
420
  const sessionIdRef = (0, import_react.useRef)(resolveSessionId(defaultStorage, config.session?.sessionId));
@@ -428,7 +461,17 @@ function LessonkitProvider(props) {
428
461
  const courseId = config.courseId;
429
462
  const trackingEnabled = config.tracking?.enabled;
430
463
  useIsoLayoutEffect(() => {
431
- if (prevXapiCourseIdRef.current !== courseId) {
464
+ const courseChanged = prevXapiCourseIdRef.current !== courseId;
465
+ if (courseChanged) {
466
+ if (config.xapi?.client) {
467
+ const g = globalThis;
468
+ if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production") {
469
+ console.warn(
470
+ "[lessonkit] courseId changed while using config.xapi.client; flush the client between courses or use config.xapi.transport so the provider can manage the queue."
471
+ );
472
+ }
473
+ void xapiRef.current?.flush();
474
+ }
432
475
  xapiQueueRef.current = (0, import_xapi3.createInMemoryXAPIQueue)();
433
476
  prevXapiCourseIdRef.current = courseId;
434
477
  }
@@ -452,7 +495,10 @@ function LessonkitProvider(props) {
452
495
  user: userRef.current
453
496
  })
454
497
  );
455
- if (statement) next.send(statement);
498
+ if (statement) {
499
+ next.send(statement);
500
+ markCourseStarted(defaultStorage, sessionId, cid);
501
+ }
456
502
  } catch {
457
503
  }
458
504
  }
@@ -484,17 +530,24 @@ function LessonkitProvider(props) {
484
530
  const batchEnabled = config.tracking?.batch?.enabled;
485
531
  const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
486
532
  const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
487
- useIsoLayoutEffect(() => {
488
- const prev = trackingRef.current;
489
- const pluginCtx = buildPluginContext({
533
+ const buildCurrentPluginCtx = (0, import_react.useCallback)(
534
+ () => buildPluginContext({
490
535
  courseId: courseIdRef.current,
491
536
  sessionId: sessionIdRef.current,
492
537
  attemptId: attemptIdRef.current
493
- });
494
- const sink = pluginHostRef.current?.composeTrackingSink(config.tracking?.sink, pluginCtx) ?? config.tracking?.sink;
538
+ }),
539
+ []
540
+ );
541
+ useIsoLayoutEffect(() => {
542
+ const prev = trackingRef.current;
543
+ const baseSink = config.tracking?.sink;
544
+ const sink = pluginHostRef.current && baseSink ? pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink : baseSink;
495
545
  const batchSink = pluginHostRef.current && config.tracking?.batchSink ? (events) => {
496
- const filtered = pluginHostRef.current.runTelemetryBatch(events, pluginCtx);
497
- return config.tracking.batchSink(filtered);
546
+ const delivered = pluginHostRef.current.deliverTelemetryBatch(
547
+ events,
548
+ buildCurrentPluginCtx()
549
+ );
550
+ return config.tracking.batchSink(delivered);
498
551
  } : config.tracking?.batchSink;
499
552
  const next = createTrackingClientFromConfig({
500
553
  tracking: { ...config.tracking, sink, batchSink }
@@ -508,32 +561,24 @@ function LessonkitProvider(props) {
508
561
  if (!trackingActive) {
509
562
  courseStartedEmittedToSinkRef.current = false;
510
563
  } else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
511
- markCourseStarted(defaultStorage, sessionId, cid);
512
- emitTelemetryWithPlugins({
564
+ const emitted = emitCourseStarted({
513
565
  pluginHost: pluginHostRef.current,
514
566
  tracking: next,
515
567
  xapi: xapiRef.current,
516
- event: buildTrackEvent({
517
- name: "course_started",
518
- courseId: cid,
519
- sessionId,
520
- attemptId: attemptIdRef.current,
521
- user: userRef.current
522
- }),
523
- pluginCtx: buildPluginContext({
524
- courseId: cid,
525
- sessionId,
526
- attemptId: attemptIdRef.current
527
- }),
568
+ storage: defaultStorage,
569
+ sessionId,
570
+ courseId: cid,
571
+ attemptId: attemptIdRef.current,
572
+ user: userRef.current,
528
573
  lxpackBridge: lxpackBridgeModeRef.current
529
574
  });
530
- courseStartedEmittedToSinkRef.current = true;
575
+ courseStartedEmittedToSinkRef.current = emitted;
531
576
  } else if (trackingActive) {
532
577
  courseStartedEmittedToSinkRef.current = true;
533
578
  }
534
579
  return () => {
535
580
  if (prev !== trackingRef.current) {
536
- disposeTrackingClient(prev);
581
+ void disposeTrackingClient(prev);
537
582
  }
538
583
  };
539
584
  }, [
@@ -543,7 +588,9 @@ function LessonkitProvider(props) {
543
588
  batchEnabled,
544
589
  batchFlushIntervalMs,
545
590
  batchMaxBatchSize,
546
- config.plugins
591
+ config.plugins,
592
+ config.courseId,
593
+ buildCurrentPluginCtx
547
594
  ]);
548
595
  const emitWithBridge = (0, import_react.useCallback)((trackingClient, event) => {
549
596
  emitTelemetryWithPlugins({
@@ -582,28 +629,26 @@ function LessonkitProvider(props) {
582
629
  if (!isTrackingActive(config.tracking)) return;
583
630
  const sessionId = sessionIdRef.current;
584
631
  const cid = courseIdRef.current;
585
- if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
586
- markCourseStarted(defaultStorage, sessionId, cid);
587
- emitTelemetryWithPlugins({
588
- pluginHost: pluginHostRef.current,
589
- tracking: trackingRef.current,
590
- xapi: xapiRef.current,
591
- event: buildTrackEvent({
592
- name: "course_started",
593
- courseId: cid,
632
+ void (async () => {
633
+ try {
634
+ await trackingRef.current?.flush?.();
635
+ } catch {
636
+ }
637
+ if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
638
+ const emitted = emitCourseStarted({
639
+ pluginHost: pluginHostRef.current,
640
+ tracking: trackingRef.current,
641
+ xapi: xapiRef.current,
642
+ storage: defaultStorage,
594
643
  sessionId,
595
- attemptId: attemptIdRef.current,
596
- user: userRef.current
597
- }),
598
- pluginCtx: buildPluginContext({
599
644
  courseId: cid,
600
- sessionId,
601
- attemptId: attemptIdRef.current
602
- }),
603
- lxpackBridge: lxpackBridgeModeRef.current
604
- });
605
- courseStartedEmittedToSinkRef.current = true;
606
- }
645
+ attemptId: attemptIdRef.current,
646
+ user: userRef.current,
647
+ lxpackBridge: lxpackBridgeModeRef.current
648
+ });
649
+ courseStartedEmittedToSinkRef.current = emitted;
650
+ }
651
+ })();
607
652
  }, [config.courseId, config.tracking?.enabled, syncProgress]);
608
653
  const emitLessonCompleted = (0, import_react.useCallback)(
609
654
  (lessonId, durationMs) => {
@@ -620,25 +665,28 @@ function LessonkitProvider(props) {
620
665
  if (!result.didComplete) return;
621
666
  syncProgress();
622
667
  emitLessonCompleted(lessonId, result.durationMs);
623
- void trackingRef.current?.flush?.();
668
+ void Promise.resolve(trackingRef.current?.flush?.());
624
669
  },
625
670
  [syncProgress, emitLessonCompleted]
626
671
  );
627
- const unmountTimerIdsRef = (0, import_react.useRef)([]);
628
672
  (0, import_react.useEffect)(() => {
629
673
  return () => {
630
- for (const id of unmountTimerIdsRef.current) clearTimeout(id);
631
- unmountTimerIdsRef.current = [];
632
674
  const client = trackingClientForUnmountRef.current;
633
- void xapiRef.current?.flush();
634
- const flushTimer = setTimeout(() => {
635
- client?.flush?.();
636
- const disposeTimer = setTimeout(() => {
637
- client?.dispose?.();
638
- }, 0);
639
- unmountTimerIdsRef.current.push(disposeTimer);
640
- }, 0);
641
- unmountTimerIdsRef.current.push(flushTimer);
675
+ const xapi2 = xapiRef.current;
676
+ void (async () => {
677
+ try {
678
+ await xapi2?.flush();
679
+ } catch {
680
+ }
681
+ try {
682
+ await client?.flush?.();
683
+ } catch {
684
+ }
685
+ try {
686
+ await client?.dispose?.();
687
+ } catch {
688
+ }
689
+ })();
642
690
  };
643
691
  }, []);
644
692
  const setActiveLesson = (0, import_react.useCallback)(
@@ -650,6 +698,7 @@ function LessonkitProvider(props) {
650
698
  const completed = progressRef.current.completeLesson(previous, Date.now());
651
699
  if (completed.didComplete) {
652
700
  emitLessonCompleted(previous, completed.durationMs);
701
+ void Promise.resolve(trackingRef.current?.flush?.());
653
702
  }
654
703
  }
655
704
  progressRef.current.setActiveLesson(lessonId, Date.now());
@@ -659,12 +708,19 @@ function LessonkitProvider(props) {
659
708
  [track, syncProgress, emitLessonCompleted]
660
709
  );
661
710
  const completeCourse = (0, import_react.useCallback)(() => {
711
+ const current = progressRef.current.getState();
712
+ if (current.activeLessonId) {
713
+ const lessonResult = progressRef.current.completeLesson(current.activeLessonId, Date.now());
714
+ if (lessonResult.didComplete) {
715
+ emitLessonCompleted(current.activeLessonId, lessonResult.durationMs);
716
+ }
717
+ }
662
718
  const result = progressRef.current.completeCourse();
663
719
  if (!result.didComplete) return;
664
720
  syncProgress();
665
721
  track("course_completed");
666
722
  void trackingRef.current?.flush?.();
667
- }, [track, syncProgress]);
723
+ }, [track, syncProgress, emitLessonCompleted]);
668
724
  const sessionUser = config.session?.user;
669
725
  const sessionAttemptId = config.session?.attemptId;
670
726
  const sessionConfiguredId = config.session?.sessionId;
@@ -847,20 +903,32 @@ function KnowledgeCheck(props) {
847
903
  checkId: props.checkId,
848
904
  question: props.question,
849
905
  choices: props.choices,
850
- answer: props.answer
906
+ answer: props.answer,
907
+ passingScore: props.passingScore
851
908
  }
852
909
  );
853
910
  }
854
911
  function Quiz(props) {
855
912
  warnInvalidComponentId(props.checkId, "checkId");
856
913
  const quiz = useQuizState();
914
+ const { plugins, config, progress, session } = useLessonkit();
857
915
  const [selected, setSelected] = (0, import_react3.useState)(null);
916
+ const [selectionCorrect, setSelectionCorrect] = (0, import_react3.useState)(null);
858
917
  const completedRef = (0, import_react3.useRef)(false);
859
918
  const questionId = (0, import_react3.useId)();
860
919
  (0, import_react3.useEffect)(() => {
861
920
  completedRef.current = false;
862
921
  setSelected(null);
863
- }, [props.checkId, props.answer, props.question]);
922
+ setSelectionCorrect(null);
923
+ }, [props.checkId, props.answer, props.question, config.courseId]);
924
+ const isChoiceCorrect = (choice, custom) => {
925
+ if (!custom) return choice === props.answer;
926
+ if (custom.passed !== void 0) return custom.passed;
927
+ if (custom.maxScore != null && custom.maxScore > 0) {
928
+ return custom.score / custom.maxScore >= 1;
929
+ }
930
+ return choice === props.answer;
931
+ };
864
932
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
865
933
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
866
934
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
@@ -875,7 +943,21 @@ function Quiz(props) {
875
943
  checked: selected === c,
876
944
  onChange: () => {
877
945
  setSelected(c);
878
- const correct = c === props.answer;
946
+ const pluginCtx = buildPluginContext({
947
+ courseId: config.courseId,
948
+ sessionId: session.sessionId,
949
+ attemptId: session.attemptId
950
+ });
951
+ const custom = plugins?.scoreAssessment(
952
+ {
953
+ checkId: props.checkId,
954
+ lessonId: progress.activeLessonId,
955
+ response: c
956
+ },
957
+ pluginCtx
958
+ ) ?? null;
959
+ const correct = isChoiceCorrect(c, custom);
960
+ setSelectionCorrect(correct);
879
961
  quiz.answer({
880
962
  checkId: props.checkId,
881
963
  question: props.question,
@@ -884,7 +966,12 @@ function Quiz(props) {
884
966
  });
885
967
  if (correct && !completedRef.current) {
886
968
  completedRef.current = true;
887
- quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1, passingScore: 1 });
969
+ quiz.complete({
970
+ checkId: props.checkId,
971
+ score: custom?.score ?? 1,
972
+ maxScore: custom?.maxScore ?? 1,
973
+ passingScore: props.passingScore ?? 1
974
+ });
888
975
  }
889
976
  }
890
977
  }
@@ -892,7 +979,7 @@ function Quiz(props) {
892
979
  c
893
980
  ] }, `${questionId}-${i}`))
894
981
  ] }),
895
- selected ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { role: "status", "aria-live": "polite", children: selected === props.answer ? "Correct" : "Try again" }) : null
982
+ selected && selectionCorrect !== null ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null
896
983
  ] });
897
984
  }
898
985
  function ProgressTracker() {
package/dist/index.d.cts CHANGED
@@ -91,12 +91,14 @@ declare function KnowledgeCheck(props: {
91
91
  question: string;
92
92
  choices: string[];
93
93
  answer: string;
94
+ passingScore?: number;
94
95
  }): react_jsx_runtime.JSX.Element;
95
96
  declare function Quiz(props: {
96
97
  checkId: CheckId;
97
98
  question: string;
98
99
  choices: string[];
99
100
  answer: string;
101
+ passingScore?: number;
100
102
  }): react_jsx_runtime.JSX.Element;
101
103
  declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
102
104
 
package/dist/index.d.ts CHANGED
@@ -91,12 +91,14 @@ declare function KnowledgeCheck(props: {
91
91
  question: string;
92
92
  choices: string[];
93
93
  answer: string;
94
+ passingScore?: number;
94
95
  }): react_jsx_runtime.JSX.Element;
95
96
  declare function Quiz(props: {
96
97
  checkId: CheckId;
97
98
  question: string;
98
99
  choices: string[];
99
100
  answer: string;
101
+ passingScore?: number;
100
102
  }): react_jsx_runtime.JSX.Element;
101
103
  declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
102
104
 
package/dist/index.js CHANGED
@@ -327,9 +327,15 @@ function createTrackingClientFromConfig(config) {
327
327
  batch: config.tracking?.batch
328
328
  });
329
329
  }
330
- function disposeTrackingClient(client) {
331
- client?.flush?.();
332
- client?.dispose?.();
330
+ async function disposeTrackingClient(client) {
331
+ try {
332
+ await client?.flush?.();
333
+ } catch {
334
+ }
335
+ try {
336
+ await client?.dispose?.();
337
+ } catch {
338
+ }
333
339
  }
334
340
 
335
341
  // src/context.tsx
@@ -340,6 +346,33 @@ var defaultStorage = createSessionStoragePort();
340
346
  function isTrackingActive(tracking) {
341
347
  return tracking?.enabled !== false;
342
348
  }
349
+ function emitCourseStarted(opts) {
350
+ const pluginCtx = buildPluginContext({
351
+ courseId: opts.courseId,
352
+ sessionId: opts.sessionId,
353
+ attemptId: opts.attemptId
354
+ });
355
+ try {
356
+ emitTelemetryWithPlugins({
357
+ pluginHost: opts.pluginHost,
358
+ tracking: opts.tracking,
359
+ xapi: opts.xapi,
360
+ event: buildTrackEvent({
361
+ name: "course_started",
362
+ courseId: opts.courseId,
363
+ sessionId: opts.sessionId,
364
+ attemptId: opts.attemptId,
365
+ user: opts.user
366
+ }),
367
+ pluginCtx,
368
+ lxpackBridge: opts.lxpackBridge
369
+ });
370
+ markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
371
+ return true;
372
+ } catch {
373
+ return false;
374
+ }
375
+ }
343
376
  function LessonkitProvider(props) {
344
377
  const config = props.config;
345
378
  const sessionIdRef = useRef(resolveSessionId(defaultStorage, config.session?.sessionId));
@@ -386,7 +419,17 @@ function LessonkitProvider(props) {
386
419
  const courseId = config.courseId;
387
420
  const trackingEnabled = config.tracking?.enabled;
388
421
  useIsoLayoutEffect(() => {
389
- if (prevXapiCourseIdRef.current !== courseId) {
422
+ const courseChanged = prevXapiCourseIdRef.current !== courseId;
423
+ if (courseChanged) {
424
+ if (config.xapi?.client) {
425
+ const g = globalThis;
426
+ if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production") {
427
+ console.warn(
428
+ "[lessonkit] courseId changed while using config.xapi.client; flush the client between courses or use config.xapi.transport so the provider can manage the queue."
429
+ );
430
+ }
431
+ void xapiRef.current?.flush();
432
+ }
390
433
  xapiQueueRef.current = createInMemoryXAPIQueue();
391
434
  prevXapiCourseIdRef.current = courseId;
392
435
  }
@@ -410,7 +453,10 @@ function LessonkitProvider(props) {
410
453
  user: userRef.current
411
454
  })
412
455
  );
413
- if (statement) next.send(statement);
456
+ if (statement) {
457
+ next.send(statement);
458
+ markCourseStarted(defaultStorage, sessionId, cid);
459
+ }
414
460
  } catch {
415
461
  }
416
462
  }
@@ -442,17 +488,24 @@ function LessonkitProvider(props) {
442
488
  const batchEnabled = config.tracking?.batch?.enabled;
443
489
  const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
444
490
  const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
445
- useIsoLayoutEffect(() => {
446
- const prev = trackingRef.current;
447
- const pluginCtx = buildPluginContext({
491
+ const buildCurrentPluginCtx = useCallback(
492
+ () => buildPluginContext({
448
493
  courseId: courseIdRef.current,
449
494
  sessionId: sessionIdRef.current,
450
495
  attemptId: attemptIdRef.current
451
- });
452
- const sink = pluginHostRef.current?.composeTrackingSink(config.tracking?.sink, pluginCtx) ?? config.tracking?.sink;
496
+ }),
497
+ []
498
+ );
499
+ useIsoLayoutEffect(() => {
500
+ const prev = trackingRef.current;
501
+ const baseSink = config.tracking?.sink;
502
+ const sink = pluginHostRef.current && baseSink ? pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink : baseSink;
453
503
  const batchSink = pluginHostRef.current && config.tracking?.batchSink ? (events) => {
454
- const filtered = pluginHostRef.current.runTelemetryBatch(events, pluginCtx);
455
- return config.tracking.batchSink(filtered);
504
+ const delivered = pluginHostRef.current.deliverTelemetryBatch(
505
+ events,
506
+ buildCurrentPluginCtx()
507
+ );
508
+ return config.tracking.batchSink(delivered);
456
509
  } : config.tracking?.batchSink;
457
510
  const next = createTrackingClientFromConfig({
458
511
  tracking: { ...config.tracking, sink, batchSink }
@@ -466,32 +519,24 @@ function LessonkitProvider(props) {
466
519
  if (!trackingActive) {
467
520
  courseStartedEmittedToSinkRef.current = false;
468
521
  } else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
469
- markCourseStarted(defaultStorage, sessionId, cid);
470
- emitTelemetryWithPlugins({
522
+ const emitted = emitCourseStarted({
471
523
  pluginHost: pluginHostRef.current,
472
524
  tracking: next,
473
525
  xapi: xapiRef.current,
474
- event: buildTrackEvent({
475
- name: "course_started",
476
- courseId: cid,
477
- sessionId,
478
- attemptId: attemptIdRef.current,
479
- user: userRef.current
480
- }),
481
- pluginCtx: buildPluginContext({
482
- courseId: cid,
483
- sessionId,
484
- attemptId: attemptIdRef.current
485
- }),
526
+ storage: defaultStorage,
527
+ sessionId,
528
+ courseId: cid,
529
+ attemptId: attemptIdRef.current,
530
+ user: userRef.current,
486
531
  lxpackBridge: lxpackBridgeModeRef.current
487
532
  });
488
- courseStartedEmittedToSinkRef.current = true;
533
+ courseStartedEmittedToSinkRef.current = emitted;
489
534
  } else if (trackingActive) {
490
535
  courseStartedEmittedToSinkRef.current = true;
491
536
  }
492
537
  return () => {
493
538
  if (prev !== trackingRef.current) {
494
- disposeTrackingClient(prev);
539
+ void disposeTrackingClient(prev);
495
540
  }
496
541
  };
497
542
  }, [
@@ -501,7 +546,9 @@ function LessonkitProvider(props) {
501
546
  batchEnabled,
502
547
  batchFlushIntervalMs,
503
548
  batchMaxBatchSize,
504
- config.plugins
549
+ config.plugins,
550
+ config.courseId,
551
+ buildCurrentPluginCtx
505
552
  ]);
506
553
  const emitWithBridge = useCallback((trackingClient, event) => {
507
554
  emitTelemetryWithPlugins({
@@ -540,28 +587,26 @@ function LessonkitProvider(props) {
540
587
  if (!isTrackingActive(config.tracking)) return;
541
588
  const sessionId = sessionIdRef.current;
542
589
  const cid = courseIdRef.current;
543
- if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
544
- markCourseStarted(defaultStorage, sessionId, cid);
545
- emitTelemetryWithPlugins({
546
- pluginHost: pluginHostRef.current,
547
- tracking: trackingRef.current,
548
- xapi: xapiRef.current,
549
- event: buildTrackEvent({
550
- name: "course_started",
551
- courseId: cid,
590
+ void (async () => {
591
+ try {
592
+ await trackingRef.current?.flush?.();
593
+ } catch {
594
+ }
595
+ if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
596
+ const emitted = emitCourseStarted({
597
+ pluginHost: pluginHostRef.current,
598
+ tracking: trackingRef.current,
599
+ xapi: xapiRef.current,
600
+ storage: defaultStorage,
552
601
  sessionId,
553
- attemptId: attemptIdRef.current,
554
- user: userRef.current
555
- }),
556
- pluginCtx: buildPluginContext({
557
602
  courseId: cid,
558
- sessionId,
559
- attemptId: attemptIdRef.current
560
- }),
561
- lxpackBridge: lxpackBridgeModeRef.current
562
- });
563
- courseStartedEmittedToSinkRef.current = true;
564
- }
603
+ attemptId: attemptIdRef.current,
604
+ user: userRef.current,
605
+ lxpackBridge: lxpackBridgeModeRef.current
606
+ });
607
+ courseStartedEmittedToSinkRef.current = emitted;
608
+ }
609
+ })();
565
610
  }, [config.courseId, config.tracking?.enabled, syncProgress]);
566
611
  const emitLessonCompleted = useCallback(
567
612
  (lessonId, durationMs) => {
@@ -578,25 +623,28 @@ function LessonkitProvider(props) {
578
623
  if (!result.didComplete) return;
579
624
  syncProgress();
580
625
  emitLessonCompleted(lessonId, result.durationMs);
581
- void trackingRef.current?.flush?.();
626
+ void Promise.resolve(trackingRef.current?.flush?.());
582
627
  },
583
628
  [syncProgress, emitLessonCompleted]
584
629
  );
585
- const unmountTimerIdsRef = useRef([]);
586
630
  useEffect(() => {
587
631
  return () => {
588
- for (const id of unmountTimerIdsRef.current) clearTimeout(id);
589
- unmountTimerIdsRef.current = [];
590
632
  const client = trackingClientForUnmountRef.current;
591
- void xapiRef.current?.flush();
592
- const flushTimer = setTimeout(() => {
593
- client?.flush?.();
594
- const disposeTimer = setTimeout(() => {
595
- client?.dispose?.();
596
- }, 0);
597
- unmountTimerIdsRef.current.push(disposeTimer);
598
- }, 0);
599
- unmountTimerIdsRef.current.push(flushTimer);
633
+ const xapi2 = xapiRef.current;
634
+ void (async () => {
635
+ try {
636
+ await xapi2?.flush();
637
+ } catch {
638
+ }
639
+ try {
640
+ await client?.flush?.();
641
+ } catch {
642
+ }
643
+ try {
644
+ await client?.dispose?.();
645
+ } catch {
646
+ }
647
+ })();
600
648
  };
601
649
  }, []);
602
650
  const setActiveLesson = useCallback(
@@ -608,6 +656,7 @@ function LessonkitProvider(props) {
608
656
  const completed = progressRef.current.completeLesson(previous, Date.now());
609
657
  if (completed.didComplete) {
610
658
  emitLessonCompleted(previous, completed.durationMs);
659
+ void Promise.resolve(trackingRef.current?.flush?.());
611
660
  }
612
661
  }
613
662
  progressRef.current.setActiveLesson(lessonId, Date.now());
@@ -617,12 +666,19 @@ function LessonkitProvider(props) {
617
666
  [track, syncProgress, emitLessonCompleted]
618
667
  );
619
668
  const completeCourse = useCallback(() => {
669
+ const current = progressRef.current.getState();
670
+ if (current.activeLessonId) {
671
+ const lessonResult = progressRef.current.completeLesson(current.activeLessonId, Date.now());
672
+ if (lessonResult.didComplete) {
673
+ emitLessonCompleted(current.activeLessonId, lessonResult.durationMs);
674
+ }
675
+ }
620
676
  const result = progressRef.current.completeCourse();
621
677
  if (!result.didComplete) return;
622
678
  syncProgress();
623
679
  track("course_completed");
624
680
  void trackingRef.current?.flush?.();
625
- }, [track, syncProgress]);
681
+ }, [track, syncProgress, emitLessonCompleted]);
626
682
  const sessionUser = config.session?.user;
627
683
  const sessionAttemptId = config.session?.attemptId;
628
684
  const sessionConfiguredId = config.session?.sessionId;
@@ -805,20 +861,32 @@ function KnowledgeCheck(props) {
805
861
  checkId: props.checkId,
806
862
  question: props.question,
807
863
  choices: props.choices,
808
- answer: props.answer
864
+ answer: props.answer,
865
+ passingScore: props.passingScore
809
866
  }
810
867
  );
811
868
  }
812
869
  function Quiz(props) {
813
870
  warnInvalidComponentId(props.checkId, "checkId");
814
871
  const quiz = useQuizState();
872
+ const { plugins, config, progress, session } = useLessonkit();
815
873
  const [selected, setSelected] = useState2(null);
874
+ const [selectionCorrect, setSelectionCorrect] = useState2(null);
816
875
  const completedRef = useRef2(false);
817
876
  const questionId = useId();
818
877
  useEffect2(() => {
819
878
  completedRef.current = false;
820
879
  setSelected(null);
821
- }, [props.checkId, props.answer, props.question]);
880
+ setSelectionCorrect(null);
881
+ }, [props.checkId, props.answer, props.question, config.courseId]);
882
+ const isChoiceCorrect = (choice, custom) => {
883
+ if (!custom) return choice === props.answer;
884
+ if (custom.passed !== void 0) return custom.passed;
885
+ if (custom.maxScore != null && custom.maxScore > 0) {
886
+ return custom.score / custom.maxScore >= 1;
887
+ }
888
+ return choice === props.answer;
889
+ };
822
890
  return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
823
891
  /* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
824
892
  /* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
@@ -833,7 +901,21 @@ function Quiz(props) {
833
901
  checked: selected === c,
834
902
  onChange: () => {
835
903
  setSelected(c);
836
- const correct = c === props.answer;
904
+ const pluginCtx = buildPluginContext({
905
+ courseId: config.courseId,
906
+ sessionId: session.sessionId,
907
+ attemptId: session.attemptId
908
+ });
909
+ const custom = plugins?.scoreAssessment(
910
+ {
911
+ checkId: props.checkId,
912
+ lessonId: progress.activeLessonId,
913
+ response: c
914
+ },
915
+ pluginCtx
916
+ ) ?? null;
917
+ const correct = isChoiceCorrect(c, custom);
918
+ setSelectionCorrect(correct);
837
919
  quiz.answer({
838
920
  checkId: props.checkId,
839
921
  question: props.question,
@@ -842,7 +924,12 @@ function Quiz(props) {
842
924
  });
843
925
  if (correct && !completedRef.current) {
844
926
  completedRef.current = true;
845
- quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1, passingScore: 1 });
927
+ quiz.complete({
928
+ checkId: props.checkId,
929
+ score: custom?.score ?? 1,
930
+ maxScore: custom?.maxScore ?? 1,
931
+ passingScore: props.passingScore ?? 1
932
+ });
846
933
  }
847
934
  }
848
935
  }
@@ -850,7 +937,7 @@ function Quiz(props) {
850
937
  c
851
938
  ] }, `${questionId}-${i}`))
852
939
  ] }),
853
- selected ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: selected === props.answer ? "Correct" : "Try again" }) : null
940
+ selected && selectionCorrect !== null ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null
854
941
  ] });
855
942
  }
856
943
  function ProgressTracker() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/react",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "private": false,
5
5
  "description": "React components and hooks for building learning experiences with LessonKit.",
6
6
  "license": "Apache-2.0",
@@ -54,11 +54,11 @@
54
54
  "react-dom": ">=18"
55
55
  },
56
56
  "dependencies": {
57
- "@lessonkit/accessibility": "0.9.1",
58
- "@lessonkit/core": "0.9.1",
59
- "@lessonkit/lxpack": "0.9.1",
60
- "@lessonkit/themes": "0.9.1",
61
- "@lessonkit/xapi": "0.9.1"
57
+ "@lessonkit/accessibility": "0.9.3",
58
+ "@lessonkit/core": "0.9.3",
59
+ "@lessonkit/lxpack": "0.9.3",
60
+ "@lessonkit/themes": "0.9.3",
61
+ "@lessonkit/xapi": "0.9.3"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@testing-library/react": "^16.3.0",