@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.
|
|
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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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);
|
package/src/format-when.ts
CHANGED
|
@@ -1,21 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
|
7
|
+
return toInstant(value).toLocaleString();
|
|
8
8
|
} catch {
|
|
9
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
79
|
+
settleAborted(ctrl);
|
|
77
80
|
return;
|
|
78
81
|
}
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
settleAborted(ctrl);
|
|
98
100
|
return;
|
|
99
101
|
}
|
|
100
102
|
setError(mapped);
|
|
101
103
|
setStatus("error");
|
|
102
104
|
}
|
|
103
105
|
},
|
|
104
|
-
[dispatcher, type,
|
|
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(() => {
|