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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/playground/src/pages/core/issues/23/Model.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { useGLTF } from '@tresjs/cientos'

const { scene } = await useGLTF('https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/blender-cube.glb', { draco: true })
</script>

<template>
<primitive :object="scene" />
</template>
51 changes: 51 additions & 0 deletions apps/playground/src/pages/core/issues/23/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<!--
HMR regression scene — issue #23
https://github.com/Tresjs/tres/issues/23

Manual verification steps:
1. Navigate to /issues/23-hmr-disposal. Confirm scene.children.length reads
the expected number (ground, box, model group, lights, camera, controls target).
2. Edit THIS template: add <TresMesh><TresSphereGeometry /><TresMeshStandardMaterial /></TresMesh>.
Save. Counter should become N+1. No duplicate box or GLTF.
3. Edit THIS template: remove the sphere. Counter back to N.
4. Rotate with OrbitControls, then edit THIS template again. Controls target
should survive (primitive/reference state preservation).
5. Edit Model.vue script (e.g. change scale). No duplicates.
6. Open devtools → inspect scene.children → confirm no orphaned Three.js objects.
-->

<script setup lang="ts">
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import { computed, ref } from 'vue'
import Model from './Model.vue'

const canvasRef = ref()
const childCount = computed(() => canvasRef.value?.context?.scene?.value?.children?.length ?? 0)
</script>

<template>
<div style="position: fixed; top: 1rem; left: 1rem; z-index: 10; font-family: monospace; background: #000a; color: #fff; padding: 0.5rem 0.75rem; border-radius: 4px;">
scene.children.length: {{ childCount }}
</div>
<TresCanvas ref="canvasRef" window-size clear-color="#1a1a1a">
<TresPerspectiveCamera :position="[3, 3, 5]" :look-at="[0, 0, 0]" />
<OrbitControls />
<TresAmbientLight :intensity="1" />
<TresDirectionalLight :position="[5, 5, 5]" :intensity="2" />

<TresMesh :position="[0, -1, 0]" :rotation="[-Math.PI / 2, 0, 0]">
<TresPlaneGeometry :args="[10, 10]" />
<TresMeshStandardMaterial color="#444" />
</TresMesh>

<TresMesh :position="[-1.5, 0, 0]">
<TresBoxGeometry />
<TresMeshStandardMaterial color="#e56b6f" />
</TresMesh>

<Suspense>
<Model />
</Suspense>
</TresCanvas>
</template>
5 changes: 5 additions & 0 deletions apps/playground/src/router/routes/core/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,9 @@ export const issuesRoutes = [
name: '#1323: render loop delta',
component: () => import('@/pages/core/issues/1323/index.vue'),
},
{
path: '/issues/23-hmr-disposal',
name: '#23: HMR disposal / duplicates',
component: () => import('@/pages/core/issues/23/index.vue'),
},
]
71 changes: 51 additions & 20 deletions packages/core/src/components/Context.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { PerspectiveCamera, Scene, WebGLRenderer } from 'three'
import type { App } from 'vue'
import type { App, Ref } from 'vue'
import type { TresCamera, TresContextWithClock, TresObject, TresPointerEvent, TresScene } from '../types'
import * as THREE from 'three'
import {
Expand All @@ -9,6 +9,7 @@ import {
Fragment,
getCurrentInstance,
h,
onBeforeUnmount,
onMounted,
onUnmounted,
provide,
Expand All @@ -23,6 +24,7 @@ import { INJECTION_KEY as CONTEXT_INJECTION_KEY } from '../composables/useTresCo
import { extend } from '../core/catalogue'
import type { TresCustomRendererOptions } from '../core/nodeOps'
import { nodeOps } from '../core/nodeOps'
import { deleteRoot, getRoot, setRoot } from '../core/roots'
import { isScene } from '../utils/is'
import { disposeObject3D } from '../utils/'
import { registerTresDevtools } from '../devtools'
Expand Down Expand Up @@ -90,7 +92,7 @@ const scene = shallowRef<TresScene | Scene>(new Scene())
const instance = getCurrentInstance()
extend(THREE)

const createInternalComponent = (context: TresContext, empty = false) =>
const createInternalComponent = (context: TresContext, hmrTick: Ref<number>) =>
defineComponent({
setup() {
const ctx = getCurrentInstance()?.appContext
Expand Down Expand Up @@ -127,16 +129,34 @@ const createInternalComponent = (context: TresContext, empty = false) =>
if (typeof window !== 'undefined' && ctx?.app) {
registerTresDevtools(ctx?.app, context)
}
return () => h(Fragment, null, !empty ? slots.default() : [])

return () => {
// Reactive dep: bumping hmrTick in the root forces this render fn to re-run,
// which re-reads slots.default() and lets Vue diff the vnode tree.
// eslint-disable-next-line ts/no-unused-expressions
hmrTick.value
return h(Fragment, null, slots.default?.() ?? [])
}
},
})

const mountCustomRenderer = (context: TresContext, empty = false) => {
const InternalComponent = createInternalComponent(context, empty)
const { render } = createRenderer(nodeOps({ context, options: props.customRendererOptions }))
render(h(InternalComponent), scene.value as unknown as TresObject)
const mountCustomRenderer = (context: TresContext) => {
const canvas = props.canvas
if (getRoot(canvas)) { return }

const hmrTick = shallowRef(0)
const internalComponent = createInternalComponent(context, hmrTick)
const renderer = createRenderer(nodeOps({ context, options: props.customRendererOptions }))
renderer.render(h(internalComponent), scene.value as unknown as TresObject)

setRoot(canvas, { renderer, internalComponent, hmrTick, context })
}

// `force=false` (internal unmount path) only walks the scene graph — WebGL teardown
// is owned by useRendererManager's own onUnmounted, which fires alongside ours.
// `force=true` (manual TresCanvasInstance.dispose() call) additionally tears down the
// WebGLRenderer explicitly, because the manual call path is NOT backed by Vue's
// unmount lifecycle and useRendererManager won't fire on its own.
const dispose = (context: TresContext, force = false) => {
disposeObject3D(context.scene.value as unknown as TresObject)
if (force) {
Expand All @@ -146,7 +166,7 @@ const dispose = (context: TresContext, force = false) => {
context.renderer.instance.forceContextLoss()
}
}
(scene.value as TresScene).__tres = {
;(scene.value as TresScene).__tres = {
root: context,
objects: [],
isUnmounting: true,
Expand All @@ -162,25 +182,30 @@ const context = shallowRef<TresContext>(useTresContextProvider({

defineExpose({ context, dispose: () => dispose(context.value, true) })

const handleHMR = (context: TresContext) => {
// Don't call dispose during HMR - Vue's render will diff and
// unmount old nodes via nodeOps.remove(), which properly disposes them.
// Calling dispose first would delete __tres from objects that Vue
// still needs to access during unmount, breaking sibling tracking.
mountCustomRenderer(context)
// HMR: bump the tick so the internal component re-renders and Vue diffs the slot content
const handleHMR = () => {
const root = getRoot(props.canvas)
if (root) { root.hmrTick.value++ }
}

const unmountCanvas = () => {
// Render empty first to let Vue properly unmount via nodeOps.remove(),
// which handles text nodes and disposes THREE objects. Then dispose remaining resources.
const isTresScene = (value: unknown): value is TresScene => isScene(value) && '__tres' in value
const root = getRoot(props.canvas)
if (!root) { return }

const isTresScene = (value: unknown): value is TresScene => isScene(value) && '__tres' in value
if (isTresScene(scene.value)) {
(scene.value as TresScene).__tres.isUnmounting = true
}

mountCustomRenderer(context.value, true)
// `root.renderer` is Vue's custom renderer, NOT the WebGLRenderer.
// WebGL teardown is owned by `useRendererManager`, which registered its
// own `onUnmounted` earlier in setup and therefore fires first.
// By the time we're here, `render(null)` just walks the vnode tree and
// calls `nodeOps.remove` on every child — which runs user `:dispose`
// handlers and releases CPU-side three.js state.
root.renderer.render(null, scene.value as unknown as TresObject)
dispose(context.value)
deleteRoot(props.canvas)
}

const { camera, renderer } = context.value
Expand Down Expand Up @@ -246,7 +271,7 @@ renderer.loop.onBeforeLoop((loopContext) => {

renderer.onReady(() => {
// Now that renderer is initialized, mount the actual scene with slots
mountCustomRenderer(context.value, false)
mountCustomRenderer(context.value)
emit('ready', context.value)

if (!activeCamera.value) {
Expand All @@ -260,7 +285,13 @@ renderer.onError((error) => {
})

// HMR support
if (import.meta.hot) { import.meta.hot.on('vite:afterUpdate', () => handleHMR(context.value as TresContext)) }
if (import.meta.hot) {
const hmrHandler = () => handleHMR()
import.meta.hot.on('vite:afterUpdate', hmrHandler)
onBeforeUnmount(() => {
import.meta.hot?.off?.('vite:afterUpdate', hmrHandler)
})
}

// warn if the canvas has no area
onMounted(async () => {
Expand Down
Loading
Loading