-
Notifications
You must be signed in to change notification settings - Fork 39
Integrate Fiber State Management and Float Ranks #399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
9dc6c6c
900ff1c
66a5d2e
f801879
7a22b55
48bc033
6a1c68f
5823a60
3850cc8
df0e2a6
59d11cb
0aecdaa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
| 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); | ||
| } | ||
|
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; | ||
| } | ||
|
GODrums marked this conversation as resolved.
|
||
| return null; | ||
| } | ||
|
Comment on lines
+31
to
+53
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, the imperative handling isn't ideal. I do believe using Without the context from We also have to pass down the fetched listing props and 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>`; | ||
| } | ||
| } | ||
| 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; | ||
| } |
| 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; | ||
| } |
There was a problem hiding this comment.
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
memoizedPropscontainslistingas a field. Valve could add a ancestor element that is closer with some props that breaks this parsing more easily IMO.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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:
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
keyas it's the unique identifier of the list rendering. Something like this:Technically, we could switch to detecting
listingwithinmemoizedProps, it shouldn't really matter at all though as there cannot be any React components in our traversal up beforekeyis defined. On the other hand, if Valve decides to rename thelistingprops 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. Usingkeywe are guaranteed to stop at the list-rendering React component.