From 61c1e354f4432e3742d6853600f735b72005256f Mon Sep 17 00:00:00 2001 From: bhabishnu <156663652+bhabishnu@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:50:31 +0530 Subject: [PATCH 1/2] fix: add friendly errors for hook lifecycle misuse --- src/strands/p5.strands.js | 7 ++++ src/strands/strands_api.js | 25 +++++++++++-- test/unit/webgl/p5.Shader.js | 68 ++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/strands/p5.strands.js b/src/strands/p5.strands.js index f50af5a15b..f69943d929 100644 --- a/src/strands/p5.strands.js +++ b/src/strands/p5.strands.js @@ -6,6 +6,7 @@ import { transpileStrandsToJS } from './strands_transpiler'; import { BlockType } from './ir_types'; +import * as FES from './strands_FES'; import { createDirectedAcyclicGraph } from './ir_dag'; import { @@ -298,6 +299,12 @@ function strands(p5, fn) { strandsCallback(); } if (options.hook) strandsContext.renderer._pInst[options.hook].end(); + if (strandsContext.activeHook) { + FES.userError( + 'scope error', + `It looks like a hook's begin() was called without a matching end().` + ); + } popBlock(strandsContext.cfg); // 3. Generate shader code hooks object from the IR diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index b873ae916a..4210426e6d 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -1435,9 +1435,14 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) { finishHook(); }; - // In the flat strands API, this is how result-returning hooks - // are used + // In the flat strands API, result-returning hooks use set(). hook.set = function (result) { + if (!hook._active) { + FES.userError( + 'scope error', + `It looks like you're trying to call set() outside of a hook's begin()/end() block.` + ); + } hook._result = result; }; hook._active = false; @@ -1525,6 +1530,22 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) { strandsContext.activeHook = undefined; const expectedReturnType = hookType.returnType; + const expectsReturnValue = + isStructType(expectedReturnType) || + (expectedReturnType.dataType && + expectedReturnType.typeName?.trim() !== 'void'); + + if ( + expectsReturnValue && + userReturned === undefined && + hook.earlyReturns.length === 0 + ) { + FES.userError( + 'scope error', + `This hook requires a value. Make sure to call set() before end().` + ); + } + let rootNodeID = null; const handleRetVal = retNode => { if (isStructType(expectedReturnType)) { diff --git a/test/unit/webgl/p5.Shader.js b/test/unit/webgl/p5.Shader.js index d993fb69e4..5c3a2bff7b 100644 --- a/test/unit/webgl/p5.Shader.js +++ b/test/unit/webgl/p5.Shader.js @@ -4233,6 +4233,74 @@ suite('p5.Shader', function () { }); }); + test('set() outside a hook block reports a scope error', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + try { + myp5.baseFilterShader().modify( + () => { + myp5.filterColor.begin(); + myp5.filterColor.set([1, 0, 0, 1]); + myp5.filterColor.end(); + myp5.filterColor.set([0, 1, 0, 1]); + }, + { myp5 } + ); + } catch { + /* expected */ + } + + const scopeCall = mockUserError.mock.calls.find( + call => call[0] === 'scope error' && call[1].includes('set()') + ); + assert.isDefined(scopeCall, 'scope error should have been called'); + }); + + test('ending a value-returning hook without a value reports a scope error', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + try { + myp5.baseFilterShader().modify( + () => { + myp5.filterColor.begin(); + myp5.filterColor.end(); + }, + { myp5 } + ); + } catch { + /* expected */ + } + + const scopeCall = mockUserError.mock.calls.find( + call => + call[0] === 'scope error' && + call[1].includes('requires a value') + ); + assert.isDefined(scopeCall, 'scope error should have been called'); + }); + + test('missing end() reports a scope error', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + try { + myp5.baseFilterShader().modify( + () => { + myp5.filterColor.begin(); + myp5.filterColor.set([1, 0, 0, 1]); + }, + { myp5 } + ); + } catch { + /* expected */ + } + const scopeCall = mockUserError.mock.calls.find( + call => + call[0] === 'scope error' && + call[1].includes('without a matching end()') + ); + assert.isDefined(scopeCall, 'scope error should have been called'); + }); + test('scope error uses unprefixed hook name', () => { myp5.createCanvas(50, 50, myp5.WEBGL); From 5a55f168674ad034dd3d01913938164781e57a9b Mon Sep 17 00:00:00 2001 From: bhabishnu <156663652+bhabishnu@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:21:58 +0530 Subject: [PATCH 2/2] fix: name strands hooks in lifecycle errors --- src/strands/p5.strands.js | 2 +- src/strands/strands_api.js | 8 +++++-- test/unit/webgl/p5.Shader.js | 44 +++++++++++++++++++++++++++++++++++- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/strands/p5.strands.js b/src/strands/p5.strands.js index f69943d929..b86b15c521 100644 --- a/src/strands/p5.strands.js +++ b/src/strands/p5.strands.js @@ -302,7 +302,7 @@ function strands(p5, fn) { if (strandsContext.activeHook) { FES.userError( 'scope error', - `It looks like a hook's begin() was called without a matching end().` + `It looks like ${strandsContext.activeHook._publicName}.begin() was called without a matching ${strandsContext.activeHook._publicName}.end().` ); } popBlock(strandsContext.cfg); diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 4210426e6d..1f55ab9d67 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -1440,7 +1440,7 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) { if (!hook._active) { FES.userError( 'scope error', - `It looks like you're trying to call set() outside of a hook's begin()/end() block.` + `It looks like you're trying to call ${hook._publicName}.set() outside of its begin()/end() block.` ); } hook._result = result; @@ -1542,7 +1542,8 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) { ) { FES.userError( 'scope error', - `This hook requires a value. Make sure to call set() before end().` + `${hook._publicName} requires a value. Make sure to call ` + + `${hook._publicName}.set(value) before ${hook._publicName}.end().` ); } @@ -1690,6 +1691,9 @@ export function createShaderHooksFunctions(strandsContext, fn, shader) { for (const name of aliases) { augmentFnTemporary(fn, strandsContext, name, hook); } + // Name used in error messages: an explicit alias like filterColor if + // there is one, otherwise the unprefixed alias like finalColor + hook._publicName = aliases[1] ?? hookType.name; hook.earlyReturns = []; } } diff --git a/test/unit/webgl/p5.Shader.js b/test/unit/webgl/p5.Shader.js index 5c3a2bff7b..ec3c0c8d28 100644 --- a/test/unit/webgl/p5.Shader.js +++ b/test/unit/webgl/p5.Shader.js @@ -4254,6 +4254,8 @@ suite('p5.Shader', function () { call => call[0] === 'scope error' && call[1].includes('set()') ); assert.isDefined(scopeCall, 'scope error should have been called'); + assert.include(scopeCall[1], 'filterColor.set()'); + assert.notInclude(scopeCall[1], 'getColor'); }); test('ending a value-returning hook without a value reports a scope error', () => { @@ -4277,6 +4279,42 @@ suite('p5.Shader', function () { call[1].includes('requires a value') ); assert.isDefined(scopeCall, 'scope error should have been called'); + assert.include(scopeCall[1], 'filterColor requires a value'); + assert.include( + scopeCall[1], + 'filterColor.set(value) before filterColor.end()' + ); + assert.notInclude(scopeCall[1], 'getColor'); + }); + + test('value-required error uses the hook\'s own public name', () => { + myp5.createCanvas(50, 50, myp5.WEBGL); + + try { + myp5.baseMaterialShader().modify( + () => { + myp5.finalColor.begin(); + myp5.finalColor.end(); + }, + { myp5 } + ); + } catch { + /* expected */ + } + + const scopeCall = mockUserError.mock.calls.find( + call => + call[0] === 'scope error' && + call[1].includes('requires a value') + ); + assert.isDefined(scopeCall, 'scope error should have been called'); + assert.include(scopeCall[1], 'finalColor requires a value'); + assert.include( + scopeCall[1], + 'finalColor.set(value) before finalColor.end()' + ); + assert.notInclude(scopeCall[1], 'getFinalColor'); + assert.notInclude(scopeCall[1], 'filterColor'); }); test('missing end() reports a scope error', () => { @@ -4296,9 +4334,13 @@ suite('p5.Shader', function () { const scopeCall = mockUserError.mock.calls.find( call => call[0] === 'scope error' && - call[1].includes('without a matching end()') + call[1].includes('without a matching') ); assert.isDefined(scopeCall, 'scope error should have been called'); + assert.include( + scopeCall[1], + 'filterColor.begin() was called without a matching filterColor.end()' + ); }); test('scope error uses unprefixed hook name', () => {