mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Enable React Query (proposal #2) + first real-data pilot on OfferView
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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 } }
|
||||
* });
|
||||
* <QueryClientProvider client={queryClient}><App /></QueryClientProvider>
|
||||
*
|
||||
* 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<TParser extends IMessageEvent, TData>
|
||||
{
|
||||
/**
|
||||
* Stable key used for caching/deduping (TanStack Query queryKey).
|
||||
* Convention: ['nitro', '<domain>', '<request>', ...args].
|
||||
* Stable key for caching/deduping. Convention:
|
||||
* `['nitro', '<domain>', '<request>', ...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<TParser extends IMessageEvent, TData>
|
||||
*/
|
||||
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<TData>((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 = <TParser extends IMessageEvent, TData = TParser>(
|
||||
config: NitroQueryConfig<TParser, TData>
|
||||
): UseQueryResult<TData> =>
|
||||
{
|
||||
const { key, request, parser, select, timeoutMs = 15_000, enabled, staleTime, refetchOnMount } = config;
|
||||
|
||||
const options: UseQueryOptions<TData, Error, TData> = {
|
||||
queryKey: key,
|
||||
queryFn: () => awaitNitroResponse<TParser, TData>({ 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 = <TParser extends IMessageEvent, TData>(
|
||||
_cfg: NitroQueryConfig<TParser, TData>
|
||||
config: Pick<NitroQueryConfig<TParser, TData>, 'request' | 'parser' | 'select' | 'timeoutMs'>
|
||||
): Promise<TData> =>
|
||||
new Promise<TData>((resolve, reject) =>
|
||||
{
|
||||
void SendMessageComposer;
|
||||
throw new Error('useNitroQuery is not enabled. See docs/ARCHITECTURE.md proposal #2.');
|
||||
const { request, parser: ParserCtor, select, timeoutMs = 15_000 } = config;
|
||||
|
||||
let settled = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | 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());
|
||||
});
|
||||
|
||||
@@ -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, TargetedOfferData>(
|
||||
TargetedOfferEvent,
|
||||
evt => evt.getParser()?.data ?? null,
|
||||
null
|
||||
);
|
||||
const { data: offer } = useNitroQuery<TargetedOfferEvent, TargetedOfferData>({
|
||||
key: [ 'nitro', 'catalog', 'targeted-offer' ],
|
||||
request: () => new GetTargetedOfferComposer(),
|
||||
parser: TargetedOfferEvent,
|
||||
select: evt => evt.getParser()?.data ?? null,
|
||||
staleTime: Infinity
|
||||
});
|
||||
|
||||
const [ opened, setOpened ] = useState<boolean>(false);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
SendMessageComposer(new GetTargetedOfferComposer());
|
||||
}, []);
|
||||
if(!offer) return null;
|
||||
|
||||
if(!offer) return;
|
||||
|
||||
return <>
|
||||
{ opened ? <OfferWindowView offer={ offer } setOpen={ setOpened } /> : <OfferBubbleView offer={ offer } setOpen={ setOpened } /> }
|
||||
</>;
|
||||
return (
|
||||
<>
|
||||
{ opened
|
||||
? <OfferWindowView offer={ offer } setOpen={ setOpened } />
|
||||
: <OfferBubbleView offer={ offer } setOpen={ setOpened } /> }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,6 +56,7 @@ import './css/widgets/FurnitureWidgets.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={ queryClient }>
|
||||
<ErrorBoundary
|
||||
fallbackRender={ ({ error }) => (
|
||||
<LoadingView
|
||||
@@ -56,5 +68,6 @@ createRoot(document.getElementById('root')).render(
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user