diff --git a/docs/advanced/primitive.md b/docs/advanced/primitive.md index 79e1d1401..29c2f1002 100644 --- a/docs/advanced/primitive.md +++ b/docs/advanced/primitive.md @@ -1,6 +1,6 @@ # Primitives -The `` component is a versatile low-level component in TresJS that allows you to directly use any three.js object within your Vue application without an abstraction. It acts as a bridge between Vue's reactivity system and three.js's scene graph. +The `` component is a versatile low-level component in TresJS that allows you to directly use any [three.js](https://threejs.org/) object within your Vue application without an abstraction. It acts as a bridge between Vue's reactivity system and THREE's scene graph. ## Usage @@ -18,15 +18,39 @@ The `` component is a versatile low-level component in TresJS that ``` ## Props -`object`: This prop expects a three.js Object3D or any of its derived classes. It is the primary object that the `` component will render. In the updated example, a `Mesh` object with an associated `Material` is passed to this prop. +- `object`: This prop expects either a plain or a reactive three.js [Object3D](https://threejs.org/docs/index.html?q=Object#api/en/core/Object3D) (preferably a [shallowRef](https://vuejs.org/api/reactivity-advanced.html#shallowref)) or any of its derived classes. It is the primary object that the `` component will render. In the updated example, a `Mesh` object with an associated `Material` is passed to this prop. + +## Events + +The same pointer events available on the TresJS components are available on the `` component. You can use these events to interact with the object in the scene. See the complete list of events [here](/api/events). + +```html + +``` + +## Passing childrens via slots + +You can also pass children to the `` component using slots. This is useful when you want to add additional objects to the scene that are not part of the main object. + +```html + +``` ## Usage with Models diff --git a/playground/src/pages/advanced/disposal/index.vue b/playground/src/pages/advanced/disposal/index.vue new file mode 100644 index 000000000..153c13c3d --- /dev/null +++ b/playground/src/pages/advanced/disposal/index.vue @@ -0,0 +1,57 @@ + + + diff --git a/playground/src/pages/issues/701-cientos-v4/TheExperience.vue b/playground/src/pages/issues/701-cientos-v4/TheExperience.vue new file mode 100644 index 000000000..d2a948c0f --- /dev/null +++ b/playground/src/pages/issues/701-cientos-v4/TheExperience.vue @@ -0,0 +1,13 @@ + + + + diff --git a/playground/src/pages/issues/701-cientos-v4/index.vue b/playground/src/pages/issues/701-cientos-v4/index.vue new file mode 100644 index 000000000..41ed1c7ef --- /dev/null +++ b/playground/src/pages/issues/701-cientos-v4/index.vue @@ -0,0 +1,35 @@ + + + + + + diff --git a/playground/src/pages/issues/701/TheExperience.vue b/playground/src/pages/issues/701/TheExperience.vue new file mode 100644 index 000000000..51508a34b --- /dev/null +++ b/playground/src/pages/issues/701/TheExperience.vue @@ -0,0 +1,363 @@ + + + + diff --git a/playground/src/pages/issues/701/index.vue b/playground/src/pages/issues/701/index.vue new file mode 100644 index 000000000..b6a96026a --- /dev/null +++ b/playground/src/pages/issues/701/index.vue @@ -0,0 +1,40 @@ + + + + + + diff --git a/playground/src/pages/issues/749/TheExperience.vue b/playground/src/pages/issues/749/TheExperience.vue new file mode 100644 index 000000000..42a13f9cc --- /dev/null +++ b/playground/src/pages/issues/749/TheExperience.vue @@ -0,0 +1,22 @@ + + + + diff --git a/playground/src/pages/issues/749/index.vue b/playground/src/pages/issues/749/index.vue new file mode 100644 index 000000000..3f5620261 --- /dev/null +++ b/playground/src/pages/issues/749/index.vue @@ -0,0 +1,23 @@ + + + diff --git a/playground/src/router/routes/advanced.ts b/playground/src/router/routes/advanced.ts index 86a96f5ae..c14a7594c 100644 --- a/playground/src/router/routes/advanced.ts +++ b/playground/src/router/routes/advanced.ts @@ -29,4 +29,9 @@ export const advancedRoutes = [ name: 'Material array', component: () => import('../../pages/advanced/materialArray/index.vue'), }, + { + path: '/advanced/disposal', + name: 'Disposal', + component: () => import('../../pages/advanced/disposal/index.vue'), + }, ] diff --git a/playground/src/router/routes/issues.ts b/playground/src/router/routes/issues.ts index 718ce5f01..e59496fb6 100644 --- a/playground/src/router/routes/issues.ts +++ b/playground/src/router/routes/issues.ts @@ -1,7 +1,22 @@ export const issuesRoutes = [ + { + path: '/issues/701', + name: '#701: primitive :object', + component: () => import('../../pages/issues/701/index.vue'), + }, + { + path: '/issues/701-cientos-v4', + name: '#701: in Cientos v4', + component: () => import('../../pages/issues/701-cientos-v4/index.vue'), + }, { path: '/issues/717vIf', name: '#717: v-if', component: () => import('../../pages/issues/717/index.vue'), }, + { + path: '/issues/749-attach-detach', + name: '#749: attach-detach', + component: () => import('../../pages/issues/749/index.vue'), + }, ] diff --git a/src/core/nodeOps.test.ts b/src/core/nodeOps.test.ts index 62d9f3a16..7b72f285a 100644 --- a/src/core/nodeOps.test.ts +++ b/src/core/nodeOps.test.ts @@ -270,6 +270,46 @@ describe('nodeOps', () => { expect(parent.material).toBe(previousAttach) }) + it('can swap 2 materials as if by v-if', () => { + // NOTE: for the Vue template ... + // + // + // + // + // ... the calling order for nodeOps when toggling tOrF is: + // createElement(a) + // insert(a) + // remove(a) + // createElement(b) + // insert(b) + // remove(b) + // ... and so on + const mesh = nodeOps.createElement('Mesh', undefined, undefined, {}) + const originalMaterial = mesh.material + const geo = nodeOps.createElement('BoxGeometry') + let mat0 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {}) + let mat1 = nodeOps.createElement('MeshStandardMaterial', undefined, undefined, {}) + + nodeOps.insert(geo, mesh) + nodeOps.insert(mat0, mesh) + expect(mesh.material).toBe(mat0) + + let NUM_SWAPS = 10 + while (NUM_SWAPS-- > 0) { + nodeOps.remove(mat0) + expect(mesh.material).toBe(originalMaterial) + mat1 = nodeOps.createElement('MeshStandardMaterial', undefined, undefined, {}) + nodeOps.insert(mat1, mesh) + expect(mesh.material).toBe(mat1) + + nodeOps.remove(mat1) + expect(mesh.material).toBe(originalMaterial) + mat0 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {}) + nodeOps.insert(mat0, mesh) + expect(mesh.material).toBe(mat0) + } + }) + it('can attach and detach a material array', () => { const parent = nodeOps.createElement('Mesh', undefined, undefined, {}) const previousMaterial = parent.material @@ -437,7 +477,7 @@ describe('nodeOps', () => { expect(childrenSet.has(fog)).toBe(true) }) - it.skip('can insert the same `primitive :object` in multiple places in the scene graph', () => { + it('can insert the same `primitive :object` in multiple places in the scene graph', () => { const material = new THREE.MeshNormalMaterial() const geometry = new THREE.BoxGeometry() const otherMaterial = new THREE.MeshBasicMaterial() @@ -529,221 +569,351 @@ describe('nodeOps', () => { } }) - it('calls dispose on a material', () => { - const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {})) - const material = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {}) - const spy = vi.spyOn(material, 'dispose') - nodeOps.insert(material, parent) - nodeOps.remove(parent) - expect(spy).toHaveBeenCalledOnce() - }) + describe('dispose', () => { + describe('default dispose', () => { + it('calls dispose on a material', () => { + const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {})) + const material = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {}) + const spy = vi.spyOn(material, 'dispose') + nodeOps.insert(material, parent) + nodeOps.remove(parent) + expect(spy).toHaveBeenCalledOnce() + }) - it.skip('calls dispose on a material array', () => { - // TODO: Make this test pass. - // No way to add a material array via nodeOps currently. - const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {})) - const material0 = new THREE.MeshNormalMaterial() - const material1 = new THREE.MeshNormalMaterial() - const spy0 = vi.spyOn(material0, 'dispose') - const spy1 = vi.spyOn(material1, 'dispose') - parent.material = [material0, material1] - nodeOps.remove(parent) - expect(spy0).toHaveBeenCalledOnce() - expect(spy1).toHaveBeenCalledOnce() - }) + it('calls dispose on an array of materials in a TresMesh', () => { + const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {})) + const material0 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, { attach: 'material-0' }) + const material1 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, { attach: 'material-1' }) + const material2 = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, { attach: 'material-2' }) + const spy0 = vi.spyOn(material0, 'dispose') + const spy1 = vi.spyOn(material1, 'dispose') + const spy2 = vi.spyOn(material2, 'dispose') + nodeOps.insert(material0, parent) + nodeOps.insert(material1, parent) + nodeOps.insert(material2, parent) + nodeOps.remove(parent) + expect(spy0).toHaveBeenCalledOnce() + expect(spy1).toHaveBeenCalledOnce() + expect(spy2).toHaveBeenCalledOnce() + }) - it('calls dispose on geometries', () => { - const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {})) - const geometry = nodeOps.createElement('SphereGeometry', undefined, undefined, {}) - const spy = vi.spyOn(geometry, 'dispose') - nodeOps.insert(geometry, parent) - nodeOps.remove(parent) - expect(spy).toHaveBeenCalledOnce() - }) + it('calls dispose on geometries', () => { + const parent = mockTresObjectRootInObject(nodeOps.createElement('Mesh', undefined, undefined, {})) + const geometry = nodeOps.createElement('SphereGeometry', undefined, undefined, {}) + const spy = vi.spyOn(geometry, 'dispose') + nodeOps.insert(geometry, parent) + nodeOps.remove(parent) + expect(spy).toHaveBeenCalledOnce() + }) - it('calls dispose on material/geometry in a TresMesh child of a TresMesh', () => { - const { mesh: grandparent } = createElementMesh(nodeOps) - const { mesh: parent } = createElementMesh(nodeOps) - const { mesh: child } = createElementMesh(nodeOps) - nodeOps.insert(parent, grandparent) - nodeOps.insert(child, parent) - const childMaterialDisposalSpy = vi.spyOn(child.material, 'dispose') - const childGeometryDisposalSpy = vi.spyOn(child.geometry, 'dispose') - nodeOps.remove(parent) - expect(childGeometryDisposalSpy).toHaveBeenCalledOnce() - expect(childMaterialDisposalSpy).toHaveBeenCalledOnce() - }) + it('calls dispose on material/geometry in a TresMesh child of a TresMesh', () => { + const { mesh: grandparent } = createElementMesh(nodeOps) + const { mesh: parent } = createElementMesh(nodeOps) + const { mesh: child } = createElementMesh(nodeOps) + nodeOps.insert(parent, grandparent) + nodeOps.insert(child, parent) + const childMaterialDisposalSpy = vi.spyOn(child.material, 'dispose') + const childGeometryDisposalSpy = vi.spyOn(child.geometry, 'dispose') + nodeOps.remove(parent) + expect(childGeometryDisposalSpy).toHaveBeenCalledOnce() + expect(childMaterialDisposalSpy).toHaveBeenCalledOnce() + }) - it('calls dispose on every material/geometry in a TresMesh tree', () => { - const NUM_LEVEL = 5 - const NUM_CHILD_PER_NODE = 3 - const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Mesh')) - const disposalSpies = [] + it('calls dispose on every material/geometry in a TresMesh tree', () => { + const NUM_LEVEL = 5 + const NUM_CHILD_PER_NODE = 3 + const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Mesh')) + const disposalSpies = [] + + createTreeIn(rootNode, (parent, childI, levelI) => { + if (levelI > NUM_LEVEL || childI >= NUM_CHILD_PER_NODE) { + return false + } + const { mesh, material, geometry } = createElementMesh(nodeOps) + nodeOps.insert(mesh, parent) + disposalSpies.push(vi.spyOn(geometry, 'dispose')) + disposalSpies.push(vi.spyOn(material, 'dispose')) + return mesh + }) - createTreeIn(rootNode, (parent, childI, levelI) => { - if (levelI > NUM_LEVEL || childI >= NUM_CHILD_PER_NODE) { - return false - } - const { mesh, material, geometry } = createElementMesh(nodeOps) - nodeOps.insert(mesh, parent) - disposalSpies.push(vi.spyOn(geometry, 'dispose')) - disposalSpies.push(vi.spyOn(material, 'dispose')) - return mesh - }) + nodeOps.remove(rootNode) + for (const spy of disposalSpies) { + expect(spy).toHaveBeenCalledOnce() + } + }) - nodeOps.remove(rootNode) - for (const spy of disposalSpies) { - expect(spy).toHaveBeenCalledOnce() - } - }) + it('calls dispose on every material/geometry in a TresMesh/TresGroup tree', () => { + const NUM_LEVEL = 5 + const NUM_CHILD_PER_NODE = 3 + const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Group')) + const disposalSpies = [] + + createTreeIn(rootNode, (parent, childI, levelI) => { + if (childI > NUM_CHILD_PER_NODE || levelI > NUM_LEVEL) { + return false + } + if (Math.random() > 0.3) { + const { mesh, material, geometry } = createElementMesh(nodeOps) + nodeOps.insert(mesh, parent) + disposalSpies.push(vi.spyOn(geometry, 'dispose')) + disposalSpies.push(vi.spyOn(material, 'dispose')) + return mesh + } + else { + const group = nodeOps.createElement('Group') + nodeOps.insert(group, parent) + return group + } + }) + + nodeOps.remove(rootNode) + for (const spy of disposalSpies) { + expect(spy).toHaveBeenCalledOnce() + } + }) - it('calls dispose on every material/geometry in a TresMesh/TresGroup tree', () => { - const NUM_LEVEL = 5 - const NUM_CHILD_PER_NODE = 3 - const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Group')) - const disposalSpies = [] + it('does not dispose primitive material/geometries on remove(primitive)', () => { + const { primitive, material, geometry } = createElementPrimitiveMesh(nodeOps) + const spy0 = vi.spyOn(material, 'dispose') + const spy1 = vi.spyOn(geometry, 'dispose') - createTreeIn(rootNode, (parent, childI, levelI) => { - if (childI > NUM_CHILD_PER_NODE || levelI > NUM_LEVEL) { - return false - } - if (Math.random() > 0.3) { - const { mesh, material, geometry } = createElementMesh(nodeOps) - nodeOps.insert(mesh, parent) - disposalSpies.push(vi.spyOn(geometry, 'dispose')) - disposalSpies.push(vi.spyOn(material, 'dispose')) - return mesh - } - else { const group = nodeOps.createElement('Group') - nodeOps.insert(group, parent) - return group - } - }) + nodeOps.insert(primitive, group) + nodeOps.remove(primitive) - nodeOps.remove(rootNode) - for (const spy of disposalSpies) { - expect(spy).toHaveBeenCalledOnce() - } - }) + expect(spy0).not.toBeCalled() + expect(spy1).not.toBeCalled() + }) - it('does not dispose primitive material/geometries on remove(primitive)', () => { - const { primitive, material, geometry } = createElementPrimitiveMesh(nodeOps) - const spy0 = vi.spyOn(material, 'dispose') - const spy1 = vi.spyOn(geometry, 'dispose') + it('does not dispose primitive material/geometries on remove(ascestorOfPrimitive)', () => { + const { primitive, material, geometry } = createElementPrimitiveMesh(nodeOps) + const spy0 = vi.spyOn(material, 'dispose') + const spy1 = vi.spyOn(geometry, 'dispose') - const group = nodeOps.createElement('Group') - nodeOps.insert(primitive, group) - nodeOps.remove(primitive) + const group = nodeOps.createElement('Group') + nodeOps.insert(primitive, group) + nodeOps.remove(group) - expect(spy0).not.toBeCalled() - expect(spy1).not.toBeCalled() - }) + expect(spy0).not.toBeCalled() + expect(spy1).not.toBeCalled() + }) - it('does not dispose primitive material/geometries on remove(ascestorOfPrimitive)', () => { - const { primitive, material, geometry } = createElementPrimitiveMesh(nodeOps) - const spy0 = vi.spyOn(material, 'dispose') - const spy1 = vi.spyOn(geometry, 'dispose') + it('does not call dispose on primitive materials/geometries in a tree of Mesh/Groups/Primitives created by nodeOps', () => { + const NUM_LEVEL = 5 + const NUM_CHILD_PER_NODE = 3 + const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Group')) + const disposalSpies = [] + + createTreeIn(rootNode, (parent, childI, levelI) => { + if (childI > NUM_CHILD_PER_NODE || levelI > NUM_LEVEL) { + return false + } + if (Math.random() > 0.5) { + const { mesh } = createElementMesh(nodeOps) + nodeOps.insert(mesh, parent) + return mesh + } + else if (Math.random() > 0.5) { + const group = nodeOps.createElement('Group') + nodeOps.insert(group, parent) + return group + } + else { + const { primitive, material, geometry } = createElementPrimitiveMesh(nodeOps) + disposalSpies.push(vi.spyOn(geometry, 'dispose')) + disposalSpies.push(vi.spyOn(material, 'dispose')) + nodeOps.insert(primitive, parent) + return primitive + } + }) + + nodeOps.remove(rootNode) + for (const spy of disposalSpies) { + expect(spy).not.toHaveBeenCalled() + } + }) + }) - const group = nodeOps.createElement('Group') - nodeOps.insert(primitive, group) - nodeOps.remove(group) + describe(':dispose="null" or :dispose="false"', () => { + it('does not call dispose on geometry/material in a Mesh where :dispose==="null" or "false"', () => { + for (const d of [false, null]) { + const { mesh: parent } = createElementMesh(nodeOps) + const { mesh, geometry, material } = createElementMesh(nodeOps) + const spy0 = vi.spyOn(geometry, 'dispose') + const spy1 = vi.spyOn(material, 'dispose') + nodeOps.patchProp(mesh, 'dispose', undefined, d) + nodeOps.insert(mesh, parent) + nodeOps.remove(mesh) + expect(spy0).not.toBeCalled() + expect(spy1).not.toBeCalled() + } + }) + it('does not call dispose on child\'s geometry/material, for remove()', () => { + for (const d of [false, null]) { + const { mesh: grandparent } = createElementMesh(nodeOps) + const { mesh: parent } = createElementMesh(nodeOps) + const { mesh: child, geometry, material } = createElementMesh(nodeOps) + const spy0 = vi.spyOn(geometry, 'dispose') + const spy1 = vi.spyOn(material, 'dispose') + nodeOps.patchProp(child, 'dispose', undefined, d) + nodeOps.insert(parent, grandparent) + nodeOps.insert(child, parent) + nodeOps.remove(parent) + expect(spy0).not.toBeCalled() + expect(spy1).not.toBeCalled() + } + }) + it('does not call dispose on any element in a subtree where the root :dispose==="null"', () => { + for (const d of [false, null]) { + const NUM_LEVEL = 5 + const NUM_CHILD_PER_NODE = 3 + const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Group')) + const disposalSpies = [] + const nullDisposeObjects = new Set() + + createTreeIn(rootNode, (parent, childI, levelI) => { + if (childI > NUM_CHILD_PER_NODE || levelI > NUM_LEVEL) { + return false + } + const { mesh, material, geometry } = createElementMesh(nodeOps) + if (nullDisposeObjects.has(parent)) { + nullDisposeObjects.add(mesh) + disposalSpies.push(vi.spyOn(geometry, 'dispose')) + disposalSpies.push(vi.spyOn(material, 'dispose')) + } + else if (levelI > 2 && Math.random() > 0.8) { + nodeOps.patchProp(mesh, 'dispose', undefined, d) + nullDisposeObjects.add(mesh) + disposalSpies.push(vi.spyOn(geometry, 'dispose')) + disposalSpies.push(vi.spyOn(material, 'dispose')) + } + nodeOps.insert(mesh, parent) + return mesh + }) + + nodeOps.remove(rootNode) + for (const spy of disposalSpies) { + expect(spy).not.toHaveBeenCalled() + } + } + }) + }) - expect(spy0).not.toBeCalled() - expect(spy1).not.toBeCalled() - }) + describe(':dispose="false"', () => { + it('calls no `dispose`s when removed', () => { + const { spies, m } = createSimpleMeshPrimitiveTree(nodeOps) + nodeOps.patchProp(m, 'dispose', undefined, false) + nodeOps.remove(m) - it('does not call dispose on primitive materials/geometries in a tree of Mesh/Groups/Primitives created by nodeOps', () => { - const NUM_LEVEL = 5 - const NUM_CHILD_PER_NODE = 3 - const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Group')) - const disposalSpies = [] + for (const spy of spies) { + expect(spy).not.toHaveBeenCalled() + } + }) + }) - createTreeIn(rootNode, (parent, childI, levelI) => { - if (childI > NUM_CHILD_PER_NODE || levelI > NUM_LEVEL) { - return false - } - if (Math.random() > 0.5) { - const { mesh } = createElementMesh(nodeOps) - nodeOps.insert(mesh, parent) - return mesh - } - else if (Math.random() > 0.5) { - const group = nodeOps.createElement('Group') - nodeOps.insert(group, parent) - return group - } - else { - const { primitive, material, geometry } = createElementPrimitiveMesh(nodeOps) - disposalSpies.push(vi.spyOn(geometry, 'dispose')) - disposalSpies.push(vi.spyOn(material, 'dispose')) - nodeOps.insert(primitive, parent) - return primitive - } + describe(':dispose="fn"', () => { + it('calls `fn(node)`, for every declarative node and every THREE.child when tree is removed', () => { + const { nodes, m } = createSimpleMeshPrimitiveTree(nodeOps) + const spies = nodes.map((node) => { + const spy = vi.fn((n) => { + return n === node + }) + node = Object.assign(node, { foo: spy }) + return spy + }) + const fn = (node: any) => node.foo?.(node) + nodeOps.patchProp(m, 'dispose', undefined, fn) + nodeOps.remove(m) + + for (const spy of spies) { + expect(spy).toHaveBeenCalled() + } + }) }) - nodeOps.remove(rootNode) - for (const spy of disposalSpies) { - expect(spy).not.toHaveBeenCalled() - } - }) + describe('dispose="default"', () => { + it('resets a ancestor\'s :dispose="false"', () => { + const { spiesByKey: spies, m, m_m, m_p } = createSimpleMeshPrimitiveTree(nodeOps) + nodeOps.patchProp(m, 'dispose', undefined, false) + nodeOps.patchProp(m_m, 'dispose', undefined, 'default') + nodeOps.patchProp(m_p, 'dispose', undefined, 'default') + nodeOps.remove(m) - describe(':dispose="null"', () => { - it('does not call dispose on geometry/material in a Mesh where :dispose==="null"', () => { - const { mesh: parent } = createElementMesh(nodeOps) - const { mesh, geometry, material } = createElementMesh(nodeOps) - const spy0 = vi.spyOn(geometry, 'dispose') - const spy1 = vi.spyOn(material, 'dispose') - nodeOps.patchProp(mesh, 'dispose', undefined, null) - nodeOps.insert(mesh, parent) - nodeOps.remove(mesh) - expect(spy0).not.toBeCalled() - expect(spy1).not.toBeCalled() - }) - it('does not call dispose on child\'s geometry/material, for remove()', () => { - const { mesh: grandparent } = createElementMesh(nodeOps) - const { mesh: parent } = createElementMesh(nodeOps) - const { mesh: child, geometry, material } = createElementMesh(nodeOps) - const spy0 = vi.spyOn(geometry, 'dispose') - const spy1 = vi.spyOn(material, 'dispose') - nodeOps.patchProp(child, 'dispose', undefined, null) - nodeOps.insert(parent, grandparent) - nodeOps.insert(child, parent) - nodeOps.remove(parent) - expect(spy0).not.toBeCalled() - expect(spy1).not.toBeCalled() - }) - it('does not call dispose on any element in a subtree where the root :dispose==="null"', () => { - const NUM_LEVEL = 5 - const NUM_CHILD_PER_NODE = 3 - const rootNode = mockTresObjectRootInObject(nodeOps.createElement('Group')) - const disposalSpies = [] - const nullDisposeObjects = new Set() - - createTreeIn(rootNode, (parent, childI, levelI) => { - if (childI > NUM_CHILD_PER_NODE || levelI > NUM_LEVEL) { - return false - } - const { mesh, material, geometry } = createElementMesh(nodeOps) - if (nullDisposeObjects.has(parent)) { - nullDisposeObjects.add(mesh) - disposalSpies.push(vi.spyOn(geometry, 'dispose')) - disposalSpies.push(vi.spyOn(material, 'dispose')) - } - else if (levelI > 2 && Math.random() > 0.8) { - nodeOps.patchProp(mesh, 'dispose', undefined, null) - nullDisposeObjects.add(mesh) - disposalSpies.push(vi.spyOn(geometry, 'dispose')) - disposalSpies.push(vi.spyOn(material, 'dispose')) - } - nodeOps.insert(mesh, parent) - return mesh + expect(spies.m.material).not.toBeCalled() + expect(spies.m.geometry).not.toBeCalled() + + expect(spies.m_m.material).toBeCalled() + expect(spies.m_m.geometry).toBeCalled() + + expect(spies.m_p.material).not.toBeCalled() + expect(spies.m_p.geometry).not.toBeCalled() + + expect(spies.m_m_m.material).toBeCalled() + expect(spies.m_m_m.geometry).toBeCalled() + expect(spies.m_m_p.material).not.toBeCalled() + expect(spies.m_m_p.geometry).not.toBeCalled() + + expect(spies.m_p_m.material).toBeCalled() + expect(spies.m_p_m.geometry).toBeCalled() + expect(spies.m_p_p.material).not.toBeCalled() + expect(spies.m_p_p.geometry).not.toBeCalled() + }) + it('resets a :dispose="fn"', () => { + const { spiesByKey: spies, m, m_p_p } = createSimpleMeshPrimitiveTree(nodeOps) + // NOTE: `disposeRecursive` should dispose all + // children, geometries, materials, including in primitives. + nodeOps.patchProp(m, 'dispose', undefined, (node) => { + if (node.dispose) { + try { + node.dispose() + } + catch (e) {} + } + if (node.material) { node.material.dispose() } + if (node.geometry) { node.geometry.dispose() } + }) + // NOTE: In the tree of `m_p_p`, the primitives and their + // geometries, materials should not be disposed. + nodeOps.patchProp(m_p_p, 'dispose', undefined, 'default') + nodeOps.remove(m) + + expect(spies.m.material).toBeCalled() + expect(spies.m.geometry).toBeCalled() + + expect(spies.m_m.material).toBeCalled() + expect(spies.m_m.geometry).toBeCalled() + + expect(spies.m_p.material).toBeCalled() + expect(spies.m_p.geometry).toBeCalled() + + expect(spies.m_m_m.material).toBeCalled() + expect(spies.m_m_m.geometry).toBeCalled() + expect(spies.m_m_p.material).toBeCalled() + expect(spies.m_m_p.geometry).toBeCalled() + + expect(spies.m_p_m.material).toBeCalled() + expect(spies.m_p_m.geometry).toBeCalled() + + expect(spies.m_p_p.material).not.toBeCalled() + expect(spies.m_p_p.geometry).not.toBeCalled() }) + }) - nodeOps.remove(rootNode) - for (const spy of disposalSpies) { + it('disposes "nodeA" in ', () => { + const { primitive } = createElementPrimitiveMesh(nodeOps) + const { mesh } = createElementMesh(nodeOps) + nodeOps.insert(mesh, primitive) + const spies = [ + vi.spyOn(mesh.material, 'dispose'), + vi.spyOn(mesh.geometry, 'dispose'), + ] + for (const spy of spies) { expect(spy).not.toHaveBeenCalled() } + nodeOps.remove(primitive) + for (const spy of spies) { + expect(spy).toHaveBeenCalledOnce() + } }) }) @@ -775,7 +945,7 @@ describe('nodeOps', () => { nodeOps.remove(child) expect(child.parent?.uuid).toBeFalsy() }) - describe.skip('primitive', () => { + describe('primitive', () => { it('detaches mesh (in primitive :object) from mesh', () => { const { mesh: parent } = createElementMesh(nodeOps) const { primitive, mesh } = createElementPrimitiveMesh(nodeOps) @@ -842,6 +1012,11 @@ describe('nodeOps', () => { expect(parent.__tres.objects.includes(geometry)).toBe(false) }) }) + + it('disposes a GridHelper without throwing (Issue #721)', () => { + const gridHelper = nodeOps.createElement('GridHelper', undefined, undefined, {}) + expect(() => nodeOps.remove(gridHelper)).not.toThrow() + }) }) describe('patchProp', () => { @@ -948,135 +1123,168 @@ describe('nodeOps', () => { expect(spy).toHaveBeenCalledTimes(3) }) - describe.skip('patch `:object` on primitives', () => { - it('replaces original object', () => { - const material0 = new THREE.MeshNormalMaterial() - const material1 = new THREE.MeshBasicMaterial() - const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: material0 }) - nodeOps.patchProp(primitive, 'object', material0, material1) - expect(primitive.object).toBe(material1) - }) + describe('primitive', () => { + describe(':object', () => { + it('replaces original object', () => { + const material0 = new THREE.MeshNormalMaterial() + const material1 = new THREE.MeshBasicMaterial() + const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: material0 }) + nodeOps.patchProp(primitive, 'object', material0, material1) + expect(primitive.object).toBe(material1) + }) - it('does not alter __tres on another primitive sharing the same object', () => { - const materialA = new THREE.MeshNormalMaterial() - const materialB = new THREE.MeshNormalMaterial() - const primitive0 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA }) - const primitive0TresJson = JSON.stringify(primitive0.__tres) - const primitive1 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA }) + it('does not alter __tres on another primitive sharing the same object', () => { + const doFreeze = (o: any) => { + // NOTE: o.root contains references to scene, etc. + // These will change simply by adding elements to the + // scene. So copy and remove root. + o = { ...o } + delete o.root + return JSON.parse(JSON.stringify(o)) + } - expect(primitive0.__tres).not.toBe(primitive1.__tres) - expect(JSON.stringify(primitive0.__tres)).toBe(primitive0TresJson) + const materialA = new THREE.MeshNormalMaterial() + const materialB = new THREE.MeshNormalMaterial() + const primitive0 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA }) - nodeOps.patchProp(primitive1, 'object', undefined, materialB) - expect(primitive0.__tres).not.toBe(primitive1.__tres) - expect(JSON.stringify(primitive0.__tres)).toBe(primitive0TresJson) + const primitive0Tres = doFreeze(primitive0.__tres) + expect(doFreeze(primitive0.__tres)).toStrictEqual(primitive0Tres) + const primitive1 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA }) - nodeOps.patchProp(primitive1, 'object', undefined, materialA) - expect(primitive0.__tres).not.toBe(primitive1.__tres) - expect(JSON.stringify(primitive0.__tres)).toBe(primitive0TresJson) - }) + expect(primitive0.__tres).not.toBe(primitive1.__tres) + expect(doFreeze(primitive0.__tres)).toStrictEqual(primitive0Tres) - it('does not replace the object in other primitives who point to the same object', () => { - const { mesh: parent } = createElementMesh(nodeOps) - const { mesh: child0 } = createElementMesh(nodeOps) - const { mesh: child1 } = createElementMesh(nodeOps) - const materialA = new THREE.MeshNormalMaterial() - const materialB = new THREE.MeshBasicMaterial() - const primitive1 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA }) - const primitive0 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA }) - - nodeOps.insert(primitive0, child0) - nodeOps.insert(primitive1, child1) - nodeOps.insert(child0, parent) - nodeOps.insert(child1, parent) - - expect(child0.material).toBe(materialA) - expect(child1.material).toBe(materialA) - - nodeOps.patchProp(primitive1, 'object', undefined, materialB) - expect(child0.material).toBe(materialA) - expect(child1.material).not.toBe(materialA) - - nodeOps.patchProp(primitive1, 'object', undefined, materialA) - expect(child0.material).toBe(materialA) - expect(child1.material).toBe(materialA) - - nodeOps.patchProp(primitive0, 'object', undefined, materialB) - expect(child0.material).not.toBe(materialA) - expect(child1.material).toBe(materialA) - - nodeOps.patchProp(primitive1, 'object', undefined, materialB) - expect(child0.material).not.toBe(materialA) - expect(child1.material).not.toBe(materialA) - expect(child0.material).toBe(materialB) - expect(child1.material).toBe(materialB) - }) - it('attaches the new object to the old object\'s parent; clears old object\'s parent', () => { - const { mesh: parent } = createElementMesh(nodeOps) - const { mesh: child0 } = createThreeBox() - const { mesh: child1 } = createThreeBox() - const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: child0 }) - nodeOps.insert(primitive, parent) - expect(child0.parent).toBe(parent) - expect(parent.children[0]).toBe(child0) - expect(parent.children.length).toBe(1) - - nodeOps.patchProp(primitive, 'object', undefined, child1) - expect(child0.parent?.uuid).toBeFalsy() - expect(child1.parent?.uuid).toBe(parent.uuid) - expect(parent.children[0]).toBe(child1) - expect(parent.children.length).toBe(1) - }) - it('if old :object had been patched, those patches are applied to new :object', () => { - const { mesh: parent } = createElementMesh(nodeOps) - const { mesh: child0 } = createElementMesh(nodeOps) - const { mesh: child1 } = createElementMesh(nodeOps) - const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: child0 }) - nodeOps.insert(primitive, parent) - nodeOps.patchProp(primitive, 'position-x', undefined, -999) - expect(child0.position.x).toBe(-999) - - nodeOps.patchProp(primitive, 'object', undefined, child1) - expect(child1.position.x).toBe(-999) - - nodeOps.patchProp(primitive, 'position-x', undefined, 1000) - nodeOps.patchProp(primitive, 'object', undefined, child0) - expect(child0.position.x).toBe(1000) - }) - it('does not attach old :object children to new :object', () => { - const { mesh: parent } = createElementMesh(nodeOps) - const { mesh: child0 } = createElementMesh(nodeOps) - const { mesh: child1 } = createElementMesh(nodeOps) - const grandchild0 = new THREE.Mesh() - const grandchild1 = new THREE.Mesh() - child0.add(grandchild0) - child1.add(grandchild1) - const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: child0 }) - nodeOps.insert(primitive, parent) - expect(primitive.children[0]).toBe(grandchild0) - expect(primitive.children.length).toBe(1) - - nodeOps.patchProp(primitive, 'object', undefined, child1) - expect(primitive.children[0]).toBe(grandchild1) - expect(primitive.children.length).toBe(1) - - nodeOps.patchProp(primitive, 'object', undefined, child0) - expect(primitive.children[0].uuid).toBe(grandchild0.uuid) - expect(primitive.children.length).toBe(1) - - nodeOps.patchProp(primitive, 'object', undefined, child1) - expect(primitive.children[0]).toBe(grandchild1) - expect(primitive.children.length).toBe(1) - }) - it('does not copy UUID', () => { - const material0 = new THREE.MeshNormalMaterial() - const material1 = new THREE.MeshNormalMaterial() - const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: material0 }) - nodeOps.patchProp(primitive, 'object', material0, material1) - expect(material0.uuid).not.toBe(material1.uuid) - - nodeOps.patchProp(primitive, 'object', material1, material0) - expect(material0.uuid).not.toBe(material1.uuid) + nodeOps.patchProp(primitive1, 'object', undefined, materialB) + expect(primitive0.__tres).not.toStrictEqual(primitive1.__tres) + expect(doFreeze(primitive0.__tres)).toStrictEqual(primitive0Tres) + + nodeOps.patchProp(primitive1, 'object', undefined, materialA) + expect(primitive0.__tres).not.toBe(primitive1.__tres) + expect(doFreeze(primitive0.__tres)).toStrictEqual(primitive0Tres) + }) + + it('does not replace the object in other primitives who point to the same object', () => { + const { mesh: parent } = createElementMesh(nodeOps) + const { mesh: child0 } = createElementMesh(nodeOps) + const { mesh: child1 } = createElementMesh(nodeOps) + const materialA = new THREE.MeshNormalMaterial() + const materialB = new THREE.MeshBasicMaterial() + const primitive1 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA }) + const primitive0 = nodeOps.createElement('primitive', undefined, undefined, { object: materialA }) + + nodeOps.insert(primitive0, child0) + nodeOps.insert(primitive1, child1) + nodeOps.insert(child0, parent) + nodeOps.insert(child1, parent) + + expect(child0.material).toBe(materialA) + expect(child1.material).toBe(materialA) + + nodeOps.patchProp(primitive1, 'object', undefined, materialB) + expect(child0.material).toBe(materialA) + expect(child1.material).not.toBe(materialA) + + nodeOps.patchProp(primitive1, 'object', undefined, materialA) + expect(child0.material).toBe(materialA) + expect(child1.material).toBe(materialA) + + nodeOps.patchProp(primitive0, 'object', undefined, materialB) + expect(child0.material).not.toBe(materialA) + expect(child1.material).toBe(materialA) + + nodeOps.patchProp(primitive1, 'object', undefined, materialB) + expect(child0.material).not.toBe(materialA) + expect(child1.material).not.toBe(materialA) + expect(child0.material).toBe(materialB) + expect(child1.material).toBe(materialB) + }) + it('attaches the new object to the old object\'s parent; clears old object\'s parent', () => { + const { mesh: parent } = createElementMesh(nodeOps) + const { mesh: child0 } = createThreeBox() + const { mesh: child1 } = createThreeBox() + const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: child0 }) + nodeOps.insert(primitive, parent) + expect(child0.parent).toBe(parent) + expect(parent.children[0]).toBe(child0) + expect(parent.children.length).toBe(1) + + nodeOps.patchProp(primitive, 'object', undefined, child1) + expect(child0.parent?.uuid).toBeFalsy() + expect(child1.parent?.uuid).toBe(parent.uuid) + expect(parent.children[0].uuid).toBe(child1.uuid) + expect(parent.children.length).toBe(1) + }) + it('if old :object had been patched, those patches are applied to new :object', () => { + const { mesh: parent } = createElementMesh(nodeOps) + const { mesh: child0 } = createElementMesh(nodeOps) + const { mesh: child1 } = createElementMesh(nodeOps) + const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: child0 }) + nodeOps.insert(primitive, parent) + nodeOps.patchProp(primitive, 'position-x', undefined, -999) + expect(child0.position.x).toBe(-999) + + nodeOps.patchProp(primitive, 'object', undefined, child1) + expect(child1.position.x).toBe(-999) + + nodeOps.patchProp(primitive, 'position-x', undefined, 1000) + nodeOps.patchProp(primitive, 'object', undefined, child0) + expect(child0.position.x).toBe(1000) + }) + it('does not attach old :object THREE children to new :object', () => { + const { mesh: parent } = createElementMesh(nodeOps) + const { mesh: child0 } = createElementMesh(nodeOps) + const { mesh: child1 } = createElementMesh(nodeOps) + const grandchild0 = new THREE.Mesh() + const grandchild1 = new THREE.Mesh() + child0.add(grandchild0) + child1.add(grandchild1) + const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: child0 }) + nodeOps.insert(primitive, parent) + expect(primitive.children[0]).toBe(grandchild0) + expect(primitive.children.length).toBe(1) + + nodeOps.patchProp(primitive, 'object', undefined, child1) + expect(primitive.children[0]).toBe(grandchild1) + expect(primitive.children.length).toBe(1) + + nodeOps.patchProp(primitive, 'object', undefined, child0) + expect(primitive.children[0].uuid).toBe(grandchild0.uuid) + expect(primitive.children.length).toBe(1) + + nodeOps.patchProp(primitive, 'object', undefined, child1) + expect(primitive.children[0]).toBe(grandchild1) + expect(primitive.children.length).toBe(1) + }) + it('does attach old :object Vue children to new :object', () => { + const { mesh: parent } = createElementMesh(nodeOps) + const { mesh: child0 } = createElementMesh(nodeOps) + const { mesh: child1 } = createElementMesh(nodeOps) + const { mesh: vueGrandchild } = createElementMesh(nodeOps) + const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: child0 }) + nodeOps.insert(primitive, parent) + nodeOps.insert(vueGrandchild, primitive) + expect(nodeOps.parentNode(vueGrandchild)).toBe(primitive) + + nodeOps.patchProp(primitive, 'object', undefined, child1) + expect(vueGrandchild.__tres.parent.uuid).toBe(primitive.uuid) + expect(primitive.__tres.objects.includes(vueGrandchild)).toBe(true) + expect(nodeOps.parentNode(vueGrandchild)).toBe(primitive) + + nodeOps.patchProp(primitive, 'object', undefined, child0) + expect(nodeOps.parentNode(vueGrandchild)).toBe(primitive) + expect(vueGrandchild.material).toBeDefined() + expect(vueGrandchild.geometry).toBeDefined() + }) + it('does not copy UUID', () => { + const material0 = new THREE.MeshNormalMaterial() + const material1 = new THREE.MeshNormalMaterial() + const primitive = nodeOps.createElement('primitive', undefined, undefined, { object: material0 }) + nodeOps.patchProp(primitive, 'object', material0, material1) + expect(material0.uuid).not.toBe(material1.uuid) + + nodeOps.patchProp(primitive, 'object', material1, material0) + expect(material0.uuid).not.toBe(material1.uuid) + }) }) }) @@ -1136,18 +1344,171 @@ describe('nodeOps', () => { }) describe('parentNode', () => { - it('returns parent of a node', async () => { - // Setup + it('returns Vue parent of a node', async () => { + // create a nodeOps-made tree that looks like + // mesh (m) + // mesh (m_m) prim (m_p) + // mesh (m_m_m) prim (m_m_p) mesh (m_p_m) prim (m_p_p) + const { mesh: m } = createElementMesh(nodeOps) + const { mesh: m_m } = createElementMesh(nodeOps) + const { mesh: m_m_m } = createElementMesh(nodeOps) + const { mesh: m_p_m } = createElementMesh(nodeOps) + const { primitive: m_p } = createElementPrimitiveMesh(nodeOps) + const { primitive: m_m_p } = createElementPrimitiveMesh(nodeOps) + const { primitive: m_p_p } = createElementPrimitiveMesh(nodeOps) + nodeOps.insert(m_m, m) + nodeOps.insert(m_p, m) + nodeOps.insert(m_m_m, m_m) + nodeOps.insert(m_m_p, m_m) + nodeOps.insert(m_p_m, m_p) + nodeOps.insert(m_p_p, m_p) + + // NOTE: add a THREE child to the primitives, not through nodeOps. + const m_p_c = new Mesh(new THREE.ConeGeometry(), new THREE.MeshNormalMaterial()) + m_p.add(m_p_c) + const m_m_p_c = new Mesh(new THREE.ConeGeometry(), new THREE.MeshNormalMaterial()) + m_m_p.add(m_m_p_c) + const m_p_p_c = new Mesh(new THREE.ConeGeometry(), new THREE.MeshNormalMaterial()) + m_p_p.add(m_p_p_c) + + // NOTE: add a THREE grandchild to a primitive, not through nodeOps. + const m_p_p_c_c = new Mesh(new THREE.ConeGeometry(), new THREE.MeshNormalMaterial()) + m_p_p_c.add(m_p_p_c_c) + + // NOTE: Add a material that isn't allowed in THREE. + const m_p_p_mat = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {}) + const m_p_p_mat_mat = nodeOps.createElement('MeshNormalMaterial', undefined, undefined, {}) + nodeOps.insert(m_p_p_mat, m_p_p) + nodeOps.insert(m_p_p_mat_mat, m_p_p_mat) + + // NOTE: Add a geometry that isn't allowed in THREE. + const m_p_p_mat_geo = nodeOps.createElement('BoxGeometry', undefined, undefined, {}) + nodeOps.insert(m_p_p_mat_geo, m_p_p_mat) + + // NOTE: This relationship is only in THREE, not in Vue + expect(nodeOps.parentNode(m_p_p_c_c)).not.toBe(m_p_p_c) + // NOTE: This relationship is only in THREE, not in Vue + expect(nodeOps.parentNode(m_p_p_c)).not.toBe(m_p_p) + + expect(nodeOps.parentNode(m_p_p)).toBe(m_p) + expect(nodeOps.parentNode(m_p)).toBe(m) + expect(nodeOps.parentNode(m)).toBe(null) + + expect(nodeOps.parentNode(m_p_m)).toBe(m_p) + + expect(nodeOps.parentNode(m_m_p)).toBe(m_m) + expect(nodeOps.parentNode(m_m)).toBe(m) + + expect(nodeOps.parentNode(m_p_p_mat_mat)).toBe(m_p_p_mat) + expect(nodeOps.parentNode(m_p_p_mat)).toBe(m_p_p) + + expect(nodeOps.parentNode(m_p_p_mat_geo)).toBe(m_p_p_mat) + }) + it('does not return THREE parent of a node unless it is a Vue parent', () => { const parent: TresObject = new Scene() const child: TresObject = nodeOps.createElement('Mesh')! - parent.children.push(child) - child.parent = parent + // NOTE: `add` creates a `THREE` parent/children relationship. + // Since it does not add a Vue relationship, `parentNode` + // does not and should not track it. + parent.add(child as THREE.Object3D) + expect(nodeOps.parentNode(child)).not.toBe(parent) - // Test - const parentNode = nodeOps.parentNode(child) + nodeOps.insert(child, parent) + expect(nodeOps.parentNode(child)).toBe(parent) + }) + }) - // Assert - expect(parentNode === parent) + describe('nextSibling', () => { + it('returns null if no node is provided', () => { + expect(nodeOps.nextSibling(undefined)).toBe(null) + expect(nodeOps.nextSibling(null)).toBe(null) + }) + it('returns null if child is last sibling', () => { + const parent = nodeOps.createElement('Mesh') + const firstChild = nodeOps.createElement('MeshNormalMaterial') + const lastChild = nodeOps.createElement('MeshNormalMaterial') + nodeOps.insert(firstChild, parent) + nodeOps.insert(lastChild, parent) + expect(nodeOps.nextSibling(lastChild)).toBe(null) + }) + it('returns null if node is not inserted', () => { + const node = nodeOps.createElement('MeshNormalMaterial') + expect(nodeOps.nextSibling(node)).toBe(null) + }) + it('returns the next Vue sibling', () => { + const parent = nodeOps.createElement('Mesh') + const firstChild = nodeOps.createElement('MeshNormalMaterial') + const lastChild = nodeOps.createElement('MeshNormalMaterial') + nodeOps.insert(firstChild, parent) + nodeOps.insert(lastChild, parent) + expect(nodeOps.nextSibling(firstChild)).toBe(lastChild) + }) + it('returns the next Vue sibling, as a primitive', () => { + const parent = nodeOps.createElement('Mesh') + const { primitive: firstChild } = createElementPrimitiveMesh(nodeOps) + const { primitive: lastChild } = createElementPrimitiveMesh(nodeOps) + nodeOps.insert(firstChild, parent) + nodeOps.insert(lastChild, parent) + expect(parent.__tres.objects).toStrictEqual([firstChild, lastChild]) + expect(nodeOps.nextSibling(firstChild)).toBe(lastChild) + }) + it('returns the next Vue sibling among inserted meshes, materials, geometries', () => { + // NOTE: geometry and material are Vue siblings + const parent = nodeOps.createElement('Mesh') + + const mat0 = nodeOps.createElement('MeshNormalMaterial') + const mat1 = nodeOps.createElement('MeshNormalMaterial') + const geo0 = nodeOps.createElement('BoxGeometry') + const geo1 = nodeOps.createElement('BoxGeometry') + const mesh0 = nodeOps.createElement('Mesh') + const mesh1 = nodeOps.createElement('Mesh') + const { primitive: prim0 } = createElementPrimitiveMesh(nodeOps) + const { primitive: prim1 } = createElementPrimitiveMesh(nodeOps) + + const allSiblings = [mat0, mat1, geo0, geo1, mesh0, mesh1, prim0, prim1] + for (const child of allSiblings) { + nodeOps.insert(child, parent) + } + + for (let i = 0; i < allSiblings.length; i++) { + if (i < allSiblings.length - 1) { + expect(nodeOps.nextSibling(allSiblings[i]).uuid).toBe(allSiblings[i + 1].uuid) + } + else { + // NOTE: Last sibling + expect(nodeOps.nextSibling(allSiblings[i])).toBe(null) + } + } + }) + it('returns the next sibling from a tree of Meshes', () => { + const NUM_LEVEL = 5 + const NUM_CHILD_PER_NODE = 3 + const rootNode = nodeOps.createElement('Mesh') + const nodes = [rootNode] + const nodeToNextSibling = new Map() + + createTreeIn(rootNode, (parent: TresObject, childI, levelI) => { + if (levelI > NUM_LEVEL || childI >= NUM_CHILD_PER_NODE) { + return false + } + const { mesh } = createElementMesh(nodeOps) + nodeOps.insert(mesh, parent) + if (childI > 0) { + nodeToNextSibling.set(parent.__tres.objects[childI - 1], mesh) + } + return mesh + }) + + for (const node of nodes) { + const nextSibling = nodeOps.nextSibling(node) + if (nextSibling) { + expect(nextSibling).toBe(nodeToNextSibling.get(node)) + } + else { + expect(nextSibling).toBeFalsy() + expect(nodeToNextSibling.get(node)).toBeFalsy() + } + } }) }) }) @@ -1226,3 +1587,62 @@ function createTreeIn(root: T, insertCallback: (parent: T, childI: number, le levelII++ } } + +function createSimpleMeshPrimitiveTree(nodeOps) { + // returns a nodeOps-made tree that looks like + // mesh (m) + // mesh (m_m) prim (m_p) + // mesh (m_m_m) prim (m_m_p) mesh (m_p_m) prim (m_p_p) + // THREE child (m_p_p_c) + // -------- + // also returns a "disposalSpy" for each material and geometry + const { mesh: m } = createElementMesh(nodeOps) + const { mesh: m_m } = createElementMesh(nodeOps) + const { mesh: m_m_m } = createElementMesh(nodeOps) + const { mesh: m_p_m } = createElementMesh(nodeOps) + const { primitive: m_p } = createElementPrimitiveMesh(nodeOps) + const { primitive: m_m_p } = createElementPrimitiveMesh(nodeOps) + const { primitive: m_p_p } = createElementPrimitiveMesh(nodeOps) + nodeOps.insert(m_m, m) + nodeOps.insert(m_p, m) + nodeOps.insert(m_m_m, m_m) + nodeOps.insert(m_m_p, m_m) + nodeOps.insert(m_p_m, m_p) + nodeOps.insert(m_p_p, m_p) + + // NOTE: add a THREE child to the primitives. + const m_p_c = new Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial()) + m_p.add(m_p_c) + const m_m_p_c = new Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial()) + m_m_p.add(m_m_p_c) + const m_p_p_c = new Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial()) + m_p_p.add(m_p_p_c) + + // NOTE: add a THREE grandchild to a primitive. + const m_p_p_c_c = new Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial()) + m_p_p_c.add(m_p_p_c_c) + + const nodesByKey = { m, m_m, m_m_m, m_m_p, m_p, m_p_c, m_p_m, m_p_p, m_m_p_c, m_p_p_c, m_p_p_c_c } + const nodes = [] + const objects = [] + const spiesByKey: Partial void, geometry: () => void } + >> = { } + const spies = [] + const undisposed = new Set() + for (const [key, node] of Object.entries(nodesByKey)) { + const geometry = vi.spyOn(node.geometry, 'dispose') + const material = vi.spyOn(node.material, 'dispose') + objects.push(node.geometry, node.material) + spiesByKey[key] = { geometry, material } + spies.push(geometry) + spies.push(material) + nodes.push(node) + undisposed.add(node) + undisposed.add(node.geometry) + undisposed.add(node.material) + } + const result = Object.assign({ spiesByKey }, { spies }, { nodes }, nodesByKey, { nodesByKey }, { objects }) + return result +} diff --git a/src/core/nodeOps.ts b/src/core/nodeOps.ts index 622473015..e71caa416 100644 --- a/src/core/nodeOps.ts +++ b/src/core/nodeOps.ts @@ -1,10 +1,11 @@ -import type { RendererOptions } from 'vue' +import { type RendererOptions, isRef } from 'vue' import { BufferAttribute, Object3D } from 'three' import type { TresContext } from '../composables' import { useLogger } from '../composables' -import { attach, deepArrayEqual, detach, filterInPlace, invalidateInstance, isHTMLTag, kebabToCamel, noop, prepareTresInstance } from '../utils' -import type { InstanceProps, TresInstance, TresObject, TresObject3D } from '../types' +import { attach, deepArrayEqual, doRemoveDeregister, doRemoveDetach, invalidateInstance, isHTMLTag, kebabToCamel, noop, prepareTresInstance, setPrimitiveObject, unboxTresPrimitive } from '../utils' +import type { DisposeType, InstanceProps, LocalState, TresInstance, TresObject, TresObject3D, TresPrimitive } from '../types' import * as is from '../utils/is' +import { createRetargetingProxy } from '../utils/primitive/createRetargetingProxy' import { catalogue } from './catalogue' const { logError } = useLogger() @@ -41,10 +42,28 @@ export const nodeOps: (context: TresContext) => RendererOptions', + ) + } + name = props.object.type + const __tres = {} + const primitive = createRetargetingProxy( + props.object, + { + object: t => t, + isPrimitive: () => true, + __tres: () => __tres, + }, + { + object: (object: TresObject, _, primitive: TresPrimitive, setTarget: (nextObject: TresObject) => void) => { + setPrimitiveObject(object, primitive, setTarget, { patchProp, remove, insert }, context) + }, + __tres: (t: LocalState) => { Object.assign(__tres, t) }, + }, + ) + obj = primitive } else { const target = catalogue.value[name] @@ -68,139 +87,145 @@ export const nodeOps: (context: TresContext) => RendererOptions - // 2) it has :dispose="null" - // 3) it was bailed out by a parent passing `remove(..., false)` - const isPrimitive = node.__tres?.primitive - const isDisposeNull = node.dispose === null - const isBailedOut = dispose === false - const shouldDispose = !(isPrimitive || isDisposeNull || isBailedOut) - - // TODO: - // Figure out why `parent` is being set on `node` here - // and remove/refactor. - node.parent = node.parent || scene - - // NOTE: Remove `node` from __tres parent/objects graph - const parent = node.__tres?.parent || scene - if (node.__tres) { node.__tres.parent = null } - if (parent.__tres && 'objects' in parent.__tres) { - filterInPlace(parent.__tres.objects, obj => obj !== node) + // NOTE: Derive `dispose` value for this `remove` call and + // recursive remove calls. + dispose = is.und(dispose) ? 'default' : dispose + const userDispose = node.__tres?.dispose + if (!is.und(userDispose)) { + if (userDispose === null) { + // NOTE: Treat as `false` to act like R3F + dispose = false + } + else { + // NOTE: Otherwise, if the user has defined a `dispose`, use it + dispose = userDispose + } } - // NOTE: THREE.removeFromParent removes `node` from - // `parent.children`. - if (node.__tres?.attach) { - detach(parent, node, node.__tres.attach) + // NOTE: Create a `shouldDispose` boolean for readable predicates below. + // 1) If `dispose` is "default", then: + // - dispose declarative components, e.g., + // - do *not* dispose primitives or their non-declarative children + // 2) Otherwise, follow `dispose` + const isPrimitive = node.__tres?.primitive + const shouldDispose = dispose === 'default' ? !isPrimitive : !!(dispose) + + // NOTE: This function has 5 stages: + // 1) Recursively remove `node`'s children + // 2) Detach `node` from its parent + // 3) Deregister `node` with `context` and invalidate + // 4) Dispose `node` + // 5) Remove `node`'s `LocalState` + + // NOTE: 1) Recursively remove `node`'s children + // NOTE: Remove declarative children. + if (node.__tres && 'objects' in node.__tres) { + // NOTE: In the recursive `remove` calls, the array elements + // will remove themselves from the array, resulting in skipped + // elements. Make a shallow copy of the array. + [...node.__tres.objects].forEach(obj => remove(obj, dispose)) } - else { - node.removeFromParent?.() + + // NOTE: Remove remaining THREE children. + // On primitives, we do not remove THREE children unless disposing. + // Otherwise we would alter the user's `:object`. + if (shouldDispose) { + // NOTE: In the recursive `remove` calls, the array elements + // will remove themselves from the array, resulting in skipped + // elements. Make a shallow copy of the array. + if (node.children) { + [...node.children].forEach(child => remove(child, dispose)) + } } - // NOTE: Deregister `node` THREE.Object3D children - node.traverse?.((child) => { - context.deregisterCamera(child) - // deregisterAtPointerEventHandlerIfRequired?.(child as TresObject) - context.eventManager?.deregisterPointerMissedObject(child) - }) + // NOTE: 2) Detach `node` from its parent + doRemoveDetach(node, context) - // NOTE: Deregister `node` - context.deregisterCamera(node) - /* deregisterAtPointerEventHandlerIfRequired?.(node as TresObject) */ - invalidateInstance(node as TresObject) + // NOTE: 3) Deregister `node` THREE.Object3D children and invalidate `node` + doRemoveDeregister(node, context) - // TODO: support removing `attach`ed components - - // NOTE: Recursively `remove` children and objects. - // Never on primitives: - // - removing children would alter the primitive :object. - // - primitives are not expected to have declarative children - // and so should not have `objects`. - if (!isPrimitive) { - // NOTE: In recursive `remove`, the array elements will - // remove themselves from these arrays, resulting in - // skipped elements. Make shallow copies of the arrays. - if (node.children) { - [...node.children].forEach(child => remove(child, shouldDispose)) + // NOTE: 4) Dispose `node` + if (shouldDispose && !is.scene(node)) { + if (is.fun(dispose)) { + dispose(node as TresInstance) } - if (node.__tres && 'objects' in node.__tres) { - [...node.__tres.objects].forEach(obj => remove(obj, shouldDispose)) + else if (is.fun(node.dispose)) { + try { + node.dispose() + } + catch (e) { + // NOTE: We must try/catch here. We want to remove/dispose + // Vue/THREE children in bottom-up order. But THREE objects + // will e.g., call `this.material.dispose` without checking + // if the material exists, leading to an error. + // See issue #721: + // https://github.com/Tresjs/tres/issues/721 + // Cannot read properties of undefined (reading 'dispose') - GridHelper + } } } - // NOTE: Dispose `node` - if (shouldDispose && node.dispose && !is.scene(node)) { - node.dispose() + // NOTE: 5) Remove `LocalState` + if ('__tres' in node) { + delete node.__tres } - - delete node.__tres } function patchProp(node: TresObject, prop: string, prevValue: any, nextValue: any) { @@ -209,6 +234,9 @@ export const nodeOps: (context: TresContext) => RendererOptions RendererOptions RendererOptions RendererOptions RendererOptions= siblings.length - 1) { return null } - return parent.children[index + 1] || null + return siblings[index + 1] } return { diff --git a/src/types/index.ts b/src/types/index.ts index afbfbd91b..5df7ed9d4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,7 @@ import type { TresContext } from '../composables/useTresContextProvider' export type AttachFnType = (parent: any, self: TresInstance) => () => void export type AttachType = string | AttachFnType +export type DisposeType = ((self: TresInstance) => void) | boolean | 'default' export type ConstructorRepresentation = new (...args: any[]) => any export type NonFunctionKeys

= { [K in keyof P]-?: P[K] extends Function ? never : K }[keyof P] @@ -46,16 +47,19 @@ export interface LocalState { memoizedProps: { [key: string]: any } // NOTE: // LocalState holds information about the parent/child relationship - // in the Vue graph. If a child is `insert`ed into a parent using - // anything but THREE's `add`, it's put into the parent's `objects`. - // objects and parent are used when children are added with `attach` - // instead of being added to the Object3D scene graph + // in the Vue graph. Note that this is distinct from THREE's + // Object3D.parent/children graph. parent/objects holds all + // + // + // + // relationships. This includes Object3D.parent/children + // added via tags. But it also includes materials and geometries. objects: TresObject[] parent: TresObject | null // NOTE: End graph info primitive?: boolean - disposable?: boolean + dispose?: DisposeType attach?: AttachType previousAttach: any } @@ -71,6 +75,8 @@ export type TresObject = export type TresInstance = TresObject & { __tres: LocalState } +export type TresPrimitive = TresInstance & { object: TresInstance, isPrimitive: true } + export interface TresScene extends THREE.Scene { __tres: { root: TresContext diff --git a/src/utils/index.ts b/src/utils/index.ts index bde0fbd4e..bb7a7e7e4 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,7 @@ import type { Material, Mesh, Object3D, Texture } from 'three' import { DoubleSide, MeshBasicMaterial, Scene, Vector3 } from 'three' -import type { AttachType, LocalState, TresInstance, TresObject } from 'src/types' +import type { AttachType, LocalState, TresInstance, TresObject, TresPrimitive } from 'src/types' +import type { nodeOps } from 'src/core/nodeOps' import { HightlightMesh } from '../devtools/highlight' import type { TresContext } from '../composables/useTresContextProvider' import * as is from './is' @@ -385,7 +386,7 @@ export function attach(parent: TresInstance, child: TresInstance, type: AttachTy const { target, key } = resolve(parent, type) child.__tres.previousAttach = target[key] - target[key] = child + target[key] = unboxTresPrimitive(child) } else { child.__tres.previousAttach = type(parent, child) @@ -417,6 +418,7 @@ export function detach(parent: any, child: TresInstance, type: AttachType) { export function prepareTresInstance(obj: T, state: Partial, context: TresContext): TresInstance { const instance = obj as unknown as TresInstance + instance.__tres = { type: 'unknown', eventCount: 0, @@ -428,6 +430,13 @@ export function prepareTresInstance(obj: T, state: Partial previousAttach: null, ...state, } + + if (!instance.__tres.attach) { + if (instance.isMaterial) { instance.__tres.attach = 'material' } + else if (instance.isBufferGeometry) { instance.__tres.attach = 'geometry' } + else if (instance.isFog) { instance.__tres.attach = 'fog' } + } + return instance } @@ -445,3 +454,111 @@ export function noop(fn: string): any { // eslint-disable-next-line no-unused-expressions fn } + +export function setPrimitiveObject( + newObject: TresObject, + primitive: TresPrimitive, + setTarget: (object: TresObject) => void, + nodeOpsFns: Pick, 'patchProp' | 'insert' | 'remove'>, + context: TresContext, +) { + // NOTE: copy added/attached Vue children + // We need to insert `objects` into `newObject` later. + // In the meantime, `remove(primitive)` will alter + // the array, so make a copy. + const objectsToAttach = [...primitive.__tres.objects] + + const oldObject = unboxTresPrimitive(primitive) + newObject = unboxTresPrimitive(newObject) + if (oldObject === newObject) { return true } + + const newInstance: TresInstance = prepareTresInstance(newObject, primitive.__tres ?? {}, context) + + // NOTE: `remove`ing `oldInstance` will modify `parent` and `memoizedProps`. + // Copy before removing. + const parent = primitive.parent ?? primitive.__tres.parent ?? null + const propsToPatch = { ...primitive.__tres.memoizedProps } + // NOTE: `object` is a reference to `oldObject` and not to be patched. + delete propsToPatch.object + + // NOTE: detach/deactivate added/attached Vue children, but don't + // otherwise alter them and don't recurse. + for (const obj of objectsToAttach) { + doRemoveDetach(obj, context) + doRemoveDeregister(obj, context) + } + oldObject.__tres.objects = [] + + nodeOpsFns.remove(primitive) + + for (const [key, value] of Object.entries(propsToPatch)) { + nodeOpsFns.patchProp(newInstance, key, newInstance[key], value) + } + + setTarget(newObject) + nodeOpsFns.insert(primitive, parent) + + // NOTE: insert added/attached Vue children + for (const obj of objectsToAttach) { + nodeOpsFns.insert(obj, primitive) + } + + return true +} + +export function unboxTresPrimitive(maybePrimitive: T): T | TresInstance { + if (is.tresPrimitive(maybePrimitive)) { + // NOTE: + // `primitive` has-a THREE object. Multiple `primitive`s can have + // the same THREE object. We want to allow the same THREE object + // to be inserted in the graph in multiple places, where THREE supports + // that, e.g., materials and geometries. + // But __tres (`LocalState`) only allows for a single parent. + // So: copy `__tres` to the object when unboxing. + maybePrimitive.object.__tres = maybePrimitive.__tres + return maybePrimitive.object + } + else { + return maybePrimitive + } +} + +export function doRemoveDetach(node: TresObject, context: TresContext) { + // NOTE: Remove `node` from its parent's __tres parent/objects graph + const parent = node.__tres?.parent || context.scene.value + if (node.__tres) { node.__tres.parent = null } + if (parent && parent.__tres && 'objects' in parent.__tres) { + filterInPlace(parent.__tres.objects, obj => obj !== node) + } + + // NOTE: THREE.removeFromParent removes `node` from + // `parent.children`. + if (node.__tres?.attach) { + detach(parent, node as TresInstance, node.__tres.attach) + } + else { + // NOTE: In case this is a primitive, we added the :object, not + // the primitive. So we "unbox" here to remove the :object. + // If not a primitive, unboxing returns the argument. + node.parent?.remove?.(unboxTresPrimitive(node)) + // NOTE: THREE doesn't set `node.parent` when removing `node`. + // We will do that here to properly maintain the parent/children + // graph as a source of truth. + node.parent = null + } +} + +export function doRemoveDeregister(node: TresObject, context: TresContext) { + // TODO: Refactor as `context.deregister`? + // That would eliminate `context.deregisterCamera`. + node.traverse?.((child) => { + context.deregisterCamera(child) + // deregisterAtPointerEventHandlerIfRequired?.(child as TresObject) + context.eventManager?.deregisterPointerMissedObject(child) + }) + + // NOTE: Deregister `node` + context.deregisterCamera(node) + /* deregisterAtPointerEventHandlerIfRequired?.(node as TresObject) */ + invalidateInstance(node as TresObject) +} diff --git a/src/utils/is.ts b/src/utils/is.ts index 67fa0f70c..06f8a4468 100644 --- a/src/utils/is.ts +++ b/src/utils/is.ts @@ -1,4 +1,4 @@ -import type { TresObject } from 'src/types' +import type { TresObject, TresPrimitive } from 'src/types' import type { BufferGeometry, Camera, Fog, Material, Object3D, Scene } from 'three' export function und(u: unknown) { @@ -13,6 +13,10 @@ export function str(u: unknown): u is string { return typeof u === 'string' } +export function bool(u: unknown): u is boolean { + return u === true || u === false +} + export function fun(u: unknown): u is Function { return typeof u === 'function' } @@ -50,3 +54,7 @@ export function tresObject(u: unknown): u is TresObject { // TresObject3D | THREE.BufferGeometry | THREE.Material | THREE.Fog return object3D(u) || bufferGeometry(u) || material(u) || fog(u) } + +export function tresPrimitive(u: unknown): u is TresPrimitive { + return obj(u) && !!(u.isPrimitive) +} diff --git a/src/utils/primitive/createRetargetingProxy.test.ts b/src/utils/primitive/createRetargetingProxy.test.ts new file mode 100644 index 000000000..aae2860fb --- /dev/null +++ b/src/utils/primitive/createRetargetingProxy.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest' +import { createRetargetingProxy } from './createRetargetingProxy' + +describe('createRetargetingProxy', () => { + describe('const proxy = createRetargetingProxy(target)', () => { + describe('proxy.foo = ...', () => { + it('sets proxy.foo', () => { + const target = { foo: 1 } + const proxy = createRetargetingProxy(target) + proxy.foo = 2 + expect(proxy.foo).toBe(2) + proxy.foo = 999 + expect(proxy.foo).toBe(999) + }) + + it('sets target.foo', () => { + const target = { foo: 1 } + const proxy = createRetargetingProxy(target) + proxy.foo = 2 + expect(target.foo).toBe(2) + proxy.foo = 999 + expect(target.foo).toBe(999) + }) + }) + + describe('proxy.foo', () => { + it('gets target.foo', () => { + const target = { foo: 1 } + const proxy = createRetargetingProxy(target) + expect(proxy.foo).toBe(1) + expect(proxy.foo).toBe(target.foo) + proxy.foo = 2 + expect(proxy.foo).toBe(2) + expect(proxy.foo).toBe(target.foo) + target.foo = 3 + expect(proxy.foo).toBe(3) + expect(proxy.foo).toBe(target.foo) + }) + }) + }) + + describe('createRetargetingProxy(_, getters)', () => { + it('calls getter[\'foo\'] on \'result.foo\'', () => { + const spy = vi.fn(() => 'bar') + const getters = { foo: spy } + const target = { } + const proxy = createRetargetingProxy(target, getters) + const bar = (proxy as any).foo + expect(spy).toBeCalled() + expect(bar).toBe('bar') + }) + it('calls getter[\'foo\'] with target', () => { + const spy = vi.fn(target => `bar${target.foo}`) + const getters = { foo: spy } + const setters = { object: (newTarget, _, __, setTarget) => setTarget(newTarget) } + const target0 = { foo: 'baz' } + const proxy = createRetargetingProxy(target0, getters, setters) + + const barbaz = (proxy as any).foo + expect(spy).toBeCalledTimes(1) + expect(barbaz).toBe('barbaz') + + const target1 = { foo: 'bar' } + proxy.object = target1 + + const barbar = (proxy as any).foo + expect(spy).toBeCalledTimes(2) + expect(barbar).toBe('barbar') + }) + it('returns true for (\'foo\' in proxy), if (\'foo\' in getter)', () => { + const getters = { foo: vi.fn(() => false) } + const target = { } + const proxy = createRetargetingProxy(target, getters) + expect('foo' in proxy).toBe(true) + }) + }) + + describe('createRetargetingProxy(_, __, setters)', () => { + it('calls setters[\'foo\'], if setting \'foo\'', () => { + const setters = { foo: vi.fn(() => true) } + const target = { foo: 'bar' } + const proxy = createRetargetingProxy(target, {}, setters) + expect(setters.foo).toHaveBeenCalledTimes(0) + + proxy.foo = 'hello' + expect(setters.foo).toHaveBeenCalledTimes(1) + }) + + it('allows a setter to modify a passed value', () => { + const target = { foo: 1, object: null } + const proxy = createRetargetingProxy(target, {}, { + foo: (newValue, currentTarget) => { + currentTarget.foo = newValue + 1000 + return true + }, + }) + expect(proxy.foo).toBe(1) + proxy.foo = 2 + expect(target.foo).toBe(1002) + proxy.foo = 999 + expect(target.foo).toBe(1999) + }) + + it('allows a setter to update a value on the target', () => { + const setters = { + foo: vi.fn((val, target) => { + target.foo = val + return true + }), + } + const target = { foo: 'bar' } + const proxy = createRetargetingProxy(target, {}, setters) + proxy.foo = 'baz' + expect(proxy.foo).toBe('baz') + }) + + it('can `setTarget` in a setter', () => { + const target0 = { foo: 'bar', object: null } + const target1 = { foo: 'baz', object: null } + const setters = { + object: (val, _, __, setTarget) => { + setTarget(val) + return true + }, + } + + const proxy = createRetargetingProxy(target0, {}, setters) + expect(proxy.foo).toBe('bar') + + proxy.object = target1 + expect(proxy.foo).toBe('baz') + + proxy.foo = 'zab' + expect(proxy.foo).toBe('zab') + expect(target1.foo).toBe('zab') + expect(target0.foo).toBe('bar') + }) + + it('does not update oldTarget after retarget', () => { + const oldTarget = { foo: 1, object: null } + const newTarget = { foo: 1 } + const proxy = createRetargetingProxy(oldTarget, {}, { + object: (newValue, _, __, setTarget) => { + setTarget(newValue) + return true + }, + }) + proxy.object = newTarget + proxy.foo = 2 + expect(oldTarget.foo).toBe(1) + expect(newTarget.foo).toBe(2) + proxy.foo = 999 + expect(oldTarget.foo).toBe(1) + expect(newTarget.foo).toBe(999) + }) + + it('does not update oldTarget after retarget, even if proxy had been closed over', () => { + const oldTarget = { foo: 1, object: null } + const newTarget = { foo: 1 } + const proxy = createRetargetingProxy(oldTarget, {}, { + object: (newValue, _, __, setTarget) => { + setTarget(newValue) + return true + }, + }) + const update = () => { + proxy.foo++ + } + update() + expect(oldTarget.foo).toBe(2) + proxy.object = newTarget + update() + expect(oldTarget.foo).toBe(2) + expect(newTarget.foo).toBe(2) + update() + expect(oldTarget.foo).toBe(2) + expect(newTarget.foo).toBe(3) + }) + }) +}) diff --git a/src/utils/primitive/createRetargetingProxy.ts b/src/utils/primitive/createRetargetingProxy.ts new file mode 100644 index 000000000..e8a1ff770 --- /dev/null +++ b/src/utils/primitive/createRetargetingProxy.ts @@ -0,0 +1,38 @@ +export function createRetargetingProxy, K extends keyof T & string & symbol>( + target: T, + getters = {} as Record unknown>, + setters = {} as Partial void) => boolean>>, +) { + let _target = target + + const setTarget = (newTarget: T) => { + _target = newTarget + } + + let proxy = new Proxy({}, {}) as T + + const handler: ProxyHandler = { + has(_: any, key: string | number | symbol) { + return (key in getters) || (key in _target) + }, + get(_: any, prop: keyof T, __: any) { + if (prop in getters) { + return getters[prop](_target) + } + return _target[prop] + }, + set(_: any, prop: K, val: T[K]) { + if (setters[prop]) { + setters[prop](val, _target, proxy, setTarget) + } + else { + _target[prop] = val + } + return true + }, + } + + proxy = new Proxy({}, handler) as T + + return proxy +}