@lessonkit/react 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -171,7 +171,40 @@ var import_core5 = require("@lessonkit/core");
171
171
 
172
172
  // src/runtime/courseStartedPipeline.ts
173
173
  var import_xapi3 = require("@lessonkit/xapi");
174
- function emitCourseStartedNonTrackingPipeline(opts) {
174
+ function isDevEnvironment3() {
175
+ const g = globalThis;
176
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
177
+ }
178
+ function warnExtraSinkFailure(sinkId, err) {
179
+ if (isDevEnvironment3()) {
180
+ console.warn(
181
+ `[lessonkit] course_started extra sink "${sinkId}" failed:`,
182
+ err instanceof Error ? err.message : err
183
+ );
184
+ }
185
+ }
186
+ async function emitExtraSinks(sinks, event, emitCtx) {
187
+ await Promise.all(
188
+ sinks.map(async (sink) => {
189
+ let result;
190
+ try {
191
+ result = sink.emit(event, emitCtx);
192
+ } catch (err) {
193
+ warnExtraSinkFailure(sink.id, err);
194
+ throw err;
195
+ }
196
+ if (result != null && typeof result.then === "function") {
197
+ try {
198
+ await result;
199
+ } catch (err) {
200
+ warnExtraSinkFailure(sink.id, err);
201
+ throw err;
202
+ }
203
+ }
204
+ })
205
+ );
206
+ }
207
+ async function emitCourseStartedNonTrackingPipeline(opts) {
175
208
  let xapiStatementSent = false;
176
209
  if (!opts.skipXapi && opts.xapi) {
177
210
  const statement = (0, import_xapi3.telemetryEventToXAPIStatement)(opts.event);
@@ -186,9 +219,7 @@ function emitCourseStartedNonTrackingPipeline(opts) {
186
219
  sessionId: opts.event.sessionId,
187
220
  attemptId: opts.event.attemptId
188
221
  };
189
- for (const sink of opts.extraSinks ?? []) {
190
- sink.emit(opts.event, emitCtx);
191
- }
222
+ await emitExtraSinks(opts.extraSinks ?? [], opts.event, emitCtx);
192
223
  return { xapiStatementSent };
193
224
  }
194
225
 
@@ -240,6 +271,7 @@ async function disposeTrackingClient(client) {
240
271
  // src/provider/useLessonkitProviderRuntime.ts
241
272
  var useIsoLayoutEffect = typeof window !== "undefined" ? import_react.useLayoutEffect : import_react.useEffect;
242
273
  var defaultStorage = (0, import_core3.createSessionStoragePort)();
274
+ var courseStartedTrackingFlightKey = null;
243
275
  function isTrackingActive(tracking) {
244
276
  return tracking?.enabled !== false;
245
277
  }
@@ -262,15 +294,41 @@ function buildCourseStartedEvent(opts) {
262
294
  });
263
295
  return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
264
296
  }
265
- function emitCourseStartedPipelineOnly(opts) {
297
+ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseId, event, shouldCommit) {
298
+ const flightKey = `${sessionId}:${courseId}`;
299
+ if ((0, import_core5.hasCourseStartedEmittedToTracking)(storage, sessionId, courseId)) {
300
+ return true;
301
+ }
302
+ if (courseStartedTrackingFlightKey === flightKey) {
303
+ return false;
304
+ }
305
+ courseStartedTrackingFlightKey = flightKey;
306
+ try {
307
+ if (shouldCommit && !shouldCommit()) return false;
308
+ tracking.track(event);
309
+ await tracking.flush?.();
310
+ if (shouldCommit && !shouldCommit()) return false;
311
+ (0, import_core5.markCourseStartedEmittedToTracking)(storage, sessionId, courseId);
312
+ return true;
313
+ } catch {
314
+ return false;
315
+ } finally {
316
+ if (courseStartedTrackingFlightKey === flightKey) {
317
+ courseStartedTrackingFlightKey = null;
318
+ }
319
+ }
320
+ }
321
+ async function emitCourseStartedPipelineOnly(opts) {
266
322
  try {
267
- const { xapiStatementSent } = emitCourseStartedNonTrackingPipeline({
323
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
324
+ const { xapiStatementSent } = await emitCourseStartedNonTrackingPipeline({
268
325
  event: opts.event,
269
326
  xapi: opts.xapi,
270
327
  lxpackBridge: opts.lxpackBridge,
271
328
  extraSinks: opts.extraSinks,
272
329
  skipXapi: opts.skipXapi
273
330
  });
331
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
274
332
  (0, import_core5.markCourseStarted)(opts.storage, opts.sessionId, opts.courseId);
275
333
  (0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId);
276
334
  if (xapiStatementSent) {
@@ -281,7 +339,7 @@ function emitCourseStartedPipelineOnly(opts) {
281
339
  return "failed";
282
340
  }
283
341
  }
284
- function emitCourseStarted(opts) {
342
+ async function emitCourseStarted(opts) {
285
343
  const event = buildCourseStartedEvent(opts);
286
344
  if (event === null) return "filtered";
287
345
  const trackingAlreadyEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
@@ -290,21 +348,25 @@ function emitCourseStarted(opts) {
290
348
  opts.courseId
291
349
  );
292
350
  if (!trackingAlreadyEmitted) {
293
- try {
294
- opts.tracking.track(event);
295
- (0, import_core5.markCourseStartedEmittedToTracking)(opts.storage, opts.sessionId, opts.courseId);
296
- } catch {
297
- return "failed";
298
- }
351
+ const tracked = await emitCourseStartedToTracking(
352
+ opts.tracking,
353
+ opts.storage,
354
+ opts.sessionId,
355
+ opts.courseId,
356
+ event,
357
+ opts.shouldCommit
358
+ );
359
+ if (!tracked) return "failed";
299
360
  }
300
361
  return emitCourseStartedPipelineOnly({
301
362
  ...opts,
302
363
  event,
303
364
  skipXapi: opts.skipXapi,
304
- onXapiStatementSent: opts.onXapiStatementSent
365
+ onXapiStatementSent: opts.onXapiStatementSent,
366
+ shouldCommit: opts.shouldCommit
305
367
  });
306
368
  }
307
- function emitCourseStartedToTrackingOnly(opts) {
369
+ async function emitCourseStartedToTrackingOnly(opts) {
308
370
  const event = buildCourseStartedEvent(opts);
309
371
  if (event === null) return "filtered";
310
372
  const trackingAlreadyEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
@@ -313,15 +375,19 @@ function emitCourseStartedToTrackingOnly(opts) {
313
375
  opts.courseId
314
376
  );
315
377
  if (!trackingAlreadyEmitted) {
316
- try {
317
- opts.tracking.track(event);
318
- (0, import_core5.markCourseStartedEmittedToTracking)(opts.storage, opts.sessionId, opts.courseId);
319
- } catch {
320
- return "failed";
321
- }
378
+ const tracked = await emitCourseStartedToTracking(
379
+ opts.tracking,
380
+ opts.storage,
381
+ opts.sessionId,
382
+ opts.courseId,
383
+ event,
384
+ opts.shouldCommit
385
+ );
386
+ if (!tracked) return "failed";
322
387
  }
323
388
  try {
324
- emitCourseStartedNonTrackingPipeline({
389
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
390
+ await emitCourseStartedNonTrackingPipeline({
325
391
  event,
326
392
  xapi: null,
327
393
  lxpackBridge: opts.lxpackBridge,
@@ -334,7 +400,7 @@ function emitCourseStartedToTrackingOnly(opts) {
334
400
  return "failed";
335
401
  }
336
402
  }
337
- function emitPendingCourseStarted(opts) {
403
+ async function emitPendingCourseStarted(opts) {
338
404
  const trackingEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
339
405
  opts.storage,
340
406
  opts.sessionId,
@@ -408,6 +474,7 @@ function useLessonkitProviderRuntime(config) {
408
474
  pluginHostRef.current = pluginHost;
409
475
  const progressRef = (0, import_react.useRef)((0, import_core4.createProgressController)());
410
476
  const courseStartedEmittedToSinkRef = (0, import_react.useRef)(false);
477
+ const courseStartedEmitGenerationRef = (0, import_react.useRef)(0);
411
478
  const prevCourseIdForProgressRef = (0, import_react.useRef)(normalizedCourseId);
412
479
  const pendingCourseIdResetRef = (0, import_react.useRef)(false);
413
480
  const prevUseV2RuntimeRef = (0, import_react.useRef)(useV2Runtime);
@@ -427,6 +494,7 @@ function useLessonkitProviderRuntime(config) {
427
494
  }
428
495
  pendingCourseIdResetRef.current = true;
429
496
  courseStartedEmittedToSinkRef.current = false;
497
+ courseStartedEmitGenerationRef.current += 1;
430
498
  } else if (useV2Runtime && !headlessRef.current) {
431
499
  headlessRef.current = (0, import_core8.createLessonkitRuntime)({
432
500
  courseId: normalizedCourseId,
@@ -444,6 +512,7 @@ function useLessonkitProviderRuntime(config) {
444
512
  }
445
513
  pendingCourseIdResetRef.current = true;
446
514
  courseStartedEmittedToSinkRef.current = false;
515
+ courseStartedEmitGenerationRef.current += 1;
447
516
  }
448
517
  if (useV2Runtime && headlessRef.current) {
449
518
  progressRef.current = headlessRef.current.progress;
@@ -581,30 +650,39 @@ function useLessonkitProviderRuntime(config) {
581
650
  const sessionId = sessionIdRef.current;
582
651
  const cid = courseIdRef.current;
583
652
  const trackingActive = isTrackingActive(normalizedConfig.tracking);
653
+ const courseStartedFullySettled = (0, import_core5.hasCourseStartedEmittedToTracking)(defaultStorage, sessionId, cid) && (0, import_core5.hasCourseStarted)(defaultStorage, sessionId, cid) && (0, import_core5.hasCourseStartedPipelineDelivered)(defaultStorage, sessionId, cid);
584
654
  if (!trackingActive) {
585
655
  courseStartedEmittedToSinkRef.current = false;
586
- } else if (!courseStartedEmittedToSinkRef.current) {
587
- const result = emitPendingCourseStarted({
588
- pluginHost: pluginHostRef.current,
589
- tracking: next,
590
- xapi: xapiRef.current,
591
- storage: defaultStorage,
592
- sessionId,
593
- courseId: cid,
594
- attemptId: attemptIdRef.current,
595
- user: userRef.current,
596
- lxpackBridge: lxpackBridgeModeRef.current,
597
- extraSinks: extraSinksRef.current,
598
- skipXapi: xapiCourseStartedSentOnClientRef.current,
599
- onXapiStatementSent: () => {
600
- xapiCourseStartedSentOnClientRef.current = true;
601
- }
602
- });
603
- courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
604
- } else if (trackingActive) {
656
+ } else if (courseStartedFullySettled) {
605
657
  courseStartedEmittedToSinkRef.current = true;
658
+ } else if (!courseStartedEmittedToSinkRef.current) {
659
+ const generation = ++courseStartedEmitGenerationRef.current;
660
+ const shouldCommit = () => generation === courseStartedEmitGenerationRef.current;
661
+ void (async () => {
662
+ if (generation !== courseStartedEmitGenerationRef.current) return;
663
+ const result = await emitPendingCourseStarted({
664
+ pluginHost: pluginHostRef.current,
665
+ tracking: next,
666
+ xapi: xapiRef.current,
667
+ storage: defaultStorage,
668
+ sessionId,
669
+ courseId: cid,
670
+ attemptId: attemptIdRef.current,
671
+ user: userRef.current,
672
+ lxpackBridge: lxpackBridgeModeRef.current,
673
+ extraSinks: extraSinksRef.current,
674
+ skipXapi: xapiCourseStartedSentOnClientRef.current,
675
+ onXapiStatementSent: () => {
676
+ xapiCourseStartedSentOnClientRef.current = true;
677
+ },
678
+ shouldCommit
679
+ });
680
+ if (generation !== courseStartedEmitGenerationRef.current) return;
681
+ courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
682
+ })();
606
683
  }
607
684
  return () => {
685
+ courseStartedEmitGenerationRef.current += 1;
608
686
  if (prev !== trackingRef.current) {
609
687
  void disposeTrackingClient(prev);
610
688
  }
@@ -681,7 +759,7 @@ function useLessonkitProviderRuntime(config) {
681
759
  } catch {
682
760
  }
683
761
  if (!courseStartedEmittedToSinkRef.current) {
684
- const result = emitPendingCourseStarted({
762
+ const result = await emitPendingCourseStarted({
685
763
  pluginHost: pluginHostRef.current,
686
764
  tracking: trackingRef.current,
687
765
  xapi: xapiRef.current,
@@ -707,7 +785,10 @@ function useLessonkitProviderRuntime(config) {
707
785
  [track]
708
786
  );
709
787
  const completeLesson = (0, import_react.useCallback)(
710
- (lessonId) => {
788
+ (lessonId, opts) => {
789
+ if (opts?.courseId !== void 0 && opts.courseId !== courseIdRef.current) {
790
+ return;
791
+ }
711
792
  if (useV2Runtime && headlessRef.current) {
712
793
  headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
713
794
  syncProgress();
@@ -925,7 +1006,7 @@ function useEnclosingLessonId() {
925
1006
 
926
1007
  // src/runtime/validateComponentId.ts
927
1008
  var import_core9 = require("@lessonkit/core");
928
- function isDevEnvironment3() {
1009
+ function isDevEnvironment4() {
929
1010
  const g = globalThis;
930
1011
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
931
1012
  }
@@ -941,7 +1022,7 @@ function normalizeComponentId(id, path) {
941
1022
  var mountCounts = /* @__PURE__ */ new Map();
942
1023
  var warnedConcurrentLessons = false;
943
1024
  function registerLessonMount(lessonId) {
944
- if (isDevEnvironment3() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
1025
+ if (isDevEnvironment4() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
945
1026
  warnedConcurrentLessons = true;
946
1027
  console.warn(
947
1028
  "[lessonkit] Multiple <Lesson> components are mounted; only one should be active at a time. Set autoCompleteOnUnmount={false} on routed lessons or unmount the previous lesson before showing the next."
@@ -984,9 +1065,18 @@ function Lesson(props) {
984
1065
  const { setActiveLesson, config } = useLessonkit();
985
1066
  const { completeLesson } = useCompletion();
986
1067
  const lessonMountGenerationRef = (0, import_react5.useRef)(0);
1068
+ const liveCourseIdRef = (0, import_react5.useRef)(config.courseId);
1069
+ liveCourseIdRef.current = config.courseId;
987
1070
  (0, import_react5.useEffect)(() => {
988
1071
  const unregister = registerLessonMount(lessonId);
989
1072
  const generation = ++lessonMountGenerationRef.current;
1073
+ const mountedCourseId = config.courseId;
1074
+ let effectSurvivedTick = false;
1075
+ queueMicrotask(() => {
1076
+ queueMicrotask(() => {
1077
+ effectSurvivedTick = true;
1078
+ });
1079
+ });
990
1080
  setActiveLesson(lessonId);
991
1081
  return () => {
992
1082
  unregister();
@@ -995,8 +1085,10 @@ function Lesson(props) {
995
1085
  }
996
1086
  if (!autoComplete) return;
997
1087
  queueMicrotask(() => {
1088
+ if (!effectSurvivedTick) return;
998
1089
  if (lessonMountGenerationRef.current !== generation) return;
999
- completeLesson(lessonId);
1090
+ if (liveCourseIdRef.current !== mountedCourseId) return;
1091
+ completeLesson(lessonId, { courseId: mountedCourseId });
1000
1092
  });
1001
1093
  };
1002
1094
  }, [lessonId, config.courseId, setActiveLesson, completeLesson, autoComplete]);
@@ -1055,11 +1147,10 @@ function KnowledgeCheck(props) {
1055
1147
  );
1056
1148
  }
1057
1149
  function Quiz(props) {
1058
- const checkId = (0, import_react5.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1059
1150
  const enclosingLessonId = useEnclosingLessonId();
1060
1151
  const missingLesson = enclosingLessonId === void 0;
1061
1152
  (0, import_react5.useEffect)(() => {
1062
- if (!missingLesson || isDevEnvironment3()) return;
1153
+ if (!missingLesson || isDevEnvironment4()) return;
1063
1154
  if (!warnedQuizOutsideLesson) {
1064
1155
  warnedQuizOutsideLesson = true;
1065
1156
  console.error(
@@ -1067,9 +1158,17 @@ function Quiz(props) {
1067
1158
  );
1068
1159
  }
1069
1160
  }, [missingLesson]);
1070
- if (missingLesson && isDevEnvironment3()) {
1161
+ if (missingLesson && isDevEnvironment4()) {
1071
1162
  throw new Error("[lessonkit] <Quiz> must be wrapped in <Lesson>");
1072
1163
  }
1164
+ if (missingLesson) {
1165
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": props.checkId, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { children: "Quiz must be placed inside a Lesson." }) });
1166
+ }
1167
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(QuizInner, { ...props, enclosingLessonId });
1168
+ }
1169
+ function QuizInner(props) {
1170
+ const { enclosingLessonId } = props;
1171
+ const checkId = (0, import_react5.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1073
1172
  const quiz = useQuizState(enclosingLessonId);
1074
1173
  const { plugins, config, session } = useLessonkit();
1075
1174
  const [selected, setSelected] = (0, import_react5.useState)(null);
@@ -1092,9 +1191,6 @@ function Quiz(props) {
1092
1191
  }
1093
1192
  return choice === props.answer;
1094
1193
  };
1095
- if (missingLesson) {
1096
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": checkId, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { children: "Quiz must be placed inside a Lesson." }) });
1097
- }
1098
1194
  const passed = quizPassed;
1099
1195
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": checkId, children: [
1100
1196
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
package/dist/index.d.cts CHANGED
@@ -58,7 +58,9 @@ type LessonkitRuntime = {
58
58
  user?: TelemetryUser;
59
59
  };
60
60
  setActiveLesson: (lessonId: LessonId) => void;
61
- completeLesson: (lessonId: LessonId) => void;
61
+ completeLesson: (lessonId: LessonId, opts?: {
62
+ courseId?: CourseId;
63
+ }) => void;
62
64
  completeCourse: () => void;
63
65
  track: <N extends TelemetryEventName>(name: N, data?: TelemetryDataFor<N>, opts?: {
64
66
  lessonId?: LessonId;
@@ -115,7 +117,9 @@ declare function useTracking(): {
115
117
  }) => void;
116
118
  };
117
119
  declare function useCompletion(): {
118
- completeLesson: (lessonId: LessonId) => void;
120
+ completeLesson: (lessonId: LessonId, opts?: {
121
+ courseId?: _lessonkit_core.CourseId;
122
+ }) => void;
119
123
  completeCourse: () => void;
120
124
  };
121
125
  declare function useQuizState(enclosingLessonId?: LessonId): {
package/dist/index.d.ts CHANGED
@@ -58,7 +58,9 @@ type LessonkitRuntime = {
58
58
  user?: TelemetryUser;
59
59
  };
60
60
  setActiveLesson: (lessonId: LessonId) => void;
61
- completeLesson: (lessonId: LessonId) => void;
61
+ completeLesson: (lessonId: LessonId, opts?: {
62
+ courseId?: CourseId;
63
+ }) => void;
62
64
  completeCourse: () => void;
63
65
  track: <N extends TelemetryEventName>(name: N, data?: TelemetryDataFor<N>, opts?: {
64
66
  lessonId?: LessonId;
@@ -115,7 +117,9 @@ declare function useTracking(): {
115
117
  }) => void;
116
118
  };
117
119
  declare function useCompletion(): {
118
- completeLesson: (lessonId: LessonId) => void;
120
+ completeLesson: (lessonId: LessonId, opts?: {
121
+ courseId?: _lessonkit_core.CourseId;
122
+ }) => void;
119
123
  completeCourse: () => void;
120
124
  };
121
125
  declare function useQuizState(enclosingLessonId?: LessonId): {
package/dist/index.js CHANGED
@@ -142,7 +142,40 @@ import {
142
142
 
143
143
  // src/runtime/courseStartedPipeline.ts
144
144
  import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
145
- function emitCourseStartedNonTrackingPipeline(opts) {
145
+ function isDevEnvironment3() {
146
+ const g = globalThis;
147
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
148
+ }
149
+ function warnExtraSinkFailure(sinkId, err) {
150
+ if (isDevEnvironment3()) {
151
+ console.warn(
152
+ `[lessonkit] course_started extra sink "${sinkId}" failed:`,
153
+ err instanceof Error ? err.message : err
154
+ );
155
+ }
156
+ }
157
+ async function emitExtraSinks(sinks, event, emitCtx) {
158
+ await Promise.all(
159
+ sinks.map(async (sink) => {
160
+ let result;
161
+ try {
162
+ result = sink.emit(event, emitCtx);
163
+ } catch (err) {
164
+ warnExtraSinkFailure(sink.id, err);
165
+ throw err;
166
+ }
167
+ if (result != null && typeof result.then === "function") {
168
+ try {
169
+ await result;
170
+ } catch (err) {
171
+ warnExtraSinkFailure(sink.id, err);
172
+ throw err;
173
+ }
174
+ }
175
+ })
176
+ );
177
+ }
178
+ async function emitCourseStartedNonTrackingPipeline(opts) {
146
179
  let xapiStatementSent = false;
147
180
  if (!opts.skipXapi && opts.xapi) {
148
181
  const statement = telemetryEventToXAPIStatement2(opts.event);
@@ -157,9 +190,7 @@ function emitCourseStartedNonTrackingPipeline(opts) {
157
190
  sessionId: opts.event.sessionId,
158
191
  attemptId: opts.event.attemptId
159
192
  };
160
- for (const sink of opts.extraSinks ?? []) {
161
- sink.emit(opts.event, emitCtx);
162
- }
193
+ await emitExtraSinks(opts.extraSinks ?? [], opts.event, emitCtx);
163
194
  return { xapiStatementSent };
164
195
  }
165
196
 
@@ -211,6 +242,7 @@ async function disposeTrackingClient(client) {
211
242
  // src/provider/useLessonkitProviderRuntime.ts
212
243
  var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
213
244
  var defaultStorage = createSessionStoragePort();
245
+ var courseStartedTrackingFlightKey = null;
214
246
  function isTrackingActive(tracking) {
215
247
  return tracking?.enabled !== false;
216
248
  }
@@ -233,15 +265,41 @@ function buildCourseStartedEvent(opts) {
233
265
  });
234
266
  return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
235
267
  }
236
- function emitCourseStartedPipelineOnly(opts) {
268
+ async function emitCourseStartedToTracking(tracking, storage, sessionId, courseId, event, shouldCommit) {
269
+ const flightKey = `${sessionId}:${courseId}`;
270
+ if (hasCourseStartedEmittedToTracking(storage, sessionId, courseId)) {
271
+ return true;
272
+ }
273
+ if (courseStartedTrackingFlightKey === flightKey) {
274
+ return false;
275
+ }
276
+ courseStartedTrackingFlightKey = flightKey;
277
+ try {
278
+ if (shouldCommit && !shouldCommit()) return false;
279
+ tracking.track(event);
280
+ await tracking.flush?.();
281
+ if (shouldCommit && !shouldCommit()) return false;
282
+ markCourseStartedEmittedToTracking(storage, sessionId, courseId);
283
+ return true;
284
+ } catch {
285
+ return false;
286
+ } finally {
287
+ if (courseStartedTrackingFlightKey === flightKey) {
288
+ courseStartedTrackingFlightKey = null;
289
+ }
290
+ }
291
+ }
292
+ async function emitCourseStartedPipelineOnly(opts) {
237
293
  try {
238
- const { xapiStatementSent } = emitCourseStartedNonTrackingPipeline({
294
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
295
+ const { xapiStatementSent } = await emitCourseStartedNonTrackingPipeline({
239
296
  event: opts.event,
240
297
  xapi: opts.xapi,
241
298
  lxpackBridge: opts.lxpackBridge,
242
299
  extraSinks: opts.extraSinks,
243
300
  skipXapi: opts.skipXapi
244
301
  });
302
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
245
303
  markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
246
304
  markCourseStartedPipelineDelivered(opts.storage, opts.sessionId, opts.courseId);
247
305
  if (xapiStatementSent) {
@@ -252,7 +310,7 @@ function emitCourseStartedPipelineOnly(opts) {
252
310
  return "failed";
253
311
  }
254
312
  }
255
- function emitCourseStarted(opts) {
313
+ async function emitCourseStarted(opts) {
256
314
  const event = buildCourseStartedEvent(opts);
257
315
  if (event === null) return "filtered";
258
316
  const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
@@ -261,21 +319,25 @@ function emitCourseStarted(opts) {
261
319
  opts.courseId
262
320
  );
263
321
  if (!trackingAlreadyEmitted) {
264
- try {
265
- opts.tracking.track(event);
266
- markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
267
- } catch {
268
- return "failed";
269
- }
322
+ const tracked = await emitCourseStartedToTracking(
323
+ opts.tracking,
324
+ opts.storage,
325
+ opts.sessionId,
326
+ opts.courseId,
327
+ event,
328
+ opts.shouldCommit
329
+ );
330
+ if (!tracked) return "failed";
270
331
  }
271
332
  return emitCourseStartedPipelineOnly({
272
333
  ...opts,
273
334
  event,
274
335
  skipXapi: opts.skipXapi,
275
- onXapiStatementSent: opts.onXapiStatementSent
336
+ onXapiStatementSent: opts.onXapiStatementSent,
337
+ shouldCommit: opts.shouldCommit
276
338
  });
277
339
  }
278
- function emitCourseStartedToTrackingOnly(opts) {
340
+ async function emitCourseStartedToTrackingOnly(opts) {
279
341
  const event = buildCourseStartedEvent(opts);
280
342
  if (event === null) return "filtered";
281
343
  const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
@@ -284,15 +346,19 @@ function emitCourseStartedToTrackingOnly(opts) {
284
346
  opts.courseId
285
347
  );
286
348
  if (!trackingAlreadyEmitted) {
287
- try {
288
- opts.tracking.track(event);
289
- markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
290
- } catch {
291
- return "failed";
292
- }
349
+ const tracked = await emitCourseStartedToTracking(
350
+ opts.tracking,
351
+ opts.storage,
352
+ opts.sessionId,
353
+ opts.courseId,
354
+ event,
355
+ opts.shouldCommit
356
+ );
357
+ if (!tracked) return "failed";
293
358
  }
294
359
  try {
295
- emitCourseStartedNonTrackingPipeline({
360
+ if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
361
+ await emitCourseStartedNonTrackingPipeline({
296
362
  event,
297
363
  xapi: null,
298
364
  lxpackBridge: opts.lxpackBridge,
@@ -305,7 +371,7 @@ function emitCourseStartedToTrackingOnly(opts) {
305
371
  return "failed";
306
372
  }
307
373
  }
308
- function emitPendingCourseStarted(opts) {
374
+ async function emitPendingCourseStarted(opts) {
309
375
  const trackingEmitted = hasCourseStartedEmittedToTracking(
310
376
  opts.storage,
311
377
  opts.sessionId,
@@ -379,6 +445,7 @@ function useLessonkitProviderRuntime(config) {
379
445
  pluginHostRef.current = pluginHost;
380
446
  const progressRef = useRef(createProgressController());
381
447
  const courseStartedEmittedToSinkRef = useRef(false);
448
+ const courseStartedEmitGenerationRef = useRef(0);
382
449
  const prevCourseIdForProgressRef = useRef(normalizedCourseId);
383
450
  const pendingCourseIdResetRef = useRef(false);
384
451
  const prevUseV2RuntimeRef = useRef(useV2Runtime);
@@ -398,6 +465,7 @@ function useLessonkitProviderRuntime(config) {
398
465
  }
399
466
  pendingCourseIdResetRef.current = true;
400
467
  courseStartedEmittedToSinkRef.current = false;
468
+ courseStartedEmitGenerationRef.current += 1;
401
469
  } else if (useV2Runtime && !headlessRef.current) {
402
470
  headlessRef.current = createLessonkitRuntime({
403
471
  courseId: normalizedCourseId,
@@ -415,6 +483,7 @@ function useLessonkitProviderRuntime(config) {
415
483
  }
416
484
  pendingCourseIdResetRef.current = true;
417
485
  courseStartedEmittedToSinkRef.current = false;
486
+ courseStartedEmitGenerationRef.current += 1;
418
487
  }
419
488
  if (useV2Runtime && headlessRef.current) {
420
489
  progressRef.current = headlessRef.current.progress;
@@ -552,30 +621,39 @@ function useLessonkitProviderRuntime(config) {
552
621
  const sessionId = sessionIdRef.current;
553
622
  const cid = courseIdRef.current;
554
623
  const trackingActive = isTrackingActive(normalizedConfig.tracking);
624
+ const courseStartedFullySettled = hasCourseStartedEmittedToTracking(defaultStorage, sessionId, cid) && hasCourseStarted(defaultStorage, sessionId, cid) && hasCourseStartedPipelineDelivered(defaultStorage, sessionId, cid);
555
625
  if (!trackingActive) {
556
626
  courseStartedEmittedToSinkRef.current = false;
557
- } else if (!courseStartedEmittedToSinkRef.current) {
558
- const result = emitPendingCourseStarted({
559
- pluginHost: pluginHostRef.current,
560
- tracking: next,
561
- xapi: xapiRef.current,
562
- storage: defaultStorage,
563
- sessionId,
564
- courseId: cid,
565
- attemptId: attemptIdRef.current,
566
- user: userRef.current,
567
- lxpackBridge: lxpackBridgeModeRef.current,
568
- extraSinks: extraSinksRef.current,
569
- skipXapi: xapiCourseStartedSentOnClientRef.current,
570
- onXapiStatementSent: () => {
571
- xapiCourseStartedSentOnClientRef.current = true;
572
- }
573
- });
574
- courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
575
- } else if (trackingActive) {
627
+ } else if (courseStartedFullySettled) {
576
628
  courseStartedEmittedToSinkRef.current = true;
629
+ } else if (!courseStartedEmittedToSinkRef.current) {
630
+ const generation = ++courseStartedEmitGenerationRef.current;
631
+ const shouldCommit = () => generation === courseStartedEmitGenerationRef.current;
632
+ void (async () => {
633
+ if (generation !== courseStartedEmitGenerationRef.current) return;
634
+ const result = await emitPendingCourseStarted({
635
+ pluginHost: pluginHostRef.current,
636
+ tracking: next,
637
+ xapi: xapiRef.current,
638
+ storage: defaultStorage,
639
+ sessionId,
640
+ courseId: cid,
641
+ attemptId: attemptIdRef.current,
642
+ user: userRef.current,
643
+ lxpackBridge: lxpackBridgeModeRef.current,
644
+ extraSinks: extraSinksRef.current,
645
+ skipXapi: xapiCourseStartedSentOnClientRef.current,
646
+ onXapiStatementSent: () => {
647
+ xapiCourseStartedSentOnClientRef.current = true;
648
+ },
649
+ shouldCommit
650
+ });
651
+ if (generation !== courseStartedEmitGenerationRef.current) return;
652
+ courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
653
+ })();
577
654
  }
578
655
  return () => {
656
+ courseStartedEmitGenerationRef.current += 1;
579
657
  if (prev !== trackingRef.current) {
580
658
  void disposeTrackingClient(prev);
581
659
  }
@@ -652,7 +730,7 @@ function useLessonkitProviderRuntime(config) {
652
730
  } catch {
653
731
  }
654
732
  if (!courseStartedEmittedToSinkRef.current) {
655
- const result = emitPendingCourseStarted({
733
+ const result = await emitPendingCourseStarted({
656
734
  pluginHost: pluginHostRef.current,
657
735
  tracking: trackingRef.current,
658
736
  xapi: xapiRef.current,
@@ -678,7 +756,10 @@ function useLessonkitProviderRuntime(config) {
678
756
  [track]
679
757
  );
680
758
  const completeLesson = useCallback(
681
- (lessonId) => {
759
+ (lessonId, opts) => {
760
+ if (opts?.courseId !== void 0 && opts.courseId !== courseIdRef.current) {
761
+ return;
762
+ }
682
763
  if (useV2Runtime && headlessRef.current) {
683
764
  headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
684
765
  syncProgress();
@@ -896,7 +977,7 @@ function useEnclosingLessonId() {
896
977
 
897
978
  // src/runtime/validateComponentId.ts
898
979
  import { assertValidId as assertValidId2 } from "@lessonkit/core";
899
- function isDevEnvironment3() {
980
+ function isDevEnvironment4() {
900
981
  const g = globalThis;
901
982
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
902
983
  }
@@ -912,7 +993,7 @@ function normalizeComponentId(id, path) {
912
993
  var mountCounts = /* @__PURE__ */ new Map();
913
994
  var warnedConcurrentLessons = false;
914
995
  function registerLessonMount(lessonId) {
915
- if (isDevEnvironment3() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
996
+ if (isDevEnvironment4() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
916
997
  warnedConcurrentLessons = true;
917
998
  console.warn(
918
999
  "[lessonkit] Multiple <Lesson> components are mounted; only one should be active at a time. Set autoCompleteOnUnmount={false} on routed lessons or unmount the previous lesson before showing the next."
@@ -955,9 +1036,18 @@ function Lesson(props) {
955
1036
  const { setActiveLesson, config } = useLessonkit();
956
1037
  const { completeLesson } = useCompletion();
957
1038
  const lessonMountGenerationRef = useRef2(0);
1039
+ const liveCourseIdRef = useRef2(config.courseId);
1040
+ liveCourseIdRef.current = config.courseId;
958
1041
  useEffect2(() => {
959
1042
  const unregister = registerLessonMount(lessonId);
960
1043
  const generation = ++lessonMountGenerationRef.current;
1044
+ const mountedCourseId = config.courseId;
1045
+ let effectSurvivedTick = false;
1046
+ queueMicrotask(() => {
1047
+ queueMicrotask(() => {
1048
+ effectSurvivedTick = true;
1049
+ });
1050
+ });
961
1051
  setActiveLesson(lessonId);
962
1052
  return () => {
963
1053
  unregister();
@@ -966,8 +1056,10 @@ function Lesson(props) {
966
1056
  }
967
1057
  if (!autoComplete) return;
968
1058
  queueMicrotask(() => {
1059
+ if (!effectSurvivedTick) return;
969
1060
  if (lessonMountGenerationRef.current !== generation) return;
970
- completeLesson(lessonId);
1061
+ if (liveCourseIdRef.current !== mountedCourseId) return;
1062
+ completeLesson(lessonId, { courseId: mountedCourseId });
971
1063
  });
972
1064
  };
973
1065
  }, [lessonId, config.courseId, setActiveLesson, completeLesson, autoComplete]);
@@ -1026,11 +1118,10 @@ function KnowledgeCheck(props) {
1026
1118
  );
1027
1119
  }
1028
1120
  function Quiz(props) {
1029
- const checkId = useMemo3(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1030
1121
  const enclosingLessonId = useEnclosingLessonId();
1031
1122
  const missingLesson = enclosingLessonId === void 0;
1032
1123
  useEffect2(() => {
1033
- if (!missingLesson || isDevEnvironment3()) return;
1124
+ if (!missingLesson || isDevEnvironment4()) return;
1034
1125
  if (!warnedQuizOutsideLesson) {
1035
1126
  warnedQuizOutsideLesson = true;
1036
1127
  console.error(
@@ -1038,9 +1129,17 @@ function Quiz(props) {
1038
1129
  );
1039
1130
  }
1040
1131
  }, [missingLesson]);
1041
- if (missingLesson && isDevEnvironment3()) {
1132
+ if (missingLesson && isDevEnvironment4()) {
1042
1133
  throw new Error("[lessonkit] <Quiz> must be wrapped in <Lesson>");
1043
1134
  }
1135
+ if (missingLesson) {
1136
+ return /* @__PURE__ */ jsx2("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": props.checkId, children: /* @__PURE__ */ jsx2("p", { children: "Quiz must be placed inside a Lesson." }) });
1137
+ }
1138
+ return /* @__PURE__ */ jsx2(QuizInner, { ...props, enclosingLessonId });
1139
+ }
1140
+ function QuizInner(props) {
1141
+ const { enclosingLessonId } = props;
1142
+ const checkId = useMemo3(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1044
1143
  const quiz = useQuizState(enclosingLessonId);
1045
1144
  const { plugins, config, session } = useLessonkit();
1046
1145
  const [selected, setSelected] = useState2(null);
@@ -1063,9 +1162,6 @@ function Quiz(props) {
1063
1162
  }
1064
1163
  return choice === props.answer;
1065
1164
  };
1066
- if (missingLesson) {
1067
- return /* @__PURE__ */ jsx2("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": checkId, children: /* @__PURE__ */ jsx2("p", { children: "Quiz must be placed inside a Lesson." }) });
1068
- }
1069
1165
  const passed = quizPassed;
1070
1166
  return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": checkId, children: [
1071
1167
  /* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/react",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "private": false,
5
5
  "description": "React components and hooks for building learning experiences with LessonKit.",
6
6
  "license": "Apache-2.0",
@@ -56,11 +56,11 @@
56
56
  "react-dom": ">=18"
57
57
  },
58
58
  "dependencies": {
59
- "@lessonkit/accessibility": "1.0.1",
60
- "@lessonkit/core": "1.0.1",
61
- "@lessonkit/lxpack": "1.0.1",
62
- "@lessonkit/themes": "1.0.1",
63
- "@lessonkit/xapi": "1.0.1"
59
+ "@lessonkit/accessibility": "1.0.2",
60
+ "@lessonkit/core": "1.0.2",
61
+ "@lessonkit/lxpack": "1.0.2",
62
+ "@lessonkit/themes": "1.0.2",
63
+ "@lessonkit/xapi": "1.0.2"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@storybook/addon-essentials": "8.6.18",
@@ -80,6 +80,6 @@
80
80
  "tsup": "^8.5.0",
81
81
  "typescript": "^5.8.3",
82
82
  "vite": "^6.3.5",
83
- "vitest": "^3.2.4"
83
+ "vitest": "^4.1.8"
84
84
  }
85
85
  }