Browse Source

fix(primitive): implement as proxy to avoid breaking references (#764)

* test: add insert/remove tests

* feat: add filterInPlace

* refactor: make some LocalState fields non-optional

* test: add LocalState graph tests

* refactor: add prepare() to add __tres field

* refactor: add TODOs

* refactor: maintain parent/objects relationship in __tres

* test: add dispose=null test

* feat: allow "dispose=null" to bail out tree disposal

* refactor: update  comments

* refactor: add todo

* test: add/unskip  tests

* refactor(nodeOps): move helper functions to new file

* test: add primitive tests

* refactor: move nodeOpsUtils to utils

* feat: add pierced attach/detach

* chore: clean up merge

* chore: lint

* docs: add playground demo

* chore: update demos

* fix: use proxy for primitive

* fix(primitive): add playground

* test: add material swap test

* refactor: remove unused variable

* refactor: rewrite comment

* docs: add attach/detach demo

* test: update test with new function signature

* refactor: format playground demo

* refactor: add isTresContext

* feat: add general purpose dispose function

* fix: do not clone primitive object

* fix: add shallow removal for primitives

* docs: update primitive object playground

* refactor(remove): move detach, deregister to utils

* chore: merge files from main

* docs: add Sparkles playground

* chore: check if node.__tres is not undefined for increasing eventCount

* docs: improve primitives docs

* docs: added shallowRef suggestion for object

* chore: added dvanced disposal to playground

* refactor: add type cast

* refactor(TresContext): remove isTresContext

---------

Co-authored-by: alvarosabu <alvaro.saburido@gmail.com>
andretchen0 1 year ago
parent
commit
f637bf3537

+ 29 - 5
docs/advanced/primitive.md

@@ -1,6 +1,6 @@
 # Primitives
 
-The `<primitive />` 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 `<primitive />` 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 `<primitive />` component is a versatile low-level component in TresJS that
 </script>
 
 <template>
-  <TresCanvas>
-    <primitive :object="meshWithMaterial" />
-  </TresCanvas>
+  <primitive :object="meshWithMaterial" />
 </template>
 ```
 
 ## Props
 
-`object`: This prop expects a three.js Object3D or any of its derived classes. It is the primary object that the `<primitive />` 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 `<primitive />` 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 `<primitive />` component. You can use these events to interact with the object in the scene. See the complete list of events [here](/api/events).
+
+```html
+<template>
+  <primitive
+    :object="meshWithMaterial"
+    @click="onClick"
+    @pointermove="onPointerMove"
+  />
+</template>
+```
+
+## Passing childrens via slots
+
+You can also pass children to the `<primitive />` 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
+<template>
+  <primitive :object="meshWithOnlyGeometry">
+    <MeshBasicMaterial :color="0xff0000" />
+  </primitive>
+</template>
+```
 
 ## Usage with Models
 

+ 57 - 0
playground/src/pages/advanced/disposal/index.vue

@@ -0,0 +1,57 @@
+<script setup lang="ts">
+import { shallowRef } from 'vue'
+import { TresCanvas } from '@tresjs/core'
+
+const boxRef = shallowRef()
+
+const tests = [
+  {
+    getPass: () => {
+      const show = boxRef.value?.show
+      const parentName = boxRef.value?.instance?.parent?.name || null
+      return !show || parentName === 'intended-parent'
+    },
+    msg: 'v-if is false or Box has intended parent',
+  },
+]
+
+const testsRef = shallowRef({ run: () => {} })
+let intervalId: ReturnType<typeof setInterval> | null = null
+onMounted(() => {
+  intervalId = setInterval(() => testsRef.value.run(), 100)
+})
+onUnmounted(() => intervalId && clearInterval(intervalId))
+</script>
+
+<template>
+  <TresCanvas clear-color="gray">
+    <TresPerspectiveCamera :position="[5, 5, 5]" :look-at="[1, 2, 3]" />
+    <TresMesh :position="[1, 2, 3]" name="intended-parent">
+      <TresMesh
+        v-for="(_, i) of Array.from({ length: 8 }).fill(0)"
+        :key="i"
+        :position="[
+          i % 2 ? -0.5 : 0.5,
+          Math.floor(i / 2) % 2 ? -0.5 : 0.5,
+          Math.floor(i / 4) % 2 ? -0.5 : 0.5,
+        ]"
+      >
+        <TresBoxGeometry :args="[0.1, 0.1, 0.1]" />
+        <TresMeshBasicMaterial color="red" />
+      </TresMesh>
+    </TresMesh>
+  </TresCanvas>
+  <OverlayInfo>
+    <h1>Issue #717: v-if</h1>
+    <h2>Setup</h2>
+    <p>
+      In this scene, there is a Box with a <code>v-if</code>. Its <code>v-if</code> value is toggled on and off.
+      When visible, the box's 8 corners should appear at the centers of the red boxes.
+    </p>
+    <h2>Tests</h2>
+    <Tests ref="testsRef" :tests="tests" />
+    <h2>Issue</h2>
+    <a href="https://github.com/Tresjs/tres/issues/706#issuecomment-2146244326">
+      Toggle v-if on a Tres component declared in a separate SFC makes it detach from its parent</a>
+  </OverlayInfo>
+</template>

+ 13 - 0
playground/src/pages/issues/701-cientos-v4/TheExperience.vue

@@ -0,0 +1,13 @@
+<!-- eslint-disable no-console -->
+<script setup lang="ts">
+import { OrbitControls, Sparkles, TorusKnot } from '@tresjs/cientos'
+</script>
+
+<template>
+  <TresPerspectiveCamera />
+  <OrbitControls />
+  <TorusKnot :scale="0.5" :args="[1, 0.35, 128, 32]">
+    <TresMeshBasicMaterial color="black" />
+    <Sparkles />
+  </TorusKnot>
+</template>

+ 35 - 0
playground/src/pages/issues/701-cientos-v4/index.vue

@@ -0,0 +1,35 @@
+<!-- eslint-disable no-console -->
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import TheExperience from './TheExperience.vue'
+</script>
+
+<template>
+  <TresCanvas window-size clear-color="gray">
+    <TheExperience />
+  </TresCanvas>
+
+  <OverlayInfo>
+    <h1>&lt;primitive&gt; in Cientos v4</h1>
+    <h2>Setup</h2>
+    <p>This scene contains a TorusKnot and Cientos' Sparkles component.</p>
+    <h2>Context</h2>
+    <p>Sparkles uses a primitive under the hood. Changes to Tres v4's primitives caused the component to stop working.</p>
+  </OverlayInfo>
+</template>
+
+<style scoped>
+.overlay {
+  position: fixed;
+  max-width: 400px;
+  top: 0px;
+  left: 0px;
+  margin: 10px;
+  padding: 10px;
+  background-color: white;
+  border-radius: 6px;
+  font-family: sans-serif;
+  font-size: 14px;
+  color: #444;
+}
+</style>

+ 363 - 0
playground/src/pages/issues/701/TheExperience.vue

@@ -0,0 +1,363 @@
+<!-- eslint-disable no-console -->
+<script setup lang="ts">
+import { onUnmounted, shallowRef } from 'vue'
+import type { BufferGeometry, Camera, Material } from 'three'
+import {
+  BoxGeometry,
+  Group,
+  Mesh,
+  MeshBasicMaterial,
+  MeshNormalMaterial,
+  PerspectiveCamera,
+  SphereGeometry,
+  TorusGeometry,
+} from 'three'
+import { useLoop } from '@tresjs/core'
+
+const box = (() => {
+  const box = new Mesh(
+    new BoxGeometry(),
+    new MeshBasicMaterial({
+      color: 'red',
+    }),
+  )
+
+  const childBox = new Mesh(
+    new BoxGeometry(),
+    new MeshBasicMaterial({
+      color: 'red',
+    }),
+  )
+
+  childBox.position.x = 1
+  childBox.position.y = 1
+  childBox.scale.set(0.3, 0.3, 0.3)
+
+  box.add(childBox)
+  return box
+})()
+
+const sphere = (() => {
+  const sphere = new Mesh(
+    new SphereGeometry(0.5),
+    new MeshBasicMaterial({
+      color: 'blue',
+    }),
+  )
+
+  const childSphere = new Mesh(
+    new SphereGeometry(),
+    new MeshBasicMaterial({
+      color: 'blue',
+    }),
+  )
+
+  childSphere.position.x = 1
+  childSphere.position.y = 1
+  childSphere.scale.set(0.3, 0.3, 0.3)
+
+  sphere.add(childSphere)
+
+  return sphere
+})()
+
+const group = (() => {
+  const group = new Group()
+  const box0 = new Mesh(new BoxGeometry(), new MeshBasicMaterial({ color: 'red' }))
+  const box1 = new Mesh(new BoxGeometry(), new MeshBasicMaterial({ color: 'blue' }))
+  const box2 = new Mesh(new BoxGeometry(), new MeshBasicMaterial({ color: 'green' }))
+  box1.position.x = 3
+  box2.position.x = 6
+  group.add(box0, box1, box2)
+  return group
+})()
+
+const meshRef = shallowRef<Mesh>(box)
+const primitiveX = shallowRef(0)
+const primitiveY = shallowRef(0)
+const tOrF = shallowRef(false)
+const tOrFSlow = shallowRef(false)
+const tOrFFast = shallowRef(false)
+const elapsed = shallowRef(0)
+
+const pool: {
+  click: Function
+  pos: number[]
+  group: Group
+  mesh: Mesh
+  meshBox: Mesh
+  meshSphere: Mesh
+  meshTorus: Mesh
+  meshMoving: Mesh
+  geo: BufferGeometry
+  geoBox: BufferGeometry
+  mat: Material
+  matBas: Material
+  cam: Camera
+}[] = []
+const COUNT = 10
+for (let i = 0; i < COUNT; i++) {
+  pool.push({
+    click(...rest: unknown[]) {
+      console.log(i, rest)
+    },
+    pos: [(i % 3) - 1, -Math.floor(i / 3) + 1, 0].map(v => v * 3),
+    group: new Group(),
+    mesh: new Mesh(),
+    meshBox: (() => {
+      const parent = new Mesh(new BoxGeometry(), new MeshNormalMaterial())
+      const child = new Mesh(new BoxGeometry(), new MeshNormalMaterial())
+      parent.add(child)
+      child.position.set(1, 1, 1)
+      child.scale.set(0.25, 0.25, 0.25)
+      return parent
+    })(),
+    meshSphere: (() => {
+      const parent = new Mesh(
+        new SphereGeometry(0.5),
+        new MeshNormalMaterial(),
+      )
+      const child = new Mesh(new SphereGeometry(0.5), new MeshNormalMaterial())
+      parent.add(child)
+      child.position.set(1, 1, 1)
+      child.scale.set(0.25, 0.25, 0.25)
+      return parent
+    })(),
+    meshTorus: (() => {
+      const parent = new Mesh(new TorusGeometry(0.5, 0.15), new MeshNormalMaterial())
+      const child = new Mesh(new TorusGeometry(0.5, 0.15), new MeshNormalMaterial())
+      parent.add(child)
+      child.position.set(1, 1, 1)
+      child.scale.set(0.25, 0.25, 0.25)
+      return parent
+    })(),
+    meshMoving: (() => {
+      const parent = new Mesh(new BoxGeometry(), new MeshNormalMaterial())
+      const child = new Mesh(new BoxGeometry(), new MeshNormalMaterial())
+      parent.add(child)
+      child.position.set(1, 1, 1)
+      child.scale.set(0.25, 0.25, 0.25)
+      return parent
+    })(),
+    geo: new SphereGeometry(0.5),
+    geoBox: new BoxGeometry(),
+    mat: new MeshNormalMaterial(),
+    matBas: new MeshBasicMaterial({ color: 'red' }),
+    cam: new PerspectiveCamera(),
+  })
+}
+
+useLoop().onBeforeRender(({ elapsed: _elapsed }) => {
+  meshRef.value = Math.floor(_elapsed) % 2 ? sphere : box
+  sphere.scale.y = Math.sin(_elapsed)
+  primitiveX.value = Math.sin(_elapsed)
+  primitiveY.value = Math.cos(_elapsed)
+
+  tOrF.value = !!(Math.floor(_elapsed) % 2)
+  tOrFSlow.value = !!(Math.floor(_elapsed * 0.25) % 2)
+  tOrFFast.value = !!(Math.floor(_elapsed * 2.5) % 2)
+  for (const entry of pool) {
+    entry.meshMoving.rotation.y = Math.sin(_elapsed)
+  }
+  elapsed.value = _elapsed
+})
+
+onUnmounted(() => {
+  const dispose = (u: unknown) => {
+    if (typeof u !== 'object' || u === null) {
+      return
+    }
+    for (const val in Object.values(u)) {
+      dispose(val)
+    }
+    if ('dispose' in u && typeof u.dispose === 'function') {
+      u.dispose()
+    }
+  }
+
+  for (const entry of pool) {
+    dispose(entry)
+  }
+})
+</script>
+
+<template>
+  <TresPerspectiveCamera :position="[0, 0, 30]" />
+
+  <TresGroup :position="pool[0].pos">
+    <primitive
+      v-if="tOrF"
+      :object="pool[0].group"
+    >
+      <primitive :object="pool[0].mesh" @click="pool[0].click">
+        <primitive :object="pool[0].geo" />
+        <TresMeshNormalMaterial />
+        <TresMesh :position-y="1.5" :scale="-0.25">
+          <TresMeshBasicMaterial color="black" />
+          <TresConeGeometry />
+        </TresMesh>
+      </primitive>
+    </primitive>
+    <TresMesh :position-y="1.25" :scale="0.25">
+      <TresMeshBasicMaterial color="white" />
+      <TresConeGeometry />
+    </TresMesh>
+  </TresGroup>
+
+  <primitive
+    :object="pool[1].group"
+    :position="pool[1].pos"
+    @click="pool[1].click"
+  >
+    <primitive v-if="tOrF" :object="pool[1].mesh">
+      <TresSphereGeometry :args="[0.5]" />
+      <primitive :object="pool[1].mat" />
+      <TresMesh :position-y="1.5" :scale="-0.25">
+        <TresMeshBasicMaterial color="black" />
+        <TresConeGeometry />
+      </TresMesh>
+    </primitive>
+    <TresMesh :position-y="1.25" :scale="0.25">
+      <TresMeshBasicMaterial color="white" />
+      <TresConeGeometry />
+    </TresMesh>
+  </primitive>
+
+  <primitive
+    :object="pool[2].group"
+    :position="pool[2].pos"
+    @click="pool[2].click"
+  >
+    <TresMesh v-if="tOrF" :object="pool[2].mesh">
+      <primitive :object="pool[2].geo" />
+      <primitive :object="pool[2].mat" />
+      <TresMesh :position-y="1.5" :scale="-0.25">
+        <TresMeshBasicMaterial color="black" />
+        <TresConeGeometry />
+      </TresMesh>
+    </TresMesh>
+    <TresMesh :position-y="1.25" :scale="0.25">
+      <TresMeshBasicMaterial color="white" />
+      <TresConeGeometry />
+    </TresMesh>
+  </primitive>
+
+  <primitive
+    :object="pool[3].group"
+    :position="pool[3].pos"
+    @click="pool[3].click"
+  >
+    <primitive :object="tOrF ? pool[3].meshBox : pool[3].meshTorus" />
+    <TresMesh :position-y="1.25" :scale="0.25">
+      <TresMeshBasicMaterial color="white" />
+      <TresConeGeometry />
+      <TresMesh v-if="tOrF" :position-y="1.25" :scale="-1">
+        <TresMeshBasicMaterial color="black" />
+        <TresConeGeometry />
+      </TresMesh>
+    </TresMesh>
+  </primitive>
+
+  <primitive :object="pool[4].group">
+    <primitive
+      :position="pool[4].pos"
+      :object="tOrF ? pool[4].meshBox : pool[4].meshTorus"
+      :rotation-x="primitiveX"
+      @click="pool[4].click"
+    />
+    <TresMesh v-if="tOrF" :position-y="1.5" :scale="-0.25">
+      <TresMeshBasicMaterial color="black" />
+      <TresConeGeometry />
+    </TresMesh>
+    <TresMesh :position-y="1.25" :scale="0.25">
+      <TresMeshBasicMaterial color="white" />
+      <TresConeGeometry />
+    </TresMesh>
+  </primitive>
+
+  <primitive
+    :object="pool[5].group"
+    :position="pool[5].pos"
+    @click="pool[5].click"
+  >
+    <primitive
+      :object="tOrF ? pool[5].meshMoving : pool[5].meshTorus"
+      :rotation-x="primitiveX"
+    />
+    <TresMesh v-if="tOrF" :position-y="1.5" :scale="-0.25">
+      <TresMeshBasicMaterial color="black" />
+      <TresConeGeometry />
+    </TresMesh>
+    <TresMesh :position-y="1.25" :scale="0.25">
+      <TresMeshBasicMaterial color="white" />
+      <TresConeGeometry />
+    </TresMesh>
+  </primitive>
+
+  <primitive
+    :object="pool[6].group"
+    :position="pool[6].pos"
+    @click="pool[6].click"
+  >
+    <primitive :object="pool[6].mesh">
+      <primitive :object="tOrF ? pool[6].geo : pool[6].geoBox" />
+      <primitive :object="pool[6].mat" />
+      <TresMesh v-if="tOrF" :position-y="1.5" :scale="-0.25">
+        <TresMeshBasicMaterial color="black" />
+        <TresConeGeometry />
+      </TresMesh>
+    </primitive>
+    <TresMesh :position-y="1.25" :scale="0.25">
+      <TresMeshBasicMaterial color="white" />
+      <TresConeGeometry />
+    </TresMesh>
+  </primitive>
+
+  <primitive :object="pool[7].group" :position="pool[7].pos">
+    <primitive
+      :object="pool[7].mesh"
+      @click="pool[7].click"
+    >
+      <primitive :object="pool[7].geo" />
+      <primitive :object="tOrF ? pool[7].mat : pool[7].matBas" />
+      <TresMesh v-if="tOrF" :position-y="1.5" :scale="-0.25">
+        <TresMeshBasicMaterial color="black" />
+        <TresConeGeometry />
+      </TresMesh>
+    </primitive>
+    <TresMesh :position-y="1.25" :scale="0.25">
+      <TresMeshBasicMaterial color="white" />
+      <TresConeGeometry />
+    </TresMesh>
+  </primitive>
+
+  <primitive
+    :object="pool[8].group"
+    :position="pool[8].pos"
+    @click="pool[8].click"
+  >
+    <primitive :object="pool[8].mesh">
+      <primitive :object="tOrFSlow ? pool[8].geo : pool[8].geoBox" />
+      <primitive :object="tOrF ? pool[8].mat : pool[8].matBas" />
+      <TresMesh v-if="tOrF" :position-y="1.5" :scale="-0.25">
+        <TresMeshBasicMaterial color="black" />
+        <TresConeGeometry />
+      </TresMesh>
+    </primitive>
+    <TresMesh :position-y="1.25" :scale="0.25">
+      <TresMeshBasicMaterial color="white" />
+      <TresConeGeometry />
+    </TresMesh>
+  </primitive>
+
+  <primitive :position="pool[9].pos" :object="!tOrF ? pool[9].meshTorus : group" @click="pool[9].click">
+    <TresMesh :position-y="1.25" :scale="0.25">
+      <TresMeshBasicMaterial color="white" />
+      <TresConeGeometry />
+    </TresMesh>
+    <TresMesh v-if="tOrF" :position-y="1.5" :scale="-0.25">
+      <TresMeshBasicMaterial color="black" />
+      <TresConeGeometry />
+    </TresMesh>
+  </primitive>
+</template>

File diff suppressed because it is too large
+ 21 - 0
playground/src/pages/issues/701/index.vue


+ 22 - 0
playground/src/pages/issues/749/TheExperience.vue

@@ -0,0 +1,22 @@
+<!-- eslint-disable no-console -->
+<script setup lang="ts">
+import { shallowRef } from 'vue'
+import { useLoop } from '@tresjs/core'
+
+const tOrF = shallowRef(false)
+const tOrFSlow = shallowRef(false)
+
+useLoop().onBeforeRender(({ elapsed: _elapsed }) => {
+  tOrF.value = !!(Math.floor(_elapsed) % 2)
+  tOrFSlow.value = !!(Math.floor(_elapsed * 0.25) % 2)
+})
+</script>
+
+<template>
+  <TresMesh>
+    <TresBoxGeometry v-if="tOrFSlow" />
+    <TresSphereGeometry v-else />
+    <TresMeshNormalMaterial v-if="tOrF" />
+    <TresMeshBasicMaterial v-else color="red" />
+  </TresMesh>
+</template>

+ 23 - 0
playground/src/pages/issues/749/index.vue

@@ -0,0 +1,23 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import TheExperience from './TheExperience.vue'
+</script>
+
+<template>
+  <TresCanvas clear-color="gray">
+    <TresPerspectiveCamera />
+    <TresPointLight :position="[2, 2, 0]" :intensity="10" />
+    <TheExperience />
+  </TresCanvas>
+  <OverlayInfo>
+    <h1>Issue #749: attach-detach</h1>
+    <h2>Setup</h2>
+    <p>
+      In this scene, there is a Mesh.
+      <ul>
+        <li>It should switch between a box and a sphere.</li>
+        <li>It should switch between red and "MeshNormalMaterial".</li>
+      </ul>
+    </p>
+  </OverlayInfo>
+</template>

+ 5 - 0
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'),
+  },
 ]

+ 15 - 0
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: <primitive> 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'),
+  },
 ]

File diff suppressed because it is too large
+ 685 - 324
src/core/nodeOps.test.ts


+ 132 - 130
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<TresObject, Tres
     let obj: TresObject | null
 
     if (tag === 'primitive') {
-      if (props?.object === undefined) { logError('Tres primitives need a prop \'object\'') }
-      const object = props.object as TresObject
-      name = object.type
-      obj = Object.assign(object.clone(), { type: name }) as TresObject
+      if (!is.obj(props.object) || isRef(props.object)) {
+        logError(
+          'Tres primitives need an \'object\' prop, whose value is an object or shallowRef<object>',
+        )
+      }
+      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<TresObject, Tres
       }
     }
 
-    const instance = prepareTresInstance(obj, {
+    obj = prepareTresInstance(obj, {
       ...obj.__tres,
       type: name,
       memoizedProps: props,
       eventCount: 0,
-      disposable: true,
       primitive: tag === 'primitive',
       attach: props.attach,
     }, context)
 
-    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' }
-    }
-
-    // determine whether the material was passed via prop to
-    // prevent it's disposal when node is removed later in it's lifecycle
-    if (instance.isObject3D && (props?.material || props?.geometry)) {
-      instance.__tres.disposable = false
-    }
-
     return obj as TresObject
   }
 
   function insert(child: TresObject, parent: TresObject) {
     if (!child) { return }
+
+    // TODO: Investigate and eventually remove `scene` fallback.
+    // According to the signature, `parent` should always be
+    // truthy. If it is not truthy, it may be due to a bug
+    // elsewhere in Tres.
     parent = parent || scene
     const childInstance: TresInstance = (child.__tres ? child as TresInstance : prepareTresInstance(child, {}, context))
     const parentInstance: TresInstance = (parent.__tres ? parent as TresInstance : prepareTresInstance(parent, {}, context))
+    child = unboxTresPrimitive(childInstance)
+    parent = unboxTresPrimitive(parentInstance)
 
     context.registerCamera(child)
     // NOTE: Track onPointerMissed objects separate from the scene
     context.eventManager?.registerPointerMissedObject(child)
 
-    let insertedWithAdd = false
     if (childInstance.__tres.attach) {
       attach(parentInstance, childInstance, childInstance.__tres.attach)
     }
     else if (is.object3D(child) && is.object3D(parentInstance)) {
       parentInstance.add(child)
-      insertedWithAdd = true
       child.dispatchEvent({ type: 'added' })
     }
 
     // NOTE: Update __tres parent/objects graph
     childInstance.__tres.parent = parentInstance
-    if (parentInstance.__tres?.objects && !insertedWithAdd) {
-      if (!parentInstance.__tres.objects.includes(child)) {
-        parentInstance.__tres.objects.push(child)
-      }
+    if (parentInstance.__tres.objects && !parentInstance.__tres.objects.includes(childInstance)) {
+      parentInstance.__tres.objects.push(childInstance)
     }
   }
 
-  function remove(node: TresObject | null, dispose?: boolean) {
+  /**
+   * @param node – the node root to remove
+   * @param dispose – the disposal type
+   */
+  function remove(node: TresObject | null, dispose?: DisposeType) {
     // NOTE: `remove` is initially called by Vue only on
-    // the root `node` of the tree to be removed. Vue does not
-    // pass a `dispose` argument.
-    // Where appropriate, we will recursively call `remove`
-    // on `children` and `__tres.objects`.
-    // We will derive and pass a value for `dispose`, allowing
-    // nodes to "bail out" of disposal for their subtree.
+    // the root `node` of the tree to be removed. We will
+    // recursively call the function on children, if necessary.
+    // NOTE: Vue does not pass a `dispose` argument; it is
+    // used by the recursive calls.
 
     if (!node) { return }
 
-    // NOTE: Derive value for `dispose`.
-    // We stop disposal of a node and its tree if any of these are true:
-    // 1) it is a <primitive :object="..." />
-    // 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., <TresMeshNormalMaterial />
+    //   - 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<TresObject, Tres
     let root = node
     let key = prop
 
+    // NOTE: Update memoizedProps with the new value
+    if (node.__tres) { node.__tres.memoizedProps[prop] = nextValue }
+
     if (prop === 'attach') {
       // NOTE: `attach` is not a field on a TresObject.
       // `nextValue` is a string representing how Tres
@@ -221,32 +249,11 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
       return
     }
 
-    if (node.__tres?.primitive && key === 'object' && prevValue !== null) {
-      // If the prop 'object' is changed, we need to re-instance the object and swap the old one with the new one
-      const newInstance = createElement('primitive', undefined, undefined, {
-        object: nextValue,
-      })
-      for (const subkey in newInstance) {
-        if (subkey === 'uuid') { continue }
-        const target = node[subkey]
-        const value = newInstance[subkey]
-        if (!target?.set && !is.fun(target)) { node[subkey] = value }
-        else if (target.constructor === value.constructor && target?.copy) { target?.copy(value) }
-        else if (Array.isArray(value)) { target.set(...value) }
-        else if (!target.isColor && target.setScalar) { target.setScalar(value) }
-        else { target.set(value) }
-      }
-      if (newInstance?.__tres) {
-        newInstance.__tres.root = context
-      }
-      // This code is needed to handle the case where the prop 'object' type change from a group to a mesh or vice versa, otherwise the object will not be rendered correctly (models will be invisible)
-      if (newInstance?.isGroup) {
-        node.geometry = undefined
-        node.material = undefined
-      }
-      else {
-        delete node.isGroup
-      }
+    if (prop === 'dispose') {
+      // NOTE: Add node.__tres, if necessary.
+      if (!node.__tres) { node = prepareTresInstance(node, {}, context) }
+      node.__tres!.dispose = nextValue
+      return
     }
 
     if (is.object3D(node) && key === 'blocks-pointer-events') {
@@ -255,7 +262,7 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
       return
     }
     // Has events
-    if (supportedPointerEvents.includes(prop)) {
+    if (supportedPointerEvents.includes(prop) && node.__tres) {
       node.__tres.eventCount += 1
     }
     let finalKey = kebabToCamel(key)
@@ -327,7 +334,7 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
   }
 
   function parentNode(node: TresObject): TresObject | null {
-    return node?.parent || null
+    return node?.__tres?.parent || null
   }
 
   /**
@@ -340,27 +347,22 @@ export const nodeOps: (context: TresContext) => RendererOptions<TresObject, Tres
    * @returns TresObject
    */
   function createComment(comment: string): TresObject {
-    const commentObj = new Object3D() as TresObject
-
-    // Set name and type to comment
     // TODO: Add a custom type for comments instead of reusing Object3D. Comments should be light weight and not exist in the scene graph
+    const commentObj = prepareTresInstance(new Object3D(), { type: 'Comment' }, context)
     commentObj.name = comment
-    commentObj.__tres = { type: 'Comment' }
-
-    // Without this we have errors in other nodeOp functions that come across this object
-    commentObj.__tres.root = scene?.__tres.root as TresContext
-
     return commentObj
   }
 
   // nextSibling - Returns the next sibling of a TresObject
   function nextSibling(node: TresObject) {
-    if (!node) { return null }
+    const parent = parentNode(node)
+    const siblings = parent?.__tres?.objects || []
+    const index = siblings.indexOf(node)
 
-    const parent = node.parent || scene
-    const index = parent.children.indexOf(node)
+    // NOTE: If not found OR this is the last of the siblings ...
+    if (index < 0 || index >= siblings.length - 1) { return null }
 
-    return parent.children[index + 1] || null
+    return siblings[index + 1]
   }
 
   return {

+ 11 - 5
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<P> = { [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
+  // <parent>
+  //   <object />
+  // </parent>
+  // 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

+ 119 - 2
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<T extends TresObject>(obj: T, state: Partial<LocalState>, context: TresContext): TresInstance {
   const instance = obj as unknown as TresInstance
+
   instance.__tres = {
     type: 'unknown',
     eventCount: 0,
@@ -428,6 +430,13 @@ export function prepareTresInstance<T extends TresObject>(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<ReturnType<typeof nodeOps>, '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<T>(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)
+}

+ 9 - 1
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)
+}

+ 180 - 0
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)
+    })
+  })
+})

+ 38 - 0
src/utils/primitive/createRetargetingProxy.ts

@@ -0,0 +1,38 @@
+export function createRetargetingProxy<T extends Record<string | number | symbol, any>, K extends keyof T & string & symbol>(
+  target: T,
+  getters = {} as Record<string | number | symbol, (t: T) => unknown>,
+  setters = {} as Partial<Record<K, (val: T[K], t: T, proxy: T, setTarget: (newTarget: T) => void) => boolean>>,
+) {
+  let _target = target
+
+  const setTarget = (newTarget: T) => {
+    _target = newTarget
+  }
+
+  let proxy = new Proxy({}, {}) as T
+
+  const handler: ProxyHandler<any> = {
+    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
+}

Some files were not shown because too many files changed in this diff