hadars 0.1.28 → 0.1.30
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/cli.js +162 -37
- package/dist/index.cjs +1 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -4
- package/dist/slim-react/index.cjs +87 -10
- package/dist/slim-react/index.d.ts +11 -3
- package/dist/slim-react/index.js +77 -10
- package/dist/ssr-render-worker.js +152 -23
- package/dist/utils/Head.tsx +6 -8
- package/package.json +5 -3
- package/src/build.ts +13 -8
- package/src/slim-react/dispatcher.ts +84 -0
- package/src/slim-react/index.ts +1 -1
- package/src/slim-react/render.ts +52 -8
- package/src/slim-react/renderContext.ts +14 -8
- package/src/ssr-render-worker.ts +11 -16
- package/src/utils/Head.tsx +6 -8
- package/src/utils/response.tsx +8 -23
package/src/slim-react/render.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
popTreeContext,
|
|
29
29
|
pushComponentScope,
|
|
30
30
|
popComponentScope,
|
|
31
|
+
componentCalledUseId,
|
|
31
32
|
snapshotContext,
|
|
32
33
|
restoreContext,
|
|
33
34
|
pushContextValue,
|
|
@@ -35,7 +36,9 @@ import {
|
|
|
35
36
|
getContextValue,
|
|
36
37
|
swapContextMap,
|
|
37
38
|
captureMap,
|
|
39
|
+
type TreeContext,
|
|
38
40
|
} from "./renderContext";
|
|
41
|
+
import { installDispatcher, restoreDispatcher } from "./dispatcher";
|
|
39
42
|
|
|
40
43
|
// ---------------------------------------------------------------------------
|
|
41
44
|
// HTML helpers
|
|
@@ -630,6 +633,7 @@ function renderComponent(
|
|
|
630
633
|
}
|
|
631
634
|
|
|
632
635
|
let result: SlimNode;
|
|
636
|
+
const prevDispatcher = installDispatcher();
|
|
633
637
|
try {
|
|
634
638
|
if (type.prototype && typeof type.prototype.render === "function") {
|
|
635
639
|
const instance = new (type as any)(props);
|
|
@@ -643,12 +647,25 @@ function renderComponent(
|
|
|
643
647
|
result = type(props);
|
|
644
648
|
}
|
|
645
649
|
} catch (e) {
|
|
650
|
+
restoreDispatcher(prevDispatcher);
|
|
646
651
|
popComponentScope(savedScope);
|
|
647
652
|
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
648
653
|
throw e;
|
|
649
654
|
}
|
|
655
|
+
restoreDispatcher(prevDispatcher);
|
|
656
|
+
|
|
657
|
+
// React 19 finishFunctionComponent: if the component called useId, push a
|
|
658
|
+
// tree-context slot for the component's OUTPUT children — matching React 19's
|
|
659
|
+
// `pushTreeContext(keyPath, 1, 0)` call inside finishFunctionComponent.
|
|
660
|
+
// This ensures that useId IDs produced by child components of a useId-calling
|
|
661
|
+
// component are tree-positioned identically to React's own renderer.
|
|
662
|
+
let savedIdTree: TreeContext | undefined;
|
|
663
|
+
if (!(result instanceof Promise) && componentCalledUseId()) {
|
|
664
|
+
savedIdTree = pushTreeContext(1, 0);
|
|
665
|
+
}
|
|
650
666
|
|
|
651
667
|
const finish = () => {
|
|
668
|
+
if (savedIdTree !== undefined) popTreeContext(savedIdTree);
|
|
652
669
|
popComponentScope(savedScope);
|
|
653
670
|
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
654
671
|
};
|
|
@@ -658,15 +675,25 @@ function renderComponent(
|
|
|
658
675
|
const m = captureMap();
|
|
659
676
|
return result.then((resolved) => {
|
|
660
677
|
swapContextMap(m);
|
|
678
|
+
// Check useId after the async body has finished executing.
|
|
679
|
+
let asyncSavedIdTree: TreeContext | undefined;
|
|
680
|
+
if (componentCalledUseId()) {
|
|
681
|
+
asyncSavedIdTree = pushTreeContext(1, 0);
|
|
682
|
+
}
|
|
683
|
+
const asyncFinish = () => {
|
|
684
|
+
if (asyncSavedIdTree !== undefined) popTreeContext(asyncSavedIdTree);
|
|
685
|
+
popComponentScope(savedScope);
|
|
686
|
+
if (isProvider) popContextValue(ctx, prevCtxValue);
|
|
687
|
+
};
|
|
661
688
|
const r = renderNode(resolved, writer, isSvg);
|
|
662
689
|
if (r && typeof (r as any).then === "function") {
|
|
663
690
|
const m2 = captureMap();
|
|
664
691
|
return (r as Promise<void>).then(
|
|
665
|
-
() => { swapContextMap(m2);
|
|
666
|
-
(e) => { swapContextMap(m2);
|
|
692
|
+
() => { swapContextMap(m2); asyncFinish(); },
|
|
693
|
+
(e) => { swapContextMap(m2); asyncFinish(); throw e; },
|
|
667
694
|
);
|
|
668
695
|
}
|
|
669
|
-
|
|
696
|
+
asyncFinish();
|
|
670
697
|
}, (e) => { swapContextMap(m); finish(); throw e; });
|
|
671
698
|
}
|
|
672
699
|
|
|
@@ -785,8 +812,8 @@ async function renderSuspense(
|
|
|
785
812
|
while (attempts < MAX_SUSPENSE_RETRIES) {
|
|
786
813
|
// Restore context to the state it was in when we entered <Suspense>.
|
|
787
814
|
restoreContext(snap);
|
|
815
|
+
let buffer = new BufferWriter();
|
|
788
816
|
try {
|
|
789
|
-
const buffer = new BufferWriter();
|
|
790
817
|
const r = renderNode(children, buffer, isSvg);
|
|
791
818
|
if (r && typeof (r as any).then === "function") {
|
|
792
819
|
const m = captureMap(); await r; swapContextMap(m);
|
|
@@ -823,19 +850,32 @@ async function renderSuspense(
|
|
|
823
850
|
// Public API
|
|
824
851
|
// ---------------------------------------------------------------------------
|
|
825
852
|
|
|
853
|
+
export interface RenderOptions {
|
|
854
|
+
/**
|
|
855
|
+
* Must match the `identifierPrefix` option passed to `hydrateRoot` on the
|
|
856
|
+
* client so that `useId()` generates identical IDs on server and client.
|
|
857
|
+
* Defaults to `""` (React's default).
|
|
858
|
+
*/
|
|
859
|
+
identifierPrefix?: string;
|
|
860
|
+
}
|
|
861
|
+
|
|
826
862
|
/**
|
|
827
863
|
* Render a component tree to a `ReadableStream<Uint8Array>`.
|
|
828
864
|
*
|
|
829
865
|
* The stream pauses at `<Suspense>` boundaries until the suspended
|
|
830
866
|
* promise resolves, then continues writing HTML.
|
|
831
867
|
*/
|
|
832
|
-
export function renderToStream(
|
|
868
|
+
export function renderToStream(
|
|
869
|
+
element: SlimNode,
|
|
870
|
+
options?: RenderOptions,
|
|
871
|
+
): ReadableStream<Uint8Array> {
|
|
833
872
|
const encoder = new TextEncoder();
|
|
873
|
+
const idPrefix = options?.identifierPrefix ?? "";
|
|
834
874
|
|
|
835
875
|
const contextMap = new Map<object, unknown>();
|
|
836
876
|
return new ReadableStream({
|
|
837
877
|
async start(controller) {
|
|
838
|
-
resetRenderState();
|
|
878
|
+
resetRenderState(idPrefix);
|
|
839
879
|
const prev = swapContextMap(contextMap);
|
|
840
880
|
|
|
841
881
|
const writer: Writer = {
|
|
@@ -870,12 +910,16 @@ export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
|
|
|
870
910
|
* Retries the full tree when a component throws a Promise (Suspense protocol),
|
|
871
911
|
* so useServerData and similar hooks work without requiring explicit <Suspense>.
|
|
872
912
|
*/
|
|
873
|
-
export async function renderToString(
|
|
913
|
+
export async function renderToString(
|
|
914
|
+
element: SlimNode,
|
|
915
|
+
options?: RenderOptions,
|
|
916
|
+
): Promise<string> {
|
|
917
|
+
const idPrefix = options?.identifierPrefix ?? "";
|
|
874
918
|
const contextMap = new Map<object, unknown>();
|
|
875
919
|
const prev = swapContextMap(contextMap);
|
|
876
920
|
try {
|
|
877
921
|
for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
|
|
878
|
-
resetRenderState();
|
|
922
|
+
resetRenderState(idPrefix);
|
|
879
923
|
swapContextMap(contextMap); // re-activate our map on each retry
|
|
880
924
|
const chunks: string[] = [];
|
|
881
925
|
const writer: Writer = {
|
|
@@ -92,10 +92,11 @@ function s(): RenderState {
|
|
|
92
92
|
return g[GLOBAL_KEY] as RenderState;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
export function resetRenderState() {
|
|
95
|
+
export function resetRenderState(idPrefix = "") {
|
|
96
96
|
const st = s();
|
|
97
97
|
st.currentTreeContext = { ...EMPTY };
|
|
98
98
|
st.localIdCounter = 0;
|
|
99
|
+
st.idPrefix = idPrefix;
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
export function setIdPrefix(prefix: string) {
|
|
@@ -160,6 +161,11 @@ export function popComponentScope(saved: number) {
|
|
|
160
161
|
s().localIdCounter = saved;
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
/** True if the current component has called useId at least once. */
|
|
165
|
+
export function componentCalledUseId(): boolean {
|
|
166
|
+
return s().localIdCounter > 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
163
169
|
export function snapshotContext(): { tree: TreeContext; localId: number } {
|
|
164
170
|
const st = s();
|
|
165
171
|
return { tree: { ...st.currentTreeContext }, localId: st.localIdCounter };
|
|
@@ -187,19 +193,19 @@ function getTreeId(): string {
|
|
|
187
193
|
/**
|
|
188
194
|
* Generate a `useId`-compatible ID for the current call site.
|
|
189
195
|
*
|
|
190
|
-
* Format: `
|
|
191
|
-
* with an optional `H<n>` suffix
|
|
192
|
-
*
|
|
196
|
+
* Format: `_R_<idPrefix><treeId>_` (React 19.2+)
|
|
197
|
+
* with an optional `H<n>` suffix for the n-th useId call in the same
|
|
198
|
+
* component (matching React 19's `localIdCounter` behaviour).
|
|
193
199
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
200
|
+
* React 19.2 uses `_R_<id>_` (underscore-delimited).
|
|
201
|
+
* This matches React 19.2's output from both renderToString (Fizz) and
|
|
202
|
+
* hydrateRoot, so SSR-generated IDs agree with client React during hydration.
|
|
197
203
|
*/
|
|
198
204
|
export function makeId(): string {
|
|
199
205
|
const st = s();
|
|
200
206
|
const treeId = getTreeId();
|
|
201
207
|
const n = st.localIdCounter++;
|
|
202
|
-
let id = "
|
|
208
|
+
let id = "_R_" + st.idPrefix + treeId;
|
|
203
209
|
if (n > 0) id += "H" + n.toString(32);
|
|
204
210
|
return id + "_";
|
|
205
211
|
}
|
package/src/ssr-render-worker.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { workerData, parentPort } from 'node:worker_threads';
|
|
14
14
|
import { pathToFileURL } from 'node:url';
|
|
15
15
|
import { processSegmentCache } from './utils/segmentCache';
|
|
16
|
-
import { renderToString, createElement
|
|
16
|
+
import { renderToString, createElement } from './slim-react/index';
|
|
17
17
|
|
|
18
18
|
const { ssrBundlePath } = workerData as { ssrBundlePath: string };
|
|
19
19
|
|
|
@@ -145,26 +145,21 @@ parentPort!.on('message', async (msg: any) => {
|
|
|
145
145
|
|
|
146
146
|
const Component = _ssrMod.default;
|
|
147
147
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
type: 'application/json',
|
|
153
|
-
dangerouslySetInnerHTML: {
|
|
154
|
-
__html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c'),
|
|
155
|
-
},
|
|
156
|
-
}),
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
// Re-use the same cache so useServerData returns immediately (no re-fetch).
|
|
148
|
+
// Render the Component as the direct root — matching hydrateRoot(div#app, <Component>)
|
|
149
|
+
// on the client. Wrapping in Fragment(div#app(...), script) would add an extra
|
|
150
|
+
// pushTreeContext(2,0) from the Fragment's child array, shifting all tree-position
|
|
151
|
+
// useId values by 2 bits and causing a hydration mismatch.
|
|
160
152
|
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
161
|
-
let
|
|
153
|
+
let appHtml: string;
|
|
162
154
|
try {
|
|
163
|
-
|
|
155
|
+
appHtml = await renderToString(createElement(Component, finalAppProps));
|
|
164
156
|
} finally {
|
|
165
157
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
166
158
|
}
|
|
167
|
-
|
|
159
|
+
appHtml = processSegmentCache(appHtml);
|
|
160
|
+
|
|
161
|
+
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c');
|
|
162
|
+
const html = `<div id="app">${appHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>`;
|
|
168
163
|
|
|
169
164
|
parentPort!.postMessage({ id, html, headHtml, status });
|
|
170
165
|
|
package/src/utils/Head.tsx
CHANGED
|
@@ -204,6 +204,9 @@ export function initServerDataCache(data: Record<string, unknown>) {
|
|
|
204
204
|
* awaited, the cache entry is then cleared so that the next render re-calls
|
|
205
205
|
* `fn()` — at that point the Suspense hook returns synchronously.
|
|
206
206
|
*
|
|
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.
|
|
209
|
+
*
|
|
207
210
|
* @example
|
|
208
211
|
* const user = useServerData('current_user', () => db.getUser(id));
|
|
209
212
|
* const post = useServerData(['post', postId], () => db.getPost(postId));
|
|
@@ -213,14 +216,9 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
213
216
|
const cacheKey = Array.isArray(key) ? JSON.stringify(key) : key;
|
|
214
217
|
|
|
215
218
|
if (typeof window !== 'undefined') {
|
|
216
|
-
// Client: if the server serialised a value for this key, return it directly
|
|
217
|
-
// (
|
|
218
|
-
|
|
219
|
-
return clientServerDataCache.get(cacheKey) as T | undefined;
|
|
220
|
-
}
|
|
221
|
-
// Key not serialised → Suspense hook case (e.g. useSuspenseQuery).
|
|
222
|
-
// Call fn() directly so the hook runs with its own hydrated cache.
|
|
223
|
-
return fn() as T | undefined;
|
|
219
|
+
// Client: if the server serialised a value for this key, return it directly.
|
|
220
|
+
// fn() is a server-only operation and must never run in the browser.
|
|
221
|
+
return clientServerDataCache.get(cacheKey) as T | undefined;
|
|
224
222
|
}
|
|
225
223
|
|
|
226
224
|
// Server: communicate via globalThis.__hadarsUnsuspend which is set by the
|
package/src/utils/response.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
2
|
import type { AppHead, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext } from "../types/hadars";
|
|
3
|
-
import { renderToString, createElement
|
|
3
|
+
import { renderToString, createElement } from '../slim-react/index';
|
|
4
4
|
|
|
5
5
|
interface ReactResponseOptions {
|
|
6
6
|
document: {
|
|
@@ -55,14 +55,12 @@ export const getReactResponse = async (
|
|
|
55
55
|
req: HadarsRequest,
|
|
56
56
|
opts: ReactResponseOptions,
|
|
57
57
|
): Promise<{
|
|
58
|
-
|
|
58
|
+
App: React.FC<any>,
|
|
59
|
+
appProps: Record<string, unknown>,
|
|
60
|
+
clientProps: Record<string, unknown>,
|
|
59
61
|
unsuspend: { cache: Map<string, any> },
|
|
60
62
|
status: number,
|
|
61
63
|
headHtml: string,
|
|
62
|
-
renderPayload: {
|
|
63
|
-
appProps: Record<string, unknown>;
|
|
64
|
-
clientProps: Record<string, unknown>;
|
|
65
|
-
};
|
|
66
64
|
}> => {
|
|
67
65
|
const App = opts.document.body;
|
|
68
66
|
const { getInitProps, getAfterRenderProps, getFinalProps } = opts.document;
|
|
@@ -105,27 +103,14 @@ export const getReactResponse = async (
|
|
|
105
103
|
...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
|
|
106
104
|
};
|
|
107
105
|
|
|
108
|
-
const
|
|
109
|
-
createElement('div', { id: 'app' },
|
|
110
|
-
createElement(App as any, { ...props, location: req.location, context } as any),
|
|
111
|
-
),
|
|
112
|
-
createElement('script', {
|
|
113
|
-
id: 'hadars',
|
|
114
|
-
type: 'application/json',
|
|
115
|
-
dangerouslySetInnerHTML: {
|
|
116
|
-
__html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c'),
|
|
117
|
-
},
|
|
118
|
-
}),
|
|
119
|
-
);
|
|
106
|
+
const appProps = { ...props, location: req.location, context } as unknown as Record<string, unknown>;
|
|
120
107
|
|
|
121
108
|
return {
|
|
122
|
-
|
|
109
|
+
App: App as React.FC<any>,
|
|
110
|
+
appProps,
|
|
111
|
+
clientProps: clientProps as Record<string, unknown>,
|
|
123
112
|
unsuspend,
|
|
124
113
|
status: context.head.status,
|
|
125
114
|
headHtml: getHeadHtml(context.head),
|
|
126
|
-
renderPayload: {
|
|
127
|
-
appProps: { ...props, location: req.location, context } as Record<string, unknown>,
|
|
128
|
-
clientProps: clientProps as Record<string, unknown>,
|
|
129
|
-
},
|
|
130
115
|
};
|
|
131
116
|
};
|