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: {
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();
},
},
};
</script>

View File

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