Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 24 additions & 14 deletions apps/web/src/components/TslPreviewCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type {
TslPreviewModuleResult,
TslPreviewModuleRuntime,
} from '../../../../packages/schema/src/tsl-preview-module.ts'
import { createPlainErrorReport, createTslErrorReport, TslPreviewError } from '../lib/tsl-error-reporting'
import type { PlaygroundErrorReport } from '../lib/playground-types'

type THREE = typeof import('three/webgpu')
type TSL = typeof import('three/tsl')
Expand All @@ -12,7 +14,7 @@ type TslPreviewCanvasProps = {
pipeline: string
fallbackSvg?: string | null
uniformOverrides?: Record<string, number | number[] | boolean>
onError?: (errors: string[]) => void
onError?: (report: PlaygroundErrorReport) => void
onScreenshotReady?: (base64: string) => void
}

Expand Down Expand Up @@ -55,14 +57,14 @@ export default function TslPreviewCanvas(props: TslPreviewCanvasProps) {
const [loading, setLoading] = createSignal(true)
const [error, setError] = createSignal('')

function setPreviewError(message: string) {
setError(message)
props.onError?.([message])
function setPreviewError(report: PlaygroundErrorReport) {
setError(report.errors[0] ?? report.structuredErrors[0]?.message ?? 'Preview unavailable')
props.onError?.(report)
}

function clearPreviewError() {
setError('')
props.onError?.([])
props.onError?.(createPlainErrorReport([]))
}

function captureScreenshot() {
Expand Down Expand Up @@ -110,7 +112,10 @@ export default function TslPreviewCanvas(props: TslPreviewCanvasProps) {

const module = (await import(/* @vite-ignore */ currentModuleUrl)) as PreviewModuleNamespace
if (typeof module.createPreview !== 'function') {
throw new Error('TSL preview modules must export createPreview(runtime).')
throw new TslPreviewError(
'tsl-material-build',
'TSL preview modules must export createPreview(runtime).',
)
}

const nextPreview = module.createPreview({
Expand All @@ -123,7 +128,10 @@ export default function TslPreviewCanvas(props: TslPreviewCanvasProps) {
})

if (!nextPreview?.material || typeof nextPreview.material !== 'object') {
throw new Error('createPreview(runtime) must return an object with a material.')
throw new TslPreviewError(
'tsl-material-build',
'createPreview(runtime) must return an object with a material.',
)
}

previewInstance = nextPreview as PreviewInstance
Expand All @@ -144,9 +152,7 @@ export default function TslPreviewCanvas(props: TslPreviewCanvasProps) {
} catch (previewError) {
disposePreviewMesh()
setPreviewError(
previewError instanceof Error
? previewError.message
: 'Failed to build the TSL preview module.',
createTslErrorReport(previewError, 'tsl-runtime', 'Failed to build the TSL preview module.'),
)
} finally {
setLoading(false)
Expand All @@ -155,7 +161,13 @@ export default function TslPreviewCanvas(props: TslPreviewCanvasProps) {

onMount(async () => {
if (!('gpu' in navigator)) {
setPreviewError('WebGPU is not available in this browser.')
setPreviewError(
createTslErrorReport(
new TslPreviewError('tsl-runtime', 'WebGPU is not available in this browser.'),
'tsl-runtime',
'WebGPU is not available in this browser.',
Comment on lines 163 to +168
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit roundabout — you construct a TslPreviewError just so createTslErrorReport can unwrap it back into the same kind and message. The message string appears three times.

Since the kind and message are fully known here, you could skip the round-trip:

setPreviewError({
  errors: ['WebGPU is not available in this browser.'],
  structuredErrors: [{ kind: 'tsl-runtime', message: 'WebGPU is not available in this browser.' }],
})

Or if you prefer keeping the helper for consistency, at least the TslPreviewError wrapper carries the info without needing the redundant string args:

const err = new TslPreviewError('tsl-runtime', 'WebGPU is not available in this browser.')
setPreviewError(createTslErrorReport(err, 'tsl-runtime', err.message))

nit — works correctly as-is

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 19c2091. I added a direct createKnownTslErrorReport() helper and switched the WebGPU gate to use it, so there is no redundant TslPreviewError round-trip and the message only lives in one place.

),
)
setLoading(false)
return
}
Expand Down Expand Up @@ -236,9 +248,7 @@ export default function TslPreviewCanvas(props: TslPreviewCanvasProps) {
await renderPreview(props.previewModule)
} catch (previewError) {
setPreviewError(
previewError instanceof Error
? previewError.message
: 'Failed to initialize the TSL preview runtime.',
createTslErrorReport(previewError, 'tsl-runtime', 'Failed to initialize the TSL preview runtime.'),
)
setLoading(false)
}
Expand Down
16 changes: 9 additions & 7 deletions apps/web/src/components/playground/PlaygroundCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
import { buildTslPreviewModule } from '../../../../../packages/schema/src/tsl-preview-module.ts'
import { collectShaderDiagnostics, diagnosticsToMessages } from '../../lib/webgl-shader-errors'
import { createPlainErrorReport, createTslErrorReport } from '../../lib/tsl-error-reporting'
import type { PlaygroundErrorReport } from '../../lib/playground-types'
import TslPreviewCanvas from '../TslPreviewCanvas'

type THREE = typeof import('three')
Expand All @@ -11,7 +13,7 @@ type PlaygroundCanvasProps = {
tslSource?: string
pipeline: string
language: 'glsl' | 'tsl'
onError: (errors: string[]) => void
onError: (report: PlaygroundErrorReport) => void
onScreenshotReady: (base64: string) => void
}

Expand All @@ -29,7 +31,7 @@ export default function PlaygroundCanvas(props: PlaygroundCanvasProps) {
try {
return buildTslPreviewModule(props.tslSource)
} catch (error) {
props.onError([error instanceof Error ? error.message : 'Failed to build TSL preview module'])
props.onError(createTslErrorReport(error, 'tsl-parse', 'Failed to build TSL preview module.'))
return ''
}
})
Expand Down Expand Up @@ -172,7 +174,7 @@ export default function PlaygroundCanvas(props: PlaygroundCanvasProps) {
uniforms: shaderUniforms,
})
} catch (e) {
props.onError([e instanceof Error ? e.message : 'Shader compilation failed'])
props.onError(createPlainErrorReport([e instanceof Error ? e.message : 'Shader compilation failed']))
return
}

Expand Down Expand Up @@ -210,21 +212,21 @@ export default function PlaygroundCanvas(props: PlaygroundCanvasProps) {
? renderError.message
: 'Shader compilation failed'

props.onError(
props.onError(createPlainErrorReport(
shaderDiagnostics.length > 0 ? diagnosticsToMessages(shaderDiagnostics) : [fallbackMessage],
)
))
return
} finally {
renderer.debug.onShaderError = previousShaderError
}

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting for a future enhancement: GLSL errors go through createPlainErrorReport (no structured errors), even though webgl-shader-errors.ts already produces ShaderDiagnostic objects with kind: 'glsl-compile' | 'glsl-link' that map directly to PlaygroundError. If you wanted full structured coverage for both GLSL and TSL, you could map ShaderDiagnostic[]PlaygroundError[] here instead of flattening to strings. Not in scope for this PR though.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept out of scope for this PR, but I filed the follow-up here: #76. That issue tracks mapping ShaderDiagnostic[] through PlaygroundErrorReport so GLSL gets the same structured coverage as TSL.

if (shaderDiagnostics.length > 0) {
props.onError(diagnosticsToMessages(shaderDiagnostics))
props.onError(createPlainErrorReport(diagnosticsToMessages(shaderDiagnostics)))
return
}

// No errors — clear any previous errors and capture screenshot
props.onError([])
props.onError(createPlainErrorReport([]))
captureScreenshot()
}

Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/playground/PlaygroundLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createSignal, onCleanup, onMount, Show, lazy } from 'solid-js'
import type { PlaygroundSession } from '../../lib/playground-types'
import type { PlaygroundErrorReport, PlaygroundSession } from '../../lib/playground-types'

const PlaygroundCanvas = lazy(() => import('./PlaygroundCanvas'))
const PlaygroundEditor = lazy(() => import('./PlaygroundEditor'))
Expand Down Expand Up @@ -93,13 +93,13 @@ export default function PlaygroundLayout(props: PlaygroundLayoutProps) {
}
}

function handleErrors(errs: string[]) {
setErrors(errs)
function handleErrors(report: PlaygroundErrorReport) {
setErrors(report.errors)
// Post errors to server so MCP can query them
fetch(`/api/playground/${props.session.id}/errors`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ errors: errs }),
body: JSON.stringify(report),
}).catch(() => {})
}

Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/lib/playground-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export type PlaygroundError =
| { kind: 'tsl-runtime'; message: string }
| { kind: 'tsl-material-build'; message: string }

export type PlaygroundErrorReport = {
errors: string[]
structuredErrors: PlaygroundError[]
}

// ---------------------------------------------------------------------------
// Session types — discriminated union on language
// ---------------------------------------------------------------------------
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/lib/tsl-error-reporting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import {
createPlainErrorReport,
createTslErrorReport,
TslPreviewError,
} from './tsl-error-reporting.ts'

function runTest(name: string, callback: () => void) {
callback()
console.log(`ok ${name}`)
}

runTest('createPlainErrorReport keeps structured errors empty', () => {
assert.deepEqual(createPlainErrorReport(['plain error']), {
errors: ['plain error'],
structuredErrors: [],
})
})

runTest('createTslErrorReport preserves explicit preview error kinds', () => {
const report = createTslErrorReport(
new TslPreviewError('tsl-material-build', 'createPreview(runtime) must return a material.'),
'tsl-runtime',
'fallback',
)

assert.deepEqual(report, {
errors: ['createPreview(runtime) must return a material.'],
structuredErrors: [{
kind: 'tsl-material-build',
message: 'createPreview(runtime) must return a material.',
}],
})
})

runTest('createTslErrorReport maps SyntaxError to tsl-parse', () => {
const report = createTslErrorReport(
new SyntaxError('Unexpected token'),
'tsl-runtime',
'fallback',
)

assert.deepEqual(report, {
errors: ['Unexpected token'],
structuredErrors: [{
kind: 'tsl-parse',
message: 'Unexpected token',
}],
})
})
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good coverage of the TslPreviewError and SyntaxError branches. One optional gap: a plain Error (not TslPreviewError, not SyntaxError) should use fallbackKind. Easy to add:

runTest('createTslErrorReport uses fallbackKind for generic errors', () => {
  const report = createTslErrorReport(
    new Error('something broke'),
    'tsl-runtime',
    'fallback',
  )
  assert.deepEqual(report, {
    errors: ['something broke'],
    structuredErrors: [{ kind: 'tsl-runtime', message: 'something broke' }],
  })
})

nit / optional

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 19c2091. I added the plain Error coverage so the allbackKind path is now tested directly.


console.log('tsl-error-reporting tests passed')
50 changes: 50 additions & 0 deletions apps/web/src/lib/tsl-error-reporting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { PlaygroundErrorReport, PlaygroundError } from './playground-types'

type TslErrorKind = Extract<PlaygroundError['kind'], 'tsl-parse' | 'tsl-runtime' | 'tsl-material-build'>

export class TslPreviewError extends Error {
kind: TslErrorKind

constructor(kind: TslErrorKind, message: string) {
super(message)
this.kind = kind
this.name = 'TslPreviewError'
}
}

function getMessage(error: unknown, fallbackMessage: string): string {
if (error instanceof Error && error.message.trim()) {
return error.message.trim()
}

if (typeof error === 'string' && error.trim()) {
return error.trim()
}

return fallbackMessage
}

export function createPlainErrorReport(errors: string[]): PlaygroundErrorReport {
return {
errors,
structuredErrors: [],
}
}

export function createTslErrorReport(
error: unknown,
fallbackKind: TslErrorKind,
fallbackMessage: string,
): PlaygroundErrorReport {
const message = getMessage(error, fallbackMessage)
const kind = error instanceof TslPreviewError
? error.kind
: error instanceof SyntaxError
? 'tsl-parse'
: fallbackKind

return {
errors: [message],
structuredErrors: [{ kind, message }],
}
Comment on lines +34 to +49
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean classification chain. The TslPreviewErrorSyntaxError → fallback priority is the right heuristic for catching TSL module errors at different stages (parse, runtime, material build).

One thing to be aware of: PlaygroundErrorReport here and ErrorReportPayload in playground-db.ts are structurally identical types with different names. They're compatible via structural typing so nothing breaks, but if you ever want to consolidate, PlaygroundErrorReport from playground-types.ts is the natural single source since it's already in the shared types file. The server could import it instead of defining ErrorReportPayload separately. Not blocking — just noting the duplication.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 19c2091. playground-db.ts now uses the shared PlaygroundErrorReport type directly instead of keeping a separate ErrorReportPayload alias.

}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"check": "bun run test && bun run typecheck && bun run validate:shaders && bun run build:web",
"dev:web": "cd apps/web && bun run dev",
"test": "node --experimental-strip-types packages/schema/src/index.test.ts && bun run test:cli && bun run test:mcp && bun run test:registry && bun run test:web",
"test:web": "node --experimental-strip-types apps/web/src/lib/server/shaders.test.ts && node --experimental-strip-types apps/web/src/lib/server/reviews-db.test.node.ts && node --experimental-strip-types apps/web/src/lib/server/playground-db.test.node.ts && node --experimental-strip-types apps/web/src/lib/webgl-shader-errors.test.ts",
"test:web": "node --experimental-strip-types apps/web/src/lib/server/shaders.test.ts && node --experimental-strip-types apps/web/src/lib/server/reviews-db.test.node.ts && node --experimental-strip-types apps/web/src/lib/server/playground-db.test.node.ts && node --experimental-strip-types apps/web/src/lib/webgl-shader-errors.test.ts && node --experimental-strip-types apps/web/src/lib/tsl-error-reporting.test.ts",
"test:cli": "node --experimental-strip-types packages/cli/src/registry-types.test.ts && node --experimental-strip-types packages/cli/src/commands/search.test.ts && node --experimental-strip-types packages/cli/src/commands/add.test.ts && node --experimental-strip-types packages/cli/src/lib/resolve-source.test.ts && node --experimental-strip-types packages/cli/src/lib/build-manifest.test.ts && node --experimental-strip-types packages/cli/src/lib/github-pr.test.ts && node --experimental-strip-types packages/cli/src/commands/submit.test.ts",
"test:mcp": "node --experimental-strip-types packages/mcp/src/handlers.test.ts && node --experimental-strip-types packages/mcp/src/index.test.ts",
"test:registry": "node --experimental-strip-types scripts/build-registry.test.ts",
Expand Down