diff --git a/docs/agent/VRENDER_RUNTIME_AND_ENTRIES.md b/docs/agent/VRENDER_RUNTIME_AND_ENTRIES.md index dc9e64902..cad2fd9f8 100644 --- a/docs/agent/VRENDER_RUNTIME_AND_ENTRIES.md +++ b/docs/agent/VRENDER_RUNTIME_AND_ENTRIES.md @@ -65,6 +65,17 @@ Stage 创建属于 app: - handle 的 `release()` 减少引用计数;最后一个 handle 释放后 app 释放。 - 直接调用 shared app 的 `app.release()` 会走同一 record release 逻辑。 +### 跨 bundle 的内部 ID + +`vrender-core/src/common/generator.ts` 在同一 JavaScript realm 内通过 +`Symbol.for('@visactor/vrender-core/generator-state')` 共享数字 ID 计数器。 +多个 bundle 的节点可能经 shared app / runtime service 进入同一棵树, +因此 ID 不能按模块副本或 app 独立计数,也不能随 app release 重置。 +模块初始化时缓存计数器对象,分配 ID 的热路径只做一次递增。 + +旧版本的模块独立计数器不会自动接入此机制;发布时仍需覆盖共存的依赖版本。 +回归入口:core 的 `common/generator.test.ts` 和 `graphic/node-tree-cross-bundle.test.ts`。 + ## `shared` / `shared-browser` / `shared-lynx` / `shared-browser-lite` - `./entries/shared`:通用 env shared entry,支持 `browser`、`node`、`taro`、`feishu`、`tt`、`wx`、`lynx`、`harmony`。package exports 中 `browser` condition 路由到 `shared-browser.js`,`lynx` condition 路由到 `shared-lynx.js`。 diff --git a/packages/vrender-core/__tests__/unit/common/generator.test.ts b/packages/vrender-core/__tests__/unit/common/generator.test.ts index c00bc5cc4..56b99b209 100644 --- a/packages/vrender-core/__tests__/unit/common/generator.test.ts +++ b/packages/vrender-core/__tests__/unit/common/generator.test.ts @@ -1,6 +1,16 @@ -import { Generator } from '../../../src/common/generator'; +import type { Generator as GeneratorClass } from '../../../src/common/generator'; + +function loadGeneratorCopy(): typeof GeneratorClass { + let copy: typeof GeneratorClass; + jest.isolateModules(() => { + copy = jest.requireActual<{ Generator: typeof GeneratorClass }>('../../../src/common/generator').Generator; + }); + return copy; +} describe('Generator', () => { + const Generator = loadGeneratorCopy(); + test('GenAutoIncrementId returns a finite number', () => { const id = Generator.GenAutoIncrementId(); expect(typeof id).toBe('number'); @@ -20,4 +30,35 @@ describe('Generator', () => { expect(ids[i + 1]).toBe(ids[i] + 1); } }); + + test('keeps IDs unique when independently loaded copies allocate alternately', () => { + const first = loadGeneratorCopy(); + const second = loadGeneratorCopy(); + expect(first).not.toBe(second); + + const ids = Array.from({ length: 100 }, (_, index) => (index % 2 === 0 ? first : second).GenAutoIncrementId()); + + expect(new Set(ids).size).toBe(ids.length); + ids.slice(1).forEach((id, index) => expect(id).toBe(ids[index] + 1)); + }); + + test('continues the sequence when another bundle loads after allocations', () => { + const first = loadGeneratorCopy(); + const ids = Array.from({ length: 100 }, () => first.GenAutoIncrementId()); + const second = loadGeneratorCopy(); + const next = second.GenAutoIncrementId(); + + expect(next).toBe(ids[ids.length - 1] + 1); + expect(first.GenAutoIncrementId()).toBe(next + 1); + }); + + test('does not restart the sequence when a module is evaluated again', () => { + const first = loadGeneratorCopy(); + const beforeReload = first.GenAutoIncrementId(); + const reloaded = loadGeneratorCopy(); + + expect(reloaded).not.toBe(first); + expect(reloaded.GenAutoIncrementId()).toBe(beforeReload + 1); + expect(first.GenAutoIncrementId()).toBe(beforeReload + 2); + }); }); diff --git a/packages/vrender-core/__tests__/unit/graphic/node-tree-cross-bundle.test.ts b/packages/vrender-core/__tests__/unit/graphic/node-tree-cross-bundle.test.ts new file mode 100644 index 000000000..e164237ba --- /dev/null +++ b/packages/vrender-core/__tests__/unit/graphic/node-tree-cross-bundle.test.ts @@ -0,0 +1,67 @@ +import type { Node } from '../../../src/graphic/node-tree'; + +function loadNodeCopy(): typeof Node { + let copy: typeof Node; + jest.isolateModules(() => { + copy = jest.requireActual<{ Node: typeof Node }>('../../../src/graphic/node-tree').Node; + }); + return copy; +} + +describe('node trees across independently loaded bundles', () => { + test('attaches a child from another bundle instead of treating it as the parent itself', () => { + const FirstNode = loadNodeCopy(); + const SecondNode = loadNodeCopy(); + expect(FirstNode).not.toBe(SecondNode); + + const parent = new FirstNode(); + const child = new SecondNode(); + + expect(parent.appendChild(child)).toBe(child); + expect(parent.firstChild).toBe(child); + expect(parent.childrenCount).toBe(1); + expect(child.parent).toBe(parent); + expect(parent.appendChild(parent)).toBeNull(); + }); + + test('preserves child lookup and removal when reparenting nodes from another bundle', () => { + const FirstNode = loadNodeCopy(); + const SecondNode = loadNodeCopy(); + const firstParent = new FirstNode(); + const firstChild = new FirstNode(); + const secondParent = new SecondNode(); + const secondChild = new SecondNode(); + firstParent.appendChild(firstChild); + secondParent.appendChild(secondChild); + + firstParent.appendChild(secondChild); + + expect(secondParent.childrenCount).toBe(0); + expect(firstParent.childrenCount).toBe(2); + expect(firstParent.findChildByUid(firstChild._uid)).toBe(firstChild); + expect(firstParent.findChildByUid(secondChild._uid)).toBe(secondChild); + expect(firstParent.removeChild(firstChild)).toBe(firstChild); + expect(firstChild.parent).toBeNull(); + expect(firstParent.firstChild).toBe(secondChild); + expect(firstParent.findChildByUid(secondChild._uid)).toBe(secondChild); + expect(firstParent.removeChild(secondChild)).toBe(secondChild); + expect(firstParent.childrenCount).toBe(0); + }); + + test('distinguishes ancestry in separate trees created by different bundles', () => { + const FirstNode = loadNodeCopy(); + const SecondNode = loadNodeCopy(); + const firstParent = new FirstNode(); + const firstChild = new FirstNode(); + const secondParent = new SecondNode(); + const secondChild = new SecondNode(); + firstParent.appendChild(firstChild); + secondParent.appendChild(secondChild); + + expect(firstChild.isChildOf(firstParent)).toBe(true); + expect(firstChild.isChildOf(secondParent)).toBe(false); + expect(firstChild.isDescendantsOf(secondParent)).toBe(false); + expect(firstParent.containNode(secondChild)).toBe(false); + expect(secondParent.containNode(firstChild)).toBe(false); + }); +}); diff --git a/packages/vrender-core/src/common/generator.ts b/packages/vrender-core/src/common/generator.ts index 5a252a963..81e6897d1 100644 --- a/packages/vrender-core/src/common/generator.ts +++ b/packages/vrender-core/src/common/generator.ts @@ -1,7 +1,22 @@ -export class Generator { - private static auto_increment_id: number = 0; +interface IGeneratorState { + autoIncrementId: number; +} +const GENERATOR_STATE_SYMBOL = Symbol.for('@visactor/vrender-core/generator-state'); + +function getGeneratorState(): IGeneratorState { + const scope = globalThis as typeof globalThis & { + [GENERATOR_STATE_SYMBOL]?: IGeneratorState; + }; + return scope[GENERATOR_STATE_SYMBOL] || (scope[GENERATOR_STATE_SYMBOL] = { autoIncrementId: 0 }); +} + +// Application and legacy runtime state are shared across bundles. Keep node ids in the same +// global scope so a shared Stage cannot receive a Layer with a colliding id from another bundle. +const generatorState = getGeneratorState(); + +export class Generator { static GenAutoIncrementId(): number { - return Generator.auto_increment_id++; + return generatorState.autoIncrementId++; } }