···9090 return null;
9191}
92929393-// Locks are tracked as a stack of holders: the first holder locks the
9494-// page and only the last holder to unlock restores it.
9595-let __scrollLockHolders = [];
9696-let __lockedContainer = null;
9393+class ScrollLockManager {
9494+ #getContainer = null;
9595+ #leases = new Set();
9696+ #lockedContainer = null;
97979898-export class ScrollLock {
9999- constructor(target) {
100100- this.target = target ?? null;
101101- this.locked = false;
102102- this._lockedAncestor = null;
103103- this._previousAncestorOverflow = "";
9898+ setContainerProvider(getContainer) {
9999+ this.#getContainer = getContainer;
104100 }
105101106106- lock() {
107107- if (this.locked) {
108108- return;
109109- }
110110- const container = document.querySelector(".page-visible"); // todo find better way to get container
111111- if (!container) {
112112- console.warn(
113113- "ScrollLock: no .page-visible container found; skipping lock",
114114- );
115115- return;
116116- }
117117- if (__scrollLockHolders.length === 0) {
102102+ acquire({ target = null } = {}) {
103103+ let released = false;
104104+ let lockedAncestor = null;
105105+ let previousAncestorOverflow = "";
106106+ const release = ({ restoreScroll = true } = {}) => {
107107+ if (released) return;
108108+ released = true;
109109+110110+ if (lockedAncestor) {
111111+ lockedAncestor.style.overflow = previousAncestorOverflow;
112112+ lockedAncestor = null;
113113+ previousAncestorOverflow = "";
114114+ }
115115+116116+ this.#leases.delete(release);
117117+ if (this.#leases.size === 0 && this.#lockedContainer) {
118118+ unlockScroll(this.#lockedContainer, { restoreScroll });
119119+ this.#lockedContainer = null;
120120+ }
121121+ };
122122+123123+ if (this.#leases.size === 0) {
124124+ const container = this.#getContainer?.();
125125+ if (!container) {
126126+ console.warn(
127127+ "ScrollLock: no current page container found; skipping lock",
128128+ );
129129+ return { release };
130130+ }
118131 lockScroll(container);
119119- __lockedContainer = container;
132132+ this.#lockedContainer = container;
120133 }
121121- __scrollLockHolders.push(this);
122122- // If target is passed, lock the nearest scrollable ancestor of that target in addition to the outer page
123123- const ancestor = this.target ? findScrollableAncestor(this.target) : null;
134134+135135+ this.#leases.add(release);
136136+ // Also prevent scroll in the nearest scrollable ancestor of the trigger.
137137+ const ancestor = target ? findScrollableAncestor(target) : null;
124138 if (ancestor) {
125125- this._lockedAncestor = ancestor;
126126- this._previousAncestorOverflow = ancestor.style.overflow;
139139+ lockedAncestor = ancestor;
140140+ previousAncestorOverflow = ancestor.style.overflow;
127141 ancestor.style.overflow = "hidden";
128142 }
129129- this.locked = true;
130130- }
131143132132- unlock({ restoreScroll = true } = {}) {
133133- if (!this.locked) {
134134- return;
135135- }
136136- if (this._lockedAncestor) {
137137- this._lockedAncestor.style.overflow = this._previousAncestorOverflow;
138138- this._lockedAncestor = null;
139139- this._previousAncestorOverflow = "";
140140- }
141141- __scrollLockHolders = __scrollLockHolders.filter(
142142- (holder) => holder !== this,
143143- );
144144- if (__scrollLockHolders.length === 0) {
145145- unlockScroll(__lockedContainer, { restoreScroll });
146146- __lockedContainer = null;
147147- }
148148- this.locked = false;
144144+ return { release };
149145 }
150146}
147147+148148+export const scrollLocks = new ScrollLockManager();
-1
src/js/views/postThread.view.js
···267267 <div class="post-thread-reply-chains">
268268 ${replyChains.map((replyChain, i) =>
269269 // there can be a lot of images in a reply chain, so lazy load them after the first few
270270- // TODO: infinite scroll for reply chains? or use v2 endpoint?
271270 replyChainTemplate({
272271 replyChain,
273272 currentUser,
+3-1
src/js/views/profile.view.js
···302302 <p>
303303 This account has requested that users sign in to view their profile.
304304 </p>
305305- <button @click=${() => window.router.back()}>Go back</button>
305305+ <button class="rounded-button" @click=${() => window.router.back()}>
306306+ Go back
307307+ </button>
306308 </div>
307309 `;
308310 }
···111111112112globalThis.Element = globalThis.window.Element;
113113114114+// Components exercise scroll locking without the app bootstrap, so supply the
115115+// same active-page boundary through the test fixture.
116116+const { scrollLocks } = await import("/js/scrollLocks.js");
117117+scrollLocks.setContainerProvider(() => document.querySelector(".page-visible"));
118118+114119class HighlightStub {
115120 constructor(...ranges) {
116121 this._ranges = new Set(ranges);