@cosmicdrift/kumiko-renderer 0.347.0 → 0.348.1

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.
@@ -1,10 +1,12 @@
1
1
  import type { Dispatcher, DispatcherStatus } from "@cosmicdrift/kumiko-headless";
2
2
  import { type ReactNode } from "react";
3
+ import { type QueryPools } from "./query-pool.js";
3
4
  export type DispatcherProviderProps = {
4
5
  readonly dispatcher: Dispatcher;
5
6
  readonly children: ReactNode;
6
7
  };
7
8
  export declare function DispatcherProvider({ dispatcher, children }: DispatcherProviderProps): ReactNode;
9
+ export declare function useOptionalQueryPools(): QueryPools | undefined;
8
10
  export declare function useDispatcher(): Dispatcher;
9
11
  export declare function useOptionalDispatcher(): Dispatcher | undefined;
10
12
  export declare function useDispatcherStatus(): DispatcherStatus;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext } from "react";
2
+ import { createContext, useContext, useState } from "react";
3
3
  import { useStore } from "../hooks/use-store.js";
4
+ import { createQueryPools } from "./query-pool.js";
4
5
  // React Context threading the Dispatcher through the tree. An app
5
6
  // wraps its root in <DispatcherProvider dispatcher={createLiveDispatcher()}>
6
7
  // and every hook below reaches into that context instead of taking the
@@ -11,8 +12,14 @@ import { useStore } from "../hooks/use-store.js";
11
12
  // dispatcher per app (rebuilding would wipe its in-flight tracking and
12
13
  // status listeners). Tests wire a fake dispatcher in directly.
13
14
  const DispatcherContext = createContext(null);
15
+ const QueryPoolsContext = createContext(null);
14
16
  export function DispatcherProvider({ dispatcher, children }) {
15
- return _jsx(DispatcherContext, { value: dispatcher, children: children });
17
+ // Pools live with the provider so every hook of one query name shares them.
18
+ const [queryPools] = useState(createQueryPools);
19
+ return (_jsx(DispatcherContext, { value: dispatcher, children: _jsx(QueryPoolsContext, { value: queryPools, children: children }) }));
20
+ }
21
+ export function useOptionalQueryPools() {
22
+ return useContext(QueryPoolsContext) ?? undefined;
16
23
  }
17
24
  // Reads the ambient Dispatcher. Throws instead of returning null when
18
25
  // no provider is mounted — a dispatcher-less hook is always a
@@ -0,0 +1,11 @@
1
+ export type QuerySlot = {
2
+ readonly release: () => void;
3
+ };
4
+ export type QueryPools = {
5
+ /** Resolves with a slot once fewer than `limit` requests of this query name
6
+ * run, or with null when `signal` aborts first (a waiter that gives up
7
+ * never holds a slot). The caller must release a granted slot exactly once. Hooks of one
8
+ * query name are expected to pass the same limit. */
9
+ readonly acquire: (name: string, limit: number, signal: AbortSignal) => Promise<QuerySlot | null>;
10
+ };
11
+ export declare function createQueryPools(): QueryPools;
@@ -0,0 +1,64 @@
1
+ export function createQueryPools() {
2
+ const pools = new Map();
3
+ const poolFor = (name) => {
4
+ const existing = pools.get(name);
5
+ if (existing)
6
+ return existing;
7
+ const created = { running: 0, waiting: [] };
8
+ pools.set(name, created);
9
+ return created;
10
+ };
11
+ const makeSlot = (name, pool) => {
12
+ let released = false;
13
+ return {
14
+ release: () => {
15
+ if (!released) {
16
+ released = true;
17
+ pool.running -= 1;
18
+ drain(name, pool);
19
+ }
20
+ },
21
+ };
22
+ };
23
+ const drain = (name, pool) => {
24
+ let next = pool.waiting[0];
25
+ while (next !== undefined && pool.running < next.limit) {
26
+ pool.waiting.shift();
27
+ pool.running += 1;
28
+ next.grant(makeSlot(name, pool));
29
+ next = pool.waiting[0];
30
+ }
31
+ if (pool.running === 0 && pool.waiting.length === 0)
32
+ pools.delete(name);
33
+ };
34
+ return {
35
+ acquire: (name, limit, signal) => {
36
+ if (signal.aborted)
37
+ return Promise.resolve(null);
38
+ const pool = poolFor(name);
39
+ if (pool.waiting.length === 0 && pool.running < limit) {
40
+ pool.running += 1;
41
+ return Promise.resolve(makeSlot(name, pool));
42
+ }
43
+ return new Promise((resolve) => {
44
+ const waiter = {
45
+ limit,
46
+ grant: (slot) => {
47
+ signal.removeEventListener("abort", onAbort);
48
+ resolve(slot);
49
+ },
50
+ };
51
+ const onAbort = () => {
52
+ const index = pool.waiting.indexOf(waiter);
53
+ if (index >= 0)
54
+ pool.waiting.splice(index, 1);
55
+ signal.removeEventListener("abort", onAbort);
56
+ resolve(null);
57
+ drain(name, pool);
58
+ };
59
+ signal.addEventListener("abort", onAbort);
60
+ pool.waiting.push(waiter);
61
+ });
62
+ },
63
+ };
64
+ }
@@ -9,6 +9,7 @@ export type UseQueryOptions = {
9
9
  readonly enabled?: boolean;
10
10
  readonly live?: boolean;
11
11
  readonly refetchIntervalMs?: number;
12
+ readonly concurrency?: number;
12
13
  };
13
14
  export declare function entityFromQueryType(type: string): string | undefined;
14
15
  export declare function useQuery<TData = unknown>(type: string, payload: unknown, options?: UseQueryOptions): UseQueryResult<TData>;
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { useDispatcher } from "../context/dispatcher-context.js";
2
+ import { useDispatcher, useOptionalQueryPools } from "../context/dispatcher-context.js";
3
3
  import { useLiveEvents } from "../sse/live-events.js";
4
4
  // Extract the entity-name from a standard Kumiko query type. Returns
5
5
  // undefined for non-conforming types so the live-mode silently skips
@@ -13,9 +13,13 @@ export function entityFromQueryType(type) {
13
13
  return undefined;
14
14
  return parts[2];
15
15
  }
16
+ function isValidConcurrency(value) {
17
+ return value !== undefined && Number.isFinite(value) && value >= 1;
18
+ }
16
19
  export function useQuery(type, payload, options = {}) {
17
20
  const dispatcher = useDispatcher();
18
- const { enabled = true, live = false, refetchIntervalMs } = options;
21
+ const { enabled = true, live = false, refetchIntervalMs, concurrency } = options;
22
+ const queryPools = useOptionalQueryPools();
19
23
  const [data, setData] = useState(null);
20
24
  const [error, setError] = useState(null);
21
25
  const [loading, setLoading] = useState(enabled);
@@ -45,10 +49,21 @@ export function useQuery(type, payload, options = {}) {
45
49
  if (!background)
46
50
  setLoading(true);
47
51
  let result;
52
+ let slot = null;
48
53
  try {
54
+ if (queryPools !== undefined && isValidConcurrency(concurrency)) {
55
+ slot = await queryPools.acquire(type, Math.floor(concurrency), ctrl.signal);
56
+ // skip: aborted while queued, the superseding run owns the state
57
+ if (slot === null)
58
+ return;
59
+ // skip: slot granted just as the run was superseded, finally frees it
60
+ if (ctrl.signal.aborted)
61
+ return;
62
+ }
49
63
  result = await dispatcher.query(type, payload, { signal: ctrl.signal });
50
64
  }
51
65
  finally {
66
+ slot?.release();
52
67
  // A rejected fetch must not leave polling stuck; a superseded one must
53
68
  // not clear the newer fetch's flag.
54
69
  if (activeCtrl.current === ctrl)
@@ -69,7 +84,7 @@ export function useQuery(type, payload, options = {}) {
69
84
  setError(result.error);
70
85
  }
71
86
  setLoading(false);
72
- }, [dispatcher, type, payloadKey]);
87
+ }, [dispatcher, type, payloadKey, queryPools, concurrency]);
73
88
  const run = useCallback(() => runFetch(false), [runFetch]);
74
89
  useEffect(() => {
75
90
  if (!enabled) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.347.0",
3
+ "version": "0.348.1",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.347.0",
19
- "@cosmicdrift/kumiko-headless": "0.347.0",
20
- "@cosmicdrift/kumiko-types": "0.347.0",
18
+ "@cosmicdrift/kumiko-framework": "0.348.1",
19
+ "@cosmicdrift/kumiko-headless": "0.348.1",
20
+ "@cosmicdrift/kumiko-types": "0.348.1",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.347.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.348.1"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.348.0",
4
+ "type": "improvement",
5
+ "title": "ModeSwitch gains a pill variant and className, LanguageSwitcher gains a compact chip variant",
6
+ "detail": "ModeSwitch pill variant and LanguageSwitcher chip variant\n`ModeSwitch` gets `variant=\"pill\"` (grey track, raised active segment, dark mode aware) and a `className` prop; `outline` stays the default. `LanguageSwitcher` gets `variant=\"chip\"`, a compact monospace chip with the uppercase locale code in a border; `default` stays as it was."
7
+ },
8
+ {
9
+ "version": "0.348.0",
10
+ "type": "improvement",
11
+ "title": "TimeseriesChart takes a height, an optional y-axis with gridlines and a date axis with n labels",
12
+ "detail": "TimeseriesChart: height, y-axis gridlines and a date axis\n`height` (px) replaces the fixed `h-16`; without it the chart looks as before. `yAxis: { ticks, format? }` draws that many gridlines with rounded value labels (0/200/400/600) in a left gutter, and the y-scale reaches the top tick. `xAxis: { ticks, format }` renders n evenly spaced date labels instead of the fixed start/mid/end of `axisLabels`."
13
+ },
14
+ {
15
+ "version": "0.348.0",
16
+ "type": "improvement",
17
+ "title": "useQuery accepts concurrency to cap parallel requests per query name through a pool in the DispatcherProvider",
18
+ "detail": "useQuery option `concurrency` caps parallel requests per query name\n`useQuery(type, payload, { concurrency: n })` lets at most n `dispatcher.query` calls of that query name run at once. The pool lives in the DispatcherProvider and is shared by every hook with the same name; the rest wait in line with `loading` still true. A hook that unmounts, refetches or changes its payload while waiting leaves the line without holding a slot, and a finished or failed request hands its slot to the next waiter. Without the option nothing changes."
19
+ },
2
20
  {
3
21
  "version": "0.347.0",
4
22
  "type": "improvement",