From 399b1034a41c52d91b40d797e2c94ee9bcae1953 Mon Sep 17 00:00:00 2001 From: Allen Ding Date: Wed, 11 Jun 2025 16:59:00 -0400 Subject: [PATCH 1/6] fix: add mutation observer to persist sort direction when switching pages --- src/lib/components/market/sort_listings.ts | 133 +++++++++++++++------ 1 file changed, 94 insertions(+), 39 deletions(-) diff --git a/src/lib/components/market/sort_listings.ts b/src/lib/components/market/sort_listings.ts index 4506efce..4189fc55 100644 --- a/src/lib/components/market/sort_listings.ts +++ b/src/lib/components/market/sort_listings.ts @@ -8,6 +8,7 @@ import {getMarketInspectLink} from './helpers'; import {ItemInfo} from '../../bridge/handlers/fetch_inspect_info'; import {getFadeParams, getFadePercentage} from '../../utils/skin'; import {AppId, ContextId} from '../../types/steam_constants'; +import {debounce} from 'lodash-decorators'; enum SortType { FLOAT = 'Float', @@ -27,6 +28,8 @@ export class SortListings extends FloatElement { @state() private direction: SortDirection = SortDirection.NONE; + private observer: MutationObserver | null = null; + @state() get isFadeSkin() { const firstRow = document.querySelector('#searchResultsRows .market_listing_row.market_recent_listing_row'); @@ -42,6 +45,97 @@ export class SortListings extends FloatElement { return getFadeParams(asset) !== undefined; } + connectedCallback() { + super.connectedCallback(); + + // Find the container of listings that we need to watch. + const targetNode = document.getElementById('searchResultsRows'); + if (!targetNode) return; + + const config = {childList: true}; + + // Create a MutationObserver to detect when the page's items are dynamically replaced. + this.observer = new MutationObserver(() => this.onMutation()); + + // Start observing the target node for additions or removals of child elements. + this.observer.observe(targetNode, config); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.observer) { + this.observer.disconnect(); + } + } + + /** + * This decorated method is called when the item list changes. + * The @debounce decorator ensures it only runs once after a series of rapid changes. + */ + @debounce(500) + private onMutation() { + // Only re-sort if a sort is currently active. + if (this.direction === SortDirection.NONE) return; + + const targetNode = document.getElementById('searchResultsRows'); + const config = {childList: true}; + + // Disconnect the observer temporarily to prevent sortListings() from causing this mutation + // handler to re-trigger, causing a loop. + this.observer?.disconnect(); + + this.sortListings(this.type, this.direction) + .catch((err) => console.error('CSFloat: Failed to re-sort list', err)) + .finally(() => { + // Reconnect the observer to watch for the next page change. + if (targetNode) { + this.observer?.observe(targetNode, config); + } + }); + } + + private async sortListings(sortType: SortType, direction: SortDirection) { + const rows = document.querySelectorAll('#searchResultsRows .market_listing_row.market_recent_listing_row'); + if (rows.length === 0) return; + + const infoPromises = [...rows] + .map((e) => e.id.replace('listing_', '')) + .map(async (listingId) => { + const link = getMarketInspectLink(listingId); + const info = await gFloatFetcher.fetch({link: link!}); + const listingInfo = g_rgListingInfo[listingId]; + const asset = g_rgAssets[AppId.CSGO][ContextId.PRIMARY][listingInfo.asset.id]; + return { + info, + listingId: listingId!, + converted_price: listingInfo?.converted_price || 0, + fadePercentage: (asset && getFadePercentage(asset, info)?.percentage) || 0, + }; + }); + + const infos = await Promise.all(infoPromises); + const sortedInfos = SortListings.sort(infos, sortType, direction); + + let lastItem = document.querySelector('#searchResultsRows .market_listing_table_header'); + + for (const info of sortedInfos) { + const itemElement = document.querySelector(`#listing_${info.listingId}`); + if (itemElement && itemElement.parentNode && lastItem) { + lastItem = itemElement.parentNode.insertBefore(itemElement, lastItem.nextSibling); + } + } + } + + async onClick(sortType: SortType) { + const newDirection = + sortType === this.type ? SortListings.getNextSortDirection(this.direction) : SortDirection.ASC; + + await this.sortListings(sortType, newDirection); + + this.type = sortType; + this.direction = newDirection; + } + computeButtonText(sortType: SortType): string { let txt = `Sort by ${sortType}`; @@ -107,43 +201,4 @@ export class SortListings extends FloatElement { ); } } - - async onClick(sortType: SortType) { - const newDirection = - sortType == this.type ? SortListings.getNextSortDirection(this.direction) : SortDirection.ASC; - - const rows = document.querySelectorAll('#searchResultsRows .market_listing_row.market_recent_listing_row'); - - const infoPromises = [...rows] - .map((e) => e.id.replace('listing_', '')) - .map(async (listingId) => { - const link = getMarketInspectLink(listingId); - - const info = await gFloatFetcher.fetch({link: link!}); - - const listingInfo = g_rgListingInfo[listingId]; - - const asset = g_rgAssets[AppId.CSGO][ContextId.PRIMARY][listingInfo.asset.id]; - - return { - info, - listingId: listingId!, - converted_price: listingInfo?.converted_price || 0, - fadePercentage: (asset && getFadePercentage(asset, info)?.percentage) || 0, - }; - }); - - const infos = await Promise.all(infoPromises); - const sortedInfos = SortListings.sort(infos, sortType, newDirection); - - let lastItem = document.querySelector('#searchResultsRows .market_listing_table_header'); - - for (const info of sortedInfos) { - const itemElement = document.querySelector(`#listing_${info.listingId}`); - lastItem = itemElement!.parentNode!.insertBefore(itemElement!, lastItem!.nextSibling); - } - - this.type = sortType; - this.direction = newDirection; - } } From 5202bb7c888de466fafe7a9187993e9b596db835 Mon Sep 17 00:00:00 2001 From: Allen Ding Date: Wed, 11 Jun 2025 18:46:26 -0400 Subject: [PATCH 2/6] update disconnectedCallback to clean up debounced mutation handler --- src/lib/components/market/sort_listings.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lib/components/market/sort_listings.ts b/src/lib/components/market/sort_listings.ts index 4189fc55..746153f4 100644 --- a/src/lib/components/market/sort_listings.ts +++ b/src/lib/components/market/sort_listings.ts @@ -21,6 +21,12 @@ enum SortDirection { DESC, } +// Describes a function that has been decorated with a lodash debounce decorator, +// which adds a .cancel() method to it. +type CancellableDebounced = { + cancel(): void; +}; + @CustomElement() export class SortListings extends FloatElement { @state() @@ -66,6 +72,10 @@ export class SortListings extends FloatElement { if (this.observer) { this.observer.disconnect(); } + + // Workaround to avoid using @ts-ignore: + // type assertion to inform ts about the .cancel() added from the lodash debounce + (this.onMutation as unknown as CancellableDebounced).cancel(); } /** From 6b13411e4f8302ef2a5cb7b51b9c743da723dcd5 Mon Sep 17 00:00:00 2001 From: Allen Ding Date: Thu, 12 Jun 2025 12:52:00 -0400 Subject: [PATCH 3/6] update disconnectedCallback to use offical lodash DebouncedFunc type --- src/lib/components/market/sort_listings.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/lib/components/market/sort_listings.ts b/src/lib/components/market/sort_listings.ts index 746153f4..81f8160c 100644 --- a/src/lib/components/market/sort_listings.ts +++ b/src/lib/components/market/sort_listings.ts @@ -9,6 +9,7 @@ import {ItemInfo} from '../../bridge/handlers/fetch_inspect_info'; import {getFadeParams, getFadePercentage} from '../../utils/skin'; import {AppId, ContextId} from '../../types/steam_constants'; import {debounce} from 'lodash-decorators'; +import { DebouncedFunc } from 'lodash'; enum SortType { FLOAT = 'Float', @@ -21,12 +22,6 @@ enum SortDirection { DESC, } -// Describes a function that has been decorated with a lodash debounce decorator, -// which adds a .cancel() method to it. -type CancellableDebounced = { - cancel(): void; -}; - @CustomElement() export class SortListings extends FloatElement { @state() @@ -75,7 +70,7 @@ export class SortListings extends FloatElement { // Workaround to avoid using @ts-ignore: // type assertion to inform ts about the .cancel() added from the lodash debounce - (this.onMutation as unknown as CancellableDebounced).cancel(); + (this.onMutation as DebouncedFunc<() => void>).cancel(); } /** From e727c9d15b5cf5a255f39eae5a0feca1c4ca87f3 Mon Sep 17 00:00:00 2001 From: Allen Ding Date: Fri, 13 Jun 2025 12:04:19 -0400 Subject: [PATCH 4/6] fix formatting --- src/lib/components/market/sort_listings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/market/sort_listings.ts b/src/lib/components/market/sort_listings.ts index 81f8160c..6e3f1e16 100644 --- a/src/lib/components/market/sort_listings.ts +++ b/src/lib/components/market/sort_listings.ts @@ -9,7 +9,7 @@ import {ItemInfo} from '../../bridge/handlers/fetch_inspect_info'; import {getFadeParams, getFadePercentage} from '../../utils/skin'; import {AppId, ContextId} from '../../types/steam_constants'; import {debounce} from 'lodash-decorators'; -import { DebouncedFunc } from 'lodash'; +import {DebouncedFunc} from 'lodash'; enum SortType { FLOAT = 'Float', From a825cc85f15b1b370dcaaf4ea4975a79d287e545 Mon Sep 17 00:00:00 2001 From: Allen Ding Date: Mon, 16 Jun 2025 17:09:02 -0400 Subject: [PATCH 5/6] switch config vars to inline args --- src/lib/components/market/sort_listings.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/lib/components/market/sort_listings.ts b/src/lib/components/market/sort_listings.ts index 6e3f1e16..ba71c82a 100644 --- a/src/lib/components/market/sort_listings.ts +++ b/src/lib/components/market/sort_listings.ts @@ -53,13 +53,11 @@ export class SortListings extends FloatElement { const targetNode = document.getElementById('searchResultsRows'); if (!targetNode) return; - const config = {childList: true}; - // Create a MutationObserver to detect when the page's items are dynamically replaced. this.observer = new MutationObserver(() => this.onMutation()); // Start observing the target node for additions or removals of child elements. - this.observer.observe(targetNode, config); + this.observer.observe(targetNode, {childList: true}); } disconnectedCallback() { @@ -83,7 +81,6 @@ export class SortListings extends FloatElement { if (this.direction === SortDirection.NONE) return; const targetNode = document.getElementById('searchResultsRows'); - const config = {childList: true}; // Disconnect the observer temporarily to prevent sortListings() from causing this mutation // handler to re-trigger, causing a loop. @@ -94,7 +91,7 @@ export class SortListings extends FloatElement { .finally(() => { // Reconnect the observer to watch for the next page change. if (targetNode) { - this.observer?.observe(targetNode, config); + this.observer?.observe(targetNode, {childList: true}); } }); } From da78e1b4b31ec66c0b69408ab05f64c13c717850 Mon Sep 17 00:00:00 2001 From: Allen Ding Date: Mon, 16 Jun 2025 17:59:03 -0400 Subject: [PATCH 6/6] update sortListings to handle item info fetching failures gracefully --- src/lib/components/market/sort_listings.ts | 55 +++++++++++++++++----- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/src/lib/components/market/sort_listings.ts b/src/lib/components/market/sort_listings.ts index ba71c82a..d063481f 100644 --- a/src/lib/components/market/sort_listings.ts +++ b/src/lib/components/market/sort_listings.ts @@ -22,6 +22,22 @@ enum SortDirection { DESC, } +// Union type for fetched item info: successful and failed. +type SortableItem = + | { + failed: false; + listingId: string; + info: ItemInfo; + converted_price: number; + fadePercentage: number; + } + | { + failed: true; + listingId: string; + }; + +type SuccessfulSortableItem = Extract; + @CustomElement() export class SortListings extends FloatElement { @state() @@ -102,21 +118,36 @@ export class SortListings extends FloatElement { const infoPromises = [...rows] .map((e) => e.id.replace('listing_', '')) - .map(async (listingId) => { - const link = getMarketInspectLink(listingId); - const info = await gFloatFetcher.fetch({link: link!}); - const listingInfo = g_rgListingInfo[listingId]; - const asset = g_rgAssets[AppId.CSGO][ContextId.PRIMARY][listingInfo.asset.id]; - return { - info, - listingId: listingId!, - converted_price: listingInfo?.converted_price || 0, - fadePercentage: (asset && getFadePercentage(asset, info)?.percentage) || 0, - }; + .map(async (listingId): Promise => { + // Catch error to prevent one failure from stopping the Promise.all() later + try { + const link = getMarketInspectLink(listingId); + const info = await gFloatFetcher.fetch({link: link!}); + const listingInfo = g_rgListingInfo[listingId]; + const asset = g_rgAssets[AppId.CSGO][ContextId.PRIMARY][listingInfo.asset.id]; + return { + failed: false, + info, + listingId: listingId!, + converted_price: listingInfo?.converted_price || 0, + fadePercentage: (asset && getFadePercentage(asset, info)?.percentage) || 0, + }; + } catch (error) { + console.error(`CSFloat: Failed to fetch float for listing ${listingId}:`, error); + return {failed: true, listingId: listingId!}; + } }); const infos = await Promise.all(infoPromises); - const sortedInfos = SortListings.sort(infos, sortType, direction); + + // Type Guard that checks if an item was successfully fetched. + function isSuccessfulItem(item: SortableItem): item is SuccessfulSortableItem { + return !item.failed; + } + + const successfulItems = infos.filter(isSuccessfulItem); + const failedItems = infos.filter((r) => r.failed); + const sortedInfos = [...SortListings.sort(successfulItems, sortType, direction), ...failedItems]; let lastItem = document.querySelector('#searchResultsRows .market_listing_table_header');