From 5498dcabd30f2e86654fe2dd7017ac63168d17a7 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 16 Sep 2026 13:42:51 -0400 Subject: [PATCH 1/3] feat(playground): block bundle upload while the editor reports type errors The transpiler state was never a type check: esbuild strips types without checking them, so an instrument that fails tsc still reaches status 'built' and uploaded happily. Monaco already validates every instrument file, so a new diagnostics slice holds the error-severity markers owned by the typescript and javascript languages, and UploadBundleDialog disables Upload and lists them while that list is non-empty. useEditorErrorSync asks for markers one file at a time rather than reading every marker monaco holds, because monaco also holds models for the runtime declarations, globals.d.ts and files the user has deleted, none of which should block an upload. Co-Authored-By: Claude Opus 5 (1M context) --- apps/playground/AGENTS.md | 8 ++++ .../src/components/Editor/EditorPane.tsx | 3 ++ .../ActionsDropdown/UploadBundleDialog.tsx | 48 ++++++++++++++++++- .../src/hooks/useEditorErrorSync.ts | 44 +++++++++++++++++ apps/playground/src/store/index.ts | 2 + .../src/store/slices/diagnostics.slice.ts | 10 ++++ apps/playground/src/store/types.ts | 19 +++++++- 7 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 apps/playground/src/hooks/useEditorErrorSync.ts create mode 100644 apps/playground/src/store/slices/diagnostics.slice.ts diff --git a/apps/playground/AGENTS.md b/apps/playground/AGENTS.md index 25ffd6632..353851090 100644 --- a/apps/playground/AGENTS.md +++ b/apps/playground/AGENTS.md @@ -93,6 +93,14 @@ only caller, so tightening it silently breaks upload here and nowhere else (#139 narrower because a bundle is evaluated server-side: whoever may create an instrument can already run code on the API. `testing/src/specs/authorization.spec.ts` pins the contract. +Upload is gated on monaco's own diagnostics: `useEditorErrorSync` writes every error-severity marker +owned by the `typescript` or `javascript` language into `editorErrors`, and `UploadBundleDialog` +refuses to post while that list is non-empty. The transpiler state is not that gate — esbuild strips +types without checking them, so an instrument that fails `tsc` still reaches `status: 'built'`. The +hook asks for markers file by file, because monaco also holds models for the runtime declarations, +`globals.d.ts` and files the user has deleted (`deleteFile` never disposes a model), none of which +should block an upload. + Share links come from `@opendatacapture/playground-url`, which lz-string-compresses file contents into the URL fragment. A link that differs only in its fragment does not reload the page, so `IndexPage` reads the URL through `useLocationHref`, which re-renders on `hashchange`; reading `location.href` diff --git a/apps/playground/src/components/Editor/EditorPane.tsx b/apps/playground/src/components/Editor/EditorPane.tsx index 4d45e5f19..830e2986b 100644 --- a/apps/playground/src/components/Editor/EditorPane.tsx +++ b/apps/playground/src/components/Editor/EditorPane.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useImperativeHandle, useRef, useState } from 'react'; import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import MonacoEditor from '@monaco-editor/react'; +import { useEditorErrorSync } from '@/hooks/useEditorErrorSync'; import { useFilesRef } from '@/hooks/useFilesRef'; import { useRuntime } from '@/hooks/useRuntime'; import type { EditorFile } from '@/models/editor-file.model'; @@ -38,6 +39,8 @@ export const EditorPane = React.forwardRef(funct const [defaultFile, setDefaultFile] = useState<(EditorFile & { id: string }) | null>(null); const filesRef = useFilesRef(); + useEditorErrorSync(isMounted ? monacoRef.current : null); + useImperativeHandle( ref, () => ({ diff --git a/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx b/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx index 32b8d9b45..e45756feb 100644 --- a/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx +++ b/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx @@ -9,6 +9,8 @@ import type { AxiosResponse } from 'axios'; import { useAppStore } from '@/store'; +const MAX_DISPLAYED_ERRORS = 5; + export type UploadBundleDialogProps = { isOpen: boolean; onLoginRequired: () => void; @@ -20,6 +22,7 @@ export const UploadBundleDialog = ({ isOpen, setIsOpen, onLoginRequired }: Uploa const auth = useAppStore((store) => store.auth); const apiBaseUrl = useAppStore((store) => store.settings.apiBaseUrl); + const editorErrors = useAppStore((store) => store.editorErrors); const revalidateToken = useAppStore((store) => store.revalidateToken); const transpilerStateRef = useRef(useAppStore.getState().transpilerState); @@ -40,7 +43,16 @@ export const UploadBundleDialog = ({ isOpen, setIsOpen, onLoginRequired }: Uploa const handleSubmit = async () => { const state = transpilerStateRef.current; - if (state.status === 'building' || state.status === 'initial') { + if (editorErrors.length > 0) { + addNotification({ + message: t({ + en: 'Upload Failed: Type Errors', + fr: 'Échec du téléversement : Erreurs de type' + }), + type: 'error' + }); + return; + } else if (state.status === 'building' || state.status === 'initial') { addNotification({ message: t({ en: 'Upload Failed: Transpilation Incomplete', @@ -124,7 +136,39 @@ export const UploadBundleDialog = ({ isOpen, setIsOpen, onLoginRequired }: Uploa {t({ en: ' to upload a bundle.', fr: ' pour téléverser un paquet.' })}

)} - diff --git a/apps/playground/src/hooks/useEditorErrorSync.ts b/apps/playground/src/hooks/useEditorErrorSync.ts new file mode 100644 index 000000000..b32940be8 --- /dev/null +++ b/apps/playground/src/hooks/useEditorErrorSync.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; + +import type { MonacoType } from '@/components/Editor/types'; +import { useAppStore } from '@/store'; +import type { EditorError } from '@/store/types'; + +const TYPE_CHECKED_MARKER_OWNERS: string[] = ['javascript', 'typescript']; + +/** + * Markers are keyed by model, and monaco holds models the instrument does not own: the runtime + * declarations, `globals.d.ts`, and the models of files the user has since deleted, which are never + * disposed. Asking file by file is what keeps those out of the result. + */ +const collectEditorErrors = (monaco: MonacoType, filenames: string[]): EditorError[] => { + return filenames.flatMap((filename) => { + return monaco.editor + .getModelMarkers({ resource: monaco.Uri.parse(filename) }) + .filter( + (marker) => marker.severity === monaco.MarkerSeverity.Error && TYPE_CHECKED_MARKER_OWNERS.includes(marker.owner) + ) + .map((marker) => ({ filename, line: marker.startLineNumber, message: marker.message })); + }); +}; + +export function useEditorErrorSync(monaco: MonacoType | null) { + const setEditorErrors = useAppStore((store) => store.setEditorErrors); + useEffect(() => { + if (!monaco) { + return; + } + const refresh = () => { + const filenames = useAppStore.getState().files.map((file) => file.name); + setEditorErrors(collectEditorErrors(monaco, filenames)); + }; + refresh(); + const markersListener = monaco.editor.onDidChangeMarkers(refresh); + const unsubscribeFromFiles = useAppStore.subscribe((store) => store.files, refresh); + return () => { + markersListener.dispose(); + unsubscribeFromFiles(); + setEditorErrors([]); + }; + }, [monaco]); +} diff --git a/apps/playground/src/store/index.ts b/apps/playground/src/store/index.ts index 95c45bcd1..4d497fc8e 100644 --- a/apps/playground/src/store/index.ts +++ b/apps/playground/src/store/index.ts @@ -10,6 +10,7 @@ import { immer } from 'zustand/middleware/immer'; import { resolveIndexFilename } from '@/utils/file'; import { createAuthSlice } from './slices/auth.slice'; +import { createDiagnosticsSlice } from './slices/diagnostics.slice'; import { createEditorSlice } from './slices/editor.slice'; import { createInstrumentSlice } from './slices/instrument.slice'; import { createSettingsSlice } from './slices/settings.slice'; @@ -36,6 +37,7 @@ export const useAppStore = create( subscribeWithSelector( immer((...a) => ({ ...createAuthSlice(...a), + ...createDiagnosticsSlice(...a), ...createEditorSlice(...a), ...createInstrumentSlice(...a), ...createSettingsSlice(...a), diff --git a/apps/playground/src/store/slices/diagnostics.slice.ts b/apps/playground/src/store/slices/diagnostics.slice.ts new file mode 100644 index 000000000..ff7abcc79 --- /dev/null +++ b/apps/playground/src/store/slices/diagnostics.slice.ts @@ -0,0 +1,10 @@ +import type { DiagnosticsSlice, SliceCreator } from '../types'; + +export const createDiagnosticsSlice: SliceCreator = (set) => ({ + editorErrors: [], + setEditorErrors: (editorErrors) => { + set((state) => { + state.editorErrors = editorErrors; + }); + } +}); diff --git a/apps/playground/src/store/types.ts b/apps/playground/src/store/types.ts index 5bc9fa6f6..c19a91be6 100644 --- a/apps/playground/src/store/types.ts +++ b/apps/playground/src/store/types.ts @@ -44,6 +44,17 @@ export type AuthSlice = { revalidateToken: () => void; }; +export type EditorError = { + filename: string; + line: number; + message: string; +}; + +export type DiagnosticsSlice = { + editorErrors: EditorError[]; + setEditorErrors: (editorErrors: EditorError[]) => void; +}; + export type EditorState = { files: EditorFile[]; indexFilename: null | string; @@ -87,7 +98,13 @@ export type ViewerSlice = { }; }; -export type AppStore = AuthSlice & EditorSlice & InstrumentSlice & SettingsSlice & TranspilerSlice & ViewerSlice; +export type AppStore = AuthSlice & + DiagnosticsSlice & + EditorSlice & + InstrumentSlice & + SettingsSlice & + TranspilerSlice & + ViewerSlice; export type SliceCreator = StateCreator< AppStore, From 188ad51f003692d03950d65670c27a23c851732b Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 16 Sep 2026 13:56:00 -0400 Subject: [PATCH 2/3] feat(playground): surface the API's own error message when an upload fails A failed upload reported only the status line as the notification message, so a 409 said nothing about why the instrument was rejected. The status now titles the notification as "409 - Conflict", and the message is whatever the JSON body carries under `message`, or nothing when the body has none or is not JSON at all. A request that never got a response keeps its previous "HTTP Request Failed" notification. Co-Authored-By: Claude Opus 5 (1M context) --- .../ActionsDropdown/UploadBundleDialog.tsx | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx b/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx index e45756feb..00d1e515f 100644 --- a/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx +++ b/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx @@ -6,11 +6,24 @@ import { Button, Dialog } from '@douglasneuroinformatics/libui/components'; import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import axios, { isAxiosError } from 'axios'; import type { AxiosResponse } from 'axios'; +import { z } from 'zod/v4'; import { useAppStore } from '@/store'; const MAX_DISPLAYED_ERRORS = 5; +const $ErrorResponseBody = z.object({ + message: z.union([z.string(), z.array(z.string())]) +}); + +const getResponseErrorMessage = (data: unknown): string | undefined => { + const result = $ErrorResponseBody.safeParse(data); + if (!result.success) { + return undefined; + } + return Array.isArray(result.data.message) ? result.data.message.join(', ') : result.data.message; +}; + export type UploadBundleDialogProps = { isOpen: boolean; onLoginRequired: () => void; @@ -94,16 +107,19 @@ export const UploadBundleDialog = ({ isOpen, setIsOpen, onLoginRequired }: Uploa ); } catch (err) { console.error(err); - let message: string; - if (isAxiosError(err)) { - message = err.response ? `${err.response.status} ${err.response.statusText}` : err.message; - } else { - message = 'Unknown Error'; + const response = isAxiosError(err) ? err.response : undefined; + if (!response) { + addNotification({ + message: isAxiosError(err) ? err.message : 'Unknown Error', + type: 'error', + title: t({ en: 'HTTP Request Failed', fr: 'Échec de la requête HTTP' }) + }); + return; } addNotification({ - message, + message: getResponseErrorMessage(response.data), type: 'error', - title: t({ en: 'HTTP Request Failed', fr: 'Échec de la requête HTTP' }) + title: [response.status, response.statusText].filter(Boolean).join(' - ') }); return; } From b18d77f8ea614ce1adf478707998b69e2f2d7874 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 16 Sep 2026 14:02:52 -0400 Subject: [PATCH 3/3] chore: release v2.4.0 --- CHANGELOG.md | 12 ++++++++++++ docs/en/6-changelog/changelog.md | 12 ++++++++++++ package.json | 2 +- packages/instrument-bundler/package.json | 2 +- packages/instrument-guidelines/package.json | 2 +- packages/playground-url/package.json | 2 +- packages/serve-instrument/package.json | 2 +- runtime/v1/package.json | 2 +- 8 files changed, 30 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d996bb15..8cb74ddb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ Every release of Open Data Capture from 2.0.0 onward is recorded here. Each sect commit history when the version changes, and becomes the body of the GitHub release. Releases before 2.0.0 are listed in the [release history](https://github.com/DouglasNeuroinformatics/OpenDataCapture/releases). +## 2.4.0 + +### Features + +- **playground:** surface the API's own error message when an upload fails ([188ad51](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/188ad51f003692d03950d65670c27a23c851732b)) +- **playground:** block bundle upload while the editor reports type errors ([5498dca](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/5498dcabd30f2e86654fe2dd7017ac63168d17a7)) + +### Bug Fixes + +- **playground:** match preview mode help tooltip to libui tooltip styling ([fa58149](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/fa58149996c2d6300402ce33282412a1c0c0cd2f)) +- **playground:** replace inner Radix tooltip with CSS-only tooltip in ShareButton ([746db5e](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/746db5ed361f5a8028138cce05c0627e23f7e2ae)) + ## 2.3.1 ### Bug Fixes diff --git a/docs/en/6-changelog/changelog.md b/docs/en/6-changelog/changelog.md index b20888f55..5bfed22cc 100644 --- a/docs/en/6-changelog/changelog.md +++ b/docs/en/6-changelog/changelog.md @@ -7,6 +7,18 @@ Every release of Open Data Capture from 2.0.0 onward is recorded here. Each sect commit history when the version changes, and becomes the body of the GitHub release. Releases before 2.0.0 are listed in the [release history](https://github.com/DouglasNeuroinformatics/OpenDataCapture/releases). +## 2.4.0 + +### Features + +- **playground:** surface the API's own error message when an upload fails ([188ad51](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/188ad51f003692d03950d65670c27a23c851732b)) +- **playground:** block bundle upload while the editor reports type errors ([5498dca](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/5498dcabd30f2e86654fe2dd7017ac63168d17a7)) + +### Bug Fixes + +- **playground:** match preview mode help tooltip to libui tooltip styling ([fa58149](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/fa58149996c2d6300402ce33282412a1c0c0cd2f)) +- **playground:** replace inner Radix tooltip with CSS-only tooltip in ShareButton ([746db5e](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/746db5ed361f5a8028138cce05c0627e23f7e2ae)) + ## 2.3.1 ### Bug Fixes diff --git a/package.json b/package.json index 1fb48d355..71de39c5f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opendatacapture", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "private": true, "packageManager": "pnpm@10.34.3", "license": "Apache-2.0", diff --git a/packages/instrument-bundler/package.json b/packages/instrument-bundler/package.json index 09373c806..649be1de8 100644 --- a/packages/instrument-bundler/package.json +++ b/packages/instrument-bundler/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/instrument-bundler", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "sideEffects": [ "**/cli.ts" ], diff --git a/packages/instrument-guidelines/package.json b/packages/instrument-guidelines/package.json index 7c1d6d279..283dcbd38 100644 --- a/packages/instrument-guidelines/package.json +++ b/packages/instrument-guidelines/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/instrument-guidelines", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "description": "Guidelines for authoring Open Data Capture instruments, intended to be read by an AI agent (e.g. Claude Code).", "license": "Apache-2.0", "publishConfig": { diff --git a/packages/playground-url/package.json b/packages/playground-url/package.json index 675f93980..5a94f2789 100644 --- a/packages/playground-url/package.json +++ b/packages/playground-url/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/playground-url", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "sideEffects": [ "**/cli.ts" ], diff --git a/packages/serve-instrument/package.json b/packages/serve-instrument/package.json index aec1f1268..de39980b7 100644 --- a/packages/serve-instrument/package.json +++ b/packages/serve-instrument/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/serve-instrument", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "license": "Apache-2.0", "publishConfig": { "access": "public" diff --git a/runtime/v1/package.json b/runtime/v1/package.json index 1e9891f52..4fcd3bc48 100644 --- a/runtime/v1/package.json +++ b/runtime/v1/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/runtime-v1", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "author": { "name": "Douglas Neuroinformatics", "email": "support@douglasneuroinformatics.ca"