Skip to content

Fix: Support custom asset loading for GSplat (getter-only props)#316

Open
No-LAN-B wants to merge 3 commits intoplaycanvas:mainfrom
No-LAN-B:main
Open

Fix: Support custom asset loading for GSplat (getter-only props)#316
No-LAN-B wants to merge 3 commits intoplaycanvas:mainfrom
No-LAN-B:main

Conversation

@No-LAN-B
Copy link

@No-LAN-B No-LAN-B commented Feb 27, 2026

Problem (Addresses Issue - #315 )

Using with a custom-loaded asset (e.g. from a @playcanvas/splat-transform loader) throws:

Cannot set property id of #<GSplatComponent> which has only a getter
  at createComponentDefinition (validation.ts)

The same pattern can affect any component that exposes getter-only props (e.g. id).

Root cause

  • 1.) getPseudoPublicProps includes getter-only properties (e.g. GSplatComponent.id) in the props used to build the schema.
  • 2.) createComponentDefinition then adds a schema entry for each of those props with a default.
  • 3.) validatePropsWithDefaults / validatePropsPartial fill validated props from the schema, so read-only props like id are always present with a default.
  • 4.) applyProps assigns every validated prop to the instance. Assigning to a getter-only property throws, so the flow fails even when the user never sets id.

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

  • Detect getter-only in createComponentDefinition using the property descriptor (get without set) and mark them as read-only.
  • Schema entries without apply (number, string, boolean, Mat4, Material): set readOnly: true on the schema entry when the prop is getter-only.
  • Schema entries with apply (Color, Vec2, Vec3, Vec4, Quat, Arrays, Null): keep the existing apply callbacks and add an early return when the prop is read-only so they never assign.
  • applyProps: skip when propDef is missing or propDef.readOnly is true, so we never call apply or direct-assign for read-only props.

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

  • PropValidator: optional readOnly?: boolean.
  • applyProps: guard with if (!propDef || propDef.readOnly) return before applying.
  • createComponentDefinition: detect getter-only via descriptor and set propertyInfo.readOnly; add ...(propertyInfo.readOnly && { readOnly: true }) to schema entries that have no apply (Mat4, number, string, boolean, Material); add if (propertyInfo.readOnly) return at the start of every apply callback for types that have apply.

Result

  • Custom-loaded assets work with without throwing.
  • No change for components that don’t expose getter-only props.
  • Getter-only props remain in the schema for docs/API completeness and are simply skipped at apply time.

@changeset-bot
Copy link

changeset-bot bot commented Feb 27, 2026

⚠️ No Changeset found

Latest commit: d80fca7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?: boolean to the PropValidator type and PropertyInfo type
  • Adds getter-only detection in createComponentDefinition using property descriptors, and marks matching schema entries as readOnly
  • Guards applyProps and individual apply callbacks against assigning to read-only props

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

});
}


Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change

Copilot uses AI. Check for mistakes.
Comment on lines 222 to +242
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);
}
}
});
}
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
isDefinedWithSetter: boolean;
};
readOnly?: boolean; // Mark properties that should not be assigned
};
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
};
};

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 386 to 568
@@ -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];
}
};
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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, because propDef.readOnly is undefined for these types.
  2. Any external code inspecting propDef.readOnly to 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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +374 to +381
// 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;
}
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@No-LAN-B
Copy link
Author

No-LAN-B commented Mar 6, 2026

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. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants