@lessonkit/react 0.8.0 → 0.8.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/README.md CHANGED
@@ -59,7 +59,7 @@ export default function App() {
59
59
  }
60
60
  ```
61
61
 
62
- ## API (0.8.0)
62
+ ## API (0.8.1)
63
63
 
64
64
  ### Block catalog
65
65
 
package/dist/index.cjs CHANGED
@@ -58,7 +58,7 @@ var import_accessibility = require("@lessonkit/accessibility");
58
58
 
59
59
  // src/context.tsx
60
60
  var import_react = require("react");
61
- var import_core3 = require("@lessonkit/core");
61
+ var import_core4 = require("@lessonkit/core");
62
62
  var import_xapi3 = require("@lessonkit/xapi");
63
63
  var import_xapi4 = require("@lessonkit/xapi");
64
64
 
@@ -98,7 +98,10 @@ function forwardTelemetryToLxpack(event, mode = "auto") {
98
98
  bridge.submitAssessment?.({
99
99
  id: data.checkId,
100
100
  score: scaled,
101
- passingScore: (0, import_bridge.normalizeAssessmentPassingScore)(data.passingScore)
101
+ passingScore: (0, import_bridge.normalizeAssessmentPassingScore)({
102
+ passingScore: data.passingScore,
103
+ maxScore: data.maxScore
104
+ })
102
105
  });
103
106
  return;
104
107
  }
@@ -229,6 +232,12 @@ function createSessionStoragePort() {
229
232
  sessionStorage.setItem(key, value);
230
233
  } catch {
231
234
  }
235
+ },
236
+ removeItem: (key) => {
237
+ try {
238
+ sessionStorage.removeItem(key);
239
+ } catch {
240
+ }
232
241
  }
233
242
  };
234
243
  }
@@ -276,6 +285,8 @@ function createXapiClientFromConfig(config, queue) {
276
285
  if (config.xapi?.enabled === false) return null;
277
286
  if (config.xapi?.client) return config.xapi.client;
278
287
  if (!config.courseId) return null;
288
+ const hasTransport = typeof config.xapi?.transport === "function";
289
+ if (!hasTransport && config.xapi?.enabled !== true) return null;
279
290
  return (0, import_xapi2.createXAPIClient)({
280
291
  courseId: config.courseId,
281
292
  transport: config.xapi?.transport,
@@ -286,6 +297,9 @@ function createXapiClientFromConfig(config, queue) {
286
297
  // src/runtime/session.ts
287
298
  var import_core2 = require("@lessonkit/core");
288
299
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
300
+ function getTabSessionId(storage) {
301
+ return storage.getItem(SESSION_STORAGE_KEY);
302
+ }
289
303
  var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
290
304
  function resolveSessionId(storage, provided) {
291
305
  if (provided) return provided;
@@ -306,30 +320,47 @@ function markCourseStarted(storage, sessionId, courseId) {
306
320
  if (!courseId) return;
307
321
  storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
308
322
  }
309
-
310
- // src/context.tsx
311
- var import_jsx_runtime = require("react/jsx-runtime");
312
- var LessonkitContext = (0, import_react.createContext)(null);
313
- var useIsoLayoutEffect = typeof window !== "undefined" ? import_react.useLayoutEffect : import_react.useEffect;
314
- function disposeTrackingClient(client) {
315
- client?.flush?.();
316
- client?.dispose?.();
323
+ function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
324
+ if (!courseId || fromSessionId === toSessionId) return;
325
+ if (hasCourseStarted(storage, fromSessionId, courseId)) {
326
+ markCourseStarted(storage, toSessionId, courseId);
327
+ storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
328
+ }
317
329
  }
318
- var defaultStorage = createSessionStoragePort();
330
+
331
+ // src/runtime/telemetry.ts
332
+ var import_core3 = require("@lessonkit/core");
319
333
  function createTrackingClientFromConfig(config) {
320
- if (config.tracking?.enabled === false) {
321
- return (0, import_core3.createTrackingClient)();
322
- }
334
+ if (config.tracking?.enabled === false) return (0, import_core3.createTrackingClient)();
335
+ if (config.tracking?.createClient) return config.tracking.createClient();
323
336
  return (0, import_core3.createTrackingClient)({
324
337
  sink: config.tracking?.sink,
325
338
  batchSink: config.tracking?.batchSink,
326
339
  batch: config.tracking?.batch
327
340
  });
328
341
  }
342
+ function disposeTrackingClient(client) {
343
+ client?.flush?.();
344
+ client?.dispose?.();
345
+ }
346
+
347
+ // src/context.tsx
348
+ var import_jsx_runtime = require("react/jsx-runtime");
349
+ var LessonkitContext = (0, import_react.createContext)(null);
350
+ var useIsoLayoutEffect = typeof window !== "undefined" ? import_react.useLayoutEffect : import_react.useEffect;
351
+ var defaultStorage = createSessionStoragePort();
352
+ function isTrackingActive(tracking) {
353
+ return tracking?.enabled !== false;
354
+ }
329
355
  function LessonkitProvider(props) {
330
356
  const config = props.config;
331
357
  const sessionIdRef = (0, import_react.useRef)(resolveSessionId(defaultStorage, config.session?.sessionId));
332
- if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
358
+ const prevConfiguredSessionIdRef = (0, import_react.useRef)(config.session?.sessionId);
359
+ if (config.session?.sessionId) {
360
+ sessionIdRef.current = config.session.sessionId;
361
+ } else if (prevConfiguredSessionIdRef.current) {
362
+ sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
363
+ }
333
364
  const attemptIdRef = (0, import_react.useRef)(config.session?.attemptId);
334
365
  const userRef = (0, import_react.useRef)(config.session?.user);
335
366
  attemptIdRef.current = config.session?.attemptId;
@@ -339,6 +370,15 @@ function LessonkitProvider(props) {
339
370
  const lxpackBridgeModeRef = (0, import_react.useRef)(config.lxpack?.bridge ?? "auto");
340
371
  lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
341
372
  const progressRef = (0, import_react.useRef)(createProgressController());
373
+ const courseStartedEmittedToSinkRef = (0, import_react.useRef)(false);
374
+ const prevCourseIdForProgressRef = (0, import_react.useRef)(config.courseId);
375
+ const pendingCourseIdResetRef = (0, import_react.useRef)(false);
376
+ if (prevCourseIdForProgressRef.current !== config.courseId) {
377
+ prevCourseIdForProgressRef.current = config.courseId;
378
+ progressRef.current = createProgressController();
379
+ pendingCourseIdResetRef.current = true;
380
+ courseStartedEmittedToSinkRef.current = false;
381
+ }
342
382
  const [progress, setProgress] = (0, import_react.useState)(() => progressRef.current.getState());
343
383
  const syncProgress = (0, import_react.useCallback)(() => {
344
384
  setProgress(progressRef.current.getState());
@@ -348,11 +388,16 @@ function LessonkitProvider(props) {
348
388
  const xapiQueueRef = (0, import_react.useRef)((0, import_xapi3.createInMemoryXAPIQueue)());
349
389
  const xapiRef = (0, import_react.useRef)(null);
350
390
  const [xapi, setXapi] = (0, import_react.useState)(null);
391
+ const prevXapiCourseIdRef = (0, import_react.useRef)(config.courseId);
351
392
  const xapiEnabled = config.xapi?.enabled;
352
393
  const xapiClient = config.xapi?.client;
353
394
  const xapiTransport = config.xapi?.transport;
354
395
  const courseId = config.courseId;
355
396
  useIsoLayoutEffect(() => {
397
+ if (prevXapiCourseIdRef.current !== courseId) {
398
+ xapiQueueRef.current = (0, import_xapi3.createInMemoryXAPIQueue)();
399
+ prevXapiCourseIdRef.current = courseId;
400
+ }
356
401
  const prev = xapiRef.current;
357
402
  const next = createXapiClientFromConfig(config, xapiQueueRef.current);
358
403
  xapiRef.current = next;
@@ -360,22 +405,21 @@ function LessonkitProvider(props) {
360
405
  if (next && !prev) {
361
406
  const sessionId = sessionIdRef.current;
362
407
  const cid = courseIdRef.current;
363
- if (hasCourseStarted(defaultStorage, sessionId, cid)) {
364
- try {
365
- const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(
366
- buildTrackEvent({
367
- name: "course_started",
368
- courseId: cid,
369
- sessionId,
370
- attemptId: attemptIdRef.current,
371
- user: userRef.current
372
- })
373
- );
374
- if (statement) next.send(statement);
375
- } catch {
376
- }
408
+ try {
409
+ const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(
410
+ buildTrackEvent({
411
+ name: "course_started",
412
+ courseId: cid,
413
+ sessionId,
414
+ attemptId: attemptIdRef.current,
415
+ user: userRef.current
416
+ })
417
+ );
418
+ if (statement) next.send(statement);
419
+ } catch {
377
420
  }
378
421
  }
422
+ let cancelled = false;
379
423
  void (async () => {
380
424
  if (prev) {
381
425
  try {
@@ -383,16 +427,19 @@ function LessonkitProvider(props) {
383
427
  } catch {
384
428
  }
385
429
  }
430
+ if (cancelled) return;
386
431
  try {
387
432
  await next?.flush();
388
433
  } catch {
389
434
  }
390
435
  })();
391
436
  return () => {
437
+ cancelled = true;
392
438
  void prev?.flush();
393
439
  };
394
440
  }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
395
- const trackingRef = (0, import_react.useRef)((0, import_core3.createTrackingClient)());
441
+ const trackingRef = (0, import_react.useRef)((0, import_core4.createTrackingClient)());
442
+ const trackingClientForUnmountRef = (0, import_react.useRef)(trackingRef.current);
396
443
  const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
397
444
  const trackingEnabled = config.tracking?.enabled;
398
445
  const trackingSink = config.tracking?.sink;
@@ -402,12 +449,16 @@ function LessonkitProvider(props) {
402
449
  const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
403
450
  useIsoLayoutEffect(() => {
404
451
  const prev = trackingRef.current;
405
- const next = createTrackingClientFromConfig(config);
452
+ const next = createTrackingClientFromConfig({ tracking: config.tracking });
406
453
  trackingRef.current = next;
454
+ trackingClientForUnmountRef.current = next;
407
455
  setTracking(next);
408
456
  const sessionId = sessionIdRef.current;
409
457
  const cid = courseIdRef.current;
410
- if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
458
+ const trackingActive = isTrackingActive(config.tracking);
459
+ if (!trackingActive) {
460
+ courseStartedEmittedToSinkRef.current = false;
461
+ } else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
411
462
  markCourseStarted(defaultStorage, sessionId, cid);
412
463
  emitTelemetry(
413
464
  next,
@@ -421,6 +472,9 @@ function LessonkitProvider(props) {
421
472
  }),
422
473
  { lxpackBridge: lxpackBridgeModeRef.current }
423
474
  );
475
+ courseStartedEmittedToSinkRef.current = true;
476
+ } else if (trackingActive) {
477
+ courseStartedEmittedToSinkRef.current = true;
424
478
  }
425
479
  return () => {
426
480
  if (prev !== trackingRef.current) {
@@ -459,21 +513,14 @@ function LessonkitProvider(props) {
459
513
  },
460
514
  [emitWithBridge]
461
515
  );
462
- const prevCourseIdRef = (0, import_react.useRef)(config.courseId);
463
- (0, import_react.useEffect)(() => {
464
- if (prevCourseIdRef.current === config.courseId) return;
465
- const previousActiveLesson = progressRef.current.getState().activeLessonId;
466
- prevCourseIdRef.current = config.courseId;
467
- progressRef.current = createProgressController();
516
+ (0, import_react.useLayoutEffect)(() => {
517
+ if (!pendingCourseIdResetRef.current) return;
518
+ pendingCourseIdResetRef.current = false;
468
519
  syncProgress();
469
- if (previousActiveLesson) {
470
- progressRef.current.setActiveLesson(previousActiveLesson, Date.now());
471
- syncProgress();
472
- track("lesson_started", { lessonId: previousActiveLesson }, { lessonId: previousActiveLesson });
473
- }
520
+ if (!isTrackingActive(config.tracking)) return;
474
521
  const sessionId = sessionIdRef.current;
475
- const cid = config.courseId;
476
- if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
522
+ const cid = courseIdRef.current;
523
+ if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
477
524
  markCourseStarted(defaultStorage, sessionId, cid);
478
525
  emitTelemetry(
479
526
  trackingRef.current,
@@ -487,8 +534,9 @@ function LessonkitProvider(props) {
487
534
  }),
488
535
  { lxpackBridge: lxpackBridgeModeRef.current }
489
536
  );
537
+ courseStartedEmittedToSinkRef.current = true;
490
538
  }
491
- }, [config.courseId, syncProgress, track]);
539
+ }, [config.courseId, config.tracking?.enabled, syncProgress]);
492
540
  const emitLessonCompleted = (0, import_react.useCallback)(
493
541
  (lessonId, durationMs) => {
494
542
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
@@ -508,16 +556,21 @@ function LessonkitProvider(props) {
508
556
  },
509
557
  [syncProgress, emitLessonCompleted]
510
558
  );
559
+ const unmountTimerIdsRef = (0, import_react.useRef)([]);
511
560
  (0, import_react.useEffect)(() => {
512
561
  return () => {
513
- const client = trackingRef.current;
562
+ for (const id of unmountTimerIdsRef.current) clearTimeout(id);
563
+ unmountTimerIdsRef.current = [];
564
+ const client = trackingClientForUnmountRef.current;
514
565
  void xapiRef.current?.flush();
515
- setTimeout(() => {
566
+ const flushTimer = setTimeout(() => {
516
567
  client?.flush?.();
517
- setTimeout(() => {
568
+ const disposeTimer = setTimeout(() => {
518
569
  client?.dispose?.();
519
570
  }, 0);
571
+ unmountTimerIdsRef.current.push(disposeTimer);
520
572
  }, 0);
573
+ unmountTimerIdsRef.current.push(flushTimer);
521
574
  };
522
575
  }, []);
523
576
  const setActiveLesson = (0, import_react.useCallback)(
@@ -542,10 +595,34 @@ function LessonkitProvider(props) {
542
595
  if (!result.didComplete) return;
543
596
  syncProgress();
544
597
  track("course_completed");
598
+ void trackingRef.current?.flush?.();
545
599
  }, [track, syncProgress]);
546
600
  const sessionUser = config.session?.user;
547
601
  const sessionAttemptId = config.session?.attemptId;
548
602
  const sessionConfiguredId = config.session?.sessionId;
603
+ (0, import_react.useEffect)(() => {
604
+ const nextConfigured = config.session?.sessionId;
605
+ const prevConfigured = prevConfiguredSessionIdRef.current;
606
+ if (nextConfigured === prevConfigured) return;
607
+ prevConfiguredSessionIdRef.current = nextConfigured;
608
+ const cid = courseIdRef.current;
609
+ if (nextConfigured) {
610
+ const fromIds = /* @__PURE__ */ new Set();
611
+ if (prevConfigured) fromIds.add(prevConfigured);
612
+ const tabId = getTabSessionId(defaultStorage);
613
+ if (tabId) fromIds.add(tabId);
614
+ for (const fromId of fromIds) {
615
+ if (fromId !== nextConfigured) {
616
+ migrateCourseStartedMark(defaultStorage, fromId, nextConfigured, cid);
617
+ }
618
+ }
619
+ sessionIdRef.current = nextConfigured;
620
+ } else if (prevConfigured) {
621
+ const nextAuto = resolveSessionId(defaultStorage, void 0);
622
+ migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
623
+ sessionIdRef.current = nextAuto;
624
+ }
625
+ }, [sessionConfiguredId, config.courseId]);
549
626
  const runtime = (0, import_react.useMemo)(
550
627
  () => ({
551
628
  config,
@@ -610,7 +687,7 @@ function useQuizState() {
610
687
  }
611
688
 
612
689
  // src/runtime/validateComponentId.ts
613
- var import_core4 = require("@lessonkit/core");
690
+ var import_core5 = require("@lessonkit/core");
614
691
  var warnedPaths = /* @__PURE__ */ new Set();
615
692
  function isDevEnvironment2() {
616
693
  const g = globalThis;
@@ -620,7 +697,7 @@ function warnInvalidComponentId(id, path) {
620
697
  if (!isDevEnvironment2()) return;
621
698
  const key = `${path}:${String(id)}`;
622
699
  if (warnedPaths.has(key)) return;
623
- const result = (0, import_core4.validateId)(id, path);
700
+ const result = (0, import_core5.validateId)(id, path);
624
701
  if (result.ok) return;
625
702
  warnedPaths.add(key);
626
703
  const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
@@ -698,6 +775,10 @@ function Quiz(props) {
698
775
  const [selected, setSelected] = (0, import_react3.useState)(null);
699
776
  const completedRef = (0, import_react3.useRef)(false);
700
777
  const questionId = (0, import_react3.useId)();
778
+ (0, import_react3.useEffect)(() => {
779
+ completedRef.current = false;
780
+ setSelected(null);
781
+ }, [props.checkId, props.answer, props.question]);
701
782
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
702
783
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
703
784
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  useRef,
13
13
  useState
14
14
  } from "react";
15
- import { createTrackingClient } from "@lessonkit/core";
15
+ import { createTrackingClient as createTrackingClient2 } from "@lessonkit/core";
16
16
  import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
17
17
  import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
18
18
 
@@ -55,7 +55,10 @@ function forwardTelemetryToLxpack(event, mode = "auto") {
55
55
  bridge.submitAssessment?.({
56
56
  id: data.checkId,
57
57
  score: scaled,
58
- passingScore: normalizeAssessmentPassingScore(data.passingScore)
58
+ passingScore: normalizeAssessmentPassingScore({
59
+ passingScore: data.passingScore,
60
+ maxScore: data.maxScore
61
+ })
59
62
  });
60
63
  return;
61
64
  }
@@ -186,6 +189,12 @@ function createSessionStoragePort() {
186
189
  sessionStorage.setItem(key, value);
187
190
  } catch {
188
191
  }
192
+ },
193
+ removeItem: (key) => {
194
+ try {
195
+ sessionStorage.removeItem(key);
196
+ } catch {
197
+ }
189
198
  }
190
199
  };
191
200
  }
@@ -233,6 +242,8 @@ function createXapiClientFromConfig(config, queue) {
233
242
  if (config.xapi?.enabled === false) return null;
234
243
  if (config.xapi?.client) return config.xapi.client;
235
244
  if (!config.courseId) return null;
245
+ const hasTransport = typeof config.xapi?.transport === "function";
246
+ if (!hasTransport && config.xapi?.enabled !== true) return null;
236
247
  return createXAPIClient({
237
248
  courseId: config.courseId,
238
249
  transport: config.xapi?.transport,
@@ -243,6 +254,9 @@ function createXapiClientFromConfig(config, queue) {
243
254
  // src/runtime/session.ts
244
255
  import { createSessionId } from "@lessonkit/core";
245
256
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
257
+ function getTabSessionId(storage) {
258
+ return storage.getItem(SESSION_STORAGE_KEY);
259
+ }
246
260
  var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
247
261
  function resolveSessionId(storage, provided) {
248
262
  if (provided) return provided;
@@ -263,30 +277,47 @@ function markCourseStarted(storage, sessionId, courseId) {
263
277
  if (!courseId) return;
264
278
  storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
265
279
  }
266
-
267
- // src/context.tsx
268
- import { jsx } from "react/jsx-runtime";
269
- var LessonkitContext = createContext(null);
270
- var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
271
- function disposeTrackingClient(client) {
272
- client?.flush?.();
273
- client?.dispose?.();
280
+ function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
281
+ if (!courseId || fromSessionId === toSessionId) return;
282
+ if (hasCourseStarted(storage, fromSessionId, courseId)) {
283
+ markCourseStarted(storage, toSessionId, courseId);
284
+ storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
285
+ }
274
286
  }
275
- var defaultStorage = createSessionStoragePort();
287
+
288
+ // src/runtime/telemetry.ts
289
+ import { createTrackingClient } from "@lessonkit/core";
276
290
  function createTrackingClientFromConfig(config) {
277
- if (config.tracking?.enabled === false) {
278
- return createTrackingClient();
279
- }
291
+ if (config.tracking?.enabled === false) return createTrackingClient();
292
+ if (config.tracking?.createClient) return config.tracking.createClient();
280
293
  return createTrackingClient({
281
294
  sink: config.tracking?.sink,
282
295
  batchSink: config.tracking?.batchSink,
283
296
  batch: config.tracking?.batch
284
297
  });
285
298
  }
299
+ function disposeTrackingClient(client) {
300
+ client?.flush?.();
301
+ client?.dispose?.();
302
+ }
303
+
304
+ // src/context.tsx
305
+ import { jsx } from "react/jsx-runtime";
306
+ var LessonkitContext = createContext(null);
307
+ var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
308
+ var defaultStorage = createSessionStoragePort();
309
+ function isTrackingActive(tracking) {
310
+ return tracking?.enabled !== false;
311
+ }
286
312
  function LessonkitProvider(props) {
287
313
  const config = props.config;
288
314
  const sessionIdRef = useRef(resolveSessionId(defaultStorage, config.session?.sessionId));
289
- if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
315
+ const prevConfiguredSessionIdRef = useRef(config.session?.sessionId);
316
+ if (config.session?.sessionId) {
317
+ sessionIdRef.current = config.session.sessionId;
318
+ } else if (prevConfiguredSessionIdRef.current) {
319
+ sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
320
+ }
290
321
  const attemptIdRef = useRef(config.session?.attemptId);
291
322
  const userRef = useRef(config.session?.user);
292
323
  attemptIdRef.current = config.session?.attemptId;
@@ -296,6 +327,15 @@ function LessonkitProvider(props) {
296
327
  const lxpackBridgeModeRef = useRef(config.lxpack?.bridge ?? "auto");
297
328
  lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
298
329
  const progressRef = useRef(createProgressController());
330
+ const courseStartedEmittedToSinkRef = useRef(false);
331
+ const prevCourseIdForProgressRef = useRef(config.courseId);
332
+ const pendingCourseIdResetRef = useRef(false);
333
+ if (prevCourseIdForProgressRef.current !== config.courseId) {
334
+ prevCourseIdForProgressRef.current = config.courseId;
335
+ progressRef.current = createProgressController();
336
+ pendingCourseIdResetRef.current = true;
337
+ courseStartedEmittedToSinkRef.current = false;
338
+ }
299
339
  const [progress, setProgress] = useState(() => progressRef.current.getState());
300
340
  const syncProgress = useCallback(() => {
301
341
  setProgress(progressRef.current.getState());
@@ -305,11 +345,16 @@ function LessonkitProvider(props) {
305
345
  const xapiQueueRef = useRef(createInMemoryXAPIQueue());
306
346
  const xapiRef = useRef(null);
307
347
  const [xapi, setXapi] = useState(null);
348
+ const prevXapiCourseIdRef = useRef(config.courseId);
308
349
  const xapiEnabled = config.xapi?.enabled;
309
350
  const xapiClient = config.xapi?.client;
310
351
  const xapiTransport = config.xapi?.transport;
311
352
  const courseId = config.courseId;
312
353
  useIsoLayoutEffect(() => {
354
+ if (prevXapiCourseIdRef.current !== courseId) {
355
+ xapiQueueRef.current = createInMemoryXAPIQueue();
356
+ prevXapiCourseIdRef.current = courseId;
357
+ }
313
358
  const prev = xapiRef.current;
314
359
  const next = createXapiClientFromConfig(config, xapiQueueRef.current);
315
360
  xapiRef.current = next;
@@ -317,22 +362,21 @@ function LessonkitProvider(props) {
317
362
  if (next && !prev) {
318
363
  const sessionId = sessionIdRef.current;
319
364
  const cid = courseIdRef.current;
320
- if (hasCourseStarted(defaultStorage, sessionId, cid)) {
321
- try {
322
- const statement = telemetryEventToXAPIStatement2(
323
- buildTrackEvent({
324
- name: "course_started",
325
- courseId: cid,
326
- sessionId,
327
- attemptId: attemptIdRef.current,
328
- user: userRef.current
329
- })
330
- );
331
- if (statement) next.send(statement);
332
- } catch {
333
- }
365
+ try {
366
+ const statement = telemetryEventToXAPIStatement2(
367
+ buildTrackEvent({
368
+ name: "course_started",
369
+ courseId: cid,
370
+ sessionId,
371
+ attemptId: attemptIdRef.current,
372
+ user: userRef.current
373
+ })
374
+ );
375
+ if (statement) next.send(statement);
376
+ } catch {
334
377
  }
335
378
  }
379
+ let cancelled = false;
336
380
  void (async () => {
337
381
  if (prev) {
338
382
  try {
@@ -340,16 +384,19 @@ function LessonkitProvider(props) {
340
384
  } catch {
341
385
  }
342
386
  }
387
+ if (cancelled) return;
343
388
  try {
344
389
  await next?.flush();
345
390
  } catch {
346
391
  }
347
392
  })();
348
393
  return () => {
394
+ cancelled = true;
349
395
  void prev?.flush();
350
396
  };
351
397
  }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
352
- const trackingRef = useRef(createTrackingClient());
398
+ const trackingRef = useRef(createTrackingClient2());
399
+ const trackingClientForUnmountRef = useRef(trackingRef.current);
353
400
  const [tracking, setTracking] = useState(() => trackingRef.current);
354
401
  const trackingEnabled = config.tracking?.enabled;
355
402
  const trackingSink = config.tracking?.sink;
@@ -359,12 +406,16 @@ function LessonkitProvider(props) {
359
406
  const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
360
407
  useIsoLayoutEffect(() => {
361
408
  const prev = trackingRef.current;
362
- const next = createTrackingClientFromConfig(config);
409
+ const next = createTrackingClientFromConfig({ tracking: config.tracking });
363
410
  trackingRef.current = next;
411
+ trackingClientForUnmountRef.current = next;
364
412
  setTracking(next);
365
413
  const sessionId = sessionIdRef.current;
366
414
  const cid = courseIdRef.current;
367
- if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
415
+ const trackingActive = isTrackingActive(config.tracking);
416
+ if (!trackingActive) {
417
+ courseStartedEmittedToSinkRef.current = false;
418
+ } else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
368
419
  markCourseStarted(defaultStorage, sessionId, cid);
369
420
  emitTelemetry(
370
421
  next,
@@ -378,6 +429,9 @@ function LessonkitProvider(props) {
378
429
  }),
379
430
  { lxpackBridge: lxpackBridgeModeRef.current }
380
431
  );
432
+ courseStartedEmittedToSinkRef.current = true;
433
+ } else if (trackingActive) {
434
+ courseStartedEmittedToSinkRef.current = true;
381
435
  }
382
436
  return () => {
383
437
  if (prev !== trackingRef.current) {
@@ -416,21 +470,14 @@ function LessonkitProvider(props) {
416
470
  },
417
471
  [emitWithBridge]
418
472
  );
419
- const prevCourseIdRef = useRef(config.courseId);
420
- useEffect(() => {
421
- if (prevCourseIdRef.current === config.courseId) return;
422
- const previousActiveLesson = progressRef.current.getState().activeLessonId;
423
- prevCourseIdRef.current = config.courseId;
424
- progressRef.current = createProgressController();
473
+ useLayoutEffect(() => {
474
+ if (!pendingCourseIdResetRef.current) return;
475
+ pendingCourseIdResetRef.current = false;
425
476
  syncProgress();
426
- if (previousActiveLesson) {
427
- progressRef.current.setActiveLesson(previousActiveLesson, Date.now());
428
- syncProgress();
429
- track("lesson_started", { lessonId: previousActiveLesson }, { lessonId: previousActiveLesson });
430
- }
477
+ if (!isTrackingActive(config.tracking)) return;
431
478
  const sessionId = sessionIdRef.current;
432
- const cid = config.courseId;
433
- if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
479
+ const cid = courseIdRef.current;
480
+ if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
434
481
  markCourseStarted(defaultStorage, sessionId, cid);
435
482
  emitTelemetry(
436
483
  trackingRef.current,
@@ -444,8 +491,9 @@ function LessonkitProvider(props) {
444
491
  }),
445
492
  { lxpackBridge: lxpackBridgeModeRef.current }
446
493
  );
494
+ courseStartedEmittedToSinkRef.current = true;
447
495
  }
448
- }, [config.courseId, syncProgress, track]);
496
+ }, [config.courseId, config.tracking?.enabled, syncProgress]);
449
497
  const emitLessonCompleted = useCallback(
450
498
  (lessonId, durationMs) => {
451
499
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
@@ -465,16 +513,21 @@ function LessonkitProvider(props) {
465
513
  },
466
514
  [syncProgress, emitLessonCompleted]
467
515
  );
516
+ const unmountTimerIdsRef = useRef([]);
468
517
  useEffect(() => {
469
518
  return () => {
470
- const client = trackingRef.current;
519
+ for (const id of unmountTimerIdsRef.current) clearTimeout(id);
520
+ unmountTimerIdsRef.current = [];
521
+ const client = trackingClientForUnmountRef.current;
471
522
  void xapiRef.current?.flush();
472
- setTimeout(() => {
523
+ const flushTimer = setTimeout(() => {
473
524
  client?.flush?.();
474
- setTimeout(() => {
525
+ const disposeTimer = setTimeout(() => {
475
526
  client?.dispose?.();
476
527
  }, 0);
528
+ unmountTimerIdsRef.current.push(disposeTimer);
477
529
  }, 0);
530
+ unmountTimerIdsRef.current.push(flushTimer);
478
531
  };
479
532
  }, []);
480
533
  const setActiveLesson = useCallback(
@@ -499,10 +552,34 @@ function LessonkitProvider(props) {
499
552
  if (!result.didComplete) return;
500
553
  syncProgress();
501
554
  track("course_completed");
555
+ void trackingRef.current?.flush?.();
502
556
  }, [track, syncProgress]);
503
557
  const sessionUser = config.session?.user;
504
558
  const sessionAttemptId = config.session?.attemptId;
505
559
  const sessionConfiguredId = config.session?.sessionId;
560
+ useEffect(() => {
561
+ const nextConfigured = config.session?.sessionId;
562
+ const prevConfigured = prevConfiguredSessionIdRef.current;
563
+ if (nextConfigured === prevConfigured) return;
564
+ prevConfiguredSessionIdRef.current = nextConfigured;
565
+ const cid = courseIdRef.current;
566
+ if (nextConfigured) {
567
+ const fromIds = /* @__PURE__ */ new Set();
568
+ if (prevConfigured) fromIds.add(prevConfigured);
569
+ const tabId = getTabSessionId(defaultStorage);
570
+ if (tabId) fromIds.add(tabId);
571
+ for (const fromId of fromIds) {
572
+ if (fromId !== nextConfigured) {
573
+ migrateCourseStartedMark(defaultStorage, fromId, nextConfigured, cid);
574
+ }
575
+ }
576
+ sessionIdRef.current = nextConfigured;
577
+ } else if (prevConfigured) {
578
+ const nextAuto = resolveSessionId(defaultStorage, void 0);
579
+ migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
580
+ sessionIdRef.current = nextAuto;
581
+ }
582
+ }, [sessionConfiguredId, config.courseId]);
506
583
  const runtime = useMemo(
507
584
  () => ({
508
585
  config,
@@ -655,6 +732,10 @@ function Quiz(props) {
655
732
  const [selected, setSelected] = useState2(null);
656
733
  const completedRef = useRef2(false);
657
734
  const questionId = useId();
735
+ useEffect2(() => {
736
+ completedRef.current = false;
737
+ setSelected(null);
738
+ }, [props.checkId, props.answer, props.question]);
658
739
  return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
659
740
  /* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
660
741
  /* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/react",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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.8.0",
58
- "@lessonkit/core": "0.8.0",
59
- "@lessonkit/lxpack": "0.8.0",
60
- "@lessonkit/themes": "0.8.0",
61
- "@lessonkit/xapi": "0.8.0"
57
+ "@lessonkit/accessibility": "0.8.1",
58
+ "@lessonkit/core": "0.8.1",
59
+ "@lessonkit/lxpack": "0.8.1",
60
+ "@lessonkit/themes": "0.8.1",
61
+ "@lessonkit/xapi": "0.8.1"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@testing-library/react": "^16.3.0",