Skip to content
6 changes: 3 additions & 3 deletions src/lib/components/injectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>(selector).forEach((el) => {
InjectionConfigs[type].op(el, target);
});
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/market/mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
112 changes: 112 additions & 0 deletions src/lib/components/market/react/listing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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 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<MarketListingProps>(this.card, (fiber) => typeof fiber.key === 'string') ?? null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd expect this to be more stable if we checked that memoizedProps contains listing as a field. Valve could add a ancestor element that is closer with some props that breaks this parsing more easily IMO.

@GODrums GODrums Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite. The way this traversal works is that we traverse through the original React layers until we reach the base component. From my analysis, their code looks something like this:

function ListingCard({ listing, ... }) {
  return (
    <{/* Some memo/forwardRef wrapper */}>
      <Grid ...>
        <Focusable {/* React.context wrapper for controller / Steam Deck */} ...>
          <div ...> /* ← this is our entry HTML traversal element */
             {/* inner content */}
          </div>
        </Focusable>
      </Grid>
    </{wrapper}>
  );
}

So we basically traverse the non-rendered layers up until we reach the base of the React component. And we know this one will always contain key as it's the unique identifier of the list rendering. Something like this:

function ListingResults({ listings }) {
  return (
    <>
      {listings.map((listing) => (
        <ListingCard
          key={listing.listingid}        // ← this is fiber.key
          listing={listing}
          ...
        />
      ))}
    </>
  );
}

Technically, we could switch to detecting listing within memoizedProps, it shouldn't really matter at all though as there cannot be any React components in our traversal up before key is defined. On the other hand, if Valve decides to rename the listing props later on, we would traverse up until the top root in the worst case, which would make the website pretty laggy for users until we push a fix. Using key we are guaranteed to stop at the list-rendering React component.

}

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();
}

protected render(): typeof nothing {
return nothing;
}

private async processListing(inspectLink: string, assetId: string): Promise<void> {
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 {
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);
}
}
60 changes: 60 additions & 0 deletions src/lib/components/market/react/rank.ts
Original file line number Diff line number Diff line change
@@ -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 ReactListingRank 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);
}
Comment thread
GODrums marked this conversation as resolved.

private findWearSpan(): HTMLSpanElement | null {
if (!this.targetFloat) return null;

const spans = this.card.querySelectorAll<HTMLSpanElement>('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;
}
Comment thread
GODrums marked this conversation as resolved.
return null;
}
Comment on lines +31 to +53

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally understandable that it is getting quite difficult to figure out which element relates to wear considering we can't rely on DOM siblings being consistent (i.e. some skins have elements like stickers).

However, we're starting to break the pattern of declarative handling (granted, I think some usages of the old market UI also did).

I'm wondering if we can find a middle-ground where InjectionGuard inside of @Inject* can provide some of this context filtering that can't easily be encoded into a query selector. For instance, we'd have a @InjectAppend decorator for BetaListingRank that attempts to target the exact element we want to append to. Since we expect our query selector to collide, we can pass the proposed element into the InjectionGuard and do some context handling to deduce whether a float is there.

As an alternative, to make our lives simpler, we could try to inject into a more stable element. However, you'd lose the flair of having the rank next to the float.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the imperative handling isn't ideal. I do believe using ReactListingEnhancer as orchestrator is the simplest and most stable approach though.

Without the context from ReactListingEnhancer (i.e. knowing the float we look for), the guard could only search for "a parseable number between 0 and 1", which becomes extremely brittle (e.g. a skin may have an applied name tag with a matching number).
This approach will quickly hit its limits, for example for our paint seed enhancements in #401 ("any number between 0 and 1000") as it wouldn't be able to distinguish between the pattern template and paint seed.

We also have to pass down the fetched listing props and itemInfo from ReactListingEnhancer (here: low_rank, high_rank) as all smaller injections depend on it. Fetching them directly wouldn't be possible.

I would leave the final choice up to you, but the declarative approach would definitely come with much stricter limits along the way, which might not be easily solvable.


protected render() {
if (!this.itemInfo || !this.targetFloat || !this.card) return nothing;

return html`<span @click=${(e: Event) => e.stopPropagation()}>${renderClickableRank(this.itemInfo)}</span>`;
}
}
72 changes: 72 additions & 0 deletions src/lib/components/market/react/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type {Action, rgAssetProperty, rgDescription, rgInternalDescription} from '../../../types/steam';

/**
* 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.
*/

export interface EnhancedAppearance {
mime_type: string;
url: string;
}

export interface MarketDescriptionLine extends rgInternalDescription {
color?: string;
name: string;
}

export interface MarketListingDescription
extends Omit<rgDescription, 'commodity' | 'tradable' | 'marketable' | 'tags'> {
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;
}
1 change: 1 addition & 0 deletions src/lib/page_scripts/market_listing.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down
47 changes: 47 additions & 0 deletions src/lib/utils/fiber.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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;
}