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.
- package/dist/utils/Head.tsx +357 -0
- 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.
|
|
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/
|
|
93
|
+
"url": "git+https://github.com/dpostolachi/hadars.git"
|
|
94
94
|
},
|
|
95
95
|
"bugs": {
|
|
96
|
-
"url": "https://github.com/
|
|
96
|
+
"url": "https://github.com/dpostolachi/hadars/issues"
|
|
97
97
|
},
|
|
98
|
-
"homepage": "https://github.com/
|
|
98
|
+
"homepage": "https://github.com/dpostolachi/hadars#readme"
|
|
99
99
|
}
|