Skip to content

Add OneOf property type annotation#146

Open
shrinktofit wants to merge 5 commits into
cocos:v4.0.0from
shrinktofit:stf/one-of-fields
Open

Add OneOf property type annotation#146
shrinktofit wants to merge 5 commits into
cocos:v4.0.0from
shrinktofit:stf/one-of-fields

Conversation

@shrinktofit

@shrinktofit shrinktofit commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Re: # N/A

Changelog

  • Add OneOf(...) property type annotation support for polymorphic serialized fields and inline Inspector switching.

Motivation

Some serialized properties naturally accept one value from several possible types. For example, a shape property may be a sphere, box, capsule, or torus; a pet-like object may use a discriminator field such as kind; and some less common fields may switch between primitive values or a small configuration object.

Before this change, these cases usually had to be modeled with custom type selector properties or custom accessors. The setter would then create the selected variant manually and keep the actual serialized property in sync. This works, but each component has to reinvent the same pattern, and the Inspector UI cannot understand the relationship between the selector and the value being edited.

Public API Scope

This PR keeps OneOf as a property type token.

Supported root property forms:

const ShapeOneOf = OneOf<Shape>({
  variants: [
    DemoSphere,
    { type: DemoBox, label: 'Box' },
    { type: DemoCapsule },
    { type: DemoTorus, label: 'Torus' },
  ],
});

@property({ type: ShapeOneOf })
public shape: Shape = new DemoSphere();

@property(ShapeOneOf)
public shapeShortcut: Shape = new DemoSphere();

@ccType(ShapeOneOf)
public shapeByType: Shape = new DemoSphere();

Not supported in this PR:

  • Array-element OneOf.
  • @property([OneOf(...)]).

property() keeps the root OneOf shorthand, but its public array shorthand type is narrowed so OneOfPropertyType is not accepted as an array element type.

Variant Forms

OneOf(...) supports these variant shapes:

  • Constructor shorthand: DemoSphere.
  • Typed descriptor: { type: DemoBox, label: 'Box' }.
  • Discriminated descriptor: { key: 'dog', create: () => new DemoDog(), label: 'Dog' }.

The current key can be resolved by:

  • Runtime class/type matching.
  • A discriminator property key, such as discriminator: 'kind'.
  • A discriminator function, such as (value) => typeof value.

Implementation

The implementation keeps the existing property dump shape intact and uses userData.oneOf metadata to describe variants, labels, current key, and switch commands.

On the engine side:

  • OneOfPropertyType stores the normalized variants and discriminator.
  • The property attribute pipeline recognizes OneOf(...) as a special type annotation.
  • Dynamic attributes resolve the current runtime value into the active variant, then expose the concrete type / ctor / primitive type to existing property rendering.
  • Dynamic attribute lookup does not call create() and does not synthesize dynamic variant type metadata.
  • A hidden virtual helper accessor named like __cc_oneOfSwitch_<property> receives switch commands. This avoids adding a new dump mutation protocol; the Inspector can issue a normal property set request against the helper accessor, and the helper setter creates the selected variant and assigns it to the original property.

On the Inspector side:

  • OneOf metadata is detected from dump.userData.oneOf.
  • A compact selector is inserted into the current property's first rendered row, next to the property name or type when possible.
  • The current property's existing renderer is still used for the actual value, whether it is an object dump, number, string, boolean, or another supported renderer.
  • Primitive variants are normalized before rendering so type: Unknown dumps can still be rendered as Number, String, or Boolean when the active runtime value is primitive.

Pitfalls / Notes

  • create() may be called when switching or resetting the OneOf root property. It should be cheap and side-effect-free.
  • Function discriminators may run during attribute lookup. They should be pure and should not depend on transient external state.
  • Discriminated variants must use unique keys.
  • Object variants are expected to be class instances with Cocos metadata. Plain object literals are not the target use case.
  • The helper accessor name is reserved for the switch path and is intentionally hidden/non-enumerable.
  • Primitive-only and mixed primitive/object unions are supported, but the main intended use case is object variants.

Inspector Preview

OneOf Inspector preview

The Inspector renders a selector inline with the current property row, then keeps the active variant's normal property renderer below it. For example:

  • Shape is resolved by runtime type and displays object fields such as radius and segments.
  • Pet is resolved by the kind discriminator and displays labels where provided; variants without labels fall back to their key/type name.
  • Token demonstrates primitive-only variants.
  • Settings demonstrates a less common mixed union of string | boolean | object.

Demo Code

import { _decorator, Component } from 'cc';

const { ccclass, property, type: ccType, OneOf } = _decorator;

@ccclass('DemoSphere')
export class DemoSphere {
  @property
  public radius = 1;

  @property
  public segments = 24;
}

@ccclass('DemoBox')
export class DemoBox {
  @property
  public width = 1;

  @property
  public height = 1;

  @property
  public depth = 1;
}

@ccclass('DemoCapsule')
export class DemoCapsule {
  @property
  public radius = 0.5;

  @property
  public height = 2;
}

@ccclass('DemoTorus')
export class DemoTorus {
  @property
  public majorRadius = 1;

  @property
  public minorRadius = 0.25;
}

type Shape = DemoSphere | DemoBox | DemoCapsule | DemoTorus;

const ShapeOneOf = OneOf<Shape>({
  variants: [
    DemoSphere,
    {
      type: DemoBox,
      label: 'Box',
    },
    {
      type: DemoCapsule,
    },
    {
      type: DemoTorus,
      label: 'Torus',
    },
  ],
});

@ccclass('DemoDog')
export class DemoDog {
  @property
  public readonly kind = 'dog';

  @property
  public woof = true;

  @property
  public barkVolume = 8;
}

@ccclass('DemoCat')
export class DemoCat {
  @property
  public readonly kind = 'cat';

  @property
  public meow = 'miao';

  @property
  public lives = 9;
}

@ccclass('DemoDuck')
export class DemoDuck {
  @property
  public readonly kind = 'duck';

  @property
  public quack = 'ga';

  @property
  public swims = true;
}

@ccclass('DemoHamster')
export class DemoHamster {
  @property
  public readonly kind = 'hamster';

  @property
  public wheelSpeed = 12;

  @property
  public storesSeeds = true;
}

type Pet = DemoDog | DemoCat | DemoDuck | DemoHamster;

const PetOneOf = OneOf<Pet>({
  discriminator: 'kind',
  variants: [
    {
      key: 'dog',
      create: () => new DemoDog(),
      label: 'Dog',
    },
    {
      key: 'cat',
      create: () => new DemoCat(),
    },
    {
      key: 'duck',
      create: () => new DemoDuck(),
      label: 'Duck',
    },
    {
      key: 'hamster',
      create: () => new DemoHamster(),
    },
  ],
});

type TokenValue = string | number | boolean;

const TokenOneOf = OneOf<TokenValue>({
  discriminator: (value) => typeof value,
  variants: [
    {
      key: 'string',
      create: () => 'ready',
      label: 'Text',
    },
    {
      key: 'number',
      create: () => 42,
    },
    {
      key: 'boolean',
      create: () => true,
      label: 'Enabled',
    },
  ],
});

@ccclass('DemoSettingsObject')
export class DemoSettingsObject {
  @property
  public readonly mode = 'advanced';

  @property
  public profile = 'balanced';

  @property
  public quality = 3;
}

type Settings = string | boolean | DemoSettingsObject;

const SettingsOneOf = OneOf<Settings>({
  discriminator: (value) => {
    if (typeof value === 'string') {
      return 'preset';
    }
    if (typeof value === 'boolean') {
      return 'enabled';
    }
    return value.mode;
  },
  variants: [
    {
      key: 'preset',
      create: () => 'balanced',
      label: 'Preset Name',
    },
    {
      key: 'enabled',
      create: () => true,
      label: 'Enabled Flag',
    },
    {
      key: 'advanced',
      create: () => new DemoSettingsObject(),
      label: 'Advanced Object',
    },
  ],
});

@ccclass('OneOfDemo')
export class OneOfDemo extends Component {
  @property({ type: ShapeOneOf })
  public shape: Shape = new DemoSphere();

  @property(PetOneOf)
  public pet: Pet = new DemoCat();

  @ccType(TokenOneOf)
  public token: TokenValue = 'ready';

  @property({ type: SettingsOneOf })
  public settings: Settings = 'balanced';
}

Continuous Integration

This pull request:

  • needs automatic test cases check.

    Manual trigger with @cocos-robot run test cases afterward.

  • does not change any runtime related code or build configuration

    If any reviewer thinks the CI checks are needed, please uncheck this option, then close and reopen the issue.


Compatibility Check

This pull request:

  • changes public API, and have ensured backward compatibility with deprecated features.
  • affects platform compatibility, e.g. system version, browser version, platform sdk version, platform toolchain, language version, hardware compatibility etc.
  • affects file structure of the build package or build configuration which requires user project upgrade.
  • introduces breaking changes, please list all changes, affected features and the scope of violation.

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown

Code Size Check Report

Wechat (WASM) Before After Diff
2D Empty (legacy pipeline) 1014459 bytes 1019083 bytes ⚠️ +4624 bytes
2D All (legacy pipeline) 2681984 bytes 2686735 bytes ⚠️ +4751 bytes
2D All (new pipeline) 2773753 bytes 2778485 bytes ⚠️ +4732 bytes
(2D + 3D) All 10030969 bytes 10035665 bytes ⚠️ +4696 bytes
Web (WASM + ASMJS) Before After Diff
(2D + 3D) All 16867382 bytes 16872094 bytes ⚠️ +4712 bytes

Interface Check Report

! WARNING this pull request has changed these public interfaces:

@@ -21229,9 +21229,9 @@
          * @param type
          */
         export function type(type: Function | [
             Function
-        ] | any): __private._cocos_core_data_decorators_utils__LegacyPropertyDecorator;
+        ] | OneOfPropertyType | any): __private._cocos_core_data_decorators_utils__LegacyPropertyDecorator;
         export function type<T>(type: __private._cocos_core_data_utils_attribute__PrimitiveType<T> | [
             __private._cocos_core_data_utils_attribute__PrimitiveType<T>
         ]): __private._cocos_core_data_decorators_utils__LegacyPropertyDecorator;
         /**
@@ -21253,8 +21253,39 @@
          * @en Declare the property as string
          * @zh 将该属性标记为字符串。
          */
         export const string: __private._cocos_core_data_decorators_utils__LegacyPropertyDecorator;
+        export function OneOf<T = unknown>(options: OneOfOptions<T>): OneOfPropertyType<T>;
+        export interface OneOfOptions<T = unknown> {
+            variants: readonly OneOfVariant<T>[];
+            discriminator?: OneOfDiscriminator<T>;
+        }
+        export type OneOfVariant<T = unknown> = __private._cocos_core_data_decorators_one_of__OneOfConstructor<T> | OneOfTypedVariant<T> | OneOfKeyVariant<T>;
+        export interface OneOfTypedVariant<T = unknown> {
+            type: __private._cocos_core_data_decorators_one_of__OneOfConstructor<T>;
+            label?: string;
+            key?: OneOfKey;
+            create?: () => __private._cocos_core_data_decorators_one_of__NoInferType<T>;
+        }
+        export interface OneOfKeyVariant<T = unknown> {
+            key: OneOfKey;
+            create: () => unknown;
+            label?: string;
+            type?: __private._cocos_core_data_decorators_one_of__OneOfConstructor<T>;
+        }
+        export type OneOfDiscriminator<T = unknown> = string | ((value: T) => OneOfKey) | {
+            kind?: "field";
+            property: string;
+        } | {
+            kind: "type-id";
+        };
+        export type OneOfKey = string | number | boolean | null;
+        export class OneOfPropertyType<T = unknown> {
+            readonly [__private._cocos_core_data_decorators_one_of__ONE_OF_BRAND]: true;
+            readonly discriminator: __private._cocos_core_data_decorators_one_of__NormalizedOneOfDiscriminator<T>;
+            readonly variants: readonly __private._cocos_core_data_decorators_one_of__NormalizedOneOfVariant<T>[];
+            constructor(options: OneOfOptions<T>);
+        }
     }
     export function CCClass<TFunction>(options: {
         name?: string;
         extends: null | (Function & {
@@ -65774,16 +65805,39 @@
          * 该签名同时兼容 TypeScript legacy 装饰器以及 Babel legacy 装饰器。
          * 第三个参数在 Babel 情况下,会传入 descriptor。对于一些被优化的引擎内部装饰器,会传入 initializer。
          */
         export type _cocos_core_data_decorators_utils__LegacyPropertyDecorator = (target: Record<string, any>, propertyKey: string | symbol, descriptorOrInitializer?: _cocos_core_data_decorators_utils__BabelPropertyDecoratorDescriptor | _cocos_core_data_decorators_utils__Initializer | null) => void;
-        export type _cocos_core_data_decorators_property__SimplePropertyType = Function | string | typeof CCString | typeof CCInteger | typeof CCBoolean;
-        export type _cocos_core_data_decorators_property__PropertyType = _cocos_core_data_decorators_property__SimplePropertyType | _cocos_core_data_decorators_property__SimplePropertyType[];
+        export type _cocos_core_data_decorators_property__SimplePropertyElementType = Function | string | typeof CCString | typeof CCInteger | typeof CCBoolean;
+        export type _cocos_core_data_decorators_property__SimplePropertyType = _cocos_core_data_decorators_property__SimplePropertyElementType | _decorator.OneOfPropertyType;
+        export type _cocos_core_data_decorators_property__PropertyType = _cocos_core_data_decorators_property__SimplePropertyType | _cocos_core_data_decorators_property__SimplePropertyElementType[];
         export class _cocos_core_data_utils_attribute__PrimitiveType<T> {
             name: string;
             default: T;
             constructor(name: string, defaultValue: T);
             toString(): string;
         }
+        export type _cocos_core_data_decorators_one_of__OneOfConstructor<T = unknown> = Function & {
+            prototype: T;
+        };
+        export type _cocos_core_data_decorators_one_of__NoInferType<T> = [
+            T
+        ][T extends unknown ? 0 : never];
+        export const _cocos_core_data_decorators_one_of__ONE_OF_BRAND = "__ccOneOfPropertyType__";
+        export type _cocos_core_data_decorators_one_of__NormalizedOneOfDiscriminator<T = unknown> = {
+            kind: "type-id";
+        } | {
+            kind: "field";
+            property: string;
+        } | {
+            kind: "function";
+            get: (value: T) => _decorator.OneOfKey;
+        };
+        export interface _cocos_core_data_decorators_one_of__NormalizedOneOfVariant<T = unknown> {
+            type?: _cocos_core_data_decorators_one_of__OneOfConstructor<T>;
+            label?: string;
+            key?: _decorator.OneOfKey;
+            create?: () => unknown;
+        }
         namespace _cocos_core_data_utils_attribute {
             export const DELIMETER = "$_$";
             export function createAttrsSingle(owner: Object, superAttrs?: any): any;
             /**

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown

@shrinktofit, Please check the result of run test cases:

Task Details

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown

@shrinktofit, Please check the result of run test cases:

Task Details

@star-e star-e requested a review from bofeng-song June 11, 2026 03:12
@star-e star-e added the investigation Investigate the impact on the code base label Jun 11, 2026

@bofeng-song bofeng-song left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

Reviewed across 7 dimensions (line-by-line scan, removed-behavior audit, cross-file tracing, reuse, simplification, efficiency, altitude). Findings ranked by severity below.

@@ -80,7 +81,7 @@ export function attr (constructor: any, propertyName: string): { [attributeName:
ret[key.slice(prefix.length)] = attrs[key];
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Performance: applyDynamicOneOfAttrs on every attr() call — uncached object allocation + create() invocation

attr() is the engine's universal attribute accessor, called per-property per-frame by the Inspector, serialization, and deserialization.

For any OneOf-typed property where attrs.oneOf is truthy and owner is an instance, applyDynamicOneOfAttrs will:

  1. Spread { ...attrs } into a new object (allocation)
  2. Clone all variant userData via .map(item => ({ ...item })) — O(variants) allocations
  3. Call variant.create() to compute a default value — constructing a throwaway instance every time

None of this is cached. In a scene with many OneOf components, this causes GC pressure on every Inspector repaint.

Also, this breaks reference identity: callers that cache attr() results and compare by reference will see a new object every time, potentially causing infinite re-render loops in reactive systems.

Suggestion: Cache the dynamic attrs result (keyed on the current variant index), or at minimum cache the create() result so a new instance isn't constructed on every read.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d7ebee9. Dynamic attrs no longer call variant.create(), no longer synthesize defaults, and no longer clone or mutate userData.oneOf.variants. The dynamic path now only shallow-copies the attrs/userData wrapper to expose currentKey/currentVariantIndex and root type/ctor for the current value; tests assert the variants array keeps the same reference and create-only variants do not get inferred type metadata.

(this as Record<string, unknown>)[propertyName] = createOneOfVariantValue(oneOfType, index);
},
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Bug: No serialization handler recognizes the 'OneOf' type tag

This sets attrs.type = 'OneOf' (via ONE_OF_TYPE_TAG). However, the engine's serialization/deserialization code only recognizes known type tags: 'Float', 'Integer', 'Boolean', 'String', 'Enum', 'BitMask', and constructor references.

applyDynamicOneOfAttrs dynamically rewrites the type for Inspector rendering, but deserialization reads static attribute metadata directly — it does not go through the dynamic rewrite path.

When a scene is saved and reloaded, the deserializer encounters type: 'OneOf' with no handler. Depending on the code path, it may skip the property (data loss) or fail silently.

Suggestion: Verify that OneOf properties survive a full serialize → deserialize round-trip. If the serializer relies on type to choose a strategy, consider keeping the concrete variant's constructor as the serialized type, and storing 'OneOf' only in userData.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d7ebee9 with coverage for the deserialization side. The static property metadata can remain type: OneOf because the saved value still carries the concrete variant object type. I added a regression where a OneOf property is saved with a concrete Cat object while the property metadata is OneOf, and deserialize restores the concrete Cat instance.

): Record<string, any> {
const oneOfUserData = userData.oneOf;
if (!oneOfUserData || typeof oneOfUserData !== 'object') {
return userData;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Bug: === strict equality fails across JSON serialization boundaries

oneOf.variants.findIndex((variant) => 'key' in variant && variant.key === key);

If a variant is defined with key: 0 (number), but the discriminator field value went through JSON round-tripping (e.g., Inspector dump → JSON → parse), the value arrives as '0' (string). 0 === '0' is false.

Same issue for boolean keys: true === 'true' is false.

Result: findOneOfVariantByKey returns undefined, applyDynamicOneOfAttrs can't resolve the current variant, and the Inspector renders the property as Unknown with no editing capability.

Suggestion: Use loose comparison (==) for key matching, or coerce both sides to string before comparing:

oneOf.variants.findIndex((v) => 'key' in v && String(v.key) === String(key));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving this strict intentionally. The discriminator result and variant key are both OneOfKey values, and coercing 0 and "0" would make distinct variants ambiguous. JSON preserves number/string/boolean/null value types here, so callers should keep the key type consistent with the discriminator output.

if (!value || typeof value !== 'object') {
return undefined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Bug: Unhandled exception in switch setter causes Inspector/value desync

The set handler calls createOneOfVariantValue(oneOfType, index) which may call new (variant.type)() with zero arguments. If the constructor requires mandatory parameters, it throws.

This set handler has no try/catch, so the exception propagates. But the Inspector has already updated the dropdown selection to the new variant. The actual property value remains unchanged.

Result: Inspector shows variant B selected, actual value is still variant A. The user sees no error.

Note: createOneOfVariantDefaultValue (line 389) has a try/catch for the attr() path, but this setter does not.

Suggested change
set (value: unknown) {
const index = getOneOfSwitchCommandIndex(value);
if (index === undefined || index >= oneOfType.variants.length) {
return;
}
try {
(this as Record<string, unknown>)[propertyName] = createOneOfVariantValue(oneOfType, index);
} catch (error) {
console.warn(`[OneOf] Failed to create variant ${index} for property "${propertyName}":`, error);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d7ebee9. The hidden switch setter now catches create failures, keeps the previous property value, and emits a DEV warning. Added coverage for the failing create path.

return;
}

const prototype = constructor.prototype as Record<string, unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Bug: Discriminator field assignment silently fails on frozen/readonly objects

After create() returns, this line assigns the discriminator key onto the value. If the create() factory returns a frozen object (Object.freeze(...)) or the discriminator field is defined with writable: false / readonly, the assignment silently fails in sloppy mode or throws in strict mode.

The value will have the wrong discriminator field, causing findCurrentOneOfVariant to match the wrong variant on the next read.

Suggestion: Add a guard or warning:

if (oneOfType.discriminator.kind === 'field' && 'key' in variant && value && typeof value === 'object') {
    try {
        (value as Record<string, OneOfKey>)[oneOfType.discriminator.property] = variant.key as OneOfKey;
    } catch {
        console.warn(`[OneOf] Cannot set discriminator "${oneOfType.discriminator.property}" on frozen/readonly object`);
    }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d7ebee9. createOneOfVariantValue no longer writes the discriminator field after construction; the variant factory/type is responsible for returning a value with the proper discriminator. Added coverage for getter-only field discriminators.

return isOneOfKey(key) ? findOneOfVariantByKey(oneOf, key) : undefined;
}
case 'function': {
if (value == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Silent error swallowing — create() factory failures produce no diagnostic

function createOneOfVariantDefaultValue (oneOfType, index) {
    try {
        return createOneOfVariantValue(oneOfType, index);
    } catch (error) {
        return undefined;
    }
}

This silently catches ALL exceptions from user-provided create() factories. When a factory throws due to a bug (missing dependency, initialization error), the property gets no default value, the Inspector shows a blank field, and there is zero diagnostic output.

Suggestion: At minimum log a warning in DEV mode:

Suggested change
if (value == null) {
function createOneOfVariantDefaultValue (oneOfType: OneOfPropertyType, index: number): unknown {
try {
return createOneOfVariantValue(oneOfType, index);
} catch (error) {
if (DEV) {
console.warn(`[OneOf] Failed to create default value for variant ${index}:`, error);
}
return undefined;
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d7ebee9. Attribute lookup no longer calls create(), so there is no swallowed factory failure on read. The remaining factory invocation path is the switch setter, and that now reports a DEV warning while preserving the previous value.

oneOfType: OneOfPropertyType,
propertyName: string,
): IExposedAttributesUserData {
const merged = userData && typeof userData === 'object' && !Array.isArray(userData)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 EDITOR guard mismatch: Inspector always dispatches to the hidden accessor

installOneOfSwitchVirtualAccessor is guarded by if (EDITOR || TEST), so the __cc_oneOfSwitch_<prop> setter only exists in editor/test builds.

However, the Inspector side (one-of-prop.js) unconditionally dispatches variant-switch commands to this accessor path via change-dump / confirm-dump events.

If any non-EDITOR build path reaches this code (preview mode, third-party editor integrations), the dump write will store the raw command string '__cc_oneof_switch__:N' as the property value instead of triggering the setter, silently corrupting component state.

Suggestion: Add a runtime guard in the setter dispatch, or document this constraint clearly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Keeping this as an editor/test-only bridge. one-of-prop.js lives in the editor inspector layer and dispatches the switch command only through that layer; the runtime API should not receive these command strings. I also kept the hidden accessor behind EDITOR || TEST and added inspector tests for switch/reset behavior.

? { kind: 'function' as const }
: oneOfType.discriminator;
return {
discriminator,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Side-effect: owner[propertyName] read on every attr() call

const value = owner[propertyName];

For OneOf properties, every attr() call now reads the actual property value from the instance. If this property has a getter with side effects (logging, lazy initialization, state mutation), those side effects fire on every attribute lookup — not just when the user interacts with the property.

attr() was previously a pure metadata lookup with no instance access. This change makes it impure for OneOf properties.

Suggestion: Consider passing the current value explicitly from callers that already have it, rather than reading it inside attr().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Keeping this read intentionally for the instance dynamic attrs path. attr(Class, prop) remains a static metadata lookup; attr(instance, prop) for OneOf must read the current value to compute currentKey/currentVariantIndex and the root type/ctor used by the Inspector. Passing the value explicitly would require a wider attr API change, so I am leaving this as the expected OneOf inspector behavior.

@knoxHuang knoxHuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I left a few focused OneOf review comments.

Comment thread cocos/core/data/decorators/one-of.ts Outdated
return undefined;
}

const index = Number(value.slice(ONE_OF_SWITCH_COMMAND_PREFIX.length));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[must fix] Empty switch commands are currently parsed as variant 0 because Number('') returns 0. If the Inspector has no resolvable current variant, the select value can be empty, and reset/change can silently switch the property to the first variant. Please reject empty suffixes explicitly, for example by validating the suffix with a decimal-integer check before converting it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 31037b7. Empty and non-decimal switch suffixes are now rejected before Number() conversion, while index 0 remains valid. The editor dispatch path also uses the same non-negative decimal check so an unresolved empty select value no longer switches to variant 0. Added core parser coverage and inspector change/reset coverage for the unresolved case.

const variantCtor = getOneOfVariantCtor(current.variant);
ctor = getOneOfValueCtor(value)
|| (variantCtor && isOneOfValueOfCtor(value, variantCtor) ? variantCtor : undefined);
if (ctor) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[must fix] For object variants this dynamic attr path sets ctor, but it leaves type as the static OneOf tag. Existing object-property attrs use type: 'Object' together with ctor, and render/dump/type-check code commonly branches on type first. Please also set nextAttrs.type = 'Object' when a concrete object ctor is resolved, and add a regression that asserts object OneOf attrs expose both type: 'Object' and the concrete ctor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 31037b7. Dynamic object OneOf attrs now return type: Object with the concrete ctor, while userData.oneOf remains available for the selector. Added regression coverage for object-only and mixed object/primitive cases.

return;
}

for (const variant of variants) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[must fix] Discriminated variants should reject duplicate keys during validation. findOneOfVariantByKey() uses findIndex(), so if two variants share the same key, switching to the later one will resolve back to the first one on the next attr read and the Inspector selection/value will drift. Please track keys here and throw when a duplicate key is found.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 31037b7. Discriminated OneOf variants now validate duplicate keys during OneOf construction and throw before the invalid OneOf can be used. Added a regression for duplicate-key declarations.

Comment thread editor/inspector/utils/prop.js Outdated
}
}
$prop.render(info);
const renderInfo = oneOfProp.normalizeOneOfDumpForRender(info);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] This only wires OneOf normalization/decoration into the generic updatePropByDump() path. Existing custom inspector paths that render dump props through updateCustomPropElements() still call element.render(prop.dump) directly, so OneOf fields there will not get the selector or primitive normalization. Could we route both paths through a shared helper that normalizes the dump, renders it, and applies the OneOf decoration?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 31037b7. I added a shared propUtils.renderDumpProp() helper that normalizes the dump, renders it, and applies OneOf decoration. The generic updatePropByDump path now uses it, and the existing particle-system/widget custom prop paths call the same helper without changing their visibility/className flow. Added coverage for the shared renderer.

Comment thread cocos/core/data/decorators/type.ts Outdated
*/
export function type (type: Function | [Function] | any): LegacyPropertyDecorator;
export function type (
type: Function | [Function] | OneOfPropertyType | [OneOfPropertyType] | any,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[question] This public overload exposes [OneOfPropertyType], but I do not see support for switching individual array elements. preprocess-class collapses array type annotations to the element type, while the generated switch property/path still targets the root property. Should array-element OneOf be implemented in this PR, or should the array form stay out of the public type surface until that path is supported?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 31037b7 by narrowing the explicit public type surface. Array-element OneOf switching is not implemented in this PR, so I removed [OneOfPropertyType] from the type() overload/signature and keep only root-property OneOf as the declared supported form.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Narrowing the type() overload helps, but the array form still leaks through property() because PropertyType = SimplePropertyType | SimplePropertyType[] and SimplePropertyType now includes OneOfPropertyType. That still allows @property([OneOf(...)]), while array-element OneOf switching is not implemented and preprocess-class still collapses array annotations to the element type.

If array-element OneOf is intentionally unsupported in this PR, could we also remove/reject the OneOf array form from the property() type surface, or emit a clear validation error for [OneOf(...)]?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in dba6fab.

I kept the existing root OneOf entrypoints, but narrowed the array shorthand type surface:

  • @property({ type: OneOf(...) }) remains supported.
  • @property(OneOf(...)) remains supported for root properties.
  • @type(OneOf(...)) remains supported, with test coverage added.
  • @property([OneOf(...)]) is not supported: PropertyType now allows OneOfPropertyType only at the root shorthand level, while array shorthand elements are limited to normal property element types.

So this keeps the current root-property semantics while closing the array-element OneOf leak you pointed out.

@github-actions

Copy link
Copy Markdown

@shrinktofit, Please check the result of run test cases:

Task Details

@github-actions

Copy link
Copy Markdown

@shrinktofit, Please check the result of run test cases:

Task Details

@github-actions

Copy link
Copy Markdown

@shrinktofit, Please check the result of run test cases:

Task Details

@github-actions

Copy link
Copy Markdown

@shrinktofit, Please check the result of run test cases:

Task Details

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

Labels

investigation Investigate the impact on the code base

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants