@jini-ai/ui 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +31 -0
  3. package/dist/features/interactive-ui/interactive-ui.css +1 -1
  4. package/dist/features/interactive-ui/providers/native/data-table.js +8 -1
  5. package/dist/features/interactive-ui/providers/native/data-table.manifest.d.ts +3 -5
  6. package/dist/features/interactive-ui/providers/native/data-table.manifest.js +5 -7
  7. package/dist/features/interactive-ui/providers/recharts/bar-chart.js +8 -3
  8. package/dist/features/interactive-ui/providers/recharts/chart-config.d.ts +40 -0
  9. package/dist/features/interactive-ui/providers/recharts/chart-config.js +32 -0
  10. package/dist/features/interactive-ui/providers/recharts/chart-tokens.d.ts +18 -12
  11. package/dist/features/interactive-ui/providers/recharts/chart-tokens.js +6 -11
  12. package/dist/features/interactive-ui/providers/recharts/chart.d.ts +31 -0
  13. package/dist/features/interactive-ui/providers/recharts/chart.js +34 -0
  14. package/dist/features/interactive-ui/providers/recharts/line-chart.js +10 -3
  15. package/dist/features/interactive-ui/providers/recharts/pie-chart.js +8 -3
  16. package/dist/features/interactive-ui/providers/shadcn/data-table.d.ts +5 -1
  17. package/dist/features/interactive-ui/providers/shadcn/data-table.js +26 -2
  18. package/dist/features/interactive-ui/providers/shadcn/data-table.manifest.d.ts +8 -1
  19. package/dist/features/interactive-ui/providers/shadcn/data-table.manifest.js +6 -3
  20. package/dist/features/interactive-ui/table-data.d.ts +25 -0
  21. package/dist/features/interactive-ui/table-data.js +36 -0
  22. package/dist/features/mcp-ui/sandbox-proxy-link-forwarder.d.ts +26 -0
  23. package/dist/features/mcp-ui/sandbox-proxy-link-forwarder.js +40 -0
  24. package/dist/features/mcp-ui/sandbox-proxy.d.ts +5 -1
  25. package/dist/features/mcp-ui/sandbox-proxy.js +11 -0
  26. package/dist/features/mcp-ui/surfaces/bridge.js +6 -2
  27. package/dist/features/panel-kit/fetch-query/adapter.react.js +3 -1
  28. package/dist/features/panel-kit/fetch-query/adapter.tanstack.d.ts +11 -0
  29. package/dist/features/panel-kit/fetch-query/adapter.tanstack.js +190 -0
  30. package/dist/features/panel-kit/fetch-query/binding.react.d.ts +17 -0
  31. package/dist/features/panel-kit/fetch-query/binding.react.js +24 -0
  32. package/dist/features/panel-kit/fetch-query/cache.d.ts +2 -0
  33. package/dist/features/panel-kit/fetch-query/cache.js +2 -1
  34. package/dist/features/panel-kit/fetch-query/index.d.ts +2 -1
  35. package/dist/features/panel-kit/fetch-query/index.js +2 -1
  36. package/dist/features/panel-kit/fetch-query/tanstack.d.ts +6 -0
  37. package/dist/features/panel-kit/fetch-query/tanstack.js +4 -0
  38. package/dist/features/panel-kit/fetch-query/types.d.ts +2 -0
  39. package/package.json +17 -5
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import type { InteractiveComponentManifest } from '../../types.js';
3
3
  /**
4
- * Same wire shape as `native.data-table` (see that manifest) — same capabilities, so
4
+ * Same required wire shape as `native.data-table` (see that manifest), plus optional local
5
+ * sorting/paging settings — shared capabilities, so
5
6
  * `resolveByCapability('data-table')` returns both, proving the fallback-chain mechanic across
6
7
  * two real providers. `.passthrough()` for the same reason as that manifest: an A2UI-embedded
7
8
  * instance may carry an `action` prop this schema doesn't itself validate.
@@ -18,6 +19,8 @@ export declare const shadcnDataTablePropsSchema: z.ZodObject<{
18
19
  label: string;
19
20
  }>, "many">;
20
21
  rows: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>, "many">;
22
+ sortable: z.ZodOptional<z.ZodBoolean>;
23
+ pageSize: z.ZodOptional<z.ZodNumber>;
21
24
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
22
25
  columns: z.ZodArray<z.ZodObject<{
23
26
  key: z.ZodString;
@@ -30,6 +33,8 @@ export declare const shadcnDataTablePropsSchema: z.ZodObject<{
30
33
  label: string;
31
34
  }>, "many">;
32
35
  rows: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>, "many">;
36
+ sortable: z.ZodOptional<z.ZodBoolean>;
37
+ pageSize: z.ZodOptional<z.ZodNumber>;
33
38
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
34
39
  columns: z.ZodArray<z.ZodObject<{
35
40
  key: z.ZodString;
@@ -42,6 +47,8 @@ export declare const shadcnDataTablePropsSchema: z.ZodObject<{
42
47
  label: string;
43
48
  }>, "many">;
44
49
  rows: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>, "many">;
50
+ sortable: z.ZodOptional<z.ZodBoolean>;
51
+ pageSize: z.ZodOptional<z.ZodNumber>;
45
52
  }, z.ZodTypeAny, "passthrough">>;
46
53
  export declare const shadcnDataTableManifest: InteractiveComponentManifest;
47
54
  //# sourceMappingURL=data-table.manifest.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  /**
3
- * Same wire shape as `native.data-table` (see that manifest) — same capabilities, so
3
+ * Same required wire shape as `native.data-table` (see that manifest), plus optional local
4
+ * sorting/paging settings — shared capabilities, so
4
5
  * `resolveByCapability('data-table')` returns both, proving the fallback-chain mechanic across
5
6
  * two real providers. `.passthrough()` for the same reason as that manifest: an A2UI-embedded
6
7
  * instance may carry an `action` prop this schema doesn't itself validate.
@@ -9,13 +10,15 @@ export const shadcnDataTablePropsSchema = z
9
10
  .object({
10
11
  columns: z.array(z.object({ key: z.string(), label: z.string() }).strict()).min(1),
11
12
  rows: z.array(z.record(z.unknown())),
13
+ sortable: z.boolean().optional(),
14
+ pageSize: z.number().int().min(1).max(100).optional(),
12
15
  })
13
16
  .passthrough();
14
17
  export const shadcnDataTableManifest = {
15
18
  id: 'shadcn.data-table',
16
19
  provider: 'shadcn',
17
- capabilities: ['data-table', 'table', 'tabular-data'],
20
+ capabilities: ['data-table', 'table', 'tabular-data', 'spreadsheet', 'list-of-records'],
18
21
  propsSchema: shadcnDataTablePropsSchema,
19
- description: 'shadcn/ui table primitives (real CLI-pulled source), composed into columns + rows, optional row click.',
22
+ description: 'Preferred styled table for tabular data, spreadsheets and lists of records: columns + rows, optional row click. Rounded border, sticky muted headers, row hover, numeric alignment and empty state. Client-side column sorting (sortable defaults to true); optional pageSize (1–100) enables paging. Load @jini-ai/ui/interactive-ui.css in the renderer host.',
20
23
  };
21
24
  //# sourceMappingURL=data-table.manifest.js.map
@@ -0,0 +1,25 @@
1
+ interface TableData {
2
+ readonly columns: readonly {
3
+ readonly key: string;
4
+ }[];
5
+ readonly rows: readonly Record<string, unknown>[];
6
+ }
7
+ export interface TableSort {
8
+ readonly key: string;
9
+ readonly direction: 'ascending' | 'descending';
10
+ }
11
+ /** Infer numeric columns once for both providers; empty cells do not turn a numeric column into text.
12
+ * @complexity O(c × r), c columns and r rows; no per-cell rescans during rendering. */
13
+ export declare function getNumericColumnKeys({ columns, rows }: TableData, _options?: {}): ReadonlySet<string>;
14
+ /** Sort a copy while preserving source indexes for host actions; missing values stay last in both directions.
15
+ * @complexity O(r log r) time and O(r) space for r rows; the caller memoizes the result. */
16
+ export declare function sortTableRows({ rows, sort, numericKeys }: {
17
+ readonly rows: TableData['rows'];
18
+ readonly sort: TableSort | null;
19
+ readonly numericKeys: ReadonlySet<string>;
20
+ }, _options?: {}): {
21
+ row: Record<string, unknown>;
22
+ index: number;
23
+ }[];
24
+ export {};
25
+ //# sourceMappingURL=table-data.d.ts.map
@@ -0,0 +1,36 @@
1
+ function isNumericValue(value) {
2
+ if (typeof value === 'number')
3
+ return Number.isFinite(value);
4
+ return typeof value === 'string' && /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim())
5
+ && Number.isFinite(Number(value));
6
+ }
7
+ /** Infer numeric columns once for both providers; empty cells do not turn a numeric column into text.
8
+ * @complexity O(c × r), c columns and r rows; no per-cell rescans during rendering. */
9
+ export function getNumericColumnKeys({ columns, rows }, _options = {}) {
10
+ return new Set(columns.filter(({ key }) => {
11
+ const values = rows.map((row) => row[key]).filter((value) => value !== null && value !== undefined && value !== '');
12
+ return values.length > 0 && values.every(isNumericValue);
13
+ }).map(({ key }) => key));
14
+ }
15
+ /** Sort a copy while preserving source indexes for host actions; missing values stay last in both directions.
16
+ * @complexity O(r log r) time and O(r) space for r rows; the caller memoizes the result. */
17
+ export function sortTableRows({ rows, sort, numericKeys }, _options = {}) {
18
+ const indexed = rows.map((row, index) => ({ row, index }));
19
+ if (!sort)
20
+ return indexed;
21
+ const { key, direction } = sort;
22
+ const multiplier = direction === 'ascending' ? 1 : -1;
23
+ return indexed.sort((a, b) => {
24
+ const left = a.row[key];
25
+ const right = b.row[key];
26
+ const leftEmpty = left === null || left === undefined || left === '';
27
+ const rightEmpty = right === null || right === undefined || right === '';
28
+ if (leftEmpty || rightEmpty)
29
+ return Number(leftEmpty) - Number(rightEmpty);
30
+ const compared = numericKeys.has(key)
31
+ ? Number(left) - Number(right)
32
+ : String(left).localeCompare(String(right));
33
+ return compared * multiplier;
34
+ });
35
+ }
36
+ //# sourceMappingURL=table-data.js.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @module features/mcp-ui/sandbox-proxy-link-forwarder
3
+ *
4
+ * Page-script source the sandbox proxy (`sandbox-proxy.ts`) embeds so a View's plain `<a href>`
5
+ * opens in a new tab. Inside the proxy's frame a link is useless on its own: `AppFrame`'s hardcoded
6
+ * sandbox has neither `allow-popups` nor `allow-top-navigation`, so a same-tab link navigates the
7
+ * card's own frame away from the View and a `target="_blank"` link is silently blocked. The one
8
+ * channel a View has to open anything is the `ui/open-link` request, answered by the Host's
9
+ * `onOpenLink` — so every qualifying link click becomes that request instead.
10
+ *
11
+ * Window, bubble phase, and skipped once `defaultPrevented`: it runs after every listener the View
12
+ * itself registered, so a View that already handles its own link (calling `openLink` itself and
13
+ * cancelling the click) is not opened twice.
14
+ *
15
+ * The request id is a string the View's own JSON-RPC ids (numbers) never use, so the Host's reply
16
+ * cannot resolve one of the View's pending requests. `link.href` is the resolved URL when the
17
+ * proxy has a real base; under the opaque `data:` proxy a relative href stays as written and the
18
+ * Host decides how to resolve it.
19
+ *
20
+ * Not re-exported from the package (`index.ts` re-exports `sandbox-proxy.ts` only); the tests
21
+ * evaluate this exact source.
22
+ *
23
+ * Expects `host` (the embedding window) and `hostOrigin` in scope — the proxy script's own vars.
24
+ */
25
+ export declare const PROXY_LINK_FORWARDER_JS = "var openLinkSeq = 0;\n function forwardLinkClick(event) {\n if (event.defaultPrevented || event.button !== 0) return;\n var target = event.target;\n var link = target && target.closest ? target.closest(\"a[href]\") : null;\n if (!link || link.hasAttribute(\"download\")) return;\n var href = link.getAttribute(\"href\");\n if (!href || href.charAt(0) === \"#\" || /^\\s*javascript:/i.test(href)) return;\n event.preventDefault();\n openLinkSeq += 1;\n host.postMessage(\n { jsonrpc: \"2.0\", id: \"jini-proxy-open-link-\" + openLinkSeq, method: \"ui/open-link\", params: { url: link.href } },\n hostOrigin\n );\n }";
26
+ //# sourceMappingURL=sandbox-proxy-link-forwarder.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @module features/mcp-ui/sandbox-proxy-link-forwarder
3
+ *
4
+ * Page-script source the sandbox proxy (`sandbox-proxy.ts`) embeds so a View's plain `<a href>`
5
+ * opens in a new tab. Inside the proxy's frame a link is useless on its own: `AppFrame`'s hardcoded
6
+ * sandbox has neither `allow-popups` nor `allow-top-navigation`, so a same-tab link navigates the
7
+ * card's own frame away from the View and a `target="_blank"` link is silently blocked. The one
8
+ * channel a View has to open anything is the `ui/open-link` request, answered by the Host's
9
+ * `onOpenLink` — so every qualifying link click becomes that request instead.
10
+ *
11
+ * Window, bubble phase, and skipped once `defaultPrevented`: it runs after every listener the View
12
+ * itself registered, so a View that already handles its own link (calling `openLink` itself and
13
+ * cancelling the click) is not opened twice.
14
+ *
15
+ * The request id is a string the View's own JSON-RPC ids (numbers) never use, so the Host's reply
16
+ * cannot resolve one of the View's pending requests. `link.href` is the resolved URL when the
17
+ * proxy has a real base; under the opaque `data:` proxy a relative href stays as written and the
18
+ * Host decides how to resolve it.
19
+ *
20
+ * Not re-exported from the package (`index.ts` re-exports `sandbox-proxy.ts` only); the tests
21
+ * evaluate this exact source.
22
+ *
23
+ * Expects `host` (the embedding window) and `hostOrigin` in scope — the proxy script's own vars.
24
+ */
25
+ export const PROXY_LINK_FORWARDER_JS = `var openLinkSeq = 0;
26
+ function forwardLinkClick(event) {
27
+ if (event.defaultPrevented || event.button !== 0) return;
28
+ var target = event.target;
29
+ var link = target && target.closest ? target.closest("a[href]") : null;
30
+ if (!link || link.hasAttribute("download")) return;
31
+ var href = link.getAttribute("href");
32
+ if (!href || href.charAt(0) === "#" || /^\\s*javascript:/i.test(href)) return;
33
+ event.preventDefault();
34
+ openLinkSeq += 1;
35
+ host.postMessage(
36
+ { jsonrpc: "2.0", id: "jini-proxy-open-link-" + openLinkSeq, method: "ui/open-link", params: { url: link.href } },
37
+ hostOrigin
38
+ );
39
+ }`;
40
+ //# sourceMappingURL=sandbox-proxy-link-forwarder.js.map
@@ -127,8 +127,12 @@
127
127
  * survives `document.open()` and would then apply to the guest HTML written in below, silently
128
128
  * breaking any UIResource whose HTML this package did not build. Surfaces built by
129
129
  * `surfaces/document.ts` already carry their own `SURFACE_CSP` meta tag for exactly that job.
130
+ *
131
+ * Once the guest HTML is written, the page also forwards the View's link clicks to the Host as
132
+ * `ui/open-link` (`sandbox-proxy-link-forwarder.ts`) — registered after `document.close()` because
133
+ * `document.open()` erases every listener registered before it.
130
134
  */
131
- export declare const SANDBOX_PROXY_HTML = "<!doctype html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>MCP-UI sandbox proxy</title>\n<style>html, body { margin: 0; padding: 0; width: 100%; height: 100%; }</style>\n</head>\n<body>\n<script>\n(function () {\n \"use strict\";\n var host = window.parent;\n var hostOrigin = window.location.origin;\n if (host === window) return;\n\n function isFromHost(event) {\n return event.source === host && event.origin === hostOrigin;\n }\n\n window.addEventListener(\"message\", function (event) {\n if (!isFromHost(event)) return;\n var data = event.data;\n if (!data || typeof data !== \"object\") return;\n if (data.method !== \"ui/notifications/sandbox-resource-ready\") return;\n var html = data.params && data.params.html;\n if (typeof html !== \"string\") return;\n document.open();\n document.write(html);\n document.close();\n });\n\n host.postMessage({ method: \"ui/notifications/sandbox-proxy-ready\", params: {} }, hostOrigin);\n}());\n</script>\n</body>\n</html>";
135
+ export declare const SANDBOX_PROXY_HTML = "<!doctype html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>MCP-UI sandbox proxy</title>\n<style>html, body { margin: 0; padding: 0; width: 100%; height: 100%; }</style>\n</head>\n<body>\n<script>\n(function () {\n \"use strict\";\n var host = window.parent;\n var hostOrigin = window.location.origin;\n if (host === window) return;\n\n function isFromHost(event) {\n return event.source === host && event.origin === hostOrigin;\n }\n\n var openLinkSeq = 0;\n function forwardLinkClick(event) {\n if (event.defaultPrevented || event.button !== 0) return;\n var target = event.target;\n var link = target && target.closest ? target.closest(\"a[href]\") : null;\n if (!link || link.hasAttribute(\"download\")) return;\n var href = link.getAttribute(\"href\");\n if (!href || href.charAt(0) === \"#\" || /^\\s*javascript:/i.test(href)) return;\n event.preventDefault();\n openLinkSeq += 1;\n host.postMessage(\n { jsonrpc: \"2.0\", id: \"jini-proxy-open-link-\" + openLinkSeq, method: \"ui/open-link\", params: { url: link.href } },\n hostOrigin\n );\n }\n\n window.addEventListener(\"message\", function (event) {\n if (!isFromHost(event)) return;\n var data = event.data;\n if (!data || typeof data !== \"object\") return;\n if (data.method !== \"ui/notifications/sandbox-resource-ready\") return;\n var html = data.params && data.params.html;\n if (typeof html !== \"string\") return;\n document.open();\n document.write(html);\n document.close();\n window.addEventListener(\"click\", forwardLinkClick);\n });\n\n host.postMessage({ method: \"ui/notifications/sandbox-proxy-ready\", params: {} }, hostOrigin);\n}());\n</script>\n</body>\n</html>";
132
136
  /**
133
137
  * Same script as {@link SANDBOX_PROXY_HTML}, with one load-bearing difference: `hostOrigin` is the
134
138
  * caller-supplied `hostOrigin` argument, baked in as a literal, instead of read from
@@ -109,6 +109,7 @@
109
109
  * hardcodes any particular host — a consumer's origin is discovered at runtime from the page's own
110
110
  * URL — and neither touches the single-hop `document.write` shape the section above depends on.
111
111
  */
112
+ import { PROXY_LINK_FORWARDER_JS } from './sandbox-proxy-link-forwarder.js';
112
113
  /**
113
114
  * The sandbox proxy page's complete, self-contained source — see this module's own doc for the
114
115
  * protocol it implements and the security tradeoff serving it makes.
@@ -127,6 +128,10 @@
127
128
  * survives `document.open()` and would then apply to the guest HTML written in below, silently
128
129
  * breaking any UIResource whose HTML this package did not build. Surfaces built by
129
130
  * `surfaces/document.ts` already carry their own `SURFACE_CSP` meta tag for exactly that job.
131
+ *
132
+ * Once the guest HTML is written, the page also forwards the View's link clicks to the Host as
133
+ * `ui/open-link` (`sandbox-proxy-link-forwarder.ts`) — registered after `document.close()` because
134
+ * `document.open()` erases every listener registered before it.
130
135
  */
131
136
  export const SANDBOX_PROXY_HTML = `<!doctype html>
132
137
  <html>
@@ -147,6 +152,8 @@ export const SANDBOX_PROXY_HTML = `<!doctype html>
147
152
  return event.source === host && event.origin === hostOrigin;
148
153
  }
149
154
 
155
+ ${PROXY_LINK_FORWARDER_JS}
156
+
150
157
  window.addEventListener("message", function (event) {
151
158
  if (!isFromHost(event)) return;
152
159
  var data = event.data;
@@ -157,6 +164,7 @@ export const SANDBOX_PROXY_HTML = `<!doctype html>
157
164
  document.open();
158
165
  document.write(html);
159
166
  document.close();
167
+ window.addEventListener("click", forwardLinkClick);
160
168
  });
161
169
 
162
170
  host.postMessage({ method: "ui/notifications/sandbox-proxy-ready", params: {} }, hostOrigin);
@@ -220,6 +228,8 @@ export function buildIsolatedSandboxProxyHtml(hostOrigin) {
220
228
  return event.source === host && event.origin === hostOrigin;
221
229
  }
222
230
 
231
+ ${PROXY_LINK_FORWARDER_JS}
232
+
223
233
  window.addEventListener("message", function (event) {
224
234
  if (!isFromHost(event)) return;
225
235
  var data = event.data;
@@ -230,6 +240,7 @@ export function buildIsolatedSandboxProxyHtml(hostOrigin) {
230
240
  document.open();
231
241
  document.write(html);
232
242
  document.close();
243
+ window.addEventListener("click", forwardLinkClick);
233
244
  });
234
245
 
235
246
  host.postMessage({ method: "ui/notifications/sandbox-proxy-ready", params: {} }, hostOrigin);
@@ -133,9 +133,13 @@ export function renderBridgeScript(spec) {
133
133
  return requestWhenReady("ui/open-link", { url: url });
134
134
  }
135
135
 
136
+ // Height only, never width: @mcp-ui/client's AppFrame writes a reported width onto the iframe as a
137
+ // fixed px style. A surface is fluid, so its scrollWidth is just the frame's width at that moment;
138
+ // reported while mounting in a hidden or narrow pane it froze the frame at min-content width for
139
+ // good (owner bug 2026-10-08: an ask-choice card stuck at 192px after a reload). Leaving width out
140
+ // keeps the frame at the host's width: 100%, and the ResizeObserver below re-reports height on resize.
136
141
  function reportSize() {
137
- var root = document.documentElement;
138
- notify("ui/notifications/size-changed", { width: root.scrollWidth, height: root.scrollHeight });
142
+ notify("ui/notifications/size-changed", { height: document.documentElement.scrollHeight });
139
143
  }
140
144
 
141
145
  window.addEventListener("message", function (event) {
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
3
  import { DEFAULT_STALE_TIME, FetchQueryCache, toQueryError } from './cache.js';
4
4
  import { browserFetchQueryEnvironment } from './environment.browser.js';
5
+ import { FetchQueryBinding } from './binding.react.js';
5
6
  const CacheContext = createContext(null);
6
7
  /** Isolated cache scope; no external provider or query-library singleton is needed. */
7
8
  export function FetchQueryProvider({ children, environment = browserFetchQueryEnvironment }) {
@@ -12,7 +13,7 @@ export function FetchQueryProvider({ children, environment = browserFetchQueryEn
12
13
  cache.activate();
13
14
  return () => cache.dispose();
14
15
  }, [cache]);
15
- return _jsx(CacheContext.Provider, { value: cache, children: children });
16
+ return _jsx(CacheContext.Provider, { value: cache, children: _jsx(FetchQueryBinding, { adapter: adapter, children: children }) });
16
17
  }
17
18
  function useCache() {
18
19
  const cache = useContext(CacheContext);
@@ -119,4 +120,5 @@ export function useInvalidate() {
119
120
  const cache = useCache();
120
121
  return useCallback(({ key }) => cache.invalidate({ prefix: key }), [cache]);
121
122
  }
123
+ const adapter = { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate };
122
124
  //# sourceMappingURL=adapter.react.js.map
@@ -0,0 +1,11 @@
1
+ /** Optional TanStack translation layer. No library types escape the fetch-query contract. */
2
+ import { type ReactNode } from 'react';
3
+ import { type FetchQueryEnvironmentPort } from './cache.js';
4
+ /** Provider-owned TanStack cache and binding, compatible with hooks from the default entry.
5
+ * @example <FetchQueryProvider><ResourceScreen /></FetchQueryProvider>
6
+ * @param props Children and optional host connectivity/focus port. */
7
+ export declare function FetchQueryProvider({ children, environment }: {
8
+ children: ReactNode;
9
+ environment?: FetchQueryEnvironmentPort;
10
+ }): import("react").JSX.Element;
11
+ //# sourceMappingURL=adapter.tanstack.d.ts.map
@@ -0,0 +1,190 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /** Optional TanStack translation layer. No library types escape the fetch-query contract. */
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
4
+ import { isCancelledError, QueryClient, QueryClientProvider, useMutation, useQuery } from '@tanstack/react-query';
5
+ import { DEFAULT_STALE_TIME, FetchQueryCache, IDLE_RETENTION, toQueryError } from './cache.js';
6
+ import { browserFetchQueryEnvironment } from './environment.browser.js';
7
+ import { resolveFetchQueryError, resolveFetchQueryStatus } from './adapter.react.js';
8
+ import { FetchQueryBinding } from './binding.react.js';
9
+ const ScopeContext = createContext(null);
10
+ /** Match built-in defaults. Failed authorization/validation must surface immediately;
11
+ * automatic retries would hide the server's first useful error behind repeated requests. */
12
+ function createScope({ environment }) {
13
+ return {
14
+ client: new QueryClient({ defaultOptions: {
15
+ queries: { staleTime: DEFAULT_STALE_TIME, retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false, networkMode: 'always' },
16
+ mutations: { retry: false, networkMode: 'always' },
17
+ } }),
18
+ // Reuse the existing connectivity gate; TanStack's online/focus managers are global
19
+ // and cannot represent two providers with independent host environment ports.
20
+ gate: new FetchQueryCache({}, { environment }), environment, history: new WeakMap(),
21
+ };
22
+ }
23
+ /** Provider-owned TanStack cache and binding, compatible with hooks from the default entry.
24
+ * @example <FetchQueryProvider><ResourceScreen /></FetchQueryProvider>
25
+ * @param props Children and optional host connectivity/focus port. */
26
+ export function FetchQueryProvider({ children, environment = browserFetchQueryEnvironment }) {
27
+ // A module singleton would leak cache values between independent providers/tests,
28
+ // producing cases that pass alone but fail together. Scope ownership to this provider.
29
+ const scope = useMemo(() => createScope({ environment }), [environment]);
30
+ useEffect(() => {
31
+ scope.gate.activate();
32
+ let online = environment.isOnline();
33
+ const release = environment.subscribeOnline({ listener: next => {
34
+ const reconnect = next && !online;
35
+ online = next;
36
+ // An offline/online pair may be batched into one React render. Signal the cache
37
+ // directly rather than depending on a disabled -> enabled render to revalidate.
38
+ if (reconnect)
39
+ void scope.client.refetchQueries({ predicate: query => query.isActive() && query.isStale() && query.state.fetchStatus !== 'fetching' });
40
+ } });
41
+ return () => { release(); scope.gate.dispose(); };
42
+ }, [scope, environment]);
43
+ return _jsx(QueryClientProvider, { client: scope.client, children: _jsx(ScopeContext.Provider, { value: scope, children: _jsx(FetchQueryBinding, { adapter: adapter, children: children }) }) });
44
+ }
45
+ /** Fail closed when an adapter hook is used without its provider. */
46
+ function useScope() {
47
+ const scope = useContext(ScopeContext);
48
+ if (!scope)
49
+ throw new Error('Fetch-query hooks require FetchQueryProvider');
50
+ return scope;
51
+ }
52
+ /** Observe only this provider's connectivity; paused reads do not claim network activity. */
53
+ function useOnline({ environment }) {
54
+ const subscribe = useCallback((listener) => environment.subscribeOnline({ listener }), [environment]);
55
+ const snapshot = useCallback(() => environment.isOnline(), [environment]);
56
+ return useSyncExternalStore(subscribe, snapshot, snapshot);
57
+ }
58
+ /** Preserve contract metadata across observers, without retaining evicted query objects. */
59
+ function historyFor({ scope, query }) {
60
+ let history = scope.history.get(query);
61
+ if (!history) {
62
+ history = { replacement: 0, request: 0, error: null };
63
+ scope.history.set(query, history);
64
+ }
65
+ return history;
66
+ }
67
+ /** Replacement writes win over either outcome of an older transport, across all loaders
68
+ * and reactive reads. Weak keys follow TanStack's own query eviction lifecycle. */
69
+ async function read({ scope, key, fetch }) {
70
+ const query = scope.client.getQueryCache().find({ queryKey: key, exact: true });
71
+ const history = historyFor({ scope, query });
72
+ const generation = history.replacement;
73
+ const request = ++history.request;
74
+ try {
75
+ const value = await scope.gate.runOnline({ run: fetch });
76
+ if (generation !== history.replacement)
77
+ return scope.client.getQueryData(key);
78
+ if (value === undefined)
79
+ throw new Error(`${JSON.stringify(key)} data is undefined`);
80
+ if (request === history.request)
81
+ history.error = null;
82
+ return value;
83
+ }
84
+ catch (error) {
85
+ if (generation !== history.replacement)
86
+ return scope.client.getQueryData(key);
87
+ // Earlier data remains useful, but a failed refresh is not a fresh cache hit.
88
+ // The next loader/mount must retry even when its staleTime would retain that data.
89
+ if (request === history.request) {
90
+ history.error = toQueryError(error);
91
+ query.invalidate();
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+ /** Match the built-in cache's idle retention. TanStack's default gcTime (5 min) would
97
+ * otherwise evict an unobserved entry before a longer staleTime says it is stale. */
98
+ function gcTimeFor({ staleTime }) {
99
+ return Math.max(IDLE_RETENTION, staleTime);
100
+ }
101
+ /** Reactive read translated to the library-free contract, with explicit focus opt-in. */
102
+ function useFetchQuery({ key, fetch }, { enabled = true, staleTime = DEFAULT_STALE_TIME, refetchOnWindowFocus = false } = {}) {
103
+ const scope = useScope();
104
+ const online = useOnline(scope);
105
+ const query = useQuery({ queryKey: key, queryFn: () => read({ scope, key, fetch }), enabled: enabled && online, staleTime, gcTime: gcTimeFor({ staleTime }) }, scope.client);
106
+ const { refetch: refresh } = query;
107
+ const refetch = useCallback(() => {
108
+ if (scope.environment.isOnline()) {
109
+ void refresh();
110
+ return;
111
+ }
112
+ // Reconnect may already have started a fetch (re-enabled observer or provider refetch);
113
+ // that request postdates this one, so join it instead of cancelling it into a duplicate.
114
+ void scope.gate.runOnline({ run: () => refresh({ cancelRefetch: false }) });
115
+ }, [scope, refresh]);
116
+ useEffect(() => {
117
+ if (!enabled || !refetchOnWindowFocus)
118
+ return;
119
+ return scope.environment.subscribeFocus({ listener: () => {
120
+ const cached = scope.client.getQueryCache().find({ queryKey: key, exact: true });
121
+ if (scope.environment.isOnline() && cached?.isStaleByTime(staleTime) && cached.state.fetchStatus !== 'fetching')
122
+ refetch();
123
+ } });
124
+ }, [scope, JSON.stringify(key), enabled, staleTime, refetchOnWindowFocus, refetch]);
125
+ // TanStack clears an initial error when a retry starts. The contract keeps it until
126
+ // recovery, so a retry is never mistaken for evidence that the last failure is gone.
127
+ const cached = scope.client.getQueryCache().find({ queryKey: key, exact: true });
128
+ const history = historyFor({ scope, query: cached });
129
+ const hasData = query.data !== undefined;
130
+ return {
131
+ data: query.data,
132
+ status: resolveFetchQueryStatus({ disabled: !enabled, hasData, status: query.status }),
133
+ error: resolveFetchQueryError({ error: history.error ?? query.error, disabled: !enabled, hasData }),
134
+ isFetching: online && query.isFetching, refetch,
135
+ };
136
+ }
137
+ /** Preserve promise rejection for awaiting writes while also handling discarded promises. */
138
+ function useFetchMutation({ run }, { invalidates } = {}) {
139
+ const scope = useScope();
140
+ const mutation = useMutation({
141
+ mutationFn: required => scope.gate.runOnline({ run: () => run(required) }),
142
+ onSuccess: () => {
143
+ // Do not await dependent reads: the mutation promise means the write succeeded,
144
+ // not that every list finished reloading. Refetches proceed on their own schedule.
145
+ for (const key of invalidates ?? [])
146
+ void scope.client.invalidateQueries({ queryKey: key });
147
+ },
148
+ }, scope.client);
149
+ const { mutateAsync, reset } = mutation;
150
+ const mutate = useCallback((required) => {
151
+ const promise = mutateAsync(required);
152
+ // Handle this same promise for callers that discard it, while still returning it so
153
+ // await/catch observes the original rejection; discarding the derived promise is intentional.
154
+ void promise.catch(() => { });
155
+ return promise;
156
+ }, [mutateAsync]);
157
+ return { mutate, status: mutation.status, error: mutation.status === 'error' ? toQueryError(mutation.error) : null, reset };
158
+ }
159
+ /** Loader shares reactive reads, retains fresh idle values, and protects replacement writes. */
160
+ function useCachedLoader({ key, fetch }, { staleTime = DEFAULT_STALE_TIME } = {}) {
161
+ const scope = useScope();
162
+ return useMemo(() => {
163
+ const options = { queryKey: key, queryFn: () => read({ scope, key, fetch }), staleTime, gcTime: gcTimeFor({ staleTime }) };
164
+ return {
165
+ peek: () => scope.client.getQueryData(key),
166
+ load: () => scope.client.fetchQuery(options).catch(error => {
167
+ // A forced refresh can supersede a loader's request without aborting its transport.
168
+ // Follow the new request instead of exposing TanStack's cancellation vocabulary.
169
+ if (!isCancelledError(error))
170
+ throw error;
171
+ const query = scope.client.getQueryCache().find({ queryKey: key, exact: true });
172
+ return (query.promise ?? scope.client.getQueryData(key));
173
+ }),
174
+ replace: ({ value }) => {
175
+ const query = scope.client.getQueryCache().build(scope.client, options);
176
+ const history = historyFor({ scope, query });
177
+ history.replacement += 1;
178
+ history.error = null;
179
+ scope.client.setQueryData(key, value);
180
+ },
181
+ };
182
+ }, [scope, key, fetch, staleTime]);
183
+ }
184
+ /** Prefix invalidation also reaches child keys read by other mounted consumers. */
185
+ function useInvalidate() {
186
+ const { client } = useScope();
187
+ return useCallback(({ key }) => { void client.invalidateQueries({ queryKey: key }); }, [client]);
188
+ }
189
+ const adapter = { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate };
190
+ //# sourceMappingURL=adapter.tanstack.js.map
@@ -0,0 +1,17 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { FetchQueryAdapter } from './types.js';
3
+ /** Internal composition seam: hooks and imperative loaders always use the same provider.
4
+ * Keep the adapter fixed for this provider's lifetime to preserve React hook ordering. */
5
+ export declare function FetchQueryBinding({ adapter, children }: {
6
+ adapter: FetchQueryAdapter;
7
+ children: ReactNode;
8
+ }): import("react").JSX.Element;
9
+ /** Library-free read facade; the provider selects its implementation. */
10
+ export declare const useFetchQuery: FetchQueryAdapter['useFetchQuery'];
11
+ /** Library-free write facade, including safe discarded promises. */
12
+ export declare const useFetchMutation: FetchQueryAdapter['useFetchMutation'];
13
+ /** Prefix invalidator for the nearest provider's cache. */
14
+ export declare const useInvalidate: FetchQueryAdapter['useInvalidate'];
15
+ /** Promise loader using the same cache as reactive reads. */
16
+ export declare const useCachedLoader: FetchQueryAdapter['useCachedLoader'];
17
+ //# sourceMappingURL=binding.react.d.ts.map
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from 'react';
3
+ const AdapterContext = createContext(null);
4
+ /** Internal composition seam: hooks and imperative loaders always use the same provider.
5
+ * Keep the adapter fixed for this provider's lifetime to preserve React hook ordering. */
6
+ export function FetchQueryBinding({ adapter, children }) {
7
+ return _jsx(AdapterContext.Provider, { value: adapter, children: children });
8
+ }
9
+ /** Resolve the nearest provider; fail before attempting an unscoped read or write. */
10
+ function useAdapter() {
11
+ const adapter = useContext(AdapterContext);
12
+ if (!adapter)
13
+ throw new Error('Fetch-query hooks require FetchQueryProvider');
14
+ return adapter;
15
+ }
16
+ /** Library-free read facade; the provider selects its implementation. */
17
+ export const useFetchQuery = (required, optional = {}) => useAdapter().useFetchQuery(required, optional);
18
+ /** Library-free write facade, including safe discarded promises. */
19
+ export const useFetchMutation = (required, optional = {}) => useAdapter().useFetchMutation(required, optional);
20
+ /** Prefix invalidator for the nearest provider's cache. */
21
+ export const useInvalidate = () => useAdapter().useInvalidate();
22
+ /** Promise loader using the same cache as reactive reads. */
23
+ export const useCachedLoader = (required, optional = {}) => useAdapter().useCachedLoader(required, optional);
24
+ //# sourceMappingURL=binding.react.js.map
@@ -9,6 +9,8 @@ import type { QueryKey } from './types.js';
9
9
  * must be fresh on every mount can choose staleTime: 0 without lowering every query default.
10
10
  */
11
11
  export declare const DEFAULT_STALE_TIME = 10000;
12
+ /** Minimum time an unobserved entry is kept; both adapters retain max(this, staleTime). */
13
+ export declare const IDLE_RETENTION: number;
12
14
  export interface QueryCacheSchedulerPort {
13
15
  schedule(required: {
14
16
  callback: () => void;
@@ -7,7 +7,8 @@
7
7
  * must be fresh on every mount can choose staleTime: 0 without lowering every query default.
8
8
  */
9
9
  export const DEFAULT_STALE_TIME = 10_000;
10
- const IDLE_RETENTION = 5 * 60_000;
10
+ /** Minimum time an unobserved entry is kept; both adapters retain max(this, staleTime). */
11
+ export const IDLE_RETENTION = 5 * 60_000;
11
12
  const browserScheduler = {
12
13
  schedule: ({ callback, delayMs }) => setTimeout(callback, delayMs),
13
14
  cancel: ({ handle }) => clearTimeout(handle),
@@ -1,4 +1,5 @@
1
- export { FetchQueryProvider, useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate } from './adapter.react.js';
1
+ export { FetchQueryProvider } from './adapter.react.js';
2
+ export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate } from './binding.react.js';
2
3
  export type { FetchQueryEnvironmentPort } from './cache.js';
3
4
  export type { QueryKey, QueryStatus, QueryResult, FetchQueryOptions, MutationStatus, MutationResult, FetchMutationOptions, CachedLoaderOptions, CachedLoader, FetchQueryAdapter } from './types.js';
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,3 @@
1
- export { FetchQueryProvider, useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate } from './adapter.react.js';
1
+ export { FetchQueryProvider } from './adapter.react.js';
2
+ export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate } from './binding.react.js';
2
3
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,6 @@
1
+ /** Optional adapter entry. Importing the default entry never loads TanStack. */
2
+ export { FetchQueryProvider } from './adapter.tanstack.js';
3
+ export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate } from './binding.react.js';
4
+ export type { FetchQueryEnvironmentPort } from './cache.js';
5
+ export type { QueryKey, QueryStatus, QueryResult, FetchQueryOptions, MutationStatus, MutationResult, FetchMutationOptions, CachedLoaderOptions, CachedLoader, FetchQueryAdapter } from './types.js';
6
+ //# sourceMappingURL=tanstack.d.ts.map
@@ -0,0 +1,4 @@
1
+ /** Optional adapter entry. Importing the default entry never loads TanStack. */
2
+ export { FetchQueryProvider } from './adapter.tanstack.js';
3
+ export { useFetchQuery, useFetchMutation, useCachedLoader, useInvalidate } from './binding.react.js';
4
+ //# sourceMappingURL=tanstack.js.map
@@ -9,6 +9,8 @@
9
9
  * key also refreshes its derived child keys. Exact-key equality would compile while quietly
10
10
  * breaking that guarantee; avoid a shared prefix for resources that merely sound related,
11
11
  * or writes will fan out unnecessary refetches.
12
+ * Stay primitive-only: the built-in cache matches each part with === while TanStack deep-matches,
13
+ * so an object part would invalidate under one adapter and silently not under the other.
12
14
  */
13
15
  export type QueryKey = readonly (string | number)[];
14
16
  export type QueryStatus = "loading" | "success" | "error";