diff --git a/.changeset/fix-screen-element-components.md b/.changeset/fix-screen-element-components.md new file mode 100644 index 00000000..77f9b1b5 --- /dev/null +++ b/.changeset/fix-screen-element-components.md @@ -0,0 +1,10 @@ +--- +"@playcanvas/react": patch +--- + +Fix the `Screen` and `Element` components, which previously failed to render. + +- `` threw `Cannot set property aabb` on mount: the generated prop schema included read-only engine getters (such as `aabb`) and tried to assign to them. It also applied `null` defaults for props the user never set, which broke settable-but-initially-null props like `color` (`this._color.copy(null)`). Read-only accessors are now excluded from the schema, and the schema no longer assigns `null`/`undefined` defaults. +- `` rendered nothing because `referenceResolution` was applied as a raw array; the engine setter reads `value.x`/`value.y`, so it now receives a `Vec2`. + +Also corrects the `Element` component's JSDoc (previously a copy of `Screen`'s, with an invalid example). diff --git a/packages/lib/src/components/Element.test.tsx b/packages/lib/src/components/Element.test.tsx new file mode 100644 index 00000000..6a3fe69b --- /dev/null +++ b/packages/lib/src/components/Element.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Entity as PcEntity } from 'playcanvas'; +import { Element } from './Element.tsx'; +import { Screen } from './Screen.tsx'; +import { Entity } from '../Entity.tsx'; +import { Application } from '../Application.tsx'; + +describe('Element', () => { + // Regression: the auto-generated schema used to include the engine's read-only + // `aabb` getter and assign to it on mount, throwing "Cannot set property aabb". + it('mounts a text element without throwing', async () => { + const ref = React.createRef(); + + render( + + + + + + + + + ); + + await waitFor(() => expect(ref.current?.element).toBeTruthy()); + + expect(ref.current!.element!.type).toBe('text'); + expect(ref.current!.element!.text).toBe('Hello, World!'); + }); +}); diff --git a/packages/lib/src/components/Element.tsx b/packages/lib/src/components/Element.tsx index f09edde7..284953ec 100644 --- a/packages/lib/src/components/Element.tsx +++ b/packages/lib/src/components/Element.tsx @@ -7,17 +7,18 @@ import { PublicProps, Serializable } from "../utils/types-utils.ts"; import { validatePropsWithDefaults, createComponentDefinition, getStaticNullApplication, Schema } from "../utils/validation.ts"; /** - * The Screen component allows an entity to render a 2D screen space UI element. - * This is useful for creating UI elements that are rendered in screen space rather than world space. - * - * @param {ElementProps} props - The props to pass to the screen component. + * The Element component renders 2D UI content — text, an image, or a group — on an entity. + * Add it to a child of an entity that has a {@link Screen} component. + * + * @param {ElementProps} props - The props to pass to the element component. * @see https://api.playcanvas.com/engine/classes/ElementComponent.html - * + * * @example * - * - * Hey - * + * + * + * + * * */ export const Element: FC = (props) => { diff --git a/packages/lib/src/components/Screen.test.tsx b/packages/lib/src/components/Screen.test.tsx index 176cdab1..d22152da 100644 --- a/packages/lib/src/components/Screen.test.tsx +++ b/packages/lib/src/components/Screen.test.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Screen } from './Screen.tsx'; import { Entity } from '../Entity.tsx'; import { Application } from '../Application.tsx'; +import { Vec2, Entity as PcEntity } from 'playcanvas'; const renderWithProviders = (ui: React.ReactNode) => { return render( @@ -31,7 +32,7 @@ describe('Screen', () => { it('should render with custom props', () => { const { container } = renderWithProviders( - { render(); }).toThrow('`useParent` must be used within an App or Entity via a ParentContext.Provider'); }); -}); \ No newline at end of file +}); + +describe('Screen prop application', () => { + // Regression: referenceResolution was applied as a raw array, but the engine + // setter reads value.x/value.y, leaving the screen scale (and child UI) NaN. + it('applies referenceResolution as a Vec2, not a raw array', async () => { + const ref = React.createRef(); + render( + + + + + + ); + + await waitFor(() => expect(ref.current?.screen).toBeTruthy()); + + const screen = ref.current!.screen!; + expect(screen.referenceResolution).toBeInstanceOf(Vec2); + expect(screen.referenceResolution.x).toBe(1280); + expect(screen.referenceResolution.y).toBe(720); + }); +}); diff --git a/packages/lib/src/components/Screen.tsx b/packages/lib/src/components/Screen.tsx index 5cfa0015..f944d2ad 100644 --- a/packages/lib/src/components/Screen.tsx +++ b/packages/lib/src/components/Screen.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; import { useComponent } from "../hooks/index.ts"; -import { Entity, ScreenComponent } from "playcanvas"; +import { Entity, ScreenComponent, Vec2 } from "playcanvas"; import { PublicProps, Serializable } from "../utils/types-utils.ts"; import { validatePropsWithDefaults, createComponentDefinition, getStaticNullApplication, Schema } from "../utils/validation.ts"; @@ -60,7 +60,11 @@ componentDefinition.schema = { referenceResolution: { validate: (value: unknown) => Array.isArray(value) && value.length === 2 && value.every(v => typeof v === "number"), errorMsg: (value: unknown) => `Invalid value for prop "referenceResolution": ${value}. Expected a tuple of [number, number].`, - default: [1280, 720] + default: [1280, 720], + // The engine setter reads `value.x`/`value.y`, so convert the array to a Vec2. + apply: (instance, props, key) => { + (instance[key as keyof ScreenComponent] as Vec2) = new Vec2().fromArray(props[key] as number[]); + } }, scaleMode: { validate: (value: unknown) => typeof value === "string" && ["blend", "stretch", "fit"].includes(value as string), diff --git a/packages/lib/src/utils/validation.ts b/packages/lib/src/utils/validation.ts index 5b3bf90b..bceb55fc 100644 --- a/packages/lib/src/utils/validation.ts +++ b/packages/lib/src/utils/validation.ts @@ -283,7 +283,11 @@ export function getPseudoPublicProps(container: Record): Record const hasGetter = typeof descriptor.get === 'function'; const hasSetter = typeof descriptor.set === 'function'; - if (hasSetter && !hasGetter) return; + if (hasSetter && !hasGetter) return; + + // Skip read-only props (a getter with no setter). They can't be applied, + // and assigning to them throws — e.g. `ElementComponent.aabb`. + if (hasGetter && !hasSetter) return; // If it's a getter/setter property, try to get the value if (descriptor.get) { @@ -519,6 +523,9 @@ export function createComponentDefinition( default: value, errorMsg: () => '', apply: (instance, props, key) => { + // Don't assign null/undefined — many engine setters reject it + // (e.g. ElementComponent.color runs `this._color.copy(value)`). + if (props[key] === null || props[key] === undefined) return; (instance[key as keyof InstanceType] as unknown) = props[key]; } };