hadars 0.1.22 → 0.1.24

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.
@@ -30,6 +30,10 @@ import {
30
30
  popComponentScope,
31
31
  snapshotContext,
32
32
  restoreContext,
33
+ pushContextValue,
34
+ popContextValue,
35
+ getContextValue,
36
+ runWithContextStore,
33
37
  } from "./renderContext";
34
38
 
35
39
  // ---------------------------------------------------------------------------
@@ -566,7 +570,7 @@ function renderComponent(
566
570
  // React.Consumer (React 19) — call the children render prop with the current value
567
571
  if (typeOf === REACT_CONSUMER) {
568
572
  const ctx = (type as any)._context;
569
- const value = ctx?._currentValue;
573
+ const value = ctx ? getContextValue(ctx) : undefined;
570
574
  const result: SlimNode =
571
575
  typeof props.children === "function" ? props.children(value) : null;
572
576
  const savedScope = pushComponentScope();
@@ -594,8 +598,7 @@ function renderComponent(
594
598
  if (isProvider) {
595
599
  // Resolve the actual context object from any provider variant
596
600
  ctx = (type as any)._context ?? type;
597
- prevCtxValue = ctx._currentValue;
598
- ctx._currentValue = props.value;
601
+ prevCtxValue = pushContextValue(ctx, props.value);
599
602
  }
600
603
 
601
604
  // Each component gets a fresh local-ID counter (for multiple useId calls).
@@ -606,11 +609,11 @@ function renderComponent(
606
609
  if (isProvider && typeof type !== "function") {
607
610
  const finish = () => {
608
611
  popComponentScope(savedScope);
609
- ctx._currentValue = prevCtxValue;
612
+ popContextValue(ctx, prevCtxValue);
610
613
  };
611
614
  const r = renderChildren(props.children, writer, isSvg);
612
615
  if (r && typeof (r as any).then === "function") {
613
- return (r as Promise<void>).then(finish);
616
+ return (r as Promise<void>).then(finish, (e) => { finish(); throw e; });
614
617
  }
615
618
  finish();
616
619
  return;
@@ -631,13 +634,13 @@ function renderComponent(
631
634
  }
632
635
  } catch (e) {
633
636
  popComponentScope(savedScope);
634
- if (isProvider) ctx._currentValue = prevCtxValue;
637
+ if (isProvider) popContextValue(ctx, prevCtxValue);
635
638
  throw e;
636
639
  }
637
640
 
638
641
  const finish = () => {
639
642
  popComponentScope(savedScope);
640
- if (isProvider) ctx._currentValue = prevCtxValue;
643
+ if (isProvider) popContextValue(ctx, prevCtxValue);
641
644
  };
642
645
 
643
646
  // Async component
@@ -645,16 +648,16 @@ function renderComponent(
645
648
  return result.then((resolved) => {
646
649
  const r = renderNode(resolved, writer, isSvg);
647
650
  if (r && typeof (r as any).then === "function") {
648
- return (r as Promise<void>).then(finish);
651
+ return (r as Promise<void>).then(finish, (e) => { finish(); throw e; });
649
652
  }
650
653
  finish();
651
- });
654
+ }, (e) => { finish(); throw e; });
652
655
  }
653
656
 
654
657
  const r = renderNode(result, writer, isSvg);
655
658
 
656
659
  if (r && typeof (r as any).then === "function") {
657
- return (r as Promise<void>).then(finish);
660
+ return (r as Promise<void>).then(finish, (e) => { finish(); throw e; });
658
661
  }
659
662
  finish();
660
663
  }
@@ -803,7 +806,7 @@ async function renderSuspense(
803
806
  export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
804
807
  const encoder = new TextEncoder();
805
808
 
806
- return new ReadableStream({
809
+ return runWithContextStore(() => new ReadableStream({
807
810
  async start(controller) {
808
811
  resetRenderState();
809
812
 
@@ -827,7 +830,7 @@ export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
827
830
  controller.error(error);
828
831
  }
829
832
  },
830
- });
833
+ }));
831
834
  }
832
835
 
833
836
  /**
@@ -835,28 +838,30 @@ export function renderToStream(element: SlimNode): ReadableStream<Uint8Array> {
835
838
  * Retries the full tree when a component throws a Promise (Suspense protocol),
836
839
  * so useServerData and similar hooks work without requiring explicit <Suspense>.
837
840
  */
838
- export async function renderToString(element: SlimNode): Promise<string> {
839
- for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
840
- resetRenderState();
841
- const chunks: string[] = [];
842
- const writer: Writer = {
843
- lastWasText: false,
844
- write(c) { chunks.push(c); this.lastWasText = false; },
845
- text(s) { chunks.push(s); this.lastWasText = true; },
846
- };
847
- try {
848
- const r = renderNode(element, writer);
849
- if (r && typeof (r as any).then === "function") await r;
850
- return chunks.join("");
851
- } catch (error) {
852
- if (error && typeof (error as any).then === "function") {
853
- await (error as Promise<unknown>);
854
- continue;
841
+ export function renderToString(element: SlimNode): Promise<string> {
842
+ return runWithContextStore(async () => {
843
+ for (let attempt = 0; attempt < MAX_SUSPENSE_RETRIES; attempt++) {
844
+ resetRenderState();
845
+ const chunks: string[] = [];
846
+ const writer: Writer = {
847
+ lastWasText: false,
848
+ write(c) { chunks.push(c); this.lastWasText = false; },
849
+ text(s) { chunks.push(s); this.lastWasText = true; },
850
+ };
851
+ try {
852
+ const r = renderNode(element, writer);
853
+ if (r && typeof (r as any).then === "function") await r;
854
+ return chunks.join("");
855
+ } catch (error) {
856
+ if (error && typeof (error as any).then === "function") {
857
+ await (error as Promise<unknown>);
858
+ continue;
859
+ }
860
+ throw error;
855
861
  }
856
- throw error;
857
862
  }
858
- }
859
- throw new Error("[slim-react] renderToString exceeded maximum retries");
863
+ throw new Error("[slim-react] renderToString exceeded maximum retries");
864
+ });
860
865
  }
861
866
 
862
867
  /** Alias matching React 18+ server API naming. */
@@ -1,12 +1,71 @@
1
1
  /**
2
- * Render-time context for tree-position-based `useId`.
2
+ * Render-time context for tree-position-based `useId` and React Context values.
3
3
  *
4
4
  * State lives on `globalThis` rather than module-level variables so that
5
5
  * multiple slim-react instances (the render worker's direct import and the
6
- * SSR bundle's bundled copy) share the same context without any coordination.
7
- * Safe because each worker processes one render at a time; `resetRenderState`
8
- * is always called at the top of every `renderToString` / `renderToStream`.
6
+ * SSR bundle's bundled copy) share the same singletons without coordination.
7
+ *
8
+ * Context values are stored in an AsyncLocalStorage<Map> so each concurrent
9
+ * SSR request gets its own isolated scope that propagates through all awaits.
10
+ * Call `runWithContextStore` at the start of every render to establish the scope.
11
+ */
12
+
13
+ // Shared AsyncLocalStorage instance — kept on globalThis so both copies of
14
+ // slim-react (direct import + SSR bundle) use the same store.
15
+ // The import is done with require() inside a try/catch so that bundlers that
16
+ // cannot resolve node:async_hooks (e.g. rspack without target:node set) do
17
+ // not fail at build time — the SSR render process always runs in Node.js and
18
+ // will find the module at runtime regardless.
19
+ const CONTEXT_STORE_KEY = "__slimReactContextStore";
20
+ const _g = globalThis as any;
21
+ if (!_g[CONTEXT_STORE_KEY]) {
22
+ try {
23
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
24
+ const { AsyncLocalStorage } = require("node:async_hooks") as typeof import("node:async_hooks");
25
+ _g[CONTEXT_STORE_KEY] = new AsyncLocalStorage<Map<object, unknown>>();
26
+ } catch {
27
+ // Fallback: no-op store — context values fall back to _defaultValue.
28
+ // This should never happen in a real SSR environment.
29
+ _g[CONTEXT_STORE_KEY] = null;
30
+ }
31
+ }
32
+ const _contextStore: { run<T>(store: Map<object,unknown>, fn: () => T): T; getStore(): Map<object,unknown> | undefined } | null = _g[CONTEXT_STORE_KEY];
33
+
34
+ /** Wrap a render entry-point so it gets its own isolated context-value scope. */
35
+ export function runWithContextStore<T>(fn: () => T): T {
36
+ return _contextStore ? _contextStore.run(new Map(), fn) : fn();
37
+ }
38
+
39
+ /**
40
+ * Read the current value for a context within the active render.
41
+ * Falls back to `_defaultValue` (or `_currentValue` for external contexts).
42
+ */
43
+ export function getContextValue<T>(context: object): T {
44
+ const store = _contextStore?.getStore();
45
+ if (store && store.has(context)) return store.get(context) as T;
46
+ const c = context as any;
47
+ return ("_defaultValue" in c ? c._defaultValue : c._currentValue) as T;
48
+ }
49
+
50
+ /**
51
+ * Push a new value for a context Provider onto the per-request store.
52
+ * Returns the previous value so the caller can restore it later.
9
53
  */
54
+ export function pushContextValue(context: object, value: unknown): unknown {
55
+ const store = _contextStore?.getStore();
56
+ const c = context as any;
57
+ const prev = store && store.has(context)
58
+ ? store.get(context)
59
+ : ("_defaultValue" in c ? c._defaultValue : c._currentValue);
60
+ if (store) store.set(context, value);
61
+ return prev;
62
+ }
63
+
64
+ /** Restore a previously saved context value (called by Provider on exit). */
65
+ export function popContextValue(context: object, prev: unknown): void {
66
+ const store = _contextStore?.getStore();
67
+ if (store) store.set(context, prev);
68
+ }
10
69
 
11
70
  export interface TreeContext {
12
71
  id: number;
@@ -18,7 +18,7 @@ const loaderPath = existsSync(pathMod.resolve(packageDir, 'loader.cjs'))
18
18
  ? pathMod.resolve(packageDir, 'loader.cjs')
19
19
  : pathMod.resolve(packageDir, 'loader.ts');
20
20
 
21
- const getConfigBase = (mode: "development" | "production"): Omit<Configuration, "entry" | "output" | "plugins"> => {
21
+ const getConfigBase = (mode: "development" | "production", isServerBuild = false): Omit<Configuration, "entry" | "output" | "plugins"> => {
22
22
  const isDev = mode === 'development';
23
23
  return {
24
24
  experiments: {
@@ -141,9 +141,12 @@ const buildCompilerConfig = (
141
141
  opts: EntryOptions,
142
142
  includeHotPlugin: boolean,
143
143
  ): Configuration => {
144
- const Config = getConfigBase(opts.mode);
145
144
  const { base } = opts;
146
145
  const isDev = opts.mode === 'development';
146
+ const isServerBuild = Boolean(
147
+ (opts.output && typeof opts.output === 'object' && (opts.output.library || String(opts.output.filename || '').includes('ssr')))
148
+ );
149
+ const Config = getConfigBase(opts.mode, isServerBuild);
147
150
 
148
151
  // shallow-clone base config to avoid mutating shared Config while preserving RegExp and plugin instances
149
152
  const localConfig: any = {
@@ -203,15 +206,6 @@ const buildCompilerConfig = (
203
206
  localConfig.module.rules.push(...opts.moduleRules);
204
207
  }
205
208
 
206
- // For server (SSR) builds we should avoid bundling react/react-dom so
207
- // the runtime uses the same React instance as the host. If the output
208
- // is a library/module (i.e. `opts.output.library` present or filename
209
- // contains "ssr"), treat it as a server build and mark react/react-dom
210
- // as externals and alias React imports to the project's node_modules.
211
- const isServerBuild = Boolean(
212
- (opts.output && typeof opts.output === 'object' && (opts.output.library || String(opts.output.filename || '').includes('ssr')))
213
- );
214
-
215
209
  // slim-react: the SSR-only React-compatible renderer bundled with hadars.
216
210
  // On server builds we replace the real React with slim-react so that hooks
217
211
  // get safe SSR stubs, context works, and renderToStream / Suspense are
@@ -232,6 +226,11 @@ const buildCompilerConfig = (
232
226
  } : undefined;
233
227
 
234
228
  const externals = isServerBuild ? [
229
+ // Node.js built-ins — must not be bundled; resolved by the runtime.
230
+ // Both the bare name and the node: prefix are listed because rspack
231
+ // may encounter either form depending on how the import is written.
232
+ 'node:async_hooks', 'async_hooks',
233
+ 'node:fs', 'node:path', 'node:os', 'node:stream', 'node:util',
235
234
  // react / react-dom are replaced by slim-react via alias above — not external.
236
235
  // emotion should be external on server builds to avoid client/browser code
237
236
  '@emotion/react',
@@ -291,7 +290,7 @@ const buildCompilerConfig = (
291
290
  externals,
292
291
  ...(optimization !== undefined ? { optimization } : {}),
293
292
  plugins: [
294
- new rspack.HtmlRspackPlugin({
293
+ !isServerBuild && new rspack.HtmlRspackPlugin({
295
294
  publicPath: base || '/',
296
295
  template: opts.htmlTemplate
297
296
  ? pathMod.resolve(process.cwd(), opts.htmlTemplate)
@@ -301,12 +300,7 @@ const buildCompilerConfig = (
301
300
  inject: 'head',
302
301
  minify: opts.mode === 'production',
303
302
  }),
304
- // Add `async` to the emitted module script so DOMContentLoaded fires
305
- // as soon as HTML is parsed — without waiting for the bundle to execute.
306
- // `<script type="module" async>` is valid: it downloads in parallel and
307
- // executes without blocking DOMContentLoaded, while retaining module
308
- // semantics (strict mode, ES imports, etc.).
309
- {
303
+ !isServerBuild && {
310
304
  apply(compiler: any) {
311
305
  compiler.hooks.emit.tapAsync('HadarsAsyncModuleScript', (compilation: any, cb: () => void) => {
312
306
  const asset = compilation.assets['out.html'];
@@ -327,7 +321,7 @@ const buildCompilerConfig = (
327
321
  },
328
322
  },
329
323
  isDev && !isServerBuild && new ReactRefreshPlugin(),
330
- includeHotPlugin && isDev && new rspack.HotModuleReplacementPlugin(),
324
+ includeHotPlugin && isDev && !isServerBuild && new rspack.HotModuleReplacementPlugin(),
331
325
  ...extraPlugins,
332
326
  ],
333
327
  ...localConfig,