diff --git a/package.json b/package.json index 036d32ed..d19e8a52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.28", + "version": "0.18.29", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/index.html b/src/index.html index 43f0fcfe..4fc8285d 100644 --- a/src/index.html +++ b/src/index.html @@ -26,7 +26,9 @@ appRoot.innerHTML = /* html */ `
There was an error loading the app.
- +
`; } @@ -154,6 +156,7 @@ import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js"; import { IdentityResolver } from "/js/atproto.js"; import { Router } from "/js/router.js"; + import { scrollLocks } from "/js/scrollLocks.js"; import { Api } from "/js/api.js"; import { auth } from "/js/auth.js"; import { NotificationService } from "/js/notificationService.js"; @@ -352,6 +355,9 @@ }; const router = new Router(); + + scrollLocks.setContainerProvider(() => router.currentPage); + router.addRoute(["/", "/intent/compose"], () => homeView, { layoutOptions: { activeNavItem: "home" }, }); diff --git a/src/js/components/account-switcher-dialog.js b/src/js/components/account-switcher-dialog.js index 098f9a7d..e31d50e9 100644 --- a/src/js/components/account-switcher-dialog.js +++ b/src/js/components/account-switcher-dialog.js @@ -1,6 +1,6 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { enableDragToDismiss } from "/js/utils.js"; import { auth, getLoginErrorMessage } from "/js/auth.js"; import { Signal, ReactiveStore, effect } from "/js/signals.js"; @@ -19,7 +19,7 @@ class AccountSwitcherDialog extends Component { return; } this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this.state = new ReactiveStore("account-switcher-dialog"); this.state.$currentDid = new Signal.State(null); this.state.$accounts = new Signal.State(null); @@ -278,7 +278,7 @@ class AccountSwitcherDialog extends Component { } open() { - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector("dialog"); dialog.showModal(); enableDragToDismiss(dialog, { @@ -290,7 +290,8 @@ class AccountSwitcherDialog extends Component { } close() { - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; const dialog = this.querySelector("dialog"); if (dialog?.open) { dialog.close(); diff --git a/src/js/components/add-to-lists-dialog.js b/src/js/components/add-to-lists-dialog.js index 9c9bc087..17d6e2ad 100644 --- a/src/js/components/add-to-lists-dialog.js +++ b/src/js/components/add-to-lists-dialog.js @@ -1,6 +1,6 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { enableDragToDismiss } from "/js/utils.js"; import { Signal, SignalSet, ReactiveStore, effect } from "/js/signals.js"; import { isModerationList } from "/js/dataHelpers.js"; @@ -13,7 +13,7 @@ class AddToListsDialog extends Component { return; } this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this.state = new ReactiveStore("add-to-lists-dialog"); this.state.$pendingByListUri = new SignalSet(); this.state.$loadError = new Signal.State(null); @@ -230,7 +230,7 @@ class AddToListsDialog extends Component { } open() { - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector(".add-to-lists-dialog"); dialog.showModal(); enableDragToDismiss(dialog, { @@ -241,7 +241,8 @@ class AddToListsDialog extends Component { } close() { - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; const dialog = this.querySelector(".add-to-lists-dialog"); if (dialog?.open) { dialog.close(); diff --git a/src/js/components/animated-sidebar.js b/src/js/components/animated-sidebar.js index ac404f44..36375ac5 100644 --- a/src/js/components/animated-sidebar.js +++ b/src/js/components/animated-sidebar.js @@ -1,13 +1,13 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component, getChildrenFragment } from "/js/components/component.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { isMobileViewport } from "/js/utils.js"; class AnimatedSidebar extends Component { connectedCallback() { if (!this._initialized) { this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this.isOpen = false; this._children = getChildrenFragment(this); this.innerHTML = ""; @@ -59,7 +59,7 @@ class AnimatedSidebar extends Component { return; } this.isOpen = true; - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); this.querySelector("dialog.sidebar").showModal(); } @@ -68,7 +68,8 @@ class AnimatedSidebar extends Component { return; } this.isOpen = false; - this.scrollLock.unlock({ restoreScroll }); + this.scrollLock?.release({ restoreScroll }); + this.scrollLock = null; const dialog = this.querySelector("dialog.sidebar"); if (dialog.hasAttribute("open")) { dialog.close(); diff --git a/src/js/components/context-menu.js b/src/js/components/context-menu.js index f015c220..0d64cfca 100644 --- a/src/js/components/context-menu.js +++ b/src/js/components/context-menu.js @@ -5,7 +5,7 @@ import { isMobileViewport, } from "/js/utils.js"; import { Component, getChildrenFragment } from "/js/components/component.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { hapticsImpactLight } from "/js/haptics.js"; class ContextMenu extends Component { @@ -14,7 +14,7 @@ class ContextMenu extends Component { return; } this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this._childNodes = [...this.childNodes]; this.innerHTML = ""; this.isOpen = false; @@ -29,7 +29,8 @@ class ContextMenu extends Component { disconnectedCallback() { // If scroll is still prevented, restore it - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; if (this._observer) { this._observer.disconnect(); } @@ -85,7 +86,7 @@ class ContextMenu extends Component { open(x, y) { hapticsImpactLight(); - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector(".context-menu"); dialog.showModal(); @@ -124,7 +125,8 @@ class ContextMenu extends Component { } close() { - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; const dialog = this.querySelector(".context-menu"); dialog.close(); this.isOpen = false; diff --git a/src/js/components/drafts-dialog.js b/src/js/components/drafts-dialog.js index de80bb0a..07567b53 100644 --- a/src/js/components/drafts-dialog.js +++ b/src/js/components/drafts-dialog.js @@ -1,6 +1,6 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { displayRelativeTime, enableDragToDismiss } from "/js/utils.js"; import { Signal, ReactiveStore, effect, untrack } from "/js/signals.js"; import { confirmModal } from "/js/modals/confirm.modal.js"; @@ -165,7 +165,7 @@ class DraftsDialog extends Component { return; } this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this.state = new ReactiveStore("drafts-dialog"); this.state.$loadError = new Signal.State(false); this.state.$isLoadingMore = new Signal.State(false); @@ -323,7 +323,7 @@ class DraftsDialog extends Component { } open() { - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector("dialog"); dialog.showModal(); enableDragToDismiss(dialog, { @@ -335,7 +335,8 @@ class DraftsDialog extends Component { } close() { - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; const dialog = this.querySelector("dialog"); if (dialog?.open) { dialog.close(); diff --git a/src/js/components/edit-profile-dialog.js b/src/js/components/edit-profile-dialog.js index bffdd0ae..eee86c39 100644 --- a/src/js/components/edit-profile-dialog.js +++ b/src/js/components/edit-profile-dialog.js @@ -1,6 +1,6 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { avatarThumbnailUrl } from "/js/dataHelpers.js"; import { classnames, @@ -26,7 +26,7 @@ class EditProfileDialog extends Component { return; } this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this._displayName = ""; this._description = ""; this._currentAvatar = null; @@ -475,7 +475,7 @@ class EditProfileDialog extends Component { open() { this._isOpen = true; - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector(".edit-profile-dialog"); if (dialog) { dialog.showModal(); @@ -510,7 +510,8 @@ class EditProfileDialog extends Component { close() { this._isOpen = false; - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; const dialog = this.querySelector(".edit-profile-dialog"); if (dialog) { dialog.close(); diff --git a/src/js/components/emoji-picker-dialog.js b/src/js/components/emoji-picker-dialog.js index fb7d0afd..ea668efb 100644 --- a/src/js/components/emoji-picker-dialog.js +++ b/src/js/components/emoji-picker-dialog.js @@ -1,5 +1,5 @@ import { Component } from "/js/components/component.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import "/js/lib/emoji-picker-element.js"; class EmojiPickerDialog extends Component { @@ -7,7 +7,7 @@ class EmojiPickerDialog extends Component { if (this._initialized) { return; } - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this.isOpen = false; this._initialized = true; } @@ -35,7 +35,7 @@ class EmojiPickerDialog extends Component { }); document.body.appendChild(dialog); dialog.showModal(); - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); this.isOpen = true; this._dialog = dialog; this._picker = picker; @@ -57,7 +57,8 @@ class EmojiPickerDialog extends Component { this._dialog?.remove(); this._dialog = null; this._anchor = null; - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; this.isOpen = false; } diff --git a/src/js/components/image-alt-text-dialog.js b/src/js/components/image-alt-text-dialog.js index 7277b311..26274ffd 100644 --- a/src/js/components/image-alt-text-dialog.js +++ b/src/js/components/image-alt-text-dialog.js @@ -1,7 +1,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { classnames, graphemeCount, resetScrollOnBlur } from "/js/utils.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; class ImageAltTextDialog extends Component { connectedCallback() { @@ -9,7 +9,7 @@ class ImageAltTextDialog extends Component { return; } this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this.innerHTML = ""; this.render(); this.initialized = true; @@ -112,7 +112,7 @@ class ImageAltTextDialog extends Component { } open() { - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector(".image-alt-text-dialog"); dialog.showModal(); this.querySelector(".image-alt-text-dialog-textarea")?.focus({ @@ -126,7 +126,8 @@ class ImageAltTextDialog extends Component { } close() { - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; const dialog = this.querySelector(".image-alt-text-dialog"); dialog.close(); this.dispatchEvent(new CustomEvent("alt-text-dialog-closed")); diff --git a/src/js/components/new-chat-dialog.js b/src/js/components/new-chat-dialog.js index 1a81c15e..994859ed 100644 --- a/src/js/components/new-chat-dialog.js +++ b/src/js/components/new-chat-dialog.js @@ -1,6 +1,6 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { enableDragToDismiss } from "/js/utils.js"; import { Signal, ReactiveStore, effect } from "/js/signals.js"; import { getDisplayName, MISSING_HANDLE } from "/js/dataHelpers.js"; @@ -144,7 +144,7 @@ class NewChatDialog extends Component { } this.dataLayer = this.dataLayer ?? null; this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this.state = new ReactiveStore("new-chat-dialog"); this.state.$query = new Signal.State(""); this.innerHTML = ""; @@ -296,7 +296,7 @@ class NewChatDialog extends Component { } open() { - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector(".new-chat-dialog"); dialog.showModal(); this.querySelector(".new-chat-search-input")?.focus({ @@ -310,7 +310,8 @@ class NewChatDialog extends Component { } close() { - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; const dialog = this.querySelector(".new-chat-dialog"); if (dialog?.open) { dialog.close(); diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js index cef66b7f..70e2ca41 100644 --- a/src/js/components/post-composer.js +++ b/src/js/components/post-composer.js @@ -13,7 +13,7 @@ import { } from "/js/utils.js"; import { externalLinkTemplate } from "/js/templates/externalLink.template.js"; import { confirmModal } from "/js/modals/confirm.modal.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { imageIconTemplate } from "/js/templates/icons/imageIcon.template.js"; import { emojiIconTemplate } from "/js/templates/icons/emojiIcon.template.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; @@ -359,7 +359,7 @@ class PostComposer extends Component { return; } this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this.innerHTML = ""; this._draftId = null; this._isDirty = false; @@ -1234,7 +1234,7 @@ class PostComposer extends Component { } open() { - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector(".post-composer"); dialog.showModal(); this.querySelector("rich-text-input")?.focus({ preventScroll: true }); @@ -1269,7 +1269,8 @@ class PostComposer extends Component { } close() { - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; const dialog = this.querySelector(".post-composer"); dialog.close(); this.dispatchEvent(new CustomEvent("post-composer-closed")); diff --git a/src/js/components/post-notifications-dialog.js b/src/js/components/post-notifications-dialog.js index 8f8fb7a9..15ffcf51 100644 --- a/src/js/components/post-notifications-dialog.js +++ b/src/js/components/post-notifications-dialog.js @@ -1,6 +1,6 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { enableDragToDismiss } from "/js/utils.js"; import "/js/components/toggle-switch.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; @@ -11,7 +11,7 @@ class PostNotificationsDialog extends Component { return; } this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this._postEnabled = this.activitySubscription?.post ?? false; this._replyEnabled = this.activitySubscription?.reply ?? false; this._isSaving = false; @@ -146,7 +146,7 @@ class PostNotificationsDialog extends Component { } open() { - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector(".post-notifications-dialog"); dialog.showModal(); @@ -158,7 +158,8 @@ class PostNotificationsDialog extends Component { } close() { - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; const dialog = this.querySelector(".post-notifications-dialog"); if (dialog?.open) { dialog.close(); diff --git a/src/js/components/reactions-dialog.js b/src/js/components/reactions-dialog.js index b8ff22fb..2c5a592a 100644 --- a/src/js/components/reactions-dialog.js +++ b/src/js/components/reactions-dialog.js @@ -1,7 +1,7 @@ import { Component } from "/js/components/component.js"; import { html, render } from "/js/lib/lit-html.js"; import { effect } from "/js/signals.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { enableDragToDismiss } from "/js/utils.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { getDisplayName, groupReactions } from "/js/dataHelpers.js"; @@ -13,7 +13,7 @@ class ReactionsDialog extends Component { this._initialized = true; this.setAttribute("data-dialog-wrapper", ""); this._activeFilter = "all"; - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this._dispose = effect(() => { this.render(); }); @@ -21,7 +21,7 @@ class ReactionsDialog extends Component { const dialog = this.querySelector(".reactions-dialog"); if (dialog && !dialog.open) { dialog.showModal(); - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); enableDragToDismiss(dialog, { onClose: () => this._close(), scrollContainer: this.querySelector(".reactions-list"), @@ -33,7 +33,8 @@ class ReactionsDialog extends Component { disconnectedCallback() { if (this._dispose) this._dispose(); - this.scrollLock?.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; } _close() { diff --git a/src/js/components/report-dialog.js b/src/js/components/report-dialog.js index df533a2c..5320026d 100644 --- a/src/js/components/report-dialog.js +++ b/src/js/components/report-dialog.js @@ -1,6 +1,6 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { enableDragToDismiss, kebabCase, @@ -632,7 +632,7 @@ class ReportDialog extends Component { return; } this.setAttribute("data-dialog-wrapper", ""); - this.scrollLock = new ScrollLock(this); + this.scrollLock = null; this.innerHTML = ""; this._stepIndex = 0; this._selectedCategory = null; @@ -808,7 +808,7 @@ class ReportDialog extends Component { } open() { - this.scrollLock.lock(); + this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector(".report-dialog"); dialog.showModal(); @@ -823,7 +823,8 @@ class ReportDialog extends Component { } close() { - this.scrollLock.unlock(); + this.scrollLock?.release(); + this.scrollLock = null; const dialog = this.querySelector(".report-dialog"); dialog.close(); this.dispatchEvent(new CustomEvent("report-dialog-closed")); diff --git a/src/js/modals/modal.js b/src/js/modals/modal.js index cebaa141..f80b7dd4 100644 --- a/src/js/modals/modal.js +++ b/src/js/modals/modal.js @@ -1,5 +1,5 @@ import { render } from "/js/lib/lit-html.js"; -import { ScrollLock } from "/js/scrollLock.js"; +import { scrollLocks } from "/js/scrollLocks.js"; import { enableDragToDismiss } from "/js/utils.js"; export class Modal { @@ -44,12 +44,12 @@ export class Modal { dialog.setAttribute(key, value); } - const scrollLock = new ScrollLock(dialog); + let scrollLock = null; let resolved = false; const dismiss = (value) => { if (resolved) return; resolved = true; - scrollLock.unlock(); + scrollLock?.release(); dialog.close(); dialog.remove(); resolve(value); @@ -72,7 +72,7 @@ export class Modal { }); document.body.appendChild(dialog); - scrollLock.lock(); + scrollLock = scrollLocks.acquire({ target: dialog }); dialog.showModal(); if (this.dragToDismiss) { diff --git a/src/js/preferences.js b/src/js/preferences.js index 94122b17..83456171 100644 --- a/src/js/preferences.js +++ b/src/js/preferences.js @@ -491,7 +491,6 @@ export class Preferences { return false; } - // Todo - memoize this? postHasMutedWord(post) { const text = post?.record?.text ?? null; const facets = post?.record?.facets ?? null; diff --git a/src/js/scrollLock.js b/src/js/scrollLocks.js similarity index 63% rename from src/js/scrollLock.js rename to src/js/scrollLocks.js index cae2d583..7511afb2 100644 --- a/src/js/scrollLock.js +++ b/src/js/scrollLocks.js @@ -90,61 +90,59 @@ function findScrollableAncestor(element) { return null; } -// Locks are tracked as a stack of holders: the first holder locks the -// page and only the last holder to unlock restores it. -let __scrollLockHolders = []; -let __lockedContainer = null; +class ScrollLockManager { + #getContainer = null; + #leases = new Set(); + #lockedContainer = null; -export class ScrollLock { - constructor(target) { - this.target = target ?? null; - this.locked = false; - this._lockedAncestor = null; - this._previousAncestorOverflow = ""; + setContainerProvider(getContainer) { + this.#getContainer = getContainer; } - lock() { - if (this.locked) { - return; - } - const container = document.querySelector(".page-visible"); // todo find better way to get container - if (!container) { - console.warn( - "ScrollLock: no .page-visible container found; skipping lock", - ); - return; - } - if (__scrollLockHolders.length === 0) { + acquire({ target = null } = {}) { + let released = false; + let lockedAncestor = null; + let previousAncestorOverflow = ""; + const release = ({ restoreScroll = true } = {}) => { + if (released) return; + released = true; + + if (lockedAncestor) { + lockedAncestor.style.overflow = previousAncestorOverflow; + lockedAncestor = null; + previousAncestorOverflow = ""; + } + + this.#leases.delete(release); + if (this.#leases.size === 0 && this.#lockedContainer) { + unlockScroll(this.#lockedContainer, { restoreScroll }); + this.#lockedContainer = null; + } + }; + + if (this.#leases.size === 0) { + const container = this.#getContainer?.(); + if (!container) { + console.warn( + "ScrollLock: no current page container found; skipping lock", + ); + return { release }; + } lockScroll(container); - __lockedContainer = container; + this.#lockedContainer = container; } - __scrollLockHolders.push(this); - // If target is passed, lock the nearest scrollable ancestor of that target in addition to the outer page - const ancestor = this.target ? findScrollableAncestor(this.target) : null; + + this.#leases.add(release); + // Also prevent scroll in the nearest scrollable ancestor of the trigger. + const ancestor = target ? findScrollableAncestor(target) : null; if (ancestor) { - this._lockedAncestor = ancestor; - this._previousAncestorOverflow = ancestor.style.overflow; + lockedAncestor = ancestor; + previousAncestorOverflow = ancestor.style.overflow; ancestor.style.overflow = "hidden"; } - this.locked = true; - } - unlock({ restoreScroll = true } = {}) { - if (!this.locked) { - return; - } - if (this._lockedAncestor) { - this._lockedAncestor.style.overflow = this._previousAncestorOverflow; - this._lockedAncestor = null; - this._previousAncestorOverflow = ""; - } - __scrollLockHolders = __scrollLockHolders.filter( - (holder) => holder !== this, - ); - if (__scrollLockHolders.length === 0) { - unlockScroll(__lockedContainer, { restoreScroll }); - __lockedContainer = null; - } - this.locked = false; + return { release }; } } + +export const scrollLocks = new ScrollLockManager(); diff --git a/src/js/views/postThread.view.js b/src/js/views/postThread.view.js index 6e284b65..fefc42ae 100644 --- a/src/js/views/postThread.view.js +++ b/src/js/views/postThread.view.js @@ -267,7 +267,6 @@ class PostThreadView extends View {
${replyChains.map((replyChain, i) => // there can be a lot of images in a reply chain, so lazy load them after the first few - // TODO: infinite scroll for reply chains? or use v2 endpoint? replyChainTemplate({ replyChain, currentUser, diff --git a/src/js/views/profile.view.js b/src/js/views/profile.view.js index 2a3fa653..3ca18f8c 100644 --- a/src/js/views/profile.view.js +++ b/src/js/views/profile.view.js @@ -302,7 +302,9 @@ class ProfileView extends View {

This account has requested that users sign in to view their profile.

- +
`; } diff --git a/tests/e2e/specs/concerns/globalError.test.js b/tests/e2e/specs/concerns/globalError.test.js new file mode 100644 index 00000000..912f73dc --- /dev/null +++ b/tests/e2e/specs/concerns/globalError.test.js @@ -0,0 +1,31 @@ +import { test, expect } from "../../base.js"; + +test.describe("Global error state", () => { + test("renders a retry action that reloads the app", async ({ page }) => { + let navigationRequests = 0; + page.on("request", (request) => { + if ( + request.isNavigationRequest() && + request.frame() === page.mainFrame() + ) { + navigationRequests += 1; + } + }); + + await page.goto("/login"); + await page.evaluate(() => window.showGlobalErrorState()); + + const errorState = page.locator(".app-error-state"); + await expect(errorState).toContainText( + "There was an error loading the app.", + ); + + const tryAgainButton = errorState.getByRole("button", { + name: "Try again", + }); + await expect(tryAgainButton).toBeVisible(); + await tryAgainButton.click(); + + await expect.poll(() => navigationRequests).toBe(2); + }); +}); diff --git a/tests/unit/env.js b/tests/unit/env.js index 15c152ec..22216dca 100644 --- a/tests/unit/env.js +++ b/tests/unit/env.js @@ -111,6 +111,11 @@ globalThis.localStorage = new LocalStorageStub(); globalThis.Element = globalThis.window.Element; +// Components exercise scroll locking without the app bootstrap, so supply the +// same active-page boundary through the test fixture. +const { scrollLocks } = await import("/js/scrollLocks.js"); +scrollLocks.setContainerProvider(() => document.querySelector(".page-visible")); + class HighlightStub { constructor(...ranges) { this._ranges = new Set(ranges); diff --git a/tests/unit/specs/scrollLock.test.js b/tests/unit/specs/scrollLock.test.js index 4a767a0b..48a1879e 100644 --- a/tests/unit/specs/scrollLock.test.js +++ b/tests/unit/specs/scrollLock.test.js @@ -1,31 +1,32 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; -// scrollLock.js tracks lock holders in module-global state -const { ScrollLock } = await import("/js/scrollLock.js?fresh-for-test"); +// scrollLocks.js coordinates holders through a module-level manager. +const { scrollLocks } = await import("/js/scrollLocks.js?fresh-for-test"); describe("ScrollLock", () => { let container; let createdLocks; const createLock = (target) => { - const lock = new ScrollLock(target); + const lock = scrollLocks.acquire({ target }); createdLocks.push(lock); return lock; }; beforeEach(() => { container = document.createElement("div"); - container.className = "page-visible"; + container.className = "current-page"; container.innerHTML = "
"; document.body.appendChild(container); + scrollLocks.setContainerProvider(() => container); createdLocks = []; document.body.style.position = ""; }); afterEach(() => { for (const lock of createdLocks) { - lock.unlock(); + lock.release(); } container.remove(); document.body.style.position = ""; @@ -37,55 +38,53 @@ describe("ScrollLock", () => { it("locks and unlocks page scroll", () => { const lock = createLock(); - lock.lock(); assert.deepEqual(document.body.style.position, "fixed"); - lock.unlock(); + lock.release(); assert.deepEqual(document.body.style.position, ""); }); it("keeps the page locked when the first holder unlocks before the second", () => { const menuLock = createLock(); const dialogLock = createLock(); - menuLock.lock(); - dialogLock.lock(); - menuLock.unlock(); + menuLock.release(); assert.deepEqual(document.body.style.position, "fixed"); - dialogLock.unlock(); + dialogLock.release(); assert.deepEqual(document.body.style.position, ""); }); it("keeps the page locked when a stacked dialog unlocks first", () => { const composerLock = createLock(); const nestedDialogLock = createLock(); - composerLock.lock(); - nestedDialogLock.lock(); - nestedDialogLock.unlock(); + nestedDialogLock.release(); assert.deepEqual(document.body.style.position, "fixed"); - composerLock.unlock(); + composerLock.release(); assert.deepEqual(document.body.style.position, ""); }); - it("ignores unlock without a prior lock", () => { - const heldLock = createLock(); - heldLock.lock(); + it("returns a harmless release when no page container is available", () => { + scrollLocks.setContainerProvider(() => null); - const idleLock = createLock(); - idleLock.unlock(); - assert.deepEqual(document.body.style.position, "fixed"); + const warn = console.warn; + console.warn = () => {}; + try { + const lock = createLock(); + assert.deepEqual(document.body.style.position, ""); - heldLock.unlock(); - assert.deepEqual(document.body.style.position, ""); + lock.release(); + assert.deepEqual(document.body.style.position, ""); + } finally { + console.warn = warn; + } }); - it("ignores repeated lock calls from the same holder", () => { + it("ignores repeated releases from the same lease", () => { const lock = createLock(); - lock.lock(); - lock.lock(); - lock.unlock(); + lock.release(); + lock.release(); assert.deepEqual(document.body.style.position, ""); }); @@ -99,9 +98,8 @@ describe("ScrollLock", () => { container.appendChild(scrollable); const lock = createLock(target); - lock.lock(); assert.deepEqual(scrollable.style.overflow, "hidden"); - lock.unlock(); + lock.release(); assert.deepEqual(scrollable.style.overflow, ""); }); });