fix(core): reuse custom renderer per canvas to stop HMR duplicates#1400
Open
alvarosabu wants to merge 15 commits intomainfrom
Open
fix(core): reuse custom renderer per canvas to stop HMR duplicates#1400alvarosabu wants to merge 15 commits intomainfrom
alvarosabu wants to merge 15 commits intomainfrom
Conversation
Prevents tests from having to dynamically import roots.ts after vi.resetModules() — the helper now hands back getRoot/hasRoot/deleteRoot resolved against the same module graph as Context.vue, so a stale top-level import cannot silently produce a divergent _roots Map.
Replace reactive ref with a plain mutable so mutation alone does not trigger re-render. Adds an intermediate assertion after the mutation, before the tick bump, confirming the scene is unchanged. This proves the hmrTick.value++ is the actual trigger that causes the internal component to re-read slots.default(). Previously the test passed even when handleHMR was gutted — Vue's native reactivity flow was carrying the slot swap, not the HMR mechanism.
root.renderer is Vue's custom renderer — WebGL teardown is owned by useRendererManager. The render(null) call here only walks the vnode tree and disposes CPU-side three.js state via nodeOps.remove.
The previous 'mounts cleanly with no default slot' test actually passed an empty array via createScene(() => []), which never exercises the slots.default?.() optional-chain — slots.default is defined, it just returns []. Split into two tests: - 'empty default slot' keeps the () => [] case (guards against empty scene.children access) - 'undefined default slot' mounts TresCanvas directly with no children, exercising the actual optional chain that Task 2 introduced
The Task 4 dispose simplification correctly avoided double-dispose on the Vue unmount path (useRendererManager owns renderer teardown there), but also silently lost the behavior on the manual TresCanvasInstance .dispose() call — consumers holding a ref and calling .dispose() expected the previous superset behavior: scene graph walk + renderer .dispose() + forceContextLoss + renderLists.dispose. Bring back the `force` parameter. Internal unmountCanvas still calls dispose(context) (force=false — useRendererManager handles the rest); the exposed method calls dispose(context, true) to preserve the consumer-facing contract.
✅ Deploy Preview for tresjs-lab ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for tresjs-docs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for cientos-tresjs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
@tresjs/cientos
@tresjs/core
@tresjs/eslint-config
@tresjs/leches
@tresjs/nuxt
@tresjs/post-processing
commit: |
This update includes the import of WebGLRenderer from the three.js library in Context.vue, enhancing the component's capabilities for rendering in a WebGL context.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes HMR duplicating scene objects on template edits.
Root cause:
Context.vue#mountCustomRendererwas creating a fresh Vue custom renderer on everyvite:afterUpdateevent. A fresh renderer has no prior vnode tree, so Vue's diff runs againstnulland mounts everything as new while the old scene objects remain parented toscene— duplicates accumulate every HMR tick.Fix (R3F-inspired): One Vue custom renderer per canvas, stored in a module-scoped
_roots: Map<HTMLCanvasElement, TresRoot>. The map survives HMR because module state persists. Onvite:afterUpdate, instead of rebuilding, a reactivehmrTickref is bumped — the internal component's render function tracks it as a dep and re-runs, re-readingslots.default(). Vue's custom renderer then diffs old vs new and callsnodeOps.remove/patchProp/insertto sync the Three.js scene graph in place.What changed
packages/core/src/core/roots.ts— module-scoped per-canvas renderer state (getRoot/setRoot/deleteRoot/hasRoot+TresRoottype)Context.vue#mountCustomRendereris now idempotent — lazily creates one renderer per canvas, reuses on subsequent callsContext.vue#createInternalComponenttakes anhmrTick: Ref<number>and reads it reactively inside the render functionContext.vue#handleHMR→getRoot(canvas).hmrTick.value++Context.vue#unmountCanvasusesroot.renderer.render(null, scene)so Vue walks the vnode tree and disposes vianodeOps.remove, thendeleteRoot(canvas)onBeforeUnmountcleans up thevite:afterUpdatelistener (also fixes a pre-existing leak)registerTresDevtoolsnow runs exactly once per mount instead of every HMR tick (pre-existing duplicate-registration bug quietly fixed)Tests added
packages/core/src/components/TresScene.test/hmr.test.ts(8 tests + 1 documentedit.skip):hmrTickas the load-bearing trigger)<primitive :object="mesh">keeps the same THREE reference)registerTresDevtoolscalled exactly once per mountit.skipforvite:afterUpdatelistener cleanup — mockingimport.meta.hotdoesn't reachContext.vueacrossvi.resetModules(); cleanup is verified by inspection and documented in the test commentPlus
packages/core/src/core/roots.test.tswith 5 unit tests for the map helpers in isolation.Full core suite: 528 passed + 3 skipped.
Breaking changes
None on public API.
TresCanvasInstanceshape is unchanged.TresCanvasInstance.dispose()runtime behavior is preserved — theforceparameter on the internaldisposehelper was restored after a late review caught that the manual-dispose path had silently lost its WebGL teardown.Behavioral note worth calling out in release notes: HMR no longer wipes imperative scene state that was previously getting destroyed on every template edit. Most users will experience this as an improvement (OrbitControls target survives, primitive GLTFs don't re-download). Edge-case code that relied on the implicit reset may behave differently.
Ecosystem impact
@tresjs/cientos@tresjs/postprocessing<EffectComposer>wraps the WebGL render loop. Worth verifying effect pass HMR.@tresjs/nuxt_rootsMap lives at module level. Nuxt's vite-node SSR runtime isolates modules per request, so it should be fine, but warrants an explicit SSR test.@tresjs/rapier@tresjs/lechesTest plan
pnpm --filter @tresjs/core test)pnpm --filter @tresjs/core lint/build)/issues/23-hmr-disposal:scene.children.lengthcounter stable<TresMesh><TresSphereGeometry /></TresMesh>→ counter goes up by exactly 1, no duplicatesModel.vuescript → no duplicatesscene.children→ no orphans@tresjs/nuxtSSR sanity checkFollow-ups (not this PR)
it.skipwith an integration test via asrc/core/viteHot.tsshimdispose→disposeSceneGraphor extractingmountCustomRendererintocreateTresRoot.ts@tresjs/nuxtCloses #23