@myna-sh/react 0.3.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/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @myna-sh/react
2
+
3
+ React hooks for reading [Myna](https://myna.sh) content.
4
+
5
+ A thin layer over [`@myna-sh/sdk`](https://www.npmjs.com/package/@myna-sh/sdk) with no dependencies of its own: request sharing, loading and error state, stale-while-revalidate, preview-token propagation, and Suspense support.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @myna-sh/react @myna-sh/sdk
11
+ ```
12
+
13
+ `@myna-sh/react`, `@myna-sh/sdk`, `@myna-sh/cli`, and `@myna-sh/mcp` are released in lockstep — the same version number always means the same commit.
14
+
15
+ ## Use
16
+
17
+ ```tsx
18
+ import { createMyna } from "@myna-sh/sdk";
19
+ import { MynaProvider, useMynaEntries } from "@myna-sh/react";
20
+ import type { MynaCollections } from "./myna.generated";
21
+
22
+ const myna = createMyna<MynaCollections>({
23
+ project: "my-site",
24
+ // A preview token here reaches every hook below.
25
+ previewToken: new URLSearchParams(location.search).get("preview") ?? undefined,
26
+ });
27
+
28
+ function App() {
29
+ return (
30
+ <MynaProvider client={myna}>
31
+ <Posts />
32
+ </MynaProvider>
33
+ );
34
+ }
35
+
36
+ function Posts() {
37
+ const { data, error, isLoading } = useMynaEntries<MynaCollections>("posts", {
38
+ order: "-publishedAt",
39
+ limit: 20,
40
+ });
41
+
42
+ if (isLoading) return <p>Loading…</p>;
43
+ if (error) return <p>{error.message}</p>;
44
+
45
+ return (
46
+ <ul>
47
+ {data?.data.map((post) => (
48
+ <li key={post.id}>{post.fields.title}</li>
49
+ ))}
50
+ </ul>
51
+ );
52
+ }
53
+ ```
54
+
55
+ Pass your generated `MynaCollections` and `post.fields.title` is a `string` the compiler knows about — see [generated types](https://docs.myna.sh/schema/generated-types).
56
+
57
+ ## Hooks
58
+
59
+ | Hook | Returns |
60
+ | --- | --- |
61
+ | `useMynaEntries(collection, options?)` | A list of entries plus `nextCursor` |
62
+ | `useMynaEntry(collection, slugOrId, options?)` | One entry; waits rather than fetching when `slugOrId` is undefined |
63
+ | `useMynaSingleton(collection, options?)` | A singleton collection's entry |
64
+
65
+ Each returns `{ data, error, isLoading, isValidating, refetch }` and accepts the SDK's read options (`order`, `filter`, `fields`, `include`, `representation`) alongside:
66
+
67
+ | Option | Meaning |
68
+ | --- | --- |
69
+ | `enabled` | Skip the read — for a query whose input is not ready yet |
70
+ | `staleMs` | How long a cached value stays fresh before a background refetch. Default 30s |
71
+ | `suspense` | Throw the in-flight promise so a `<Suspense>` boundary handles loading |
72
+
73
+ ## Behavior worth knowing
74
+
75
+ - **One request per query.** Components asking for the same thing share a single fetch, in-flight and across re-renders.
76
+ - **A failed refresh does not blank the page.** The last good value stays in `data` alongside `error`, because content already on screen is still correct.
77
+ - **Previews need no separate code path.** The provider's token flows into every read, and the server composes the collection as it would be published.
78
+
79
+ ## Bring your own data layer
80
+
81
+ If you already use TanStack Query or SWR, call `@myna-sh/sdk` directly through it instead — this package exists for apps that would otherwise write the adapter by hand, not to compete with a real cache.
82
+
83
+ ## Links
84
+
85
+ - [Myna](https://myna.sh)
86
+ - Support: [support@myna.sh](mailto:support@myna.sh)
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,101 @@
1
+ import { ReactNode } from 'react';
2
+ import { MynaClient, CreateMynaOptions, CollectionShape, ListEntriesOptions, EntryList, GetEntryOptions, PublishedEntry } from '@myna-sh/sdk';
3
+
4
+ interface MynaProviderProps {
5
+ /** An existing client. Takes precedence over `options`. */
6
+ client?: MynaClient<any>;
7
+ /** Client options, when you would rather this component construct one. */
8
+ options?: CreateMynaOptions;
9
+ children: ReactNode;
10
+ }
11
+ /**
12
+ * Provide a Myna client to the hooks below.
13
+ *
14
+ * Constructing from `options` is memoized on the values that matter, so a
15
+ * parent re-render does not silently create a new client — which would discard
16
+ * the in-flight request coalescing that makes concurrent reads cheap.
17
+ */
18
+ declare function MynaProvider({ client, options, children }: MynaProviderProps): ReactNode;
19
+
20
+ declare function useMynaClient<T extends Record<keyof T, CollectionShape> = Record<string, CollectionShape>>(): MynaClient<T>;
21
+
22
+ /**
23
+ * Reading content from React, without the adapter every site was writing.
24
+ *
25
+ * Each hook gives loading and error state, shares one request between
26
+ * components asking for the same thing, keeps the last good value while
27
+ * revalidating, and inherits the provider's preview token — so a previewed page
28
+ * needs no separate code path.
29
+ */
30
+ interface UseMynaOptions {
31
+ /** Skip the read entirely (a dependent query whose input is not ready yet). */
32
+ enabled?: boolean;
33
+ /** How long a cached value stays fresh before a background refetch. Default 30s. */
34
+ staleMs?: number;
35
+ /** Throw the in-flight promise so a `<Suspense>` boundary handles loading. */
36
+ suspense?: boolean;
37
+ }
38
+ interface UseMynaResult<T> {
39
+ data: T | undefined;
40
+ error: Error | undefined;
41
+ isLoading: boolean;
42
+ /** True while revalidating with a value already on screen. */
43
+ isValidating: boolean;
44
+ refetch: () => void;
45
+ }
46
+ /** Read a collection. Returns the entry list, typed by the generated registry. */
47
+ declare function useMynaEntries<T extends Record<keyof T, CollectionShape> = Record<string, CollectionShape>, K extends keyof T & string = keyof T & string>(collection: K, options?: ListEntriesOptions<T[K]> & UseMynaOptions): UseMynaResult<EntryList<T[K]["fields"]>>;
48
+ /** Read one entry by slug or id. */
49
+ declare function useMynaEntry<T extends Record<keyof T, CollectionShape> = Record<string, CollectionShape>, K extends keyof T & string = keyof T & string>(collection: K, slugOrId: string | undefined, options?: GetEntryOptions<T[K]> & UseMynaOptions): UseMynaResult<PublishedEntry<T[K]["fields"]>>;
50
+ /** Read a singleton collection's entry. */
51
+ declare function useMynaSingleton<T extends Record<keyof T, CollectionShape> = Record<string, CollectionShape>, K extends keyof T & string = keyof T & string>(collection: K, options?: GetEntryOptions<T[K]> & UseMynaOptions): UseMynaResult<PublishedEntry<T[K]["fields"]>>;
52
+
53
+ /**
54
+ * A minimal cache with stale-while-revalidate semantics.
55
+ *
56
+ * Deliberately not a dependency on a data library: this package's job is to
57
+ * remove the adapter every Myna site was writing by hand, and adding a required
58
+ * peer of TanStack Query or SWR would just move that decision rather than
59
+ * remove it. Anyone who already has one should call the SDK directly through
60
+ * it — this exists for the apps that do not.
61
+ *
62
+ * Entries hold the last successful value, so a re-mount paints immediately and
63
+ * refetches behind the paint instead of flashing a spinner at content the user
64
+ * has already seen.
65
+ */
66
+ type EntryState<T> = {
67
+ status: "pending";
68
+ data: undefined;
69
+ error: undefined;
70
+ } | {
71
+ status: "success";
72
+ data: T;
73
+ error: undefined;
74
+ } | {
75
+ status: "error";
76
+ data: T | undefined;
77
+ error: Error;
78
+ };
79
+ declare class MynaStore {
80
+ private readonly entries;
81
+ private entry;
82
+ getState<T>(key: string): EntryState<T>;
83
+ subscribe(key: string, listener: () => void): () => void;
84
+ private publish;
85
+ /**
86
+ * Ensure `key` holds a fresh-enough value, fetching if not.
87
+ *
88
+ * Returns the in-flight promise so a Suspense boundary can throw it. Several
89
+ * components asking at once share one request — a second layer of coalescing
90
+ * above the SDK's, because this one also covers sequential re-renders.
91
+ */
92
+ load<T>(key: string, fetcher: () => Promise<T>, staleMs?: number): Promise<void>;
93
+ /** Drop a cached value, forcing the next read to refetch. */
94
+ invalidate(prefix?: string): void;
95
+ }
96
+ /** Module-level default store, shared by every hook in a page. */
97
+ declare const defaultStore: MynaStore;
98
+
99
+ declare const VERSION: string;
100
+
101
+ export { type EntryState, MynaProvider, type MynaProviderProps, MynaStore, type UseMynaOptions, type UseMynaResult, VERSION, defaultStore, useMynaClient, useMynaEntries, useMynaEntry, useMynaSingleton };
package/dist/index.js ADDED
@@ -0,0 +1,189 @@
1
+ // src/provider.tsx
2
+ import { useMemo } from "react";
3
+ import { createMyna } from "@myna-sh/sdk";
4
+
5
+ // src/context.ts
6
+ import { createContext, useContext } from "react";
7
+ var MynaContext = createContext(null);
8
+ function useMynaClient() {
9
+ const client = useContext(MynaContext);
10
+ if (!client) {
11
+ throw new Error(
12
+ "No Myna client in context. Wrap your app in <MynaProvider client={createMyna({ project })}>."
13
+ );
14
+ }
15
+ return client;
16
+ }
17
+
18
+ // src/provider.tsx
19
+ import { jsx } from "react/jsx-runtime";
20
+ function MynaProvider({ client, options, children }) {
21
+ const resolved = useMemo(() => {
22
+ if (client) return client;
23
+ if (!options) {
24
+ throw new Error("MynaProvider needs either a `client` or `options`.");
25
+ }
26
+ return createMyna(options);
27
+ }, [
28
+ client,
29
+ options?.project,
30
+ options?.apiUrl,
31
+ options?.apiKey,
32
+ options?.previewToken,
33
+ options?.previewMode
34
+ ]);
35
+ return /* @__PURE__ */ jsx(MynaContext.Provider, { value: resolved, children });
36
+ }
37
+
38
+ // src/hooks.ts
39
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
40
+
41
+ // src/store.ts
42
+ var DEFAULT_STALE_MS = 3e4;
43
+ var MynaStore = class {
44
+ entries = /* @__PURE__ */ new Map();
45
+ entry(key) {
46
+ let found = this.entries.get(key);
47
+ if (!found) {
48
+ found = {
49
+ state: { status: "pending", data: void 0, error: void 0 },
50
+ updatedAt: 0,
51
+ listeners: /* @__PURE__ */ new Set()
52
+ };
53
+ this.entries.set(key, found);
54
+ }
55
+ return found;
56
+ }
57
+ getState(key) {
58
+ return this.entry(key).state;
59
+ }
60
+ subscribe(key, listener) {
61
+ const entry = this.entry(key);
62
+ entry.listeners.add(listener);
63
+ return () => {
64
+ entry.listeners.delete(listener);
65
+ };
66
+ }
67
+ publish(key, state) {
68
+ const entry = this.entry(key);
69
+ entry.state = state;
70
+ for (const listener of entry.listeners) listener();
71
+ }
72
+ /**
73
+ * Ensure `key` holds a fresh-enough value, fetching if not.
74
+ *
75
+ * Returns the in-flight promise so a Suspense boundary can throw it. Several
76
+ * components asking at once share one request — a second layer of coalescing
77
+ * above the SDK's, because this one also covers sequential re-renders.
78
+ */
79
+ load(key, fetcher, staleMs = DEFAULT_STALE_MS) {
80
+ const entry = this.entry(key);
81
+ if (entry.promise) return entry.promise;
82
+ const fresh = entry.state.status === "success" && Date.now() - entry.updatedAt < staleMs;
83
+ if (fresh) return Promise.resolve();
84
+ const promise = fetcher().then((data) => {
85
+ entry.updatedAt = Date.now();
86
+ this.publish(key, { status: "success", data, error: void 0 });
87
+ }).catch((error) => {
88
+ this.publish(key, {
89
+ status: "error",
90
+ data: entry.state.data,
91
+ error: error instanceof Error ? error : new Error(String(error))
92
+ });
93
+ }).finally(() => {
94
+ entry.promise = void 0;
95
+ });
96
+ entry.promise = promise;
97
+ return promise;
98
+ }
99
+ /** Drop a cached value, forcing the next read to refetch. */
100
+ invalidate(prefix) {
101
+ for (const [key, entry] of this.entries) {
102
+ if (prefix && !key.startsWith(prefix)) continue;
103
+ entry.updatedAt = 0;
104
+ for (const listener of entry.listeners) listener();
105
+ }
106
+ }
107
+ };
108
+ var defaultStore = new MynaStore();
109
+
110
+ // src/hooks.ts
111
+ function cacheKey(kind, collection, rest) {
112
+ return `${kind}:${collection}:${stable(rest)}`;
113
+ }
114
+ function stable(value) {
115
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
116
+ if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
117
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0 && typeof v !== "function" && !(v instanceof AbortSignal)).sort(([a], [b]) => a.localeCompare(b));
118
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(",")}}`;
119
+ }
120
+ function useMynaQuery(key, fetcher, options) {
121
+ const { enabled = true, staleMs, suspense = false } = options;
122
+ const subscribe = useCallback((listener) => defaultStore.subscribe(key, listener), [key]);
123
+ const snapshot = useCallback(() => defaultStore.getState(key), [key]);
124
+ const state = useSyncExternalStore(subscribe, snapshot, snapshot);
125
+ const fetcherRef = useRef(fetcher);
126
+ fetcherRef.current = fetcher;
127
+ const run = useCallback(() => fetcherRef.current(), []);
128
+ useEffect(() => {
129
+ if (!enabled) return;
130
+ void defaultStore.load(key, run, staleMs);
131
+ }, [key, enabled, staleMs, run]);
132
+ if (suspense && enabled && state.status === "pending") {
133
+ throw defaultStore.load(key, run, staleMs);
134
+ }
135
+ const refetch = useCallback(() => {
136
+ defaultStore.invalidate(key);
137
+ void defaultStore.load(key, run, 0);
138
+ }, [key, run]);
139
+ return {
140
+ data: state.data,
141
+ error: state.error,
142
+ isLoading: enabled && state.status === "pending",
143
+ isValidating: enabled && state.status !== "pending" && state.data !== void 0,
144
+ refetch
145
+ };
146
+ }
147
+ function useMynaEntries(collection, options = {}) {
148
+ const client = useMynaClient();
149
+ const { enabled, staleMs, suspense, ...query } = options;
150
+ return useMynaQuery(
151
+ cacheKey("entries", collection, query),
152
+ () => client.entries.list(collection, query),
153
+ { enabled, staleMs, suspense }
154
+ );
155
+ }
156
+ function useMynaEntry(collection, slugOrId, options = {}) {
157
+ const client = useMynaClient();
158
+ const { enabled = true, staleMs, suspense, ...query } = options;
159
+ return useMynaQuery(
160
+ cacheKey("entry", `${collection}/${slugOrId ?? ""}`, query),
161
+ () => client.entries.get(collection, slugOrId, query),
162
+ // A route param that has not resolved yet is a reason to wait, not to fetch
163
+ // `undefined` and render a 404.
164
+ { enabled: enabled && Boolean(slugOrId), staleMs, suspense }
165
+ );
166
+ }
167
+ function useMynaSingleton(collection, options = {}) {
168
+ const client = useMynaClient();
169
+ const { enabled, staleMs, suspense, ...query } = options;
170
+ return useMynaQuery(
171
+ cacheKey("singleton", collection, query),
172
+ () => client.singleton(collection, query),
173
+ { enabled, staleMs, suspense }
174
+ );
175
+ }
176
+
177
+ // src/version.ts
178
+ var VERSION = true ? "0.3.0" : "0.0.0-dev";
179
+ export {
180
+ MynaProvider,
181
+ MynaStore,
182
+ VERSION,
183
+ defaultStore,
184
+ useMynaClient,
185
+ useMynaEntries,
186
+ useMynaEntry,
187
+ useMynaSingleton
188
+ };
189
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/provider.tsx","../src/context.ts","../src/hooks.ts","../src/store.ts","../src/version.ts"],"sourcesContent":["import { useMemo, type ReactNode } from \"react\";\nimport { createMyna, type CreateMynaOptions, type MynaClient } from \"@myna-sh/sdk\";\nimport { MynaContext } from \"./context.js\";\n\nexport interface MynaProviderProps {\n /** An existing client. Takes precedence over `options`. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n client?: MynaClient<any>;\n /** Client options, when you would rather this component construct one. */\n options?: CreateMynaOptions;\n children: ReactNode;\n}\n\n/**\n * Provide a Myna client to the hooks below.\n *\n * Constructing from `options` is memoized on the values that matter, so a\n * parent re-render does not silently create a new client — which would discard\n * the in-flight request coalescing that makes concurrent reads cheap.\n */\nexport function MynaProvider({ client, options, children }: MynaProviderProps): ReactNode {\n const resolved = useMemo(() => {\n if (client) return client;\n if (!options) {\n throw new Error(\"MynaProvider needs either a `client` or `options`.\");\n }\n return createMyna(options);\n }, [\n client,\n options?.project,\n options?.apiUrl,\n options?.apiKey,\n options?.previewToken,\n options?.previewMode,\n ]);\n\n return <MynaContext.Provider value={resolved}>{children}</MynaContext.Provider>;\n}\n","import { createContext, useContext } from \"react\";\nimport type { CollectionShape, MynaClient } from \"@myna-sh/sdk\";\n\n/**\n * The client every hook reads from.\n *\n * Preview propagation is the reason this is context rather than an argument:\n * a preview token has to reach every read on the page, and threading it through\n * each component by hand is how a page ends up half-previewed — the list from\n * the change set, a nested card from published content.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const MynaContext = createContext<MynaClient<any> | null>(null);\n\nexport function useMynaClient<\n T extends Record<keyof T, CollectionShape> = Record<string, CollectionShape>,\n>(): MynaClient<T> {\n const client = useContext(MynaContext);\n if (!client) {\n throw new Error(\n \"No Myna client in context. Wrap your app in <MynaProvider client={createMyna({ project })}>.\",\n );\n }\n return client as MynaClient<T>;\n}\n","import { useCallback, useEffect, useRef, useSyncExternalStore } from \"react\";\nimport type {\n CollectionShape,\n EntryList,\n GetEntryOptions,\n ListEntriesOptions,\n PublishedEntry,\n} from \"@myna-sh/sdk\";\nimport { useMynaClient } from \"./context.js\";\nimport { defaultStore, type EntryState } from \"./store.js\";\n\n/**\n * Reading content from React, without the adapter every site was writing.\n *\n * Each hook gives loading and error state, shares one request between\n * components asking for the same thing, keeps the last good value while\n * revalidating, and inherits the provider's preview token — so a previewed page\n * needs no separate code path.\n */\n\nexport interface UseMynaOptions {\n /** Skip the read entirely (a dependent query whose input is not ready yet). */\n enabled?: boolean;\n /** How long a cached value stays fresh before a background refetch. Default 30s. */\n staleMs?: number;\n /** Throw the in-flight promise so a `<Suspense>` boundary handles loading. */\n suspense?: boolean;\n}\n\nexport interface UseMynaResult<T> {\n data: T | undefined;\n error: Error | undefined;\n isLoading: boolean;\n /** True while revalidating with a value already on screen. */\n isValidating: boolean;\n refetch: () => void;\n}\n\n/** Stable cache key. Options are serialized so two different queries never collide. */\nfunction cacheKey(kind: string, collection: string, rest: unknown): string {\n return `${kind}:${collection}:${stable(rest)}`;\n}\n\nfunction stable(value: unknown): string {\n if (value === null || typeof value !== \"object\") return JSON.stringify(value) ?? \"null\";\n if (Array.isArray(value)) return `[${value.map(stable).join(\",\")}]`;\n const entries = Object.entries(value as Record<string, unknown>)\n .filter(([, v]) => v !== undefined && typeof v !== \"function\" && !(v instanceof AbortSignal))\n .sort(([a], [b]) => a.localeCompare(b));\n return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(\",\")}}`;\n}\n\nfunction useMynaQuery<T>(\n key: string,\n fetcher: () => Promise<T>,\n options: UseMynaOptions,\n): UseMynaResult<T> {\n const { enabled = true, staleMs, suspense = false } = options;\n\n const subscribe = useCallback((listener: () => void) => defaultStore.subscribe(key, listener), [key]);\n const snapshot = useCallback(() => defaultStore.getState<T>(key), [key]);\n // The server snapshot is the same store read; on the server nothing has been\n // loaded, so this yields the pending state rather than throwing.\n const state: EntryState<T> = useSyncExternalStore(subscribe, snapshot, snapshot);\n\n /**\n * The fetcher closes over fresh props and is a new function every render, so\n * it cannot be an effect dependency without refetching on every render. The\n * cache key already identifies the query; the ref keeps the *call* current.\n */\n const fetcherRef = useRef(fetcher);\n fetcherRef.current = fetcher;\n const run = useCallback(() => fetcherRef.current(), []);\n\n useEffect(() => {\n if (!enabled) return;\n void defaultStore.load(key, run, staleMs);\n }, [key, enabled, staleMs, run]);\n\n if (suspense && enabled && state.status === \"pending\") {\n throw defaultStore.load(key, run, staleMs);\n }\n\n const refetch = useCallback(() => {\n defaultStore.invalidate(key);\n void defaultStore.load(key, run, 0);\n }, [key, run]);\n\n return {\n data: state.data,\n error: state.error,\n isLoading: enabled && state.status === \"pending\",\n isValidating: enabled && state.status !== \"pending\" && state.data !== undefined,\n refetch,\n };\n}\n\n/** Read a collection. Returns the entry list, typed by the generated registry. */\nexport function useMynaEntries<\n T extends Record<keyof T, CollectionShape> = Record<string, CollectionShape>,\n K extends keyof T & string = keyof T & string,\n>(\n collection: K,\n options: ListEntriesOptions<T[K]> & UseMynaOptions = {} as ListEntriesOptions<T[K]> & UseMynaOptions,\n): UseMynaResult<EntryList<T[K][\"fields\"]>> {\n const client = useMynaClient<T>();\n const { enabled, staleMs, suspense, ...query } = options;\n return useMynaQuery(\n cacheKey(\"entries\", collection, query),\n () => client.entries.list(collection, query as ListEntriesOptions<T[K]>),\n { enabled, staleMs, suspense },\n );\n}\n\n/** Read one entry by slug or id. */\nexport function useMynaEntry<\n T extends Record<keyof T, CollectionShape> = Record<string, CollectionShape>,\n K extends keyof T & string = keyof T & string,\n>(\n collection: K,\n slugOrId: string | undefined,\n options: GetEntryOptions<T[K]> & UseMynaOptions = {} as GetEntryOptions<T[K]> & UseMynaOptions,\n): UseMynaResult<PublishedEntry<T[K][\"fields\"]>> {\n const client = useMynaClient<T>();\n const { enabled = true, staleMs, suspense, ...query } = options;\n return useMynaQuery(\n cacheKey(\"entry\", `${collection}/${slugOrId ?? \"\"}`, query),\n () => client.entries.get(collection, slugOrId!, query as GetEntryOptions<T[K]>),\n // A route param that has not resolved yet is a reason to wait, not to fetch\n // `undefined` and render a 404.\n { enabled: enabled && Boolean(slugOrId), staleMs, suspense },\n );\n}\n\n/** Read a singleton collection's entry. */\nexport function useMynaSingleton<\n T extends Record<keyof T, CollectionShape> = Record<string, CollectionShape>,\n K extends keyof T & string = keyof T & string,\n>(\n collection: K,\n options: GetEntryOptions<T[K]> & UseMynaOptions = {} as GetEntryOptions<T[K]> & UseMynaOptions,\n): UseMynaResult<PublishedEntry<T[K][\"fields\"]>> {\n const client = useMynaClient<T>();\n const { enabled, staleMs, suspense, ...query } = options;\n return useMynaQuery(\n cacheKey(\"singleton\", collection, query),\n () => client.singleton(collection, query as GetEntryOptions<T[K]>),\n { enabled, staleMs, suspense },\n );\n}\n","/**\n * A minimal cache with stale-while-revalidate semantics.\n *\n * Deliberately not a dependency on a data library: this package's job is to\n * remove the adapter every Myna site was writing by hand, and adding a required\n * peer of TanStack Query or SWR would just move that decision rather than\n * remove it. Anyone who already has one should call the SDK directly through\n * it — this exists for the apps that do not.\n *\n * Entries hold the last successful value, so a re-mount paints immediately and\n * refetches behind the paint instead of flashing a spinner at content the user\n * has already seen.\n */\n\nexport type EntryState<T> =\n | { status: \"pending\"; data: undefined; error: undefined }\n | { status: \"success\"; data: T; error: undefined }\n | { status: \"error\"; data: T | undefined; error: Error };\n\ninterface CacheEntry<T> {\n state: EntryState<T>;\n /** In-flight request, so concurrent subscribers share one fetch. */\n promise?: Promise<void>;\n /** When the value was last written, for staleness checks. */\n updatedAt: number;\n listeners: Set<() => void>;\n}\n\nconst DEFAULT_STALE_MS = 30_000;\n\nexport class MynaStore {\n private readonly entries = new Map<string, CacheEntry<unknown>>();\n\n private entry<T>(key: string): CacheEntry<T> {\n let found = this.entries.get(key) as CacheEntry<T> | undefined;\n if (!found) {\n found = {\n state: { status: \"pending\", data: undefined, error: undefined },\n updatedAt: 0,\n listeners: new Set(),\n };\n this.entries.set(key, found as CacheEntry<unknown>);\n }\n return found;\n }\n\n getState<T>(key: string): EntryState<T> {\n return this.entry<T>(key).state;\n }\n\n subscribe(key: string, listener: () => void): () => void {\n const entry = this.entry(key);\n entry.listeners.add(listener);\n return () => {\n entry.listeners.delete(listener);\n };\n }\n\n private publish<T>(key: string, state: EntryState<T>): void {\n const entry = this.entry<T>(key);\n entry.state = state;\n for (const listener of entry.listeners) listener();\n }\n\n /**\n * Ensure `key` holds a fresh-enough value, fetching if not.\n *\n * Returns the in-flight promise so a Suspense boundary can throw it. Several\n * components asking at once share one request — a second layer of coalescing\n * above the SDK's, because this one also covers sequential re-renders.\n */\n load<T>(key: string, fetcher: () => Promise<T>, staleMs = DEFAULT_STALE_MS): Promise<void> {\n const entry = this.entry<T>(key);\n if (entry.promise) return entry.promise;\n\n const fresh = entry.state.status === \"success\" && Date.now() - entry.updatedAt < staleMs;\n if (fresh) return Promise.resolve();\n\n const promise = fetcher()\n .then((data) => {\n entry.updatedAt = Date.now();\n this.publish<T>(key, { status: \"success\", data, error: undefined });\n })\n .catch((error: unknown) => {\n // Keep the last good value alongside the error: a failed refresh should\n // not blank a page that is already showing correct content.\n this.publish<T>(key, {\n status: \"error\",\n data: entry.state.data,\n error: error instanceof Error ? error : new Error(String(error)),\n });\n })\n .finally(() => {\n entry.promise = undefined;\n });\n\n entry.promise = promise;\n return promise;\n }\n\n /** Drop a cached value, forcing the next read to refetch. */\n invalidate(prefix?: string): void {\n for (const [key, entry] of this.entries) {\n if (prefix && !key.startsWith(prefix)) continue;\n entry.updatedAt = 0;\n for (const listener of entry.listeners) listener();\n }\n }\n}\n\n/** Module-level default store, shared by every hook in a page. */\nexport const defaultStore = new MynaStore();\n","/**\n * Build-time version stamp, matching every other released package: tsup\n * substitutes `__MYNA_VERSION__` from `package.json`, so the bundle can never\n * disagree about which release it came from.\n */\ndeclare const __MYNA_VERSION__: string | undefined;\n\nexport const VERSION: string =\n typeof __MYNA_VERSION__ === \"string\" ? __MYNA_VERSION__ : \"0.0.0-dev\";\n"],"mappings":";AAAA,SAAS,eAA+B;AACxC,SAAS,kBAA2D;;;ACDpE,SAAS,eAAe,kBAAkB;AAYnC,IAAM,cAAc,cAAsC,IAAI;AAE9D,SAAS,gBAEG;AACjB,QAAM,SAAS,WAAW,WAAW;AACrC,MAAI,CAAC,QAAQ;AACX,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;;;ADYS;AAhBF,SAAS,aAAa,EAAE,QAAQ,SAAS,SAAS,GAAiC;AACxF,QAAM,WAAW,QAAQ,MAAM;AAC7B,QAAI,OAAQ,QAAO;AACnB,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI,MAAM,oDAAoD;AAAA,IACtE;AACA,WAAO,WAAW,OAAO;AAAA,EAC3B,GAAG;AAAA,IACD;AAAA,IACA,SAAS;AAAA,IACT,SAAS;AAAA,IACT,SAAS;AAAA,IACT,SAAS;AAAA,IACT,SAAS;AAAA,EACX,CAAC;AAED,SAAO,oBAAC,YAAY,UAAZ,EAAqB,OAAO,UAAW,UAAS;AAC1D;;;AErCA,SAAS,aAAa,WAAW,QAAQ,4BAA4B;;;AC4BrE,IAAM,mBAAmB;AAElB,IAAM,YAAN,MAAgB;AAAA,EACJ,UAAU,oBAAI,IAAiC;AAAA,EAExD,MAAS,KAA4B;AAC3C,QAAI,QAAQ,KAAK,QAAQ,IAAI,GAAG;AAChC,QAAI,CAAC,OAAO;AACV,cAAQ;AAAA,QACN,OAAO,EAAE,QAAQ,WAAW,MAAM,QAAW,OAAO,OAAU;AAAA,QAC9D,WAAW;AAAA,QACX,WAAW,oBAAI,IAAI;AAAA,MACrB;AACA,WAAK,QAAQ,IAAI,KAAK,KAA4B;AAAA,IACpD;AACA,WAAO;AAAA,EACT;AAAA,EAEA,SAAY,KAA4B;AACtC,WAAO,KAAK,MAAS,GAAG,EAAE;AAAA,EAC5B;AAAA,EAEA,UAAU,KAAa,UAAkC;AACvD,UAAM,QAAQ,KAAK,MAAM,GAAG;AAC5B,UAAM,UAAU,IAAI,QAAQ;AAC5B,WAAO,MAAM;AACX,YAAM,UAAU,OAAO,QAAQ;AAAA,IACjC;AAAA,EACF;AAAA,EAEQ,QAAW,KAAa,OAA4B;AAC1D,UAAM,QAAQ,KAAK,MAAS,GAAG;AAC/B,UAAM,QAAQ;AACd,eAAW,YAAY,MAAM,UAAW,UAAS;AAAA,EACnD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,KAAQ,KAAa,SAA2B,UAAU,kBAAiC;AACzF,UAAM,QAAQ,KAAK,MAAS,GAAG;AAC/B,QAAI,MAAM,QAAS,QAAO,MAAM;AAEhC,UAAM,QAAQ,MAAM,MAAM,WAAW,aAAa,KAAK,IAAI,IAAI,MAAM,YAAY;AACjF,QAAI,MAAO,QAAO,QAAQ,QAAQ;AAElC,UAAM,UAAU,QAAQ,EACrB,KAAK,CAAC,SAAS;AACd,YAAM,YAAY,KAAK,IAAI;AAC3B,WAAK,QAAW,KAAK,EAAE,QAAQ,WAAW,MAAM,OAAO,OAAU,CAAC;AAAA,IACpE,CAAC,EACA,MAAM,CAAC,UAAmB;AAGzB,WAAK,QAAW,KAAK;AAAA,QACnB,QAAQ;AAAA,QACR,MAAM,MAAM,MAAM;AAAA,QAClB,OAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;AAAA,MACjE,CAAC;AAAA,IACH,CAAC,EACA,QAAQ,MAAM;AACb,YAAM,UAAU;AAAA,IAClB,CAAC;AAEH,UAAM,UAAU;AAChB,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,WAAW,QAAuB;AAChC,eAAW,CAAC,KAAK,KAAK,KAAK,KAAK,SAAS;AACvC,UAAI,UAAU,CAAC,IAAI,WAAW,MAAM,EAAG;AACvC,YAAM,YAAY;AAClB,iBAAW,YAAY,MAAM,UAAW,UAAS;AAAA,IACnD;AAAA,EACF;AACF;AAGO,IAAM,eAAe,IAAI,UAAU;;;ADxE1C,SAAS,SAAS,MAAc,YAAoB,MAAuB;AACzE,SAAO,GAAG,IAAI,IAAI,UAAU,IAAI,OAAO,IAAI,CAAC;AAC9C;AAEA,SAAS,OAAO,OAAwB;AACtC,MAAI,UAAU,QAAQ,OAAO,UAAU,SAAU,QAAO,KAAK,UAAU,KAAK,KAAK;AACjF,MAAI,MAAM,QAAQ,KAAK,EAAG,QAAO,IAAI,MAAM,IAAI,MAAM,EAAE,KAAK,GAAG,CAAC;AAChE,QAAM,UAAU,OAAO,QAAQ,KAAgC,EAC5D,OAAO,CAAC,CAAC,EAAE,CAAC,MAAM,MAAM,UAAa,OAAO,MAAM,cAAc,EAAE,aAAa,YAAY,EAC3F,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;AACxC,SAAO,IAAI,QAAQ,IAAI,CAAC,CAAC,GAAG,CAAC,MAAM,GAAG,KAAK,UAAU,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,EAAE,EAAE,KAAK,GAAG,CAAC;AACnF;AAEA,SAAS,aACP,KACA,SACA,SACkB;AAClB,QAAM,EAAE,UAAU,MAAM,SAAS,WAAW,MAAM,IAAI;AAEtD,QAAM,YAAY,YAAY,CAAC,aAAyB,aAAa,UAAU,KAAK,QAAQ,GAAG,CAAC,GAAG,CAAC;AACpG,QAAM,WAAW,YAAY,MAAM,aAAa,SAAY,GAAG,GAAG,CAAC,GAAG,CAAC;AAGvE,QAAM,QAAuB,qBAAqB,WAAW,UAAU,QAAQ;AAO/E,QAAM,aAAa,OAAO,OAAO;AACjC,aAAW,UAAU;AACrB,QAAM,MAAM,YAAY,MAAM,WAAW,QAAQ,GAAG,CAAC,CAAC;AAEtD,YAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,SAAK,aAAa,KAAK,KAAK,KAAK,OAAO;AAAA,EAC1C,GAAG,CAAC,KAAK,SAAS,SAAS,GAAG,CAAC;AAE/B,MAAI,YAAY,WAAW,MAAM,WAAW,WAAW;AACrD,UAAM,aAAa,KAAK,KAAK,KAAK,OAAO;AAAA,EAC3C;AAEA,QAAM,UAAU,YAAY,MAAM;AAChC,iBAAa,WAAW,GAAG;AAC3B,SAAK,aAAa,KAAK,KAAK,KAAK,CAAC;AAAA,EACpC,GAAG,CAAC,KAAK,GAAG,CAAC;AAEb,SAAO;AAAA,IACL,MAAM,MAAM;AAAA,IACZ,OAAO,MAAM;AAAA,IACb,WAAW,WAAW,MAAM,WAAW;AAAA,IACvC,cAAc,WAAW,MAAM,WAAW,aAAa,MAAM,SAAS;AAAA,IACtE;AAAA,EACF;AACF;AAGO,SAAS,eAId,YACA,UAAqD,CAAC,GACZ;AAC1C,QAAM,SAAS,cAAiB;AAChC,QAAM,EAAE,SAAS,SAAS,UAAU,GAAG,MAAM,IAAI;AACjD,SAAO;AAAA,IACL,SAAS,WAAW,YAAY,KAAK;AAAA,IACrC,MAAM,OAAO,QAAQ,KAAK,YAAY,KAAiC;AAAA,IACvE,EAAE,SAAS,SAAS,SAAS;AAAA,EAC/B;AACF;AAGO,SAAS,aAId,YACA,UACA,UAAkD,CAAC,GACJ;AAC/C,QAAM,SAAS,cAAiB;AAChC,QAAM,EAAE,UAAU,MAAM,SAAS,UAAU,GAAG,MAAM,IAAI;AACxD,SAAO;AAAA,IACL,SAAS,SAAS,GAAG,UAAU,IAAI,YAAY,EAAE,IAAI,KAAK;AAAA,IAC1D,MAAM,OAAO,QAAQ,IAAI,YAAY,UAAW,KAA8B;AAAA;AAAA;AAAA,IAG9E,EAAE,SAAS,WAAW,QAAQ,QAAQ,GAAG,SAAS,SAAS;AAAA,EAC7D;AACF;AAGO,SAAS,iBAId,YACA,UAAkD,CAAC,GACJ;AAC/C,QAAM,SAAS,cAAiB;AAChC,QAAM,EAAE,SAAS,SAAS,UAAU,GAAG,MAAM,IAAI;AACjD,SAAO;AAAA,IACL,SAAS,aAAa,YAAY,KAAK;AAAA,IACvC,MAAM,OAAO,UAAU,YAAY,KAA8B;AAAA,IACjE,EAAE,SAAS,SAAS,SAAS;AAAA,EAC/B;AACF;;;AE9IO,IAAM,UACX,OAAuC,UAAmB;","names":[]}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@myna-sh/react",
3
+ "version": "0.3.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "license": "MIT",
10
+ "homepage": "https://myna.sh",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "main": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "peerDependencies": {
25
+ "@myna-sh/sdk": "^0.3.0",
26
+ "react": ">=18"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "24.13.3",
30
+ "@types/react": "19.2.17",
31
+ "eslint": "9.39.5",
32
+ "react": "19.2.8",
33
+ "typescript": "5.9.3",
34
+ "@myna-sh/sdk": "0.3.0",
35
+ "@myna-sh/config": "0.0.0"
36
+ },
37
+ "scripts": {
38
+ "build": "tsup",
39
+ "typecheck": "tsc -p tsconfig.json --noEmit",
40
+ "lint": "eslint src",
41
+ "clean": "rm -rf dist"
42
+ }
43
+ }