@lessonkit/react 0.5.0 → 0.7.0

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
@@ -1,6 +1,6 @@
1
1
  # `@lessonkit/react`
2
2
 
3
- [![CI](https://github.com/eddiethedean/lessonkit/actions/workflows/checks.yml/badge.svg)](https://github.com/eddiethedean/lessonkit/actions/workflows/checks.yml)
3
+ [![CI](https://github.com/eddiethedean/lessonkit/actions/workflows/ci.yml/badge.svg)](https://github.com/eddiethedean/lessonkit/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/@lessonkit/react.svg)](https://www.npmjs.com/package/@lessonkit/react)
5
5
  [![License](https://img.shields.io/github/license/eddiethedean/lessonkit)](../../LICENSE)
6
6
 
@@ -56,7 +56,7 @@ export default function App() {
56
56
  }
57
57
  ```
58
58
 
59
- ## API (0.5.0)
59
+ ## API (0.6.0)
60
60
 
61
61
  ### Components
62
62
 
@@ -87,6 +87,8 @@ export default function App() {
87
87
  - `Course` accepts a `config` prop that is passed through to `LessonkitProvider` (tracking sink,
88
88
  optional `xapi.transport` or custom `xapi.client`, session metadata). Hoist `config` with `useMemo`
89
89
  so tracking/xAPI clients are not recreated every render.
90
+ - xAPI is enabled by default unless `xapi.enabled: false`. Provide `xapi.transport` or `xapi.client`
91
+ or statements are queued in memory and never sent (dev warns once).
90
92
  - A lesson is marked complete when its `<Lesson>` unmounts (for example, wizard navigation) or when
91
93
  another lesson becomes active via `setActiveLesson`. Use stable `lessonId` values so completion and
92
94
  time-on-task telemetry stay consistent.
package/dist/index.cjs CHANGED
@@ -56,16 +56,61 @@ var import_accessibility = require("@lessonkit/accessibility");
56
56
  var import_react = require("react");
57
57
  var import_core3 = require("@lessonkit/core");
58
58
  var import_xapi3 = require("@lessonkit/xapi");
59
+ var import_xapi4 = require("@lessonkit/xapi");
59
60
 
60
61
  // src/runtime/emitTelemetry.ts
61
62
  var import_core = require("@lessonkit/core");
62
63
  var import_xapi = require("@lessonkit/xapi");
64
+
65
+ // src/runtime/lxpackBridge.ts
66
+ var import_bridge = require("@lessonkit/lxpack/bridge");
67
+ function getBridge() {
68
+ if (typeof window === "undefined") return null;
69
+ const parent = window.parent;
70
+ if (!parent || parent === window) return null;
71
+ return parent.lxpackBridge?.v1 ?? parent.lxpack ?? null;
72
+ }
73
+ function forwardTelemetryToLxpack(event, mode = "auto") {
74
+ if (mode === "off") return;
75
+ const bridge = getBridge();
76
+ if (!bridge) return;
77
+ switch (event.name) {
78
+ case "lesson_completed": {
79
+ const lessonId = event.lessonId;
80
+ if (lessonId) bridge.completeLesson?.(lessonId);
81
+ return;
82
+ }
83
+ case "course_completed":
84
+ bridge.completeCourse?.();
85
+ return;
86
+ case "quiz_completed": {
87
+ const data = event.data;
88
+ if (!data?.checkId) return;
89
+ const scaled = (0, import_bridge.normalizeAssessmentScore)({
90
+ score: data.score,
91
+ maxScore: data.maxScore
92
+ });
93
+ if (scaled === null) return;
94
+ bridge.submitAssessment?.({
95
+ id: data.checkId,
96
+ score: scaled,
97
+ passingScore: (0, import_bridge.normalizeAssessmentPassingScore)(data.passingScore)
98
+ });
99
+ return;
100
+ }
101
+ default:
102
+ return;
103
+ }
104
+ }
105
+
106
+ // src/runtime/emitTelemetry.ts
63
107
  var warnedMissingCourseId = false;
108
+ var warnedMissingQuizLesson = false;
64
109
  function isDevEnvironment() {
65
110
  const g = globalThis;
66
111
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
67
112
  }
68
- function emitTelemetry(tracking, xapi, event) {
113
+ function emitTelemetry(tracking, xapi, event, opts) {
69
114
  if (!event.courseId) {
70
115
  if (isDevEnvironment() && !warnedMissingCourseId) {
71
116
  warnedMissingCourseId = true;
@@ -82,6 +127,7 @@ function emitTelemetry(tracking, xapi, event) {
82
127
  console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
83
128
  }
84
129
  }
130
+ forwardTelemetryToLxpack(event, opts?.lxpackBridge ?? "auto");
85
131
  }
86
132
  function buildTrackEvent(opts) {
87
133
  const base = {
@@ -104,7 +150,7 @@ function buildTrackEvent(opts) {
104
150
  name: "lesson_started",
105
151
  ...base,
106
152
  lessonId,
107
- data: { lessonId, ...data }
153
+ data: { ...data, lessonId }
108
154
  };
109
155
  }
110
156
  case "lesson_completed":
@@ -116,7 +162,7 @@ function buildTrackEvent(opts) {
116
162
  name: opts.name,
117
163
  ...base,
118
164
  lessonId,
119
- data: { lessonId, ...data }
165
+ data: { ...data, lessonId }
120
166
  };
121
167
  }
122
168
  case "quiz_answered": {
@@ -142,6 +188,19 @@ function buildTrackEvent(opts) {
142
188
  return { name: opts.name, ...base };
143
189
  }
144
190
  }
191
+ function tryBuildTrackEvent(opts) {
192
+ const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
193
+ if (isQuiz && !opts.lessonId) {
194
+ if (isDevEnvironment() && !warnedMissingQuizLesson) {
195
+ warnedMissingQuizLesson = true;
196
+ console.warn(
197
+ `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
198
+ );
199
+ }
200
+ return null;
201
+ }
202
+ return buildTrackEvent(opts);
203
+ }
145
204
 
146
205
  // src/runtime/ports.ts
147
206
  function createNoopStorage() {
@@ -191,6 +250,9 @@ function createProgressController() {
191
250
  completeLesson: (lessonId, completedAtMs) => {
192
251
  if (completedLessonIds.has(lessonId)) return { didComplete: false };
193
252
  completedLessonIds = new Set(completedLessonIds).add(lessonId);
253
+ if (activeLessonId === lessonId) {
254
+ activeLessonId = void 0;
255
+ }
194
256
  const startedAt = lessonStartTimes.get(lessonId);
195
257
  lessonStartTimes.delete(lessonId);
196
258
  const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
@@ -270,6 +332,8 @@ function LessonkitProvider(props) {
270
332
  userRef.current = config.session?.user;
271
333
  const courseIdRef = (0, import_react.useRef)(config.courseId);
272
334
  courseIdRef.current = config.courseId;
335
+ const lxpackBridgeModeRef = (0, import_react.useRef)(config.lxpack?.bridge ?? "auto");
336
+ lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
273
337
  const progressRef = (0, import_react.useRef)(createProgressController());
274
338
  const [progress, setProgress] = (0, import_react.useState)(() => progressRef.current.getState());
275
339
  const syncProgress = (0, import_react.useCallback)(() => {
@@ -277,6 +341,53 @@ function LessonkitProvider(props) {
277
341
  }, []);
278
342
  const activeLessonIdRef = (0, import_react.useRef)(progress.activeLessonId);
279
343
  activeLessonIdRef.current = progress.activeLessonId;
344
+ const xapiQueueRef = (0, import_react.useRef)((0, import_xapi3.createInMemoryXAPIQueue)());
345
+ const xapiRef = (0, import_react.useRef)(null);
346
+ const [xapi, setXapi] = (0, import_react.useState)(null);
347
+ const xapiEnabled = config.xapi?.enabled;
348
+ const xapiClient = config.xapi?.client;
349
+ const xapiTransport = config.xapi?.transport;
350
+ const courseId = config.courseId;
351
+ useIsoLayoutEffect(() => {
352
+ const prev = xapiRef.current;
353
+ const next = createXapiClientFromConfig(config, xapiQueueRef.current);
354
+ xapiRef.current = next;
355
+ setXapi(next);
356
+ if (next && !prev) {
357
+ const sessionId = sessionIdRef.current;
358
+ const cid = courseIdRef.current;
359
+ if (hasCourseStarted(defaultStorage, sessionId, cid)) {
360
+ try {
361
+ const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(
362
+ buildTrackEvent({
363
+ name: "course_started",
364
+ courseId: cid,
365
+ sessionId,
366
+ attemptId: attemptIdRef.current,
367
+ user: userRef.current
368
+ })
369
+ );
370
+ if (statement) next.send(statement);
371
+ } catch {
372
+ }
373
+ }
374
+ }
375
+ void (async () => {
376
+ if (prev) {
377
+ try {
378
+ await prev.flush();
379
+ } catch {
380
+ }
381
+ }
382
+ try {
383
+ await next?.flush();
384
+ } catch {
385
+ }
386
+ })();
387
+ return () => {
388
+ void prev?.flush();
389
+ };
390
+ }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
280
391
  const trackingRef = (0, import_react.useRef)((0, import_core3.createTrackingClient)());
281
392
  const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
282
393
  const trackingEnabled = config.tracking?.enabled;
@@ -303,11 +414,14 @@ function LessonkitProvider(props) {
303
414
  sessionId,
304
415
  attemptId: attemptIdRef.current,
305
416
  user: userRef.current
306
- })
417
+ }),
418
+ { lxpackBridge: lxpackBridgeModeRef.current }
307
419
  );
308
420
  }
309
421
  return () => {
310
- disposeTrackingClient(prev);
422
+ if (prev !== trackingRef.current) {
423
+ disposeTrackingClient(prev);
424
+ }
311
425
  };
312
426
  }, [
313
427
  trackingEnabled,
@@ -317,37 +431,17 @@ function LessonkitProvider(props) {
317
431
  batchFlushIntervalMs,
318
432
  batchMaxBatchSize
319
433
  ]);
320
- const xapiQueueRef = (0, import_react.useRef)((0, import_xapi3.createInMemoryXAPIQueue)());
321
- const xapiRef = (0, import_react.useRef)(null);
322
- const [xapi, setXapi] = (0, import_react.useState)(null);
323
- const xapiEnabled = config.xapi?.enabled;
324
- const xapiClient = config.xapi?.client;
325
- const xapiTransport = config.xapi?.transport;
326
- const courseId = config.courseId;
327
- useIsoLayoutEffect(() => {
328
- const prev = xapiRef.current;
329
- const next = createXapiClientFromConfig(config, xapiQueueRef.current);
330
- xapiRef.current = next;
331
- setXapi(next);
332
- void (async () => {
333
- if (prev) {
334
- try {
335
- await prev.flush();
336
- } catch {
337
- }
338
- }
339
- try {
340
- await next?.flush();
341
- } catch {
342
- }
343
- })();
344
- return () => {
345
- void prev?.flush();
346
- };
347
- }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
434
+ const emitWithBridge = (0, import_react.useCallback)(
435
+ (trackingClient, event) => {
436
+ emitTelemetry(trackingClient, xapiRef.current, event, {
437
+ lxpackBridge: lxpackBridgeModeRef.current
438
+ });
439
+ },
440
+ []
441
+ );
348
442
  const track = (0, import_react.useCallback)(
349
443
  (name, data, opts) => {
350
- const event = buildTrackEvent({
444
+ const event = tryBuildTrackEvent({
351
445
  name,
352
446
  courseId: courseIdRef.current,
353
447
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
@@ -356,16 +450,41 @@ function LessonkitProvider(props) {
356
450
  user: userRef.current,
357
451
  data
358
452
  });
359
- emitTelemetry(trackingRef.current, xapiRef.current, event);
453
+ if (!event) return;
454
+ emitWithBridge(trackingRef.current, event);
360
455
  },
361
- []
456
+ [emitWithBridge]
362
457
  );
458
+ const prevCourseIdRef = (0, import_react.useRef)(config.courseId);
363
459
  (0, import_react.useEffect)(() => {
364
- return () => {
365
- trackingRef.current?.flush?.();
366
- void xapiRef.current?.flush();
367
- };
368
- }, []);
460
+ if (prevCourseIdRef.current === config.courseId) return;
461
+ const previousActiveLesson = progressRef.current.getState().activeLessonId;
462
+ prevCourseIdRef.current = config.courseId;
463
+ progressRef.current = createProgressController();
464
+ syncProgress();
465
+ if (previousActiveLesson) {
466
+ progressRef.current.setActiveLesson(previousActiveLesson, Date.now());
467
+ syncProgress();
468
+ track("lesson_started", { lessonId: previousActiveLesson }, { lessonId: previousActiveLesson });
469
+ }
470
+ const sessionId = sessionIdRef.current;
471
+ const cid = config.courseId;
472
+ if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
473
+ markCourseStarted(defaultStorage, sessionId, cid);
474
+ emitTelemetry(
475
+ trackingRef.current,
476
+ xapiRef.current,
477
+ buildTrackEvent({
478
+ name: "course_started",
479
+ courseId: cid,
480
+ sessionId,
481
+ attemptId: attemptIdRef.current,
482
+ user: userRef.current
483
+ }),
484
+ { lxpackBridge: lxpackBridgeModeRef.current }
485
+ );
486
+ }
487
+ }, [config.courseId, syncProgress, track]);
369
488
  const emitLessonCompleted = (0, import_react.useCallback)(
370
489
  (lessonId, durationMs) => {
371
490
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
@@ -381,9 +500,22 @@ function LessonkitProvider(props) {
381
500
  if (!result.didComplete) return;
382
501
  syncProgress();
383
502
  emitLessonCompleted(lessonId, result.durationMs);
503
+ void trackingRef.current?.flush?.();
384
504
  },
385
505
  [syncProgress, emitLessonCompleted]
386
506
  );
507
+ (0, import_react.useEffect)(() => {
508
+ return () => {
509
+ const client = trackingRef.current;
510
+ void xapiRef.current?.flush();
511
+ setTimeout(() => {
512
+ client?.flush?.();
513
+ setTimeout(() => {
514
+ client?.dispose?.();
515
+ }, 0);
516
+ }, 0);
517
+ };
518
+ }, []);
387
519
  const setActiveLesson = (0, import_react.useCallback)(
388
520
  (lessonId) => {
389
521
  const current = progressRef.current.getState();
@@ -407,6 +539,9 @@ function LessonkitProvider(props) {
407
539
  syncProgress();
408
540
  track("course_completed");
409
541
  }, [track, syncProgress]);
542
+ const sessionUser = config.session?.user;
543
+ const sessionAttemptId = config.session?.attemptId;
544
+ const sessionConfiguredId = config.session?.sessionId;
410
545
  const runtime = (0, import_react.useMemo)(
411
546
  () => ({
412
547
  config,
@@ -419,7 +554,19 @@ function LessonkitProvider(props) {
419
554
  completeCourse,
420
555
  track
421
556
  }),
422
- [config, tracking, xapi, progress, setActiveLesson, completeLesson, completeCourse, track]
557
+ [
558
+ config,
559
+ tracking,
560
+ xapi,
561
+ progress,
562
+ setActiveLesson,
563
+ completeLesson,
564
+ completeCourse,
565
+ track,
566
+ sessionUser,
567
+ sessionAttemptId,
568
+ sessionConfiguredId
569
+ ]
423
570
  );
424
571
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LessonkitContext.Provider, { value: runtime, children: props.children });
425
572
  }
@@ -491,24 +638,21 @@ function Course(props) {
491
638
  }
492
639
  function Lesson(props) {
493
640
  warnInvalidComponentId(props.lessonId, "lessonId");
494
- const { setActiveLesson } = useLessonkit();
641
+ const { setActiveLesson, config } = useLessonkit();
495
642
  const { completeLesson } = useCompletion();
496
643
  const id = props.lessonId;
497
- const pendingCompleteRef = (0, import_react3.useRef)(null);
644
+ const lessonMountGenerationRef = (0, import_react3.useRef)(0);
498
645
  (0, import_react3.useEffect)(() => {
499
- if (pendingCompleteRef.current !== null) {
500
- clearTimeout(pendingCompleteRef.current);
501
- pendingCompleteRef.current = null;
502
- }
646
+ const generation = ++lessonMountGenerationRef.current;
503
647
  setActiveLesson(id);
504
648
  return () => {
505
649
  const lessonId = id;
506
- pendingCompleteRef.current = setTimeout(() => {
507
- pendingCompleteRef.current = null;
650
+ queueMicrotask(() => {
651
+ if (lessonMountGenerationRef.current !== generation) return;
508
652
  completeLesson(lessonId);
509
- }, 0);
653
+ });
510
654
  };
511
- }, [id, setActiveLesson, completeLesson]);
655
+ }, [id, config.courseId, setActiveLesson, completeLesson]);
512
656
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("article", { "aria-label": props.title, children: [
513
657
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { children: props.title }),
514
658
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { children: props.children })
@@ -573,7 +717,7 @@ function Quiz(props) {
573
717
  });
574
718
  if (correct && !completedRef.current) {
575
719
  completedRef.current = true;
576
- quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1 });
720
+ quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1, passingScore: 1 });
577
721
  }
578
722
  }
579
723
  }
package/dist/index.d.cts CHANGED
@@ -34,6 +34,10 @@ type LessonkitConfig = {
34
34
  transport?: XAPITransport;
35
35
  client?: XAPIClient;
36
36
  };
37
+ lxpack?: {
38
+ /** Forward completion events to `window.parent.lxpackBridge.v1` when embedded (default `auto`). */
39
+ bridge?: "auto" | "off";
40
+ };
37
41
  };
38
42
 
39
43
  type LessonkitRuntime = {
@@ -114,6 +118,7 @@ declare function useQuizState(): {
114
118
  checkId: CheckId;
115
119
  score?: number;
116
120
  maxScore?: number;
121
+ passingScore?: number;
117
122
  }) => void;
118
123
  };
119
124
 
package/dist/index.d.ts CHANGED
@@ -34,6 +34,10 @@ type LessonkitConfig = {
34
34
  transport?: XAPITransport;
35
35
  client?: XAPIClient;
36
36
  };
37
+ lxpack?: {
38
+ /** Forward completion events to `window.parent.lxpackBridge.v1` when embedded (default `auto`). */
39
+ bridge?: "auto" | "off";
40
+ };
37
41
  };
38
42
 
39
43
  type LessonkitRuntime = {
@@ -114,6 +118,7 @@ declare function useQuizState(): {
114
118
  checkId: CheckId;
115
119
  score?: number;
116
120
  maxScore?: number;
121
+ passingScore?: number;
117
122
  }) => void;
118
123
  };
119
124
 
package/dist/index.js CHANGED
@@ -14,16 +14,64 @@ import {
14
14
  } from "react";
15
15
  import { createTrackingClient } from "@lessonkit/core";
16
16
  import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
17
+ import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
17
18
 
18
19
  // src/runtime/emitTelemetry.ts
19
20
  import { nowIso } from "@lessonkit/core";
20
21
  import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
22
+
23
+ // src/runtime/lxpackBridge.ts
24
+ import {
25
+ normalizeAssessmentPassingScore,
26
+ normalizeAssessmentScore
27
+ } from "@lessonkit/lxpack/bridge";
28
+ function getBridge() {
29
+ if (typeof window === "undefined") return null;
30
+ const parent = window.parent;
31
+ if (!parent || parent === window) return null;
32
+ return parent.lxpackBridge?.v1 ?? parent.lxpack ?? null;
33
+ }
34
+ function forwardTelemetryToLxpack(event, mode = "auto") {
35
+ if (mode === "off") return;
36
+ const bridge = getBridge();
37
+ if (!bridge) return;
38
+ switch (event.name) {
39
+ case "lesson_completed": {
40
+ const lessonId = event.lessonId;
41
+ if (lessonId) bridge.completeLesson?.(lessonId);
42
+ return;
43
+ }
44
+ case "course_completed":
45
+ bridge.completeCourse?.();
46
+ return;
47
+ case "quiz_completed": {
48
+ const data = event.data;
49
+ if (!data?.checkId) return;
50
+ const scaled = normalizeAssessmentScore({
51
+ score: data.score,
52
+ maxScore: data.maxScore
53
+ });
54
+ if (scaled === null) return;
55
+ bridge.submitAssessment?.({
56
+ id: data.checkId,
57
+ score: scaled,
58
+ passingScore: normalizeAssessmentPassingScore(data.passingScore)
59
+ });
60
+ return;
61
+ }
62
+ default:
63
+ return;
64
+ }
65
+ }
66
+
67
+ // src/runtime/emitTelemetry.ts
21
68
  var warnedMissingCourseId = false;
69
+ var warnedMissingQuizLesson = false;
22
70
  function isDevEnvironment() {
23
71
  const g = globalThis;
24
72
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
25
73
  }
26
- function emitTelemetry(tracking, xapi, event) {
74
+ function emitTelemetry(tracking, xapi, event, opts) {
27
75
  if (!event.courseId) {
28
76
  if (isDevEnvironment() && !warnedMissingCourseId) {
29
77
  warnedMissingCourseId = true;
@@ -40,6 +88,7 @@ function emitTelemetry(tracking, xapi, event) {
40
88
  console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
41
89
  }
42
90
  }
91
+ forwardTelemetryToLxpack(event, opts?.lxpackBridge ?? "auto");
43
92
  }
44
93
  function buildTrackEvent(opts) {
45
94
  const base = {
@@ -62,7 +111,7 @@ function buildTrackEvent(opts) {
62
111
  name: "lesson_started",
63
112
  ...base,
64
113
  lessonId,
65
- data: { lessonId, ...data }
114
+ data: { ...data, lessonId }
66
115
  };
67
116
  }
68
117
  case "lesson_completed":
@@ -74,7 +123,7 @@ function buildTrackEvent(opts) {
74
123
  name: opts.name,
75
124
  ...base,
76
125
  lessonId,
77
- data: { lessonId, ...data }
126
+ data: { ...data, lessonId }
78
127
  };
79
128
  }
80
129
  case "quiz_answered": {
@@ -100,6 +149,19 @@ function buildTrackEvent(opts) {
100
149
  return { name: opts.name, ...base };
101
150
  }
102
151
  }
152
+ function tryBuildTrackEvent(opts) {
153
+ const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
154
+ if (isQuiz && !opts.lessonId) {
155
+ if (isDevEnvironment() && !warnedMissingQuizLesson) {
156
+ warnedMissingQuizLesson = true;
157
+ console.warn(
158
+ `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
159
+ );
160
+ }
161
+ return null;
162
+ }
163
+ return buildTrackEvent(opts);
164
+ }
103
165
 
104
166
  // src/runtime/ports.ts
105
167
  function createNoopStorage() {
@@ -149,6 +211,9 @@ function createProgressController() {
149
211
  completeLesson: (lessonId, completedAtMs) => {
150
212
  if (completedLessonIds.has(lessonId)) return { didComplete: false };
151
213
  completedLessonIds = new Set(completedLessonIds).add(lessonId);
214
+ if (activeLessonId === lessonId) {
215
+ activeLessonId = void 0;
216
+ }
152
217
  const startedAt = lessonStartTimes.get(lessonId);
153
218
  lessonStartTimes.delete(lessonId);
154
219
  const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
@@ -228,6 +293,8 @@ function LessonkitProvider(props) {
228
293
  userRef.current = config.session?.user;
229
294
  const courseIdRef = useRef(config.courseId);
230
295
  courseIdRef.current = config.courseId;
296
+ const lxpackBridgeModeRef = useRef(config.lxpack?.bridge ?? "auto");
297
+ lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
231
298
  const progressRef = useRef(createProgressController());
232
299
  const [progress, setProgress] = useState(() => progressRef.current.getState());
233
300
  const syncProgress = useCallback(() => {
@@ -235,6 +302,53 @@ function LessonkitProvider(props) {
235
302
  }, []);
236
303
  const activeLessonIdRef = useRef(progress.activeLessonId);
237
304
  activeLessonIdRef.current = progress.activeLessonId;
305
+ const xapiQueueRef = useRef(createInMemoryXAPIQueue());
306
+ const xapiRef = useRef(null);
307
+ const [xapi, setXapi] = useState(null);
308
+ const xapiEnabled = config.xapi?.enabled;
309
+ const xapiClient = config.xapi?.client;
310
+ const xapiTransport = config.xapi?.transport;
311
+ const courseId = config.courseId;
312
+ useIsoLayoutEffect(() => {
313
+ const prev = xapiRef.current;
314
+ const next = createXapiClientFromConfig(config, xapiQueueRef.current);
315
+ xapiRef.current = next;
316
+ setXapi(next);
317
+ if (next && !prev) {
318
+ const sessionId = sessionIdRef.current;
319
+ 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
+ }
334
+ }
335
+ }
336
+ void (async () => {
337
+ if (prev) {
338
+ try {
339
+ await prev.flush();
340
+ } catch {
341
+ }
342
+ }
343
+ try {
344
+ await next?.flush();
345
+ } catch {
346
+ }
347
+ })();
348
+ return () => {
349
+ void prev?.flush();
350
+ };
351
+ }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
238
352
  const trackingRef = useRef(createTrackingClient());
239
353
  const [tracking, setTracking] = useState(() => trackingRef.current);
240
354
  const trackingEnabled = config.tracking?.enabled;
@@ -261,11 +375,14 @@ function LessonkitProvider(props) {
261
375
  sessionId,
262
376
  attemptId: attemptIdRef.current,
263
377
  user: userRef.current
264
- })
378
+ }),
379
+ { lxpackBridge: lxpackBridgeModeRef.current }
265
380
  );
266
381
  }
267
382
  return () => {
268
- disposeTrackingClient(prev);
383
+ if (prev !== trackingRef.current) {
384
+ disposeTrackingClient(prev);
385
+ }
269
386
  };
270
387
  }, [
271
388
  trackingEnabled,
@@ -275,37 +392,17 @@ function LessonkitProvider(props) {
275
392
  batchFlushIntervalMs,
276
393
  batchMaxBatchSize
277
394
  ]);
278
- const xapiQueueRef = useRef(createInMemoryXAPIQueue());
279
- const xapiRef = useRef(null);
280
- const [xapi, setXapi] = useState(null);
281
- const xapiEnabled = config.xapi?.enabled;
282
- const xapiClient = config.xapi?.client;
283
- const xapiTransport = config.xapi?.transport;
284
- const courseId = config.courseId;
285
- useIsoLayoutEffect(() => {
286
- const prev = xapiRef.current;
287
- const next = createXapiClientFromConfig(config, xapiQueueRef.current);
288
- xapiRef.current = next;
289
- setXapi(next);
290
- void (async () => {
291
- if (prev) {
292
- try {
293
- await prev.flush();
294
- } catch {
295
- }
296
- }
297
- try {
298
- await next?.flush();
299
- } catch {
300
- }
301
- })();
302
- return () => {
303
- void prev?.flush();
304
- };
305
- }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
395
+ const emitWithBridge = useCallback(
396
+ (trackingClient, event) => {
397
+ emitTelemetry(trackingClient, xapiRef.current, event, {
398
+ lxpackBridge: lxpackBridgeModeRef.current
399
+ });
400
+ },
401
+ []
402
+ );
306
403
  const track = useCallback(
307
404
  (name, data, opts) => {
308
- const event = buildTrackEvent({
405
+ const event = tryBuildTrackEvent({
309
406
  name,
310
407
  courseId: courseIdRef.current,
311
408
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
@@ -314,16 +411,41 @@ function LessonkitProvider(props) {
314
411
  user: userRef.current,
315
412
  data
316
413
  });
317
- emitTelemetry(trackingRef.current, xapiRef.current, event);
414
+ if (!event) return;
415
+ emitWithBridge(trackingRef.current, event);
318
416
  },
319
- []
417
+ [emitWithBridge]
320
418
  );
419
+ const prevCourseIdRef = useRef(config.courseId);
321
420
  useEffect(() => {
322
- return () => {
323
- trackingRef.current?.flush?.();
324
- void xapiRef.current?.flush();
325
- };
326
- }, []);
421
+ if (prevCourseIdRef.current === config.courseId) return;
422
+ const previousActiveLesson = progressRef.current.getState().activeLessonId;
423
+ prevCourseIdRef.current = config.courseId;
424
+ progressRef.current = createProgressController();
425
+ syncProgress();
426
+ if (previousActiveLesson) {
427
+ progressRef.current.setActiveLesson(previousActiveLesson, Date.now());
428
+ syncProgress();
429
+ track("lesson_started", { lessonId: previousActiveLesson }, { lessonId: previousActiveLesson });
430
+ }
431
+ const sessionId = sessionIdRef.current;
432
+ const cid = config.courseId;
433
+ if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
434
+ markCourseStarted(defaultStorage, sessionId, cid);
435
+ emitTelemetry(
436
+ trackingRef.current,
437
+ xapiRef.current,
438
+ buildTrackEvent({
439
+ name: "course_started",
440
+ courseId: cid,
441
+ sessionId,
442
+ attemptId: attemptIdRef.current,
443
+ user: userRef.current
444
+ }),
445
+ { lxpackBridge: lxpackBridgeModeRef.current }
446
+ );
447
+ }
448
+ }, [config.courseId, syncProgress, track]);
327
449
  const emitLessonCompleted = useCallback(
328
450
  (lessonId, durationMs) => {
329
451
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
@@ -339,9 +461,22 @@ function LessonkitProvider(props) {
339
461
  if (!result.didComplete) return;
340
462
  syncProgress();
341
463
  emitLessonCompleted(lessonId, result.durationMs);
464
+ void trackingRef.current?.flush?.();
342
465
  },
343
466
  [syncProgress, emitLessonCompleted]
344
467
  );
468
+ useEffect(() => {
469
+ return () => {
470
+ const client = trackingRef.current;
471
+ void xapiRef.current?.flush();
472
+ setTimeout(() => {
473
+ client?.flush?.();
474
+ setTimeout(() => {
475
+ client?.dispose?.();
476
+ }, 0);
477
+ }, 0);
478
+ };
479
+ }, []);
345
480
  const setActiveLesson = useCallback(
346
481
  (lessonId) => {
347
482
  const current = progressRef.current.getState();
@@ -365,6 +500,9 @@ function LessonkitProvider(props) {
365
500
  syncProgress();
366
501
  track("course_completed");
367
502
  }, [track, syncProgress]);
503
+ const sessionUser = config.session?.user;
504
+ const sessionAttemptId = config.session?.attemptId;
505
+ const sessionConfiguredId = config.session?.sessionId;
368
506
  const runtime = useMemo(
369
507
  () => ({
370
508
  config,
@@ -377,7 +515,19 @@ function LessonkitProvider(props) {
377
515
  completeCourse,
378
516
  track
379
517
  }),
380
- [config, tracking, xapi, progress, setActiveLesson, completeLesson, completeCourse, track]
518
+ [
519
+ config,
520
+ tracking,
521
+ xapi,
522
+ progress,
523
+ setActiveLesson,
524
+ completeLesson,
525
+ completeCourse,
526
+ track,
527
+ sessionUser,
528
+ sessionAttemptId,
529
+ sessionConfiguredId
530
+ ]
381
531
  );
382
532
  return /* @__PURE__ */ jsx(LessonkitContext.Provider, { value: runtime, children: props.children });
383
533
  }
@@ -449,24 +599,21 @@ function Course(props) {
449
599
  }
450
600
  function Lesson(props) {
451
601
  warnInvalidComponentId(props.lessonId, "lessonId");
452
- const { setActiveLesson } = useLessonkit();
602
+ const { setActiveLesson, config } = useLessonkit();
453
603
  const { completeLesson } = useCompletion();
454
604
  const id = props.lessonId;
455
- const pendingCompleteRef = useRef2(null);
605
+ const lessonMountGenerationRef = useRef2(0);
456
606
  useEffect2(() => {
457
- if (pendingCompleteRef.current !== null) {
458
- clearTimeout(pendingCompleteRef.current);
459
- pendingCompleteRef.current = null;
460
- }
607
+ const generation = ++lessonMountGenerationRef.current;
461
608
  setActiveLesson(id);
462
609
  return () => {
463
610
  const lessonId = id;
464
- pendingCompleteRef.current = setTimeout(() => {
465
- pendingCompleteRef.current = null;
611
+ queueMicrotask(() => {
612
+ if (lessonMountGenerationRef.current !== generation) return;
466
613
  completeLesson(lessonId);
467
- }, 0);
614
+ });
468
615
  };
469
- }, [id, setActiveLesson, completeLesson]);
616
+ }, [id, config.courseId, setActiveLesson, completeLesson]);
470
617
  return /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
471
618
  /* @__PURE__ */ jsx2("h2", { children: props.title }),
472
619
  /* @__PURE__ */ jsx2("div", { children: props.children })
@@ -531,7 +678,7 @@ function Quiz(props) {
531
678
  });
532
679
  if (correct && !completedRef.current) {
533
680
  completedRef.current = true;
534
- quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1 });
681
+ quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1, passingScore: 1 });
535
682
  }
536
683
  }
537
684
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/react",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "React components and hooks for building learning experiences with LessonKit.",
6
6
  "license": "Apache-2.0",
@@ -37,8 +37,8 @@
37
37
  "dist"
38
38
  ],
39
39
  "scripts": {
40
- "build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/themes",
41
- "dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/themes",
40
+ "build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/lxpack --external @lessonkit/themes",
41
+ "dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/lxpack --external @lessonkit/themes",
42
42
  "prepublishOnly": "npm run build",
43
43
  "typecheck": "tsc -p tsconfig.json",
44
44
  "test": "vitest run --passWithNoTests",
@@ -50,10 +50,11 @@
50
50
  "react-dom": ">=18"
51
51
  },
52
52
  "dependencies": {
53
- "@lessonkit/accessibility": "0.5.0",
54
- "@lessonkit/core": "0.5.0",
55
- "@lessonkit/themes": "0.5.0",
56
- "@lessonkit/xapi": "0.5.0"
53
+ "@lessonkit/accessibility": "0.7.0",
54
+ "@lessonkit/core": "0.7.0",
55
+ "@lessonkit/lxpack": "0.7.0",
56
+ "@lessonkit/themes": "0.7.0",
57
+ "@lessonkit/xapi": "0.7.0"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@testing-library/react": "^16.3.0",