fix(falukant): lazily render house list previews
All checks were successful
Deploy to production / deploy (push) Successful in 1m58s

This commit is contained in:
Torsten Schulz (local)
2026-09-29 12:10:43 +02:00
parent e45d02673f
commit 01752ddb3e
2 changed files with 56 additions and 6 deletions

View File

@@ -14,6 +14,10 @@ export default {
props: { props: {
model: { type: String, required: true }, model: { type: String, required: true },
label: { type: String, default: '' }, 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() { data() {
return { return {
@@ -22,27 +26,53 @@ export default {
renderer: null, renderer: null,
renderedModel: null, renderedModel: null,
resizeObserver: null, resizeObserver: null,
visibilityObserver: null,
isVisible: false,
lifecycleToken: 0,
loadFailed: false, loadFailed: false,
}; };
}, },
watch: { watch: {
model() { model() {
this.loadModel(); if (this.isVisible) this.loadModel();
}, },
}, },
async mounted() { async mounted() {
await this.init(); if (!this.lazy) {
await this.loadModel(); 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() { beforeUnmount() {
this.dispose(); this.dispose();
}, },
methods: { 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() { async init() {
const container = this.$refs.container; const container = this.$refs.container;
if (!container || import.meta.env.VITE_DISABLE_3D === 'true') return; if (!container || import.meta.env.VITE_DISABLE_3D === 'true') return;
const runtime = await import('@/utils/threeRuntime.js'); const runtime = await import('@/utils/threeRuntime.js');
if (!this.isVisible || !this.$refs.container || this.renderer) return;
this.scene = markRaw(new runtime.Scene()); this.scene = markRaw(new runtime.Scene());
this.scene.background = new runtime.Color(0xf7f1e6); this.scene.background = new runtime.Color(0xf7f1e6);
this.camera = markRaw(new runtime.PerspectiveCamera(36, 1, 0.1, 100)); this.camera = markRaw(new runtime.PerspectiveCamera(36, 1, 0.1, 100));
@@ -63,6 +93,7 @@ export default {
}, },
async loadModel() { async loadModel() {
if (!this.scene || !this.renderer || !this.model) return; if (!this.scene || !this.renderer || !this.model) return;
const lifecycleToken = this.lifecycleToken;
this.loadFailed = false; this.loadFailed = false;
this.removeModel(); this.removeModel();
@@ -78,6 +109,11 @@ export default {
const gltf = await loader.loadAsync(`${MODEL_BASE_PATH}/${this.model}_opt.glb`); const gltf = await loader.loadAsync(`${MODEL_BASE_PATH}/${this.model}_opt.glb`);
dracoLoader.dispose(); dracoLoader.dispose();
if (!this.isVisible || lifecycleToken !== this.lifecycleToken || !this.scene) {
this.disposeObject(gltf.scene);
return;
}
this.renderedModel = markRaw(gltf.scene); this.renderedModel = markRaw(gltf.scene);
const box = new modelRuntime.Box3().setFromObject(this.renderedModel); const box = new modelRuntime.Box3().setFromObject(this.renderedModel);
const size = box.getSize(new modelRuntime.Vector3()); const size = box.getSize(new modelRuntime.Vector3());
@@ -112,15 +148,22 @@ export default {
removeModel() { removeModel() {
if (!this.renderedModel) return; if (!this.renderedModel) return;
this.scene.remove(this.renderedModel); this.scene.remove(this.renderedModel);
this.renderedModel.traverse((object) => { this.disposeObject(this.renderedModel);
this.renderedModel = null;
},
disposeObject(object3d) {
object3d?.traverse((object) => {
object.geometry?.dispose(); object.geometry?.dispose();
const materials = Array.isArray(object.material) ? object.material : [object.material]; const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.filter(Boolean).forEach((material) => material.dispose()); 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?.disconnect();
this.resizeObserver = null;
this.removeModel(); this.removeModel();
if (this.renderer) { if (this.renderer) {
this.$refs.container?.removeChild(this.renderer.domElement); this.$refs.container?.removeChild(this.renderer.domElement);
@@ -134,6 +177,12 @@ export default {
this.scene = null; this.scene = null;
this.camera = null; this.camera = null;
}, },
dispose() {
this.isVisible = false;
this.visibilityObserver?.disconnect();
this.visibilityObserver = null;
this.disposeRenderer();
},
}, },
}; };
</script> </script>

View File

@@ -138,6 +138,7 @@
class="house-preview" class="house-preview"
:model="houseModel(house.houseType)" :model="houseModel(house.houseType)"
:label="$t(`falukant.house.type.${house.houseType.labelTr}`)" :label="$t(`falukant.house.type.${house.houseType.labelTr}`)"
lazy
/> />
<div class="house-info"> <div class="house-info">
<h4>{{ $t(`falukant.house.type.${house.houseType.labelTr}`) }}</h4> <h4>{{ $t(`falukant.house.type.${house.houseType.labelTr}`) }}</h4>