From e47ba52942e7d54ae096b9af97509725c28c8a9d Mon Sep 17 00:00:00 2001 From: Lin Junrong Date: Fri, 31 Jul 2026 15:51:44 +0800 Subject: [PATCH] fix(Ballpit): bind the resize/visibility handlers once so they can be removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#initObservers` registered the listeners with `this.#onResize.bind(this)` and the teardown removed them with `this.#onResize.bind(this)` again. `.bind()` returns a new function object every call, so `removeEventListener` was handed a function that had never been registered and removed nothing. Every mount therefore leaves a `resize` listener on `window` and a `visibilitychange` listener on `document` attached for the lifetime of the page, and each one keeps `this` alive — the Three.js scene, renderer and canvas with it. Binds both handlers once as class fields and uses those for add and remove. Applied to all four variants. --- src/content/Backgrounds/Ballpit/Ballpit.jsx | 13 +++++++++---- src/tailwind/Backgrounds/Ballpit/Ballpit.jsx | 13 +++++++++---- src/ts-default/Backgrounds/Ballpit/Ballpit.tsx | 13 +++++++++---- src/ts-tailwind/Backgrounds/Ballpit/Ballpit.tsx | 13 +++++++++---- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/content/Backgrounds/Ballpit/Ballpit.jsx b/src/content/Backgrounds/Ballpit/Ballpit.jsx index e9ee9e24..cb06451f 100644 --- a/src/content/Backgrounds/Ballpit/Ballpit.jsx +++ b/src/content/Backgrounds/Ballpit/Ballpit.jsx @@ -42,6 +42,11 @@ class x { onAfterResize = () => {}; #s = false; #n = false; + // Bind once: `.bind()` returns a new function on every call, so binding again + // in the teardown would hand removeEventListener a function that was never + // registered, leaving the listener attached for the lifetime of the page. + #boundResize = this.#f.bind(this); + #boundVisibilityChange = this.#v.bind(this); isDisposed = false; #o; #r; @@ -83,7 +88,7 @@ class x { } #g() { if (!(this.#e.size instanceof Object)) { - window.addEventListener('resize', this.#f.bind(this)); + window.addEventListener('resize', this.#boundResize); if (this.#e.size === 'parent' && this.canvas.parentNode) { this.#r = new ResizeObserver(this.#f.bind(this)); this.#r.observe(this.canvas.parentNode); @@ -95,13 +100,13 @@ class x { threshold: 0 }); this.#o.observe(this.canvas); - document.addEventListener('visibilitychange', this.#v.bind(this)); + document.addEventListener('visibilitychange', this.#boundVisibilityChange); } #y() { - window.removeEventListener('resize', this.#f.bind(this)); + window.removeEventListener('resize', this.#boundResize); this.#r?.disconnect(); this.#o?.disconnect(); - document.removeEventListener('visibilitychange', this.#v.bind(this)); + document.removeEventListener('visibilitychange', this.#boundVisibilityChange); } #u(e) { this.#s = e[0].isIntersecting; diff --git a/src/tailwind/Backgrounds/Ballpit/Ballpit.jsx b/src/tailwind/Backgrounds/Ballpit/Ballpit.jsx index 7b932685..9ff03114 100644 --- a/src/tailwind/Backgrounds/Ballpit/Ballpit.jsx +++ b/src/tailwind/Backgrounds/Ballpit/Ballpit.jsx @@ -42,6 +42,11 @@ class x { onAfterResize = () => {}; #s = false; #n = false; + // Bind once: `.bind()` returns a new function on every call, so binding again + // in the teardown would hand removeEventListener a function that was never + // registered, leaving the listener attached for the lifetime of the page. + #boundResize = this.#f.bind(this); + #boundVisibilityChange = this.#v.bind(this); isDisposed = false; #o; #r; @@ -83,7 +88,7 @@ class x { } #g() { if (!(this.#e.size instanceof Object)) { - window.addEventListener('resize', this.#f.bind(this)); + window.addEventListener('resize', this.#boundResize); if (this.#e.size === 'parent' && this.canvas.parentNode) { this.#r = new ResizeObserver(this.#f.bind(this)); this.#r.observe(this.canvas.parentNode); @@ -95,13 +100,13 @@ class x { threshold: 0 }); this.#o.observe(this.canvas); - document.addEventListener('visibilitychange', this.#v.bind(this)); + document.addEventListener('visibilitychange', this.#boundVisibilityChange); } #y() { - window.removeEventListener('resize', this.#f.bind(this)); + window.removeEventListener('resize', this.#boundResize); this.#r?.disconnect(); this.#o?.disconnect(); - document.removeEventListener('visibilitychange', this.#v.bind(this)); + document.removeEventListener('visibilitychange', this.#boundVisibilityChange); } #u(e) { this.#s = e[0].isIntersecting; diff --git a/src/ts-default/Backgrounds/Ballpit/Ballpit.tsx b/src/ts-default/Backgrounds/Ballpit/Ballpit.tsx index 3f9700f6..52a82d48 100644 --- a/src/ts-default/Backgrounds/Ballpit/Ballpit.tsx +++ b/src/ts-default/Backgrounds/Ballpit/Ballpit.tsx @@ -55,6 +55,11 @@ class X { #animationState = { elapsed: 0, delta: 0 }; #isAnimating: boolean = false; #isVisible: boolean = false; + // Bind once: `.bind()` returns a new function on every call, so binding again + // in the teardown would hand removeEventListener a function that was never + // registered, leaving the listener attached for the lifetime of the page. + #boundResize = this.#onResize.bind(this); + #boundVisibilityChange = this.#onVisibilityChange.bind(this); canvas!: HTMLCanvasElement; camera!: PerspectiveCamera; @@ -123,7 +128,7 @@ class X { #initObservers() { if (!(this.#config.size instanceof Object)) { - window.addEventListener('resize', this.#onResize.bind(this)); + window.addEventListener('resize', this.#boundResize); if (this.#config.size === 'parent' && this.canvas.parentNode) { this.#resizeObserver = new ResizeObserver(this.#onResize.bind(this)); this.#resizeObserver.observe(this.canvas.parentNode as Element); @@ -135,7 +140,7 @@ class X { threshold: 0 }); this.#intersectionObserver.observe(this.canvas); - document.addEventListener('visibilitychange', this.#onVisibilityChange.bind(this)); + document.addEventListener('visibilitychange', this.#boundVisibilityChange); } #onResize() { @@ -283,10 +288,10 @@ class X { } #onResizeCleanup() { - window.removeEventListener('resize', this.#onResize.bind(this)); + window.removeEventListener('resize', this.#boundResize); this.#resizeObserver?.disconnect(); this.#intersectionObserver?.disconnect(); - document.removeEventListener('visibilitychange', this.#onVisibilityChange.bind(this)); + document.removeEventListener('visibilitychange', this.#boundVisibilityChange); } } diff --git a/src/ts-tailwind/Backgrounds/Ballpit/Ballpit.tsx b/src/ts-tailwind/Backgrounds/Ballpit/Ballpit.tsx index 05eb478e..30078ad1 100644 --- a/src/ts-tailwind/Backgrounds/Ballpit/Ballpit.tsx +++ b/src/ts-tailwind/Backgrounds/Ballpit/Ballpit.tsx @@ -55,6 +55,11 @@ class X { #animationState = { elapsed: 0, delta: 0 }; #isAnimating: boolean = false; #isVisible: boolean = false; + // Bind once: `.bind()` returns a new function on every call, so binding again + // in the teardown would hand removeEventListener a function that was never + // registered, leaving the listener attached for the lifetime of the page. + #boundResize = this.#onResize.bind(this); + #boundVisibilityChange = this.#onVisibilityChange.bind(this); canvas!: HTMLCanvasElement; camera!: PerspectiveCamera; @@ -123,7 +128,7 @@ class X { #initObservers() { if (!(this.#config.size instanceof Object)) { - window.addEventListener('resize', this.#onResize.bind(this)); + window.addEventListener('resize', this.#boundResize); if (this.#config.size === 'parent' && this.canvas.parentNode) { this.#resizeObserver = new ResizeObserver(this.#onResize.bind(this)); this.#resizeObserver.observe(this.canvas.parentNode as Element); @@ -135,7 +140,7 @@ class X { threshold: 0 }); this.#intersectionObserver.observe(this.canvas); - document.addEventListener('visibilitychange', this.#onVisibilityChange.bind(this)); + document.addEventListener('visibilitychange', this.#boundVisibilityChange); } #onResize() { @@ -283,10 +288,10 @@ class X { } #onResizeCleanup() { - window.removeEventListener('resize', this.#onResize.bind(this)); + window.removeEventListener('resize', this.#boundResize); this.#resizeObserver?.disconnect(); this.#intersectionObserver?.disconnect(); - document.removeEventListener('visibilitychange', this.#onVisibilityChange.bind(this)); + document.removeEventListener('visibilitychange', this.#boundVisibilityChange); } }