Fix: Support custom asset loading for GSplat (getter-only props)#316
Fix: Support custom asset loading for GSplat (getter-only props)#316No-LAN-B wants to merge 3 commits intoplaycanvas:mainfrom
Conversation
|
There was a problem hiding this comment.
Pull request overview
This PR fixes a runtime error ("Cannot set property id of # which has only a getter") that occurs when using <GSplat asset={customAsset} /> with a custom-loaded asset. The root cause is that getter-only properties (like GSplatComponent.id) were being included in the component schema and subsequently assigned during prop application. The fix introduces a readOnly flag on schema entries for getter-only properties, and guards against assignment in applyProps and inside individual apply callbacks.
Changes:
- Adds
readOnly?: booleanto thePropValidatortype andPropertyInfotype - Adds getter-only detection in
createComponentDefinitionusing property descriptors, and marks matching schema entries asreadOnly - Guards
applyPropsand individualapplycallbacks against assigning to read-only props
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| }); | ||
| } | ||
|
|
||
|
|
There was a problem hiding this comment.
There is a spurious trailing blank line with leading whitespace at line 244, immediately after the closing brace of applyProps. This stray indented blank line is inconsistent with the rest of the file's formatting and should be removed.
| export function applyProps<T extends Record<string, unknown>, InstanceType>( | ||
| instance: InstanceType, | ||
| schema: Schema<T, InstanceType>, | ||
| instance: InstanceType, | ||
| schema: Schema<T, InstanceType>, | ||
| props: T | ||
| ) { | ||
| Object.entries(props as Record<keyof T, unknown>).forEach(([key, value]) => { | ||
| if (key in schema) { | ||
| const propDef = schema[key as keyof T] as PropValidator<T[keyof T], InstanceType>; | ||
| if (propDef) { | ||
| if (propDef.apply) { | ||
| // Use type assertion to satisfy the type checker | ||
| propDef.apply(instance, props, key as string); | ||
| } else { | ||
| try { | ||
| (instance as Record<string, unknown>)[key] = value; | ||
| } catch (error) { | ||
| console.error(`Error applying prop ${key}: ${error}`); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| ) { | ||
| Object.entries(props).forEach(([key, value]) => { | ||
| if (!(key in schema)) return; | ||
| const propDef = schema[key] as PropValidator<T[keyof T], InstanceType> | undefined; | ||
| if (!propDef || propDef.readOnly) return; | ||
|
|
||
| if (propDef.apply) { | ||
| propDef.apply(instance, props, key as string); | ||
| } else { | ||
| try { | ||
| (instance as Record<string, unknown>)[key] = value; | ||
| } catch (error) { | ||
| console.error(`Error applying prop ${key}:`, error); | ||
| } | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
The new readOnly guard in applyProps and the getter-only detection in createComponentDefinition are the core of this fix but have no test coverage. Given that the codebase has component-level tests (e.g., Screen.test.tsx, Script.test.tsx) and the fix is non-trivial, a test should be added to verify that: (1) a component schema entry for a getter-only prop has readOnly: true (for primitive/Mat4/Material types), (2) applyProps does not throw or attempt to assign when a schema entry has readOnly: true, and (3) the original failing scenario (passing a custom asset to <GSplat />) works without throwing.
| isDefinedWithSetter: boolean; | ||
| }; | ||
| readOnly?: boolean; // Mark properties that should not be assigned | ||
| }; |
There was a problem hiding this comment.
The closing brace of the PropertyInfo type is indented with 2 spaces ( };) while the type body uses 4-space indentation. This is inconsistent with the rest of the file which uses 4-space indentation for type declarations. The closing brace should be at column 0, consistent with how PropValidator and Schema types are defined in this file.
| }; | |
| }; |
There was a problem hiding this comment.
I normally have a lint script run on commit so I must have simply forgot to run the linter explicitly here should be a simple linter run to adapt some of the inline consistency.
| @@ -380,6 +390,9 @@ export function createComponentDefinition<T, InstanceType>( | |||
| errorMsg: (val: unknown) => `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 (propertyInfo.readOnly) { | |||
| return; | |||
| } | |||
| if(typeof props[key] === 'string') { | |||
| const colorString = getColorFromName(props[key] as string) || props[key] as string; | |||
| (instance[key as keyof InstanceType] as Color) = new Color().fromString(colorString); | |||
| @@ -397,8 +410,14 @@ export function createComponentDefinition<T, InstanceType>( | |||
| errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". ` + | |||
| `Expected an array of 2 numbers.`, | |||
| apply: isDefinedWithSetter ? (instance, props, key) => { | |||
| if (propertyInfo.readOnly) { | |||
| return; | |||
| } | |||
| (instance[key as keyof InstanceType] as Vec2) = new Vec2().fromArray(props[key] as number[]); | |||
| } : (instance, props, key) => { | |||
| if (propertyInfo.readOnly) { | |||
| return; | |||
| } | |||
| (instance[key as keyof InstanceType] as Vec2).set(...props[key] as [number, number]); | |||
| } | |||
| }; | |||
| @@ -411,8 +430,14 @@ export function createComponentDefinition<T, InstanceType>( | |||
| errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". ` + | |||
| `Expected an array of 3 numbers.`, | |||
| apply: isDefinedWithSetter ? (instance, props, key) => { | |||
| if (propertyInfo.readOnly) { | |||
| return; | |||
| } | |||
| (instance[key as keyof InstanceType] as Vec3) = new Vec3().fromArray(props[key] as number[]); | |||
| } : (instance, props, key) => { | |||
| if (propertyInfo.readOnly) { | |||
| return; | |||
| } | |||
| (instance[key as keyof InstanceType] as Vec3).set(...props[key] as [number, number, number]); | |||
| } | |||
| }; | |||
| @@ -424,8 +449,14 @@ export function createComponentDefinition<T, InstanceType>( | |||
| 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) => { | |||
| if (propertyInfo.readOnly) { | |||
| return; | |||
| } | |||
| (instance[key as keyof InstanceType] as Vec4) = new Vec4().fromArray(props[key] as number[]); | |||
| } : (instance, props, key) => { | |||
| if (propertyInfo.readOnly) { | |||
| return; | |||
| } | |||
| (instance[key as keyof InstanceType] as Vec4).set(...props[key] as [number, number, number, number]); | |||
| } | |||
| }; | |||
| @@ -439,8 +470,14 @@ export function createComponentDefinition<T, InstanceType>( | |||
| errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". ` + | |||
| `Expected an array of 4 numbers.`, | |||
| apply: isDefinedWithSetter ? (instance, props, key) => { | |||
| if (propertyInfo.readOnly) { | |||
| return; | |||
| } | |||
| (instance[key as keyof InstanceType] as Quat) = new Quat().fromArray(props[key] as number[]); | |||
| } : (instance, props, key) => { | |||
| if (propertyInfo.readOnly) { | |||
| return; | |||
| } | |||
| (instance[key as keyof InstanceType] as Quat).set(...props[key] as [number, number, number, number]); | |||
| } | |||
| }; | |||
| @@ -452,30 +489,34 @@ export function createComponentDefinition<T, InstanceType>( | |||
| default: Array.from((value.data)), | |||
| errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". ` + | |||
| `Expected an array of 16 numbers.`, | |||
| ...(propertyInfo.readOnly && { readOnly: true }), | |||
| }; | |||
| } | |||
| // 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.` | |||
| errorMsg: (val) => `Invalid value for prop "${String(key)}": "${val}". Expected a number.`, | |||
| ...(propertyInfo.readOnly && { readOnly: true }), | |||
| }; | |||
| } | |||
| // Strings | |||
| else if (typeof value === 'string') { | |||
| schema[key] = { | |||
| validate: (val) => typeof val === 'string', | |||
| default: value as string, | |||
| errorMsg: (val) => `Invalid value for prop "${String(key)}": "${val}". Expected a string.` | |||
| errorMsg: (val) => `Invalid value for prop "${String(key)}": "${val}". Expected a string.`, | |||
| ...(propertyInfo.readOnly && { readOnly: true }), | |||
| }; | |||
| } | |||
| // Booleans | |||
| else if (typeof value === 'boolean') { | |||
| schema[key] = { | |||
| validate: (val) => typeof val === 'boolean', | |||
| default: value as boolean, | |||
| errorMsg: (val) => `Invalid value for prop "${String(key)}": "${val}". Expected a boolean.` | |||
| errorMsg: (val) => `Invalid value for prop "${String(key)}": "${val}". Expected a boolean.`, | |||
| ...(propertyInfo.readOnly && { readOnly: true }), | |||
| }; | |||
| } | |||
|
|
|||
| @@ -486,6 +527,9 @@ export function createComponentDefinition<T, InstanceType>( | |||
| default: value, | |||
| errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". Expected an array.`, | |||
| apply: (instance, props, key) => { | |||
| if (propertyInfo.readOnly) { | |||
| return; | |||
| } | |||
| // For arrays, use a different approach to avoid spread operator issues | |||
| const values = props[key] as unknown[]; | |||
|
|
|||
| @@ -505,6 +549,7 @@ export function createComponentDefinition<T, InstanceType>( | |||
| validate: (val) => val instanceof Material, | |||
| default: value, | |||
| errorMsg: (val) => `Invalid value for prop "${String(key)}": "${JSON.stringify(val)}". Expected a Material.`, | |||
| ...(propertyInfo.readOnly && { readOnly: true }), | |||
| }; | |||
| } | |||
|
|
|||
| @@ -515,6 +560,9 @@ export function createComponentDefinition<T, InstanceType>( | |||
| default: value, | |||
| errorMsg: () => '', | |||
| apply: (instance, props, key) => { | |||
| if (propertyInfo.readOnly) { | |||
| return; | |||
| } | |||
| (instance[key as keyof InstanceType] as unknown) = props[key]; | |||
| } | |||
| }; | |||
There was a problem hiding this comment.
For schema entries that have an apply callback (Color, Vec2, Vec3, Vec4, Quat, Array, Null), the readOnly flag is not set on the schema entry itself — unlike types without an apply callback (Mat4, number, string, boolean, Material) which correctly spread ...(propertyInfo.readOnly && { readOnly: true }).
This inconsistency means:
- The outer guard in
applyProps(if (!propDef || propDef.readOnly) return) will never skip these props at the top level even when the property is getter-only, becausepropDef.readOnlyisundefinedfor these types. - Any external code inspecting
propDef.readOnlyto determine if a prop is read-only (e.g. for docs/tooling) will get incorrect results for getter-only Color, Vec2, Vec3, Vec4, Quat, Array, or Null props.
The early-return inside the apply callback does prevent the actual write, so no exception is thrown — but the approach is not consistent with the rest of the implementation. The schema entries for these types should also spread ...(propertyInfo.readOnly && { readOnly: true }) so propDef.readOnly is reliable as a public indicator of read-only status.
There was a problem hiding this comment.
The main goal was to implement early returns as I did not want to disrupt any preexisting code that may have rely on the type failure to catch I couldn't find anything that did this but my aim was to make the change with as little potential ripples as possible. The early exit is the simplest and easiest fix besides simply skipping getter only properties but, I wanted to provide a read-only property implementation as a proof of concept for further development. For types without an apply callback implementing the read-only property for consistency made sense by doing a simple append to the schema as the previous logic was based off this universal 'apply all' idea which is flawed.
This boils down to 2 things:
-
Firstly the segment of code addressing apply callbacks is the essential bugfix here and I can definitely see fully implementing the read-only property to the schema being beneficial. It likely would become a much bigger change that I am willing to undertake but wanted to wait for approval as insurance.
-
Secondly the read-only being a public indicator would allow for both custom debugging / a more informative error message so rather than 'is getter only' pops up something like property is of read only or more informative messages involving class access can be provided through the property as a point of reference.
NOTE: Testing can and should use this read-only property especially for asset creation it is likely required to revisit some older tests and add some insurance guards. I ran the test module and everything passed quite nicely however it passing nicely also likely means that the testing scripts should be more strict with class access properties as support for flexibility of asset expression IMO should be a test in and of itself as the original engine addresses custom assets cleanly and as a wrapper for the engine should ensure that feature carries over.
| // Mark getter-only properties as read-only | ||
| const descriptor = Object.getOwnPropertyDescriptor( | ||
| (instance as any).constructor.prototype, | ||
| key as string | symbol | ||
| ); | ||
| if (descriptor && descriptor.get && !descriptor.set) { | ||
| propertyInfo.readOnly = true; | ||
| } |
There was a problem hiding this comment.
The getter-only detection in createComponentDefinition uses Object.getOwnPropertyDescriptor((instance as any).constructor.prototype, key), which only looks at the direct prototype of the constructor. If a getter-only property is inherited from a grandparent class (not defined on the immediate prototype but on a higher ancestor), this check will return undefined and the property will not be marked as readOnly.
Note that getPseudoPublicProps has the same limitation (only traverses one prototype level), so such a property would likely not be included in the schema at all — meaning the current code is consistent. However, this is a latent bug: if getPseudoPublicProps is ever extended to walk the full prototype chain, the getter-only detection here would need to be updated to match. Consider using a prototype-walking loop (similar to how getPseudoPublicProps could be extended) or using Object.getOwnPropertyDescriptor on each level of the prototype chain to be safe.
There was a problem hiding this comment.
This was actually one concern with integration and why I decided to go with early exits at the beginning. I would personally suggest downstream making a test case for grandparent classes ( if its a point of interest ) and simply run it to detect any fault lines in the class structure for the future.
As of writing this there are ZERO explicit grandparent classes in the traditional inheritance sense for the schema generator. Instead, the current system uses a composition-based pattern with type definitions and factory functions. So this is a misguided concern and Copilot didn't really do its due diligence here.
|
I addressed copilots review with comments that illustrate some extra rationale. TLDR: All of copilots concerns were more aligned with consistency and I made a few suggestions in that regard as Copilot is a little bit out of scope in reference here. Looking forward to seeing what becomes of this! I'd love to be able to abstract from the engine a bit more in my current implementation and I believe this should allow for a few cool features and implementations to be viable. :) |
Problem (Addresses Issue - #315 )
Using with a custom-loaded asset (e.g. from a @playcanvas/splat-transform loader) throws:
The same pattern can affect any component that exposes getter-only props (e.g. id).
Root cause
So the failure comes from the generic “apply all schema props” path trying to assign to getter-only props, not from the custom asset loading path itself.
Solution
Getter-only props stay in the schema (so the public API stays complete and anything that relies on them being present still works), but they are never applied, so the “Cannot set property id” error is fixed.
Changes
Result