@pie-players/pie-players-shared 0.3.72 → 0.3.73

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.
@@ -27,9 +27,11 @@ export type { InstrumentationEventMapping } from "./instrumentation-event-map.js
27
27
  export type { ItemControllerOptions } from "./item-controller.js";
28
28
  export { ItemController, normalizeItemSessionContainer, } from "./item-controller.js";
29
29
  export type { ItemSessionContainer as CanonicalItemSessionContainer, ItemSessionUpdateIntent, NormalizedItemSessionChange, } from "./item-session-contract.js";
30
- export { hasResponseValue, normalizeItemSessionChange, } from "./item-session-contract.js";
30
+ export { ensureHostSessionEntries, hasLearnerResponse, hasResponseValue, normalizeItemSessionChange, projectSessionIntoHostContainer, } from "./item-session-contract.js";
31
31
  export type { ItemSessionContainer, ItemSessionStorageStrategy, } from "./item-controller-storage.js";
32
32
  export { MemoryItemSessionStorage, SessionStorageItemSessionStorage, } from "./item-controller-storage.js";
33
+ export { SESSION_COMMIT_METHOD, bindPageLifecycleCommit, commitPendingSessions, noteSessionBaseline, noteSessionObserved, } from "./session-commit.js";
34
+ export type { BindPageLifecycleCommitOptions, CommitPendingSessionsOptions, CommitPendingSessionsResult, SessionCommitReason, } from "./session-commit.js";
33
35
  export type { PieLogger } from "./logger.js";
34
36
  export { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
35
37
  export type { ItemData, PiePlayerConfig, PiePlayerElements, } from "./player-initializer.js";
package/dist/pie/index.js CHANGED
@@ -30,8 +30,9 @@ export { STAGES, applicableStages, stageOrdinal, } from "./stages.js";
30
30
  export { createStageTracker } from "./stage-tracker.js";
31
31
  export { ASSESSMENT_INSTRUMENTATION_EVENT_MAP, ITEM_INSTRUMENTATION_EVENT_MAP, SECTION_INSTRUMENTATION_EVENT_MAP, TOOLKIT_INSTRUMENTATION_EVENT_MAP, } from "./instrumentation-event-map.js";
32
32
  export { ItemController, normalizeItemSessionContainer, } from "./item-controller.js";
33
- export { hasResponseValue, normalizeItemSessionChange, } from "./item-session-contract.js";
33
+ export { ensureHostSessionEntries, hasLearnerResponse, hasResponseValue, normalizeItemSessionChange, projectSessionIntoHostContainer, } from "./item-session-contract.js";
34
34
  export { MemoryItemSessionStorage, SessionStorageItemSessionStorage, } from "./item-controller-storage.js";
35
+ export { SESSION_COMMIT_METHOD, bindPageLifecycleCommit, commitPendingSessions, noteSessionBaseline, noteSessionObserved, } from "./session-commit.js";
35
36
  export { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
36
37
  export { buildApiParams, buildEventListenersMap, ensurePiePlayerLoaded, extractPassageMarkup, fetchItemData, initializePiePlayer, } from "./player-initializer.js";
37
38
  // Registry
@@ -12,6 +12,19 @@ export type NormalizedItemSessionChange = {
12
12
  };
13
13
  export declare function normalizeItemSessionContainer(input: unknown, fallbackSessionId?: string): ItemSessionContainer;
14
14
  export declare function hasResponseValue(value: unknown): boolean;
15
+ /**
16
+ * Whether an element session holds anything the learner put there.
17
+ *
18
+ * Shape-agnostic by necessity: `value` is one element's answer key, and
19
+ * `response`, `selectedTokens`, `answers`, `answer`, `drawables` are others.
20
+ * Anything outside the non-response key set with content in it counts, so an
21
+ * element this function has never heard of is handled by default.
22
+ *
23
+ * This is the fallback discriminant for the session commit. The primary one is
24
+ * a comparison against the session the player last observed, which needs no
25
+ * schema knowledge at all; see `session-commit.ts`.
26
+ */
27
+ export declare function hasLearnerResponse(session: unknown): boolean;
15
28
  export declare function hasResponseField(value: unknown): boolean;
16
29
  export declare function mergeElementIntoSession(itemId: string, previousItemSession: unknown, entryId: string, entry: Record<string, unknown>): ItemSessionContainer;
17
30
  export declare function normalizeItemSessionChange(args: {
@@ -19,3 +32,29 @@ export declare function normalizeItemSessionChange(args: {
19
32
  sessionDetail: unknown;
20
33
  previousItemSession?: unknown;
21
34
  }): NormalizedItemSessionChange;
35
+ /**
36
+ * Create the bare `{ id }` entry `<pie-player>` created at render time, for each
37
+ * element the item declares. A host that indexes `data[0]` before the learner
38
+ * has answered finds an entry instead of `undefined`.
39
+ *
40
+ * Only the host's container gets these. The authoritative session stays empty
41
+ * until an element reports something, so an untouched item still saves nothing.
42
+ */
43
+ export declare function ensureHostSessionEntries(hostContainer: unknown, entryIds: Iterable<string>): boolean;
44
+ /**
45
+ * Keep the container a host handed in as a live view of the session, the
46
+ * contract `<pie-player>` had: `findOrAddSession` pushed an entry per element
47
+ * into the host's own `data` array and the element mutated that entry in place,
48
+ * so a host read the response off its own object. `ItemController` owns the
49
+ * session here, so this runs one way and never reads the container back.
50
+ *
51
+ * The array and the entry objects keep their identity, for a host holding a
52
+ * reference into `data`. Entries this player did not produce are left alone: in
53
+ * a section the host's container spans every item and an item player sees one.
54
+ *
55
+ * Inside an entry the player's own session wins, and a key it does not carry is
56
+ * removed - a cleared response has to clear here too, and the entry belongs to
57
+ * the element, as it did under `<pie-player>`. A host keeping its own
58
+ * annotations on an entry keeps them beside it, not inside it.
59
+ */
60
+ export declare function projectSessionIntoHostContainer(hostContainer: unknown, liveSession: unknown): boolean;
@@ -71,6 +71,64 @@ export function hasResponseValue(value) {
71
71
  }
72
72
  return false;
73
73
  }
74
+ /**
75
+ * Keys a delivery element carries on its session that are never learner input:
76
+ * element identity, the dispatch metadata the event contract defines, and
77
+ * presentation state a controller writes at load (choice shuffle order).
78
+ */
79
+ const NON_RESPONSE_SESSION_KEYS = new Set([
80
+ "id",
81
+ "element",
82
+ "complete",
83
+ "component",
84
+ "timestamp",
85
+ "sourceRuntimeId",
86
+ "shuffledValues",
87
+ ]);
88
+ /**
89
+ * `id` inside an array entry is the response - which hotspot, which choice.
90
+ * `id` inside an object property that repeats the property's own name is
91
+ * structure, which is how `explicit-constructed-response` and `ebsr` key their
92
+ * parts. Distinguishing the two by container is what keeps an unanswered
93
+ * `{ value: { partA: { id: "partA" } } }` from reading as a response.
94
+ */
95
+ function carriesContent(value, owningKey) {
96
+ if (value == null)
97
+ return false;
98
+ if (typeof value === "string")
99
+ return value.trim() !== "";
100
+ if (typeof value === "number" || typeof value === "boolean")
101
+ return true;
102
+ if (Array.isArray(value))
103
+ return value.some((entry) => carriesContent(entry));
104
+ if (typeof value !== "object")
105
+ return false;
106
+ return Object.entries(value).some(([key, nested]) => {
107
+ if (key === "id" && owningKey !== undefined && nested === owningKey) {
108
+ return false;
109
+ }
110
+ return carriesContent(nested, key);
111
+ });
112
+ }
113
+ /**
114
+ * Whether an element session holds anything the learner put there.
115
+ *
116
+ * Shape-agnostic by necessity: `value` is one element's answer key, and
117
+ * `response`, `selectedTokens`, `answers`, `answer`, `drawables` are others.
118
+ * Anything outside the non-response key set with content in it counts, so an
119
+ * element this function has never heard of is handled by default.
120
+ *
121
+ * This is the fallback discriminant for the session commit. The primary one is
122
+ * a comparison against the session the player last observed, which needs no
123
+ * schema knowledge at all; see `session-commit.ts`.
124
+ */
125
+ export function hasLearnerResponse(session) {
126
+ if (!session || typeof session !== "object")
127
+ return false;
128
+ if (Array.isArray(session))
129
+ return session.some(hasLearnerResponse);
130
+ return Object.entries(session).some(([key, value]) => !NON_RESPONSE_SESSION_KEYS.has(key) && carriesContent(value, key));
131
+ }
74
132
  export function hasResponseField(value) {
75
133
  if (value == null)
76
134
  return false;
@@ -254,3 +312,111 @@ export function normalizeItemSessionChange(args) {
254
312
  complete: getMetadataComplete(sessionDetail, candidate),
255
313
  };
256
314
  }
315
+ function hostContainerEntries(hostContainer) {
316
+ if (!isPlainObject(hostContainer)) {
317
+ return null;
318
+ }
319
+ // A frozen container is not a live view of anything, and a shared immutable
320
+ // default (one object handed to every item) must never take one item's
321
+ // entries. Both say the same thing: do not project here.
322
+ if (Object.isFrozen(hostContainer)) {
323
+ return null;
324
+ }
325
+ const existing = hostContainer.data;
326
+ if (Array.isArray(existing)) {
327
+ return Object.isFrozen(existing) ? null : existing;
328
+ }
329
+ if (existing === undefined || existing === null) {
330
+ const created = [];
331
+ hostContainer.data = created;
332
+ return created;
333
+ }
334
+ return null;
335
+ }
336
+ function sessionEntryId(value) {
337
+ if (!isPlainObject(value)) {
338
+ return null;
339
+ }
340
+ const id = value.id;
341
+ return typeof id === "string" && id.length > 0 ? id : null;
342
+ }
343
+ /**
344
+ * Create the bare `{ id }` entry `<pie-player>` created at render time, for each
345
+ * element the item declares. A host that indexes `data[0]` before the learner
346
+ * has answered finds an entry instead of `undefined`.
347
+ *
348
+ * Only the host's container gets these. The authoritative session stays empty
349
+ * until an element reports something, so an untouched item still saves nothing.
350
+ */
351
+ export function ensureHostSessionEntries(hostContainer, entryIds) {
352
+ const entries = hostContainerEntries(hostContainer);
353
+ if (!entries) {
354
+ return false;
355
+ }
356
+ let wrote = false;
357
+ for (const id of entryIds) {
358
+ if (typeof id !== "string" || id.length === 0) {
359
+ continue;
360
+ }
361
+ if (entries.some((entry) => sessionEntryId(entry) === id)) {
362
+ continue;
363
+ }
364
+ entries.push({ id });
365
+ wrote = true;
366
+ }
367
+ return wrote;
368
+ }
369
+ /**
370
+ * Keep the container a host handed in as a live view of the session, the
371
+ * contract `<pie-player>` had: `findOrAddSession` pushed an entry per element
372
+ * into the host's own `data` array and the element mutated that entry in place,
373
+ * so a host read the response off its own object. `ItemController` owns the
374
+ * session here, so this runs one way and never reads the container back.
375
+ *
376
+ * The array and the entry objects keep their identity, for a host holding a
377
+ * reference into `data`. Entries this player did not produce are left alone: in
378
+ * a section the host's container spans every item and an item player sees one.
379
+ *
380
+ * Inside an entry the player's own session wins, and a key it does not carry is
381
+ * removed - a cleared response has to clear here too, and the entry belongs to
382
+ * the element, as it did under `<pie-player>`. A host keeping its own
383
+ * annotations on an entry keeps them beside it, not inside it.
384
+ */
385
+ export function projectSessionIntoHostContainer(hostContainer, liveSession) {
386
+ const entries = hostContainerEntries(hostContainer);
387
+ if (!entries || !isPlainObject(hostContainer)) {
388
+ return false;
389
+ }
390
+ const live = normalizeItemSessionContainer(liveSession);
391
+ let wrote = false;
392
+ // The host owns the container id; fill it only when there is nothing there.
393
+ if (typeof hostContainer.id !== "string") {
394
+ hostContainer.id = live.id;
395
+ wrote = true;
396
+ }
397
+ for (const nextEntry of live.data) {
398
+ const id = sessionEntryId(nextEntry);
399
+ if (!id || !isPlainObject(nextEntry)) {
400
+ continue;
401
+ }
402
+ const target = entries.find((entry) => sessionEntryId(entry) === id);
403
+ if (!isPlainObject(target)) {
404
+ entries.push({ ...nextEntry });
405
+ wrote = true;
406
+ continue;
407
+ }
408
+ for (const key of Object.keys(target)) {
409
+ if (!(key in nextEntry)) {
410
+ delete target[key];
411
+ wrote = true;
412
+ }
413
+ }
414
+ for (const [key, value] of Object.entries(nextEntry)) {
415
+ if (target[key] !== value) {
416
+ target[key] = value;
417
+ wrote = true;
418
+ }
419
+ }
420
+ }
421
+ return wrote;
422
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Final session commit at a player seam.
3
+ *
4
+ * A delivery element coalesces its `session-changed` dispatch, so a response the
5
+ * learner finished entering can still be pending when the player is about to
6
+ * discard the element. Losing it produces no event, which leaves a host with
7
+ * nothing to detect.
8
+ *
9
+ * Reach, per seam:
10
+ *
11
+ * - `navigate` and `page-hidden` run while the elements are still attached, so
12
+ * the event reaches a `document`-level listener. Quiz Engine listens on
13
+ * `document`.
14
+ * - `teardown` runs from the player's own destroy, which a custom element only
15
+ * learns about in `disconnectedCallback` - after removal. The event reaches
16
+ * the player's own backend save and any listener bound directly to the player
17
+ * element, and it does not reach `document`. An SPA host that unmounts the
18
+ * player is served by `commitPendingSessions()` on the player element, called
19
+ * before the unmount.
20
+ *
21
+ * Two paths, per element:
22
+ *
23
+ * - An element that owns its notification exposes `commitPendingSession()`, so
24
+ * it dispatches its own event with its own `complete` semantics. The sweep
25
+ * marks that event with `detail.sessionCommitReason`, which the element
26
+ * cannot do for itself and which every guard in a `session-changed`'s way
27
+ * keys on.
28
+ * - An older element gets a synthesized `session-changed` built from its
29
+ * `session` getter. Those elements write the session synchronously and defer
30
+ * only the dispatch, so the read is never older than what the host was last
31
+ * told.
32
+ *
33
+ * Duplicated deliberately into `pie-player-components` and `pie-api-components`,
34
+ * which cannot depend on this package. Changes belong here first; the copies are
35
+ * `src/utils/session-commit.ts` and `src/session-commit.ts` respectively.
36
+ */
37
+ import type { PieLogger } from "./logger.js";
38
+ /**
39
+ * Commit hook a delivery element installs when it owns a deferred
40
+ * `session-changed`. Owned by `@pie-element/shared-player-events`
41
+ * (`createSessionNotifier`); the name is duplicated rather than imported so the
42
+ * players carry no dependency on an element-repo package.
43
+ */
44
+ export declare const SESSION_COMMIT_METHOD = "commitPendingSession";
45
+ export type SessionCommitReason = "teardown" | "navigate" | "page-hidden";
46
+ export interface CommitPendingSessionsOptions {
47
+ /** Recorded on a synthesized event as `detail.sessionCommitReason`. */
48
+ reason?: SessionCommitReason;
49
+ logger?: PieLogger;
50
+ }
51
+ export interface CommitPendingSessionsResult {
52
+ /** Elements that committed their own pending notification. */
53
+ committed: number;
54
+ /** Elements for which the player synthesized a `session-changed`. */
55
+ synthesized: number;
56
+ /** Candidate elements with nothing new to commit. */
57
+ skipped: number;
58
+ }
59
+ /**
60
+ * Record an element's session as one the host has been told about.
61
+ *
62
+ * A player calls this from its `session-changed` listener, which is what makes
63
+ * the commit's discriminant "has anything happened since the host last heard"
64
+ * rather than a guess about an element's session schema.
65
+ */
66
+ export declare function noteSessionObserved(target: EventTarget | null | undefined): void;
67
+ /**
68
+ * Seed the baseline for every delivery element in a subtree.
69
+ *
70
+ * A player calls this once its elements hold their loaded sessions, so a
71
+ * restored response the learner has not touched is never announced as a change.
72
+ */
73
+ export declare function noteSessionBaseline(root: ParentNode | null | undefined): void;
74
+ export declare function commitPendingSessions(root: ParentNode | null | undefined, options?: CommitPendingSessionsOptions): CommitPendingSessionsResult;
75
+ export interface BindPageLifecycleCommitOptions {
76
+ /**
77
+ * The subtree to commit, re-evaluated on every transition: a player's
78
+ * container is replaced during its lifetime.
79
+ */
80
+ root: () => ParentNode | null | undefined;
81
+ /**
82
+ * Runs after the synchronous commit, for a player that owns a backend save.
83
+ * A hidden document only performs synchronous work dependably, so a save
84
+ * started here needs `keepalive`.
85
+ */
86
+ onHidden?: (reason: SessionCommitReason) => void;
87
+ logger?: PieLogger;
88
+ }
89
+ /**
90
+ * Commit on the page going away rather than on the player being removed from it.
91
+ *
92
+ * `visibilitychange` to `hidden` is the primary signal: it is the last event a
93
+ * mobile browser reliably delivers before freezing or discarding a page.
94
+ * `pagehide` backs it up for same-document navigation and back/forward cache
95
+ * entry, and for iOS Safari, which can fire it without a preceding
96
+ * `visibilitychange`. Both fire on an ordinary navigation; the per-element
97
+ * baseline makes the second a no-op, and input arriving between them is still
98
+ * committed - which a "commit once per hidden transition" guard would drop.
99
+ * `beforeunload` is not used: it is unreliable on mobile and costs the
100
+ * back/forward cache.
101
+ *
102
+ * Returns an unbind function. Safe to call outside a browser, where it binds
103
+ * nothing.
104
+ */
105
+ export declare function bindPageLifecycleCommit(options: BindPageLifecycleCommitOptions): () => void;
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Final session commit at a player seam.
3
+ *
4
+ * A delivery element coalesces its `session-changed` dispatch, so a response the
5
+ * learner finished entering can still be pending when the player is about to
6
+ * discard the element. Losing it produces no event, which leaves a host with
7
+ * nothing to detect.
8
+ *
9
+ * Reach, per seam:
10
+ *
11
+ * - `navigate` and `page-hidden` run while the elements are still attached, so
12
+ * the event reaches a `document`-level listener. Quiz Engine listens on
13
+ * `document`.
14
+ * - `teardown` runs from the player's own destroy, which a custom element only
15
+ * learns about in `disconnectedCallback` - after removal. The event reaches
16
+ * the player's own backend save and any listener bound directly to the player
17
+ * element, and it does not reach `document`. An SPA host that unmounts the
18
+ * player is served by `commitPendingSessions()` on the player element, called
19
+ * before the unmount.
20
+ *
21
+ * Two paths, per element:
22
+ *
23
+ * - An element that owns its notification exposes `commitPendingSession()`, so
24
+ * it dispatches its own event with its own `complete` semantics. The sweep
25
+ * marks that event with `detail.sessionCommitReason`, which the element
26
+ * cannot do for itself and which every guard in a `session-changed`'s way
27
+ * keys on.
28
+ * - An older element gets a synthesized `session-changed` built from its
29
+ * `session` getter. Those elements write the session synchronously and defer
30
+ * only the dispatch, so the read is never older than what the host was last
31
+ * told.
32
+ *
33
+ * Duplicated deliberately into `pie-player-components` and `pie-api-components`,
34
+ * which cannot depend on this package. Changes belong here first; the copies are
35
+ * `src/utils/session-commit.ts` and `src/session-commit.ts` respectively.
36
+ */
37
+ import { hasLearnerResponse } from "./item-session-contract.js";
38
+ /**
39
+ * Commit hook a delivery element installs when it owns a deferred
40
+ * `session-changed`. Owned by `@pie-element/shared-player-events`
41
+ * (`createSessionNotifier`); the name is duplicated rather than imported so the
42
+ * players carry no dependency on an element-repo package.
43
+ */
44
+ export const SESSION_COMMIT_METHOD = "commitPendingSession";
45
+ /**
46
+ * Where the last-observed session signature is recorded, on the element itself.
47
+ *
48
+ * On the element rather than in a module-scope map for two reasons: a nested
49
+ * player stack loads two copies of this module from different bundles, and
50
+ * module state would then let each layer announce the same commit; and a map
51
+ * keyed by element records what the *sweep* last emitted, which suppresses a
52
+ * later commit of a value the sweep emitted once before (answer A, hide, answer
53
+ * B, answer A again, navigate mid-debounce - the host keeps B).
54
+ *
55
+ * `Symbol.for` so the two bundles resolve the same key.
56
+ */
57
+ const OBSERVED_SIGNATURE = Symbol.for("pie.sessionCommit.observedSignature");
58
+ function isRecord(value) {
59
+ return !!value && typeof value === "object";
60
+ }
61
+ /**
62
+ * A PIE delivery element carries both halves of the element contract: a
63
+ * `session` accessor and a `model` setter. Requiring both keeps the sweep off
64
+ * player chrome and tool custom elements, and an element that has not been
65
+ * upgraded yet has neither.
66
+ */
67
+ function isPieDeliveryElement(element) {
68
+ if (!element.tagName.includes("-"))
69
+ return false;
70
+ return "session" in element && "model" in element;
71
+ }
72
+ function shadowRootOf(node) {
73
+ const candidate = node
74
+ ?.shadowRoot;
75
+ return candidate ?? null;
76
+ }
77
+ function assignedElementsOf(element) {
78
+ const slot = element;
79
+ if (typeof slot.assignedElements !== "function")
80
+ return [];
81
+ try {
82
+ return slot.assignedElements({ flatten: true });
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ }
88
+ /**
89
+ * Walks the flattened tree: `children`, then open shadow roots, then the
90
+ * elements assigned to each `<slot>`.
91
+ *
92
+ * `querySelectorAll("*")` sees none of the last two, and both are load-bearing
93
+ * here. The section player mounts each item inside `<pie-item-shell>`'s shadow
94
+ * root, and projects its item pane through a slot on the assessment toolkit -
95
+ * a walk that stops at `<slot>` finds every delivery element in a section
96
+ * unreachable.
97
+ */
98
+ function collectCandidates(root, seen, out) {
99
+ const rootShadow = shadowRootOf(root);
100
+ if (rootShadow)
101
+ collectCandidates(rootShadow, seen, out);
102
+ const visit = (element) => {
103
+ if (seen.has(element))
104
+ return;
105
+ seen.add(element);
106
+ if (isPieDeliveryElement(element))
107
+ out.push(element);
108
+ collectCandidates(element, seen, out);
109
+ };
110
+ for (const assigned of assignedElementsOf(root))
111
+ visit(assigned);
112
+ const children = root.children;
113
+ if (!children)
114
+ return;
115
+ for (const element of Array.from(children))
116
+ visit(element);
117
+ }
118
+ /**
119
+ * Duck-typed rather than `instanceof Element`: the legacy players' test
120
+ * environment is Stencil's mock DOM, where a mounted element is not an instance
121
+ * of the global `Element`, and a check that silently fails there fails the same
122
+ * way against any other DOM implementation a host brings.
123
+ */
124
+ function isElementLike(node) {
125
+ return (!!node &&
126
+ typeof node === "object" &&
127
+ typeof node.tagName === "string");
128
+ }
129
+ function candidatesIn(root) {
130
+ const candidates = [];
131
+ const seen = new Set();
132
+ if (isElementLike(root) && isPieDeliveryElement(root)) {
133
+ seen.add(root);
134
+ candidates.push(root);
135
+ }
136
+ collectCandidates(root, seen, candidates);
137
+ return candidates;
138
+ }
139
+ function readSession(element) {
140
+ try {
141
+ return element.session;
142
+ }
143
+ catch {
144
+ // A getter that throws is not a session the player can commit.
145
+ return undefined;
146
+ }
147
+ }
148
+ function signatureOf(session) {
149
+ try {
150
+ const json = JSON.stringify(session);
151
+ // `JSON.stringify(undefined)` is `undefined`, which would be written as a
152
+ // signature and then read back as "no baseline".
153
+ return typeof json === "string" ? json : null;
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ }
159
+ function readObservedSignature(element) {
160
+ const recorded = element[OBSERVED_SIGNATURE];
161
+ return typeof recorded === "string" ? recorded : undefined;
162
+ }
163
+ function writeObservedSignature(element, signature) {
164
+ try {
165
+ Object.defineProperty(element, OBSERVED_SIGNATURE, {
166
+ value: signature,
167
+ configurable: true,
168
+ enumerable: false,
169
+ writable: true,
170
+ });
171
+ }
172
+ catch {
173
+ // A frozen element cannot be marked. It then commits again on the next
174
+ // seam, which a player's own session signature check absorbs.
175
+ }
176
+ }
177
+ /**
178
+ * Record an element's session as one the host has been told about.
179
+ *
180
+ * A player calls this from its `session-changed` listener, which is what makes
181
+ * the commit's discriminant "has anything happened since the host last heard"
182
+ * rather than a guess about an element's session schema.
183
+ */
184
+ export function noteSessionObserved(target) {
185
+ if (!isElementLike(target))
186
+ return;
187
+ const signature = signatureOf(readSession(target));
188
+ if (signature === null)
189
+ return;
190
+ writeObservedSignature(target, signature);
191
+ }
192
+ /**
193
+ * Seed the baseline for every delivery element in a subtree.
194
+ *
195
+ * A player calls this once its elements hold their loaded sessions, so a
196
+ * restored response the learner has not touched is never announced as a change.
197
+ */
198
+ export function noteSessionBaseline(root) {
199
+ if (!root)
200
+ return;
201
+ for (const element of candidatesIn(root)) {
202
+ const signature = signatureOf(readSession(element));
203
+ if (signature !== null)
204
+ writeObservedSignature(element, signature);
205
+ }
206
+ }
207
+ /**
208
+ * Mark an element-owned commit's own event as a commit, for the length of the
209
+ * sweep.
210
+ *
211
+ * `commitPendingSession()` lets the element dispatch its own `session-changed`,
212
+ * with its own `complete` semantics, and that event carries no commit marker.
213
+ * Every guard a player puts in a `session-changed`'s way keys on
214
+ * `detail.sessionCommitReason` to let a commit through: the Stencil player's
215
+ * model-set blocker, this project's renderer dedupe, the section player's
216
+ * cross-shell dedupe, `<pie-api-player>`'s save-now decision. Unmarked, an
217
+ * element-owned commit is exactly the event those guards drop - and the
218
+ * elements that adopted `createSessionNotifier` are the ones taking that path,
219
+ * so the exemption applied to nothing.
220
+ *
221
+ * A capture listener on the sweep root runs before the target's own listeners
222
+ * and before every bubble listener at or above the root, which is where all
223
+ * four guards sit. Only the absent key is added; a synthesized event already
224
+ * carries its own.
225
+ */
226
+ function markCommitsDuring(root, reason, sweep) {
227
+ const target = root;
228
+ if (typeof target.addEventListener !== "function")
229
+ return sweep();
230
+ const stamp = (event) => {
231
+ const detail = event.detail;
232
+ if (!isRecord(detail) || "sessionCommitReason" in detail)
233
+ return;
234
+ try {
235
+ detail.sessionCommitReason = reason;
236
+ }
237
+ catch {
238
+ // A frozen detail keeps its own contract; the commit still dispatches.
239
+ }
240
+ };
241
+ target.addEventListener("session-changed", stamp, true);
242
+ try {
243
+ return sweep();
244
+ }
245
+ finally {
246
+ target.removeEventListener("session-changed", stamp, true);
247
+ }
248
+ }
249
+ export function commitPendingSessions(root, options = {}) {
250
+ const result = {
251
+ committed: 0,
252
+ synthesized: 0,
253
+ skipped: 0,
254
+ };
255
+ if (!root)
256
+ return result;
257
+ return markCommitsDuring(root, options.reason ?? "teardown", () => sweepPendingSessions(root, options, result));
258
+ }
259
+ function sweepPendingSessions(root, options, result) {
260
+ for (const element of candidatesIn(root)) {
261
+ const session = readSession(element);
262
+ if (!isRecord(session)) {
263
+ result.skipped += 1;
264
+ continue;
265
+ }
266
+ const signature = signatureOf(session);
267
+ if (signature === null) {
268
+ result.skipped += 1;
269
+ continue;
270
+ }
271
+ const observed = readObservedSignature(element);
272
+ if (observed === signature) {
273
+ // The host already has this. Nothing is pending, whichever path the
274
+ // element would have taken.
275
+ result.skipped += 1;
276
+ continue;
277
+ }
278
+ if (observed === undefined && !hasLearnerResponse(session)) {
279
+ // No baseline, so the player never saw this element load - the section
280
+ // player's shells and the legacy Stencil stack both have paths where
281
+ // that happens. Announcing an untouched element's identity-only session
282
+ // makes a host that re-renders on `session-changed` cancel work it was
283
+ // in the middle of: a config swap re-pushed the host's own config and
284
+ // discarded the incoming item.
285
+ result.skipped += 1;
286
+ continue;
287
+ }
288
+ const commit = element[SESSION_COMMIT_METHOD];
289
+ if (typeof commit === "function") {
290
+ // An element's own commit is a no-op when nothing is pending, and its
291
+ // session can still differ from what the host heard: a controller
292
+ // writing into the session, or an element path that stores a value
293
+ // without notifying. Counting the call as the announcement recorded a
294
+ // response the host never received and every later seam then skipped
295
+ // it, so what the element dispatches is what counts. `flush()` is
296
+ // synchronous in every implementation of this contract; one that
297
+ // deferred would be announced twice, which a player's signature check
298
+ // absorbs.
299
+ let dispatched = false;
300
+ const witness = () => {
301
+ dispatched = true;
302
+ };
303
+ element.addEventListener("session-changed", witness, true);
304
+ let failed = false;
305
+ try {
306
+ commit.call(element);
307
+ }
308
+ catch (error) {
309
+ failed = true;
310
+ options.logger?.warn(`[commitPendingSessions] ${element.tagName.toLowerCase()} failed to commit its pending session`, error);
311
+ }
312
+ finally {
313
+ element.removeEventListener("session-changed", witness, true);
314
+ }
315
+ if (failed) {
316
+ result.skipped += 1;
317
+ continue;
318
+ }
319
+ if (dispatched) {
320
+ writeObservedSignature(element, signature);
321
+ result.committed += 1;
322
+ continue;
323
+ }
324
+ // Nothing was pending, so fall through and announce the session the
325
+ // element holds.
326
+ }
327
+ try {
328
+ element.dispatchEvent(new CustomEvent("session-changed", {
329
+ bubbles: true,
330
+ composed: true,
331
+ detail: {
332
+ component: element.tagName.toLowerCase(),
333
+ session: JSON.parse(signature),
334
+ sessionCommitReason: options.reason ?? "teardown",
335
+ },
336
+ }));
337
+ writeObservedSignature(element, signature);
338
+ result.synthesized += 1;
339
+ }
340
+ catch (error) {
341
+ options.logger?.warn(`[commitPendingSessions] ${element.tagName.toLowerCase()} session commit dispatch failed`, error);
342
+ result.skipped += 1;
343
+ }
344
+ }
345
+ return result;
346
+ }
347
+ /**
348
+ * Commit on the page going away rather than on the player being removed from it.
349
+ *
350
+ * `visibilitychange` to `hidden` is the primary signal: it is the last event a
351
+ * mobile browser reliably delivers before freezing or discarding a page.
352
+ * `pagehide` backs it up for same-document navigation and back/forward cache
353
+ * entry, and for iOS Safari, which can fire it without a preceding
354
+ * `visibilitychange`. Both fire on an ordinary navigation; the per-element
355
+ * baseline makes the second a no-op, and input arriving between them is still
356
+ * committed - which a "commit once per hidden transition" guard would drop.
357
+ * `beforeunload` is not used: it is unreliable on mobile and costs the
358
+ * back/forward cache.
359
+ *
360
+ * Returns an unbind function. Safe to call outside a browser, where it binds
361
+ * nothing.
362
+ */
363
+ export function bindPageLifecycleCommit(options) {
364
+ if (typeof document === "undefined" || typeof window === "undefined") {
365
+ return () => { };
366
+ }
367
+ const commit = (reason) => {
368
+ commitPendingSessions(options.root(), {
369
+ reason,
370
+ logger: options.logger,
371
+ });
372
+ try {
373
+ options.onHidden?.(reason);
374
+ }
375
+ catch (error) {
376
+ options.logger?.warn("[bindPageLifecycleCommit] onHidden failed", error);
377
+ }
378
+ };
379
+ const handleVisibilityChange = () => {
380
+ if (document.visibilityState === "hidden")
381
+ commit("page-hidden");
382
+ };
383
+ const handlePageHide = () => {
384
+ commit("page-hidden");
385
+ };
386
+ document.addEventListener("visibilitychange", handleVisibilityChange);
387
+ window.addEventListener("pagehide", handlePageHide);
388
+ return () => {
389
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
390
+ window.removeEventListener("pagehide", handlePageHide);
391
+ };
392
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-players/pie-players-shared",
3
- "version": "0.3.72",
3
+ "version": "0.3.73",
4
4
  "type": "module",
5
5
  "description": "Shared runtime + UI utilities for PIE players",
6
6
  "license": "MIT",
@@ -117,7 +117,7 @@
117
117
  "scan-hardcoded": "bun run src/i18n/scripts/scan-hardcoded.ts"
118
118
  },
119
119
  "devDependencies": {
120
- "@biomejs/biome": "^2.5.11",
120
+ "@biomejs/biome": "^2.5.13",
121
121
  "@happy-dom/global-registrator": "^20.11.15",
122
122
  "@playwright/test": "^1.62.1",
123
123
  "@types/semver": "^7.8.0",