[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Refactor scroll lock and fix global error styling

Grace Kind (Jul 20, 2026, 12:14 AM -0500) fdb9154b ce5b780f

+190 -138
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.18.28", 3 + "version": "0.18.29", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+7 -1
src/index.html
··· 26 26 appRoot.innerHTML = /* html */ ` 27 27 <div class="error-state app-error-state"> 28 28 <div>There was an error loading the app.</div> 29 - <button onclick="window.location.reload()">Try again</button> 29 + <button class="rounded-button" onclick="window.location.reload()"> 30 + Try again 31 + </button> 30 32 </div> 31 33 `; 32 34 } ··· 154 156 import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js"; 155 157 import { IdentityResolver } from "/js/atproto.js"; 156 158 import { Router } from "/js/router.js"; 159 + import { scrollLocks } from "/js/scrollLocks.js"; 157 160 import { Api } from "/js/api.js"; 158 161 import { auth } from "/js/auth.js"; 159 162 import { NotificationService } from "/js/notificationService.js"; ··· 352 355 }; 353 356 354 357 const router = new Router(); 358 + 359 + scrollLocks.setContainerProvider(() => router.currentPage); 360 + 355 361 router.addRoute(["/", "/intent/compose"], () => homeView, { 356 362 layoutOptions: { activeNavItem: "home" }, 357 363 });
+5 -4
src/js/components/account-switcher-dialog.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 - import { ScrollLock } from "/js/scrollLock.js"; 3 + import { scrollLocks } from "/js/scrollLocks.js"; 4 4 import { enableDragToDismiss } from "/js/utils.js"; 5 5 import { auth, getLoginErrorMessage } from "/js/auth.js"; 6 6 import { Signal, ReactiveStore, effect } from "/js/signals.js"; ··· 19 19 return; 20 20 } 21 21 this.setAttribute("data-dialog-wrapper", ""); 22 - this.scrollLock = new ScrollLock(this); 22 + this.scrollLock = null; 23 23 this.state = new ReactiveStore("account-switcher-dialog"); 24 24 this.state.$currentDid = new Signal.State(null); 25 25 this.state.$accounts = new Signal.State(null); ··· 278 278 } 279 279 280 280 open() { 281 - this.scrollLock.lock(); 281 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 282 282 const dialog = this.querySelector("dialog"); 283 283 dialog.showModal(); 284 284 enableDragToDismiss(dialog, { ··· 290 290 } 291 291 292 292 close() { 293 - this.scrollLock.unlock(); 293 + this.scrollLock?.release(); 294 + this.scrollLock = null; 294 295 const dialog = this.querySelector("dialog"); 295 296 if (dialog?.open) { 296 297 dialog.close();
+5 -4
src/js/components/add-to-lists-dialog.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 - import { ScrollLock } from "/js/scrollLock.js"; 3 + import { scrollLocks } from "/js/scrollLocks.js"; 4 4 import { enableDragToDismiss } from "/js/utils.js"; 5 5 import { Signal, SignalSet, ReactiveStore, effect } from "/js/signals.js"; 6 6 import { isModerationList } from "/js/dataHelpers.js"; ··· 13 13 return; 14 14 } 15 15 this.setAttribute("data-dialog-wrapper", ""); 16 - this.scrollLock = new ScrollLock(this); 16 + this.scrollLock = null; 17 17 this.state = new ReactiveStore("add-to-lists-dialog"); 18 18 this.state.$pendingByListUri = new SignalSet(); 19 19 this.state.$loadError = new Signal.State(null); ··· 230 230 } 231 231 232 232 open() { 233 - this.scrollLock.lock(); 233 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 234 234 const dialog = this.querySelector(".add-to-lists-dialog"); 235 235 dialog.showModal(); 236 236 enableDragToDismiss(dialog, { ··· 241 241 } 242 242 243 243 close() { 244 - this.scrollLock.unlock(); 244 + this.scrollLock?.release(); 245 + this.scrollLock = null; 245 246 const dialog = this.querySelector(".add-to-lists-dialog"); 246 247 if (dialog?.open) { 247 248 dialog.close();
+5 -4
src/js/components/animated-sidebar.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component, getChildrenFragment } from "/js/components/component.js"; 3 - import { ScrollLock } from "/js/scrollLock.js"; 3 + import { scrollLocks } from "/js/scrollLocks.js"; 4 4 import { isMobileViewport } from "/js/utils.js"; 5 5 6 6 class AnimatedSidebar extends Component { 7 7 connectedCallback() { 8 8 if (!this._initialized) { 9 9 this.setAttribute("data-dialog-wrapper", ""); 10 - this.scrollLock = new ScrollLock(this); 10 + this.scrollLock = null; 11 11 this.isOpen = false; 12 12 this._children = getChildrenFragment(this); 13 13 this.innerHTML = ""; ··· 59 59 return; 60 60 } 61 61 this.isOpen = true; 62 - this.scrollLock.lock(); 62 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 63 63 this.querySelector("dialog.sidebar").showModal(); 64 64 } 65 65 ··· 68 68 return; 69 69 } 70 70 this.isOpen = false; 71 - this.scrollLock.unlock({ restoreScroll }); 71 + this.scrollLock?.release({ restoreScroll }); 72 + this.scrollLock = null; 72 73 const dialog = this.querySelector("dialog.sidebar"); 73 74 if (dialog.hasAttribute("open")) { 74 75 dialog.close();
+7 -5
src/js/components/context-menu.js
··· 5 5 isMobileViewport, 6 6 } from "/js/utils.js"; 7 7 import { Component, getChildrenFragment } from "/js/components/component.js"; 8 - import { ScrollLock } from "/js/scrollLock.js"; 8 + import { scrollLocks } from "/js/scrollLocks.js"; 9 9 import { hapticsImpactLight } from "/js/haptics.js"; 10 10 11 11 class ContextMenu extends Component { ··· 14 14 return; 15 15 } 16 16 this.setAttribute("data-dialog-wrapper", ""); 17 - this.scrollLock = new ScrollLock(this); 17 + this.scrollLock = null; 18 18 this._childNodes = [...this.childNodes]; 19 19 this.innerHTML = ""; 20 20 this.isOpen = false; ··· 29 29 30 30 disconnectedCallback() { 31 31 // If scroll is still prevented, restore it 32 - this.scrollLock.unlock(); 32 + this.scrollLock?.release(); 33 + this.scrollLock = null; 33 34 if (this._observer) { 34 35 this._observer.disconnect(); 35 36 } ··· 85 86 86 87 open(x, y) { 87 88 hapticsImpactLight(); 88 - this.scrollLock.lock(); 89 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 89 90 90 91 const dialog = this.querySelector(".context-menu"); 91 92 dialog.showModal(); ··· 124 125 } 125 126 126 127 close() { 127 - this.scrollLock.unlock(); 128 + this.scrollLock?.release(); 129 + this.scrollLock = null; 128 130 const dialog = this.querySelector(".context-menu"); 129 131 dialog.close(); 130 132 this.isOpen = false;
+5 -4
src/js/components/drafts-dialog.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 - import { ScrollLock } from "/js/scrollLock.js"; 3 + import { scrollLocks } from "/js/scrollLocks.js"; 4 4 import { displayRelativeTime, enableDragToDismiss } from "/js/utils.js"; 5 5 import { Signal, ReactiveStore, effect, untrack } from "/js/signals.js"; 6 6 import { confirmModal } from "/js/modals/confirm.modal.js"; ··· 165 165 return; 166 166 } 167 167 this.setAttribute("data-dialog-wrapper", ""); 168 - this.scrollLock = new ScrollLock(this); 168 + this.scrollLock = null; 169 169 this.state = new ReactiveStore("drafts-dialog"); 170 170 this.state.$loadError = new Signal.State(false); 171 171 this.state.$isLoadingMore = new Signal.State(false); ··· 323 323 } 324 324 325 325 open() { 326 - this.scrollLock.lock(); 326 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 327 327 const dialog = this.querySelector("dialog"); 328 328 dialog.showModal(); 329 329 enableDragToDismiss(dialog, { ··· 335 335 } 336 336 337 337 close() { 338 - this.scrollLock.unlock(); 338 + this.scrollLock?.release(); 339 + this.scrollLock = null; 339 340 const dialog = this.querySelector("dialog"); 340 341 if (dialog?.open) { 341 342 dialog.close();
+5 -4
src/js/components/edit-profile-dialog.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 - import { ScrollLock } from "/js/scrollLock.js"; 3 + import { scrollLocks } from "/js/scrollLocks.js"; 4 4 import { avatarThumbnailUrl } from "/js/dataHelpers.js"; 5 5 import { 6 6 classnames, ··· 26 26 return; 27 27 } 28 28 this.setAttribute("data-dialog-wrapper", ""); 29 - this.scrollLock = new ScrollLock(this); 29 + this.scrollLock = null; 30 30 this._displayName = ""; 31 31 this._description = ""; 32 32 this._currentAvatar = null; ··· 475 475 476 476 open() { 477 477 this._isOpen = true; 478 - this.scrollLock.lock(); 478 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 479 479 const dialog = this.querySelector(".edit-profile-dialog"); 480 480 if (dialog) { 481 481 dialog.showModal(); ··· 510 510 511 511 close() { 512 512 this._isOpen = false; 513 - this.scrollLock.unlock(); 513 + this.scrollLock?.release(); 514 + this.scrollLock = null; 514 515 const dialog = this.querySelector(".edit-profile-dialog"); 515 516 if (dialog) { 516 517 dialog.close();
+5 -4
src/js/components/emoji-picker-dialog.js
··· 1 1 import { Component } from "/js/components/component.js"; 2 - import { ScrollLock } from "/js/scrollLock.js"; 2 + import { scrollLocks } from "/js/scrollLocks.js"; 3 3 import "/js/lib/emoji-picker-element.js"; 4 4 5 5 class EmojiPickerDialog extends Component { ··· 7 7 if (this._initialized) { 8 8 return; 9 9 } 10 - this.scrollLock = new ScrollLock(this); 10 + this.scrollLock = null; 11 11 this.isOpen = false; 12 12 this._initialized = true; 13 13 } ··· 35 35 }); 36 36 document.body.appendChild(dialog); 37 37 dialog.showModal(); 38 - this.scrollLock.lock(); 38 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 39 39 this.isOpen = true; 40 40 this._dialog = dialog; 41 41 this._picker = picker; ··· 57 57 this._dialog?.remove(); 58 58 this._dialog = null; 59 59 this._anchor = null; 60 - this.scrollLock.unlock(); 60 + this.scrollLock?.release(); 61 + this.scrollLock = null; 61 62 this.isOpen = false; 62 63 } 63 64
+5 -4
src/js/components/image-alt-text-dialog.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 3 import { classnames, graphemeCount, resetScrollOnBlur } from "/js/utils.js"; 4 - import { ScrollLock } from "/js/scrollLock.js"; 4 + import { scrollLocks } from "/js/scrollLocks.js"; 5 5 6 6 class ImageAltTextDialog extends Component { 7 7 connectedCallback() { ··· 9 9 return; 10 10 } 11 11 this.setAttribute("data-dialog-wrapper", ""); 12 - this.scrollLock = new ScrollLock(this); 12 + this.scrollLock = null; 13 13 this.innerHTML = ""; 14 14 this.render(); 15 15 this.initialized = true; ··· 112 112 } 113 113 114 114 open() { 115 - this.scrollLock.lock(); 115 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 116 116 const dialog = this.querySelector(".image-alt-text-dialog"); 117 117 dialog.showModal(); 118 118 this.querySelector(".image-alt-text-dialog-textarea")?.focus({ ··· 126 126 } 127 127 128 128 close() { 129 - this.scrollLock.unlock(); 129 + this.scrollLock?.release(); 130 + this.scrollLock = null; 130 131 const dialog = this.querySelector(".image-alt-text-dialog"); 131 132 dialog.close(); 132 133 this.dispatchEvent(new CustomEvent("alt-text-dialog-closed"));
+5 -4
src/js/components/new-chat-dialog.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 - import { ScrollLock } from "/js/scrollLock.js"; 3 + import { scrollLocks } from "/js/scrollLocks.js"; 4 4 import { enableDragToDismiss } from "/js/utils.js"; 5 5 import { Signal, ReactiveStore, effect } from "/js/signals.js"; 6 6 import { getDisplayName, MISSING_HANDLE } from "/js/dataHelpers.js"; ··· 144 144 } 145 145 this.dataLayer = this.dataLayer ?? null; 146 146 this.setAttribute("data-dialog-wrapper", ""); 147 - this.scrollLock = new ScrollLock(this); 147 + this.scrollLock = null; 148 148 this.state = new ReactiveStore("new-chat-dialog"); 149 149 this.state.$query = new Signal.State(""); 150 150 this.innerHTML = ""; ··· 296 296 } 297 297 298 298 open() { 299 - this.scrollLock.lock(); 299 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 300 300 const dialog = this.querySelector(".new-chat-dialog"); 301 301 dialog.showModal(); 302 302 this.querySelector(".new-chat-search-input")?.focus({ ··· 310 310 } 311 311 312 312 close() { 313 - this.scrollLock.unlock(); 313 + this.scrollLock?.release(); 314 + this.scrollLock = null; 314 315 const dialog = this.querySelector(".new-chat-dialog"); 315 316 if (dialog?.open) { 316 317 dialog.close();
+5 -4
src/js/components/post-composer.js
··· 13 13 } from "/js/utils.js"; 14 14 import { externalLinkTemplate } from "/js/templates/externalLink.template.js"; 15 15 import { confirmModal } from "/js/modals/confirm.modal.js"; 16 - import { ScrollLock } from "/js/scrollLock.js"; 16 + import { scrollLocks } from "/js/scrollLocks.js"; 17 17 import { imageIconTemplate } from "/js/templates/icons/imageIcon.template.js"; 18 18 import { emojiIconTemplate } from "/js/templates/icons/emojiIcon.template.js"; 19 19 import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; ··· 359 359 return; 360 360 } 361 361 this.setAttribute("data-dialog-wrapper", ""); 362 - this.scrollLock = new ScrollLock(this); 362 + this.scrollLock = null; 363 363 this.innerHTML = ""; 364 364 this._draftId = null; 365 365 this._isDirty = false; ··· 1234 1234 } 1235 1235 1236 1236 open() { 1237 - this.scrollLock.lock(); 1237 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 1238 1238 const dialog = this.querySelector(".post-composer"); 1239 1239 dialog.showModal(); 1240 1240 this.querySelector("rich-text-input")?.focus({ preventScroll: true }); ··· 1269 1269 } 1270 1270 1271 1271 close() { 1272 - this.scrollLock.unlock(); 1272 + this.scrollLock?.release(); 1273 + this.scrollLock = null; 1273 1274 const dialog = this.querySelector(".post-composer"); 1274 1275 dialog.close(); 1275 1276 this.dispatchEvent(new CustomEvent("post-composer-closed"));
+5 -4
src/js/components/post-notifications-dialog.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 - import { ScrollLock } from "/js/scrollLock.js"; 3 + import { scrollLocks } from "/js/scrollLocks.js"; 4 4 import { enableDragToDismiss } from "/js/utils.js"; 5 5 import "/js/components/toggle-switch.js"; 6 6 import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; ··· 11 11 return; 12 12 } 13 13 this.setAttribute("data-dialog-wrapper", ""); 14 - this.scrollLock = new ScrollLock(this); 14 + this.scrollLock = null; 15 15 this._postEnabled = this.activitySubscription?.post ?? false; 16 16 this._replyEnabled = this.activitySubscription?.reply ?? false; 17 17 this._isSaving = false; ··· 146 146 } 147 147 148 148 open() { 149 - this.scrollLock.lock(); 149 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 150 150 const dialog = this.querySelector(".post-notifications-dialog"); 151 151 dialog.showModal(); 152 152 ··· 158 158 } 159 159 160 160 close() { 161 - this.scrollLock.unlock(); 161 + this.scrollLock?.release(); 162 + this.scrollLock = null; 162 163 const dialog = this.querySelector(".post-notifications-dialog"); 163 164 if (dialog?.open) { 164 165 dialog.close();
+5 -4
src/js/components/reactions-dialog.js
··· 1 1 import { Component } from "/js/components/component.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 3 import { effect } from "/js/signals.js"; 4 - import { ScrollLock } from "/js/scrollLock.js"; 4 + import { scrollLocks } from "/js/scrollLocks.js"; 5 5 import { enableDragToDismiss } from "/js/utils.js"; 6 6 import { avatarTemplate } from "/js/templates/avatar.template.js"; 7 7 import { getDisplayName, groupReactions } from "/js/dataHelpers.js"; ··· 13 13 this._initialized = true; 14 14 this.setAttribute("data-dialog-wrapper", ""); 15 15 this._activeFilter = "all"; 16 - this.scrollLock = new ScrollLock(this); 16 + this.scrollLock = null; 17 17 this._dispose = effect(() => { 18 18 this.render(); 19 19 }); ··· 21 21 const dialog = this.querySelector(".reactions-dialog"); 22 22 if (dialog && !dialog.open) { 23 23 dialog.showModal(); 24 - this.scrollLock.lock(); 24 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 25 25 enableDragToDismiss(dialog, { 26 26 onClose: () => this._close(), 27 27 scrollContainer: this.querySelector(".reactions-list"), ··· 33 33 34 34 disconnectedCallback() { 35 35 if (this._dispose) this._dispose(); 36 - this.scrollLock?.unlock(); 36 + this.scrollLock?.release(); 37 + this.scrollLock = null; 37 38 } 38 39 39 40 _close() {
+5 -4
src/js/components/report-dialog.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 - import { ScrollLock } from "/js/scrollLock.js"; 3 + import { scrollLocks } from "/js/scrollLocks.js"; 4 4 import { 5 5 enableDragToDismiss, 6 6 kebabCase, ··· 632 632 return; 633 633 } 634 634 this.setAttribute("data-dialog-wrapper", ""); 635 - this.scrollLock = new ScrollLock(this); 635 + this.scrollLock = null; 636 636 this.innerHTML = ""; 637 637 this._stepIndex = 0; 638 638 this._selectedCategory = null; ··· 808 808 } 809 809 810 810 open() { 811 - this.scrollLock.lock(); 811 + this.scrollLock ??= scrollLocks.acquire({ target: this }); 812 812 const dialog = this.querySelector(".report-dialog"); 813 813 dialog.showModal(); 814 814 ··· 823 823 } 824 824 825 825 close() { 826 - this.scrollLock.unlock(); 826 + this.scrollLock?.release(); 827 + this.scrollLock = null; 827 828 const dialog = this.querySelector(".report-dialog"); 828 829 dialog.close(); 829 830 this.dispatchEvent(new CustomEvent("report-dialog-closed"));
+4 -4
src/js/modals/modal.js
··· 1 1 import { render } from "/js/lib/lit-html.js"; 2 - import { ScrollLock } from "/js/scrollLock.js"; 2 + import { scrollLocks } from "/js/scrollLocks.js"; 3 3 import { enableDragToDismiss } from "/js/utils.js"; 4 4 5 5 export class Modal { ··· 44 44 dialog.setAttribute(key, value); 45 45 } 46 46 47 - const scrollLock = new ScrollLock(dialog); 47 + let scrollLock = null; 48 48 let resolved = false; 49 49 const dismiss = (value) => { 50 50 if (resolved) return; 51 51 resolved = true; 52 - scrollLock.unlock(); 52 + scrollLock?.release(); 53 53 dialog.close(); 54 54 dialog.remove(); 55 55 resolve(value); ··· 72 72 }); 73 73 74 74 document.body.appendChild(dialog); 75 - scrollLock.lock(); 75 + scrollLock = scrollLocks.acquire({ target: dialog }); 76 76 dialog.showModal(); 77 77 78 78 if (this.dragToDismiss) {
-1
src/js/preferences.js
··· 491 491 return false; 492 492 } 493 493 494 - // Todo - memoize this? 495 494 postHasMutedWord(post) { 496 495 const text = post?.record?.text ?? null; 497 496 const facets = post?.record?.facets ?? null;
+45 -47
src/js/scrollLock.js src/js/scrollLocks.js
··· 90 90 return null; 91 91 } 92 92 93 - // Locks are tracked as a stack of holders: the first holder locks the 94 - // page and only the last holder to unlock restores it. 95 - let __scrollLockHolders = []; 96 - let __lockedContainer = null; 93 + class ScrollLockManager { 94 + #getContainer = null; 95 + #leases = new Set(); 96 + #lockedContainer = null; 97 97 98 - export class ScrollLock { 99 - constructor(target) { 100 - this.target = target ?? null; 101 - this.locked = false; 102 - this._lockedAncestor = null; 103 - this._previousAncestorOverflow = ""; 98 + setContainerProvider(getContainer) { 99 + this.#getContainer = getContainer; 104 100 } 105 101 106 - lock() { 107 - if (this.locked) { 108 - return; 109 - } 110 - const container = document.querySelector(".page-visible"); // todo find better way to get container 111 - if (!container) { 112 - console.warn( 113 - "ScrollLock: no .page-visible container found; skipping lock", 114 - ); 115 - return; 116 - } 117 - if (__scrollLockHolders.length === 0) { 102 + acquire({ target = null } = {}) { 103 + let released = false; 104 + let lockedAncestor = null; 105 + let previousAncestorOverflow = ""; 106 + const release = ({ restoreScroll = true } = {}) => { 107 + if (released) return; 108 + released = true; 109 + 110 + if (lockedAncestor) { 111 + lockedAncestor.style.overflow = previousAncestorOverflow; 112 + lockedAncestor = null; 113 + previousAncestorOverflow = ""; 114 + } 115 + 116 + this.#leases.delete(release); 117 + if (this.#leases.size === 0 && this.#lockedContainer) { 118 + unlockScroll(this.#lockedContainer, { restoreScroll }); 119 + this.#lockedContainer = null; 120 + } 121 + }; 122 + 123 + if (this.#leases.size === 0) { 124 + const container = this.#getContainer?.(); 125 + if (!container) { 126 + console.warn( 127 + "ScrollLock: no current page container found; skipping lock", 128 + ); 129 + return { release }; 130 + } 118 131 lockScroll(container); 119 - __lockedContainer = container; 132 + this.#lockedContainer = container; 120 133 } 121 - __scrollLockHolders.push(this); 122 - // If target is passed, lock the nearest scrollable ancestor of that target in addition to the outer page 123 - const ancestor = this.target ? findScrollableAncestor(this.target) : null; 134 + 135 + this.#leases.add(release); 136 + // Also prevent scroll in the nearest scrollable ancestor of the trigger. 137 + const ancestor = target ? findScrollableAncestor(target) : null; 124 138 if (ancestor) { 125 - this._lockedAncestor = ancestor; 126 - this._previousAncestorOverflow = ancestor.style.overflow; 139 + lockedAncestor = ancestor; 140 + previousAncestorOverflow = ancestor.style.overflow; 127 141 ancestor.style.overflow = "hidden"; 128 142 } 129 - this.locked = true; 130 - } 131 143 132 - unlock({ restoreScroll = true } = {}) { 133 - if (!this.locked) { 134 - return; 135 - } 136 - if (this._lockedAncestor) { 137 - this._lockedAncestor.style.overflow = this._previousAncestorOverflow; 138 - this._lockedAncestor = null; 139 - this._previousAncestorOverflow = ""; 140 - } 141 - __scrollLockHolders = __scrollLockHolders.filter( 142 - (holder) => holder !== this, 143 - ); 144 - if (__scrollLockHolders.length === 0) { 145 - unlockScroll(__lockedContainer, { restoreScroll }); 146 - __lockedContainer = null; 147 - } 148 - this.locked = false; 144 + return { release }; 149 145 } 150 146 } 147 + 148 + export const scrollLocks = new ScrollLockManager();
-1
src/js/views/postThread.view.js
··· 267 267 <div class="post-thread-reply-chains"> 268 268 ${replyChains.map((replyChain, i) => 269 269 // there can be a lot of images in a reply chain, so lazy load them after the first few 270 - // TODO: infinite scroll for reply chains? or use v2 endpoint? 271 270 replyChainTemplate({ 272 271 replyChain, 273 272 currentUser,
+3 -1
src/js/views/profile.view.js
··· 302 302 <p> 303 303 This account has requested that users sign in to view their profile. 304 304 </p> 305 - <button @click=${() => window.router.back()}>Go back</button> 305 + <button class="rounded-button" @click=${() => window.router.back()}> 306 + Go back 307 + </button> 306 308 </div> 307 309 `; 308 310 }
+31
tests/e2e/specs/concerns/globalError.test.js
··· 1 + import { test, expect } from "../../base.js"; 2 + 3 + test.describe("Global error state", () => { 4 + test("renders a retry action that reloads the app", async ({ page }) => { 5 + let navigationRequests = 0; 6 + page.on("request", (request) => { 7 + if ( 8 + request.isNavigationRequest() && 9 + request.frame() === page.mainFrame() 10 + ) { 11 + navigationRequests += 1; 12 + } 13 + }); 14 + 15 + await page.goto("/login"); 16 + await page.evaluate(() => window.showGlobalErrorState()); 17 + 18 + const errorState = page.locator(".app-error-state"); 19 + await expect(errorState).toContainText( 20 + "There was an error loading the app.", 21 + ); 22 + 23 + const tryAgainButton = errorState.getByRole("button", { 24 + name: "Try again", 25 + }); 26 + await expect(tryAgainButton).toBeVisible(); 27 + await tryAgainButton.click(); 28 + 29 + await expect.poll(() => navigationRequests).toBe(2); 30 + }); 31 + });
+5
tests/unit/env.js
··· 111 111 112 112 globalThis.Element = globalThis.window.Element; 113 113 114 + // Components exercise scroll locking without the app bootstrap, so supply the 115 + // same active-page boundary through the test fixture. 116 + const { scrollLocks } = await import("/js/scrollLocks.js"); 117 + scrollLocks.setContainerProvider(() => document.querySelector(".page-visible")); 118 + 114 119 class HighlightStub { 115 120 constructor(...ranges) { 116 121 this._ranges = new Set(ranges);
+27 -29
tests/unit/specs/scrollLock.test.js
··· 1 1 import { describe, it, beforeEach, afterEach } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 3 4 - // scrollLock.js tracks lock holders in module-global state 5 - const { ScrollLock } = await import("/js/scrollLock.js?fresh-for-test"); 4 + // scrollLocks.js coordinates holders through a module-level manager. 5 + const { scrollLocks } = await import("/js/scrollLocks.js?fresh-for-test"); 6 6 7 7 describe("ScrollLock", () => { 8 8 let container; 9 9 let createdLocks; 10 10 11 11 const createLock = (target) => { 12 - const lock = new ScrollLock(target); 12 + const lock = scrollLocks.acquire({ target }); 13 13 createdLocks.push(lock); 14 14 return lock; 15 15 }; 16 16 17 17 beforeEach(() => { 18 18 container = document.createElement("div"); 19 - container.className = "page-visible"; 19 + container.className = "current-page"; 20 20 container.innerHTML = "<header></header><main></main>"; 21 21 document.body.appendChild(container); 22 + scrollLocks.setContainerProvider(() => container); 22 23 createdLocks = []; 23 24 document.body.style.position = ""; 24 25 }); 25 26 26 27 afterEach(() => { 27 28 for (const lock of createdLocks) { 28 - lock.unlock(); 29 + lock.release(); 29 30 } 30 31 container.remove(); 31 32 document.body.style.position = ""; ··· 37 38 38 39 it("locks and unlocks page scroll", () => { 39 40 const lock = createLock(); 40 - lock.lock(); 41 41 assert.deepEqual(document.body.style.position, "fixed"); 42 - lock.unlock(); 42 + lock.release(); 43 43 assert.deepEqual(document.body.style.position, ""); 44 44 }); 45 45 46 46 it("keeps the page locked when the first holder unlocks before the second", () => { 47 47 const menuLock = createLock(); 48 48 const dialogLock = createLock(); 49 - menuLock.lock(); 50 - dialogLock.lock(); 51 49 52 - menuLock.unlock(); 50 + menuLock.release(); 53 51 assert.deepEqual(document.body.style.position, "fixed"); 54 52 55 - dialogLock.unlock(); 53 + dialogLock.release(); 56 54 assert.deepEqual(document.body.style.position, ""); 57 55 }); 58 56 59 57 it("keeps the page locked when a stacked dialog unlocks first", () => { 60 58 const composerLock = createLock(); 61 59 const nestedDialogLock = createLock(); 62 - composerLock.lock(); 63 - nestedDialogLock.lock(); 64 60 65 - nestedDialogLock.unlock(); 61 + nestedDialogLock.release(); 66 62 assert.deepEqual(document.body.style.position, "fixed"); 67 63 68 - composerLock.unlock(); 64 + composerLock.release(); 69 65 assert.deepEqual(document.body.style.position, ""); 70 66 }); 71 67 72 - it("ignores unlock without a prior lock", () => { 73 - const heldLock = createLock(); 74 - heldLock.lock(); 68 + it("returns a harmless release when no page container is available", () => { 69 + scrollLocks.setContainerProvider(() => null); 75 70 76 - const idleLock = createLock(); 77 - idleLock.unlock(); 78 - assert.deepEqual(document.body.style.position, "fixed"); 71 + const warn = console.warn; 72 + console.warn = () => {}; 73 + try { 74 + const lock = createLock(); 75 + assert.deepEqual(document.body.style.position, ""); 79 76 80 - heldLock.unlock(); 81 - assert.deepEqual(document.body.style.position, ""); 77 + lock.release(); 78 + assert.deepEqual(document.body.style.position, ""); 79 + } finally { 80 + console.warn = warn; 81 + } 82 82 }); 83 83 84 - it("ignores repeated lock calls from the same holder", () => { 84 + it("ignores repeated releases from the same lease", () => { 85 85 const lock = createLock(); 86 - lock.lock(); 87 - lock.lock(); 88 - lock.unlock(); 86 + lock.release(); 87 + lock.release(); 89 88 assert.deepEqual(document.body.style.position, ""); 90 89 }); 91 90 ··· 99 98 container.appendChild(scrollable); 100 99 101 100 const lock = createLock(target); 102 - lock.lock(); 103 101 assert.deepEqual(scrollable.style.overflow, "hidden"); 104 - lock.unlock(); 102 + lock.release(); 105 103 assert.deepEqual(scrollable.style.overflow, ""); 106 104 }); 107 105 });