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"