From 349dd3fd7ccbbfebecf8618de9b196468f3fa034 Mon Sep 17 00:00:00 2001 From: Ayush Date: Fri, 25 Sep 2026 00:08:16 +0530 Subject: [PATCH 1/4] feat: Re-enable FES in strands via dual Zod registry --- src/friendly_errors/param_validator.js | 50 ++++++++++++++++++++++++-- src/strands/p5.strands.js | 4 +-- src/strands/strands_api.js | 40 ++++++++++++++++++++- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/friendly_errors/param_validator.js b/src/friendly_errors/param_validator.js index 8510831344..c17d8dc03a 100644 --- a/src/friendly_errors/param_validator.js +++ b/src/friendly_errors/param_validator.js @@ -18,6 +18,7 @@ let documentationData = dataDoc; function validateParams(p5, fn, lifecycles) { // Cache for Zod schemas let schemaRegistry = new Map(); + let strandsSchemaRegistry = new Map(); // Mapping names of p5 types to their constructor functions. // p5Constructors: @@ -200,6 +201,17 @@ function validateParams(p5, fn, lifecycles) { // For primitive types and web API objects. else if (schemaMap[baseType]) { typeSchema = schemaMap[baseType]; + if ( + p5._isStrandsContextActive && + ['Number', 'Integer', 'Boolean', 'String'].includes(baseType) + ) { + typeSchema = z.union([ + typeSchema, + z.custom(val => val && val.isStrandsNode, { + message: 'Expected StrandsNode' + }) + ]); + } } // Tuple types else if ( @@ -615,11 +627,43 @@ function validateParams(p5, fn, lifecycles) { }; } - let funcSchemas = schemaRegistry.get(func); + let activeRegistry = p5._isStrandsContextActive ? strandsSchemaRegistry : schemaRegistry; + let funcSchemas = activeRegistry.get(func); + if (!funcSchemas) { - funcSchemas = generateZodSchemasForFunc(func); + // Convention-based bypass for shader builder entry-point functions. + // All p5 shader builders follow the build*Shader naming convention + // (buildMaterialShader, buildComputeShader, etc.). They are called + // BEFORE the strands context activates and handle their own validation + // and error reporting internally (e.g. "only available with WebGPU"). + if (/^build\w+Shader$/.test(func)) { + funcSchemas = z.any(); + } + // Check _strandsSignatures for explicit bypass entries (value === true) + // regardless of strands context, or array-based overload schemas that + // only apply inside an active strands context. + else if (p5._strandsSignatures?.has(func)) { + const sig = p5._strandsSignatures.get(func); + if (sig === true) { + funcSchemas = z.any(); + } else if (p5._isStrandsContextActive && Array.isArray(sig)) { + // Array-based overload schemas only apply inside an active strands + // context; outside of it the normal JSDoc schema is correct. + const nodeSchema = z.union([ + z.number(), + z.boolean(), + z.custom(val => val && val.isStrandsNode, { message: 'Expected StrandsNode or primitive' }) + ]); + const overloads = sig.map(overload => z.tuple(overload.params.map(() => nodeSchema))); + funcSchemas = z.union(overloads); + } + } + + if (!funcSchemas) { + funcSchemas = generateZodSchemasForFunc(func); + } if (!funcSchemas) return; - schemaRegistry.set(func, funcSchemas); + activeRegistry.set(func, funcSchemas); } if (z.validate(funcSchemas, args)) { diff --git a/src/strands/p5.strands.js b/src/strands/p5.strands.js index 627e834e9c..8804010e42 100644 --- a/src/strands/p5.strands.js +++ b/src/strands/p5.strands.js @@ -144,7 +144,7 @@ function strands(p5, fn) { // the pass is done since FES is switched off while it runs (see below). ctx.experimentalFeaturesUsed = new Set(); if (active) { - p5.disableFriendlyErrors = true; + p5._isStrandsContextActive = true; } } @@ -193,7 +193,7 @@ function strands(p5, fn) { * @return {Error[]} Errors from temporary writes that could not be undone. */ function deinitStrandsContext(ctx) { - p5.disableFriendlyErrors = ctx.previousFES; + p5._isStrandsContextActive = false; // Undo hooks, hook aliases and options.hook properties (incl. #8549's // p5.Graphics.prototype entries) exactly, newest first. const restoreErrors = restoreTemporaryProperties(ctx); diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 37d219e2f2..74b475a24c 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -364,6 +364,8 @@ function resolveShaderName(strandsContext, name, apiName) { // User nodes ////////////////////////////////////////////// export function initGlobalStrandsAPI(p5, fn, strandsContext) { + p5._strandsSignatures = new Map(); + // We augment the strands node with operations programatically // this means methods like .add, .sub, etc can be chained for (const { name, arity, opCode } of OperatorTable) { @@ -392,9 +394,9 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { ////////////////////////////////////////////// // Unique Functions ////////////////////////////////////////////// + p5._strandsSignatures.set('discard', true); augmentFn(fn, p5, 'discard', function () { build.statementNode(strandsContext, StatementType.DISCARD); - }); // Early exits would skip helper-state writes at the end of the loop. // The generated loop-exit break bypasses this check. function rejectLoopExitWithHelpers(statement) { @@ -408,11 +410,13 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { ); } } + p5._strandsSignatures.set('break', true); augmentFn(fn, p5, 'break', function () { rejectLoopExitWithHelpers('break'); build.statementNode(strandsContext, StatementType.BREAK); }); p5.break = fn.break; + p5._strandsSignatures.set('instanceID', true); augmentFn(fn, p5, 'instanceID', function () { const node = build.variableNode( strandsContext, @@ -426,6 +430,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { p5.strandsIf = function (conditionNode, ifBody) { return new StrandsConditional(strandsContext, conditionNode, ifBody); }; + p5._strandsSignatures.set('strandsIf', true); augmentFn(fn, p5, 'strandsIf', p5.strandsIf); p5.strandsFor = function ( initialCb, @@ -443,10 +448,12 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { initialVars ).build(); }; + p5._strandsSignatures.set('strandsFor', true); augmentFn(fn, p5, 'strandsFor', p5.strandsFor); p5.strandsTernary = function (condition, ifTrue, ifFalse) { return buildTernary(strandsContext, condition, ifTrue, ifFalse); }; + p5._strandsSignatures.set('strandsTernary', true); augmentFn(fn, p5, 'strandsTernary', p5.strandsTernary); // Both sides are built; allow helper reads here, but reject updates. p5.strandsOperand = function (operator, operand) { @@ -506,6 +513,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { return valueNode; }; + p5._strandsSignatures.set('strandsEarlyReturn', true); augmentFn(fn, p5, 'strandsEarlyReturn', p5.strandsEarlyReturn); p5.strandsNode = function (...args) { if (args.length === 1 && args[0] instanceof StrandsNode) { @@ -878,9 +886,12 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { ////////////////////////////////////////////// // Builtins, uniforms, variable constructors ////////////////////////////////////////////// + p5._strandsSignatures = p5._strandsSignatures || new Map(); + for (const [functionName, overrides] of Object.entries( strandsBuiltinFunctions )) { + p5._strandsSignatures.set(functionName, overrides); const isp5Function = overrides[0].isp5Function; if (isp5Function) { const originalFn = fn[functionName]; @@ -915,6 +926,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { // Alias lerp to GLSL mix in strands context const originalLerp = fn.lerp; + p5._strandsSignatures.set('lerp', true); augmentFn(fn, p5, 'lerp', function (...args) { if (strandsContext.active) { return this.mix(...args); @@ -924,6 +936,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); const originalMap = fn.map; + p5._strandsSignatures.set('map', true); augmentFn(fn, p5, 'map', function (...args) { if (!strandsContext.active) { return originalMap.apply(this, args); @@ -945,6 +958,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); const originalColor = fn.color; + p5._strandsSignatures.set('color', true); augmentFn(fn, p5, 'color', function (...args) { if (!strandsContext.active) { return originalColor.apply(this, args); @@ -961,6 +975,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { return createStrandsNode(id, dimension, strandsContext); }); const originalLerpColor = fn.lerpColor; + p5._strandsSignatures.set('lerpColor', true); augmentFn(fn, p5, 'lerpColor', function (...args) { if (!strandsContext.active) { return originalLerpColor.apply(this, args); @@ -970,6 +985,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); // Component accessors: extract scalar channels from a vec4 color const originalRed = fn.red; + p5._strandsSignatures.set('red', true); augmentFn(fn, p5, 'red', function (...args) { if (!strandsContext.active) { return originalRed.apply(this, args); @@ -978,6 +994,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); const originalGreen = fn.green; + p5._strandsSignatures.set('green', true); augmentFn(fn, p5, 'green', function (...args) { if (!strandsContext.active) { return originalGreen.apply(this, args); @@ -986,6 +1003,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); const originalBlue = fn.blue; + p5._strandsSignatures.set('blue', true); augmentFn(fn, p5, 'blue', function (...args) { if (!strandsContext.active) { return originalBlue.apply(this, args); @@ -994,6 +1012,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); const originalAlpha = fn.alpha; + p5._strandsSignatures.set('alpha', true); augmentFn(fn, p5, 'alpha', function (...args) { if (!strandsContext.active) { return originalAlpha.apply(this, args); @@ -1054,6 +1073,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { return instance.vec3(h, s, l); }; const originalHue = fn.hue; + p5._strandsSignatures.set('hue', true); augmentFn(fn, p5, 'hue', function (...args) { if (!strandsContext.active) return originalHue.apply(this, args); const colorNode = p5.strandsNode(args[0]); @@ -1061,6 +1081,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); const originalSaturation = fn.saturation; + p5._strandsSignatures.set('saturation', true); augmentFn(fn, p5, 'saturation', function (...args) { if (!strandsContext.active) return originalSaturation.apply(this, args); const colorNode = p5.strandsNode(args[0]); @@ -1068,6 +1089,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); const originalBrightness = fn.brightness; + p5._strandsSignatures.set('brightness', true); augmentFn(fn, p5, 'brightness', function (...args) { if (!strandsContext.active) return originalBrightness.apply(this, args); const colorNode = p5.strandsNode(args[0]); @@ -1075,12 +1097,14 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); const originalLightness = fn.lightness; + p5._strandsSignatures.set('lightness', true); augmentFn(fn, p5, 'lightness', function (...args) { if (!strandsContext.active) return originalLightness.apply(this, args); const colorNode = p5.strandsNode(args[0]); return _rgb2hsl(this, this.vec3(colorNode.x, colorNode.y, colorNode.z)).z; }); + p5._strandsSignatures.set('getTexture', true); augmentFn(fn, p5, 'getTexture', function (...rawArgs) { if (strandsContext.active) { const { id, dimension } = strandsContext.backend.createGetTextureCall( @@ -1096,6 +1120,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { // Add texture function as alias for getTexture with p5 fallback const originalTexture = fn.texture; + p5._strandsSignatures.set('texture', true); augmentFn(fn, p5, 'texture', function (...args) { if (strandsContext.active) { return this.getTexture(...args); @@ -1112,6 +1137,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { const originalRandomSeed = fn.randomSeed; const originalMillis = fn.millis; + p5._strandsSignatures.set('noiseDetail', true); augmentFn(fn, p5, 'noiseDetail', function (lod, falloff = 0.5) { if (!strandsContext.active) { return originalNoiseDetail.apply(this, arguments); @@ -1121,6 +1147,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { strandsContext._noiseAmpFalloff = falloff; }); + p5._strandsSignatures.set('noise', true); augmentFn(fn, p5, 'noise', function (...args) { if (!strandsContext.active) { return originalNoise.apply(this, args); // fallback to regular p5.js noise @@ -1249,6 +1276,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { { type: 'Number' } ]; + p5._strandsSignatures.set('randomSeed', true); augmentFn(fn, p5, 'randomSeed', function (seed) { if (!strandsContext.active) { return originalRandomSeed.apply(this, arguments); @@ -1256,6 +1284,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { strandsContext._randomSeed = seed; }); + p5._strandsSignatures.set('random', true); augmentFn(fn, p5, 'random', function (...args) { if (!strandsContext.active) { return originalRandom.apply(this, args); @@ -1353,6 +1382,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { } }); + p5._strandsSignatures.set('randomGaussian', true); augmentFn(fn, p5, 'randomGaussian', function (...args) { if (!strandsContext.active) { return originalRandomGaussian.apply(this, args); @@ -1369,6 +1399,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { return z.mult(stdDev).add(mean); }); + p5._strandsSignatures.set('millis', true); augmentFn(fn, p5, 'millis', function (...args) { if (!strandsContext.active) { return originalMillis.apply(this, args); @@ -1417,6 +1448,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { typeAliases.push(pascalTypeName.slice(0, 4)); } } + p5._strandsSignatures.set(`uniform${pascalTypeName}`, true); augmentFn( fn, p5, @@ -1441,6 +1473,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { } ); // Shared variables with smart context detection + p5._strandsSignatures.set(`shared${pascalTypeName}`, true); augmentFn(fn, p5, `shared${pascalTypeName}`, function (name) { const shaderName = resolveShaderName( strandsContext, @@ -1464,6 +1497,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { }); // Alias varying* as shared* for backward compatibility + p5._strandsSignatures.set(`varying${pascalTypeName}`, true); augmentFn( fn, p5, @@ -1474,11 +1508,15 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { for (const typeAlias of typeAliases) { // For compatibility, also alias uniformVec2 as uniformVector2, what we initially // documented these as + p5._strandsSignatures.set(`uniform${typeAlias}`, true); augmentFn(fn, p5, `uniform${typeAlias}`, fn[`uniform${pascalTypeName}`]); + p5._strandsSignatures.set(`varying${typeAlias}`, true); augmentFn(fn, p5, `varying${typeAlias}`, fn[`varying${pascalTypeName}`]); + p5._strandsSignatures.set(`shared${typeAlias}`, true); augmentFn(fn, p5, `shared${typeAlias}`, fn[`shared${pascalTypeName}`]); } const originalp5Fn = fn[typeInfo.fnName]; + p5._strandsSignatures.set(typeInfo.fnName, true); augmentFn(fn, p5, typeInfo.fnName, function (...args) { if (strandsContext.active) { if ( From 52f20c042ddb2a9c51c74dd10591543cafa603bf Mon Sep 17 00:00:00 2001 From: Ayush Date: Fri, 9 Oct 2026 00:15:36 +0530 Subject: [PATCH 2/4] address the changes requested by reviewers --- src/friendly_errors/param_validator.js | 19 +++---------------- src/strands/p5.strands.js | 2 -- src/strands/strands_api.js | 5 +---- test/unit/core/param_errors.js | 8 ++++++++ test/unit/webgl/p5.RendererGL.js | 12 ++++++------ 5 files changed, 18 insertions(+), 28 deletions(-) diff --git a/src/friendly_errors/param_validator.js b/src/friendly_errors/param_validator.js index c17d8dc03a..a2b2a42b83 100644 --- a/src/friendly_errors/param_validator.js +++ b/src/friendly_errors/param_validator.js @@ -636,27 +636,14 @@ function validateParams(p5, fn, lifecycles) { // (buildMaterialShader, buildComputeShader, etc.). They are called // BEFORE the strands context activates and handle their own validation // and error reporting internally (e.g. "only available with WebGPU"). - if (/^build\w+Shader$/.test(func)) { + if (/^build\w+Shader$/.test(func) && p5._isStrandsContextActive) { funcSchemas = z.any(); } // Check _strandsSignatures for explicit bypass entries (value === true) // regardless of strands context, or array-based overload schemas that // only apply inside an active strands context. - else if (p5._strandsSignatures?.has(func)) { - const sig = p5._strandsSignatures.get(func); - if (sig === true) { - funcSchemas = z.any(); - } else if (p5._isStrandsContextActive && Array.isArray(sig)) { - // Array-based overload schemas only apply inside an active strands - // context; outside of it the normal JSDoc schema is correct. - const nodeSchema = z.union([ - z.number(), - z.boolean(), - z.custom(val => val && val.isStrandsNode, { message: 'Expected StrandsNode or primitive' }) - ]); - const overloads = sig.map(overload => z.tuple(overload.params.map(() => nodeSchema))); - funcSchemas = z.union(overloads); - } + else if (p5._isStrandsContextActive && p5._strandsSignatures?.has(func)) { + funcSchemas = z.any(); } if (!funcSchemas) { diff --git a/src/strands/p5.strands.js b/src/strands/p5.strands.js index 8804010e42..e95cc4788a 100644 --- a/src/strands/p5.strands.js +++ b/src/strands/p5.strands.js @@ -125,7 +125,6 @@ function strands(p5, fn) { ctx.backend = backend; ctx.active = active; ctx.renderer = renderer; - ctx.baseShader = baseShader; ctx.previousFES = p5.disableFriendlyErrors; ctx.tempProps = []; ctx._noiseOctaves = null; @@ -167,7 +166,6 @@ function strands(p5, fn) { ctx.backend = undefined; ctx.active = false; ctx.renderer = null; - ctx.baseShader = null; ctx.previousFES = p5.disableFriendlyErrors; ctx.tempProps = []; ctx._noiseOctaves = null; diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 74b475a24c..074f05de99 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -885,13 +885,10 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { ////////////////////////////////////////////// // Builtins, uniforms, variable constructors - ////////////////////////////////////////////// - p5._strandsSignatures = p5._strandsSignatures || new Map(); - for (const [functionName, overrides] of Object.entries( strandsBuiltinFunctions )) { - p5._strandsSignatures.set(functionName, overrides); + p5._strandsSignatures.set(functionName, true); const isp5Function = overrides[0].isp5Function; if (isp5Function) { const originalFn = fn[functionName]; diff --git a/test/unit/core/param_errors.js b/test/unit/core/param_errors.js index be7b112b8c..954a907653 100644 --- a/test/unit/core/param_errors.js +++ b/test/unit/core/param_errors.js @@ -472,4 +472,12 @@ suite('Validate Params', function () { assert.isFalse(result.success); }); }); + + suite('validateParams: math functions without strands context', function () { + test('map(): fails with string parameters outside strands', function () { + const result = mockP5Prototype._validate('p5.map', ['a', 'b']); + assert.isFalse(result.success); + assert.isTrue(result.error.toString().includes('Expected at least 5 arguments')); + }); + }); }); diff --git a/test/unit/webgl/p5.RendererGL.js b/test/unit/webgl/p5.RendererGL.js index 9ad13fdce9..9ad5c098a4 100644 --- a/test/unit/webgl/p5.RendererGL.js +++ b/test/unit/webgl/p5.RendererGL.js @@ -102,21 +102,21 @@ suite('p5.RendererGL', function () { }); test('shader creation logs a warning', function() { - const shader = myp5.buildMaterialShader(() => {}); + myp5.buildMaterialShader(() => {}); expect(logSpy).toHaveBeenCalled(); expect(logSpy.mock.calls.length).toEqual(1); }); test('warning logs only once with multiple shaders', function() { - const shader = myp5.buildMaterialShader(() => {}); - const shader2 = myp5.buildMaterialShader(() => {}); + myp5.buildMaterialShader(() => {}); + myp5.buildMaterialShader(() => {}); expect(logSpy).toHaveBeenCalled(); expect(logSpy.mock.calls.length).toEqual(1); }); test('warning logs only once with multiple strands calls', function() { - const shader = myp5.buildMaterialShader(() => {}); - const shader2 = myp5.buildFilterShader(() => {}); + myp5.buildMaterialShader(() => {}); + myp5.buildFilterShader(() => {}); expect(logSpy).toHaveBeenCalled(); expect(logSpy.mock.calls.length).toEqual(1); }); @@ -134,7 +134,7 @@ suite('p5.RendererGL', function () { }); test('no warnings are logged', function() { - const shader = myp5.buildMaterialShader(() => {}); + myp5.buildMaterialShader(() => {}); expect(logSpy).not.toHaveBeenCalled(); }); }); From d80ada9cdd5405159c94546b48bd9b620e12a07c Mon Sep 17 00:00:00 2001 From: Ayush Date: Thu, 15 Oct 2026 01:23:42 +0530 Subject: [PATCH 3/4] fix(fes): validate shader callbacks, prevent stack leaks, and remove dead code --- src/core/p5.Renderer3D.js | 8 +++- src/friendly_errors/param_validator.js | 15 ++----- src/friendly_errors/stacktrace.js | 59 +++++++++++++++++++------- src/strands/strands_api.js | 1 + src/webgl/material.js | 40 ++++++++++++++--- test/unit/core/param_errors.js | 11 ++++- 6 files changed, 100 insertions(+), 34 deletions(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index d6cd1ce5f5..02a0fbd8ad 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -2722,7 +2722,13 @@ function renderer3D(p5, fn) { `buildComputeShader() is only available with the WebGPU renderer. ${webGPUAddonMessage}` ); } - return this.baseComputeShader().modify(cb, context, { hook: 'iteration' }); + const wasInternal = this._isUserCall; + this._isUserCall = false; + try { + return this.baseComputeShader().modify(cb, context, { hook: 'iteration' }); + } finally { + this._isUserCall = wasInternal; + } }; p5.registerDecorator('p5.prototype.buildComputeShader', markExperimental('webgpu', p5)); diff --git a/src/friendly_errors/param_validator.js b/src/friendly_errors/param_validator.js index a2b2a42b83..5cab134c75 100644 --- a/src/friendly_errors/param_validator.js +++ b/src/friendly_errors/param_validator.js @@ -584,7 +584,7 @@ function validateParams(p5, fn, lifecycles) { errorStackParser.parse(Error()).slice(3) ); const locationStr = getFriendlyStack(stacktrace, true); - FES.log`${locationStr} ${message}`(); + FES.log`${locationStr ? locationStr + ' ' : ''}${message}`(); } return message; }; @@ -631,18 +631,9 @@ function validateParams(p5, fn, lifecycles) { let funcSchemas = activeRegistry.get(func); if (!funcSchemas) { - // Convention-based bypass for shader builder entry-point functions. - // All p5 shader builders follow the build*Shader naming convention - // (buildMaterialShader, buildComputeShader, etc.). They are called - // BEFORE the strands context activates and handle their own validation - // and error reporting internally (e.g. "only available with WebGPU"). - if (/^build\w+Shader$/.test(func) && p5._isStrandsContextActive) { - funcSchemas = z.any(); - } // Check _strandsSignatures for explicit bypass entries (value === true) - // regardless of strands context, or array-based overload schemas that - // only apply inside an active strands context. - else if (p5._isStrandsContextActive && p5._strandsSignatures?.has(func)) { + // that only apply inside an active strands context. + if (p5._isStrandsContextActive && p5._strandsSignatures?.has(func)) { funcSchemas = z.any(); } diff --git a/src/friendly_errors/stacktrace.js b/src/friendly_errors/stacktrace.js index 015e429705..a32433d83b 100644 --- a/src/friendly_errors/stacktrace.js +++ b/src/friendly_errors/stacktrace.js @@ -279,11 +279,22 @@ export const processStack = (error, stacktrace) => { let isInternal = false; let p5FileName, friendlyStack; + // Helper to strip query strings and hashes from URLs for consistent matching + const cleanUrl = url => (typeof url === 'string' ? url.split('?')[0].split('#')[0] : ''); + // Intentionally throw an error that we catch so that we can check the name // of the current file. Any errors we see from this file, we treat as // internal errors. const testStacktrace = errorStackParser.parse(Error()); - p5FileName = testStacktrace[0].fileName; + p5FileName = cleanUrl(testStacktrace[0]?.fileName); + const p5BaseName = p5FileName ? p5FileName.split('/').slice(-1)[0] : ''; + const isP5File = url => { + const cleaned = cleanUrl(url); + if (!cleaned) return false; + if (cleaned === p5FileName) return true; + if (p5BaseName && cleaned.split('/').slice(-1)[0] === p5BaseName) return true; + return false; + }; for (let i = stacktrace.length - 1; i >= 0; i--) { let splitted = stacktrace[i].functionName.split('.'); @@ -291,15 +302,6 @@ export const processStack = (error, stacktrace) => { // remove everything below an entry point function (setup, draw, etc). // (it's usually the internal initialization calls) friendlyStack = stacktrace.slice(0, i + 1); - // We call the error "internal" if the source of the error was a - // function from within the p5.js library file, but called from the - // user's code directly. We only need to check the topmost frame in - // the stack trace since any function internal to p5 should pass this - // check, not just public p5 functions. - if (stacktrace[0].fileName === p5FileName) { - isInternal = true; - break; - } break; } } @@ -309,6 +311,14 @@ export const processStack = (error, stacktrace) => { // entire stacktrace for friendlyStack if (!friendlyStack) friendlyStack = stacktrace; + // We call the error "internal" if the source of the error was a + // function from within the p5.js library file, but called from the + // user's code directly. When error is null (called from param_validator), + // it is always an internal validation call. + if (error === null || isP5File(stacktrace[0]?.fileName)) { + isInternal = true; + } + if (isInternal) { // the frameIndex property is added before the filter, so frameIndex // corresponds to the index of a frame in the original stacktrace. @@ -319,7 +329,15 @@ export const processStack = (error, stacktrace) => { frame.frameIndex = index; return frame; }) - .filter(frame => frame.fileName !== p5FileName); + .filter(frame => { + const cleaned = cleanUrl(frame.fileName); + return ( + cleaned && + !isP5File(frame.fileName) && + cleaned !== '' && + !cleaned.startsWith('eval') + ); + }); // a weird case, if for some reason we can't identify the function called // from user's code @@ -332,16 +350,19 @@ export const processStack = (error, stacktrace) => { friendlyStack[0].lineNumber && friendlyStack[0].columnNumber ) { + const cleanFile = cleanUrl(friendlyStack[0].fileName); locationObj = { - location: `${friendlyStack[0].fileName}:${ + location: `${cleanFile}:${ friendlyStack[0].lineNumber }:${friendlyStack[0].columnNumber}`, - file: friendlyStack[0].fileName.split('/').slice(-1), + file: cleanFile.split('/').slice(-1)[0], line: friendlyStack[0].lineNumber }; // if already handled by another part of the FES, don't handle again - if (p5._fesLogCache[locationObj.location]) return [true, null]; + if (typeof p5 !== 'undefined' && p5?._fesLogCache?.[locationObj.location]) { + return [true, null]; + } } // Library error @@ -394,8 +415,16 @@ export function printFriendlyStack(friendlyStack) { } export function getFriendlyStack(stacktrace, compact = false) { + if (!stacktrace || !stacktrace.length || !stacktrace[0]?.fileName) { + return null; + } + const rawFile = stacktrace[0].fileName; + const cleanFile = (typeof rawFile === 'string' ? rawFile.split('?')[0].split('#')[0] : '').split('/').slice(-1)[0]; + if (!cleanFile || cleanFile === '' || cleanFile.startsWith('eval')) { + return null; + } if (compact) { - return `[${stacktrace[0].fileName.split('/').slice(-1)}, line ${stacktrace[0].lineNumber}]`; + return `[${cleanFile}, line ${stacktrace[0].lineNumber}]`; } else { if (stacktrace.length > 1) { let stacktraceMsg = ''; diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 074f05de99..86da34fcba 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -889,6 +889,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { strandsBuiltinFunctions )) { p5._strandsSignatures.set(functionName, true); + p5._strandsSignatures.set(`p5.${functionName}`, true); const isp5Function = overrides[0].isp5Function; if (isp5Function) { const originalFn = fn[functionName]; diff --git a/src/webgl/material.js b/src/webgl/material.js index d7a375d93d..a22328505c 100644 --- a/src/webgl/material.js +++ b/src/webgl/material.js @@ -742,7 +742,13 @@ function material(p5, fn) { * @returns {p5.Shader} The material shader */ fn.buildFilterShader = function (callback, scope) { - return this.baseFilterShader().modify(callback, scope); + const wasInternal = this._isUserCall; + this._isUserCall = false; + try { + return this.baseFilterShader().modify(callback, scope); + } finally { + this._isUserCall = wasInternal; + } }; p5.registerDecorator('p5.prototype.buildFilterShader', markExperimental('p5.strands', p5)); @@ -1571,7 +1577,13 @@ function material(p5, fn) { * @returns {p5.Shader} The material shader. */ fn.buildMaterialShader = function (cb, scope) { - return this.baseMaterialShader().modify(cb, scope); + const wasInternal = this._isUserCall; + this._isUserCall = false; // Temporarily un-suppress FES so calls inside cb are validated + try { + return this.baseMaterialShader().modify(cb, scope); + } finally { + this._isUserCall = wasInternal; + } }; p5.registerDecorator('p5.prototype.buildMaterialShader', markExperimental('p5.strands', p5)); @@ -1790,7 +1802,13 @@ function material(p5, fn) { * @returns {p5.Shader} The normal shader. */ fn.buildNormalShader = function (cb, scope) { - return this.baseNormalShader().modify(cb, scope); + const wasInternal = this._isUserCall; + this._isUserCall = false; + try { + return this.baseNormalShader().modify(cb, scope); + } finally { + this._isUserCall = wasInternal; + } }; p5.registerDecorator('p5.prototype.buildNormalShader', markExperimental('p5.strands', p5)); @@ -1955,7 +1973,13 @@ function material(p5, fn) { * @returns {p5.Shader} The color shader. */ fn.buildColorShader = function (cb, scope) { - return this.baseColorShader().modify(cb, scope); + const wasInternal = this._isUserCall; + this._isUserCall = false; + try { + return this.baseColorShader().modify(cb, scope); + } finally { + this._isUserCall = wasInternal; + } }; p5.registerDecorator('p5.prototype.buildColorShader', markExperimental('p5.strands', p5)); @@ -2213,7 +2237,13 @@ function material(p5, fn) { * @returns {p5.Shader} The stroke shader. */ fn.buildStrokeShader = function (cb, scope) { - return this.baseStrokeShader().modify(cb, scope); + const wasInternal = this._isUserCall; + this._isUserCall = false; + try { + return this.baseStrokeShader().modify(cb, scope); + } finally { + this._isUserCall = wasInternal; + } }; p5.registerDecorator('p5.prototype.buildStrokeShader', markExperimental('p5.strands', p5)); diff --git a/test/unit/core/param_errors.js b/test/unit/core/param_errors.js index 954a907653..3f0c839970 100644 --- a/test/unit/core/param_errors.js +++ b/test/unit/core/param_errors.js @@ -29,7 +29,9 @@ suite('Validate Params', function () { return 'mock p5.Graphics'; }, _error: () => {}, - registerDecorator: () => {} + registerDecorator: () => {}, + _strandsSignatures: new Map([['p5.map', true]]), + _isStrandsContextActive: false }; const mockP5Prototype = {}; @@ -479,5 +481,12 @@ suite('Validate Params', function () { assert.isFalse(result.success); assert.isTrue(result.error.toString().includes('Expected at least 5 arguments')); }); + + test('map(): bypasses FES validation when inside strands context', function () { + mockP5._isStrandsContextActive = true; + const result = mockP5Prototype._validate('p5.map', ['a', 'b']); + assert.isTrue(result.success); + mockP5._isStrandsContextActive = false; // reset + }); }); }); From a9efe22b99c4f8efd2d2822621810fe384466730 Mon Sep 17 00:00:00 2001 From: Ayush Date: Fri, 23 Oct 2026 01:04:29 +0530 Subject: [PATCH 4/4] fix(strands): register augmented functions and uniformStorage in FES signatures --- src/strands/strands_api.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/strands/strands_api.js b/src/strands/strands_api.js index 86da34fcba..104cb76b4b 100644 --- a/src/strands/strands_api.js +++ b/src/strands/strands_api.js @@ -251,6 +251,8 @@ function installInstanceIndexAccessor(strandsContext) { */ function augmentFn(fn, p5, name, value) { fn[name] = value; + p5?._strandsSignatures?.set(name, true); + p5?._strandsSignatures?.set(`p5.${name}`, true); const GraphicsProto = p5?.Graphics?.prototype; if (GraphicsProto) { GraphicsProto[name] = value; @@ -397,6 +399,7 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { p5._strandsSignatures.set('discard', true); augmentFn(fn, p5, 'discard', function () { build.statementNode(strandsContext, StatementType.DISCARD); + }); // Early exits would skip helper-state writes at the end of the loop. // The generated loop-exit break bypasses this check. function rejectLoopExitWithHelpers(statement) { @@ -1682,6 +1685,8 @@ export function initGlobalStrandsAPI(p5, fn, strandsContext) { } // Storage buffer uniform function for compute shaders + p5?._strandsSignatures?.set('uniformStorage', true); + p5?._strandsSignatures?.set('p5.uniformStorage', true); fn.uniformStorage = function (name, bufferOrSchema) { const shaderName = resolveShaderName( strandsContext,