diff --git a/frontend/src/components/falukant/House3D.vue b/frontend/src/components/falukant/House3D.vue index fb0b5c8..594ba84 100644 --- a/frontend/src/components/falukant/House3D.vue +++ b/frontend/src/components/falukant/House3D.vue @@ -14,6 +14,10 @@ export default { props: { model: { type: String, required: true }, label: { type: String, default: '' }, + // List previews must not keep a WebGL context while they are outside the + // scroll viewport. A page with several houses otherwise exhausts the + // browser's per-site context limit after navigating around Falukant. + lazy: { type: Boolean, default: false }, }, data() { return { @@ -22,27 +26,53 @@ export default { renderer: null, renderedModel: null, resizeObserver: null, + visibilityObserver: null, + isVisible: false, + lifecycleToken: 0, loadFailed: false, }; }, watch: { model() { - this.loadModel(); + if (this.isVisible) this.loadModel(); }, }, async mounted() { - await this.init(); - await this.loadModel(); + if (!this.lazy) { + await this.activate(); + return; + } + + this.visibilityObserver = new IntersectionObserver(([entry]) => { + if (entry?.isIntersecting) { + this.activate(); + } else { + this.deactivate(); + } + }, { rootMargin: '120px 0px' }); + this.visibilityObserver.observe(this.$refs.container); }, beforeUnmount() { this.dispose(); }, methods: { + async activate() { + if (this.isVisible) return; + this.isVisible = true; + await this.init(); + if (this.isVisible) await this.loadModel(); + }, + deactivate() { + if (!this.isVisible) return; + this.isVisible = false; + this.disposeRenderer(); + }, async init() { const container = this.$refs.container; if (!container || import.meta.env.VITE_DISABLE_3D === 'true') return; const runtime = await import('@/utils/threeRuntime.js'); + if (!this.isVisible || !this.$refs.container || this.renderer) return; this.scene = markRaw(new runtime.Scene()); this.scene.background = new runtime.Color(0xf7f1e6); this.camera = markRaw(new runtime.PerspectiveCamera(36, 1, 0.1, 100)); @@ -63,6 +93,7 @@ export default { }, async loadModel() { if (!this.scene || !this.renderer || !this.model) return; + const lifecycleToken = this.lifecycleToken; this.loadFailed = false; this.removeModel(); @@ -78,6 +109,11 @@ export default { const gltf = await loader.loadAsync(`${MODEL_BASE_PATH}/${this.model}_opt.glb`); dracoLoader.dispose(); + if (!this.isVisible || lifecycleToken !== this.lifecycleToken || !this.scene) { + this.disposeObject(gltf.scene); + return; + } + this.renderedModel = markRaw(gltf.scene); const box = new modelRuntime.Box3().setFromObject(this.renderedModel); const size = box.getSize(new modelRuntime.Vector3()); @@ -112,15 +148,22 @@ export default { removeModel() { if (!this.renderedModel) return; this.scene.remove(this.renderedModel); - this.renderedModel.traverse((object) => { + this.disposeObject(this.renderedModel); + this.renderedModel = null; + }, + disposeObject(object3d) { + object3d?.traverse((object) => { object.geometry?.dispose(); const materials = Array.isArray(object.material) ? object.material : [object.material]; materials.filter(Boolean).forEach((material) => material.dispose()); }); - this.renderedModel = null; }, - dispose() { + disposeRenderer() { + // Invalidates in-flight dynamic imports/model loads before they can add + // a renderer or model after this card has left the viewport. + this.lifecycleToken += 1; this.resizeObserver?.disconnect(); + this.resizeObserver = null; this.removeModel(); if (this.renderer) { this.$refs.container?.removeChild(this.renderer.domElement); @@ -134,6 +177,12 @@ export default { this.scene = null; this.camera = null; }, + dispose() { + this.isVisible = false; + this.visibilityObserver?.disconnect(); + this.visibilityObserver = null; + this.disposeRenderer(); + }, }, }; diff --git a/frontend/src/views/falukant/HouseView.vue b/frontend/src/views/falukant/HouseView.vue index ac765d9..032307e 100755 --- a/frontend/src/views/falukant/HouseView.vue +++ b/frontend/src/views/falukant/HouseView.vue @@ -138,6 +138,7 @@ class="house-preview" :model="houseModel(house.houseType)" :label="$t(`falukant.house.type.${house.houseType.labelTr}`)" + lazy />

{{ $t(`falukant.house.type.${house.houseType.labelTr}`) }}