Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@ export abstract class HierarchyParser<T extends Scene | PrefabResource, V extend
for (let i = 0, l = entities.length; i < l; i++) {
entityMap.set(entitiesConfig[i].id, entities[i]);
}

// Build rootIds in serialization order (not async completion order)
const rootIds = this.context.rootIds;
for (let i = 0, l = entitiesConfig.length; i < l; i++) {
if (!entitiesConfig[i].parent && !(entitiesConfig[i] as IStrippedEntity).strippedId) {
rootIds.push(entitiesConfig[i].id);
}
}
return entities;
});
}
Expand Down Expand Up @@ -238,8 +244,6 @@ export abstract class HierarchyParser<T extends Scene | PrefabResource, V extend
private _parseEntity(entityConfig: IEntity, engine: Engine): Promise<Entity> {
const transform = entityConfig.transform;
const entity = new Entity(engine, entityConfig.name, transform ? Loader.getClass(transform.class) : Transform);
if (!entityConfig.parent) this.context.rootIds.push(entityConfig.id);

this._addEntityPlugin(entityConfig.id, entity);
return Promise.resolve(entity);
}
Expand All @@ -259,8 +263,6 @@ export abstract class HierarchyParser<T extends Scene | PrefabResource, V extend
? prefabResource.instantiate()
: prefabResource.instantiateSceneRoot();
const instanceContext = new ParserContext<IHierarchyFile, Entity>(engine, ParserType.Prefab, null);
if (!entityConfig.parent) this.context.rootIds.push(entityConfig.id);

this._generateInstanceContext(entity, instanceContext, "");

this._prefabContextMap.set(entity, instanceContext);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,13 @@ export class ReflectionParser {
return resource;
});
} else if (ReflectionParser._isComponentRef(value)) {
return Promise.resolve(this._context.components.get(value.componentId) ?? null);
const entity = this._resolveEntityByPath(value.entityPath);
if (!entity) return Promise.resolve(null);
const type = Loader.getClass(value.componentType);
if (!type) return Promise.resolve(null);
return Promise.resolve(entity.getComponents(type, [])[value.componentIndex] ?? null);
} else if (ReflectionParser._isEntityRef(value)) {
// entity reference
return Promise.resolve(this._context.entityMap.get(value.entityId));
return Promise.resolve(this._resolveEntityByPath(value.entityPath));
} else if (ReflectionParser._isSignalRef(value)) {
return this.parseSignal(value);
} else if (originValue) {
Expand Down Expand Up @@ -183,6 +186,17 @@ export class ReflectionParser {
}
}

private _resolveEntityByPath(entityPath: number[]): Entity | null {
const { rootIds, entityMap } = this._context;
if (!entityPath.length || entityPath[0] >= rootIds.length) return null;
let entity = entityMap.get(rootIds[entityPath[0]]);
for (let i = 1; i < entityPath.length; i++) {
if (!entity || entityPath[i] >= entity.children.length) return null;
entity = entity.children[entityPath[i]];
}
return entity;
}
Comment on lines +189 to +198
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Harden _resolveEntityByPath index validation and null contract.

Line 191 only checks upper bounds. Negative/non-integer indices can slip through, and Line 198 can return undefined (not null) on malformed input.

Suggested fix
 private _resolveEntityByPath(entityPath: number[]): Entity | null {
   const { rootIds, entityMap } = this._context;
-  if (!entityPath.length || entityPath[0] >= rootIds.length) return null;
-  let entity = entityMap.get(rootIds[entityPath[0]]);
+  const isValidIndex = (index: number, length: number) =>
+    Number.isInteger(index) && index >= 0 && index < length;
+
+  if (!entityPath.length || !isValidIndex(entityPath[0], rootIds.length)) return null;
+
+  let entity = entityMap.get(rootIds[entityPath[0]]);
+  if (!entity) return null;
+
   for (let i = 1; i < entityPath.length; i++) {
-    if (!entity || entityPath[i] >= entity.children.length) return null;
+    if (!isValidIndex(entityPath[i], entity.children.length)) return null;
     entity = entity.children[entityPath[i]];
+    if (!entity) return null;
   }
-  return entity;
+  return entity ?? null;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts`
around lines 189 - 198, The _resolveEntityByPath function currently only checks
upper bounds and can return undefined; update it to validate that entityPath is
an array of finite non-negative integers and that each index is an integer >= 0
before using it. Specifically, in _resolveEntityByPath verify
Number.isInteger(entityPath[0]) && entityPath[0] >= 0 && entityPath[0] <
rootIds.length before getting rootIds[entityPath[0]]; inside the loop validate
Number.isInteger(entityPath[i]) && entityPath[i] >= 0 && entityPath[i] <
entity.children.length and that entity is non-null before indexing; always
return null on any malformed input instead of allowing undefined to slip
through.


private static _isClass(value: any): value is IClass {
return value["class"] !== undefined;
}
Expand All @@ -196,11 +210,11 @@ export class ReflectionParser {
}

private static _isEntityRef(value: any): value is IEntityRef {
return value["entityId"] !== undefined;
return Array.isArray(value["entityPath"]) && value["componentType"] === undefined;
}

private static _isComponentRef(value: any): value is IComponentRef {
return value["ownerId"] !== undefined && value["componentId"] !== undefined;
return Array.isArray(value["entityPath"]) && value["componentType"] !== undefined;
}

private static _isSignalRef(value: any): value is ISignalRef {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,12 @@ export type IBasicType =

export type IAssetRef = { key?: string; url: string };

export type IEntityRef = { entityId: string };
export type IEntityRef = { entityPath: number[] };

export type IComponentRef = {
ownerId: string;
componentId: string;
entityPath: number[];
componentType: string;
componentIndex: number;
};

export type ISignalRef = {
Expand Down
155 changes: 155 additions & 0 deletions tests/src/loader/PrefabResource.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { expect, beforeAll, afterAll, describe, it } from "vitest";
import { WebGLEngine } from "@galacean/engine-rhi-webgl";
import type { IHierarchyFile } from "@galacean/engine-loader";
import { Loader, MeshRenderer, Script } from "@galacean/engine-core";
import { PrefabParser } from "../../../packages/loader/src/prefab/PrefabParser";

let engine: WebGLEngine;

class DiceScript extends Script {
skinMesh: MeshRenderer = null;
numMesh: MeshRenderer = null;
}

Loader.registerClass("MeshRenderer", MeshRenderer);
Loader.registerClass("DiceScript", DiceScript);

beforeAll(async () => {
const canvas = document.createElement("canvas");
canvas.width = 256;
Expand Down Expand Up @@ -52,3 +61,149 @@ describe("PrefabResource refCount", () => {
expect(prefab.refCount).toBe(0);
});
});

describe("Cross-prefab IComponentRef (path-based)", () => {
it("should resolve component ref inside nested prefab instance via entityPath", async () => {
// 1. nested prefab (dice.prefab): single root entity with MeshRenderer
const nestedPrefabData: IHierarchyFile = {
entities: [
{
id: "0",
name: "diceRoot",
components: [{ id: "mesh-comp", class: "MeshRenderer" }],
children: []
}
]
};
const nestedPrefab = await PrefabParser.parse(engine, "dice.prefab", nestedPrefabData);
// @ts-ignore — register to resourceManager so getResourceByRef can find it
engine.resourceManager._objectPool["dice.prefab"] = nestedPrefab;

Comment on lines +78 to +81
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Guard _objectPool cleanup with try/finally to avoid test pollution.

If an assertion throws before the tail cleanup lines, "dice.prefab" remains in _objectPool and can affect later tests.

Suggested pattern
 const nestedPrefab = await PrefabParser.parse(engine, "dice.prefab", nestedPrefabData);
 // `@ts-ignore`
 engine.resourceManager._objectPool["dice.prefab"] = nestedPrefab;
-
-const outerPrefab = await PrefabParser.parse(engine, "DiceNode.prefab", outerPrefabData);
-const root = outerPrefab.instantiate();
-const script = root.getComponent(DiceScript);
-
-expect(script.skinMesh).not.toBeNull();
-...
-root.destroy();
-// `@ts-ignore`
-delete engine.resourceManager._objectPool["dice.prefab"];
+try {
+  const outerPrefab = await PrefabParser.parse(engine, "DiceNode.prefab", outerPrefabData);
+  const root = outerPrefab.instantiate();
+  const script = root.getComponent(DiceScript);
+
+  expect(script.skinMesh).not.toBeNull();
+  ...
+  root.destroy();
+} finally {
+  // `@ts-ignore`
+  delete engine.resourceManager._objectPool["dice.prefab"];
+}

Also applies to: 123-126, 139-142, 204-207

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/src/loader/PrefabResource.test.ts` around lines 78 - 81, Wrap the
temporary registration into engine.resourceManager._objectPool around the
PrefabParser.parse call in a try/finally: after assigning
engine.resourceManager._objectPool["dice.prefab"]=nestedPrefab, run assertions
inside the try block and in the finally block remove the key (delete
engine.resourceManager._objectPool["dice.prefab"]) to guarantee cleanup; apply
the same try/finally pattern to the other spots that set
engine.resourceManager._objectPool["dice.prefab"] (the later occurrences in this
test) so the pool is always cleaned even if assertions throw.

// 2. outer prefab (DiceNode.prefab)
// resolved tree: DiceNode(root=[0]) -> [0]dice(nested)
// skinMesh crosses nested prefab boundary via entityPath
const outerPrefabData: IHierarchyFile = {
entities: [
{
id: "root",
name: "DiceNode",
components: [
{
id: "script-comp",
class: "DiceScript",
props: {
skinMesh: { entityPath: [0, 0], componentType: "MeshRenderer", componentIndex: 0 }
}
}
],
children: ["dice-inst"]
},
{
id: "dice-inst",
name: "dice",
parent: "root",
components: [],
assetUrl: "dice.prefab",
modifications: [],
removedEntities: [],
removedComponents: []
}
]
};

const outerPrefab = await PrefabParser.parse(engine, "DiceNode.prefab", outerPrefabData);
const root = outerPrefab.instantiate();
const script = root.getComponent(DiceScript);

// skinMesh should point to the MeshRenderer inside nested prefab instance
expect(script.skinMesh).not.toBeNull();
expect(script.skinMesh).toBeInstanceOf(MeshRenderer);
expect(script.skinMesh.entity.name).toBe("dice");

root.destroy();
// @ts-ignore
delete engine.resourceManager._objectPool["dice.prefab"];
});

it("should resolve both local and cross-prefab refs, and survive clone independently", async () => {
const nestedPrefabData: IHierarchyFile = {
entities: [
{
id: "0",
name: "diceRoot",
components: [{ id: "mesh-comp", class: "MeshRenderer" }],
children: []
}
]
};
const nestedPrefab = await PrefabParser.parse(engine, "dice.prefab", nestedPrefabData);
// @ts-ignore
engine.resourceManager._objectPool["dice.prefab"] = nestedPrefab;

// resolved tree: DiceNode(root=[0]) -> [0]numCube, [1]dice(nested)
const outerPrefabData: IHierarchyFile = {
entities: [
{
id: "root",
name: "DiceNode",
components: [
{
id: "script-comp",
class: "DiceScript",
props: {
numMesh: { entityPath: [0, 0], componentType: "MeshRenderer", componentIndex: 0 },
skinMesh: { entityPath: [0, 1], componentType: "MeshRenderer", componentIndex: 0 }
}
}
],
children: ["num-cube", "dice-inst"]
},
{
id: "num-cube",
name: "numCube",
parent: "root",
components: [{ id: "num-mesh-comp", class: "MeshRenderer" }]
},
{
id: "dice-inst",
name: "dice",
parent: "root",
components: [],
assetUrl: "dice.prefab",
modifications: [],
removedEntities: [],
removedComponents: []
}
]
};

const outerPrefab = await PrefabParser.parse(engine, "DiceNode.prefab", outerPrefabData);

const instance1 = outerPrefab.instantiate();
const instance2 = outerPrefab.instantiate();

const script1 = instance1.getComponent(DiceScript);
const script2 = instance2.getComponent(DiceScript);

// instance1: numMesh -> numCube's MeshRenderer, skinMesh -> dice's MeshRenderer
const numMesh1 = instance1.children[0].getComponent(MeshRenderer);
const skinMesh1 = instance1.children[1].getComponent(MeshRenderer);
expect(script1.numMesh).toBe(numMesh1);
expect(script1.skinMesh).toBe(skinMesh1);

// instance2: independent refs
const numMesh2 = instance2.children[0].getComponent(MeshRenderer);
const skinMesh2 = instance2.children[1].getComponent(MeshRenderer);
expect(script2.numMesh).toBe(numMesh2);
expect(script2.skinMesh).toBe(skinMesh2);

// refs across instances are independent
expect(script1.numMesh).not.toBe(script2.numMesh);
expect(script1.skinMesh).not.toBe(script2.skinMesh);

instance1.destroy();
instance2.destroy();
// @ts-ignore
delete engine.resourceManager._objectPool["dice.prefab"];
});
});
Comment on lines +65 to +209
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add regression tests for the two known parser edge cases before merge.

This suite is good for nested/cross-prefab refs, but it still misses the two explicitly raised risks:

  1. top-level entityPath[0] resolution when multiple prefab roots complete async in different orders, and
  2. componentIndex/entityPath behavior when removedEntities or removedComponents are applied.

Please add tests that lock in intended semantics (especially pre-removal vs post-removal interpretation).

Suggested test skeleton
+it("should resolve top-level entityPath deterministically with multiple async prefab roots", async () => {
+  // Arrange: two top-level prefab instances with controllable async completion order.
+  // Assert: entityPath[0] binds by serialized order (or documented contract), not completion timing.
+});
+
+it("should define componentIndex/entityPath semantics with removedEntities/removedComponents", async () => {
+  // Arrange: target component/entity preceded by removable siblings.
+  // Assert: binding semantics stay consistent with documented behavior.
+});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/src/loader/PrefabResource.test.ts` around lines 65 - 209, Add two
regression tests to PrefabResource.test.ts: one that simulates concurrent async
loading of multiple prefab roots to assert entityPath[0] resolves to the correct
root regardless of load order (use PrefabParser.parse to create two prefab
resources, register them in engine.resourceManager._objectPool, instantiate
outer prefab that references both roots via entityPath and assert resolved
components on DiceScript), and a second that verifies componentIndex/entityPath
semantics when removedEntities/removedComponents are present (create a prefab
with removedEntities/removedComponents entries, parse via PrefabParser.parse,
instantiate and assert that component resolution on DiceScript uses pre-removal
indexing semantics — i.e., indices refer to original pre-removed layout — and
that removed items are not returned as resolved targets); reference
PrefabParser.parse, DiceScript, entityPath, componentIndex, removedEntities, and
removedComponents when adding the assertions.

Loading