@fragno-dev/core 0.0.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.
Files changed (108) hide show
  1. package/.turbo/turbo-build.log +61 -0
  2. package/.turbo/turbo-types$colon$check.log +2 -0
  3. package/dist/api/api.d.ts +2 -0
  4. package/dist/api/api.js +3 -0
  5. package/dist/api-CBDGZiLC.d.ts +278 -0
  6. package/dist/api-CBDGZiLC.d.ts.map +1 -0
  7. package/dist/api-DgHfYjq2.js +54 -0
  8. package/dist/api-DgHfYjq2.js.map +1 -0
  9. package/dist/client/client.d.ts +3 -0
  10. package/dist/client/client.js +6 -0
  11. package/dist/client/client.svelte.d.ts +33 -0
  12. package/dist/client/client.svelte.d.ts.map +1 -0
  13. package/dist/client/client.svelte.js +123 -0
  14. package/dist/client/client.svelte.js.map +1 -0
  15. package/dist/client/react.d.ts +58 -0
  16. package/dist/client/react.d.ts.map +1 -0
  17. package/dist/client/react.js +80 -0
  18. package/dist/client/react.js.map +1 -0
  19. package/dist/client/vanilla.d.ts +61 -0
  20. package/dist/client/vanilla.d.ts.map +1 -0
  21. package/dist/client/vanilla.js +136 -0
  22. package/dist/client/vanilla.js.map +1 -0
  23. package/dist/client/vue.d.ts +39 -0
  24. package/dist/client/vue.d.ts.map +1 -0
  25. package/dist/client/vue.js +108 -0
  26. package/dist/client/vue.js.map +1 -0
  27. package/dist/client-DWjxKDnE.js +703 -0
  28. package/dist/client-DWjxKDnE.js.map +1 -0
  29. package/dist/client-XFdAy-IQ.d.ts +287 -0
  30. package/dist/client-XFdAy-IQ.d.ts.map +1 -0
  31. package/dist/integrations/astro.d.ts +18 -0
  32. package/dist/integrations/astro.d.ts.map +1 -0
  33. package/dist/integrations/astro.js +16 -0
  34. package/dist/integrations/astro.js.map +1 -0
  35. package/dist/integrations/next-js.d.ts +15 -0
  36. package/dist/integrations/next-js.d.ts.map +1 -0
  37. package/dist/integrations/next-js.js +17 -0
  38. package/dist/integrations/next-js.js.map +1 -0
  39. package/dist/integrations/react-ssr.d.ts +19 -0
  40. package/dist/integrations/react-ssr.d.ts.map +1 -0
  41. package/dist/integrations/react-ssr.js +38 -0
  42. package/dist/integrations/react-ssr.js.map +1 -0
  43. package/dist/integrations/svelte-kit.d.ts +21 -0
  44. package/dist/integrations/svelte-kit.d.ts.map +1 -0
  45. package/dist/integrations/svelte-kit.js +18 -0
  46. package/dist/integrations/svelte-kit.js.map +1 -0
  47. package/dist/mod.d.ts +3 -0
  48. package/dist/mod.js +177 -0
  49. package/dist/mod.js.map +1 -0
  50. package/dist/route-Bp6eByhz.js +331 -0
  51. package/dist/route-Bp6eByhz.js.map +1 -0
  52. package/dist/ssr-tJHqcNSw.js +48 -0
  53. package/dist/ssr-tJHqcNSw.js.map +1 -0
  54. package/package.json +127 -0
  55. package/src/api/api.test.ts +140 -0
  56. package/src/api/api.ts +106 -0
  57. package/src/api/error.ts +47 -0
  58. package/src/api/fragment.test.ts +509 -0
  59. package/src/api/fragment.ts +277 -0
  60. package/src/api/internal/path-runtime.test.ts +121 -0
  61. package/src/api/internal/path-type.test.ts +602 -0
  62. package/src/api/internal/path.ts +322 -0
  63. package/src/api/internal/response-stream.ts +118 -0
  64. package/src/api/internal/route.test.ts +56 -0
  65. package/src/api/internal/route.ts +9 -0
  66. package/src/api/request-input-context.test.ts +437 -0
  67. package/src/api/request-input-context.ts +201 -0
  68. package/src/api/request-middleware.test.ts +544 -0
  69. package/src/api/request-middleware.ts +126 -0
  70. package/src/api/request-output-context.test.ts +626 -0
  71. package/src/api/request-output-context.ts +175 -0
  72. package/src/api/route.test.ts +176 -0
  73. package/src/api/route.ts +152 -0
  74. package/src/client/client-builder.test.ts +264 -0
  75. package/src/client/client-error.test.ts +15 -0
  76. package/src/client/client-error.ts +141 -0
  77. package/src/client/client-types.test.ts +493 -0
  78. package/src/client/client.ssr.test.ts +173 -0
  79. package/src/client/client.svelte.test.ts +837 -0
  80. package/src/client/client.svelte.ts +278 -0
  81. package/src/client/client.test.ts +1690 -0
  82. package/src/client/client.ts +1035 -0
  83. package/src/client/component.test.svelte +21 -0
  84. package/src/client/internal/ndjson-streaming.test.ts +457 -0
  85. package/src/client/internal/ndjson-streaming.ts +248 -0
  86. package/src/client/react.test.ts +947 -0
  87. package/src/client/react.ts +241 -0
  88. package/src/client/vanilla.test.ts +867 -0
  89. package/src/client/vanilla.ts +265 -0
  90. package/src/client/vue.test.ts +754 -0
  91. package/src/client/vue.ts +242 -0
  92. package/src/http/http-status.ts +60 -0
  93. package/src/integrations/astro.ts +17 -0
  94. package/src/integrations/next-js.ts +31 -0
  95. package/src/integrations/react-ssr.ts +40 -0
  96. package/src/integrations/svelte-kit.ts +41 -0
  97. package/src/mod.ts +20 -0
  98. package/src/util/async.test.ts +85 -0
  99. package/src/util/async.ts +96 -0
  100. package/src/util/content-type.test.ts +136 -0
  101. package/src/util/content-type.ts +84 -0
  102. package/src/util/nanostores.test.ts +28 -0
  103. package/src/util/nanostores.ts +65 -0
  104. package/src/util/ssr.ts +75 -0
  105. package/src/util/types-util.ts +16 -0
  106. package/tsconfig.json +10 -0
  107. package/tsdown.config.ts +21 -0
  108. package/vitest.config.ts +10 -0
@@ -0,0 +1,947 @@
1
+ import { test, expect, describe, vi, beforeEach, afterEach, expectTypeOf } from "vitest";
2
+ import { renderHook, act, waitFor } from "@testing-library/react";
3
+ import { atom, computed, type ReadableAtom } from "nanostores";
4
+ import { z } from "zod";
5
+ import { createClientBuilder } from "./client";
6
+ import { useFragno, useStore, type FragnoReactStore } from "./react";
7
+ import { defineRoute } from "../api/route";
8
+ import { defineFragment } from "../api/fragment";
9
+ import type { FragnoPublicClientConfig } from "../mod";
10
+ import { FragnoClientFetchNetworkError, type FragnoClientError } from "./client-error";
11
+ import { RequestOutputContext } from "../api/request-output-context";
12
+ import type { FetcherStore } from "@nanostores/query";
13
+
14
+ // Mock fetch globally
15
+ global.fetch = vi.fn();
16
+
17
+ describe("createReactHook", () => {
18
+ const testFragmentDefinition = defineFragment("test-fragment");
19
+ const testRoutes = [
20
+ defineRoute({
21
+ method: "GET",
22
+ path: "/users",
23
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
24
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
25
+ }),
26
+ defineRoute({
27
+ method: "GET",
28
+ path: "/users/:id",
29
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
30
+ handler: async ({ pathParams }, { json }) =>
31
+ json({ id: Number(pathParams["id"]), name: "John" }),
32
+ }),
33
+ defineRoute({
34
+ method: "GET",
35
+ path: "/search",
36
+ outputSchema: z.array(z.string()),
37
+ handler: async (_ctx, { json }) => json(["result1", "result2"]),
38
+ }),
39
+ ] as const;
40
+
41
+ const clientConfig: FragnoPublicClientConfig = {
42
+ baseUrl: "http://localhost:3000",
43
+ };
44
+
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
48
+ });
49
+
50
+ afterEach(() => {
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ test("should create a hook for a simple GET route", async () => {
55
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
56
+ headers: new Headers(),
57
+ ok: true,
58
+ json: async () => [{ id: 1, name: "John" }],
59
+ });
60
+
61
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
62
+ const clientObj = {
63
+ users: client.createHook("/users"),
64
+ };
65
+
66
+ const { users } = useFragno(clientObj);
67
+ const { result } = renderHook(() => users());
68
+
69
+ await waitFor(() => {
70
+ expect(result.current.loading).toBe(false);
71
+ });
72
+
73
+ expect(result.current.data).toEqual([{ id: 1, name: "John" }]);
74
+ expect(result.current.error).toBeUndefined();
75
+ });
76
+
77
+ test("should create a hook with path parameters", async () => {
78
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
79
+ headers: new Headers(),
80
+ ok: true,
81
+ json: async () => ({ id: 123, name: "John" }),
82
+ });
83
+
84
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
85
+ const clientObj = {
86
+ user: client.createHook("/users/:id"),
87
+ };
88
+
89
+ const { user } = useFragno(clientObj);
90
+ const { result } = renderHook(() => user({ path: { id: "123" } }));
91
+
92
+ await waitFor(() => {
93
+ expect(result.current.loading).toBe(false);
94
+ });
95
+
96
+ expect(result.current.data).toEqual({ id: 123, name: "John" });
97
+ expect(fetch).toHaveBeenCalledWith("http://localhost:3000/api/test-fragment/users/123");
98
+ });
99
+
100
+ test("should create a hook with query parameters", async () => {
101
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
102
+ headers: new Headers(),
103
+ ok: true,
104
+ json: async () => ["result1", "result2"],
105
+ });
106
+
107
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
108
+ const clientObj = {
109
+ search: client.createHook("/search"),
110
+ };
111
+
112
+ const { search } = useFragno(clientObj);
113
+ const { result } = renderHook(() => search({ query: { q: "test" } }));
114
+
115
+ await waitFor(() => {
116
+ expect(result.current.loading).toBe(false);
117
+ });
118
+
119
+ expect(result.current.data).toEqual(["result1", "result2"]);
120
+ expect(fetch).toHaveBeenCalledWith("http://localhost:3000/api/test-fragment/search?q=test");
121
+ });
122
+
123
+ test("should support reactive path parameters with atoms", async () => {
124
+ const idAtom = atom("1");
125
+
126
+ (global.fetch as ReturnType<typeof vi.fn>)
127
+ .mockResolvedValueOnce({
128
+ headers: new Headers(),
129
+ ok: true,
130
+ json: async () => ({ id: 1, name: "John" }),
131
+ })
132
+ .mockResolvedValueOnce({
133
+ headers: new Headers(),
134
+ ok: true,
135
+ json: async () => ({ id: 2, name: "Jane" }),
136
+ });
137
+
138
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
139
+ const clientObj = {
140
+ user: client.createHook("/users/:id"),
141
+ };
142
+
143
+ const { user } = useFragno(clientObj);
144
+ const { result } = renderHook(() => user({ path: { id: idAtom } }));
145
+
146
+ await waitFor(() => {
147
+ expect(result.current.loading).toBe(false);
148
+ });
149
+
150
+ expect(result.current.data).toEqual({ id: 1, name: "John" });
151
+
152
+ // Update the atom value
153
+ act(() => {
154
+ idAtom.set("2");
155
+ });
156
+
157
+ await waitFor(() => {
158
+ expect(result.current.data).toEqual({ id: 2, name: "Jane" });
159
+ });
160
+
161
+ expect(fetch).toHaveBeenCalledTimes(2);
162
+ });
163
+
164
+ test("should handle errors gracefully", async () => {
165
+ (global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
166
+
167
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
168
+ const clientObj = {
169
+ useUsers: client.createHook("/users"),
170
+ };
171
+
172
+ const { useUsers } = useFragno(clientObj);
173
+ const { result } = renderHook(() => useUsers());
174
+
175
+ await waitFor(() => {
176
+ expect(result.current.loading).toBe(false);
177
+ });
178
+
179
+ expect(result.current.error).toBeInstanceOf(FragnoClientFetchNetworkError);
180
+ expect(result.current.data).toBeUndefined();
181
+ });
182
+ });
183
+
184
+ describe("createReactMutator", () => {
185
+ const testFragmentDefinition = defineFragment("test-fragment");
186
+ const testRoutes = [
187
+ defineRoute({
188
+ method: "POST",
189
+ path: "/users",
190
+ inputSchema: z.object({ name: z.string(), email: z.string() }),
191
+ outputSchema: z.object({ id: z.number(), name: z.string(), email: z.string() }),
192
+ handler: async (_ctx, { json }) => json({ id: 1, name: "", email: "" }),
193
+ }),
194
+ defineRoute({
195
+ method: "PUT",
196
+ path: "/users/:id",
197
+ inputSchema: z.object({ name: z.string() }),
198
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
199
+ handler: async ({ pathParams }, { json }) => json({ id: Number(pathParams["id"]), name: "" }),
200
+ }),
201
+ ] as const;
202
+
203
+ const clientConfig: FragnoPublicClientConfig = {
204
+ baseUrl: "http://localhost:3000",
205
+ };
206
+
207
+ beforeEach(() => {
208
+ vi.clearAllMocks();
209
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
210
+ });
211
+
212
+ test("should be able to use mutator for POST route - direct result", async () => {
213
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
214
+ headers: new Headers(),
215
+ ok: true,
216
+ json: async () => ({ id: 1, name: "John", email: "john@example.com" }),
217
+ });
218
+
219
+ const _route1 = testRoutes[0];
220
+ type _Route1 = typeof _route1;
221
+
222
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
223
+ const clientObj = {
224
+ useCreateUserMutator: client.createMutator("POST", "/users"),
225
+ };
226
+
227
+ const { useCreateUserMutator } = useFragno(clientObj);
228
+ const { result: renderedHook } = renderHook(() => useCreateUserMutator());
229
+ const { mutate: createUser } = renderedHook.current;
230
+
231
+ const result = await createUser({
232
+ body: { name: "John", email: "john@example.com" },
233
+ });
234
+
235
+ expect(result).toEqual({ id: 1, name: "John", email: "john@example.com" });
236
+ expect(fetch).toHaveBeenCalledWith(
237
+ expect.stringContaining("/users"),
238
+ expect.objectContaining({
239
+ method: "POST",
240
+ body: JSON.stringify({ name: "John", email: "john@example.com" }),
241
+ }),
242
+ );
243
+ });
244
+
245
+ test("should be able to use mutator for POST route - result in store", async () => {
246
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
247
+ headers: new Headers(),
248
+ ok: true,
249
+ json: async () => ({ id: 1, name: "John", email: "john@example.com" }),
250
+ });
251
+
252
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
253
+ const clientObj = {
254
+ useCreateUserMutator: client.createMutator("POST", "/users"),
255
+ };
256
+
257
+ const { useCreateUserMutator } = useFragno(clientObj);
258
+ const { result: renderedHook } = renderHook(() => useCreateUserMutator());
259
+ const { mutate: createUser } = renderedHook.current;
260
+
261
+ await createUser({
262
+ body: { name: "John", email: "john@example.com" },
263
+ });
264
+
265
+ await waitFor(() => {
266
+ expect(renderedHook.current.loading).toBe(false);
267
+ });
268
+
269
+ expect(renderedHook.current.data).toEqual({ id: 1, name: "John", email: "john@example.com" });
270
+ expect(fetch).toHaveBeenCalledWith(
271
+ expect.stringContaining("/users"),
272
+ expect.objectContaining({
273
+ method: "POST",
274
+ body: JSON.stringify({ name: "John", email: "john@example.com" }),
275
+ }),
276
+ );
277
+ });
278
+
279
+ test("should create a mutator for PUT route with path params", async () => {
280
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
281
+ headers: new Headers(),
282
+ ok: true,
283
+ json: async () => ({ id: 123, name: "Jane" }),
284
+ });
285
+
286
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
287
+ const clientObj = {
288
+ useUpdateUserMutator: client.createMutator("PUT", "/users/:id"),
289
+ };
290
+
291
+ const { useUpdateUserMutator } = useFragno(clientObj);
292
+ const { result: renderedHook } = renderHook(() => useUpdateUserMutator());
293
+ const { mutate: updateUser } = renderedHook.current;
294
+
295
+ const result = await updateUser({
296
+ body: { name: "Jane" },
297
+ path: { id: "123" },
298
+ });
299
+
300
+ expect(result).toEqual({ id: 123, name: "Jane" });
301
+ expect(fetch).toHaveBeenCalledWith(
302
+ expect.stringContaining("/users/123"),
303
+ expect.objectContaining({
304
+ method: "PUT",
305
+ body: JSON.stringify({ name: "Jane" }),
306
+ }),
307
+ );
308
+ });
309
+
310
+ test("should create a mutator for DELETE route (with inputSchema and outputSchema)", async () => {
311
+ const testLocalFragmentDefinition = defineFragment("test-fragment");
312
+ const testLocalRoutes = [
313
+ defineRoute({
314
+ method: "DELETE",
315
+ path: "/users/:id",
316
+ inputSchema: z.object({}),
317
+ outputSchema: z.object({ success: z.boolean() }),
318
+ handler: async (_ctx, { json }) => json({ success: true }),
319
+ }),
320
+ ] as const;
321
+
322
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
323
+ headers: new Headers(),
324
+ ok: true,
325
+ json: async () => ({ success: true }),
326
+ });
327
+
328
+ const client = createClientBuilder(testLocalFragmentDefinition, clientConfig, testLocalRoutes);
329
+ const clientObj = {
330
+ useDeleteUserMutator: client.createMutator("DELETE", "/users/:id"),
331
+ };
332
+
333
+ const { useDeleteUserMutator } = useFragno(clientObj);
334
+ const { result: renderedHook } = renderHook(() => useDeleteUserMutator());
335
+ const hook = renderedHook.current;
336
+
337
+ const result = await hook.mutate({
338
+ body: {},
339
+ path: { id: "123" },
340
+ });
341
+
342
+ expect(result).toEqual({ success: true });
343
+
344
+ await waitFor(() => {
345
+ expect(hook.loading).toBe(false);
346
+ });
347
+
348
+ expect(hook).toEqual({
349
+ loading: false,
350
+ // TODO: Error and data should be in here, right?
351
+ // error: undefined,
352
+ // data: { success: true },
353
+ mutate: expect.any(Function),
354
+ });
355
+
356
+ expect(fetch).toHaveBeenCalledWith(
357
+ expect.stringContaining("/users/123"),
358
+ expect.objectContaining({
359
+ method: "DELETE",
360
+ }),
361
+ );
362
+ });
363
+
364
+ test("should create a mutator for DELETE route (withOUT inputSchema and outputSchema)", async () => {
365
+ const testLocalFragmentDefinition = defineFragment("test-fragment");
366
+ const testLocalRoutes = [
367
+ defineRoute({
368
+ method: "DELETE",
369
+ path: "/users/:id",
370
+ handler: async (_ctx, { empty }) => empty(),
371
+ }),
372
+ ] as const;
373
+
374
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
375
+ headers: new Headers(),
376
+ ok: true,
377
+ status: 204,
378
+ });
379
+
380
+ const client = createClientBuilder(testLocalFragmentDefinition, clientConfig, testLocalRoutes);
381
+ const clientObj = {
382
+ useDeleteUserMutator: client.createMutator("DELETE", "/users/:id"),
383
+ };
384
+
385
+ const { useDeleteUserMutator } = useFragno(clientObj);
386
+ const { result: renderedHook } = renderHook(() => useDeleteUserMutator());
387
+ const hook = renderedHook.current;
388
+
389
+ const result = await hook.mutate({
390
+ path: { id: "123" },
391
+ });
392
+
393
+ expect(result).toBeUndefined();
394
+
395
+ await waitFor(() => {
396
+ expect(hook.loading).toBe(false);
397
+ });
398
+
399
+ expect(hook).toEqual({
400
+ loading: false,
401
+ // TODO: Error and data should be in here, right?
402
+ // error: undefined,
403
+ // data: { success: true },
404
+ mutate: expect.any(Function),
405
+ });
406
+
407
+ expect(fetch).toHaveBeenCalledWith(
408
+ expect.stringContaining("/users/123"),
409
+ expect.objectContaining({
410
+ method: "DELETE",
411
+ }),
412
+ );
413
+ });
414
+
415
+ test("should handle mutation errors", async () => {
416
+ (global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Server error"));
417
+
418
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
419
+ const clientObj = {
420
+ useCreateUserMutator: client.createMutator("POST", "/users"),
421
+ };
422
+
423
+ const { useCreateUserMutator } = useFragno(clientObj);
424
+ const { result: renderedHook } = renderHook(() => useCreateUserMutator());
425
+ const { mutate: createUser } = renderedHook.current;
426
+
427
+ await expect(
428
+ createUser({
429
+ body: { name: "John", email: "john@example.com" },
430
+ }),
431
+ );
432
+
433
+ await waitFor(() => {
434
+ expect(renderedHook.current.loading).toBe(false);
435
+ });
436
+
437
+ expect(renderedHook.current.error).toBeInstanceOf(FragnoClientFetchNetworkError);
438
+ });
439
+ });
440
+
441
+ describe("useFragno", () => {
442
+ const testFragmentDefinition = defineFragment("test-fragment");
443
+ const testRoutes = [
444
+ defineRoute({
445
+ method: "GET",
446
+ path: "/data",
447
+ outputSchema: z.string(),
448
+ handler: async (_ctx, { json }) => json("test data"),
449
+ }),
450
+ defineRoute({
451
+ method: "POST",
452
+ path: "/action",
453
+ inputSchema: z.object({ value: z.string() }),
454
+ outputSchema: z.object({ result: z.string() }),
455
+ handler: async (_ctx, { json }) => json({ result: "test value" }),
456
+ }),
457
+ ] as const;
458
+
459
+ const clientConfig: FragnoPublicClientConfig = {
460
+ baseUrl: "http://localhost:3000",
461
+ };
462
+
463
+ test("should transform a mixed object of hooks and mutators", async () => {
464
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
465
+ headers: new Headers(),
466
+ ok: true,
467
+ json: async () => "test data",
468
+ });
469
+
470
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
471
+ const clientObj = {
472
+ useData: client.createHook("/data"),
473
+ usePostAction: client.createMutator("POST", "/action"),
474
+ };
475
+
476
+ const { useData, usePostAction } = useFragno(clientObj);
477
+
478
+ const { result: renderedHook } = renderHook(() => usePostAction());
479
+ const { mutate: postAction } = renderedHook.current;
480
+
481
+ // Test the hook
482
+ const { result: hookResult } = renderHook(() => useData());
483
+
484
+ await waitFor(() => {
485
+ expect(hookResult.current.loading).toBe(false);
486
+ });
487
+
488
+ expect(hookResult.current.data).toBe("test data");
489
+
490
+ // Test the mutator
491
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
492
+ headers: new Headers(),
493
+ ok: true,
494
+ json: async () => ({ result: "test value" }),
495
+ });
496
+
497
+ const mutatorResult = await postAction({
498
+ body: { value: "test value" },
499
+ });
500
+ expect(mutatorResult).toEqual({ result: "test value" });
501
+ });
502
+
503
+ test("should pass through non-hook values unchanged", () => {
504
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
505
+ const clientObj = {
506
+ useData: client.createHook("/data"),
507
+ someString: "hello world",
508
+ someNumber: 42,
509
+ someObject: { foo: "bar", nested: { value: true } },
510
+ someArray: [1, 2, 3],
511
+ someFunction: () => "test",
512
+ someNull: null,
513
+ someUndefined: undefined,
514
+ };
515
+
516
+ const result = useFragno(clientObj);
517
+
518
+ // Check that non-hook values are passed through unchanged
519
+ expect(result.someString).toBe("hello world");
520
+ expect(result.someNumber).toBe(42);
521
+ expect(result.someObject).toEqual({ foo: "bar", nested: { value: true } });
522
+ expect(result.someArray).toEqual([1, 2, 3]);
523
+ expect(result.someFunction()).toBe("test");
524
+ expect(result.someNull).toBeNull();
525
+ expect(result.someUndefined).toBeUndefined();
526
+
527
+ // Verify that the hook is still transformed
528
+ expect(typeof result.useData).toBe("function");
529
+ });
530
+
531
+ test("should preserve reference equality for non-hook objects", () => {
532
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
533
+ const sharedObject = { shared: true };
534
+ const sharedArray = [1, 2, 3];
535
+ const sharedFunction = () => "shared";
536
+
537
+ const clientObj = {
538
+ useData: client.createHook("/data"),
539
+ obj: sharedObject,
540
+ arr: sharedArray,
541
+ fn: sharedFunction,
542
+ };
543
+
544
+ const result = useFragno(clientObj);
545
+
546
+ // Check that references are preserved
547
+ expect(result.obj).toBe(sharedObject);
548
+ expect(result.arr).toBe(sharedArray);
549
+ expect(result.fn).toBe(sharedFunction);
550
+ });
551
+
552
+ test("should handle mixed object with hooks, mutators, and other values", () => {
553
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
554
+ const clientObj = {
555
+ // Hooks and mutators
556
+ useData: client.createHook("/data"),
557
+ usePostAction: client.createMutator("POST", "/action"),
558
+ // Regular values
559
+ config: { apiKey: "test-key", timeout: 5000 },
560
+ utils: {
561
+ formatDate: (date: Date) => date.toISOString(),
562
+ parseId: (id: string) => parseInt(id, 10),
563
+ },
564
+ constants: {
565
+ MAX_RETRIES: 3,
566
+ DEFAULT_PAGE_SIZE: 20,
567
+ },
568
+ metadata: {
569
+ version: "1.0.0",
570
+ environment: "test",
571
+ },
572
+ };
573
+
574
+ const result = useFragno(clientObj);
575
+
576
+ // Check hooks are transformed
577
+ expect(typeof result.useData).toBe("function");
578
+ expect(typeof result.usePostAction).toBe("function");
579
+
580
+ // Check other values are passed through
581
+ expect(result.config).toEqual({ apiKey: "test-key", timeout: 5000 });
582
+ expect(result.utils.formatDate(new Date("2024-01-01"))).toBe("2024-01-01T00:00:00.000Z");
583
+ expect(result.utils.parseId("123")).toBe(123);
584
+ expect(result.constants.MAX_RETRIES).toBe(3);
585
+ expect(result.constants.DEFAULT_PAGE_SIZE).toBe(20);
586
+ expect(result.metadata).toEqual({ version: "1.0.0", environment: "test" });
587
+
588
+ expectTypeOf<(typeof result)["config"]>().toEqualTypeOf<{ apiKey: string; timeout: number }>();
589
+ expectTypeOf<(typeof result)["utils"]>().toEqualTypeOf<{
590
+ formatDate: (date: Date) => string;
591
+ parseId: (id: string) => number;
592
+ }>();
593
+ expectTypeOf<(typeof result)["constants"]>().toEqualTypeOf<{
594
+ MAX_RETRIES: number;
595
+ DEFAULT_PAGE_SIZE: number;
596
+ }>();
597
+ expectTypeOf<(typeof result)["metadata"]>().toEqualTypeOf<{
598
+ version: string;
599
+ environment: string;
600
+ }>();
601
+ });
602
+
603
+ test("should handle empty object", () => {
604
+ const clientObj = {};
605
+ const result = useFragno(clientObj);
606
+ expect(result).toEqual({});
607
+ });
608
+
609
+ test("should handle object with only non-hook values", () => {
610
+ const clientObj = {
611
+ a: 1,
612
+ b: "two",
613
+ c: { three: 3 },
614
+ d: [4, 5, 6],
615
+ };
616
+
617
+ const result = useFragno(clientObj);
618
+
619
+ expect(result).toEqual(clientObj);
620
+ expect(result.a).toBe(1);
621
+ expect(result.b).toBe("two");
622
+ expect(result.c).toEqual({ three: 3 });
623
+ expect(result.d).toEqual([4, 5, 6]);
624
+ });
625
+ });
626
+
627
+ describe("useStore", () => {
628
+ test("should subscribe to store changes", async () => {
629
+ const store = atom({ count: 0 });
630
+
631
+ const { result } = renderHook(() => useStore(store));
632
+
633
+ expect(result.current).toEqual({ count: 0 });
634
+
635
+ act(() => {
636
+ store.set({ count: 1 });
637
+ });
638
+
639
+ expect(result.current).toEqual({ count: 1 });
640
+ });
641
+
642
+ test("should handle computed stores", async () => {
643
+ const baseStore = atom(5);
644
+ const doubledStore = computed(baseStore, (value) => value * 2);
645
+
646
+ const { result } = renderHook(() => useStore(doubledStore));
647
+
648
+ expect(result.current).toBe(10);
649
+
650
+ act(() => {
651
+ baseStore.set(7);
652
+ });
653
+
654
+ expect(result.current).toBe(14);
655
+ });
656
+
657
+ test("should unsubscribe on unmount", () => {
658
+ const store = atom({ value: 0 });
659
+ const unsubscribeSpy = vi.fn();
660
+
661
+ // Mock the store.listen method to track unsubscribe
662
+ const originalListen = store.listen;
663
+ store.listen = vi.fn((callback) => {
664
+ const unsubscribe = originalListen.call(store, callback);
665
+ return () => {
666
+ unsubscribeSpy();
667
+ unsubscribe();
668
+ };
669
+ });
670
+
671
+ const { unmount } = renderHook(() => useStore(store));
672
+
673
+ expect(store.listen).toHaveBeenCalled();
674
+
675
+ unmount();
676
+
677
+ expect(unsubscribeSpy).toHaveBeenCalled();
678
+ });
679
+ });
680
+
681
+ describe("useFragno - createStore", () => {
682
+ const clientConfig: FragnoPublicClientConfig = {
683
+ baseUrl: "http://localhost:3000",
684
+ };
685
+
686
+ beforeEach(() => {
687
+ vi.clearAllMocks();
688
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
689
+ });
690
+
691
+ afterEach(() => {
692
+ vi.restoreAllMocks();
693
+ });
694
+
695
+ test("FragnoReactStore type test - ReadableAtom fields", () => {
696
+ // Test that ReadableAtom fields are properly unwrapped to their value types
697
+ const stringAtom: ReadableAtom<string> = atom("hello");
698
+ const numberAtom: ReadableAtom<number> = atom(42);
699
+ const booleanAtom: ReadableAtom<boolean> = atom(true);
700
+ const objectAtom: ReadableAtom<{ count: number }> = atom({ count: 0 });
701
+ const arrayAtom: ReadableAtom<string[]> = atom(["a", "b", "c"]);
702
+
703
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
704
+ const client = {
705
+ useStore: cb.createStore({
706
+ message: stringAtom,
707
+ count: numberAtom,
708
+ isActive: booleanAtom,
709
+ data: objectAtom,
710
+ items: arrayAtom,
711
+ }),
712
+ };
713
+
714
+ const { useStore } = useFragno(client);
715
+
716
+ // Type assertions to ensure the types are correctly inferred
717
+ expectTypeOf(useStore).toExtend<
718
+ () => {
719
+ message: string;
720
+ count: number;
721
+ isActive: boolean;
722
+ data: { count: number };
723
+ items: string[];
724
+ }
725
+ >();
726
+
727
+ // Runtime test
728
+ const { result } = renderHook(() => useStore());
729
+ expect(result.current.message).toBe("hello");
730
+ expect(result.current.count).toBe(42);
731
+ expect(result.current.isActive).toBe(true);
732
+ expect(result.current.data).toEqual({ count: 0 });
733
+ expect(result.current.items).toEqual(["a", "b", "c"]);
734
+ });
735
+
736
+ test("FragnoReactStore type test - computed stores", () => {
737
+ // Test that computed stores (which are also ReadableAtom) are properly unwrapped
738
+ const baseNumber = atom(10);
739
+ const doubled = computed(baseNumber, (n) => n * 2);
740
+ const tripled = computed(baseNumber, (n) => n * 3);
741
+ const combined = computed([doubled, tripled], (d, t) => ({ doubled: d, tripled: t }));
742
+
743
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
744
+ const client = {
745
+ useComputedValues: cb.createStore({
746
+ base: baseNumber,
747
+ doubled: doubled,
748
+ tripled: tripled,
749
+ combined: combined,
750
+ }),
751
+ };
752
+
753
+ const { useComputedValues } = useFragno(client);
754
+
755
+ // Type assertions
756
+ expectTypeOf(useComputedValues).toExtend<
757
+ () => {
758
+ base: number;
759
+ doubled: number;
760
+ tripled: number;
761
+ combined: { doubled: number; tripled: number };
762
+ }
763
+ >();
764
+
765
+ // Runtime test
766
+ const { result } = renderHook(() => useComputedValues());
767
+ expect(result.current.base).toBe(10);
768
+ expect(result.current.doubled).toBe(20);
769
+ expect(result.current.tripled).toBe(30);
770
+ expect(result.current.combined).toEqual({ doubled: 20, tripled: 30 });
771
+ });
772
+
773
+ test("FragnoReactStore type test - mixed store and non-store fields", () => {
774
+ // Test that non-store fields are passed through unchanged
775
+ const messageAtom: ReadableAtom<string> = atom("test");
776
+ const regularFunction = (x: number) => x * 2;
777
+ const regularObject = { foo: "bar", baz: 123 };
778
+
779
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
780
+ const client = {
781
+ useMixed: cb.createStore({
782
+ message: messageAtom,
783
+ multiply: regularFunction,
784
+ config: regularObject,
785
+ constant: 42,
786
+ }),
787
+ };
788
+
789
+ const { useMixed } = useFragno(client);
790
+
791
+ // Type assertions
792
+ expectTypeOf(useMixed).toExtend<
793
+ () => {
794
+ message: string;
795
+ multiply: (x: number) => number;
796
+ config: { foo: string; baz: number };
797
+ constant: number;
798
+ }
799
+ >();
800
+
801
+ // Runtime test
802
+ const { result } = renderHook(() => useMixed());
803
+ expect(result.current.message).toBe("test");
804
+ expect(result.current.multiply(5)).toBe(10);
805
+ expect(result.current.config).toEqual({ foo: "bar", baz: 123 });
806
+ expect(result.current.constant).toBe(42);
807
+ });
808
+
809
+ test("FragnoReactStore type test - single store vs object with stores", () => {
810
+ // Test that a single store is unwrapped directly
811
+ const singleAtom: ReadableAtom<string> = atom("single");
812
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
813
+
814
+ // Single store case
815
+ const clientSingle = {
816
+ useSingle: cb.createStore(singleAtom),
817
+ };
818
+ const { useSingle } = useFragno(clientSingle);
819
+ expectTypeOf(useSingle).toExtend<() => string>();
820
+
821
+ // Object with stores case
822
+ const clientObject = {
823
+ useObject: cb.createStore({
824
+ value: singleAtom,
825
+ }),
826
+ };
827
+ const { useObject } = useFragno(clientObject);
828
+ expectTypeOf(useObject).toExtend<() => { value: string }>();
829
+
830
+ // Runtime test
831
+ const { result: singleResult } = renderHook(() => useSingle());
832
+ expect(singleResult.current).toBe("single");
833
+
834
+ const { result: objectResult } = renderHook(() => useObject());
835
+ expect(objectResult.current).toEqual({ value: "single" });
836
+ });
837
+
838
+ test("FragnoReactStore type test - complex nested atoms", () => {
839
+ // Test complex nested structures with atoms
840
+ type User = { id: number; name: string; email: string };
841
+ type Settings = { theme: "light" | "dark"; notifications: boolean };
842
+
843
+ const userAtom: ReadableAtom<User> = atom({ id: 1, name: "John", email: "john@example.com" });
844
+ const settingsAtom: ReadableAtom<Settings> = atom({ theme: "light", notifications: true });
845
+ const loadingAtom: ReadableAtom<boolean> = atom(false);
846
+ const errorAtom: ReadableAtom<string | null> = atom(null);
847
+
848
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
849
+ const client = {
850
+ useAppState: cb.createStore({
851
+ user: userAtom,
852
+ settings: settingsAtom,
853
+ loading: loadingAtom,
854
+ error: errorAtom,
855
+ }),
856
+ };
857
+
858
+ const { useAppState } = useFragno(client);
859
+
860
+ // Type assertions for complex nested structure
861
+ expectTypeOf(useAppState).toExtend<
862
+ () => {
863
+ user: User;
864
+ settings: Settings;
865
+ loading: boolean;
866
+ error: string | null;
867
+ }
868
+ >();
869
+
870
+ // Runtime test
871
+ const { result } = renderHook(() => useAppState());
872
+ expect(result.current.user).toEqual({ id: 1, name: "John", email: "john@example.com" });
873
+ expect(result.current.settings).toEqual({ theme: "light", notifications: true });
874
+ expect(result.current.loading).toBe(false);
875
+ expect(result.current.error).toBeNull();
876
+ });
877
+
878
+ test("Derived from streaming route", async () => {
879
+ const streamFragmentDefinition = defineFragment("stream-fragment");
880
+ const streamRoutes = [
881
+ defineRoute({
882
+ method: "GET",
883
+ path: "/users-stream",
884
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
885
+ handler: async () => {
886
+ throw new Error("Not implemented");
887
+ },
888
+ }),
889
+ ] as const;
890
+ const cb = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
891
+ const usersStream = cb.createHook("/users-stream");
892
+
893
+ // Create a single shared store instance
894
+ const sharedStore = usersStream.store({});
895
+
896
+ const names = computed(sharedStore, ({ data }) => {
897
+ return (data ?? []).map((user) => user.name).join(", ");
898
+ });
899
+
900
+ const client = {
901
+ useUsersStream: cb.createStore(sharedStore),
902
+ useNames: cb.createStore(names),
903
+ };
904
+
905
+ vi.mocked(global.fetch).mockImplementation(async () => {
906
+ const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
907
+ return ctx.jsonStream(async (stream) => {
908
+ await stream.write({ id: 1, name: "John" });
909
+ await stream.sleep(0);
910
+ await stream.write({ id: 2, name: "Jane" });
911
+ await stream.sleep(0);
912
+ await stream.write({ id: 3, name: "Jim" });
913
+ });
914
+ });
915
+
916
+ const { useNames, useUsersStream } = useFragno(client);
917
+
918
+ expectTypeOf(useUsersStream).toEqualTypeOf<
919
+ FragnoReactStore<
920
+ FetcherStore<
921
+ {
922
+ id: number;
923
+ name: string;
924
+ }[],
925
+ FragnoClientError<string>
926
+ >
927
+ >
928
+ >();
929
+
930
+ expectTypeOf(useNames).toEqualTypeOf<FragnoReactStore<ReadableAtom<string>>>();
931
+
932
+ const { result } = renderHook(() => ({
933
+ usersStream: useUsersStream(),
934
+ names: useNames(),
935
+ }));
936
+
937
+ await waitFor(() => {
938
+ expect(result.current.names).toEqual("John, Jane, Jim");
939
+ expect(result.current.usersStream.loading).toBe(false);
940
+ expect(result.current.usersStream.data).toEqual([
941
+ { id: 1, name: "John" },
942
+ { id: 2, name: "Jane" },
943
+ { id: 3, name: "Jim" },
944
+ ]);
945
+ });
946
+ });
947
+ });