@cosmicdrift/kumiko-renderer 0.164.0 → 0.165.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.164.0",
3
+ "version": "0.165.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,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.164.0",
19
- "@cosmicdrift/kumiko-headless": "0.164.0",
18
+ "@cosmicdrift/kumiko-framework": "0.165.1",
19
+ "@cosmicdrift/kumiko-headless": "0.165.1",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2"
22
22
  },
@@ -3,11 +3,13 @@ import { sortByAccessor } from "../sort-by-accessor";
3
3
 
4
4
  type Row = { readonly name: string; readonly count: number };
5
5
 
6
- const rows: readonly Row[] = [
7
- { name: "b", count: 2 },
8
- { name: "a", count: 3 },
9
- { name: "c", count: 1 },
10
- ];
6
+ function makeRows(): readonly Row[] {
7
+ return [
8
+ { name: "b", count: 2 },
9
+ { name: "a", count: 3 },
10
+ { name: "c", count: 1 },
11
+ ];
12
+ }
11
13
 
12
14
  const accessors = {
13
15
  name: (r: Row) => r.name,
@@ -16,24 +18,29 @@ const accessors = {
16
18
 
17
19
  describe("sortByAccessor", () => {
18
20
  test("sort === null returns rows unchanged (same reference)", () => {
21
+ const rows = makeRows();
19
22
  expect(sortByAccessor(rows, null, accessors)).toBe(rows);
20
23
  });
21
24
 
22
25
  test("an unknown field returns rows unchanged (same reference)", () => {
26
+ const rows = makeRows();
23
27
  expect(sortByAccessor(rows, { field: "nope", dir: "asc" }, accessors)).toBe(rows);
24
28
  });
25
29
 
26
30
  test("sorts ascending by the given accessor", () => {
31
+ const rows = makeRows();
27
32
  const sorted = sortByAccessor(rows, { field: "name", dir: "asc" }, accessors);
28
33
  expect(sorted.map((r) => r.name)).toEqual(["a", "b", "c"]);
29
34
  });
30
35
 
31
36
  test("sorts descending by the given accessor", () => {
37
+ const rows = makeRows();
32
38
  const sorted = sortByAccessor(rows, { field: "count", dir: "desc" }, accessors);
33
39
  expect(sorted.map((r) => r.count)).toEqual([3, 2, 1]);
34
40
  });
35
41
 
36
42
  test("does not mutate the input array", () => {
43
+ const rows = makeRows();
37
44
  const original = [...rows];
38
45
  sortByAccessor(rows, { field: "name", dir: "asc" }, accessors);
39
46
  expect(rows).toEqual(original);
@@ -1,21 +1,11 @@
1
- import { Temporal } from "temporal-polyfill";
1
+ import { toInstant } from "@cosmicdrift/kumiko-headless";
2
2
 
3
3
  // Shared timestamp formatter for operator screens (audit log, job runs) —
4
4
  // falls back to the raw ISO string on an unparseable value instead of "Invalid Date".
5
5
  export function formatWhen(value: string): string {
6
6
  try {
7
- return Temporal.Instant.from(value).toLocaleString();
7
+ return toInstant(value).toLocaleString();
8
8
  } catch {
9
- // Temporal.Instant.from requires a UTC designator/offset — offset-less
10
- // timestamps (still valid `new Date` input) throw here. Retry as a
11
- // local wall-clock time before giving up and returning the raw string.
12
- try {
13
- return Temporal.PlainDateTime.from(value)
14
- .toZonedDateTime(Temporal.Now.timeZoneId())
15
- .toInstant()
16
- .toLocaleString();
17
- } catch {
18
- return value;
19
- }
9
+ return value;
20
10
  }
21
11
  }
@@ -38,7 +38,6 @@ export function useStreamHandler<TChunk = unknown>(
38
38
  const [error, setError] = useState<DispatcherError | null>(null);
39
39
 
40
40
  const activeCtrl = useRef<AbortController | null>(null);
41
- const payloadKey = JSON.stringify(payload);
42
41
  const payloadRef = useRef(payload);
43
42
  payloadRef.current = payload;
44
43
 
@@ -56,7 +55,12 @@ export function useStreamHandler<TChunk = unknown>(
56
55
  setError(null);
57
56
  }, [abort]);
58
57
 
59
- // biome-ignore lint/correctness/useExhaustiveDependencies: payload via payloadKey / payloadRef
58
+ // Shared by every abort-detection branch below: only touch status if this
59
+ // run is still the active one (or activeCtrl was already cleared by abort()).
60
+ const settleAborted = useCallback((ctrl: AbortController): void => {
61
+ if (activeCtrl.current === null || activeCtrl.current === ctrl) setStatus("idle");
62
+ }, []);
63
+
60
64
  const start = useCallback(
61
65
  async (overridePayload?: unknown): Promise<void> => {
62
66
  activeCtrl.current?.abort();
@@ -69,48 +73,43 @@ export function useStreamHandler<TChunk = unknown>(
69
73
 
70
74
  const body = overridePayload !== undefined ? overridePayload : payloadRef.current;
71
75
  try {
72
- const accumulated: TChunk[] = [];
73
76
  for await (const chunk of dispatcher.stream<TChunk>(type, body, { signal: ctrl.signal })) {
74
77
  // skip: a newer start() already superseded this run
75
78
  if (ctrl.signal.aborted) {
76
- if (activeCtrl.current === null || activeCtrl.current === ctrl) setStatus("idle");
79
+ settleAborted(ctrl);
77
80
  return;
78
81
  }
79
- accumulated.push(chunk);
80
- setChunks([...accumulated]);
82
+ setChunks((prev) => [...prev, chunk]);
81
83
  }
82
84
  // skip: aborted after last chunk, don't mark done
83
85
  if (ctrl.signal.aborted) {
84
- if (activeCtrl.current === null || activeCtrl.current === ctrl) setStatus("idle");
86
+ settleAborted(ctrl);
85
87
  return;
86
88
  }
87
89
  setStatus("done");
88
90
  } catch (e) {
89
91
  // skip: abort is not an error toast
90
92
  if (ctrl.signal.aborted) {
91
- if (activeCtrl.current === null || activeCtrl.current === ctrl) setStatus("idle");
93
+ settleAborted(ctrl);
92
94
  return;
93
95
  }
94
96
  const mapped = asDispatcherError(e);
95
97
  // skip: dispatcher-mapped abort (fetch cancelled)
96
98
  if (mapped.code === "aborted") {
97
- if (activeCtrl.current === null || activeCtrl.current === ctrl) setStatus("idle");
99
+ settleAborted(ctrl);
98
100
  return;
99
101
  }
100
102
  setError(mapped);
101
103
  setStatus("error");
102
104
  }
103
105
  },
104
- [dispatcher, type, payloadKey],
106
+ [dispatcher, type, settleAborted],
105
107
  );
106
108
 
107
109
  useEffect(() => {
108
110
  // skip: autoStart off — streams are usually user-triggered
109
111
  if (!autoStart) return;
110
112
  void start();
111
- return () => {
112
- activeCtrl.current?.abort();
113
- };
114
113
  }, [autoStart, start]);
115
114
 
116
115
  useEffect(() => {