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.' })}