@playcanvas/react
Version:
A React renderer for PlayCanvas – build interactive 3D applications using React's declarative paradigm.
430 lines • 18.5 kB
JavaScript
import { Color, Quat, Vec2, Vec3, Vec4, Mat4, Application, NullGraphicsDevice, Material } from "playcanvas";
import { getColorFromName } from "./color.js";
import { env } from "./env.js";
// Limit the size of the warned set to prevent memory leaks
const MAX_WARNED_SIZE = 1000;
const warned = new Set();
export const warnOnce = (message) => {
if (!warned.has(message)) {
if (env !== 'production') {
// Use setTimeout to break the call stack
setTimeout(() => {
// Apply styling to make the warning stand out
console.warn('%c[PlayCanvas React]:', 'color: #ff9800; font-weight: bold; font-size: 12px;', message);
}, 0);
}
else {
// Error in production for critical issues
console.error(`[PlayCanvas React]:\n\n` +
`${message}`);
}
warned.add(message);
// Prevent the warned set from growing too large
if (warned.size > MAX_WARNED_SIZE) {
// Remove oldest entries when the set gets too large
const entriesToRemove = Array.from(warned).slice(0, warned.size - MAX_WARNED_SIZE);
entriesToRemove.forEach(entry => warned.delete(entry));
}
}
};
/**
* Validate and sanitize a prop. This will validate the prop and return the default value if the prop is invalid.
*
* @param value The value to validate.
* @param propDef The prop definition.
* @param propName The name of the prop.
* @param componentName The name of the component.
* @param apiName The API name of the component. eg `<Render/>`. Use for logging.
*/
export function validateAndSanitize(value, propDef, propName, componentName, apiName) {
const isValid = value !== undefined && propDef.validate(value);
if (!isValid && value !== undefined && env !== 'production') {
warnOnce(`Invalid prop "${propName}" in \`<${componentName} ${propName}={${JSON.stringify(value)}} />\`\n` +
` ${propDef.errorMsg(value)}\n` +
(propDef.default !== undefined ? ` Using default: ${JSON.stringify(propDef.default)}` : '') +
`\n\n See the docs https://api.playcanvas.com/engine/classes/${apiName ?? componentName}.html.`);
}
return isValid ? value : propDef.default;
}
/**
* Validate props partially. This iterates over props and validates them against the schema.
* If a prop is not in the schema, it will raise a warning but not applied.
* This will not return default values for missing props.
*
* @param rawProps The raw props to validate.
* @param componentDef The component definition.
* @param warnUnknownProps Whether to warn about unknown props.
* @returns The validated props.
*/
export function validatePropsPartial(rawProps, componentDef, warnUnknownProps = true) {
// Start with a copy of the raw props
const { schema, name, apiName } = componentDef;
const result = { ...rawProps };
// Track unknown props for warning
const unknownProps = [];
// Process each prop once
Object.keys(rawProps).forEach((key) => {
// Skip 'children' as it's a special React prop
if (key === 'children')
return;
// Check if this prop is in the schema
if (key in schema) {
// Validate and sanitize props that are in the schema
const propDef = schema[key];
if (propDef) {
result[key] = validateAndSanitize(rawProps[key], propDef, key, name, apiName);
}
}
else {
// Collect unknown props for warning
unknownProps.push(key);
}
});
// Warn about unknown props in development mode
if (env !== 'production' && warnUnknownProps && unknownProps.length > 0) {
warnOnce(`Unknown props in "<${name}/>."\n` +
`The following props are invalid and will be ignored: "${unknownProps.join('", "')}"\n\n` +
`Please see the documentation https://api.playcanvas.com/engine/classes/${apiName ?? name}.html.`);
}
return result;
}
/**
* Validate props returning defaults. This iterates over a schema and uses the default value if the prop is not defined.
*
* @param rawProps The raw props to validate.
* @param componentDef The component definition.
* @param warnUnknownProps Whether to warn about unknown props.
* @returns The validated props.
*/
export function validatePropsWithDefaults(rawProps, componentDef, warnUnknownProps = true) {
const { schema, name, apiName } = componentDef;
// Start with an empty object (so we can control all keys)
const result = {};
// Track unknown props for warning
const unknownProps = [];
// Iterate over the schema keys — these are the "valid" props
for (const key in schema) {
const propDef = schema[key];
const rawValue = rawProps[key];
// Use raw value if defined, otherwise fall back to default
const valueToValidate = rawValue !== undefined ? rawValue : propDef?.default;
result[key] = validateAndSanitize(valueToValidate, propDef, key, name, apiName);
}
// Optionally warn about unknown props
if (env !== 'production' &&
warnUnknownProps &&
rawProps) {
for (const key in rawProps) {
if (key === 'children')
continue;
if (!(key in schema)) {
unknownProps.push(key);
}
}
if (unknownProps.length > 0) {
warnOnce(`Unknown props in "<${name}/>."\n` +
`The following props are invalid and will be ignored: "${unknownProps.join('", "')}"\n\n` +
`Please see the documentation https://api.playcanvas.com/engine/classes/${apiName ?? name}.html.`);
}
}
return result;
}
/**
* Apply props to an instance in a safe way. It will use the apply function if it exists, otherwise it will assign the value directly.
* This is useful for components that need to map props to instance properties. eg [0, 1] => Vec2(0, 1).
*
* @param container The container to apply the props to
* @param schema The schema of the container
* @param props The props to apply
*/
export function applyProps(instance, schema, props) {
Object.entries(props).forEach(([key, value]) => {
if (key in schema) {
const propDef = schema[key];
if (propDef) {
if (propDef.apply) {
// Use type assertion to satisfy the type checker
propDef.apply(instance, props, key);
}
else {
try {
instance[key] = value;
}
catch (error) {
console.error(`Error applying prop ${key}: ${error}`);
}
}
}
}
});
}
/**
* Get the pseudo public props of an instance with setter information. This is useful for creating a component definition from an instance.
* @param container The container to get the pseudo public props from.
* @returns The pseudo public props of the container with setter flags.
*/
export function getPseudoPublicProps(container) {
const result = {};
// Get regular enumerable properties
const entries = Object.entries(container)
.filter(([key]) => !key.startsWith('_') && typeof container[key] !== 'function');
// Add regular properties (not defined with setters)
entries.forEach(([key, value]) => {
result[key] = {
value,
isDefinedWithSetter: false
};
});
// Get getters and setters from the prototype
const prototype = Object.getPrototypeOf(container);
if (prototype && prototype !== Object.prototype) {
const descriptors = Object.getOwnPropertyDescriptors(prototype);
Object.entries(descriptors).forEach(([key, descriptor]) => {
// Skip private properties and constructor
if (key.startsWith('_') || key === 'constructor')
return;
const hasGetter = typeof descriptor.get === 'function';
const hasSetter = typeof descriptor.set === 'function';
if (hasSetter && !hasGetter)
return;
// If it's a getter/setter property, try to get the value
if (descriptor.get) {
const originalWarn = console.warn;
try {
// Temporarily silence the console
console.warn = () => { };
const value = descriptor.get.call(container);
// Create a shallow copy of the value to avoid reference issues
const safeValue = value !== null && typeof value === 'object'
? value.clone ? value.clone() : { ...value }
: value;
result[key] = {
value: safeValue,
isDefinedWithSetter: hasSetter
};
}
catch {
// If we can't get the value, just use undefined
result[key] = {
value: undefined,
isDefinedWithSetter: hasSetter
};
}
finally {
// Restore the console
console.warn = originalWarn;
}
}
else if (hasSetter) {
// Setter-only property
result[key] = {
value: undefined,
isDefinedWithSetter: true
};
}
});
}
return result;
}
/**
* Check if a specific property is defined using a setter (like `set prop(){}`).
* @param container The container to check.
* @param propName The property name to check.
* @returns True if the property is defined using a setter, false otherwise.
*/
export function isDefinedWithSetter(container, propName) {
const prototype = Object.getPrototypeOf(container);
if (prototype && prototype !== Object.prototype) {
const descriptor = Object.getOwnPropertyDescriptor(prototype, propName);
return descriptor?.set !== undefined;
}
return false;
}
/**
* Create a component definition from an instance. A component definition is a schema that describes the props of a component,
* and can be used to validate and apply props to an instance.
*
* @param name The name of the component.
* @param createInstance A function that creates an instance of the component.
* @param cleanup A function that cleans up the instance.
* @param options The options for the component definition.
* @param options.exclude The props to exclude from the component definition.
* @param options.apiName The API name of the component.
*/
export function createComponentDefinition(name, createInstance, cleanup, options) {
const { exclude = [], apiName = name } = options ?? {};
const instance = createInstance();
const schema = {};
const props = getPseudoPublicProps(instance);
const entries = Object.entries(props);
// Basic type detection
entries.forEach(([key, propertyInfo]) => {
if (exclude.includes(String(key)))
return;
const { value, isDefinedWithSetter } = propertyInfo;
// Colors
if (value instanceof Color) {
schema[key] = {
validate: (val) => (Array.isArray(val) && val.length === 3) || typeof val === 'string',
default: value.toString(true),
errorMsg: (val) => `Invalid value for prop "${String(key)}": "${val}". ` +
`Expected a hex like "#FF0000", CSS color name like "red", or an array "[1, 0, 0]").`,
apply: (instance, props, key) => {
if (typeof props[key] === 'string') {
const colorString = getColorFromName(props[key]) || props[key];
instance[key] = new Color().fromString(colorString);
}
else {
instance[key] = instance[key] = new Color().fromArray(props[key]);
}
}
};
}
// Vec2
else if (value instanceof Vec2) {
schema[key] = {
validate: (val) => Array.isArray(val) && val.length === 2,
default: [value.x, value.y],
errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". ` +
`Expected an array of 2 numbers.`,
apply: isDefinedWithSetter ? (instance, props, key) => {
instance[key] = new Vec2().fromArray(props[key]);
} : (instance, props, key) => {
instance[key].set(...props[key]);
}
};
}
// Vec3
else if (value instanceof Vec3) {
schema[key] = {
validate: (val) => Array.isArray(val) && val.length === 3,
default: [value.x, value.y, value.z],
errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". ` +
`Expected an array of 3 numbers.`,
apply: isDefinedWithSetter ? (instance, props, key) => {
instance[key] = new Vec3().fromArray(props[key]);
} : (instance, props, key) => {
instance[key].set(...props[key]);
}
};
}
// Vec4
else if (value instanceof Vec4) {
schema[key] = {
validate: (val) => Array.isArray(val) && val.length === 4,
default: [value.x, value.y, value.z, value.w],
errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". Expected an array of 4 numbers.`,
apply: isDefinedWithSetter ? (instance, props, key) => {
instance[key] = new Vec4().fromArray(props[key]);
} : (instance, props, key) => {
instance[key].set(...props[key]);
}
};
}
// Quaternions
else if (value instanceof Quat) {
schema[key] = {
validate: (val) => Array.isArray(val) && (val.length === 4 || val.length === 3),
default: [value.x, value.y, value.z, value.w],
errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". ` +
`Expected an array of 4 numbers.`,
apply: isDefinedWithSetter ? (instance, props, key) => {
instance[key] = new Quat().fromArray(props[key]);
} : (instance, props, key) => {
instance[key].set(...props[key]);
}
};
}
// Mat4
else if (value instanceof Mat4) {
schema[key] = {
validate: (val) => Array.isArray(val) && val.length === 16,
default: Array.from((value.data)),
errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". ` +
`Expected an array of 16 numbers.`,
};
}
// Numbers
else if (typeof value === 'number') {
schema[key] = {
validate: (val) => typeof val === 'number',
default: value,
errorMsg: (val) => `Invalid value for prop "${String(key)}": "${val}". Expected a number.`
};
}
// Strings
else if (typeof value === 'string') {
schema[key] = {
validate: (val) => typeof val === 'string',
default: value,
errorMsg: (val) => `Invalid value for prop "${String(key)}": "${val}". Expected a string.`
};
}
// Booleans
else if (typeof value === 'boolean') {
schema[key] = {
validate: (val) => typeof val === 'boolean',
default: value,
errorMsg: (val) => `Invalid value for prop "${String(key)}": "${val}". Expected a boolean.`
};
}
// Arrays
else if (Array.isArray(value)) {
schema[key] = {
validate: (val) => Array.isArray(val),
default: value,
errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". Expected an array.`,
apply: (instance, props, key) => {
// For arrays, use a different approach to avoid spread operator issues
const values = props[key];
// mutate the instance
if (Array.isArray(values)) {
const target = instance[key];
target.length = 0;
target.push(...values);
}
}
};
}
// Materials
else if (value instanceof Material) {
schema[key] = {
validate: (val) => val instanceof Material,
default: value,
errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". Expected a Material.`,
};
}
// Null
else if (value === null) {
schema[key] = {
validate: () => true,
default: value,
errorMsg: () => '',
apply: (instance, props, key) => {
instance[key] = props[key];
}
};
}
});
if (cleanup)
cleanup(instance);
const componentDef = {
name,
apiName,
schema
};
return componentDef;
}
/**
* This is a mock application that is used to render the application without a canvas.
* @private
* @returns A mock application that is used to render the application without a canvas.
*/
export function getNullApplication() {
const mockCanvas = { id: 'pc-react-mock-canvas' };
// @ts-expect-error - Mock canvas is not a real canvas
return new Application(mockCanvas, { graphicsDevice: new NullGraphicsDevice(mockCanvas) });
}
const localApp = getNullApplication();
export const getStaticNullApplication = () => localApp;
//# sourceMappingURL=validation.js.map