hadars 0.1.1 → 0.1.3

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 (2) hide show
  1. package/dist/utils/Head.tsx +357 -0
  2. package/package.json +4 -4
@@ -0,0 +1,357 @@
1
+ import React from 'react';
2
+ import type { AppContext, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/ninety'
3
+
4
+ interface InnerContext {
5
+ setTitle: (title: string) => void;
6
+ addMeta: (id: string, props: MetaProps) => void;
7
+ addLink: (id: string, props: LinkProps) => void;
8
+ addStyle: (id: string, props: StyleProps) => void;
9
+ addScript: (id: string, props: ScriptProps) => void;
10
+ setStatus: (status: number) => void;
11
+ }
12
+
13
+ const AppContext = React.createContext<InnerContext>({
14
+ setTitle: () => {
15
+ console.warn('AppContext: setTitle called outside of provider');
16
+ },
17
+ addMeta: () => {
18
+ console.warn('AppContext: addMeta called outside of provider');
19
+ },
20
+ addLink: () => {
21
+ console.warn('AppContext: addLink called outside of provider');
22
+ },
23
+ addStyle: () => {
24
+ console.warn('AppContext: addStyle called outside of provider');
25
+ },
26
+ addScript: () => {
27
+ console.warn('AppContext: addScript called outside of provider');
28
+ },
29
+ setStatus: () => { },
30
+ });
31
+
32
+ export const AppProviderSSR: React.FC<{
33
+ children: React.ReactNode,
34
+ context: AppContext,
35
+ }> = React.memo( ({ children, context }) => {
36
+
37
+ const { head } = context;
38
+
39
+ // mutate seoData
40
+ const setTitle = React.useCallback((title: string) => {
41
+ head.title = title;
42
+ }, [head]);
43
+ const addMeta = React.useCallback((id: string, props: MetaProps) => {
44
+ head.meta[id] = props;
45
+ }, [head]);
46
+ const addLink = React.useCallback((id: string, props: LinkProps) => {
47
+ head.link[id] = props;
48
+ }, [head]);
49
+ const addStyle = React.useCallback((id: string, props: StyleProps) => {
50
+ head.style[id] = props;
51
+ }, [head]);
52
+ const addScript = React.useCallback((id: string, props: ScriptProps) => {
53
+ head.script[id] = props;
54
+ }, [head]);
55
+
56
+ const setStatus = React.useCallback((status: number) => {
57
+ head.status = status;
58
+ }, [head]);
59
+
60
+ const contextValue: InnerContext = React.useMemo(() => ({
61
+ setTitle,
62
+ addMeta,
63
+ addLink,
64
+ addStyle,
65
+ addScript,
66
+ setStatus,
67
+ }), [ setTitle, addMeta, addLink, addStyle, addScript, setStatus]);
68
+ return (
69
+ <AppContext.Provider value={contextValue}>
70
+ {children}
71
+ </AppContext.Provider>
72
+ );
73
+ } );
74
+
75
+ export const AppProviderCSR: React.FC<{
76
+ children: React.ReactNode
77
+ }> = React.memo( ({ children }) => {
78
+
79
+ const setTitle = React.useCallback((title: string) => {
80
+ document.title = title;
81
+ }, []);
82
+
83
+ const addMeta = React.useCallback((id: string, props: MetaProps) => {
84
+ let meta = document.querySelector(`#${id}`) as HTMLMetaElement | null;
85
+ if (!meta) {
86
+ meta = document.createElement('meta');
87
+ meta.setAttribute('id', id);
88
+ document.head.appendChild(meta);
89
+ }
90
+ Object.keys(props).forEach(key => {
91
+ const value = (props)[key as keyof MetaProps];
92
+ if (value) {
93
+ meta!.setAttribute(key, value);
94
+ }
95
+ });
96
+ }, []);
97
+
98
+ const addLink = React.useCallback((id: string, props: LinkProps) => {
99
+ let link = document.querySelector(`#${id}`) as HTMLLinkElement | null;
100
+ if (!link) {
101
+ link = document.createElement('link');
102
+ link.setAttribute('id', id);
103
+ document.head.appendChild(link);
104
+ }
105
+ Object.keys(props).forEach(key => {
106
+ const value = (props)[key as keyof LinkProps];
107
+ if (value) {
108
+ link!.setAttribute(key, value);
109
+ }
110
+ });
111
+ }, []);
112
+
113
+ const addStyle = React.useCallback((id: string, props: StyleProps) => {
114
+ let style = document.getElementById(id) as HTMLStyleElement | null;
115
+ if (!style) {
116
+ style = document.createElement('style');
117
+ style.setAttribute('id', id);
118
+ document.head.appendChild(style);
119
+ }
120
+ Object.keys(props).forEach(key => {
121
+ // handle dangerouslySetInnerHTML
122
+ if (key === 'dangerouslySetInnerHTML' && (props as any)[key] && (props as any)[key].__html) {
123
+ style!.innerHTML = (props as any)[key].__html;
124
+ return;
125
+ }
126
+ const value = (props)[key as keyof StyleProps];
127
+ if (value) {
128
+ (style as any)[key] = value;
129
+ }
130
+ });
131
+ }, []);
132
+
133
+ const addScript = React.useCallback((id: string, props: ScriptProps) => {
134
+ let script = document.getElementById(id) as HTMLScriptElement | null;
135
+ if (!script) {
136
+ script = document.createElement('script');
137
+ script.setAttribute('id', id);
138
+ document.body.appendChild(script);
139
+ }
140
+ Object.keys(props).forEach(key => {
141
+ // handle dangerouslySetInnerHTML
142
+ if (key === 'dangerouslySetInnerHTML' && (props as any)[key] && (props as any)[key].__html) {
143
+ script!.innerHTML = (props as any)[key].__html;
144
+ return;
145
+ }
146
+ const value = (props)[key as keyof ScriptProps];
147
+ if (value) {
148
+ (script as any)[key] = value;
149
+ }
150
+ });
151
+ }, []);
152
+
153
+ const contextValue: InnerContext = React.useMemo(() => ({
154
+ setTitle,
155
+ addMeta,
156
+ addLink,
157
+ addStyle,
158
+ addScript,
159
+ setStatus: () => { },
160
+ }), [setTitle, addMeta, addLink, addStyle, addScript]);
161
+
162
+ return (
163
+ <AppContext.Provider value={contextValue}>
164
+ {children}
165
+ </AppContext.Provider>
166
+ );
167
+ } );
168
+
169
+ export const useApp = () => React.useContext(AppContext);
170
+
171
+ // ── useServerData ─────────────────────────────────────────────────────────────
172
+ //
173
+ // Client-side cache pre-populated from the server's resolved data before
174
+ // hydration. Keyed by the same React useId() values that the server used.
175
+ const clientServerDataCache = new Map<string, unknown>();
176
+
177
+ /** Call this before hydrating to seed the client cache from the server's data.
178
+ * Invoked automatically by the hadars client bootstrap. */
179
+ export function initServerDataCache(data: Record<string, unknown>) {
180
+ for (const [k, v] of Object.entries(data)) {
181
+ clientServerDataCache.set(k, v);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Fetch async data on the server during SSR. Returns `undefined` on the first
187
+ * render pass(es) while the promise is in flight; returns the resolved value
188
+ * once the framework's render loop has awaited it.
189
+ *
190
+ * On the client the pre-resolved value is read from the hydration cache
191
+ * serialised into the page by the server, so no fetch is issued in the browser.
192
+ *
193
+ * The `key` (string or array of strings) uniquely identifies the cached value
194
+ * across all SSR render passes and client hydration — it must be stable and
195
+ * unique within the page.
196
+ *
197
+ * `fn` may return a `Promise<T>` (normal async usage), return `T` synchronously,
198
+ * or throw a thenable (React Suspense protocol). All three cases are handled:
199
+ * - Async `Promise<T>`: awaited across render iterations, result cached.
200
+ * - Synchronous return: value stored immediately, returned on the same pass.
201
+ * - Thrown thenable (e.g. `useQuery({ suspense: true })`): the thrown promise is
202
+ * awaited, the cache entry is then cleared so that the next render re-calls
203
+ * `fn()` — at that point the Suspense hook returns synchronously.
204
+ *
205
+ * @example
206
+ * const user = useServerData('current_user', () => db.getUser(id));
207
+ * const post = useServerData(['post', postId], () => db.getPost(postId));
208
+ * if (!user) return null; // undefined while pending on the first SSR pass
209
+ */
210
+ export function useServerData<T>(key: string | string[], fn: () => Promise<T> | T): T | undefined {
211
+ const cacheKey = Array.isArray(key) ? key.join('\x00') : key;
212
+
213
+ if (typeof window !== 'undefined') {
214
+ // Client: if the server serialised a value for this key, return it directly
215
+ // (normal async case — no re-fetch in the browser).
216
+ if (clientServerDataCache.has(cacheKey)) {
217
+ return clientServerDataCache.get(cacheKey) as T | undefined;
218
+ }
219
+ // Key not serialised → Suspense hook case (e.g. useSuspenseQuery).
220
+ // Call fn() directly so the hook runs with its own hydrated cache.
221
+ return fn() as T | undefined;
222
+ }
223
+
224
+ // Server: communicate via globalThis.__hadarsUnsuspend which is set by the
225
+ // framework around every renderToStaticMarkup / renderToString call. Using
226
+ // globalThis bypasses React context identity issues that arise when the user's
227
+ // SSR bundle and the hadars source each have their own React context instance.
228
+ const unsuspend: AppUnsuspend | undefined = (globalThis as any).__hadarsUnsuspend;
229
+ if (!unsuspend) return undefined;
230
+
231
+ const existing = unsuspend.cache.get(cacheKey);
232
+
233
+ // Suspense promise has resolved — re-call fn() so the hook returns its value
234
+ // synchronously from its own internal cache. The result is returned directly
235
+ // without being stored as 'fulfilled', so it is never included in the
236
+ // serialised serverData sent to the client (the library owns its own hydration).
237
+ if (existing?.status === 'suspense-resolved') {
238
+ try {
239
+ return fn() as T | undefined;
240
+ } catch {
241
+ return undefined;
242
+ }
243
+ }
244
+
245
+ if (!existing) {
246
+ // First encounter — call fn(), which may:
247
+ // (a) return a Promise<T> — normal async usage (serialised for the client)
248
+ // (b) return T synchronously — e.g. a sync data source
249
+ // (c) throw a thenable — Suspense protocol (e.g. useSuspenseQuery)
250
+ let result: Promise<T> | T;
251
+ try {
252
+ result = fn();
253
+ } catch (thrown) {
254
+ // (c) Suspense protocol: fn() threw a thenable. Await it, then mark the
255
+ // entry as 'suspense-resolved' so the next render re-calls fn() to get
256
+ // the synchronously available value. Not stored as 'fulfilled' → not
257
+ // serialised to the client (the Suspense library handles its own hydration).
258
+ if (thrown !== null && typeof thrown === 'object' && typeof (thrown as any).then === 'function') {
259
+ const suspensePromise = Promise.resolve(thrown as Promise<unknown>).then(
260
+ () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
261
+ () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
262
+ );
263
+ unsuspend.cache.set(cacheKey, { status: 'pending', promise: suspensePromise });
264
+ unsuspend.hasPending = true;
265
+ return undefined;
266
+ }
267
+ throw thrown;
268
+ }
269
+
270
+ const isThenable = result !== null && typeof result === 'object' && typeof (result as any).then === 'function';
271
+
272
+ // (b) Synchronous return — store as fulfilled and return immediately.
273
+ if (!isThenable) {
274
+ const value = result as T;
275
+ unsuspend.cache.set(cacheKey, { status: 'fulfilled', value });
276
+ return value;
277
+ }
278
+
279
+ // (a) Async Promise — standard useServerData usage.
280
+ const promise = (result as Promise<T>).then(
281
+ value => { unsuspend.cache.set(cacheKey, { status: 'fulfilled', value }); },
282
+ reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
283
+ );
284
+ unsuspend.cache.set(cacheKey, { status: 'pending', promise });
285
+ unsuspend.hasPending = true;
286
+ return undefined;
287
+ }
288
+ if (existing.status === 'pending') {
289
+ unsuspend.hasPending = true;
290
+ return undefined;
291
+ }
292
+ if (existing.status === 'rejected') throw existing.reason;
293
+ return existing.value as T;
294
+ }
295
+
296
+
297
+ const genRandomId = () => {
298
+ return 'head-' + Math.random().toString(36).substr(2, 9);
299
+ }
300
+
301
+ export const Head: React.FC<{
302
+ children?: React.ReactNode;
303
+ status?: number;
304
+ }> = React.memo( ({ children, status }) => {
305
+
306
+ const {
307
+ setStatus,
308
+ setTitle,
309
+ addMeta,
310
+ addLink,
311
+ addStyle,
312
+ addScript,
313
+ } = useApp();
314
+
315
+ if ( status ) {
316
+ setStatus(status);
317
+ }
318
+
319
+ React.Children.forEach(children, ( child ) => {
320
+ if ( !React.isValidElement(child) ) return;
321
+
322
+ const childType = child.type;
323
+ // React 19 types element.props as unknown; cast here since we
324
+ // inspect props dynamically based on the element type below.
325
+ const childProps = child.props as Record<string, any>;
326
+ const id = childProps['id'] || genRandomId();
327
+
328
+ switch ( childType ) {
329
+ case 'title': {
330
+ setTitle(childProps['children'] as string);
331
+ return;
332
+ }
333
+ case 'meta': {
334
+ addMeta(id.toString(), childProps as MetaProps);
335
+ return;
336
+ }
337
+ case 'link': {
338
+ addLink(id.toString(), childProps as LinkProps);
339
+ return;
340
+ }
341
+ case 'script': {
342
+ addScript(id.toString(), childProps as ScriptProps);
343
+ return;
344
+ }
345
+ case 'style': {
346
+ addStyle(id.toString(), childProps as StyleProps);
347
+ return;
348
+ }
349
+ default: {
350
+ console.warn(`HadarsHead: Unsupported child type: ${childType}`);
351
+ return;
352
+ }
353
+ }
354
+ });
355
+
356
+ return null;
357
+ } );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -90,10 +90,10 @@
90
90
  "license": "MIT",
91
91
  "repository": {
92
92
  "type": "git",
93
- "url": "git+https://github.com/dumistoklus/ninety.git"
93
+ "url": "git+https://github.com/dpostolachi/hadars.git"
94
94
  },
95
95
  "bugs": {
96
- "url": "https://github.com/dumistoklus/ninety/issues"
96
+ "url": "https://github.com/dpostolachi/hadars/issues"
97
97
  },
98
- "homepage": "https://github.com/dumistoklus/ninety#readme"
98
+ "homepage": "https://github.com/dpostolachi/hadars#readme"
99
99
  }