From 9dc6c6ccd71536d183932cd6da6f21f8968a0be3 Mon Sep 17 00:00:00 2001 From: GODrums Date: Fri, 5 Jun 2026 20:17:19 +0200 Subject: [PATCH 1/9] add Fiber management and rank enhancement --- src/lib/components/market/mode.ts | 4 + src/lib/components/market/react/listing.ts | 185 +++++++++++++++++++++ src/lib/components/market/react/rank.ts | 60 +++++++ src/lib/page_scripts/market_listing.ts | 1 + src/lib/utils/fiber.ts | 47 ++++++ 5 files changed, 297 insertions(+) create mode 100644 src/lib/components/market/react/listing.ts create mode 100644 src/lib/components/market/react/rank.ts create mode 100644 src/lib/utils/fiber.ts diff --git a/src/lib/components/market/mode.ts b/src/lib/components/market/mode.ts index 65549185..36316a10 100644 --- a/src/lib/components/market/mode.ts +++ b/src/lib/components/market/mode.ts @@ -25,3 +25,7 @@ export function isSteamMarketMode(mode: SteamMarketMode): boolean { export function isLegacySteamMarket(): boolean { return isSteamMarketMode(SteamMarketMode.LEGACY); } + +export function isReactSteamMarket(): boolean { + return isSteamMarketMode(SteamMarketMode.REACT); +} diff --git a/src/lib/components/market/react/listing.ts b/src/lib/components/market/react/listing.ts new file mode 100644 index 00000000..f080802a --- /dev/null +++ b/src/lib/components/market/react/listing.ts @@ -0,0 +1,185 @@ +import {css, nothing} from 'lit'; + +import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; +import {FloatElement} from '../../custom'; +import {CustomElement, InjectAppend, InjectionMode} from '../../injectors'; +import {isReactSteamMarket} from '../mode'; +import {gFloatFetcher} from '../../../services/float_fetcher'; +import {BetaListingRank} from './rank'; + +import {getFiberProps} from '../../../utils/fiber'; +import {Action, rgAssetProperty, rgDescription, rgInternalDescription} from '../../../types/steam'; + +interface EnhancedAppearance { + mime_type: string; + url: string; +} + +interface BetaDescriptionLine extends rgInternalDescription { + color?: string; + name: string; +} + +interface BetaDescription extends Omit { + commodity: boolean; + currency: boolean; + descriptions: BetaDescriptionLine[]; + fraudwarnings: string[]; + tradable: boolean; + market_marketable_restriction: number; + market_bucket_group_name: string; + market_bucket_group_id: string; + market_name_inside_group: string; + marketable: boolean; + owner_actions: Action[]; + owner_descriptions: rgInternalDescription[]; + sealed: boolean; + sealed_type: number; + tags: unknown[]; +} + +interface BetaAsset { + asset_properties: rgAssetProperty[]; + amount: number; + appid: number; + accessory_properties: rgAssetProperty[]; + assetid: string; + classid: string; + contextid: string; + id: string; + instanceid: string; +} + +interface BetaListing { + asset: BetaAsset; + description: BetaDescription; + eCurrency: number; + enhanced_appearances: EnhancedAppearance[]; + listingid: string; + publisherFeeApp: number; + publisherFeePct: number; + strSubtotal: string; + unFee: number; + unFeePerUnit: number; + unPrice: number; + unPricePerUnit: number; + unPublisherFee: number; + unPublisherFeePerUnit: number; + unSteamFee: number; + unSteamFeePerUnit: number; +} + +interface FiberListingProps { + expectEnhancedAppearance: boolean; + listing: BetaListing; +} + +/** + * Simple version of {@link ItemRowWrapper} with reduced functionality, adapted for the Steam Market beta. + */ +@CustomElement() +@InjectAppend( + 'div[style*="--grid-rows"]:has([style*="market_listings/"])', + InjectionMode.CONTINUOUS, + isReactSteamMarket +) +export class BetaListingEnhancer extends FloatElement { + private rankInjected = false; + + static styles = [ + css` + :host { + display: none; + } + `, + ]; + + private get card(): HTMLElement { + const parent = this.parentElement; + if (!parent) throw new Error('Card element not found'); + return parent; + } + + /** Steam implementation detail: the "key" property on the Fiber node is the listing ID. */ + get fiberProps(): FiberListingProps | null { + return getFiberProps(this.card, (fiber) => typeof fiber.key === 'string') ?? null; + } + + get listing(): BetaListing | null { + return this.fiberProps?.listing ?? null; + } + + get listingId(): string | null { + return this.fiberProps?.listing?.listingid ?? null; + } + + get inspectLink(): string | null { + const listing = this.listing; + if (!listing) return null; + + const link = listing.description.actions?.[0]?.link; + if (!link) return null; + + if (link.includes('%propid:6%')) { + const propId = listing.asset.asset_properties?.find((p) => p.propertyid === 6)?.string_value; + if (!propId || !link) return null; + return link.replace('%propid:6%', propId); + } + return link; + } + + get assetId(): string | null { + const listing = this.listing; + if (!listing) return null; + return listing.asset.assetid; + } + + get targetFloat(): number | null { + const wearProp = this.listing?.asset.asset_properties?.find((p) => p.propertyid === 2); + // this is a number in the React properties, but a string in the rgAsset properties + const rawFloat = wearProp?.float_value; + if (rawFloat === undefined || rawFloat === null) return null; + return Number(rawFloat); + } + + connectedCallback(): void { + super.connectedCallback(); + + if (this.inspectLink && this.assetId) { + void this.processListing(this.inspectLink, this.assetId); + } + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + } + + protected render(): typeof nothing { + return nothing; + } + + private async processListing(inspectLink: string, assetId: string): Promise { + if (!inspectLink || !assetId || !this.isConnected || !this.card) return; + + let info: ItemInfo; + try { + info = await gFloatFetcher.fetch({link: inspectLink, asset_id: assetId}); + } catch (e) { + return; + } + + this.injectRank(info); + } + + private injectRank(info: ItemInfo): void { + if (this.rankInjected) return; + this.rankInjected = true; + + const rank = BetaListingRank.elem() as BetaListingRank; + rank.itemInfo = info; + rank.card = this.card; + rank.targetFloat = this.targetFloat; + // Append into the card; the element repositions itself correctly. + this.card.appendChild(rank); + } +} diff --git a/src/lib/components/market/react/rank.ts b/src/lib/components/market/react/rank.ts new file mode 100644 index 00000000..9e9de078 --- /dev/null +++ b/src/lib/components/market/react/rank.ts @@ -0,0 +1,60 @@ +import {css, html, nothing} from 'lit'; +import {property} from 'lit/decorators.js'; + +import {CustomElement} from '../../injectors'; +import {FloatElement} from '../../custom'; +import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; +import {parseRank, renderClickableRank} from '../../../utils/skin'; + +@CustomElement() +export class BetaListingRank extends FloatElement { + @property({type: Object}) itemInfo!: ItemInfo; + @property({attribute: false}) card!: HTMLElement; + @property({attribute: false}) targetFloat!: number | null; + + static styles = [ + css` + :host:has(a[href]) { + margin-left: 4px; + } + `, + ]; + + private injected = false; + + connectedCallback(): void { + super.connectedCallback(); + this.placeNextToWear(); + } + + /** Moves this element next to the wear span in the card. */ + private placeNextToWear(): void { + if (this.injected || !this.itemInfo || !this.card) return; + if (!parseRank(this.itemInfo)) return; + + const wearSpan = this.findWearSpan(); + if (!wearSpan) return; + + this.injected = true; + wearSpan.insertAdjacentElement('afterend', this); + } + + private findWearSpan(): HTMLSpanElement | null { + if (this.targetFloat === null) return null; + + const spans = this.card.querySelectorAll('span[style*="pre-wrap"]'); + for (const span of spans) { + const text = span.textContent?.trim(); + if (!text) continue; + const value = parseFloat(text); + if (!Number.isNaN(value) && Math.abs(value - this.targetFloat) < 1e-6) return span; + } + return null; + } + + protected render() { + if (!this.itemInfo) return nothing; + + return html` e.stopPropagation()}>${renderClickableRank(this.itemInfo)}`; + } +} diff --git a/src/lib/page_scripts/market_listing.ts b/src/lib/page_scripts/market_listing.ts index a7d31f6b..55b425b8 100644 --- a/src/lib/page_scripts/market_listing.ts +++ b/src/lib/page_scripts/market_listing.ts @@ -1,6 +1,7 @@ import {init} from './utils'; import '../components/market/item_row_wrapper'; import '../components/market/utility_belt'; +import '../components/market/react/listing'; init('src/lib/page_scripts/market_listing.js', main); diff --git a/src/lib/utils/fiber.ts b/src/lib/utils/fiber.ts new file mode 100644 index 00000000..3183aadc --- /dev/null +++ b/src/lib/utils/fiber.ts @@ -0,0 +1,47 @@ +/** + * Subset of React's internal Fiber instance shape. These are internal implementation details of React, + * so they may change between React versions. + * + * See React's source for the authoritative definition for Steam's React version (v19.1.1): + * https://github.com/facebook/react/blob/v19.1.1/packages/react-reconciler/src/ReactInternalTypes.js + */ +export interface Fiber { + alternate: Fiber | null; + child: Fiber | null; + deletions: Fiber[] | null; + elementType: unknown; + flags: number; + key: string | null; + memoizedProps: unknown; + pendingProps: unknown; + return: Fiber | null; + sibling: Fiber | null; + stateNode: HTMLElement | null; +} + +/** + * Returns the Fiber instance React attached to `element`, or null if the element was not rendered + * by React (no `__reactFiber` property present). + */ +function getCurrentFiber(element: HTMLElement): Fiber | null { + const key = Object.keys(element).find((k) => k.startsWith('__reactFiber$')); + if (!key) return null; + return (element[key as keyof HTMLElement] as Fiber | undefined) ?? null; +} + +/** + * Walks up the Fiber tree from `element` until `predicate` returns true for an ancestor Fiber, then + * returns that Fiber's `memoizedProps`. + * + * Returns undefined if the element wasn't rendered by React or no ancestor satisfies `predicate`. + */ +export function getFiberProps(element: HTMLElement, predicate: (fiber: Fiber) => boolean): T | undefined { + let fiber = getCurrentFiber(element); + while (fiber) { + if (predicate(fiber)) { + return fiber.memoizedProps as T; + } + fiber = fiber.return; + } + return undefined; +} From 66a5d2e9917428d9e7924b8350ef58419be5f48b Mon Sep 17 00:00:00 2001 From: GODrums Date: Fri, 5 Jun 2026 21:03:45 +0200 Subject: [PATCH 2/9] fix: merging conflict --- src/lib/components/market/mode.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib/components/market/mode.ts b/src/lib/components/market/mode.ts index 022b67df..103cd3fb 100644 --- a/src/lib/components/market/mode.ts +++ b/src/lib/components/market/mode.ts @@ -16,8 +16,4 @@ export function isLegacySteamMarket(): boolean { /** True only if current page is part of the Steam Market AND the beta is being used */ export function isReactSteamMarket(): boolean { return (window as any).SSR?.reactRoot !== undefined; -} - -export function isReactSteamMarket(): boolean { - return isSteamMarketMode(SteamMarketMode.REACT); -} +} \ No newline at end of file From f801879709be89828591e2535ff569e93d8459ce Mon Sep 17 00:00:00 2001 From: GODrums Date: Fri, 5 Jun 2026 21:15:50 +0200 Subject: [PATCH 3/9] remove unused mode --- src/lib/components/market/mode.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/components/market/mode.ts b/src/lib/components/market/mode.ts index 103cd3fb..f296c640 100644 --- a/src/lib/components/market/mode.ts +++ b/src/lib/components/market/mode.ts @@ -1,8 +1,3 @@ -export enum SteamMarketMode { - REACT = 'react', - LEGACY = 'legacy', -} - export function isLegacySteamMarket(): boolean { return ( typeof $J === 'function' && From 7a22b55473a097a58796403be08224bb2d8269c3 Mon Sep 17 00:00:00 2001 From: GODrums Date: Tue, 9 Jun 2026 00:00:49 +0200 Subject: [PATCH 4/9] improve naming of the types --- src/lib/components/market/react/listing.ts | 72 ++-------------------- src/lib/components/market/react/types.ts | 72 ++++++++++++++++++++++ 2 files changed, 76 insertions(+), 68 deletions(-) create mode 100644 src/lib/components/market/react/types.ts diff --git a/src/lib/components/market/react/listing.ts b/src/lib/components/market/react/listing.ts index f080802a..f5d24b1b 100644 --- a/src/lib/components/market/react/listing.ts +++ b/src/lib/components/market/react/listing.ts @@ -8,71 +8,7 @@ import {gFloatFetcher} from '../../../services/float_fetcher'; import {BetaListingRank} from './rank'; import {getFiberProps} from '../../../utils/fiber'; -import {Action, rgAssetProperty, rgDescription, rgInternalDescription} from '../../../types/steam'; - -interface EnhancedAppearance { - mime_type: string; - url: string; -} - -interface BetaDescriptionLine extends rgInternalDescription { - color?: string; - name: string; -} - -interface BetaDescription extends Omit { - commodity: boolean; - currency: boolean; - descriptions: BetaDescriptionLine[]; - fraudwarnings: string[]; - tradable: boolean; - market_marketable_restriction: number; - market_bucket_group_name: string; - market_bucket_group_id: string; - market_name_inside_group: string; - marketable: boolean; - owner_actions: Action[]; - owner_descriptions: rgInternalDescription[]; - sealed: boolean; - sealed_type: number; - tags: unknown[]; -} - -interface BetaAsset { - asset_properties: rgAssetProperty[]; - amount: number; - appid: number; - accessory_properties: rgAssetProperty[]; - assetid: string; - classid: string; - contextid: string; - id: string; - instanceid: string; -} - -interface BetaListing { - asset: BetaAsset; - description: BetaDescription; - eCurrency: number; - enhanced_appearances: EnhancedAppearance[]; - listingid: string; - publisherFeeApp: number; - publisherFeePct: number; - strSubtotal: string; - unFee: number; - unFeePerUnit: number; - unPrice: number; - unPricePerUnit: number; - unPublisherFee: number; - unPublisherFeePerUnit: number; - unSteamFee: number; - unSteamFeePerUnit: number; -} - -interface FiberListingProps { - expectEnhancedAppearance: boolean; - listing: BetaListing; -} +import type {MarketListing, MarketListingProps} from './types'; /** * Simple version of {@link ItemRowWrapper} with reduced functionality, adapted for the Steam Market beta. @@ -101,11 +37,11 @@ export class BetaListingEnhancer extends FloatElement { } /** Steam implementation detail: the "key" property on the Fiber node is the listing ID. */ - get fiberProps(): FiberListingProps | null { - return getFiberProps(this.card, (fiber) => typeof fiber.key === 'string') ?? null; + get fiberProps(): MarketListingProps | null { + return getFiberProps(this.card, (fiber) => typeof fiber.key === 'string') ?? null; } - get listing(): BetaListing | null { + get listing(): MarketListing | null { return this.fiberProps?.listing ?? null; } diff --git a/src/lib/components/market/react/types.ts b/src/lib/components/market/react/types.ts new file mode 100644 index 00000000..85e17920 --- /dev/null +++ b/src/lib/components/market/react/types.ts @@ -0,0 +1,72 @@ +import type {Action, rgAssetProperty, rgDescription, rgInternalDescription} from '../../../types/steam'; + +/** + * Shapes of the props Steam's React (beta) market renders into its listing components. We read these + * off the React Fiber node's `memoizedProps` (see {@link getFiberProps}), but the types describe the + * market listing domain data, not React internals. + */ + +export interface EnhancedAppearance { + mime_type: string; + url: string; +} + +export interface MarketDescriptionLine extends rgInternalDescription { + color?: string; + name: string; +} + +export interface MarketListingDescription + extends Omit { + commodity: boolean; + currency: boolean; + descriptions: MarketDescriptionLine[]; + fraudwarnings: string[]; + tradable: boolean; + market_marketable_restriction: number; + market_bucket_group_name: string; + market_bucket_group_id: string; + market_name_inside_group: string; + marketable: boolean; + owner_actions: Action[]; + owner_descriptions: rgInternalDescription[]; + sealed: boolean; + sealed_type: number; + tags: unknown[]; +} + +export interface MarketListingAsset { + asset_properties: rgAssetProperty[]; + amount: number; + appid: number; + accessory_properties: rgAssetProperty[]; + assetid: string; + classid: string; + contextid: string; + id: string; + instanceid: string; +} + +export interface MarketListing { + asset: MarketListingAsset; + description: MarketListingDescription; + eCurrency: number; + enhanced_appearances: EnhancedAppearance[]; + listingid: string; + publisherFeeApp: number; + publisherFeePct: number; + strSubtotal: string; + unFee: number; + unFeePerUnit: number; + unPrice: number; + unPricePerUnit: number; + unPublisherFee: number; + unPublisherFeePerUnit: number; + unSteamFee: number; + unSteamFeePerUnit: number; +} + +export interface MarketListingProps { + expectEnhancedAppearance: boolean; + listing: MarketListing; +} From 6a1c68f54e2a8668f2cde83c622ea00eb7ce6a43 Mon Sep 17 00:00:00 2001 From: GODrums Date: Tue, 9 Jun 2026 03:50:31 +0200 Subject: [PATCH 5/9] add rank early return --- src/lib/components/market/react/rank.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/market/react/rank.ts b/src/lib/components/market/react/rank.ts index 9e9de078..e2abde00 100644 --- a/src/lib/components/market/react/rank.ts +++ b/src/lib/components/market/react/rank.ts @@ -40,7 +40,7 @@ export class BetaListingRank extends FloatElement { } private findWearSpan(): HTMLSpanElement | null { - if (this.targetFloat === null) return null; + if (!this.targetFloat) return null; const spans = this.card.querySelectorAll('span[style*="pre-wrap"]'); for (const span of spans) { @@ -53,7 +53,7 @@ export class BetaListingRank extends FloatElement { } protected render() { - if (!this.itemInfo) return nothing; + if (!this.itemInfo || !this.targetFloat || !this.card) return nothing; return html` e.stopPropagation()}>${renderClickableRank(this.itemInfo)}`; } From 3850cc81553704b1b7c246bf8a77f999498864be Mon Sep 17 00:00:00 2001 From: GODrums Date: Wed, 10 Jun 2026 00:49:09 +0200 Subject: [PATCH 6/9] chore: run format --- src/lib/components/market/mode.ts | 2 +- src/lib/utils/fiber.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/market/mode.ts b/src/lib/components/market/mode.ts index f296c640..02775183 100644 --- a/src/lib/components/market/mode.ts +++ b/src/lib/components/market/mode.ts @@ -11,4 +11,4 @@ export function isLegacySteamMarket(): boolean { /** True only if current page is part of the Steam Market AND the beta is being used */ export function isReactSteamMarket(): boolean { return (window as any).SSR?.reactRoot !== undefined; -} \ No newline at end of file +} diff --git a/src/lib/utils/fiber.ts b/src/lib/utils/fiber.ts index 3183aadc..4f8ec9a0 100644 --- a/src/lib/utils/fiber.ts +++ b/src/lib/utils/fiber.ts @@ -1,5 +1,5 @@ /** - * Subset of React's internal Fiber instance shape. These are internal implementation details of React, + * Subset of React's internal Fiber instance shape. These are internal implementation details of React, * so they may change between React versions. * * See React's source for the authoritative definition for Steam's React version (v19.1.1): From df0e2a67789b4184655882910f021e941e05d72d Mon Sep 17 00:00:00 2001 From: GODrums Date: Mon, 15 Jun 2026 03:25:32 +0200 Subject: [PATCH 7/9] move guard check into switch --- src/lib/components/injectors.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/components/injectors.ts b/src/lib/components/injectors.ts index 80f16320..7e150226 100644 --- a/src/lib/components/injectors.ts +++ b/src/lib/components/injectors.ts @@ -74,12 +74,12 @@ function Inject(selector: string, mode: InjectionMode, type: InjectionType, guar if (!inPageContext()) { return; } - if (!canInject(guard)) { - return; - } switch (mode) { case InjectionMode.ONCE: + if (!canInject(guard)) { + return; + } document.querySelectorAll(selector).forEach((el) => { InjectionConfigs[type].op(el, target); }); From 59d11cbe24163b651a88248b743930856dc9b571 Mon Sep 17 00:00:00 2001 From: GODrums Date: Tue, 16 Jun 2026 18:01:39 +0200 Subject: [PATCH 8/9] address comments --- src/lib/components/market/mode.ts | 2 +- src/lib/components/market/react/listing.ts | 17 ++++------------- src/lib/components/market/react/rank.ts | 2 +- src/lib/components/market/react/types.ts | 2 +- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/lib/components/market/mode.ts b/src/lib/components/market/mode.ts index 02775183..eadaea94 100644 --- a/src/lib/components/market/mode.ts +++ b/src/lib/components/market/mode.ts @@ -8,7 +8,7 @@ export function isLegacySteamMarket(): boolean { ); } -/** True only if current page is part of the Steam Market AND the beta is being used */ +/** True only if current page is part of the Steam Market AND the React version is being used */ export function isReactSteamMarket(): boolean { return (window as any).SSR?.reactRoot !== undefined; } diff --git a/src/lib/components/market/react/listing.ts b/src/lib/components/market/react/listing.ts index f5d24b1b..5677ad6c 100644 --- a/src/lib/components/market/react/listing.ts +++ b/src/lib/components/market/react/listing.ts @@ -5,13 +5,13 @@ import {FloatElement} from '../../custom'; import {CustomElement, InjectAppend, InjectionMode} from '../../injectors'; import {isReactSteamMarket} from '../mode'; import {gFloatFetcher} from '../../../services/float_fetcher'; -import {BetaListingRank} from './rank'; +import {ReactListingRank} from './rank'; import {getFiberProps} from '../../../utils/fiber'; import type {MarketListing, MarketListingProps} from './types'; /** - * Simple version of {@link ItemRowWrapper} with reduced functionality, adapted for the Steam Market beta. + * Simple version of {@link ItemRowWrapper} with reduced functionality, adapted for the React version of the Steam Market. */ @CustomElement() @InjectAppend( @@ -19,9 +19,7 @@ import type {MarketListing, MarketListingProps} from './types'; InjectionMode.CONTINUOUS, isReactSteamMarket ) -export class BetaListingEnhancer extends FloatElement { - private rankInjected = false; - +export class ReactListingEnhancer extends FloatElement { static styles = [ css` :host { @@ -45,10 +43,6 @@ export class BetaListingEnhancer extends FloatElement { return this.fiberProps?.listing ?? null; } - get listingId(): string | null { - return this.fiberProps?.listing?.listingid ?? null; - } - get inspectLink(): string | null { const listing = this.listing; if (!listing) return null; @@ -108,10 +102,7 @@ export class BetaListingEnhancer extends FloatElement { } private injectRank(info: ItemInfo): void { - if (this.rankInjected) return; - this.rankInjected = true; - - const rank = BetaListingRank.elem() as BetaListingRank; + const rank = ReactListingRank.elem() as ReactListingRank; rank.itemInfo = info; rank.card = this.card; rank.targetFloat = this.targetFloat; diff --git a/src/lib/components/market/react/rank.ts b/src/lib/components/market/react/rank.ts index e2abde00..e756866c 100644 --- a/src/lib/components/market/react/rank.ts +++ b/src/lib/components/market/react/rank.ts @@ -7,7 +7,7 @@ import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; import {parseRank, renderClickableRank} from '../../../utils/skin'; @CustomElement() -export class BetaListingRank extends FloatElement { +export class ReactListingRank extends FloatElement { @property({type: Object}) itemInfo!: ItemInfo; @property({attribute: false}) card!: HTMLElement; @property({attribute: false}) targetFloat!: number | null; diff --git a/src/lib/components/market/react/types.ts b/src/lib/components/market/react/types.ts index 85e17920..83c1753e 100644 --- a/src/lib/components/market/react/types.ts +++ b/src/lib/components/market/react/types.ts @@ -1,7 +1,7 @@ import type {Action, rgAssetProperty, rgDescription, rgInternalDescription} from '../../../types/steam'; /** - * Shapes of the props Steam's React (beta) market renders into its listing components. We read these + * Shapes of the props Steam's React version of the Steam Market renders into its listing components. We read these * off the React Fiber node's `memoizedProps` (see {@link getFiberProps}), but the types describe the * market listing domain data, not React internals. */ From 62ed48e098f96371991657be61f8fda5cd10411a Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 2 Jul 2026 19:26:41 -0600 Subject: [PATCH 9/9] adds declarative react scope injection --- src/lib/components/injectors.ts | 217 +++++++++++++++++++ src/lib/components/market/react/listing.ts | 152 +++++-------- src/lib/components/market/react/placement.ts | 21 ++ src/lib/components/market/react/rank.ts | 53 ++--- src/lib/page_scripts/market_listing.ts | 2 +- 5 files changed, 305 insertions(+), 140 deletions(-) create mode 100644 src/lib/components/market/react/placement.ts diff --git a/src/lib/components/injectors.ts b/src/lib/components/injectors.ts index 7e150226..683af275 100644 --- a/src/lib/components/injectors.ts +++ b/src/lib/components/injectors.ts @@ -12,6 +12,13 @@ export enum InjectionMode { CONTINUOUS, } +export enum InjectionPosition { + Before = 'beforebegin', + Prepend = 'afterbegin', + Append = 'beforeend', + After = 'afterend', +} + enum InjectionType { Append, Before, @@ -24,6 +31,44 @@ interface InjectionConfig { } type InjectionGuard = () => boolean; +type MaybePromise = T | Promise; +type InjectionContextResult = TContext | null | undefined; +type InjectionContextBuilder = (scope: HTMLElement) => MaybePromise>; + +export interface ScopedInjectionArgs { + scope: HTMLElement; + context: TContext; +} + +export interface InjectionScope { + selector: string; + mode: InjectionMode; + guard?: InjectionGuard; + context: InjectionContextBuilder; + state: InjectionScopeState; +} + +interface InjectionScopeState { + contextCache: WeakMap>>; + completed: WeakMap>; + inFlight: WeakMap>; +} + +export interface InjectionScopeConfig { + selector: string; + mode?: InjectionMode; + guard?: InjectionGuard; + context: InjectionContextBuilder; +} + +export interface ScopedInjectionConfig { + anchor: (args: ScopedInjectionArgs) => HTMLElement | null | undefined; + position?: InjectionPosition; +} + +type ScopedElement = FloatElement & { + injectionContext?: TContext; +}; const InjectionConfigs: {[key in InjectionType]: InjectionConfig} = { [InjectionType.Append]: { @@ -69,6 +114,144 @@ export function CustomElement(): any { const canInject = (guard?: InjectionGuard) => (guard ? guard() : true); +function assertNever(value: never): never { + throw new Error(`Unhandled injection mode: ${value}`); +} + +export function defineInjectionScope(config: InjectionScopeConfig): InjectionScope { + return { + ...config, + mode: config.mode ?? InjectionMode.ONCE, + state: { + contextCache: new WeakMap(), + completed: new WeakMap(), + inFlight: new WeakMap(), + }, + }; +} + +function getTagSet(map: WeakMap>, scope: HTMLElement): Set { + let tags = map.get(scope); + if (!tags) { + tags = new Set(); + map.set(scope, tags); + } + return tags; +} + +function hasTag(map: WeakMap>, scope: HTMLElement, tag: string): boolean { + return map.get(scope)?.has(tag) ?? false; +} + +function addTag(map: WeakMap>, scope: HTMLElement, tag: string): void { + getTagSet(map, scope).add(tag); +} + +function deleteTag(map: WeakMap>, scope: HTMLElement, tag: string): void { + map.get(scope)?.delete(tag); +} + +function getCompletedMap( + map: WeakMap>, + scope: HTMLElement +): Map { + let tags = map.get(scope); + if (!tags) { + tags = new Map(); + map.set(scope, tags); + } + return tags; +} + +function hasCompletedInjection( + injectionScope: InjectionScope, + scope: HTMLElement, + tag: string +): boolean { + const element = injectionScope.state.completed.get(scope)?.get(tag); + if (element === undefined) return false; + if (element === null) return true; + if (element.isConnected) return true; + + injectionScope.state.completed.get(scope)?.delete(tag); + return false; +} + +function addCompletedInjection( + injectionScope: InjectionScope, + scope: HTMLElement, + tag: string, + element: Element | null +): void { + getCompletedMap(injectionScope.state.completed, scope).set(tag, element); +} + +async function getScopeContext( + injectionScope: InjectionScope, + scope: HTMLElement +): Promise> { + const cached = injectionScope.state.contextCache.get(scope); + if (cached) return cached; + + const context = Promise.resolve() + .then(() => injectionScope.context(scope)) + .then((result) => { + if (result === undefined) { + injectionScope.state.contextCache.delete(scope); + return result; + } + + injectionScope.state.contextCache.set(scope, Promise.resolve(result)); + return result; + }) + .catch((e) => { + injectionScope.state.contextCache.delete(scope); + throw e; + }); + + injectionScope.state.contextCache.set(scope, context); + return context; +} + +async function injectIntoScope( + scope: HTMLElement, + target: typeof FloatElement, + injectionScope: InjectionScope, + config: ScopedInjectionConfig +): Promise { + const tag = target.tag(); + if (hasCompletedInjection(injectionScope, scope, tag) || hasTag(injectionScope.state.inFlight, scope, tag)) { + return; + } + + addTag(injectionScope.state.inFlight, scope, tag); + + try { + const context = await getScopeContext(injectionScope, scope); + if (context === undefined) return; + if (context === null) { + addCompletedInjection(injectionScope, scope, tag, null); + return; + } + + const anchor = config.anchor({scope, context}); + if (anchor === undefined) return; + if (anchor === null) { + addCompletedInjection(injectionScope, scope, tag, null); + return; + } + + const element = target.elem() as ScopedElement; + element.injectionContext = context; + anchor.insertAdjacentElement(config.position ?? InjectionPosition.Append, element); + addCompletedInjection(injectionScope, scope, tag, element); + } catch (e) { + // Failed context builders are retried on the next scan. + } finally { + deleteTag(injectionScope.state.inFlight, scope, tag); + } +} + function Inject(selector: string, mode: InjectionMode, type: InjectionType, guard?: InjectionGuard): any { return function (target: typeof FloatElement, propertyKey: string, descriptor: PropertyDescriptor) { if (!inPageContext()) { @@ -98,6 +281,8 @@ function Inject(selector: string, mode: InjectionMode, type: InjectionType, guar }); }, 250); break; + default: + assertNever(mode); } }; } @@ -113,3 +298,35 @@ export function InjectBefore(selector: string, mode: InjectionMode = InjectionMo export function InjectAfter(selector: string, mode: InjectionMode = InjectionMode.ONCE, guard?: InjectionGuard): any { return Inject(selector, mode, InjectionType.After, guard); } + +export function InjectIntoScope( + injectionScope: InjectionScope, + config: ScopedInjectionConfig +): any { + return function (target: typeof FloatElement, propertyKey: string, descriptor: PropertyDescriptor) { + if (!inPageContext()) { + return; + } + + const inject = () => { + if (!canInject(injectionScope.guard)) { + return; + } + + document.querySelectorAll(injectionScope.selector).forEach((scope) => { + void injectIntoScope(scope, target, injectionScope, config); + }); + }; + + switch (injectionScope.mode) { + case InjectionMode.ONCE: + inject(); + break; + case InjectionMode.CONTINUOUS: + setInterval(inject, 250); + break; + default: + assertNever(injectionScope.mode); + } + }; +} diff --git a/src/lib/components/market/react/listing.ts b/src/lib/components/market/react/listing.ts index 5677ad6c..9955b2ed 100644 --- a/src/lib/components/market/react/listing.ts +++ b/src/lib/components/market/react/listing.ts @@ -1,112 +1,66 @@ -import {css, nothing} from 'lit'; - import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; -import {FloatElement} from '../../custom'; -import {CustomElement, InjectAppend, InjectionMode} from '../../injectors'; -import {isReactSteamMarket} from '../mode'; import {gFloatFetcher} from '../../../services/float_fetcher'; -import {ReactListingRank} from './rank'; - import {getFiberProps} from '../../../utils/fiber'; +import {defineInjectionScope, InjectionMode} from '../../injectors'; +import {isReactSteamMarket} from '../mode'; import type {MarketListing, MarketListingProps} from './types'; -/** - * Simple version of {@link ItemRowWrapper} with reduced functionality, adapted for the React version of the Steam Market. - */ -@CustomElement() -@InjectAppend( - 'div[style*="--grid-rows"]:has([style*="market_listings/"])', - InjectionMode.CONTINUOUS, - isReactSteamMarket -) -export class ReactListingEnhancer extends FloatElement { - static styles = [ - css` - :host { - display: none; - } - `, - ]; - - private get card(): HTMLElement { - const parent = this.parentElement; - if (!parent) throw new Error('Card element not found'); - return parent; - } - - /** Steam implementation detail: the "key" property on the Fiber node is the listing ID. */ - get fiberProps(): MarketListingProps | null { - return getFiberProps(this.card, (fiber) => typeof fiber.key === 'string') ?? null; - } - - get listing(): MarketListing | null { - return this.fiberProps?.listing ?? null; - } - - get inspectLink(): string | null { - const listing = this.listing; - if (!listing) return null; - - const link = listing.description.actions?.[0]?.link; - if (!link) return null; - - if (link.includes('%propid:6%')) { - const propId = listing.asset.asset_properties?.find((p) => p.propertyid === 6)?.string_value; - if (!propId || !link) return null; - return link.replace('%propid:6%', propId); - } - return link; - } - - get assetId(): string | null { - const listing = this.listing; - if (!listing) return null; - return listing.asset.assetid; - } - - get targetFloat(): number | null { - const wearProp = this.listing?.asset.asset_properties?.find((p) => p.propertyid === 2); - // this is a number in the React properties, but a string in the rgAsset properties - const rawFloat = wearProp?.float_value; - if (rawFloat === undefined || rawFloat === null) return null; - return Number(rawFloat); - } - - connectedCallback(): void { - super.connectedCallback(); - - if (this.inspectLink && this.assetId) { - void this.processListing(this.inspectLink, this.assetId); - } - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - } +export interface ReactListingContext { + listing: MarketListing; + inspectLink: string; + assetId: string; + targetFloat: number | null; + itemInfo: ItemInfo; +} - protected render(): typeof nothing { - return nothing; +export const ReactMarketListingScope = defineInjectionScope({ + selector: 'div[style*="--grid-rows"]:has([style*="market_listings/"])', + mode: InjectionMode.CONTINUOUS, + guard: isReactSteamMarket, + context: buildReactListingContext, +}); + +function getInspectLink(listing: MarketListing): string | null { + const link = listing.description.actions?.[0]?.link; + if (!link) return null; + + if (link.includes('%propid:6%')) { + const propId = listing.asset.asset_properties?.find((p) => p.propertyid === 6)?.string_value; + if (!propId) return null; + return link.replace('%propid:6%', propId); } - private async processListing(inspectLink: string, assetId: string): Promise { - if (!inspectLink || !assetId || !this.isConnected || !this.card) return; + return link; +} - let info: ItemInfo; - try { - info = await gFloatFetcher.fetch({link: inspectLink, asset_id: assetId}); - } catch (e) { - return; - } +function getTargetFloat(listing: MarketListing): number | null { + const wearProp = listing.asset.asset_properties?.find((p) => p.propertyid === 2); + // This is a number in the React properties, but a string in the rgAsset properties. + const rawFloat = wearProp?.float_value; + if (rawFloat === undefined || rawFloat === null) return null; - this.injectRank(info); - } + const targetFloat = Number(rawFloat); + return Number.isNaN(targetFloat) ? null : targetFloat; +} - private injectRank(info: ItemInfo): void { - const rank = ReactListingRank.elem() as ReactListingRank; - rank.itemInfo = info; - rank.card = this.card; - rank.targetFloat = this.targetFloat; - // Append into the card; the element repositions itself correctly. - this.card.appendChild(rank); +async function buildReactListingContext(scope: HTMLElement): Promise { + const listing = getFiberProps(scope, (fiber) => typeof fiber.key === 'string')?.listing; + if (!listing) return undefined; + + const inspectLink = getInspectLink(listing); + const assetId = listing.asset.assetid; + if (!inspectLink || !assetId) return null; + + try { + const itemInfo = await gFloatFetcher.fetch({link: inspectLink, asset_id: assetId}); + return { + listing, + inspectLink, + assetId, + targetFloat: getTargetFloat(listing), + itemInfo, + }; + } catch (e) { + return null; } } diff --git a/src/lib/components/market/react/placement.ts b/src/lib/components/market/react/placement.ts new file mode 100644 index 00000000..8978828f --- /dev/null +++ b/src/lib/components/market/react/placement.ts @@ -0,0 +1,21 @@ +import type {ScopedInjectionArgs} from '../../injectors'; +import {parseRank} from '../../../utils/skin'; +import type {ReactListingContext} from './listing'; + +export function findWearSpan({ + scope, + context, +}: ScopedInjectionArgs): HTMLSpanElement | null | undefined { + if (!parseRank(context.itemInfo) || context.targetFloat === null) return null; + + const spans = scope.querySelectorAll('span[style*="pre-wrap"]'); + for (const span of spans) { + const text = span.textContent?.trim(); + if (!text) continue; + + const value = parseFloat(text); + if (!Number.isNaN(value) && Math.abs(value - context.targetFloat) < 1e-6) return span; + } + + return undefined; +} diff --git a/src/lib/components/market/react/rank.ts b/src/lib/components/market/react/rank.ts index e756866c..8b500d0c 100644 --- a/src/lib/components/market/react/rank.ts +++ b/src/lib/components/market/react/rank.ts @@ -1,16 +1,19 @@ import {css, html, nothing} from 'lit'; import {property} from 'lit/decorators.js'; -import {CustomElement} from '../../injectors'; +import {CustomElement, InjectIntoScope, InjectionPosition} from '../../injectors'; import {FloatElement} from '../../custom'; -import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; -import {parseRank, renderClickableRank} from '../../../utils/skin'; +import {renderClickableRank} from '../../../utils/skin'; +import {ReactMarketListingScope, ReactListingContext} from './listing'; +import {findWearSpan} from './placement'; @CustomElement() +@InjectIntoScope(ReactMarketListingScope, { + anchor: findWearSpan, + position: InjectionPosition.After, +}) export class ReactListingRank extends FloatElement { - @property({type: Object}) itemInfo!: ItemInfo; - @property({attribute: false}) card!: HTMLElement; - @property({attribute: false}) targetFloat!: number | null; + @property({attribute: false}) injectionContext?: ReactListingContext; static styles = [ css` @@ -20,41 +23,11 @@ export class ReactListingRank extends FloatElement { `, ]; - private injected = false; - - connectedCallback(): void { - super.connectedCallback(); - this.placeNextToWear(); - } - - /** Moves this element next to the wear span in the card. */ - private placeNextToWear(): void { - if (this.injected || !this.itemInfo || !this.card) return; - if (!parseRank(this.itemInfo)) return; - - const wearSpan = this.findWearSpan(); - if (!wearSpan) return; - - this.injected = true; - wearSpan.insertAdjacentElement('afterend', this); - } - - private findWearSpan(): HTMLSpanElement | null { - if (!this.targetFloat) return null; - - const spans = this.card.querySelectorAll('span[style*="pre-wrap"]'); - for (const span of spans) { - const text = span.textContent?.trim(); - if (!text) continue; - const value = parseFloat(text); - if (!Number.isNaN(value) && Math.abs(value - this.targetFloat) < 1e-6) return span; - } - return null; - } - protected render() { - if (!this.itemInfo || !this.targetFloat || !this.card) return nothing; + if (!this.injectionContext) return nothing; - return html` e.stopPropagation()}>${renderClickableRank(this.itemInfo)}`; + return html` e.stopPropagation()}> + ${renderClickableRank(this.injectionContext.itemInfo)} + `; } } diff --git a/src/lib/page_scripts/market_listing.ts b/src/lib/page_scripts/market_listing.ts index 55b425b8..e8149f06 100644 --- a/src/lib/page_scripts/market_listing.ts +++ b/src/lib/page_scripts/market_listing.ts @@ -1,7 +1,7 @@ import {init} from './utils'; import '../components/market/item_row_wrapper'; import '../components/market/utility_belt'; -import '../components/market/react/listing'; +import '../components/market/react/rank'; init('src/lib/page_scripts/market_listing.js', main);