hadars 0.1.31 → 0.1.32

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 CHANGED
@@ -16,6 +16,10 @@ hadars is an alternative to Next.js for apps that just need SSR.
16
16
 
17
17
  Bring your own router (or none), keep your components as plain React, and get SSR, HMR, and a production build from a single config file.
18
18
 
19
+ ## Benchmarks
20
+
21
+ Benchmarks against an equivalent Next.js app show significantly faster server throughput (requests/second) and meaningfully better page load metrics (TTFB, FCP, DOMContentLoaded). Build times are also much lower due to rspack.
22
+
19
23
  ## Quick start
20
24
 
21
25
  Scaffold a new project in seconds:
@@ -114,7 +118,6 @@ const UserCard = ({ userId }: { userId: string }) => {
114
118
  - **`key`** - string or string array; must be stable and unique within the page
115
119
  - **Server** - calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
116
120
  - **Client** - reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
117
- - **Suspense libraries** - also works when `fn()` throws a thenable (e.g. Relay `useLazyLoadQuery` with `suspense: true`); the thrown promise is awaited and the next render re-calls `fn()` synchronously
118
121
 
119
122
  ## Data lifecycle hooks
120
123
 
@@ -182,21 +185,14 @@ export default config;
182
185
 
183
186
  ## slim-react
184
187
 
185
- hadars ships its own lightweight React-compatible SSR renderer called **slim-react** (`src/slim-react/`). It replaces `react-dom/server` entirely on the server side - no `renderToStaticMarkup`, no `renderToPipeableStream`, no react-dom dependency at all.
186
-
187
- For server builds, rspack aliases `react` and `react/jsx-runtime` to slim-react, so your components and any libraries they import render through it automatically without any code changes.
188
-
189
- **What it does:**
190
-
191
- - Renders the full component tree to an HTML string with native `async/await` support - async components and hooks that return Promises are awaited directly without streaming workarounds
192
- - Implements the React Suspense protocol: when a component throws a Promise (e.g. from `useServerData` or a Suspense-enabled data library), slim-react awaits it and retries the tree automatically
193
- - Emits React-compatible hydration markers - `<!--$-->…<!--/$-->` for resolved Suspense boundaries, `<!-- -->` separators between adjacent text nodes - so `hydrateRoot` on the client works without mismatches
194
- - Supports `React.memo`, `React.forwardRef`, `React.lazy`, `Context.Provider`, `Context.Consumer`, and the React 18/19 element wire formats
195
- - Covers the full hook surface needed for SSR: `useState`, `useReducer`, `useContext`, `useRef`, `useMemo`, `useCallback`, `useId`, `useSyncExternalStore`, `use`, and more - all as lightweight SSR stubs
188
+ hadars ships its own lightweight React-compatible SSR renderer called **slim-react** (`src/slim-react/`). It replaces `react-dom/server` on the server side entirely.
196
189
 
197
- **Why not react-dom/server?**
190
+ For server builds, rspack aliases `react` and `react/jsx-runtime` to slim-react, so your components and any libraries they import render through it automatically without code changes.
198
191
 
199
- `react-dom/server` cannot `await` arbitrary Promises thrown during render - it only handles Suspense via streaming and requires components to use `React.lazy` or Relay-style Suspense resources. slim-react's retry loop makes `useServerData` (and any hook that throws a Promise) work without wrapping every async component in a `<Suspense>` boundary.
192
+ - Renders the full component tree to an HTML string with native `async/await` async components are awaited directly
193
+ - Implements the React Suspense protocol: thrown Promises (e.g. from `useSuspenseQuery`) are awaited and the component retried automatically
194
+ - Compatible with `hydrateRoot` — output matches what React expects on the client
195
+ - Supports `React.memo`, `React.forwardRef`, `React.lazy`, `Context.Provider`, `Context.Consumer`, and the React 19 element format
200
196
 
201
197
  ## License
202
198
 
package/dist/index.cjs CHANGED
@@ -189,37 +189,8 @@ function useServerData(key, fn) {
189
189
  if (!unsuspend)
190
190
  return void 0;
191
191
  const existing = unsuspend.cache.get(cacheKey);
192
- if (existing?.status === "suspense-resolved") {
193
- try {
194
- const value = fn();
195
- unsuspend.cache.set(cacheKey, { status: "suspense-cached", value });
196
- return value;
197
- } catch {
198
- return void 0;
199
- }
200
- }
201
- if (existing?.status === "suspense-cached") {
202
- return existing.value;
203
- }
204
192
  if (!existing) {
205
- let result;
206
- try {
207
- result = fn();
208
- } catch (thrown) {
209
- if (thrown !== null && typeof thrown === "object" && typeof thrown.then === "function") {
210
- const suspensePromise = Promise.resolve(thrown).then(
211
- () => {
212
- unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
213
- },
214
- () => {
215
- unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
216
- }
217
- );
218
- unsuspend.cache.set(cacheKey, { status: "pending", promise: suspensePromise });
219
- throw suspensePromise;
220
- }
221
- throw thrown;
222
- }
193
+ const result = fn();
223
194
  const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
224
195
  if (!isThenable) {
225
196
  const value = result;
package/dist/index.d.ts CHANGED
@@ -28,11 +28,6 @@ type UnsuspendEntry = {
28
28
  } | {
29
29
  status: 'fulfilled';
30
30
  value: unknown;
31
- } | {
32
- status: 'suspense-resolved';
33
- } | {
34
- status: 'suspense-cached';
35
- value: unknown;
36
31
  } | {
37
32
  status: 'rejected';
38
33
  reason: unknown;
@@ -40,7 +35,6 @@ type UnsuspendEntry = {
40
35
  /** @internal Populated by the framework's render loop — use useServerData() instead. */
41
36
  interface AppUnsuspend {
42
37
  cache: Map<string, UnsuspendEntry>;
43
- hasPending: boolean;
44
38
  }
45
39
  interface AppContext {
46
40
  path?: string;
@@ -197,16 +191,11 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
197
191
  * across all SSR render passes and client hydration — it must be stable and
198
192
  * unique within the page.
199
193
  *
200
- * `fn` may return a `Promise<T>` (normal async usage), return `T` synchronously,
201
- * or throw a thenable (React Suspense protocol). All three cases are handled:
202
- * - Async `Promise<T>`: awaited across render iterations, result cached.
203
- * - Synchronous return: value stored immediately, returned on the same pass.
204
- * - Thrown thenable (e.g. `useQuery({ suspense: true })`): the thrown promise is
205
- * awaited, the cache entry is then cleared so that the next render re-calls
206
- * `fn()` — at that point the Suspense hook returns synchronously.
194
+ * `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
195
+ * The resolved value is serialised into `__serverData` and returned from cache
196
+ * during hydration.
207
197
  *
208
- * `fn` is **server-only**: it is never called in the browser. The resolved value
209
- * is serialised into `__serverData` and returned from cache during hydration.
198
+ * `fn` is **server-only**: it is never called in the browser.
210
199
  *
211
200
  * @example
212
201
  * const user = useServerData('current_user', () => db.getUser(id));
package/dist/index.js CHANGED
@@ -146,37 +146,8 @@ function useServerData(key, fn) {
146
146
  if (!unsuspend)
147
147
  return void 0;
148
148
  const existing = unsuspend.cache.get(cacheKey);
149
- if (existing?.status === "suspense-resolved") {
150
- try {
151
- const value = fn();
152
- unsuspend.cache.set(cacheKey, { status: "suspense-cached", value });
153
- return value;
154
- } catch {
155
- return void 0;
156
- }
157
- }
158
- if (existing?.status === "suspense-cached") {
159
- return existing.value;
160
- }
161
149
  if (!existing) {
162
- let result;
163
- try {
164
- result = fn();
165
- } catch (thrown) {
166
- if (thrown !== null && typeof thrown === "object" && typeof thrown.then === "function") {
167
- const suspensePromise = Promise.resolve(thrown).then(
168
- () => {
169
- unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
170
- },
171
- () => {
172
- unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
173
- }
174
- );
175
- unsuspend.cache.set(cacheKey, { status: "pending", promise: suspensePromise });
176
- throw suspensePromise;
177
- }
178
- throw thrown;
179
- }
150
+ const result = fn();
180
151
  const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
181
152
  if (!isThenable) {
182
153
  const value = result;
@@ -196,16 +196,11 @@ export function initServerDataCache(data: Record<string, unknown>) {
196
196
  * across all SSR render passes and client hydration — it must be stable and
197
197
  * unique within the page.
198
198
  *
199
- * `fn` may return a `Promise<T>` (normal async usage), return `T` synchronously,
200
- * or throw a thenable (React Suspense protocol). All three cases are handled:
201
- * - Async `Promise<T>`: awaited across render iterations, result cached.
202
- * - Synchronous return: value stored immediately, returned on the same pass.
203
- * - Thrown thenable (e.g. `useQuery({ suspense: true })`): the thrown promise is
204
- * awaited, the cache entry is then cleared so that the next render re-calls
205
- * `fn()` — at that point the Suspense hook returns synchronously.
199
+ * `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
200
+ * The resolved value is serialised into `__serverData` and returned from cache
201
+ * during hydration.
206
202
  *
207
- * `fn` is **server-only**: it is never called in the browser. The resolved value
208
- * is serialised into `__serverData` and returned from cache during hydration.
203
+ * `fn` is **server-only**: it is never called in the browser.
209
204
  *
210
205
  * @example
211
206
  * const user = useServerData('current_user', () => db.getUser(id));
@@ -230,51 +225,11 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
230
225
 
231
226
  const existing = unsuspend.cache.get(cacheKey);
232
227
 
233
- // Suspense promise has resolved — re-call fn() so the hook returns its value
234
- // synchronously from its own internal cache. Cache the result as
235
- // 'suspense-cached' so later renders (e.g. the final renderToString in
236
- // buildSsrResponse, which runs after getFinalProps may have cleared the
237
- // user's QueryClient) can return the value without calling fn() again.
238
- // NOT stored as 'fulfilled' so it is never included in serverData sent to
239
- // the client — the Suspense library owns its own hydration.
240
- if (existing?.status === 'suspense-resolved') {
241
- try {
242
- const value = fn() as T;
243
- unsuspend.cache.set(cacheKey, { status: 'suspense-cached', value });
244
- return value;
245
- } catch {
246
- return undefined;
247
- }
248
- }
249
-
250
- // Return the cached Suspense value on all subsequent renders.
251
- if (existing?.status === 'suspense-cached') {
252
- return existing.value as T;
253
- }
254
-
255
228
  if (!existing) {
256
229
  // First encounter — call fn(), which may:
257
- // (a) return a Promise<T> — normal async usage (serialised for the client)
230
+ // (a) return a Promise<T> — async usage (serialised for the client)
258
231
  // (b) return T synchronously — e.g. a sync data source
259
- // (c) throw a thenable — Suspense protocol (e.g. useSuspenseQuery)
260
- let result: Promise<T> | T;
261
- try {
262
- result = fn();
263
- } catch (thrown) {
264
- // (c) Suspense protocol: fn() threw a thenable. Await it, then mark the
265
- // entry as 'suspense-resolved' so the next render re-calls fn() to get
266
- // the synchronously available value. Not stored as 'fulfilled' → not
267
- // serialised to the client (the Suspense library handles its own hydration).
268
- if (thrown !== null && typeof thrown === 'object' && typeof (thrown as any).then === 'function') {
269
- const suspensePromise = Promise.resolve(thrown as Promise<unknown>).then(
270
- () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
271
- () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
272
- );
273
- unsuspend.cache.set(cacheKey, { status: 'pending', promise: suspensePromise });
274
- throw suspensePromise; // slim-react will await and retry
275
- }
276
- throw thrown;
277
- }
232
+ const result = fn();
278
233
 
279
234
  const isThenable = result !== null && typeof result === 'object' && typeof (result as any).then === 'function';
280
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -26,14 +26,11 @@ export interface AppHead {
26
26
  export type UnsuspendEntry =
27
27
  | { status: 'pending'; promise: Promise<unknown> }
28
28
  | { status: 'fulfilled'; value: unknown }
29
- | { status: 'suspense-resolved' }
30
- | { status: 'suspense-cached'; value: unknown }
31
29
  | { status: 'rejected'; reason: unknown };
32
30
 
33
31
  /** @internal Populated by the framework's render loop — use useServerData() instead. */
34
32
  export interface AppUnsuspend {
35
33
  cache: Map<string, UnsuspendEntry>;
36
- hasPending: boolean;
37
34
  }
38
35
 
39
36
  export interface AppContext {
@@ -196,16 +196,11 @@ export function initServerDataCache(data: Record<string, unknown>) {
196
196
  * across all SSR render passes and client hydration — it must be stable and
197
197
  * unique within the page.
198
198
  *
199
- * `fn` may return a `Promise<T>` (normal async usage), return `T` synchronously,
200
- * or throw a thenable (React Suspense protocol). All three cases are handled:
201
- * - Async `Promise<T>`: awaited across render iterations, result cached.
202
- * - Synchronous return: value stored immediately, returned on the same pass.
203
- * - Thrown thenable (e.g. `useQuery({ suspense: true })`): the thrown promise is
204
- * awaited, the cache entry is then cleared so that the next render re-calls
205
- * `fn()` — at that point the Suspense hook returns synchronously.
199
+ * `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
200
+ * The resolved value is serialised into `__serverData` and returned from cache
201
+ * during hydration.
206
202
  *
207
- * `fn` is **server-only**: it is never called in the browser. The resolved value
208
- * is serialised into `__serverData` and returned from cache during hydration.
203
+ * `fn` is **server-only**: it is never called in the browser.
209
204
  *
210
205
  * @example
211
206
  * const user = useServerData('current_user', () => db.getUser(id));
@@ -230,51 +225,11 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
230
225
 
231
226
  const existing = unsuspend.cache.get(cacheKey);
232
227
 
233
- // Suspense promise has resolved — re-call fn() so the hook returns its value
234
- // synchronously from its own internal cache. Cache the result as
235
- // 'suspense-cached' so later renders (e.g. the final renderToString in
236
- // buildSsrResponse, which runs after getFinalProps may have cleared the
237
- // user's QueryClient) can return the value without calling fn() again.
238
- // NOT stored as 'fulfilled' so it is never included in serverData sent to
239
- // the client — the Suspense library owns its own hydration.
240
- if (existing?.status === 'suspense-resolved') {
241
- try {
242
- const value = fn() as T;
243
- unsuspend.cache.set(cacheKey, { status: 'suspense-cached', value });
244
- return value;
245
- } catch {
246
- return undefined;
247
- }
248
- }
249
-
250
- // Return the cached Suspense value on all subsequent renders.
251
- if (existing?.status === 'suspense-cached') {
252
- return existing.value as T;
253
- }
254
-
255
228
  if (!existing) {
256
229
  // First encounter — call fn(), which may:
257
- // (a) return a Promise<T> — normal async usage (serialised for the client)
230
+ // (a) return a Promise<T> — async usage (serialised for the client)
258
231
  // (b) return T synchronously — e.g. a sync data source
259
- // (c) throw a thenable — Suspense protocol (e.g. useSuspenseQuery)
260
- let result: Promise<T> | T;
261
- try {
262
- result = fn();
263
- } catch (thrown) {
264
- // (c) Suspense protocol: fn() threw a thenable. Await it, then mark the
265
- // entry as 'suspense-resolved' so the next render re-calls fn() to get
266
- // the synchronously available value. Not stored as 'fulfilled' → not
267
- // serialised to the client (the Suspense library handles its own hydration).
268
- if (thrown !== null && typeof thrown === 'object' && typeof (thrown as any).then === 'function') {
269
- const suspensePromise = Promise.resolve(thrown as Promise<unknown>).then(
270
- () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
271
- () => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
272
- );
273
- unsuspend.cache.set(cacheKey, { status: 'pending', promise: suspensePromise });
274
- throw suspensePromise; // slim-react will await and retry
275
- }
276
- throw thrown;
277
- }
232
+ const result = fn();
278
233
 
279
234
  const isThenable = result !== null && typeof result === 'object' && typeof (result as any).then === 'function';
280
235