@cosmicdrift/kumiko-renderer 0.159.1 → 0.161.0
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.161.0",
|
|
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.161.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.161.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2"
|
|
22
22
|
},
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher, DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
6
|
+
import { useStreamHandler } from "../use-stream-handler";
|
|
7
|
+
|
|
8
|
+
function makeDispatcher(streamImpl: unknown): Dispatcher {
|
|
9
|
+
return {
|
|
10
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
11
|
+
query: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"],
|
|
12
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
13
|
+
stream: streamImpl as Dispatcher["stream"],
|
|
14
|
+
statusStore: {
|
|
15
|
+
getState: () => "online",
|
|
16
|
+
subscribe: () => () => {},
|
|
17
|
+
} as unknown as Dispatcher["statusStore"],
|
|
18
|
+
pendingWrites: () => [],
|
|
19
|
+
pendingFiles: () => [],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function wrapperFor(dispatcher: Dispatcher) {
|
|
24
|
+
return ({ children }: { readonly children: ReactNode }) => (
|
|
25
|
+
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("useStreamHandler", () => {
|
|
30
|
+
test("start accumulates chunks then status=done", async () => {
|
|
31
|
+
const dispatcher = makeDispatcher(async function* () {
|
|
32
|
+
yield { i: 0 };
|
|
33
|
+
yield { i: 1 };
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const { result } = renderHook(() => useStreamHandler<{ i: number }>("f:stream:x:tail"), {
|
|
37
|
+
wrapper: wrapperFor(dispatcher),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await act(async () => {
|
|
41
|
+
await result.current.start({ count: 2 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await waitFor(() => expect(result.current.status).toBe("done"));
|
|
45
|
+
expect(result.current.chunks).toEqual([{ i: 0 }, { i: 1 }]);
|
|
46
|
+
expect(result.current.error).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("stream error sets status=error and error envelope", async () => {
|
|
50
|
+
const err: DispatcherError = {
|
|
51
|
+
code: "access_denied",
|
|
52
|
+
httpStatus: 403,
|
|
53
|
+
i18nKey: "errors.access",
|
|
54
|
+
message: "denied",
|
|
55
|
+
};
|
|
56
|
+
const dispatcher = makeDispatcher(async function* () {
|
|
57
|
+
yield* []; // satisfy generator shape; error is the only outcome
|
|
58
|
+
throw err;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const { result } = renderHook(() => useStreamHandler("f:stream:x:tail"), {
|
|
62
|
+
wrapper: wrapperFor(dispatcher),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await act(async () => {
|
|
66
|
+
await result.current.start();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await waitFor(() => expect(result.current.status).toBe("error"));
|
|
70
|
+
expect(result.current.error?.code).toBe("access_denied");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("abort during stream resets status to idle (no late done)", async () => {
|
|
74
|
+
let release!: () => void;
|
|
75
|
+
const gate = new Promise<void>((r) => {
|
|
76
|
+
release = r;
|
|
77
|
+
});
|
|
78
|
+
const dispatcher = makeDispatcher(async function* (
|
|
79
|
+
_t: string,
|
|
80
|
+
_p: unknown,
|
|
81
|
+
opts?: { signal?: AbortSignal },
|
|
82
|
+
) {
|
|
83
|
+
yield { i: 0 };
|
|
84
|
+
await gate;
|
|
85
|
+
if (opts?.signal?.aborted) {
|
|
86
|
+
const e = { code: "aborted", httpStatus: 0, i18nKey: "x", message: "aborted" };
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
yield { i: 1 };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const { result } = renderHook(() => useStreamHandler<{ i: number }>("f:stream:x:tail"), {
|
|
93
|
+
wrapper: wrapperFor(dispatcher),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
let started!: Promise<void>;
|
|
97
|
+
act(() => {
|
|
98
|
+
started = result.current.start();
|
|
99
|
+
});
|
|
100
|
+
await waitFor(() => expect(result.current.chunks).toEqual([{ i: 0 }]));
|
|
101
|
+
act(() => result.current.abort());
|
|
102
|
+
release();
|
|
103
|
+
await started;
|
|
104
|
+
expect(result.current.chunks).toEqual([{ i: 0 }]);
|
|
105
|
+
expect(result.current.status).toBe("idle");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useDispatcher } from "../context/dispatcher-context";
|
|
4
|
+
|
|
5
|
+
// React wrapper around dispatcher.stream (#1382). Accumulates yielded
|
|
6
|
+
// chunks into `chunks` (unlike useQuery({live:true}), which only
|
|
7
|
+
// invalidates and re-fetches). Each start() owns an AbortController;
|
|
8
|
+
// a newer start() / unmount aborts the previous run so late chunks
|
|
9
|
+
// cannot clobber fresher state.
|
|
10
|
+
|
|
11
|
+
export type StreamStatus = "idle" | "streaming" | "done" | "error";
|
|
12
|
+
|
|
13
|
+
export type UseStreamHandlerResult<TChunk> = {
|
|
14
|
+
readonly chunks: readonly TChunk[];
|
|
15
|
+
readonly status: StreamStatus;
|
|
16
|
+
readonly error: DispatcherError | null;
|
|
17
|
+
readonly start: (payload?: unknown) => Promise<void>;
|
|
18
|
+
readonly abort: () => void;
|
|
19
|
+
readonly reset: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type UseStreamHandlerOptions = {
|
|
23
|
+
// When true, start() runs once on mount with the initial payload.
|
|
24
|
+
// Default false — streams are usually user-triggered (unlike queries).
|
|
25
|
+
readonly autoStart?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function useStreamHandler<TChunk = unknown>(
|
|
29
|
+
type: string,
|
|
30
|
+
payload: unknown = {},
|
|
31
|
+
options: UseStreamHandlerOptions = {},
|
|
32
|
+
): UseStreamHandlerResult<TChunk> {
|
|
33
|
+
const dispatcher = useDispatcher();
|
|
34
|
+
const { autoStart = false } = options;
|
|
35
|
+
|
|
36
|
+
const [chunks, setChunks] = useState<readonly TChunk[]>([]);
|
|
37
|
+
const [status, setStatus] = useState<StreamStatus>("idle");
|
|
38
|
+
const [error, setError] = useState<DispatcherError | null>(null);
|
|
39
|
+
|
|
40
|
+
const activeCtrl = useRef<AbortController | null>(null);
|
|
41
|
+
const payloadKey = JSON.stringify(payload);
|
|
42
|
+
const payloadRef = useRef(payload);
|
|
43
|
+
payloadRef.current = payload;
|
|
44
|
+
|
|
45
|
+
const abort = useCallback((): void => {
|
|
46
|
+
activeCtrl.current?.abort();
|
|
47
|
+
activeCtrl.current = null;
|
|
48
|
+
// User cancel — exit streaming so UI can re-enable the start button.
|
|
49
|
+
setStatus((s) => (s === "streaming" ? "idle" : s));
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const reset = useCallback((): void => {
|
|
53
|
+
abort();
|
|
54
|
+
setChunks([]);
|
|
55
|
+
setStatus("idle");
|
|
56
|
+
setError(null);
|
|
57
|
+
}, [abort]);
|
|
58
|
+
|
|
59
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: payload via payloadKey / payloadRef
|
|
60
|
+
const start = useCallback(
|
|
61
|
+
async (overridePayload?: unknown): Promise<void> => {
|
|
62
|
+
activeCtrl.current?.abort();
|
|
63
|
+
const ctrl = new AbortController();
|
|
64
|
+
activeCtrl.current = ctrl;
|
|
65
|
+
|
|
66
|
+
setChunks([]);
|
|
67
|
+
setError(null);
|
|
68
|
+
setStatus("streaming");
|
|
69
|
+
|
|
70
|
+
const body = overridePayload !== undefined ? overridePayload : payloadRef.current;
|
|
71
|
+
try {
|
|
72
|
+
const accumulated: TChunk[] = [];
|
|
73
|
+
for await (const chunk of dispatcher.stream<TChunk>(type, body, { signal: ctrl.signal })) {
|
|
74
|
+
// skip: a newer start() already superseded this run
|
|
75
|
+
if (ctrl.signal.aborted) {
|
|
76
|
+
if (activeCtrl.current === null || activeCtrl.current === ctrl) setStatus("idle");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
accumulated.push(chunk);
|
|
80
|
+
setChunks([...accumulated]);
|
|
81
|
+
}
|
|
82
|
+
// skip: aborted after last chunk, don't mark done
|
|
83
|
+
if (ctrl.signal.aborted) {
|
|
84
|
+
if (activeCtrl.current === null || activeCtrl.current === ctrl) setStatus("idle");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
setStatus("done");
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// skip: abort is not an error toast
|
|
90
|
+
if (ctrl.signal.aborted) {
|
|
91
|
+
if (activeCtrl.current === null || activeCtrl.current === ctrl) setStatus("idle");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const mapped = asDispatcherError(e);
|
|
95
|
+
// skip: dispatcher-mapped abort (fetch cancelled)
|
|
96
|
+
if (mapped.code === "aborted") {
|
|
97
|
+
if (activeCtrl.current === null || activeCtrl.current === ctrl) setStatus("idle");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setError(mapped);
|
|
101
|
+
setStatus("error");
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
[dispatcher, type, payloadKey],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
// skip: autoStart off — streams are usually user-triggered
|
|
109
|
+
if (!autoStart) return;
|
|
110
|
+
void start();
|
|
111
|
+
return () => {
|
|
112
|
+
activeCtrl.current?.abort();
|
|
113
|
+
};
|
|
114
|
+
}, [autoStart, start]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
return () => {
|
|
118
|
+
activeCtrl.current?.abort();
|
|
119
|
+
};
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
return { chunks, status, error, start, abort, reset };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function asDispatcherError(e: unknown): DispatcherError {
|
|
126
|
+
if (e && typeof e === "object" && "code" in e && "message" in e && "i18nKey" in e) {
|
|
127
|
+
return e as DispatcherError;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
code: "stream_error",
|
|
131
|
+
httpStatus: 0,
|
|
132
|
+
i18nKey: "errors.unknown",
|
|
133
|
+
message: e instanceof Error ? e.message : String(e),
|
|
134
|
+
};
|
|
135
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -94,6 +94,12 @@ export { useMutation } from "./hooks/use-mutation";
|
|
|
94
94
|
export type { UseQueryOptions, UseQueryResult } from "./hooks/use-query";
|
|
95
95
|
export { useQuery } from "./hooks/use-query";
|
|
96
96
|
export { useStore, useStoreSelector } from "./hooks/use-store";
|
|
97
|
+
export type {
|
|
98
|
+
StreamStatus,
|
|
99
|
+
UseStreamHandlerOptions,
|
|
100
|
+
UseStreamHandlerResult,
|
|
101
|
+
} from "./hooks/use-stream-handler";
|
|
102
|
+
export { useStreamHandler } from "./hooks/use-stream-handler";
|
|
97
103
|
export type {
|
|
98
104
|
LocaleProviderProps,
|
|
99
105
|
TranslationBundle,
|