diff --git a/public/demo-html-trrack/assets/dots-count.html b/public/demo-html-trrack/assets/dots-count.html
index 3c860c1437..52e0f064ac 100644
--- a/public/demo-html-trrack/assets/dots-count.html
+++ b/public/demo-html-trrack/assets/dots-count.html
@@ -142,7 +142,8 @@
// Initialize Trrack
- const trrack = initializeTrrack({
+ const trrack = Revisit.createTrrack({
+ initializeTrrack,
initialState,
registry
});
@@ -182,7 +183,6 @@
Revisit.postAnswers({
[taskId]: dots.length
});
- Revisit.postProvenance(trrack.graph.backend);
}
// Register a listener to update the dot chart based on trrack updates.
diff --git a/public/demo-svelte-trrack/assets/dots-count.html b/public/demo-svelte-trrack/assets/dots-count.html
index 10d69adfb2..0272803ad4 100644
--- a/public/demo-svelte-trrack/assets/dots-count.html
+++ b/public/demo-svelte-trrack/assets/dots-count.html
@@ -77,7 +77,8 @@
}
);
- const trrack = initializeTrrack({
+ const trrack = Revisit.createTrrack({
+ initializeTrrack,
initialState,
registry
});
@@ -128,7 +129,6 @@
Revisit.postAnswers({
[taskId]: dots.length
});
- Revisit.postProvenance(trrack.graph.backend);
}
trrack.currentChange(updateRevisit);
diff --git a/public/revisitUtilities/revisit-communicate.js b/public/revisitUtilities/revisit-communicate.js
index 3fcd766687..f604ff2f5d 100644
--- a/public/revisitUtilities/revisit-communicate.js
+++ b/public/revisitUtilities/revisit-communicate.js
@@ -21,6 +21,48 @@
let onDataReceiveCallback = null;
let onProvenanceReceiveCallback = null;
let onAnswerReceiveCallback = null;
+ const managedTrrackSubscriptions = new Set();
+
+ const appendTraversalEvent = (previousProvenance, incomingProvenance, observedAt) => {
+ const isSameGraph = previousProvenance?.root === incomingProvenance.root;
+ const previousEvents = isSameGraph && Array.isArray(previousProvenance?.traversalEvents)
+ ? previousProvenance.traversalEvents
+ : [];
+ const incomingEvents = Array.isArray(incomingProvenance.traversalEvents)
+ ? incomingProvenance.traversalEvents
+ : [];
+ const traversalEvents = incomingEvents.length >= previousEvents.length
+ ? incomingEvents
+ : previousEvents;
+ const currentNodeId = incomingProvenance.current;
+ const currentNode = incomingProvenance.nodes?.[currentNodeId];
+
+ if (!currentNode || traversalEvents.at(-1)?.nodeId === currentNodeId) {
+ return traversalEvents.length > 0
+ ? { ...incomingProvenance, traversalEvents }
+ : incomingProvenance;
+ }
+
+ const isNewNode = !isSameGraph || !previousProvenance?.nodes?.[currentNodeId];
+ const eventTime = isNewNode ? currentNode.createdOn : observedAt;
+ const previousEventTime = traversalEvents.at(-1)?.createdOn ?? Number.NEGATIVE_INFINITY;
+
+ return {
+ ...incomingProvenance,
+ traversalEvents: [
+ ...traversalEvents,
+ {
+ nodeId: currentNodeId,
+ createdOn: Math.max(eventTime, previousEventTime + 1),
+ },
+ ],
+ };
+ };
+
+ const disposeManagedTrracks = () => {
+ managedTrrackSubscriptions.forEach((unsubscribe) => unsubscribe());
+ managedTrrackSubscriptions.clear();
+ };
window.addEventListener('message', function (e) {
const data = e.data;
@@ -41,9 +83,35 @@
postAnswers: (answers) => {
sendMessage("ANSWERS", answers);
},
+ /**
+ * @deprecated Use Revisit.createTrrack so every traversal is reported automatically.
+ */
postProvenance: (provenance) => {
sendMessage("PROVENANCE", provenance);
},
+ createTrrack: ({ initializeTrrack, ...options }) => {
+ if (typeof initializeTrrack !== 'function') {
+ throw new TypeError('Revisit.createTrrack requires the Trrack initializeTrrack function.');
+ }
+
+ const trrack = initializeTrrack(options);
+ let previousProvenance;
+ const publishProvenance = () => {
+ const provenance = appendTraversalEvent(
+ previousProvenance,
+ trrack.graph.backend,
+ Date.now(),
+ );
+ previousProvenance = provenance;
+ sendMessage("PROVENANCE", provenance);
+ };
+ const unsubscribe = trrack.currentChange(publishProvenance);
+
+ managedTrrackSubscriptions.add(unsubscribe);
+ publishProvenance();
+
+ return trrack;
+ },
postEvent: (eventName, objectId) => {
sendMessage("EVENT", { eventName, objectId });
},
@@ -72,4 +140,9 @@
},
false
);
+ window.addEventListener('pagehide', (event) => {
+ if (!event.persisted) {
+ disposeManagedTrracks();
+ }
+ });
})();
diff --git a/src/components/audioAnalysis/provenanceReplay.ts b/src/components/audioAnalysis/provenanceReplay.ts
index 818a8ffed7..ffad23baa6 100644
--- a/src/components/audioAnalysis/provenanceReplay.ts
+++ b/src/components/audioAnalysis/provenanceReplay.ts
@@ -5,29 +5,15 @@ export type ProvenanceReplaySelection = ProvenanceTraversalEvent & {
fromTraversal: boolean;
};
-export function getReplaySelection(
+/**
+ * @deprecated Graph creation order cannot reproduce undo, redo, or arbitrary
+ * traversal. Retained only for provenance recorded before traversal events.
+ */
+function getLegacyReplaySelection(
provenance: TrrackedProvenance,
playTime: number,
currentNode?: string | null,
): ProvenanceReplaySelection {
- const traversalEvents = getProvenanceTraversalEvents(provenance);
-
- if (traversalEvents.length > 0) {
- let replaySelection = {
- nodeId: provenance.root as string,
- createdOn: provenance.nodes[provenance.root].createdOn,
- fromTraversal: true,
- };
-
- traversalEvents.forEach((event) => {
- if (event.createdOn <= playTime) {
- replaySelection = { ...event, fromTraversal: true };
- }
- });
-
- return replaySelection;
- }
-
if (!currentNode || !provenance.nodes[currentNode]) {
return {
nodeId: provenance.root as string,
@@ -65,6 +51,32 @@ export function getReplaySelection(
};
}
+export function getReplaySelection(
+ provenance: TrrackedProvenance,
+ playTime: number,
+ currentNode?: string | null,
+): ProvenanceReplaySelection {
+ const traversalEvents = getProvenanceTraversalEvents(provenance);
+
+ if (traversalEvents.length > 0) {
+ let replaySelection = {
+ nodeId: provenance.root as string,
+ createdOn: provenance.nodes[provenance.root].createdOn,
+ fromTraversal: true,
+ };
+
+ traversalEvents.forEach((event) => {
+ if (event.createdOn <= playTime) {
+ replaySelection = { ...event, fromTraversal: true };
+ }
+ });
+
+ return replaySelection;
+ }
+
+ return getLegacyReplaySelection(provenance, playTime, currentNode);
+}
+
export function getReplayNodeId(
provenance: TrrackedProvenance,
playTime: number,
diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx
index f278a9570e..4d0ad3656d 100644
--- a/src/components/response/ResponseBlock.tsx
+++ b/src/components/response/ResponseBlock.tsx
@@ -8,7 +8,7 @@ import React, {
import isEqual from 'lodash.isequal';
import { IconAlertTriangle } from '@tabler/icons-react';
import { useNavigate } from 'react-router';
-import { Registry, initializeTrrack } from '@trrack/core';
+import { Registry } from '@trrack/core';
import {
IndividualComponent,
ResponseBlockLocation,
@@ -33,7 +33,7 @@ import { shouldUseStimulusValidation } from './stimulusErrors';
import { ResponseSwitcher } from './ResponseSwitcher';
import { FeedbackAlert } from './FeedbackAlert';
import {
- CustomResponseField, FormElementProvenance, StoredAnswer,
+ CustomResponseField, FormElementProvenance, StoredAnswer, TrrackedProvenance,
} from '../../store/types';
import { useStudyConfig } from '../../store/hooks/useStudyConfig';
import { useStoredAnswer } from '../../store/hooks/useStoredAnswer';
@@ -42,6 +42,7 @@ import { useIsAnalysis } from '../../store/hooks/useIsAnalysis';
import { responseAnswerIsCorrect } from '../../utils/correctAnswer';
import { getCustomResponseModule, getCustomResponseModuleLoadError } from './customResponseModules';
import { appendStimulusShowErrorsToGraph } from './stimulusProvenance';
+import { useManagedTrrack } from '../../store/hooks/useRevisitTrrack';
import { useStorageEngine } from '../../storage/storageEngineHooks';
import { showNotification } from '../../utils/notifications';
import { getAnswersFromAllLocations } from '../../utils/getAnswersFromAllLocations';
@@ -71,10 +72,12 @@ export function ResponseBlock({
}: Props) {
const storeDispatch = useStoreDispatch();
const {
- updateResponseBlockValidation, saveIncorrectAnswer, saveTrialAnswer, setResponseSubmitAttempt, setStimulusSubmitAttempt, setCheckAnswerResult,
+ updateProvenance, updateResponseBlockValidation, saveIncorrectAnswer, saveTrialAnswer, setResponseSubmitAttempt, setStimulusSubmitAttempt, setCheckAnswerResult,
} = useStoreActions();
const currentStep = useCurrentStep();
+ const identifier = useCurrentIdentifier();
+ const isAnalysis = useIsAnalysis();
const currentProvenance = useStoreSelector((state) => state.analysisProvState[location]) as FormElementProvenance | undefined;
const storedAnswer = useMemo(() => currentProvenance?.form || status?.answer, [currentProvenance, status]);
@@ -113,7 +116,7 @@ export function ResponseBlock({
}), [allResponses]);
// Set up trrack to store provenance graph of the answerValidator status
- const { actions, trrack } = useMemo(() => {
+ const { actions, registry } = useMemo(() => {
const reg = Registry.create();
const updateFormAction = reg.register('update', (state, payload: StoredAnswer['answer']) => {
@@ -125,22 +128,29 @@ export function ResponseBlock({
return state;
});
- const trrackInst = initializeTrrack({
- registry: reg,
- initialState: {
- form: null,
- showResponseErrors: false,
- },
- });
-
return {
actions: {
updateFormAction,
updateShowResponseErrorsAction,
},
- trrack: trrackInst,
+ registry: reg,
};
}, []);
+ const reportProvenance = useCallback((provenanceGraph: TrrackedProvenance) => {
+ if (isAnalysis) return;
+ storeDispatch(updateProvenance({
+ location,
+ identifier,
+ provenanceGraph,
+ }));
+ }, [identifier, isAnalysis, location, storeDispatch, updateProvenance]);
+ const trrack = useManagedTrrack({
+ registry,
+ initialState: {
+ form: null,
+ showResponseErrors: false,
+ },
+ }, reportProvenance, identifier);
const reactiveAnswers = useStoreSelector((state) => state.reactiveAnswers);
@@ -150,7 +160,6 @@ export function ResponseBlock({
const trialValidation = useStoreSelector((state) => state.trialValidation);
const analysisProvState = useStoreSelector((state) => state.analysisProvState);
const { goToNextStep } = useNextStep();
- const isAnalysis = useIsAnalysis();
const studyConfig = useStudyConfig();
@@ -158,7 +167,6 @@ export function ResponseBlock({
const hasCorrectAnswerFeedback = !!provideFeedback && ((config?.correctAnswer?.length || 0) > 0);
const allowFailedTraining = useMemo(() => config?.allowFailedTraining ?? studyConfig.uiConfig.allowFailedTraining ?? true, [config, studyConfig]);
const trainingAttempts = useMemo(() => config?.trainingAttempts ?? studyConfig.uiConfig.trainingAttempts ?? 2, [config, studyConfig]);
- const identifier = useCurrentIdentifier();
const savedSubmitAttempt = storedAnswerData?.responseSubmitAttempted ?? status?.responseSubmitAttempted ?? false;
const currentSubmitAttempt = useStoreSelector((state) => state.responseSubmitAttempted[identifier]);
const liveErrors = currentSubmitAttempt ?? savedSubmitAttempt;
@@ -395,7 +403,6 @@ export function ResponseBlock({
identifier,
status: answerValidator.isValid(),
values: structuredClone(answerValidator.values),
- provenanceGraph: trrack.graph.backend,
});
}
// Disable exhaustive-deps because we only want this to run when there is a new storedAnswer
@@ -449,7 +456,6 @@ export function ResponseBlock({
identifier,
status: answerValidator.isValid() || bypassValidationForFailedTraining,
values: structuredClone(answerValidator.values),
- provenanceGraph: trrack.graph.backend,
}),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -467,7 +473,6 @@ export function ResponseBlock({
identifier,
status: answerValidator.isValid() || bypassValidationForFailedTraining,
values: structuredClone(answerValidator.values),
- provenanceGraph: trrack.graph.backend,
}),
);
}, [actions, answerValidator, bypassValidationForFailedTraining, identifier, isAnalysis, liveErrors, location, storeDispatch, trrack, updateResponseBlockValidation]);
diff --git a/src/components/response/tests/ResponseBlock.spec.tsx b/src/components/response/tests/ResponseBlock.spec.tsx
index 9f201944b1..2ad5477e88 100644
--- a/src/components/response/tests/ResponseBlock.spec.tsx
+++ b/src/components/response/tests/ResponseBlock.spec.tsx
@@ -73,6 +73,7 @@ vi.mock('@trrack/core', () => ({
},
initializeTrrack: vi.fn(() => ({
apply: vi.fn(),
+ currentChange: vi.fn(() => vi.fn()),
graph: {
backend: {
root: 'root',
diff --git a/src/controllers/IframeController.tsx b/src/controllers/IframeController.tsx
index 926de75e23..1c1e27e2f5 100644
--- a/src/controllers/IframeController.tsx
+++ b/src/controllers/IframeController.tsx
@@ -12,7 +12,7 @@ const PREFIX = '@REVISIT_COMMS';
export function IframeController({ currentConfig, provState, answers }: { currentConfig: WebsiteComponent; provState?: unknown, answers: ParticipantData['answers'] }) {
const {
- setReactiveAnswers, updateResponseBlockValidation,
+ setReactiveAnswers, updateProvenance, updateResponseBlockValidation,
} = useStoreActions();
const storeDispatch = useStoreDispatch();
const dispatch = useDispatch();
@@ -96,15 +96,10 @@ export function IframeController({ currentConfig, provState, answers }: { curren
break;
case `${PREFIX}/PROVENANCE`: {
if (isAnalysis) return;
- const currentStimulusValidation = stimulusValidationRef.current;
- storeDispatch(updateResponseBlockValidation({
+ storeDispatch(updateProvenance({
location: 'stimulus',
identifier,
- values: {},
- status: currentStimulusValidation?.valid ?? true,
provenanceGraph: data.message,
- reason: currentStimulusValidation?.reason,
- message: currentStimulusValidation?.message,
}));
break;
}
@@ -117,7 +112,7 @@ export function IframeController({ currentConfig, provState, answers }: { curren
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
- }, [storeDispatch, dispatch, iframeId, currentConfig, sendMessage, setReactiveAnswers, updateResponseBlockValidation, identifier, isAnalysis, provState, answers]);
+ }, [storeDispatch, dispatch, iframeId, currentConfig, sendMessage, setReactiveAnswers, updateProvenance, updateResponseBlockValidation, identifier, isAnalysis, provState, answers]);
return (