From 34b1b56788f2cef40d0dbfb13edd926e4a9b7475 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 11 May 2026 16:31:53 +0000 Subject: [PATCH] Enable React Query (proposal #2) + first real-data pilot on OfferView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the refactor plan in docs/ARCHITECTURE.md. Install - yarn add @tanstack/react-query@5 @tanstack/react-query-devtools@5 - Both pinned to ^5 (matches React 19 peer requirement). Wiring - src/index.tsx: mounts QueryClientProvider above ErrorBoundary + Suspense. Default config: staleTime=30s, retry=1, refetchOnWindowFocus=false (chat client, not a data dashboard). Adapter - src/api/nitro-query/createNitroQuery.ts: replaces the previous prototype that just threw. Exposes: * useNitroQuery({ key, request, parser, select, timeoutMs }) — wraps TanStack's useQuery; queryFn awaits the parser response. * awaitNitroResponse(...) — lower-level helper for imperative use via queryClient.fetchQuery. The Promise: 1. registers the parser via GetCommunication().registerMessageEvent 2. dispatches the composer via SendMessageComposer 3. resolves with select(event) on the first matching parser 4. rejects after timeoutMs (default 15s) 5. always cleans up the listener + timeout (cancel-safe). Pilot - src/components/catalog/views/targeted-offer/OfferView.tsx: the previous useMessageEventState + manual useEffect-send pattern becomes a single useNitroQuery call. staleTime:Infinity because the targeted offer doesn't change during a session. Subsequent OfferView remounts (e.g. opening/closing the dialog) now reuse the cached payload — the GetTargetedOfferComposer is no longer re-sent each time. Verification - yarn eslint on the four touched files: 1 pre-existing no-redundant-type-constituents error (IMessageEvent resolves as `any` in the local sandbox without the renderer SDK installed; matches the 12 other pre-existing instances of the same false positive). - yarn tsc on the four touched files: clean (modulo the project-wide TS2307 about @nitrots/nitro-renderer). - The original prototype's "throw" guard is gone — useNitroQuery is now callable. Migration path (per docs/ARCHITECTURE.md) - Next adoption targets (read-only fetches first): useCatalog's page data, useInventoryFurni's bot listing, Navigator search results, Marketplace listings. - Push messages (server-pushed events the client doesn't request) keep using useNitroEventState / useMessageEventState — they're subscriptions, not requests. https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q --- package.json | 2 + src/api/nitro-query/createNitroQuery.ts | 148 +++++++++++------- .../views/targeted-offer/OfferView.tsx | 35 +++-- src/index.tsx | 35 +++-- yarn.lock | 24 +++ 5 files changed, 159 insertions(+), 85 deletions(-) diff --git a/package.json b/package.json index cc2c81b..89ca35f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@emoji-mart/react": "^1.1.1", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slider": "^1.3.6", + "@tanstack/react-query": "5", + "@tanstack/react-query-devtools": "5", "@tanstack/react-virtual": "^3.13.24", "dompurify": "^3.4.2", "emoji-mart": "^5.6.0", diff --git a/src/api/nitro-query/createNitroQuery.ts b/src/api/nitro-query/createNitroQuery.ts index 44ff82f..1bf73ff 100644 --- a/src/api/nitro-query/createNitroQuery.ts +++ b/src/api/nitro-query/createNitroQuery.ts @@ -1,42 +1,20 @@ -/** - * Adapter prototype for proposal #2 (server requests as queries). - * - * NOT YET ENABLED — `@tanstack/react-query` is not in package.json. - * To activate: - * - * yarn add @tanstack/react-query @tanstack/react-query-devtools - * - * Then mount the provider once in `src/index.tsx`: - * - * import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - * const queryClient = new QueryClient({ - * defaultOptions: { queries: { staleTime: 30_000, retry: 1 } } - * }); - * - * - * Then this file becomes: - * - * import { useQuery } from '@tanstack/react-query'; - * ... - * - * The interface below shows the intended API. Once enabled, replace the - * placeholder bodies with the real `useQuery` calls. - */ - -import { IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer'; +import { GetCommunication, IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer'; +import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import { SendMessageComposer } from '../SendMessageComposer'; export interface NitroQueryConfig { /** - * Stable key used for caching/deduping (TanStack Query queryKey). - * Convention: ['nitro', '', '', ...args]. + * Stable key for caching/deduping. Convention: + * `['nitro', '', '', ...args]`. */ - key: readonly unknown[]; + key: QueryKey; /** * Factory for the request composer. Called once per query execution. + * `null` skips sending (useful when the server pushes the event + * unprompted — you only want subscription, not a request). */ - request: () => any; + request: (() => unknown) | null; /** * The parser class to listen for as the response. */ @@ -46,39 +24,95 @@ export interface NitroQueryConfig */ select?: (event: TParser) => TData; /** - * Optional max time to wait for the response before failing. + * Max time to wait for the response before rejecting (default 15s). */ timeoutMs?: number; + /** + * Forwarded to TanStack Query. + */ + enabled?: boolean; + staleTime?: number; + refetchOnMount?: boolean | 'always'; } /** - * Build a one-shot Promise that sends a composer and resolves with the - * matching parser event. To be passed into TanStack Query's queryFn: + * Wraps a Nitro composer/parser request-response pair as a TanStack Query + * `useQuery` call. The returned object is the standard TanStack result — + * `{ data, isLoading, isError, error, refetch, ... }`. * - * useQuery({ - * queryKey: cfg.key, - * queryFn: () => awaitNitroResponse(cfg), - * }); - * - * Implementation outline (filled in once react-query is added): - * - * return new Promise((resolve, reject) => { - * const event = new cfg.parser((e: TParser) => { - * GetCommunication().removeMessageEvent(event); - * resolve(cfg.select ? cfg.select(e) : (e as unknown as TData)); - * }); - * GetCommunication().registerMessageEvent(event); - * SendMessageComposer(cfg.request()); - * if (cfg.timeoutMs) setTimeout(() => { - * GetCommunication().removeMessageEvent(event); - * reject(new Error('NitroQuery timeout')); - * }, cfg.timeoutMs); - * }); + * Behavior: + * - On the first subscribe, registers the parser, sends the composer, + * resolves the Promise with the selected payload when the parser fires. + * - Default `staleTime` is the QueryClient default (30s). + * - Subsequent mounts within `staleTime` get the cached value immediately; + * the request is NOT re-sent. + * - Identical concurrent calls (same `key`) are deduped. + */ +export const useNitroQuery = ( + config: NitroQueryConfig +): UseQueryResult => +{ + const { key, request, parser, select, timeoutMs = 15_000, enabled, staleTime, refetchOnMount } = config; + + const options: UseQueryOptions = { + queryKey: key, + queryFn: () => awaitNitroResponse({ key, request, parser, select, timeoutMs }), + enabled, + staleTime, + refetchOnMount + }; + + return useQuery(options); +}; + +/** + * Lower-level helper: send a composer (if any) and resolve with the next + * matching parser event. Exposed so `queryClient.fetchQuery({...})` callers + * can use the same plumbing imperatively. */ export const awaitNitroResponse = ( - _cfg: NitroQueryConfig + config: Pick, 'request' | 'parser' | 'select' | 'timeoutMs'> ): Promise => -{ - void SendMessageComposer; - throw new Error('useNitroQuery is not enabled. See docs/ARCHITECTURE.md proposal #2.'); -}; + new Promise((resolve, reject) => + { + const { request, parser: ParserCtor, select, timeoutMs = 15_000 } = config; + + let settled = false; + let timeoutHandle: ReturnType | null = null; + let listener: IMessageEvent | undefined = undefined; + + const cleanup = () => + { + if(timeoutHandle !== null) clearTimeout(timeoutHandle); + if(listener) GetCommunication().removeMessageEvent(listener); + }; + + listener = new (ParserCtor as any)((event: TParser) => + { + if(settled) return; + settled = true; + + cleanup(); + + try + { + resolve(select ? select(event) : (event)); + } + catch(err) + { + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + + GetCommunication().registerMessageEvent(listener); + + timeoutHandle = setTimeout(() => + { + if(settled) return; + settled = true; + cleanup(); + reject(new Error(`NitroQuery timed out after ${ timeoutMs }ms`)); + }, timeoutMs); + + if(request) SendMessageComposer(request()); + }); diff --git a/src/components/catalog/views/targeted-offer/OfferView.tsx b/src/components/catalog/views/targeted-offer/OfferView.tsx index 4fa945b..0ec2637 100644 --- a/src/components/catalog/views/targeted-offer/OfferView.tsx +++ b/src/components/catalog/views/targeted-offer/OfferView.tsx @@ -1,27 +1,28 @@ import { GetTargetedOfferComposer, TargetedOfferData, TargetedOfferEvent } from '@nitrots/nitro-renderer'; -import { useEffect, useState } from 'react'; -import { SendMessageComposer } from '../../../../api'; -import { useMessageEventState } from '../../../../hooks'; +import { useState } from 'react'; +import { useNitroQuery } from '../../../../api/nitro-query'; import { OfferBubbleView } from './OfferBubbleView'; import { OfferWindowView } from './OfferWindowView'; export const OfferView = () => { - const offer = useMessageEventState( - TargetedOfferEvent, - evt => evt.getParser()?.data ?? null, - null - ); + const { data: offer } = useNitroQuery({ + key: [ 'nitro', 'catalog', 'targeted-offer' ], + request: () => new GetTargetedOfferComposer(), + parser: TargetedOfferEvent, + select: evt => evt.getParser()?.data ?? null, + staleTime: Infinity + }); + const [ opened, setOpened ] = useState(false); - useEffect(() => - { - SendMessageComposer(new GetTargetedOfferComposer()); - }, []); + if(!offer) return null; - if(!offer) return; - - return <> - { opened ? : } - ; + return ( + <> + { opened + ? + : } + + ); }; diff --git a/src/index.tsx b/src/index.tsx index 40553d6..8621453 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,20 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { StrictMode, Suspense } from 'react'; import { createRoot } from 'react-dom/client'; import { ErrorBoundary } from 'react-error-boundary'; import { App } from './App'; import { LoadingView } from './components/loading/LoadingView'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false + } + } +}); + import './css/index.css'; import './css/backgrounds/BackgroundsView.css'; @@ -45,16 +56,18 @@ import './css/widgets/FurnitureWidgets.css'; createRoot(document.getElementById('root')).render( - ( - - ) }> - }> - - - + + ( + + ) }> + }> + + + + ); diff --git a/yarn.lock b/yarn.lock index 62b1fef..1108213 100644 --- a/yarn.lock +++ b/yarn.lock @@ -908,6 +908,30 @@ postcss "^8.5.6" tailwindcss "4.2.4" +"@tanstack/query-core@5.100.10": + version "5.100.10" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.100.10.tgz#aeb34d301fd4ff9762e67dfa018adc33b7a18be4" + integrity sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w== + +"@tanstack/query-devtools@5.100.10": + version "5.100.10" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.100.10.tgz#1972789fdc7c4cb9ec2062d51f25bc4dc655a27b" + integrity sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw== + +"@tanstack/react-query-devtools@5": + version "5.100.10" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.100.10.tgz#cca3479cc2c8b434637c31f8119fe6ff93e5832c" + integrity sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg== + dependencies: + "@tanstack/query-devtools" "5.100.10" + +"@tanstack/react-query@5": + version "5.100.10" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.100.10.tgz#3bf1844efd76f5f68f9f39da2917fc4c6023e726" + integrity sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q== + dependencies: + "@tanstack/query-core" "5.100.10" + "@tanstack/react-virtual@^3.13.24": version "3.13.24" resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz#77af3d5dcf77358d805b7b3b06d3221af7bd3f6f"