Add a DataPipe extension, so an experiment needs no DataPipe code - #3718
Conversation
Registering the extension in initJsPsych is the whole integration. It stages each trial as it finishes, so a participant who closes the tab partway through does not take their data with them, and it submits the complete dataset when the experiment ends. No save trial, no await, and no session variable threaded through the timeline. It takes jsPsych's two global callbacks through the public getInitSettings(), wrapping rather than replacing them, instead of using the per-trial extension callbacks. Those fire only for trials whose own `extensions` parameter names the extension -- a different thing from the array passed to initJsPsych, and one a nested timeline shadows without merging -- so an extension relying on them would silently miss trials. on_finish is also awaited by run(), which is what lets the final upload block the end of the experiment, and what makes the save survive abortExperiment(): that unwinds the timeline and falls through, where a save trial is simply never reached. The staging protocol itself lives in datapipe-client, which is published from the DataPipe repository. It is mocked in these tests as a virtual module, since it is not installed in this monorepo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgDfss2zhjwMky2mBKbcBX
Covers the one deviation from the usual extension shape: there are no trial parameters and it is never added to a trial or timeline, because saving an experiment's data should not depend on where else a researcher happened to use an extension. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgDfss2zhjwMky2mBKbcBX
createSession() returns synchronously and the /api/session round trip finishes later, so sessionId is empty until it does. A short experiment can reach on_finish inside that window, and the submission would then go without the id -- leaving DataPipe unable to match the file to the staged copy, which it would recover a second time as a spurious .partial.json. flush() already waits for the start request to settle, so flushing before reading sessionId fixes it, and restores the flush-before-submit ordering the plugin had: it also makes the staged copy as complete as it can be if the submission fails. Verified against the real datapipe-client types rather than the test's mock: tsc and the rollup build both pass with the package linked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgDfss2zhjwMky2mBKbcBX
Both are static, because neither fits the extension's own lifecycle: a condition usually decides which timeline to build, so it has to be known before initJsPsych() runs, and a media file is saved from a trial's on_finish. They are here only so an experiment needs one script tag rather than two -- without them the docs would recommend the extension for saving data and then send researchers back to the plugin for the other two things DataPipe does. getCondition throws, and the tests pin that it propagates rather than returning a fallback. A participant sent down the wrong branch, or an empty one, looks like a successful run until someone reads the data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgDfss2zhjwMky2mBKbcBX
🦋 Changeset detectedLatest commit: 1f44c89 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Review catch on #3718. The option let a jsPsychPipe save trial own the submission instead of the extension, and that combination cannot work. The plugin has no session, so it cannot send a sessionId. DataPipe therefore has no way to tell that the submission completes the staged copy, and nothing discards the staging node. Meanwhile the extension saw the trial's success and cancelled the abandonment stamp, so the sweep's `abandoned` branch never fired either. The node sat there until the 24-hour expiry backstop, which recovered the same trials a second time as a .partial.json -- a duplicate of data the researcher already had, a day late, with a phantom "session in progress" row on the dashboard until then. Making it work would mean giving the plugin the session back, which is exactly the coupling that splitting the client out removed. It is not worth that for an option whose only purpose was keeping a save trial in the timeline, and the extension already saves at the same point. Both docs now say plainly not to keep a save trial, and why: the second submission is refused as a duplicate filename, which the extension reads as a failed save and responds to by marking the session abandoned. A saveBase64 trial is unaffected. 24 tests, tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgDfss2zhjwMky2mBKbcBX
|
You were right to be suspicious — it was the duplicate problem, not just a strange one. Removed The sequence, once I traced it:
So the researcher got their complete file and then, a day later, a partial duplicating it — plus a phantom "1 session in progress" row on the dashboard until the sweep cleaned up, since the mirror doc is only removed by It can't be fixed without undoing the split. Linking a submission to a session means Both the README and the docs page now say plainly not to keep a save trial, and why it bites even without the option: two submissions, the second refused as a duplicate filename, which the extension reads as a failed save and responds to by marking the session abandoned. A There's a test pinning it now: a stray pipe save trial in the timeline no longer diverts anything, and the session is closed once, on the extension's own result. |
jsPsych.simulate() reaches this extension exactly like a real run: simulate() sets simulationMode and then calls run(), so extensions initialize identically. simulationMode is private and getSimulationMode lives only on the internal dependencies object, so the extension has no way to notice. Left on, simulating an experiment consumes one of its sessions and writes a real file of fake data into the researcher's dataset -- silently, and in the one situation where they are least expecting real side effects. `enabled: false` turns everything off: no session, no staging, no submission, and the researcher's own callbacks untouched. Documented against simulate() specifically rather than as a generic flag, since that is the case nobody would think to look for. Also guards double registration. initialize() is called once per entry in the extensions array and the same instance answers each time, so listing the extension twice would wrap the global callbacks twice and submit the data twice. 27 tests, tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgDfss2zhjwMky2mBKbcBX
The blocker on this branch: package.json declared datapipe-client but the lockfile had no entry, and CI runs npm ci, which fails when the two disagree. The lockfile could not be regenerated until the package existed on the registry. It does now (0.1.0), so this is the install. Adds 932 lines, nearly all of it firebase's tree. That is the cost of the split and it is paid here rather than by every plugin-pipe user, which was the point. The jest mock is no longer `virtual`. It had to be while the module did not exist; now that it does, letting it resolve for real means a mock that drifts from the package it stands in for fails here instead of passing against a module that is not there. Verified: npm ci exits 0, 27 tests, tsc clean, and the rollup build resolves the published package rather than the local symlink used during development. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgDfss2zhjwMky2mBKbcBX
📦 Preview build readyBuilt from PR head Changed packages: Quick-start HTML: <script src="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@2f7d1ef53fab245b47acad286077b3c3c7d6962b/packages/jspsych/dist/index.browser.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@2f7d1ef53fab245b47acad286077b3c3c7d6962b/packages/jspsych/css/jspsych.css">
<script src="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@2f7d1ef53fab245b47acad286077b3c3c7d6962b/packages/plugin-html-keyboard-response/dist/index.browser.min.js"></script>All package URLs
Last updated 2026-09-16 14:38 UTC for PR head |
The wait message told participants not to close the page and was never removed, so an experiment without its own ending screen left it up forever. Once the upload settles, and after the researcher's on_finish, the extension now shows done_message -- unless something else has taken over the display. Both messages are configurable for translation. Documents that a redirect in on_finish should set done_message, since the done message stays visible while the destination loads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…arams A filename function that threw in initialize() stopped the hooks from being installed, and since jsPsych does not handle that promise, the experiment ran with no saving at all. The hooks are now installed first, and a failed filename is retried at the end; if it fails again the data is saved under a random name rather than not at all. Registering the extension twice let the second entry overwrite params after the first had already set up the session and filename. The duplicate check now runs before params are stored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: jodeleeuw <595524+jodeleeuw@users.noreply.github.com>
Co-authored-by: jodeleeuw <595524+jodeleeuw@users.noreply.github.com>
A new extension for sending data to DataPipe. Registering it is the whole integration:
No save trial, no
await, no session variable threaded through the timeline. Each trial is staged as it finishes, so a participant who closes the tab at trial 199 of 200 does not take all 199 with them, and the complete dataset is submitted when the experiment ends.It does not use the per-trial extension callbacks
This is the part most worth reviewing, because it is a deliberate departure from how every other extension works.
on_start/on_load/on_finishfire only for trials whose ownextensionsparameter names the extension — which is a different thing from the array passed toinitJsPsych.TimelineNode.getParameterValuereturns the nearest value walking up, and onlydatagets special merge treatment viagetDataParameter(), so a nestedextensionsshadows rather than merges. A researcher who declares this extension on the root timeline and then writesextensions: [{type: jsPsychExtensionMouseTracking}]on one inner trial would silently stop staging that trial. Losing data quietly is the exact failure this feature exists to prevent.There is a second problem:
ExtensionManager.onFinishruns every extension under onePromise.alland merges afterwards, soon_finishcannot see a complete trial record anyway — not even its own contribution, let alone another extension's.So the extension wraps jsPsych's two global callbacks through the public
getInitSettings():on_data_updatefires for every trial after its data is final.on_finishis awaited byrun(), which is what lets the final upload block the end of the experiment.Both are wrapped, never replaced, and the save runs before the researcher's own
on_finish— that is where a redirect to Prolific or MTurk usually lives, and running after it would race a page navigation against the upload.The consequence for researchers is that they add
extensionstoinitJsPsychand to nothing else.It survives
abortExperiment()abortExperiment()unwinds the timeline and falls through toon_finish, so an extension-owned save still runs. A save trial is never reached, which means a participant failed out by an attention check currently loses everything. There is a test for this.Two statics
getConditionandsaveBase64Data. Neither fits the extension's lifecycle — a condition usually decides which timeline to build, so it must be known beforeinitJsPsychruns, and a media file is saved from a trial'son_finish. They are here only so an experiment needs one script tag rather than two.getConditionthrows. Everything about staging fails quietly on purpose, because the data is submitted again at the end; a condition has no safe fallback value, and a participant sent down the wrong branch looks like a successful run until someone reads the data.Verification
25 tests. Verified against the real
datapipe-client(linked into the monorepo, not the mock):tsc --noEmitclean and the full rollup build passes. Browser bundle 52.5 KB gzipped, essentially all of it the Firebase SDK.Docs page at
docs/extensions/pipe.md, wired intomkdocs.ymland the extension list.Why this is a draft
packages/extension-pipedepends ondatapipe-client, which is not yet published to npm. The rootpackage-lock.jsontherefore has no entry for it, and CI runsnpm ci, which fails whenpackage.jsonand the lockfile disagree. The lockfile cannot be regenerated until the package exists on the registry.To unblock: publish
datapipe-client(from jspsych/datapipe#234, already merged totest), thennpm installat this repo's root to update the lockfile, and mark this ready for review.The tests mock
datapipe-clientas a virtual module, sojestpasses today regardless — it is onlynpm cithat is blocked.🤖 Generated with Claude Code
https://claude.ai/code/session_01SgDfss2zhjwMky2mBKbcBX