From b168dc1e9c6fc13a0c4953e1942af05310fa4963 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 19 Sep 2025 11:51:03 -0400 Subject: [PATCH 1/8] Re-bind globals when assigned in addons --- src/core/main.js | 276 +++++++++++++++++++++++++++-------------------- 1 file changed, 158 insertions(+), 118 deletions(-) diff --git a/src/core/main.js b/src/core/main.js index 6cafe4d375..db2595de54 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -77,122 +77,7 @@ class p5 { // ensure correct reporting of window dimensions this._updateWindowSize(); - const bindGlobal = property => { - if (property === 'constructor') return; - - // Common setter for all property types - const createSetter = () => newValue => { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - value: newValue, - writable: true - }); - if (!p5.disableFriendlyErrors) { - console.log(`You just changed the value of "${property}", which was a p5 global value. This could cause problems later if you're not careful.`); - } - }; - - // Check if this property has a getter on the instance or prototype - const instanceDescriptor = Object.getOwnPropertyDescriptor(this, property); - const prototypeDescriptor = Object.getOwnPropertyDescriptor(p5.prototype, property); - const hasGetter = (instanceDescriptor && instanceDescriptor.get) || - (prototypeDescriptor && prototypeDescriptor.get); - - // Only check if it's a function if it doesn't have a getter - // to avoid actually evaluating getters before things like the - // renderer are fully constructed - let isPrototypeFunction = false; - let isConstant = false; - let constantValue; - - if (!hasGetter) { - const prototypeValue = p5.prototype[property]; - isPrototypeFunction = typeof prototypeValue === 'function'; - - // Check if this is a true constant from the constants module - if (!isPrototypeFunction && constants[property] !== undefined) { - isConstant = true; - constantValue = prototypeValue; - } - } - - if (isPrototypeFunction) { - // For regular functions, cache the bound function - const boundFunction = p5.prototype[property].bind(this); - if (p5.disableFriendlyErrors) { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - value: boundFunction, - }); - } else { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - get() { - return boundFunction; - }, - set: createSetter() - }); - } - } else if (isConstant) { - // For constants, cache the value directly - if (p5.disableFriendlyErrors) { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - value: constantValue, - }); - } else { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - get() { - return constantValue; - }, - set: createSetter() - }); - } - } else if (hasGetter || !isPrototypeFunction) { - // For properties with getters or non-function properties, use lazy optimization - // On first access, determine the type and optimize subsequent accesses - let lastFunction = null; - let boundFunction = null; - let isFunction = null; // null = unknown, true = function, false = not function - - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - get: () => { - const currentValue = this[property]; - - if (isFunction === null) { - // First access - determine type and optimize - isFunction = typeof currentValue === 'function'; - if (isFunction) { - lastFunction = currentValue; - boundFunction = currentValue.bind(this); - return boundFunction; - } else { - return currentValue; - } - } else if (isFunction) { - // Optimized function path - only rebind if function changed - if (currentValue !== lastFunction) { - lastFunction = currentValue; - boundFunction = currentValue.bind(this); - } - return boundFunction; - } else { - // Optimized non-function path - return currentValue; - } - }, - set: createSetter() - }); - } - }; + const bindGlobal = createBindGlobal(this); // If the user has created a global setup or draw function, // assume "global" mode and make everything global (i.e. on the window) if (!sketch) { @@ -259,7 +144,23 @@ class p5 { static registerAddon(addon) { const lifecycles = {}; - addon(p5, p5.prototype, lifecycles); + + // Create a proxy for p5.prototype that automatically re-binds globals when properties are added + const prototypeProxy = new Proxy(p5.prototype, { + set(target, property, value) { + const result = Reflect.set(target, property, value); + + // If we have a global instance and this is a new property, bind it globally + if (p5.instance && p5.instance._isGlobal && property[0] !== '_') { + const bindGlobal = createBindGlobal(p5.instance); + bindGlobal(property); + } + + return result; + } + }); + + addon(p5, prototypeProxy, lifecycles); const validLifecycles = Object.keys(p5.lifecycleHooks); for(const name of validLifecycles){ @@ -492,9 +393,28 @@ class p5 { } async _runLifecycleHook(hookName) { + // Create a proxy for the instance that automatically re-binds globals when + // properties are added over the course of the lifecycle. + // Afterward, we skip global binding for efficiency. + let inLifecycle = true; + const instanceProxy = new Proxy(this, { + set(target, property, value) { + const result = Reflect.set(target, property, value); + + // If this is a global instance and this is a new property, bind it globally + if (inLifecycle && target._isGlobal && property[0] !== '_') { + const bindGlobal = createBindGlobal(target); + bindGlobal(property); + } + + return result; + } + }); + await Promise.all(p5.lifecycleHooks[hookName].map(hook => { - return hook.call(this); + return hook.call(instanceProxy); })); + inLifecycle = false; } _initializeInstanceVariables() { @@ -511,6 +431,126 @@ class p5 { } } +// Global helper function for binding properties to window in global mode +function createBindGlobal(instance) { + return function bindGlobal(property) { + if (property === 'constructor') return; + + // Common setter for all property types + const createSetter = () => newValue => { + Object.defineProperty(window, property, { + configurable: true, + enumerable: true, + value: newValue, + writable: true + }); + if (!p5.disableFriendlyErrors) { + console.log(`You just changed the value of "${property}", which was a p5 global value. This could cause problems later if you're not careful.`); + } + }; + + // Check if this property has a getter on the instance or prototype + const instanceDescriptor = Object.getOwnPropertyDescriptor(instance, property); + const prototypeDescriptor = Object.getOwnPropertyDescriptor(p5.prototype, property); + const hasGetter = (instanceDescriptor && instanceDescriptor.get) || + (prototypeDescriptor && prototypeDescriptor.get); + + // Only check if it's a function if it doesn't have a getter + // to avoid actually evaluating getters before things like the + // renderer are fully constructed + let isPrototypeFunction = false; + let isConstant = false; + let constantValue; + + if (!hasGetter) { + const prototypeValue = p5.prototype[property]; + isPrototypeFunction = typeof prototypeValue === 'function'; + + // Check if this is a true constant from the constants module + if (!isPrototypeFunction && constants[property] !== undefined) { + isConstant = true; + constantValue = prototypeValue; + } + } + + if (isPrototypeFunction) { + // For regular functions, cache the bound function + const boundFunction = p5.prototype[property].bind(instance); + if (p5.disableFriendlyErrors) { + Object.defineProperty(window, property, { + configurable: true, + enumerable: true, + value: boundFunction, + }); + } else { + Object.defineProperty(window, property, { + configurable: true, + enumerable: true, + get() { + return boundFunction; + }, + set: createSetter() + }); + } + } else if (isConstant) { + // For constants, cache the value directly + if (p5.disableFriendlyErrors) { + Object.defineProperty(window, property, { + configurable: true, + enumerable: true, + value: constantValue, + }); + } else { + Object.defineProperty(window, property, { + configurable: true, + enumerable: true, + get() { + return constantValue; + }, + set: createSetter() + }); + } + } else if (hasGetter || !isPrototypeFunction) { + // For properties with getters or non-function properties, use lazy optimization + // On first access, determine the type and optimize subsequent accesses + let lastFunction = null; + let boundFunction = null; + let isFunction = null; // null = unknown, true = function, false = not function + + Object.defineProperty(window, property, { + configurable: true, + enumerable: true, + get: () => { + const currentValue = instance[property]; + + if (isFunction === null) { + // First access - determine type and optimize + isFunction = typeof currentValue === 'function'; + if (isFunction) { + lastFunction = currentValue; + boundFunction = currentValue.bind(instance); + return boundFunction; + } else { + return currentValue; + } + } else if (isFunction) { + // Optimized function path - only rebind if function changed + if (currentValue !== lastFunction) { + lastFunction = currentValue; + boundFunction = currentValue.bind(instance); + } + return boundFunction; + } else { + // Optimized non-function path + return currentValue; + } + }, + set: createSetter() + }); + } + }; +} + // Attach constants to p5 prototype for (const k in constants) { p5.prototype[k] = constants[k]; From 792888cf6166db568c6a90dcc86152f90096a67b Mon Sep 17 00:00:00 2001 From: limzykenneth Date: Sat, 27 Sep 2025 13:09:35 +0200 Subject: [PATCH 2/8] Implement decorator helper function and have FES use it --- src/core/friendly_errors/param_validator.js | 43 ++++++------- src/core/main.js | 70 +++++++++------------ 2 files changed, 49 insertions(+), 64 deletions(-) diff --git a/src/core/friendly_errors/param_validator.js b/src/core/friendly_errors/param_validator.js index 97f23dfff4..2d08257aae 100644 --- a/src/core/friendly_errors/param_validator.js +++ b/src/core/friendly_errors/param_validator.js @@ -140,7 +140,7 @@ function validateParams(p5, fn, lifecycles) { * @param {String} func - Name of the function. Expect global functions like `sin` and class methods like `p5.Vector.add` * @returns {z.ZodSchema} Zod schema */ - fn.generateZodSchemasForFunc = function (func) { + const generateZodSchemasForFunc = function (func) { const { funcName, funcClass } = extractFuncNameAndClass(func); let funcInfo = dataDoc[funcClass][funcName]; @@ -308,7 +308,7 @@ function validateParams(p5, fn, lifecycles) { * @param {Array} args - User input arguments. * @returns {z.ZodSchema} Closest schema matching the input arguments. */ - fn.findClosestSchema = function (schema, args) { + const findClosestSchema = function (schema, args) { if (!(schema instanceof z.ZodUnion)) { return schema; } @@ -389,7 +389,7 @@ function validateParams(p5, fn, lifecycles) { * @param {String} func - Name of the function. Expect global functions like `sin` and class methods like `p5.Vector.add` * @returns {String} The friendly error message. */ - fn.friendlyParamError = function (zodErrorObj, func, args) { + const friendlyParamError = function (zodErrorObj, func, args) { let message = '🌸 p5.js says: '; let isVersionError = false; // The `zodErrorObj` might contain multiple errors of equal importance @@ -520,7 +520,7 @@ function validateParams(p5, fn, lifecycles) { * @returns {any} [result.data] - The parsed data if validation was successful. * @returns {String} [result.error] - The validation error message if validation has failed. */ - fn.validate = function (func, args) { + const validate = function (func, args) { if (p5.disableFriendlyErrors) { return; // skip FES } @@ -548,7 +548,7 @@ function validateParams(p5, fn, lifecycles) { let funcSchemas = schemaRegistry.get(func); if (!funcSchemas) { - funcSchemas = fn.generateZodSchemasForFunc(func); + funcSchemas = generateZodSchemasForFunc(func); if (!funcSchemas) return; schemaRegistry.set(func, funcSchemas); } @@ -559,9 +559,9 @@ function validateParams(p5, fn, lifecycles) { data: funcSchemas.parse(args) }; } catch (error) { - const closestSchema = fn.findClosestSchema(funcSchemas, args); + const closestSchema = findClosestSchema(funcSchemas, args); const zodError = closestSchema.safeParse(args).error; - const errorMessage = fn.friendlyParamError(zodError, func, args); + const errorMessage = friendlyParamError(zodError, func, args); return { success: false, @@ -570,25 +570,20 @@ function validateParams(p5, fn, lifecycles) { } }; - lifecycles.presetup = function(){ - loadP5Constructors(); - - if( - p5.disableParameterValidator !== true && - p5.disableFriendlyErrors !== true - ){ - const excludes = ['validate']; - for(const f in this){ - if(!excludes.includes(f) && !f.startsWith('_') && typeof this[f] === 'function'){ - const copy = this[f]; - - this[f] = function(...args) { - this.validate(f, args); - return copy.call(this, ...args); - }; + p5.decorateHelper( + /^(?!_).+$/, + function(target, name){ + return function(...args){ + if(!p5.disableFriendlyErrors && !p5.disableParameterValidator) { + validate(name, args); } - } + return target(...args); + }; } + ); + + lifecycles.presetup = function(){ + loadP5Constructors(); }; } diff --git a/src/core/main.js b/src/core/main.js index db2595de54..d0219586c4 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -77,6 +77,25 @@ class p5 { // ensure correct reporting of window dimensions this._updateWindowSize(); + // Apply addon defined decorations + for(const [patternArray, decoration] of p5.decorations){ + for(const member in p5.prototype){ + // Member must be a function + if(typeof p5.prototype[member] !== 'function') continue; + + if(!patternArray.some(pattern => { + if(typeof pattern === 'string'){ + return pattern === member; + }else if(pattern instanceof RegExp){ + return pattern.test(member); + } + })) continue; + + const copy = p5.prototype[member].bind(this); + p5.prototype[member] = decoration.call(this, copy, member); + } + } + const bindGlobal = createBindGlobal(this); // If the user has created a global setup or draw function, // assume "global" mode and make everything global (i.e. on the window) @@ -145,22 +164,7 @@ class p5 { static registerAddon(addon) { const lifecycles = {}; - // Create a proxy for p5.prototype that automatically re-binds globals when properties are added - const prototypeProxy = new Proxy(p5.prototype, { - set(target, property, value) { - const result = Reflect.set(target, property, value); - - // If we have a global instance and this is a new property, bind it globally - if (p5.instance && p5.instance._isGlobal && property[0] !== '_') { - const bindGlobal = createBindGlobal(p5.instance); - bindGlobal(property); - } - - return result; - } - }); - - addon(p5, prototypeProxy, lifecycles); + addon(p5, p5.prototype, lifecycles); const validLifecycles = Object.keys(p5.lifecycleHooks); for(const name of validLifecycles){ @@ -170,6 +174,13 @@ class p5 { } } + static decorations = new Map(); + static decorateHelper(pattern, decoration){ + let patternArray = pattern; + if(!Array.isArray(pattern)) patternArray = [pattern]; + p5.decorations.set(patternArray, decoration); + } + #customActions = {}; _customActions = new Proxy({}, { get: (target, prop) => { @@ -393,28 +404,9 @@ class p5 { } async _runLifecycleHook(hookName) { - // Create a proxy for the instance that automatically re-binds globals when - // properties are added over the course of the lifecycle. - // Afterward, we skip global binding for efficiency. - let inLifecycle = true; - const instanceProxy = new Proxy(this, { - set(target, property, value) { - const result = Reflect.set(target, property, value); - - // If this is a global instance and this is a new property, bind it globally - if (inLifecycle && target._isGlobal && property[0] !== '_') { - const bindGlobal = createBindGlobal(target); - bindGlobal(property); - } - - return result; - } - }); - await Promise.all(p5.lifecycleHooks[hookName].map(hook => { - return hook.call(instanceProxy); + return hook.call(this); })); - inLifecycle = false; } _initializeInstanceVariables() { @@ -480,7 +472,7 @@ function createBindGlobal(instance) { Object.defineProperty(window, property, { configurable: true, enumerable: true, - value: boundFunction, + value: boundFunction }); } else { Object.defineProperty(window, property, { @@ -498,7 +490,7 @@ function createBindGlobal(instance) { Object.defineProperty(window, property, { configurable: true, enumerable: true, - value: constantValue, + value: constantValue }); } else { Object.defineProperty(window, property, { @@ -785,8 +777,6 @@ for (const k in constants) { * * */ -p5.disableFriendlyErrors = false; - import transform from './transform'; import structure from './structure'; import environment from './environment'; From ad206b791020991864a815e7252f064d6b6f0d4b Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 28 Sep 2025 14:11:11 -0400 Subject: [PATCH 3/8] Avoid double-binding prototype methods --- src/core/main.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/main.js b/src/core/main.js index d0219586c4..e7d06c50f7 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -78,6 +78,7 @@ class p5 { this._updateWindowSize(); // Apply addon defined decorations + p5.prototype.__originalMethods = p5.prototype.__originalMethods || {}; for(const [patternArray, decoration] of p5.decorations){ for(const member in p5.prototype){ // Member must be a function @@ -91,7 +92,10 @@ class p5 { } })) continue; - const copy = p5.prototype[member].bind(this); + // Store a copy of the original, unbound prototype method so that if we make a new p5 instance + // later, we don't double-, triple-, etc bind the function + p5.prototype.__originalMethods[member] = p5.prototype.__originalMethods[member] || p5.prototype[member]; + const copy = p5.prototype.__originalMethods[member].bind(this); p5.prototype[member] = decoration.call(this, copy, member); } } From 419574a8d20181dc7bcd1d6c22cc2cfc8f803ea7 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sun, 28 Sep 2025 15:16:20 -0400 Subject: [PATCH 4/8] Add decorators to a subclass instead --- src/core/friendly_errors/param_validator.js | 6 ++- src/core/main.js | 35 +++++++++------ src/webgl/p5.Framebuffer.js | 1 + test/unit/core/param_errors.js | 47 +++++++++++---------- 4 files changed, 50 insertions(+), 39 deletions(-) diff --git a/src/core/friendly_errors/param_validator.js b/src/core/friendly_errors/param_validator.js index 2d08257aae..2f4b557477 100644 --- a/src/core/friendly_errors/param_validator.js +++ b/src/core/friendly_errors/param_validator.js @@ -570,14 +570,16 @@ function validateParams(p5, fn, lifecycles) { } }; + fn._validate = validate; // For unit tests + p5.decorateHelper( /^(?!_).+$/, function(target, name){ return function(...args){ - if(!p5.disableFriendlyErrors && !p5.disableParameterValidator) { + if (!p5.disableFriendlyErrors && !p5.disableParameterValidator) { validate(name, args); } - return target(...args); + return p5.prototype[name].apply(this, args); }; } ); diff --git a/src/core/main.js b/src/core/main.js index e7d06c50f7..3795a48bff 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -78,25 +78,20 @@ class p5 { this._updateWindowSize(); // Apply addon defined decorations - p5.prototype.__originalMethods = p5.prototype.__originalMethods || {}; - for(const [patternArray, decoration] of p5.decorations){ - for(const member in p5.prototype){ + for (const [patternArray, decoration] of p5.decorations) { + for(const member in p5.prototype) { // Member must be a function - if(typeof p5.prototype[member] !== 'function') continue; + if (typeof p5.prototype[member] !== 'function') continue; - if(!patternArray.some(pattern => { - if(typeof pattern === 'string'){ + if (!patternArray.some(pattern => { + if (typeof pattern === 'string') { return pattern === member; - }else if(pattern instanceof RegExp){ + } else if (pattern instanceof RegExp) { return pattern.test(member); } })) continue; - // Store a copy of the original, unbound prototype method so that if we make a new p5 instance - // later, we don't double-, triple-, etc bind the function - p5.prototype.__originalMethods[member] = p5.prototype.__originalMethods[member] || p5.prototype[member]; - const copy = p5.prototype.__originalMethods[member].bind(this); - p5.prototype[member] = decoration.call(this, copy, member); + DecoratedP5.prototype[member] = decoration.call(this, p5.prototype[member], member); } } @@ -181,7 +176,7 @@ class p5 { static decorations = new Map(); static decorateHelper(pattern, decoration){ let patternArray = pattern; - if(!Array.isArray(pattern)) patternArray = [pattern]; + if (!Array.isArray(pattern)) patternArray = [pattern]; p5.decorations.set(patternArray, decoration); } @@ -427,6 +422,18 @@ class p5 { } } +class _DecoratedP5 extends p5 { + static registerAddon(addon) { + p5.registerAddon(addon); + } +} +const DecoratedP5 = new Proxy(_DecoratedP5, { + set(target, prop, newValue) { + p5[prop] = newValue; + return true; + } +}); + // Global helper function for binding properties to window in global mode function createBindGlobal(instance) { return function bindGlobal(property) { @@ -797,4 +804,4 @@ p5.registerAddon(renderer); p5.registerAddon(renderer2D); p5.registerAddon(graphics); -export default p5; +export default DecoratedP5; diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index ad481fb586..c693131082 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -1679,6 +1679,7 @@ function framebuffer(p5, fn){ * object. * @param {Object} [settings] configuration options. */ + debugger p5.Framebuffer = Framebuffer; /** diff --git a/test/unit/core/param_errors.js b/test/unit/core/param_errors.js index 247ae4a692..e37d2c4d8e 100644 --- a/test/unit/core/param_errors.js +++ b/test/unit/core/param_errors.js @@ -28,7 +28,8 @@ suite('Validate Params', function () { Graphics: function() { return 'mock p5.Graphics'; }, - _error: () => {} + _error: () => {}, + decorateHelper: () => {}, }; const mockP5Prototype = {}; @@ -48,7 +49,7 @@ suite('Validate Params', function () { ]; validInputs.forEach(({ input }) => { - const result = mockP5Prototype.validate('saturation', input); + const result = mockP5Prototype._validate('saturation', input); assert.isTrue(result.success); }); }); @@ -62,7 +63,7 @@ suite('Validate Params', function () { ]; invalidInputs.forEach(({ input }) => { - const result = mockP5Prototype.validate('p5.saturation', input); + const result = mockP5Prototype._validate('p5.saturation', input); assert.isTrue(result.error.startsWith('🌸 p5.js says: Expected Color or array or string at the first parameter, but received')); }); }); @@ -76,7 +77,7 @@ suite('Validate Params', function () { validInputs.forEach(({ name, input }) => { test(`blendMode(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.blendMode', [input]); + const result = mockP5Prototype._validate('p5.blendMode', [input]); assert.isTrue(result.success); }); }); @@ -89,7 +90,7 @@ suite('Validate Params', function () { invalidInputs.forEach(({ name, input }) => { test(`blendMode(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.blendMode', [input]); + const result = mockP5Prototype._validate('p5.blendMode', [input]); const expectedError = '🌸 p5.js says: Expected constant (please refer to documentation for allowed values) at the first parameter, but received ' + input + ' in p5.blendMode().'; assert.equal(result.error, expectedError); }); @@ -103,7 +104,7 @@ suite('Validate Params', function () { ]; validInputs.forEach(({ name, input }) => { test(`arc(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.arc', input); + const result = mockP5Prototype._validate('p5.arc', input); assert.isTrue(result.success); }); }); @@ -118,7 +119,7 @@ suite('Validate Params', function () { invalidInputs.forEach(({ name, input, msg }) => { test(`arc(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.arc', input); + const result = mockP5Prototype._validate('p5.arc', input); assert.equal(result.error, msg); }); }); @@ -126,7 +127,7 @@ suite('Validate Params', function () { suite('validateParams: promise where no promise is expected', function () { test('image(): promise for first argument', function () { - const result = mockP5Prototype.validate('p5.image', [Promise.resolve(), 0, 0]); + const result = mockP5Prototype._validate('p5.image', [Promise.resolve(), 0, 0]); console.log(result); assert.equal( result.error, @@ -137,7 +138,7 @@ suite('Validate Params', function () { suite('validateParams: class, multi-types + optional numbers', function () { test('ambientLight(): no firendly-err-msg', function () { - const result = mockP5Prototype.validate('p5.ambientLight', [new mockP5.Color()]); + const result = mockP5Prototype._validate('p5.ambientLight', [new mockP5.Color()]); assert.isTrue(result.success); }); }); @@ -155,7 +156,7 @@ suite('Validate Params', function () { invalidInputs.forEach(({ name, input, fn, msg }) => { test(`${fn}(): ${name}`, () => { - const result = mockP5Prototype.validate(`p5.${fn}`, input); + const result = mockP5Prototype._validate(`p5.${fn}`, input); assert.equal(result.error, msg); }); }); @@ -173,7 +174,7 @@ suite('Validate Params', function () { invalidInputs.forEach(({ fn, name, input, msg }) => { test(`${fn}(): ${name}`, () => { - const result = mockP5Prototype.validate(`p5.${fn}`, input); + const result = mockP5Prototype._validate(`p5.${fn}`, input); assert.equal(result.error, msg); }); }); @@ -187,7 +188,7 @@ suite('Validate Params', function () { ]; validInputs.forEach(({ name, input }) => { test(`color(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.color', input); + const result = mockP5Prototype._validate('p5.color', input); assert.isTrue(result.success); }); }); @@ -201,7 +202,7 @@ suite('Validate Params', function () { invalidInputs.forEach(({ name, input, msg }) => { test(`color(): ${name}`, () => { - const result = mockP5Prototype.validate('p5.color', input); + const result = mockP5Prototype._validate('p5.color', input); assert.equal(result.error, msg); }); @@ -216,13 +217,13 @@ suite('Validate Params', function () { ]; validInputs.forEach(({ name, input }) => { test(`${name}`, function () { - const result = mockP5Prototype.validate('p5.set', input); + const result = mockP5Prototype._validate('p5.set', input); assert.isTrue(result.success); }); }); test('set() with Boolean (invalid)', function () { - const result = mockP5Prototype.validate('p5.set', [0, 0, true]); + const result = mockP5Prototype._validate('p5.set', [0, 0, true]); assert.equal(result.error, '🌸 p5.js says: Expected number or array or object at the third parameter, but received boolean in p5.set().'); }); }); @@ -238,7 +239,7 @@ suite('Validate Params', function () { testCases.forEach(({ fn, name, input }) => { test(`${fn}(): ${name}`, function () { - const result = mockP5Prototype.validate(fn, input); + const result = mockP5Prototype._validate(fn, input); assert.isTrue(result.success); }); }); @@ -251,31 +252,31 @@ suite('Validate Params', function () { [new mockP5.Color(), 0.8], [new mockP5.Color(), 0.5] ]; - const result = mockP5Prototype.validate('p5.paletteLerp', [colorStops, 0.5]); + const result = mockP5Prototype._validate('p5.paletteLerp', [colorStops, 0.5]); assert.isTrue(result.success); }); }); suite('validateParams: rest arguments', function () { test('createVector(): works with no args', function() { - const result = mockP5Prototype.validate('p5.createVector', []); + const result = mockP5Prototype._validate('p5.createVector', []); assert.isTrue(result.success); }); test('createVector(): works with one number', function() { - const result = mockP5Prototype.validate('p5.createVector', [1]); + const result = mockP5Prototype._validate('p5.createVector', [1]); assert.isTrue(result.success); }); test('createVector(): works with many numbers', function() { - const result = mockP5Prototype.validate('p5.createVector', [1, 2, 3, 4]); + const result = mockP5Prototype._validate('p5.createVector', [1, 2, 3, 4]); assert.isTrue(result.success); }); test('createVector(): fails with a non-number', function() { - const result = mockP5Prototype.validate('p5.createVector', ['1']); + const result = mockP5Prototype._validate('p5.createVector', ['1']); assert.isFalse(result.success); }); test('createVector(): fails with any non-number', function() { - const result = mockP5Prototype.validate('p5.createVector', [1, 2, '3', 4]); + const result = mockP5Prototype._validate('p5.createVector', [1, 2, '3', 4]); assert.isFalse(result.success); }); }); -}); \ No newline at end of file +}); From b7ecbac7a1a7b1d0ff9bbd8fa3928d3d3171eb65 Mon Sep 17 00:00:00 2001 From: limzykenneth Date: Fri, 3 Oct 2025 12:49:32 +0100 Subject: [PATCH 5/8] Apply function decoration just before class contruction --- src/core/friendly_errors/param_validator.js | 2 +- src/core/main.js | 127 ++--- src/friendly_errors/engine.js | 569 ++++++++++++++++++++ src/webgl/p5.Framebuffer.js | 1 - 4 files changed, 617 insertions(+), 82 deletions(-) create mode 100644 src/friendly_errors/engine.js diff --git a/src/core/friendly_errors/param_validator.js b/src/core/friendly_errors/param_validator.js index 2f4b557477..a5e1d5bbef 100644 --- a/src/core/friendly_errors/param_validator.js +++ b/src/core/friendly_errors/param_validator.js @@ -579,7 +579,7 @@ function validateParams(p5, fn, lifecycles) { if (!p5.disableFriendlyErrors && !p5.disableParameterValidator) { validate(name, args); } - return p5.prototype[name].apply(this, args); + return target.call(this, ...args); }; } ); diff --git a/src/core/main.js b/src/core/main.js index 3795a48bff..4d7c30d6c1 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -77,24 +77,6 @@ class p5 { // ensure correct reporting of window dimensions this._updateWindowSize(); - // Apply addon defined decorations - for (const [patternArray, decoration] of p5.decorations) { - for(const member in p5.prototype) { - // Member must be a function - if (typeof p5.prototype[member] !== 'function') continue; - - if (!patternArray.some(pattern => { - if (typeof pattern === 'string') { - return pattern === member; - } else if (pattern instanceof RegExp) { - return pattern.test(member); - } - })) continue; - - DecoratedP5.prototype[member] = decoration.call(this, p5.prototype[member], member); - } - } - const bindGlobal = createBindGlobal(this); // If the user has created a global setup or draw function, // assume "global" mode and make everything global (i.e. on the window) @@ -422,39 +404,20 @@ class p5 { } } -class _DecoratedP5 extends p5 { - static registerAddon(addon) { - p5.registerAddon(addon); - } -} -const DecoratedP5 = new Proxy(_DecoratedP5, { - set(target, prop, newValue) { - p5[prop] = newValue; - return true; - } -}); - // Global helper function for binding properties to window in global mode function createBindGlobal(instance) { return function bindGlobal(property) { if (property === 'constructor') return; - // Common setter for all property types - const createSetter = () => newValue => { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - value: newValue, - writable: true - }); - if (!p5.disableFriendlyErrors) { - console.log(`You just changed the value of "${property}", which was a p5 global value. This could cause problems later if you're not careful.`); - } - }; - // Check if this property has a getter on the instance or prototype - const instanceDescriptor = Object.getOwnPropertyDescriptor(instance, property); - const prototypeDescriptor = Object.getOwnPropertyDescriptor(p5.prototype, property); + const instanceDescriptor = Object.getOwnPropertyDescriptor( + instance, + property + ); + const prototypeDescriptor = Object.getOwnPropertyDescriptor( + p5.prototype, + property + ); const hasGetter = (instanceDescriptor && instanceDescriptor.get) || (prototypeDescriptor && prototypeDescriptor.get); @@ -479,40 +442,18 @@ function createBindGlobal(instance) { if (isPrototypeFunction) { // For regular functions, cache the bound function const boundFunction = p5.prototype[property].bind(instance); - if (p5.disableFriendlyErrors) { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - value: boundFunction - }); - } else { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - get() { - return boundFunction; - }, - set: createSetter() - }); - } + Object.defineProperty(window, property, { + configurable: true, + enumerable: true, + value: boundFunction + }); } else if (isConstant) { // For constants, cache the value directly - if (p5.disableFriendlyErrors) { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - value: constantValue - }); - } else { - Object.defineProperty(window, property, { - configurable: true, - enumerable: true, - get() { - return constantValue; - }, - set: createSetter() - }); - } + Object.defineProperty(window, property, { + configurable: true, + enumerable: true, + value: constantValue + }); } else if (hasGetter || !isPrototypeFunction) { // For properties with getters or non-function properties, use lazy optimization // On first access, determine the type and optimize subsequent accesses @@ -547,8 +488,7 @@ function createBindGlobal(instance) { // Optimized non-function path return currentValue; } - }, - set: createSetter() + } }); } }; @@ -804,4 +744,31 @@ p5.registerAddon(renderer); p5.registerAddon(renderer2D); p5.registerAddon(graphics); -export default DecoratedP5; +const p5Proxy = new Proxy(p5, { + construct(target, args){ + if(p5.decorations.size > 0){ + // Apply addon defined decorations + for (const [patternArray, decoration] of p5.decorations) { + for(const member in p5.prototype) { + // Member must be a function + if (typeof p5.prototype[member] !== 'function') continue; + + if (!patternArray.some(pattern => { + if (typeof pattern === 'string') { + return pattern === member; + } else if (pattern instanceof RegExp) { + return pattern.test(member); + } + })) continue; + + p5.prototype[member] = decoration(p5.prototype[member], member); + } + } + + p5.decorations.clear(); + } + return new target(...args); + } +}); + +export default p5Proxy; diff --git a/src/friendly_errors/engine.js b/src/friendly_errors/engine.js new file mode 100644 index 0000000000..13781247ab --- /dev/null +++ b/src/friendly_errors/engine.js @@ -0,0 +1,569 @@ +// FES engine, runs in Web Worker +import * as constants from '../core/constants.js'; +import { z } from 'zod/v4'; +import dataDoc from '../../docs/parameterData.json'; + +self.onmessage = (event) => { + console.log(event); +}; + +// Cache for Zod schemas +let schemaRegistry = new Map(); + +// Mapping names of p5 types to their constructor functions. +// p5Constructors: +// - Color: f() +// - Graphics: f() +// - Vector: f() +// and so on. +// const p5Constructors = {}; +// NOTE: This is a tempt fix for unit test but is not correct +// Attaced constructors are `undefined` +const p5Constructors = Object.keys(p5).reduce((acc, val) => { + if ( + val.match(/^[A-Z]/) && // Starts with a capital + !val.match(/^[A-Z][A-Z0-9]*$/) && // Is not an all caps constant + p5[val] instanceof Function // Is a function + ) { + acc[val] = p5[val]; + } + return acc; +}, {}); + +function loadP5Constructors() { + // Make a list of all p5 classes to be used for argument validation + // This must be done only when everything has loaded otherwise we get + // an empty array + for (let key of Object.keys(p5)) { + // Get a list of all constructors in p5. They are functions whose names + // start with a capital letter + if (typeof p5[key] === 'function' && key[0] !== key[0].toLowerCase()) { + p5Constructors[key] = p5[key]; + } + } +} + +// `constantsMap` maps constants to their values, e.g. +// { +// ADD: 'lighter', +// ALT: 18, +// ARROW: 'default', +// AUTO: 'auto', +// ... +// } +const constantsMap = {}; +for (const [key, value] of Object.entries(constants)) { + constantsMap[key] = value; +} + +// Start initializing `schemaMap` with primitive types. `schemaMap` will +// eventually contain both primitive types and web API objects. +const schemaMap = { + 'Any': z.any(), + 'Array': z.array(z.any()), + 'Boolean': z.boolean(), + 'Function': z.function(), + 'Integer': z.number().int(), + 'Number': z.number(), + 'Object': z.object({}), + 'String': z.string() +}; + +const webAPIObjects = [ + 'AudioNode', + 'HTMLCanvasElement', + 'HTMLElement', + 'KeyboardEvent', + 'MouseEvent', + 'RegExp', + 'TouchEvent', + 'UIEvent', + 'WheelEvent' +]; + +function generateWebAPISchemas(apiObjects) { + return apiObjects.reduce((acc, obj) => { + acc[obj] = z.custom(data => data instanceof globalThis[obj], { + message: `Expected a ${obj}` + }); + return acc; + }, {}); +} + +const webAPISchemas = generateWebAPISchemas(webAPIObjects); +// Add web API schemas to the schema map. +Object.assign(schemaMap, webAPISchemas); + +// For mapping 0-indexed parameters to their ordinal representation, e.g. +// "first" for 0, "second" for 1, "third" for 2, etc. +const ordinals = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth']; + +function extractFuncNameAndClass(func) { + const ichDot = func.lastIndexOf('.'); + const funcName = func.slice(ichDot + 1); + const funcClass = func.slice(0, ichDot !== -1 ? ichDot : 0) || 'p5'; + return { funcName, funcClass }; +} + +function validBracketNesting(type) { + let level = 0; + for (let i = 0; i < type.length; i++) { + if (type[i] === '[') { + level++; + } else if (type[i] === ']') { + level--; + if (level < 0) return false; + } + } + return level === 0; +} + +/** + * This is a helper function that generates Zod schemas for a function based on + * the parameter data from `docs/parameterData.json`. + * + * Example parameter data for function `background`: + * "background": { + * "overloads": [ + * ["p5.Color"], + * ["String", "Number?"], + * ["Number", "Number?"], + * ["Number", "Number", "Number", "Number?"], + * ["Number[]"], + * ["p5.Image", "Number?"] + * ] + * } + * Where each array in `overloads` represents a set of valid overloaded + * parameters, and `?` is a shorthand for `Optional`. + * + * @method generateZodSchemasForFunc + * @param {String} func - Name of the function. Expect global functions like `sin` and class methods like `p5.Vector.add` + * @returns {z.ZodSchema} Zod schema + */ +function generateZodSchemasForFunc(func) { + const { funcName, funcClass } = extractFuncNameAndClass(func); + let funcInfo = dataDoc[funcClass][funcName]; + + if(!funcInfo) return; + + let overloads = []; + if (funcInfo.hasOwnProperty('overloads')) { + overloads = funcInfo.overloads; + } + + // Returns a schema for a single type, i.e. z.boolean() for `boolean`. + const generateTypeSchema = baseType => { + if (!baseType) return z.any(); + + let typeSchema; + + // Check for constants. Note that because we're ultimately interested in the value of + // the constant, mapping constants to their values via `constantsMap` is + // necessary. + if (baseType in constantsMap) { + typeSchema = z.literal(constantsMap[baseType]); + } + // Some more constants are attached directly to p5.prototype, e.g. by addons: + else if (baseType.match(/^[A-Z][A-Z0-9]*$/) && baseType in fn) { + typeSchema = z.literal(fn[baseType]); + } + // Function types + else if (baseType.startsWith('function')) { + typeSchema = z.function(); + } + // All p5 objects start with `p5` in the documentation, i.e. `p5.Camera`. + else if (/^p5\.[a-zA-Z0-9]+$/.exec(baseType) || baseType === 'p5') { + const className = baseType.substring(baseType.indexOf('.') + 1); + // NOTE: Will need to refactor to account for classes not imported + if(!p5Constructors[className]) return z.any(); + typeSchema = z.instanceof(p5Constructors[className]); + } + // For primitive types and web API objects. + else if (schemaMap[baseType]) { + typeSchema = schemaMap[baseType]; + } + // Tuple types + else if ( + baseType.startsWith('[') && + baseType.endsWith(']') && + validBracketNesting(baseType.slice(1, -1)) + ) { + typeSchema = z.tuple( + baseType + .slice(1, -1) + .split(/, */g) + .map(entry => generateTypeSchema(entry)) + ); + } + // JavaScript classes, e.g. Request + else if (baseType.match(/^[A-Z]/) && baseType in window) { + typeSchema = z.instanceof(window[baseType]); + } + // Generate a schema for a single parameter that can be of multiple + // types / constants, i.e. `String|Number|Array`. + // + // Here, z.union() is used over z.enum() (which seems more intuitive) for + // constants for the following reasons: + // 1) z.enum() only allows a fixed set of allowable string values. However, + // our constants sometimes have numeric or non-primitive values. + // 2) In some cases, the type can be constants or strings, making z.enum() + // insufficient for the use case. + else if (baseType.includes('|') && baseType.split('|').every(t => validBracketNesting(t))) { + const types = baseType.split('|'); + typeSchema = z.union(types + .map(t => generateTypeSchema(t)) + .filter(s => s !== undefined)); + } else if (baseType.endsWith('[]')) { + typeSchema = z.array(generateTypeSchema(baseType.slice(0, -2))); + } else { + throw new Error(`Unsupported type '${baseType}' in parameter validation. Please report this issue.`); + } + + return typeSchema; + }; + + // Generate a schema for a single parameter. In the case where a parameter can + // be of multiple types, `generateTypeSchema` is called for each type. + const generateParamSchema = param => { + const isOptional = param?.endsWith('?'); + param = param?.replace(/\?$/, ''); + + const isRest = param?.startsWith('...') && param?.endsWith('[]'); + param = param?.replace(/^\.\.\.(.+)\[\]$/, '$1'); + + let schema = generateTypeSchema(param); + // Fallback to z.custom() because function types are no longer + // returns a Zod schema. + if (schema.def.type === 'function') { + schema = z.custom(val => val instanceof Function); + } + + if (isOptional) { + schema = schema.optional(); + } + return { schema, rest: isRest }; + }; + + // Note that in Zod, `optional()` only checks for undefined, not the absence + // of value. + // + // Let's say we have a function with 3 parameters, and the last one is + // optional, i.e. func(a, b, c?). If we only have a z.tuple() for the + // parameters, where the third schema is optional, then we will only be able + // to validate func(10, 10, undefined), but not func(10, 10), which is + // a completely valid call. + // + // Therefore, on top of using `optional()`, we also have to generate parameter + // combinations that are valid for all numbers of parameters. + const generateOverloadCombinations = params => { + // No optional parameters, return the original parameter list right away. + if (!params.some(p => p?.endsWith('?'))) { + return [params]; + } + + const requiredParamsCount = params.filter(p => p === null || !p.endsWith('?')).length; + const result = []; + + for (let i = requiredParamsCount; i <= params.length; i++) { + result.push(params.slice(0, i)); + } + + return result; + }; + + // Generate schemas for each function overload and merge them + const overloadSchemas = overloads.flatMap(overload => { + const combinations = generateOverloadCombinations(overload); + + return combinations.map(combo => { + const params = combo + .map(p => generateParamSchema(p)) + .filter(s => s.schema !== undefined); + + let rest; + if (params.at(-1)?.rest) { + rest = params.pop(); + } + + let combined = z.tuple(params.map(s => s.schema)); + if (rest) { + combined = combined.rest(rest.schema); + } + return combined; + }); + }); + + return overloadSchemas.length === 1 + ? overloadSchemas[0] + : z.union(overloadSchemas); +}; + +/** + * Finds the closest schema to the input arguments. + * + * This is a helper function that identifies the closest schema to the input + * arguments, in the case of an initial validation error. We will then use the + * closest schema to generate a friendly error message. + * + * @private + * @param {z.ZodSchema} schema - Zod schema. + * @param {Array} args - User input arguments. + * @returns {z.ZodSchema} Closest schema matching the input arguments. + */ +function findClosestSchema(schema, args) { + if (!(schema instanceof z.ZodUnion)) { + return schema; + } + + // Helper function that scores how close the input arguments are to a schema. + // Lower score means closer match. + const scoreSchema = schema => { + let score = Infinity; + if (!(schema instanceof z.ZodTuple)) { + console.warn('Schema below is not a tuple: '); + printZodSchema(schema); + return score; + } + + const numArgs = args.length; + const schemaItems = schema.def.items; + const numSchemaItems = schemaItems.length; + const numRequiredSchemaItems = schemaItems + .filter(item => !item.isOptional()) + .length; + + if (numArgs >= numRequiredSchemaItems && numArgs <= numSchemaItems) { + score = 0; + } + // Here, give more weight to mismatch in number of arguments. + // + // For example, color() can either take [Number, Number?] or + // [Number, Number, Number, Number?] as list of parameters. + // If the user passed in 3 arguments, [10, undefined, undefined], it's + // more than likely that they intended to pass in 3 arguments, but the + // last two arguments are invalid. + // + // If there's no bias towards matching the number of arguments, the error + // message will show that we're expecting at most 2 arguments, but more + // are received. + else { + score = Math.abs( + numArgs < numRequiredSchemaItems ? + numRequiredSchemaItems - numArgs : + numArgs - numSchemaItems + ) * 4; + } + + for (let i = 0; i < Math.min(schemaItems.length, args.length); i++) { + const paramSchema = schemaItems[i]; + const arg = args[i]; + + if (!paramSchema.safeParse(arg).success) score++; + } + + return score; + }; + + // Default to the first schema, so that we are guaranteed to return a result. + let closestSchema = schema.def.options[0]; + // We want to return the schema with the lowest score. + let bestScore = Infinity; + + const schemaUnion = schema.def.options; + schemaUnion.forEach(schema => { + const score = scoreSchema(schema); + if (score < bestScore) { + closestSchema = schema; + bestScore = score; + } + }); + + return closestSchema; +}; + +/** + * Prints a friendly error message after parameter validation, if validation + * has failed. + * + * @method _friendlyParamError + * @private + * @param {z.ZodError} zodErrorObj - The Zod error object containing validation errors. + * @param {String} func - Name of the function. Expect global functions like `sin` and class methods like `p5.Vector.add` + * @returns {String} The friendly error message. + */ +function friendlyParamError(zodErrorObj, func, args) { + let message = '🌸 p5.js says: '; + let isVersionError = false; + // The `zodErrorObj` might contain multiple errors of equal importance + // (after scoring the schema closeness in `findClosestSchema`). Here, we + // always print the first error so that user can work through the errors + // one by one. + let currentError = zodErrorObj.issues[0]; + + // Helper function to build a type mismatch message. + const buildTypeMismatchMessage = + (actualType, expectedTypeStr, position) => { + const positionStr = position ? `at the ${ordinals[position]} parameter` : ''; + const actualTypeStr = actualType ? `, but received ${actualType}` : ''; + return `Expected ${expectedTypeStr} ${positionStr}${actualTypeStr}`; + }; + + // Union errors occur when a parameter can be of multiple types but is not + // of any of them. In this case, aggregate all possible types and print + // a friendly error message that indicates what the expected types are at + // which position (position is not 0-indexed, for accessibility reasons). + const processUnionError = error => { + const expectedTypes = new Set(); + let actualType; + + error.errors.forEach(err => { + const issue = err[0]; + if (issue) { + if (!actualType) { + actualType = issue.message; + } + + if (issue.code === 'invalid_type') { + actualType = issue.message.split(', received ')[1]; + expectedTypes.add(issue.expected); + } + // The case for constants. Since we don't want to print out the actual + // constant values in the error message, the error message will + // direct users to the documentation. + else if (issue.code === 'invalid_value') { + expectedTypes.add('constant (please refer to documentation for allowed values)'); + actualType = args[error.path[0]]; + } else if (issue.code === 'custom') { + const match = issue.message.match(/Input not instance of (\w+)/); + if (match) expectedTypes.add(match[1]); + actualType = undefined; + } + } + }); + + if (expectedTypes.size > 0) { + if (error.path?.length > 0 && args[error.path[0]] instanceof Promise) { + message += 'Did you mean to put `await` before a loading function? ' + + 'An unexpected Promise was found. '; + isVersionError = true; + } + + const expectedTypesStr = Array.from(expectedTypes).join(' or '); + const position = error.path.join('.'); + + message += buildTypeMismatchMessage( + actualType, expectedTypesStr, position + ); + } + + return message; + }; + + switch (currentError.code) { + case 'invalid_union': { + processUnionError(currentError); + break; + } + case 'too_small': { + const minArgs = currentError.minimum; + message += `Expected at least ${minArgs} argument${minArgs > 1 ? 's' : ''}, but received fewer`; + break; + } + case 'invalid_type': { + message += buildTypeMismatchMessage(currentError.message.split(', received ')[1], currentError.expected, currentError.path.join('.')); + break; + } + case 'too_big': { + const maxArgs = currentError.maximum; + message += `Expected at most ${maxArgs} argument${maxArgs > 1 ? 's' : ''}, but received more`; + break; + } + default: { + console.log('Zod error object', currentError); + } + } + + // Let the user know which function is generating the error. + message += ` in ${func}().`; + + // Generates a link to the documentation based on the given function name. + // TODO: Check if the link is reachable before appending it to the error + // message. + const generateDocumentationLink = func => { + const { funcName, funcClass } = extractFuncNameAndClass(func); + const p5BaseUrl = 'https://p5js.org/reference'; + const url = `${p5BaseUrl}/${funcClass}/${funcName}`; + + return url; + }; + + if (currentError.code === 'too_big' || currentError.code === 'too_small') { + const documentationLink = generateDocumentationLink(func); + message += ` For more information, see ${documentationLink}.`; + } + + if (isVersionError) { + p5._error(this, message); + } else { + console.log(message); + } + return message; +}; + +/** + * Runs parameter validation by matching the input parameters to Zod schemas + * generated from the parameter data from `docs/parameterData.json`. + * + * @private + * @param {String} func - Name of the function. + * @param {Array} args - User input arguments. + * @returns {Object} The validation result. + * @returns {Boolean} result.success - Whether the validation was successful. + * @returns {any} [result.data] - The parsed data if validation was successful. + * @returns {String} [result.error] - The validation error message if validation has failed. + */ +function validate(func, args) { + if (!Array.isArray(args)) { + args = Array.from(args); + } + + // An edge case: even when all arguments are optional and therefore, + // theoretically allowed to stay undefined and valid, it is likely that the + // user intended to call the function with non-undefined arguments. Skip + // regular workflow and return a friendly error message right away. + if ( + Array.isArray(args) && + args.length > 0 && + args.every(arg => arg === undefined) + ) { + const undefinedErrorMessage = `🌸 p5.js says: All arguments for ${func}() are undefined. There is likely an error in the code.`; + + return { + success: false, + error: undefinedErrorMessage + }; + } + + let funcSchemas = schemaRegistry.get(func); + if (!funcSchemas) { + funcSchemas = fn.generateZodSchemasForFunc(func); + if (!funcSchemas) return; + schemaRegistry.set(func, funcSchemas); + } + + try { + return { + success: true, + data: funcSchemas.parse(args) + }; + } catch (error) { + const closestSchema = fn.findClosestSchema(funcSchemas, args); + const zodError = closestSchema.safeParse(args).error; + const errorMessage = fn.friendlyParamError(zodError, func, args); + + return { + success: false, + error: errorMessage + }; + } +}; diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index c693131082..ad481fb586 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -1679,7 +1679,6 @@ function framebuffer(p5, fn){ * object. * @param {Object} [settings] configuration options. */ - debugger p5.Framebuffer = Framebuffer; /** From 8dc859d5d4e4c3d52c157aafde9a8398d1b497d9 Mon Sep 17 00:00:00 2001 From: limzykenneth Date: Fri, 3 Oct 2025 13:00:25 +0100 Subject: [PATCH 6/8] Basic conformance to TC39 decorator proposal --- src/core/friendly_errors/param_validator.js | 4 ++-- src/core/main.js | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/core/friendly_errors/param_validator.js b/src/core/friendly_errors/param_validator.js index a5e1d5bbef..d2c813a940 100644 --- a/src/core/friendly_errors/param_validator.js +++ b/src/core/friendly_errors/param_validator.js @@ -570,11 +570,11 @@ function validateParams(p5, fn, lifecycles) { } }; - fn._validate = validate; // For unit tests + fn._validate = validate; // TEMP: For unit tests p5.decorateHelper( /^(?!_).+$/, - function(target, name){ + function(target, { name }){ return function(...args){ if (!p5.disableFriendlyErrors && !p5.disableParameterValidator) { validate(name, args); diff --git a/src/core/main.js b/src/core/main.js index 4d7c30d6c1..211df1fcd1 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -761,7 +761,14 @@ const p5Proxy = new Proxy(p5, { } })) continue; - p5.prototype[member] = decoration(p5.prototype[member], member); + p5.prototype[member] = decoration(p5.prototype[member], { + kind: 'method', + name: member, + access: {}, + static: false, + private: false, + addInitializer(initializer){} + }); } } From 1deae4186a4e9d578f9be6ceb8cf4aad2837945d Mon Sep 17 00:00:00 2001 From: limzykenneth Date: Sat, 4 Oct 2025 11:04:36 +0100 Subject: [PATCH 7/8] Remove accidentally committed file --- src/friendly_errors/engine.js | 569 ---------------------------------- 1 file changed, 569 deletions(-) delete mode 100644 src/friendly_errors/engine.js diff --git a/src/friendly_errors/engine.js b/src/friendly_errors/engine.js deleted file mode 100644 index 13781247ab..0000000000 --- a/src/friendly_errors/engine.js +++ /dev/null @@ -1,569 +0,0 @@ -// FES engine, runs in Web Worker -import * as constants from '../core/constants.js'; -import { z } from 'zod/v4'; -import dataDoc from '../../docs/parameterData.json'; - -self.onmessage = (event) => { - console.log(event); -}; - -// Cache for Zod schemas -let schemaRegistry = new Map(); - -// Mapping names of p5 types to their constructor functions. -// p5Constructors: -// - Color: f() -// - Graphics: f() -// - Vector: f() -// and so on. -// const p5Constructors = {}; -// NOTE: This is a tempt fix for unit test but is not correct -// Attaced constructors are `undefined` -const p5Constructors = Object.keys(p5).reduce((acc, val) => { - if ( - val.match(/^[A-Z]/) && // Starts with a capital - !val.match(/^[A-Z][A-Z0-9]*$/) && // Is not an all caps constant - p5[val] instanceof Function // Is a function - ) { - acc[val] = p5[val]; - } - return acc; -}, {}); - -function loadP5Constructors() { - // Make a list of all p5 classes to be used for argument validation - // This must be done only when everything has loaded otherwise we get - // an empty array - for (let key of Object.keys(p5)) { - // Get a list of all constructors in p5. They are functions whose names - // start with a capital letter - if (typeof p5[key] === 'function' && key[0] !== key[0].toLowerCase()) { - p5Constructors[key] = p5[key]; - } - } -} - -// `constantsMap` maps constants to their values, e.g. -// { -// ADD: 'lighter', -// ALT: 18, -// ARROW: 'default', -// AUTO: 'auto', -// ... -// } -const constantsMap = {}; -for (const [key, value] of Object.entries(constants)) { - constantsMap[key] = value; -} - -// Start initializing `schemaMap` with primitive types. `schemaMap` will -// eventually contain both primitive types and web API objects. -const schemaMap = { - 'Any': z.any(), - 'Array': z.array(z.any()), - 'Boolean': z.boolean(), - 'Function': z.function(), - 'Integer': z.number().int(), - 'Number': z.number(), - 'Object': z.object({}), - 'String': z.string() -}; - -const webAPIObjects = [ - 'AudioNode', - 'HTMLCanvasElement', - 'HTMLElement', - 'KeyboardEvent', - 'MouseEvent', - 'RegExp', - 'TouchEvent', - 'UIEvent', - 'WheelEvent' -]; - -function generateWebAPISchemas(apiObjects) { - return apiObjects.reduce((acc, obj) => { - acc[obj] = z.custom(data => data instanceof globalThis[obj], { - message: `Expected a ${obj}` - }); - return acc; - }, {}); -} - -const webAPISchemas = generateWebAPISchemas(webAPIObjects); -// Add web API schemas to the schema map. -Object.assign(schemaMap, webAPISchemas); - -// For mapping 0-indexed parameters to their ordinal representation, e.g. -// "first" for 0, "second" for 1, "third" for 2, etc. -const ordinals = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth']; - -function extractFuncNameAndClass(func) { - const ichDot = func.lastIndexOf('.'); - const funcName = func.slice(ichDot + 1); - const funcClass = func.slice(0, ichDot !== -1 ? ichDot : 0) || 'p5'; - return { funcName, funcClass }; -} - -function validBracketNesting(type) { - let level = 0; - for (let i = 0; i < type.length; i++) { - if (type[i] === '[') { - level++; - } else if (type[i] === ']') { - level--; - if (level < 0) return false; - } - } - return level === 0; -} - -/** - * This is a helper function that generates Zod schemas for a function based on - * the parameter data from `docs/parameterData.json`. - * - * Example parameter data for function `background`: - * "background": { - * "overloads": [ - * ["p5.Color"], - * ["String", "Number?"], - * ["Number", "Number?"], - * ["Number", "Number", "Number", "Number?"], - * ["Number[]"], - * ["p5.Image", "Number?"] - * ] - * } - * Where each array in `overloads` represents a set of valid overloaded - * parameters, and `?` is a shorthand for `Optional`. - * - * @method generateZodSchemasForFunc - * @param {String} func - Name of the function. Expect global functions like `sin` and class methods like `p5.Vector.add` - * @returns {z.ZodSchema} Zod schema - */ -function generateZodSchemasForFunc(func) { - const { funcName, funcClass } = extractFuncNameAndClass(func); - let funcInfo = dataDoc[funcClass][funcName]; - - if(!funcInfo) return; - - let overloads = []; - if (funcInfo.hasOwnProperty('overloads')) { - overloads = funcInfo.overloads; - } - - // Returns a schema for a single type, i.e. z.boolean() for `boolean`. - const generateTypeSchema = baseType => { - if (!baseType) return z.any(); - - let typeSchema; - - // Check for constants. Note that because we're ultimately interested in the value of - // the constant, mapping constants to their values via `constantsMap` is - // necessary. - if (baseType in constantsMap) { - typeSchema = z.literal(constantsMap[baseType]); - } - // Some more constants are attached directly to p5.prototype, e.g. by addons: - else if (baseType.match(/^[A-Z][A-Z0-9]*$/) && baseType in fn) { - typeSchema = z.literal(fn[baseType]); - } - // Function types - else if (baseType.startsWith('function')) { - typeSchema = z.function(); - } - // All p5 objects start with `p5` in the documentation, i.e. `p5.Camera`. - else if (/^p5\.[a-zA-Z0-9]+$/.exec(baseType) || baseType === 'p5') { - const className = baseType.substring(baseType.indexOf('.') + 1); - // NOTE: Will need to refactor to account for classes not imported - if(!p5Constructors[className]) return z.any(); - typeSchema = z.instanceof(p5Constructors[className]); - } - // For primitive types and web API objects. - else if (schemaMap[baseType]) { - typeSchema = schemaMap[baseType]; - } - // Tuple types - else if ( - baseType.startsWith('[') && - baseType.endsWith(']') && - validBracketNesting(baseType.slice(1, -1)) - ) { - typeSchema = z.tuple( - baseType - .slice(1, -1) - .split(/, */g) - .map(entry => generateTypeSchema(entry)) - ); - } - // JavaScript classes, e.g. Request - else if (baseType.match(/^[A-Z]/) && baseType in window) { - typeSchema = z.instanceof(window[baseType]); - } - // Generate a schema for a single parameter that can be of multiple - // types / constants, i.e. `String|Number|Array`. - // - // Here, z.union() is used over z.enum() (which seems more intuitive) for - // constants for the following reasons: - // 1) z.enum() only allows a fixed set of allowable string values. However, - // our constants sometimes have numeric or non-primitive values. - // 2) In some cases, the type can be constants or strings, making z.enum() - // insufficient for the use case. - else if (baseType.includes('|') && baseType.split('|').every(t => validBracketNesting(t))) { - const types = baseType.split('|'); - typeSchema = z.union(types - .map(t => generateTypeSchema(t)) - .filter(s => s !== undefined)); - } else if (baseType.endsWith('[]')) { - typeSchema = z.array(generateTypeSchema(baseType.slice(0, -2))); - } else { - throw new Error(`Unsupported type '${baseType}' in parameter validation. Please report this issue.`); - } - - return typeSchema; - }; - - // Generate a schema for a single parameter. In the case where a parameter can - // be of multiple types, `generateTypeSchema` is called for each type. - const generateParamSchema = param => { - const isOptional = param?.endsWith('?'); - param = param?.replace(/\?$/, ''); - - const isRest = param?.startsWith('...') && param?.endsWith('[]'); - param = param?.replace(/^\.\.\.(.+)\[\]$/, '$1'); - - let schema = generateTypeSchema(param); - // Fallback to z.custom() because function types are no longer - // returns a Zod schema. - if (schema.def.type === 'function') { - schema = z.custom(val => val instanceof Function); - } - - if (isOptional) { - schema = schema.optional(); - } - return { schema, rest: isRest }; - }; - - // Note that in Zod, `optional()` only checks for undefined, not the absence - // of value. - // - // Let's say we have a function with 3 parameters, and the last one is - // optional, i.e. func(a, b, c?). If we only have a z.tuple() for the - // parameters, where the third schema is optional, then we will only be able - // to validate func(10, 10, undefined), but not func(10, 10), which is - // a completely valid call. - // - // Therefore, on top of using `optional()`, we also have to generate parameter - // combinations that are valid for all numbers of parameters. - const generateOverloadCombinations = params => { - // No optional parameters, return the original parameter list right away. - if (!params.some(p => p?.endsWith('?'))) { - return [params]; - } - - const requiredParamsCount = params.filter(p => p === null || !p.endsWith('?')).length; - const result = []; - - for (let i = requiredParamsCount; i <= params.length; i++) { - result.push(params.slice(0, i)); - } - - return result; - }; - - // Generate schemas for each function overload and merge them - const overloadSchemas = overloads.flatMap(overload => { - const combinations = generateOverloadCombinations(overload); - - return combinations.map(combo => { - const params = combo - .map(p => generateParamSchema(p)) - .filter(s => s.schema !== undefined); - - let rest; - if (params.at(-1)?.rest) { - rest = params.pop(); - } - - let combined = z.tuple(params.map(s => s.schema)); - if (rest) { - combined = combined.rest(rest.schema); - } - return combined; - }); - }); - - return overloadSchemas.length === 1 - ? overloadSchemas[0] - : z.union(overloadSchemas); -}; - -/** - * Finds the closest schema to the input arguments. - * - * This is a helper function that identifies the closest schema to the input - * arguments, in the case of an initial validation error. We will then use the - * closest schema to generate a friendly error message. - * - * @private - * @param {z.ZodSchema} schema - Zod schema. - * @param {Array} args - User input arguments. - * @returns {z.ZodSchema} Closest schema matching the input arguments. - */ -function findClosestSchema(schema, args) { - if (!(schema instanceof z.ZodUnion)) { - return schema; - } - - // Helper function that scores how close the input arguments are to a schema. - // Lower score means closer match. - const scoreSchema = schema => { - let score = Infinity; - if (!(schema instanceof z.ZodTuple)) { - console.warn('Schema below is not a tuple: '); - printZodSchema(schema); - return score; - } - - const numArgs = args.length; - const schemaItems = schema.def.items; - const numSchemaItems = schemaItems.length; - const numRequiredSchemaItems = schemaItems - .filter(item => !item.isOptional()) - .length; - - if (numArgs >= numRequiredSchemaItems && numArgs <= numSchemaItems) { - score = 0; - } - // Here, give more weight to mismatch in number of arguments. - // - // For example, color() can either take [Number, Number?] or - // [Number, Number, Number, Number?] as list of parameters. - // If the user passed in 3 arguments, [10, undefined, undefined], it's - // more than likely that they intended to pass in 3 arguments, but the - // last two arguments are invalid. - // - // If there's no bias towards matching the number of arguments, the error - // message will show that we're expecting at most 2 arguments, but more - // are received. - else { - score = Math.abs( - numArgs < numRequiredSchemaItems ? - numRequiredSchemaItems - numArgs : - numArgs - numSchemaItems - ) * 4; - } - - for (let i = 0; i < Math.min(schemaItems.length, args.length); i++) { - const paramSchema = schemaItems[i]; - const arg = args[i]; - - if (!paramSchema.safeParse(arg).success) score++; - } - - return score; - }; - - // Default to the first schema, so that we are guaranteed to return a result. - let closestSchema = schema.def.options[0]; - // We want to return the schema with the lowest score. - let bestScore = Infinity; - - const schemaUnion = schema.def.options; - schemaUnion.forEach(schema => { - const score = scoreSchema(schema); - if (score < bestScore) { - closestSchema = schema; - bestScore = score; - } - }); - - return closestSchema; -}; - -/** - * Prints a friendly error message after parameter validation, if validation - * has failed. - * - * @method _friendlyParamError - * @private - * @param {z.ZodError} zodErrorObj - The Zod error object containing validation errors. - * @param {String} func - Name of the function. Expect global functions like `sin` and class methods like `p5.Vector.add` - * @returns {String} The friendly error message. - */ -function friendlyParamError(zodErrorObj, func, args) { - let message = '🌸 p5.js says: '; - let isVersionError = false; - // The `zodErrorObj` might contain multiple errors of equal importance - // (after scoring the schema closeness in `findClosestSchema`). Here, we - // always print the first error so that user can work through the errors - // one by one. - let currentError = zodErrorObj.issues[0]; - - // Helper function to build a type mismatch message. - const buildTypeMismatchMessage = - (actualType, expectedTypeStr, position) => { - const positionStr = position ? `at the ${ordinals[position]} parameter` : ''; - const actualTypeStr = actualType ? `, but received ${actualType}` : ''; - return `Expected ${expectedTypeStr} ${positionStr}${actualTypeStr}`; - }; - - // Union errors occur when a parameter can be of multiple types but is not - // of any of them. In this case, aggregate all possible types and print - // a friendly error message that indicates what the expected types are at - // which position (position is not 0-indexed, for accessibility reasons). - const processUnionError = error => { - const expectedTypes = new Set(); - let actualType; - - error.errors.forEach(err => { - const issue = err[0]; - if (issue) { - if (!actualType) { - actualType = issue.message; - } - - if (issue.code === 'invalid_type') { - actualType = issue.message.split(', received ')[1]; - expectedTypes.add(issue.expected); - } - // The case for constants. Since we don't want to print out the actual - // constant values in the error message, the error message will - // direct users to the documentation. - else if (issue.code === 'invalid_value') { - expectedTypes.add('constant (please refer to documentation for allowed values)'); - actualType = args[error.path[0]]; - } else if (issue.code === 'custom') { - const match = issue.message.match(/Input not instance of (\w+)/); - if (match) expectedTypes.add(match[1]); - actualType = undefined; - } - } - }); - - if (expectedTypes.size > 0) { - if (error.path?.length > 0 && args[error.path[0]] instanceof Promise) { - message += 'Did you mean to put `await` before a loading function? ' + - 'An unexpected Promise was found. '; - isVersionError = true; - } - - const expectedTypesStr = Array.from(expectedTypes).join(' or '); - const position = error.path.join('.'); - - message += buildTypeMismatchMessage( - actualType, expectedTypesStr, position - ); - } - - return message; - }; - - switch (currentError.code) { - case 'invalid_union': { - processUnionError(currentError); - break; - } - case 'too_small': { - const minArgs = currentError.minimum; - message += `Expected at least ${minArgs} argument${minArgs > 1 ? 's' : ''}, but received fewer`; - break; - } - case 'invalid_type': { - message += buildTypeMismatchMessage(currentError.message.split(', received ')[1], currentError.expected, currentError.path.join('.')); - break; - } - case 'too_big': { - const maxArgs = currentError.maximum; - message += `Expected at most ${maxArgs} argument${maxArgs > 1 ? 's' : ''}, but received more`; - break; - } - default: { - console.log('Zod error object', currentError); - } - } - - // Let the user know which function is generating the error. - message += ` in ${func}().`; - - // Generates a link to the documentation based on the given function name. - // TODO: Check if the link is reachable before appending it to the error - // message. - const generateDocumentationLink = func => { - const { funcName, funcClass } = extractFuncNameAndClass(func); - const p5BaseUrl = 'https://p5js.org/reference'; - const url = `${p5BaseUrl}/${funcClass}/${funcName}`; - - return url; - }; - - if (currentError.code === 'too_big' || currentError.code === 'too_small') { - const documentationLink = generateDocumentationLink(func); - message += ` For more information, see ${documentationLink}.`; - } - - if (isVersionError) { - p5._error(this, message); - } else { - console.log(message); - } - return message; -}; - -/** - * Runs parameter validation by matching the input parameters to Zod schemas - * generated from the parameter data from `docs/parameterData.json`. - * - * @private - * @param {String} func - Name of the function. - * @param {Array} args - User input arguments. - * @returns {Object} The validation result. - * @returns {Boolean} result.success - Whether the validation was successful. - * @returns {any} [result.data] - The parsed data if validation was successful. - * @returns {String} [result.error] - The validation error message if validation has failed. - */ -function validate(func, args) { - if (!Array.isArray(args)) { - args = Array.from(args); - } - - // An edge case: even when all arguments are optional and therefore, - // theoretically allowed to stay undefined and valid, it is likely that the - // user intended to call the function with non-undefined arguments. Skip - // regular workflow and return a friendly error message right away. - if ( - Array.isArray(args) && - args.length > 0 && - args.every(arg => arg === undefined) - ) { - const undefinedErrorMessage = `🌸 p5.js says: All arguments for ${func}() are undefined. There is likely an error in the code.`; - - return { - success: false, - error: undefinedErrorMessage - }; - } - - let funcSchemas = schemaRegistry.get(func); - if (!funcSchemas) { - funcSchemas = fn.generateZodSchemasForFunc(func); - if (!funcSchemas) return; - schemaRegistry.set(func, funcSchemas); - } - - try { - return { - success: true, - data: funcSchemas.parse(args) - }; - } catch (error) { - const closestSchema = fn.findClosestSchema(funcSchemas, args); - const zodError = closestSchema.safeParse(args).error; - const errorMessage = fn.friendlyParamError(zodError, func, args); - - return { - success: false, - error: errorMessage - }; - } -}; From fd9df8c7cebd77196a9ed4e00dfe3a3b0792e7cc Mon Sep 17 00:00:00 2001 From: limzykenneth Date: Sat, 4 Oct 2025 11:48:40 +0100 Subject: [PATCH 8/8] Move decoration application to constructor --- src/core/main.js | 65 ++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/src/core/main.js b/src/core/main.js index 211df1fcd1..526171150c 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -48,6 +48,35 @@ class p5 { static _friendlyFileLoadError = () => {}; constructor(sketch, node) { + // Apply addon defined decorations + if(p5.decorations.size > 0){ + for (const [patternArray, decoration] of p5.decorations) { + for(const member in p5.prototype) { + // Member must be a function + if (typeof p5.prototype[member] !== 'function') continue; + + if (!patternArray.some(pattern => { + if (typeof pattern === 'string') { + return pattern === member; + } else if (pattern instanceof RegExp) { + return pattern.test(member); + } + })) continue; + + p5.prototype[member] = decoration(p5.prototype[member], { + kind: 'method', + name: member, + access: {}, + static: false, + private: false, + addInitializer(initializer){} + }); + } + } + + p5.decorations.clear(); + } + ////////////////////////////////////////////// // PRIVATE p5 PROPERTIES AND METHODS ////////////////////////////////////////////// @@ -744,38 +773,4 @@ p5.registerAddon(renderer); p5.registerAddon(renderer2D); p5.registerAddon(graphics); -const p5Proxy = new Proxy(p5, { - construct(target, args){ - if(p5.decorations.size > 0){ - // Apply addon defined decorations - for (const [patternArray, decoration] of p5.decorations) { - for(const member in p5.prototype) { - // Member must be a function - if (typeof p5.prototype[member] !== 'function') continue; - - if (!patternArray.some(pattern => { - if (typeof pattern === 'string') { - return pattern === member; - } else if (pattern instanceof RegExp) { - return pattern.test(member); - } - })) continue; - - p5.prototype[member] = decoration(p5.prototype[member], { - kind: 'method', - name: member, - access: {}, - static: false, - private: false, - addInitializer(initializer){} - }); - } - } - - p5.decorations.clear(); - } - return new target(...args); - } -}); - -export default p5Proxy; +export default p5;