@fragno-dev/core 0.2.0 → 0.2.2
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/.turbo/turbo-build.log +72 -62
- package/CHANGELOG.md +28 -0
- package/dist/api/api.d.ts +3 -2
- package/dist/api/api.d.ts.map +1 -1
- package/dist/api/api.js +2 -1
- package/dist/api/api.js.map +1 -1
- package/dist/api/bind-services.d.ts +0 -1
- package/dist/api/bind-services.d.ts.map +1 -1
- package/dist/api/bind-services.js.map +1 -1
- package/dist/api/error.d.ts.map +1 -1
- package/dist/api/error.js.map +1 -1
- package/dist/api/fragment-definition-builder.d.ts +26 -44
- package/dist/api/fragment-definition-builder.d.ts.map +1 -1
- package/dist/api/fragment-definition-builder.js +15 -22
- package/dist/api/fragment-definition-builder.js.map +1 -1
- package/dist/api/fragment-instantiator.d.ts +51 -37
- package/dist/api/fragment-instantiator.d.ts.map +1 -1
- package/dist/api/fragment-instantiator.js +74 -69
- package/dist/api/fragment-instantiator.js.map +1 -1
- package/dist/api/request-context-storage.d.ts +4 -0
- package/dist/api/request-context-storage.d.ts.map +1 -1
- package/dist/api/request-context-storage.js +6 -0
- package/dist/api/request-context-storage.js.map +1 -1
- package/dist/api/request-input-context.d.ts.map +1 -1
- package/dist/api/request-input-context.js.map +1 -1
- package/dist/api/request-middleware.d.ts +1 -1
- package/dist/api/request-middleware.d.ts.map +1 -1
- package/dist/api/request-middleware.js.map +1 -1
- package/dist/api/request-output-context.d.ts +1 -1
- package/dist/api/request-output-context.d.ts.map +1 -1
- package/dist/api/request-output-context.js.map +1 -1
- package/dist/api/route-caller.d.ts +30 -0
- package/dist/api/route-caller.d.ts.map +1 -0
- package/dist/api/route-caller.js +63 -0
- package/dist/api/route-caller.js.map +1 -0
- package/dist/api/route-handler-input-options.d.ts.map +1 -1
- package/dist/api/route.d.ts +1 -1
- package/dist/api/route.d.ts.map +1 -1
- package/dist/api/route.js.map +1 -1
- package/dist/api/shared-types.d.ts.map +1 -1
- package/dist/client/client-error.d.ts.map +1 -1
- package/dist/client/client-error.js.map +1 -1
- package/dist/client/client.d.ts +91 -52
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +25 -9
- package/dist/client/client.js.map +1 -1
- package/dist/client/client.svelte.d.ts +6 -5
- package/dist/client/client.svelte.d.ts.map +1 -1
- package/dist/client/client.svelte.js +10 -2
- package/dist/client/client.svelte.js.map +1 -1
- package/dist/client/internal/ndjson-streaming.js.map +1 -1
- package/dist/client/react.d.ts +5 -4
- package/dist/client/react.d.ts.map +1 -1
- package/dist/client/react.js +104 -12
- package/dist/client/react.js.map +1 -1
- package/dist/client/solid.d.ts +7 -5
- package/dist/client/solid.d.ts.map +1 -1
- package/dist/client/solid.js +23 -9
- package/dist/client/solid.js.map +1 -1
- package/dist/client/vanilla.d.ts +16 -4
- package/dist/client/vanilla.d.ts.map +1 -1
- package/dist/client/vanilla.js +21 -1
- package/dist/client/vanilla.js.map +1 -1
- package/dist/client/vue.d.ts +7 -5
- package/dist/client/vue.d.ts.map +1 -1
- package/dist/client/vue.js +18 -10
- package/dist/client/vue.js.map +1 -1
- package/dist/id.d.ts +2 -0
- package/dist/id.js +3 -0
- package/dist/internal/cuid.d.ts +16 -0
- package/dist/internal/cuid.d.ts.map +1 -0
- package/dist/internal/cuid.js +82 -0
- package/dist/internal/cuid.js.map +1 -0
- package/dist/mod-client.d.ts +5 -4
- package/dist/mod-client.d.ts.map +1 -1
- package/dist/mod-client.js +7 -5
- package/dist/mod-client.js.map +1 -1
- package/dist/mod.d.ts +6 -5
- package/dist/mod.js +2 -1
- package/dist/runtime.js +1 -1
- package/dist/runtime.js.map +1 -1
- package/dist/test/test.d.ts +6 -6
- package/dist/test/test.d.ts.map +1 -1
- package/dist/test/test.js.map +1 -1
- package/dist/util/ssr.js.map +1 -1
- package/package.json +24 -40
- package/src/api/api.test.ts +3 -1
- package/src/api/api.ts +6 -0
- package/src/api/bind-services.ts +0 -5
- package/src/api/error.ts +1 -0
- package/src/api/fragment-definition-builder.extend.test.ts +2 -1
- package/src/api/fragment-definition-builder.test.ts +2 -1
- package/src/api/fragment-definition-builder.ts +49 -124
- package/src/api/fragment-instantiator.test.ts +92 -233
- package/src/api/fragment-instantiator.ts +228 -196
- package/src/api/fragment-services.test.ts +1 -0
- package/src/api/internal/path-runtime.test.ts +1 -0
- package/src/api/internal/path-type.test.ts +3 -1
- package/src/api/internal/route.test.ts +1 -0
- package/src/api/request-context-storage.ts +7 -0
- package/src/api/request-input-context.test.ts +4 -2
- package/src/api/request-input-context.ts +2 -1
- package/src/api/request-middleware.test.ts +9 -14
- package/src/api/request-middleware.ts +3 -2
- package/src/api/request-output-context.test.ts +3 -1
- package/src/api/request-output-context.ts +2 -1
- package/src/api/route-caller.test.ts +195 -0
- package/src/api/route-caller.ts +167 -0
- package/src/api/route-handler-input-options.ts +2 -1
- package/src/api/route.test.ts +4 -2
- package/src/api/route.ts +2 -1
- package/src/api/shared-types.ts +2 -1
- package/src/client/client-builder.test.ts +4 -2
- package/src/client/client-error.test.ts +2 -1
- package/src/client/client-error.ts +1 -1
- package/src/client/client-types.test.ts +19 -5
- package/src/client/client.ssr.test.ts +6 -4
- package/src/client/client.svelte.test.ts +18 -9
- package/src/client/client.svelte.ts +38 -13
- package/src/client/client.test.ts +49 -10
- package/src/client/client.ts +291 -141
- package/src/client/internal/ndjson-streaming.test.ts +6 -3
- package/src/client/internal/ndjson-streaming.ts +1 -0
- package/src/client/react.test.ts +176 -6
- package/src/client/react.ts +226 -31
- package/src/client/solid.test.ts +29 -5
- package/src/client/solid.ts +60 -22
- package/src/client/vanilla.test.ts +148 -6
- package/src/client/vanilla.ts +63 -9
- package/src/client/vue.test.ts +223 -84
- package/src/client/vue.ts +57 -30
- package/src/id.ts +1 -0
- package/src/internal/cuid.test.ts +164 -0
- package/src/internal/cuid.ts +133 -0
- package/src/mod-client.ts +4 -2
- package/src/mod.ts +3 -2
- package/src/runtime.ts +1 -1
- package/src/test/test.test.ts +4 -2
- package/src/test/test.ts +7 -9
- package/src/util/async.test.ts +1 -0
- package/src/util/content-type.test.ts +1 -0
- package/src/util/nanostores.test.ts +3 -1
- package/src/util/ssr.ts +1 -0
- package/tsconfig.json +1 -1
- package/tsdown.config.ts +1 -0
- package/vitest.config.ts +2 -1
package/src/client/react.test.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { test, expect, describe, vi, beforeEach, afterEach, expectTypeOf } from "vitest";
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
import { atom, computed, type ReadableAtom } from "nanostores";
|
|
4
|
+
import { StrictMode, createElement } from "react";
|
|
4
5
|
import { z } from "zod";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
|
|
7
|
+
import type { FetcherStore } from "@nanostores/query";
|
|
8
|
+
import { renderHook, act, waitFor } from "@testing-library/react";
|
|
9
|
+
|
|
8
10
|
import { defineFragment } from "../api/fragment-definition-builder";
|
|
11
|
+
import { RequestOutputContext } from "../api/request-output-context";
|
|
12
|
+
import { defineRoute } from "../api/route";
|
|
13
|
+
import { createClientBuilder } from "./client";
|
|
9
14
|
import type { FragnoPublicClientConfig } from "./client";
|
|
10
15
|
import { FragnoClientFetchNetworkError, type FragnoClientError } from "./client-error";
|
|
11
|
-
import {
|
|
12
|
-
import type { FetcherStore } from "@nanostores/query";
|
|
16
|
+
import { useFragno, useStore, type FragnoReactStore } from "./react";
|
|
13
17
|
|
|
14
18
|
// Mock fetch globally
|
|
15
19
|
global.fetch = vi.fn();
|
|
@@ -875,6 +879,172 @@ describe("useFragno - createStore", () => {
|
|
|
875
879
|
expect(result.current.error).toBeNull();
|
|
876
880
|
});
|
|
877
881
|
|
|
882
|
+
test("createStore supports factory callbacks with cleanup", async () => {
|
|
883
|
+
const disposeOne = vi.fn();
|
|
884
|
+
const disposeTwo = vi.fn();
|
|
885
|
+
const factory = vi.fn(({ path }: { path: { sessionId: string } }) => ({
|
|
886
|
+
sessionId: atom(path.sessionId),
|
|
887
|
+
sendMessage: (text: string) => text,
|
|
888
|
+
[Symbol.dispose]: path.sessionId === "1" ? disposeOne : disposeTwo,
|
|
889
|
+
}));
|
|
890
|
+
|
|
891
|
+
const createSessionStore = (args: { path: { sessionId: string } }) => factory(args);
|
|
892
|
+
|
|
893
|
+
const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
|
|
894
|
+
const client = {
|
|
895
|
+
useSession: cb.createStore(createSessionStore),
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
const { useSession } = useFragno(client);
|
|
899
|
+
|
|
900
|
+
expectTypeOf(useSession).toExtend<
|
|
901
|
+
(args: { path: { sessionId: string } }) => {
|
|
902
|
+
sessionId: string;
|
|
903
|
+
sendMessage: (text: string) => string;
|
|
904
|
+
}
|
|
905
|
+
>();
|
|
906
|
+
|
|
907
|
+
const { result, rerender, unmount } = renderHook(
|
|
908
|
+
({ sessionId }) => useSession({ path: { sessionId } }),
|
|
909
|
+
{ initialProps: { sessionId: "1" } },
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
expect(result.current.sessionId).toBe("1");
|
|
913
|
+
expect(result.current.sendMessage("hi")).toBe("hi");
|
|
914
|
+
expect(factory).toHaveBeenCalledTimes(1);
|
|
915
|
+
|
|
916
|
+
rerender({ sessionId: "1" });
|
|
917
|
+
expect(factory).toHaveBeenCalledTimes(1);
|
|
918
|
+
expect(disposeOne).not.toHaveBeenCalled();
|
|
919
|
+
|
|
920
|
+
rerender({ sessionId: "2" });
|
|
921
|
+
expect(result.current.sessionId).toBe("2");
|
|
922
|
+
expect(factory).toHaveBeenCalledTimes(2);
|
|
923
|
+
|
|
924
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
925
|
+
expect(disposeOne).toHaveBeenCalledTimes(1);
|
|
926
|
+
expect(disposeTwo).not.toHaveBeenCalled();
|
|
927
|
+
|
|
928
|
+
unmount();
|
|
929
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
930
|
+
expect(disposeTwo).toHaveBeenCalledTimes(1);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
test("does not dispose and recreate factory stores when rerendered with equivalent Date-backed args", () => {
|
|
934
|
+
const dispose = vi.fn();
|
|
935
|
+
const factory = vi.fn(
|
|
936
|
+
({
|
|
937
|
+
path,
|
|
938
|
+
initialData,
|
|
939
|
+
}: {
|
|
940
|
+
path: { sessionId: string };
|
|
941
|
+
initialData: {
|
|
942
|
+
createdAt: Date;
|
|
943
|
+
updatedAt: Date;
|
|
944
|
+
trace: Array<{ createdAt: Date; type: string }>;
|
|
945
|
+
};
|
|
946
|
+
}) => ({
|
|
947
|
+
sessionId: path.sessionId,
|
|
948
|
+
traceCount: initialData.trace.length,
|
|
949
|
+
[Symbol.dispose]: dispose,
|
|
950
|
+
}),
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
|
|
954
|
+
const client = {
|
|
955
|
+
useSession: cb.createStore(factory),
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
const { useSession } = useFragno(client);
|
|
959
|
+
|
|
960
|
+
const buildInitialData = () => ({
|
|
961
|
+
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
962
|
+
updatedAt: new Date("2026-01-01T00:00:01.000Z"),
|
|
963
|
+
trace: [{ createdAt: new Date("2026-01-01T00:00:02.000Z"), type: "message_start" }],
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
const { result, rerender } = renderHook(
|
|
967
|
+
({ initialData }) => useSession({ path: { sessionId: "session-1" }, initialData }),
|
|
968
|
+
{
|
|
969
|
+
initialProps: {
|
|
970
|
+
initialData: buildInitialData(),
|
|
971
|
+
},
|
|
972
|
+
},
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
expect(result.current.sessionId).toBe("session-1");
|
|
976
|
+
expect(result.current.traceCount).toBe(1);
|
|
977
|
+
expect(factory).toHaveBeenCalledTimes(1);
|
|
978
|
+
|
|
979
|
+
rerender({ initialData: buildInitialData() });
|
|
980
|
+
|
|
981
|
+
expect(factory).toHaveBeenCalledTimes(1);
|
|
982
|
+
expect(dispose).not.toHaveBeenCalled();
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test("does not dispose a factory store during StrictMode remount probing", async () => {
|
|
986
|
+
const dispose = vi.fn();
|
|
987
|
+
const factory = vi.fn(({ path }: { path: { sessionId: string } }) => ({
|
|
988
|
+
sessionId: path.sessionId,
|
|
989
|
+
[Symbol.dispose]: dispose,
|
|
990
|
+
}));
|
|
991
|
+
|
|
992
|
+
const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
|
|
993
|
+
const client = {
|
|
994
|
+
useSession: cb.createStore(factory),
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const { useSession } = useFragno(client);
|
|
998
|
+
|
|
999
|
+
const { result, unmount } = renderHook(() => useSession({ path: { sessionId: "session-1" } }), {
|
|
1000
|
+
wrapper: ({ children }) => createElement(StrictMode, null, children),
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
expect(result.current.sessionId).toBe("session-1");
|
|
1004
|
+
|
|
1005
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1006
|
+
expect(dispose).not.toHaveBeenCalled();
|
|
1007
|
+
|
|
1008
|
+
unmount();
|
|
1009
|
+
|
|
1010
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1011
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("createStore preserves class methods that rely on private fields", () => {
|
|
1015
|
+
const builder = createClientBuilder(
|
|
1016
|
+
defineFragment("test-fragment-private-store"),
|
|
1017
|
+
clientConfig,
|
|
1018
|
+
[],
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
class SessionStore {
|
|
1022
|
+
sessionId = atom("initial");
|
|
1023
|
+
#prefix: string;
|
|
1024
|
+
|
|
1025
|
+
constructor(sessionId: string) {
|
|
1026
|
+
this.sessionId.set(sessionId);
|
|
1027
|
+
this.#prefix = `session:${sessionId}`;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
start() {
|
|
1031
|
+
return `${this.#prefix}:start`;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const client = {
|
|
1036
|
+
useSession: builder.createStore(
|
|
1037
|
+
({ path }: { path: { sessionId: string } }) => new SessionStore(path.sessionId),
|
|
1038
|
+
),
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
const { useSession } = useFragno(client);
|
|
1042
|
+
const { result } = renderHook(() => useSession({ path: { sessionId: "abc" } }));
|
|
1043
|
+
|
|
1044
|
+
expect(result.current.sessionId).toBe("abc");
|
|
1045
|
+
expect(result.current.start()).toBe("session:abc:start");
|
|
1046
|
+
});
|
|
1047
|
+
|
|
878
1048
|
test("Derived from streaming route", async () => {
|
|
879
1049
|
const streamFragmentDefinition = defineFragment("stream-fragment");
|
|
880
1050
|
const streamRoutes = [
|
package/src/client/react.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import { listenKeys, type ReadableAtom, type Store, type StoreValue } from "nanostores";
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useSyncExternalStore,
|
|
8
|
+
type DependencyList,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
1
11
|
import type { FetcherValue } from "@nanostores/query";
|
|
2
12
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
3
|
-
|
|
4
|
-
import { useCallback, useMemo, useRef, useSyncExternalStore, type DependencyList } from "react";
|
|
13
|
+
|
|
5
14
|
import type { NonGetHTTPMethod } from "../api/api";
|
|
6
|
-
import type { FragnoClientMutatorData, FragnoClientHookData } from "./client";
|
|
7
|
-
import { isGetHook, isMutatorHook, isStore, type FragnoStoreData } from "./client";
|
|
8
|
-
import type { FragnoClientError } from "./client-error";
|
|
9
|
-
import { hydrateFromWindow } from "../util/ssr";
|
|
10
|
-
import type { InferOr } from "../util/types-util";
|
|
11
15
|
import type {
|
|
12
16
|
ExtractPathParamsOrWiden,
|
|
13
17
|
HasPathParams,
|
|
@@ -15,6 +19,18 @@ import type {
|
|
|
15
19
|
QueryParamsHint,
|
|
16
20
|
} from "../api/internal/path";
|
|
17
21
|
import { isReadableAtom } from "../util/nanostores";
|
|
22
|
+
import { hydrateFromWindow } from "../util/ssr";
|
|
23
|
+
import type { InferOr } from "../util/types-util";
|
|
24
|
+
import type { FragnoClientMutatorData, FragnoClientHookData } from "./client";
|
|
25
|
+
import {
|
|
26
|
+
isGetHook,
|
|
27
|
+
isMutatorHook,
|
|
28
|
+
isStore,
|
|
29
|
+
type FragnoStoreData,
|
|
30
|
+
type FragnoStoreFactoryData,
|
|
31
|
+
type FragnoStoreObjectData,
|
|
32
|
+
} from "./client";
|
|
33
|
+
import type { FragnoClientError } from "./client-error";
|
|
18
34
|
|
|
19
35
|
export type FragnoReactHook<
|
|
20
36
|
_TMethod extends "GET",
|
|
@@ -103,36 +119,213 @@ function createReactMutator<
|
|
|
103
119
|
/**
|
|
104
120
|
* Type helper that unwraps any Store fields of the object into StoreValues
|
|
105
121
|
*/
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
type FragnoReactStoreValue<T extends object> =
|
|
123
|
+
T extends Store<infer TStore>
|
|
124
|
+
? StoreValue<TStore>
|
|
125
|
+
: {
|
|
126
|
+
[K in keyof T]: T[K] extends Store ? StoreValue<T[K]> : T[K];
|
|
127
|
+
};
|
|
111
128
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
129
|
+
export type FragnoReactStore<T extends object, TArgs extends unknown[] = []> = (
|
|
130
|
+
...args: TArgs
|
|
131
|
+
) => FragnoReactStoreValue<T>;
|
|
132
|
+
|
|
133
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
134
|
+
if (!value || typeof value !== "object") {
|
|
135
|
+
return false;
|
|
115
136
|
}
|
|
116
137
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
138
|
+
const prototype = Object.getPrototypeOf(value);
|
|
139
|
+
return prototype === Object.prototype || prototype === null;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const areStoreFactoryValuesEqual = (left: unknown, right: unknown): boolean => {
|
|
143
|
+
if (Object.is(left, right)) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
148
|
+
return (
|
|
149
|
+
left.length === right.length &&
|
|
150
|
+
left.every((value, index) => areStoreFactoryValuesEqual(value, right[index]))
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (left instanceof Date && right instanceof Date) {
|
|
155
|
+
return left.getTime() === right.getTime();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (isReadableAtom(left) || isReadableAtom(right)) {
|
|
159
|
+
return left === right;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (typeof left === "function" || typeof right === "function") {
|
|
163
|
+
return left === right;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (isPlainObject(left) && isPlainObject(right)) {
|
|
167
|
+
const leftKeys = Object.keys(left).sort();
|
|
168
|
+
const rightKeys = Object.keys(right).sort();
|
|
169
|
+
return (
|
|
170
|
+
leftKeys.length === rightKeys.length &&
|
|
171
|
+
leftKeys.every(
|
|
172
|
+
(key, index) =>
|
|
173
|
+
key === rightKeys[index] && areStoreFactoryValuesEqual(left[key], right[key]),
|
|
174
|
+
)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return false;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const areStoreFactoryArgsEqual = (left: unknown[], right: unknown[]) =>
|
|
182
|
+
left.length === right.length &&
|
|
183
|
+
left.every((value, index) => areStoreFactoryValuesEqual(value, right[index]));
|
|
184
|
+
|
|
185
|
+
const getStoreDisposer = (value: object): (() => void) | undefined => {
|
|
186
|
+
const disposer = (value as { [Symbol.dispose]?: (() => void) | undefined })[Symbol.dispose];
|
|
187
|
+
return typeof disposer === "function" ? disposer.bind(value) : undefined;
|
|
188
|
+
};
|
|
120
189
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
190
|
+
const createReactStoreObjectView = <T extends object>(
|
|
191
|
+
value: T,
|
|
192
|
+
getAtomValue: (store: Store<unknown>) => unknown,
|
|
193
|
+
): FragnoReactStoreValue<T> => {
|
|
194
|
+
const atomValues = new Map<Store<unknown>, unknown>();
|
|
195
|
+
const boundMethods = new Map<PropertyKey, unknown>();
|
|
196
|
+
|
|
197
|
+
return new Proxy(value, {
|
|
198
|
+
get(target, property, _receiver) {
|
|
199
|
+
const propertyValue = Reflect.get(target, property, target);
|
|
200
|
+
|
|
201
|
+
if (isReadableAtom(propertyValue)) {
|
|
202
|
+
if (atomValues.has(propertyValue)) {
|
|
203
|
+
return atomValues.get(propertyValue);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const atomValue = getAtomValue(propertyValue);
|
|
207
|
+
atomValues.set(propertyValue, atomValue);
|
|
208
|
+
return atomValue;
|
|
124
209
|
}
|
|
125
210
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
211
|
+
if (typeof propertyValue === "function") {
|
|
212
|
+
if (boundMethods.has(property)) {
|
|
213
|
+
return boundMethods.get(property);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const boundMethod = propertyValue.bind(target);
|
|
217
|
+
boundMethods.set(property, boundMethod);
|
|
218
|
+
return boundMethod;
|
|
131
219
|
}
|
|
132
|
-
}
|
|
133
220
|
|
|
134
|
-
|
|
221
|
+
return propertyValue;
|
|
222
|
+
},
|
|
223
|
+
}) as FragnoReactStoreValue<T>;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
function unwrapReactStoreValueOnServer<T extends object>(value: T): FragnoReactStoreValue<T> {
|
|
227
|
+
if (isReadableAtom(value)) {
|
|
228
|
+
return value.get() as FragnoReactStoreValue<T>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return createReactStoreObjectView(value, (store) => store.get());
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function unwrapReactStoreValue<T extends object>(value: T): FragnoReactStoreValue<T> {
|
|
235
|
+
if (isReadableAtom(value)) {
|
|
236
|
+
return useStore(value as Store) as FragnoReactStoreValue<T>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const keys = Object.keys(value);
|
|
240
|
+
const atomEntries = keys.flatMap((key) => {
|
|
241
|
+
const fieldValue = value[key as keyof T];
|
|
242
|
+
return isReadableAtom(fieldValue)
|
|
243
|
+
? ([[key, fieldValue]] as Array<[string, Store<unknown>]>)
|
|
244
|
+
: [];
|
|
245
|
+
});
|
|
246
|
+
const snapshotRef = useRef<unknown[]>(atomEntries.map(([, store]) => store.get()));
|
|
247
|
+
|
|
248
|
+
const getSnapshot = () => {
|
|
249
|
+
const nextSnapshot = atomEntries.map(([, store]) => store.get());
|
|
250
|
+
const previousSnapshot = snapshotRef.current;
|
|
251
|
+
if (
|
|
252
|
+
previousSnapshot.length === nextSnapshot.length &&
|
|
253
|
+
previousSnapshot.every((entry, index) => Object.is(entry, nextSnapshot[index]))
|
|
254
|
+
) {
|
|
255
|
+
return previousSnapshot;
|
|
256
|
+
}
|
|
257
|
+
snapshotRef.current = nextSnapshot;
|
|
258
|
+
return nextSnapshot;
|
|
135
259
|
};
|
|
260
|
+
|
|
261
|
+
const atomValues = useSyncExternalStore(
|
|
262
|
+
(onStoreChange) => {
|
|
263
|
+
const unsubscribes = atomEntries.map(([, store]) => store.listen(onStoreChange));
|
|
264
|
+
return () => {
|
|
265
|
+
for (const unsubscribe of unsubscribes) {
|
|
266
|
+
unsubscribe();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
getSnapshot,
|
|
271
|
+
getSnapshot,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
return useMemo(
|
|
275
|
+
() =>
|
|
276
|
+
createReactStoreObjectView(value, (store) => {
|
|
277
|
+
const atomIndex = atomEntries.findIndex(([, entryStore]) => entryStore === store);
|
|
278
|
+
return atomIndex === -1 ? store.get() : atomValues[atomIndex];
|
|
279
|
+
}),
|
|
280
|
+
[value, atomEntries, atomValues],
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function createReactStore<const T extends object, const TArgs extends unknown[]>(
|
|
285
|
+
hook: FragnoStoreData<T, TArgs>,
|
|
286
|
+
): FragnoReactStore<T, TArgs> {
|
|
287
|
+
return ((...args: TArgs) => {
|
|
288
|
+
const stableArgsRef = useRef<TArgs>(args);
|
|
289
|
+
const pendingDisposalsRef = useRef<Map<object, ReturnType<typeof setTimeout>>>(new Map());
|
|
290
|
+
if (!areStoreFactoryArgsEqual(stableArgsRef.current, args)) {
|
|
291
|
+
stableArgsRef.current = args;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const value = useMemo(() => {
|
|
295
|
+
if ("factory" in hook) {
|
|
296
|
+
return hook.factory(...stableArgsRef.current);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return hook.obj;
|
|
300
|
+
}, [hook, stableArgsRef.current]);
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
const disposer = getStoreDisposer(value);
|
|
304
|
+
const pendingTimeout = pendingDisposalsRef.current.get(value);
|
|
305
|
+
if (pendingTimeout !== undefined) {
|
|
306
|
+
clearTimeout(pendingTimeout);
|
|
307
|
+
pendingDisposalsRef.current.delete(value);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return () => {
|
|
311
|
+
if (!disposer) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const timeoutId = setTimeout(() => {
|
|
316
|
+
pendingDisposalsRef.current.delete(value);
|
|
317
|
+
disposer();
|
|
318
|
+
}, 0);
|
|
319
|
+
pendingDisposalsRef.current.set(value, timeoutId);
|
|
320
|
+
};
|
|
321
|
+
}, [value]);
|
|
322
|
+
|
|
323
|
+
if (typeof window === "undefined") {
|
|
324
|
+
return unwrapReactStoreValueOnServer(value);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return unwrapReactStoreValue(value);
|
|
328
|
+
}) as FragnoReactStore<T, TArgs>;
|
|
136
329
|
}
|
|
137
330
|
|
|
138
331
|
export function useFragno<T extends Record<string, unknown>>(
|
|
@@ -155,9 +348,11 @@ export function useFragno<T extends Record<string, unknown>>(
|
|
|
155
348
|
infer TQueryParameters
|
|
156
349
|
>
|
|
157
350
|
? FragnoReactMutator<TMethod, TPath, TInput, TOutput, TError, TQueryParameters>
|
|
158
|
-
: T[K] extends
|
|
159
|
-
? FragnoReactStore<TStoreObj>
|
|
160
|
-
: T[K]
|
|
351
|
+
: T[K] extends FragnoStoreObjectData<infer TStoreObj>
|
|
352
|
+
? FragnoReactStore<TStoreObj, []>
|
|
353
|
+
: T[K] extends FragnoStoreFactoryData<infer TStoreObj, infer TStoreArgs>
|
|
354
|
+
? FragnoReactStore<TStoreObj, TStoreArgs>
|
|
355
|
+
: T[K];
|
|
161
356
|
} {
|
|
162
357
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
358
|
const result = {} as any; // We need one any cast here due to TypeScript's limitations with mapped types
|
package/src/client/solid.test.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { test, expect, describe, vi, beforeEach, afterEach, assert } from "vitest";
|
|
1
|
+
import { test, expect, describe, vi, beforeEach, afterEach, assert, expectTypeOf } from "vitest";
|
|
2
|
+
|
|
2
3
|
import { atom, computed, type ReadableAtom } from "nanostores";
|
|
4
|
+
import { createSignal, createRoot } from "solid-js";
|
|
3
5
|
import { z } from "zod";
|
|
4
|
-
|
|
5
|
-
import { useFragno, accessorToAtom, isAccessor } from "./solid";
|
|
6
|
-
import { defineRoute } from "../api/route";
|
|
6
|
+
|
|
7
7
|
import { defineFragment } from "../api/fragment-definition-builder";
|
|
8
|
+
import { defineRoute } from "../api/route";
|
|
9
|
+
import { createClientBuilder } from "./client";
|
|
8
10
|
import type { FragnoPublicClientConfig } from "./client";
|
|
9
11
|
import { FragnoClientUnknownApiError } from "./client-error";
|
|
10
|
-
import {
|
|
12
|
+
import { useFragno, accessorToAtom, isAccessor } from "./solid";
|
|
11
13
|
|
|
12
14
|
// Mock fetch globally
|
|
13
15
|
global.fetch = vi.fn();
|
|
@@ -819,6 +821,28 @@ describe("createSolidStore", () => {
|
|
|
819
821
|
});
|
|
820
822
|
});
|
|
821
823
|
|
|
824
|
+
test("should type and return zero-argument factory stores as callable", () => {
|
|
825
|
+
const countAtom: ReadableAtom<number> = atom(0);
|
|
826
|
+
const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
|
|
827
|
+
|
|
828
|
+
const client = {
|
|
829
|
+
useCounter: cb.createStore(() => countAtom),
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
createRoot((dispose) => {
|
|
833
|
+
const { useCounter } = useFragno(client);
|
|
834
|
+
|
|
835
|
+
expectTypeOf(useCounter).toExtend<() => () => number>();
|
|
836
|
+
expect(typeof useCounter).toBe("function");
|
|
837
|
+
|
|
838
|
+
const counter = useCounter();
|
|
839
|
+
expect(typeof counter).toBe("function");
|
|
840
|
+
expect(counter()).toBe(0);
|
|
841
|
+
|
|
842
|
+
dispose();
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
|
|
822
846
|
test("should handle single atom as store", () => {
|
|
823
847
|
const singleAtom: ReadableAtom<string> = atom("single");
|
|
824
848
|
const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
|
package/src/client/solid.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
1
|
import { atom, type ReadableAtom, type Store, type StoreValue } from "nanostores";
|
|
3
|
-
import { useStore } from "@nanostores/solid";
|
|
4
2
|
import type { Accessor } from "solid-js";
|
|
5
|
-
import { createEffect } from "solid-js";
|
|
3
|
+
import { createEffect, onCleanup } from "solid-js";
|
|
4
|
+
|
|
5
|
+
import { useStore } from "@nanostores/solid";
|
|
6
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
7
|
+
|
|
6
8
|
import type { NonGetHTTPMethod } from "../api/api";
|
|
9
|
+
import type { MaybeExtractPathParamsOrWiden, QueryParamsHint } from "../api/internal/path";
|
|
10
|
+
import { isReadableAtom } from "../util/nanostores";
|
|
11
|
+
import type { InferOr } from "../util/types-util";
|
|
7
12
|
import {
|
|
8
13
|
isGetHook,
|
|
9
14
|
isMutatorHook,
|
|
@@ -11,11 +16,10 @@ import {
|
|
|
11
16
|
type FragnoClientHookData,
|
|
12
17
|
type FragnoClientMutatorData,
|
|
13
18
|
type FragnoStoreData,
|
|
19
|
+
type FragnoStoreFactoryData,
|
|
20
|
+
type FragnoStoreObjectData,
|
|
14
21
|
} from "./client";
|
|
15
22
|
import type { FragnoClientError } from "./client-error";
|
|
16
|
-
import type { InferOr } from "../util/types-util";
|
|
17
|
-
import type { MaybeExtractPathParamsOrWiden, QueryParamsHint } from "../api/internal/path";
|
|
18
|
-
import { isReadableAtom } from "../util/nanostores";
|
|
19
23
|
|
|
20
24
|
export type FragnoSolidHook<
|
|
21
25
|
_TMethod extends "GET",
|
|
@@ -178,38 +182,72 @@ function createSolidMutator<
|
|
|
178
182
|
};
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
|
|
185
|
+
type FragnoSolidStoreValue<T extends object> = T extends Store
|
|
186
|
+
? Accessor<StoreValue<T>>
|
|
187
|
+
: {
|
|
188
|
+
[K in keyof T]: T[K] extends Store ? Accessor<StoreValue<T[K]>> : T[K];
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
type FragnoSolidStaticStore<T extends object> = T extends Store
|
|
182
192
|
? Accessor<StoreValue<T>>
|
|
183
193
|
: () => {
|
|
184
194
|
[K in keyof T]: T[K] extends Store ? Accessor<StoreValue<T[K]>> : T[K];
|
|
185
195
|
};
|
|
186
196
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
197
|
+
export type FragnoSolidStore<TStore extends FragnoStoreData<object, unknown[]>> =
|
|
198
|
+
TStore extends FragnoStoreFactoryData<infer T, infer TArgs>
|
|
199
|
+
? (...args: TArgs) => FragnoSolidStoreValue<T>
|
|
200
|
+
: TStore extends FragnoStoreObjectData<infer T>
|
|
201
|
+
? FragnoSolidStaticStore<T>
|
|
202
|
+
: never;
|
|
203
|
+
|
|
204
|
+
function unwrapSolidStoreValue<T extends object>(value: T): FragnoSolidStoreValue<T> {
|
|
205
|
+
if (isReadableAtom(value)) {
|
|
206
|
+
return useStore(value) as FragnoSolidStoreValue<T>;
|
|
193
207
|
}
|
|
194
208
|
|
|
195
209
|
// For objects containing atoms, wrap each atom property with useStore
|
|
196
210
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
197
211
|
const result: any = {};
|
|
198
212
|
|
|
199
|
-
for (const key in
|
|
200
|
-
if (!Object.prototype.hasOwnProperty.call(
|
|
213
|
+
for (const key in value) {
|
|
214
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
|
201
215
|
continue;
|
|
202
216
|
}
|
|
203
217
|
|
|
204
|
-
const
|
|
205
|
-
if (isReadableAtom(
|
|
206
|
-
result[key] = useStore(
|
|
218
|
+
const fieldValue = value[key];
|
|
219
|
+
if (isReadableAtom(fieldValue)) {
|
|
220
|
+
result[key] = useStore(fieldValue);
|
|
207
221
|
} else {
|
|
208
|
-
result[key] =
|
|
222
|
+
result[key] = fieldValue;
|
|
209
223
|
}
|
|
210
224
|
}
|
|
211
225
|
|
|
212
|
-
return
|
|
226
|
+
return result as FragnoSolidStoreValue<T>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function createSolidStore<const TStore extends FragnoStoreData<object, unknown[]>>(
|
|
230
|
+
hook: TStore,
|
|
231
|
+
): FragnoSolidStore<TStore> {
|
|
232
|
+
if ("obj" in hook) {
|
|
233
|
+
if (isReadableAtom(hook.obj)) {
|
|
234
|
+
return useStore(hook.obj) as FragnoSolidStore<TStore>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return (() => unwrapSolidStoreValue(hook.obj)) as FragnoSolidStore<TStore>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return ((...args: Parameters<typeof hook.factory>) => {
|
|
241
|
+
const value = hook.factory(...args);
|
|
242
|
+
const disposer = (value as { [Symbol.dispose]?: (() => void) | undefined })[Symbol.dispose];
|
|
243
|
+
if (typeof disposer === "function") {
|
|
244
|
+
onCleanup(() => {
|
|
245
|
+
disposer.call(value);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return unwrapSolidStoreValue(value);
|
|
250
|
+
}) as FragnoSolidStore<TStore>;
|
|
213
251
|
}
|
|
214
252
|
|
|
215
253
|
export function useFragno<T extends Record<string, unknown>>(
|
|
@@ -232,8 +270,8 @@ export function useFragno<T extends Record<string, unknown>>(
|
|
|
232
270
|
infer TQueryParameters
|
|
233
271
|
>
|
|
234
272
|
? FragnoSolidMutator<TMethod, TPath, TInput, TOutput, TError, TQueryParameters>
|
|
235
|
-
: T[K] extends FragnoStoreData<
|
|
236
|
-
? FragnoSolidStore<
|
|
273
|
+
: T[K] extends FragnoStoreData<object, unknown[]>
|
|
274
|
+
? FragnoSolidStore<T[K]>
|
|
237
275
|
: T[K];
|
|
238
276
|
} {
|
|
239
277
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|