@alepha/react 0.7.0 → 0.7.1

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 (35) hide show
  1. package/README.md +1 -1
  2. package/dist/index.browser.cjs +21 -21
  3. package/dist/index.browser.js +2 -3
  4. package/dist/index.cjs +151 -83
  5. package/dist/index.d.ts +360 -205
  6. package/dist/index.js +129 -62
  7. package/dist/{useActive-DjpZBEuB.cjs → useRouterState-AdK-XeM2.cjs} +270 -81
  8. package/dist/{useActive-BX41CqY8.js → useRouterState-qoMq7Y9J.js} +272 -84
  9. package/package.json +11 -10
  10. package/src/components/ClientOnly.tsx +35 -0
  11. package/src/components/ErrorBoundary.tsx +1 -1
  12. package/src/components/ErrorViewer.tsx +161 -0
  13. package/src/components/Link.tsx +9 -3
  14. package/src/components/NestedView.tsx +18 -3
  15. package/src/descriptors/$page.ts +139 -30
  16. package/src/errors/RedirectionError.ts +4 -1
  17. package/src/hooks/RouterHookApi.ts +42 -5
  18. package/src/hooks/useAlepha.ts +12 -0
  19. package/src/hooks/useClient.ts +8 -6
  20. package/src/hooks/useInject.ts +2 -2
  21. package/src/hooks/useQueryParams.ts +1 -1
  22. package/src/hooks/useRouter.ts +6 -0
  23. package/src/index.browser.ts +1 -1
  24. package/src/index.shared.ts +11 -5
  25. package/src/index.ts +3 -4
  26. package/src/providers/BrowserRouterProvider.ts +1 -1
  27. package/src/providers/PageDescriptorProvider.ts +72 -21
  28. package/src/providers/ReactBrowserProvider.ts +5 -8
  29. package/src/providers/ReactServerProvider.ts +197 -80
  30. package/dist/index.browser.cjs.map +0 -1
  31. package/dist/index.browser.js.map +0 -1
  32. package/dist/index.cjs.map +0 -1
  33. package/dist/index.js.map +0 -1
  34. package/dist/useActive-BX41CqY8.js.map +0 -1
  35. package/dist/useActive-DjpZBEuB.cjs.map +0 -1
@@ -1,6 +1,6 @@
1
- import { jsx } from 'react/jsx-runtime';
2
- import { __descriptor, OPTIONS, NotImplementedError, KIND, $logger, $inject, Alepha, $hook, t } from '@alepha/core';
3
- import React, { createContext, useContext, useEffect, useState, createElement, useMemo } from 'react';
1
+ import { jsx, jsxs } from 'react/jsx-runtime';
2
+ import { __descriptor, OPTIONS, NotImplementedError, KIND, t, $logger, $inject, Alepha, $hook } from '@alepha/core';
3
+ import React, { useState, useEffect, createContext, useContext, createElement, StrictMode, useMemo } from 'react';
4
4
  import { HttpClient } from '@alepha/server';
5
5
  import { hydrateRoot, createRoot } from 'react-dom/client';
6
6
  import { RouterProvider } from '@alepha/router';
@@ -26,27 +26,161 @@ const $page = (options) => {
26
26
  [OPTIONS]: options,
27
27
  render: () => {
28
28
  throw new NotImplementedError(KEY);
29
- },
30
- go: () => {
31
- throw new NotImplementedError(KEY);
32
- },
33
- createAnchorProps: () => {
34
- throw new NotImplementedError(KEY);
35
- },
36
- can: () => {
37
- if (options.can) {
38
- return options.can();
39
- }
40
- return true;
41
29
  }
42
30
  };
43
31
  };
44
32
  $page[KIND] = KEY;
45
33
 
34
+ const ClientOnly = (props) => {
35
+ const [mounted, setMounted] = useState(false);
36
+ useEffect(() => setMounted(true), []);
37
+ if (props.disabled) {
38
+ return props.children;
39
+ }
40
+ return mounted ? props.children : props.fallback;
41
+ };
42
+
46
43
  const RouterContext = createContext(
47
44
  void 0
48
45
  );
49
46
 
47
+ const useAlepha = () => {
48
+ const routerContext = useContext(RouterContext);
49
+ if (!routerContext) {
50
+ throw new Error("useAlepha must be used within a RouterProvider");
51
+ }
52
+ return routerContext.alepha;
53
+ };
54
+
55
+ const ErrorViewer = ({ error }) => {
56
+ const [expanded, setExpanded] = useState(false);
57
+ const isProduction = useAlepha().isProduction();
58
+ if (isProduction) {
59
+ return /* @__PURE__ */ jsx(ErrorViewerProduction, {});
60
+ }
61
+ const stackLines = error.stack?.split("\n") ?? [];
62
+ const previewLines = stackLines.slice(0, 5);
63
+ const hiddenLineCount = stackLines.length - previewLines.length;
64
+ const copyToClipboard = (text) => {
65
+ navigator.clipboard.writeText(text).catch((err) => {
66
+ console.error("Clipboard error:", err);
67
+ });
68
+ };
69
+ const styles = {
70
+ container: {
71
+ padding: "24px",
72
+ backgroundColor: "#FEF2F2",
73
+ color: "#7F1D1D",
74
+ border: "1px solid #FECACA",
75
+ borderRadius: "16px",
76
+ boxShadow: "0 8px 24px rgba(0,0,0,0.05)",
77
+ fontFamily: "monospace",
78
+ maxWidth: "768px",
79
+ margin: "40px auto"
80
+ },
81
+ heading: {
82
+ fontSize: "20px",
83
+ fontWeight: "bold",
84
+ marginBottom: "4px"
85
+ },
86
+ name: {
87
+ fontSize: "16px",
88
+ fontWeight: 600
89
+ },
90
+ message: {
91
+ fontSize: "14px",
92
+ marginBottom: "16px"
93
+ },
94
+ sectionHeader: {
95
+ display: "flex",
96
+ justifyContent: "space-between",
97
+ alignItems: "center",
98
+ fontSize: "12px",
99
+ marginBottom: "4px",
100
+ color: "#991B1B"
101
+ },
102
+ copyButton: {
103
+ fontSize: "12px",
104
+ color: "#DC2626",
105
+ background: "none",
106
+ border: "none",
107
+ cursor: "pointer",
108
+ textDecoration: "underline"
109
+ },
110
+ stackContainer: {
111
+ backgroundColor: "#FEE2E2",
112
+ padding: "12px",
113
+ borderRadius: "8px",
114
+ fontSize: "13px",
115
+ lineHeight: "1.4",
116
+ overflowX: "auto",
117
+ whiteSpace: "pre-wrap"
118
+ },
119
+ expandLine: {
120
+ color: "#F87171",
121
+ cursor: "pointer",
122
+ marginTop: "8px"
123
+ }
124
+ };
125
+ return /* @__PURE__ */ jsxs("div", { style: styles.container, children: [
126
+ /* @__PURE__ */ jsxs("div", { children: [
127
+ /* @__PURE__ */ jsx("div", { style: styles.heading, children: "\u{1F525} Error" }),
128
+ /* @__PURE__ */ jsx("div", { style: styles.name, children: error.name }),
129
+ /* @__PURE__ */ jsx("div", { style: styles.message, children: error.message })
130
+ ] }),
131
+ stackLines.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
132
+ /* @__PURE__ */ jsxs("div", { style: styles.sectionHeader, children: [
133
+ /* @__PURE__ */ jsx("span", { children: "Stack trace" }),
134
+ /* @__PURE__ */ jsx(
135
+ "button",
136
+ {
137
+ onClick: () => copyToClipboard(error.stack),
138
+ style: styles.copyButton,
139
+ children: "Copy all"
140
+ }
141
+ )
142
+ ] }),
143
+ /* @__PURE__ */ jsxs("pre", { style: styles.stackContainer, children: [
144
+ (expanded ? stackLines : previewLines).map((line, i) => /* @__PURE__ */ jsx("div", { children: line }, i)),
145
+ !expanded && hiddenLineCount > 0 && /* @__PURE__ */ jsxs("div", { style: styles.expandLine, onClick: () => setExpanded(true), children: [
146
+ "+ ",
147
+ hiddenLineCount,
148
+ " more lines..."
149
+ ] })
150
+ ] })
151
+ ] })
152
+ ] });
153
+ };
154
+ const ErrorViewerProduction = () => {
155
+ const styles = {
156
+ container: {
157
+ padding: "24px",
158
+ backgroundColor: "#FEF2F2",
159
+ color: "#7F1D1D",
160
+ border: "1px solid #FECACA",
161
+ borderRadius: "16px",
162
+ boxShadow: "0 8px 24px rgba(0,0,0,0.05)",
163
+ fontFamily: "monospace",
164
+ maxWidth: "768px",
165
+ margin: "40px auto",
166
+ textAlign: "center"
167
+ },
168
+ heading: {
169
+ fontSize: "20px",
170
+ fontWeight: "bold",
171
+ marginBottom: "8px"
172
+ },
173
+ message: {
174
+ fontSize: "14px",
175
+ opacity: 0.85
176
+ }
177
+ };
178
+ return /* @__PURE__ */ jsxs("div", { style: styles.container, children: [
179
+ /* @__PURE__ */ jsx("div", { style: styles.heading, children: "\u{1F6A8} An error occurred" }),
180
+ /* @__PURE__ */ jsx("div", { style: styles.message, children: "Something went wrong. Please try again later." })
181
+ ] });
182
+ };
183
+
50
184
  const RouterLayerContext = createContext(void 0);
51
185
 
52
186
  const useRouterEvents = (opts = {}, deps = []) => {
@@ -144,14 +278,19 @@ const NestedView = (props) => {
144
278
  };
145
279
 
146
280
  class RedirectionError extends Error {
281
+ page;
147
282
  constructor(page) {
148
283
  super("Redirection");
149
284
  this.page = page;
150
285
  }
151
286
  }
152
287
 
288
+ const envSchema$1 = t.object({
289
+ REACT_STRICT_MODE: t.boolean({ default: true })
290
+ });
153
291
  class PageDescriptorProvider {
154
292
  log = $logger();
293
+ env = $inject(envSchema$1);
155
294
  alepha = $inject(Alepha);
156
295
  pages = [];
157
296
  getPages() {
@@ -165,8 +304,25 @@ class PageDescriptorProvider {
165
304
  }
166
305
  throw new Error(`Page ${name} not found`);
167
306
  }
307
+ url(name, options = {}) {
308
+ const page = this.page(name);
309
+ if (!page) {
310
+ throw new Error(`Page ${name} not found`);
311
+ }
312
+ let url = page.path ?? "";
313
+ let parent = page.parent;
314
+ while (parent) {
315
+ url = `${parent.path ?? ""}/${url}`;
316
+ parent = parent.parent;
317
+ }
318
+ url = this.compile(url, options.params ?? {});
319
+ return new URL(
320
+ url.replace(/\/\/+/g, "/") || "/",
321
+ options.base ?? `http://localhost`
322
+ );
323
+ }
168
324
  root(state, context) {
169
- return createElement(
325
+ const root = createElement(
170
326
  RouterContext.Provider,
171
327
  {
172
328
  value: {
@@ -177,13 +333,17 @@ class PageDescriptorProvider {
177
333
  },
178
334
  createElement(NestedView, {}, state.layers[0]?.element)
179
335
  );
336
+ if (this.env.REACT_STRICT_MODE) {
337
+ return createElement(StrictMode, {}, root);
338
+ }
339
+ return root;
180
340
  }
181
341
  async createLayers(route, request) {
182
342
  const { pathname, search } = request.url;
183
343
  const layers = [];
184
344
  let context = {};
185
345
  const stack = [{ route }];
186
- let onError = this.renderError;
346
+ request.onError = (error) => this.renderError(error);
187
347
  let parent = route.parent;
188
348
  while (parent) {
189
349
  stack.unshift({ route: parent });
@@ -283,23 +443,26 @@ class PageDescriptorProvider {
283
443
  const path = acc.replace(/\/+/, "/");
284
444
  const localErrorHandler = this.getErrorHandler(it.route);
285
445
  if (localErrorHandler) {
286
- onError = localErrorHandler;
446
+ request.onError = localErrorHandler;
287
447
  }
288
448
  if (it.error) {
289
- const element = await onError(it.error);
449
+ let element2 = await request.onError(it.error);
450
+ if (element2 === null) {
451
+ element2 = this.renderError(it.error);
452
+ }
290
453
  layers.push({
291
454
  props,
292
455
  error: it.error,
293
456
  name: it.route.name,
294
457
  part: it.route.path,
295
458
  config: it.config,
296
- element: this.renderView(i + 1, path, element),
459
+ element: this.renderView(i + 1, path, element2, it.route),
297
460
  index: i + 1,
298
461
  path
299
462
  });
300
463
  break;
301
464
  }
302
- const layer = await this.createElement(it.route, {
465
+ const element = await this.createElement(it.route, {
303
466
  ...props,
304
467
  ...context
305
468
  });
@@ -308,7 +471,7 @@ class PageDescriptorProvider {
308
471
  props,
309
472
  part: it.route.path,
310
473
  config: it.config,
311
- element: this.renderView(i + 1, path, layer),
474
+ element: this.renderView(i + 1, path, element, it.route),
312
475
  index: i + 1,
313
476
  path
314
477
  });
@@ -364,8 +527,8 @@ class PageDescriptorProvider {
364
527
  ctx.head.meta = [...ctx.head.meta ?? [], ...head.meta ?? []];
365
528
  }
366
529
  }
367
- renderError(e) {
368
- return createElement("pre", { style: { overflow: "auto" } }, `${e.stack}`);
530
+ renderError(error) {
531
+ return createElement(ErrorViewer, { error });
369
532
  }
370
533
  renderEmptyView() {
371
534
  return createElement(NestedView, {});
@@ -390,7 +553,13 @@ class PageDescriptorProvider {
390
553
  }
391
554
  return path;
392
555
  }
393
- renderView(index, path, view = this.renderEmptyView()) {
556
+ renderView(index, path, view, page) {
557
+ view ??= this.renderEmptyView();
558
+ const element = page.client ? createElement(
559
+ ClientOnly,
560
+ typeof page.client === "object" ? page.client : {},
561
+ view
562
+ ) : view;
394
563
  return createElement(
395
564
  RouterLayerContext.Provider,
396
565
  {
@@ -399,7 +568,7 @@ class PageDescriptorProvider {
399
568
  path
400
569
  }
401
570
  },
402
- view
571
+ element
403
572
  );
404
573
  }
405
574
  configure = $hook({
@@ -408,6 +577,8 @@ class PageDescriptorProvider {
408
577
  const pages = this.alepha.getDescriptorValues($page);
409
578
  for (const { value, key } of pages) {
410
579
  value[OPTIONS].name ??= key;
580
+ }
581
+ for (const { value } of pages) {
411
582
  if (value[OPTIONS].parent) {
412
583
  continue;
413
584
  }
@@ -417,11 +588,6 @@ class PageDescriptorProvider {
417
588
  });
418
589
  map(pages, target) {
419
590
  const children = target[OPTIONS].children ?? [];
420
- for (const it of pages) {
421
- if (it.value[OPTIONS].parent === target) {
422
- children.push(it.value);
423
- }
424
- }
425
591
  return {
426
592
  ...target[OPTIONS],
427
593
  parent: void 0,
@@ -733,7 +899,7 @@ class ReactBrowserProvider {
733
899
  const hydration = this.getHydrationState();
734
900
  const previous = hydration?.layers ?? [];
735
901
  if (hydration?.links) {
736
- for (const link of hydration.links) {
902
+ for (const link of hydration.links.links) {
737
903
  this.client.pushLink(link);
738
904
  }
739
905
  }
@@ -769,7 +935,8 @@ class ReactBrowserProvider {
769
935
  }
770
936
 
771
937
  class RouterHookApi {
772
- constructor(state, layer, browser) {
938
+ constructor(pages, state, layer, browser) {
939
+ this.pages = pages;
773
940
  this.state = state;
774
941
  this.layer = layer;
775
942
  this.browser = browser;
@@ -823,23 +990,40 @@ class RouterHookApi {
823
990
  * @param pathname
824
991
  * @param layer
825
992
  */
826
- createHref(pathname, layer = this.layer) {
993
+ createHref(pathname, layer = this.layer, options = {}) {
827
994
  if (typeof pathname === "object") {
828
995
  pathname = pathname.options.path ?? "";
829
996
  }
997
+ if (options.params) {
998
+ for (const [key, value] of Object.entries(options.params)) {
999
+ pathname = pathname.replace(`:${key}`, String(value));
1000
+ }
1001
+ }
830
1002
  return pathname.startsWith("/") ? pathname : `${layer.path}/${pathname}`.replace(/\/\/+/g, "/");
831
1003
  }
832
1004
  async go(path, options) {
833
- await this.browser?.go(this.createHref(path, this.layer), options);
1005
+ for (const page of this.pages) {
1006
+ if (page.name === path) {
1007
+ path = page.path ?? "";
1008
+ break;
1009
+ }
1010
+ }
1011
+ await this.browser?.go(this.createHref(path, this.layer, options), options);
834
1012
  }
835
- anchor(path) {
836
- const href = this.createHref(path, this.layer);
1013
+ anchor(path, options = {}) {
1014
+ for (const page of this.pages) {
1015
+ if (page.name === path) {
1016
+ path = page.path ?? "";
1017
+ break;
1018
+ }
1019
+ }
1020
+ const href = this.createHref(path, this.layer, options);
837
1021
  return {
838
1022
  href,
839
1023
  onClick: (ev) => {
840
1024
  ev.stopPropagation();
841
1025
  ev.preventDefault();
842
- this.go(path).catch(console.error);
1026
+ this.go(path, options).catch(console.error);
843
1027
  }
844
1028
  };
845
1029
  }
@@ -867,8 +1051,12 @@ const useRouter = () => {
867
1051
  if (!ctx || !layer) {
868
1052
  throw new Error("useRouter must be used within a RouterProvider");
869
1053
  }
1054
+ const pages = useMemo(() => {
1055
+ return ctx.alepha.get(PageDescriptorProvider).getPages();
1056
+ }, []);
870
1057
  return useMemo(
871
1058
  () => new RouterHookApi(
1059
+ pages,
872
1060
  ctx.state,
873
1061
  layer,
874
1062
  ctx.alepha.isBrowser() ? ctx.alepha.get(ReactBrowserProvider) : void 0
@@ -879,6 +1067,7 @@ const useRouter = () => {
879
1067
 
880
1068
  const Link = (props) => {
881
1069
  React.useContext(RouterContext);
1070
+ const router = useRouter();
882
1071
  const to = typeof props.to === "string" ? props.to : props.to[OPTIONS].path;
883
1072
  if (!to) {
884
1073
  return null;
@@ -888,8 +1077,49 @@ const Link = (props) => {
888
1077
  return null;
889
1078
  }
890
1079
  const name = typeof props.to === "string" ? void 0 : props.to[OPTIONS].name;
1080
+ const anchorProps = {
1081
+ ...props,
1082
+ to: void 0
1083
+ };
1084
+ return /* @__PURE__ */ jsx("a", { ...router.anchor(to), ...anchorProps, children: props.children ?? name });
1085
+ };
1086
+
1087
+ const useActive = (path) => {
891
1088
  const router = useRouter();
892
- return /* @__PURE__ */ jsx("a", { ...router.anchor(to), ...props, children: props.children ?? name });
1089
+ const ctx = useContext(RouterContext);
1090
+ const layer = useContext(RouterLayerContext);
1091
+ if (!ctx || !layer) {
1092
+ throw new Error("useRouter must be used within a RouterProvider");
1093
+ }
1094
+ let name;
1095
+ if (typeof path === "object" && path.options.name) {
1096
+ name = path.options.name;
1097
+ }
1098
+ const [current, setCurrent] = useState(ctx.state.pathname);
1099
+ const href = useMemo(() => router.createHref(path, layer), [path, layer]);
1100
+ const [isPending, setPending] = useState(false);
1101
+ const isActive = current === href;
1102
+ useRouterEvents({
1103
+ onEnd: ({ state }) => setCurrent(state.pathname)
1104
+ });
1105
+ return {
1106
+ name,
1107
+ isPending,
1108
+ isActive,
1109
+ anchorProps: {
1110
+ href,
1111
+ onClick: (ev) => {
1112
+ ev.stopPropagation();
1113
+ ev.preventDefault();
1114
+ if (isActive) return;
1115
+ if (isPending) return;
1116
+ setPending(true);
1117
+ router.go(href).then(() => {
1118
+ setPending(false);
1119
+ });
1120
+ }
1121
+ }
1122
+ };
893
1123
  };
894
1124
 
895
1125
  const useInject = (clazz) => {
@@ -900,10 +1130,7 @@ const useInject = (clazz) => {
900
1130
  return useMemo(() => ctx.alepha.get(clazz), []);
901
1131
  };
902
1132
 
903
- const useClient = () => {
904
- return useInject(HttpClient);
905
- };
906
- const useApi = () => {
1133
+ const useClient = (_scope) => {
907
1134
  return useInject(HttpClient).of();
908
1135
  };
909
1136
 
@@ -937,7 +1164,7 @@ const encode = (alepha, schema, data) => {
937
1164
  const decode = (alepha, schema, data) => {
938
1165
  try {
939
1166
  return alepha.parse(schema, JSON.parse(atob(decodeURIComponent(data))));
940
- } catch (error) {
1167
+ } catch (_error) {
941
1168
  return {};
942
1169
  }
943
1170
  };
@@ -955,43 +1182,4 @@ const useRouterState = () => {
955
1182
  return state;
956
1183
  };
957
1184
 
958
- const useActive = (path) => {
959
- const router = useRouter();
960
- const ctx = useContext(RouterContext);
961
- const layer = useContext(RouterLayerContext);
962
- if (!ctx || !layer) {
963
- throw new Error("useRouter must be used within a RouterProvider");
964
- }
965
- let name;
966
- if (typeof path === "object" && path.options.name) {
967
- name = path.options.name;
968
- }
969
- const [current, setCurrent] = useState(ctx.state.pathname);
970
- const href = useMemo(() => router.createHref(path, layer), [path, layer]);
971
- const [isPending, setPending] = useState(false);
972
- const isActive = current === href;
973
- useRouterEvents({
974
- onEnd: ({ state }) => setCurrent(state.pathname)
975
- });
976
- return {
977
- name,
978
- isPending,
979
- isActive,
980
- anchorProps: {
981
- href,
982
- onClick: (ev) => {
983
- ev.stopPropagation();
984
- ev.preventDefault();
985
- if (isActive) return;
986
- if (isPending) return;
987
- setPending(true);
988
- router.go(href).then(() => {
989
- setPending(false);
990
- });
991
- }
992
- }
993
- };
994
- };
995
-
996
- export { $page as $, BrowserRouterProvider as B, ErrorBoundary as E, Link as L, NestedView as N, PageDescriptorProvider as P, RouterContext as R, RouterLayerContext as a, RouterHookApi as b, useClient as c, useApi as d, useQueryParams as e, useRouter as f, useRouterEvents as g, useRouterState as h, useActive as i, RedirectionError as j, isPageRoute as k, ReactBrowserProvider as l, useInject as u };
997
- //# sourceMappingURL=useActive-BX41CqY8.js.map
1185
+ export { $page as $, BrowserRouterProvider as B, ClientOnly as C, ErrorBoundary as E, Link as L, NestedView as N, PageDescriptorProvider as P, RedirectionError as R, RouterContext as a, RouterLayerContext as b, RouterHookApi as c, useAlepha as d, useClient as e, useInject as f, useQueryParams as g, useRouter as h, useRouterEvents as i, useRouterState as j, isPageRoute as k, ReactBrowserProvider as l, useActive as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alepha/react",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",
@@ -13,25 +13,26 @@
13
13
  "src"
14
14
  ],
15
15
  "dependencies": {
16
- "@alepha/core": "0.7.0",
17
- "@alepha/router": "0.7.0",
18
- "@alepha/server": "0.7.0",
19
- "@alepha/server-static": "0.7.0",
16
+ "@alepha/core": "0.7.1",
17
+ "@alepha/router": "0.7.1",
18
+ "@alepha/server": "0.7.1",
19
+ "@alepha/server-static": "0.7.1",
20
20
  "react-dom": "^19.1.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@types/react": "^19.1.6",
24
- "@types/react-dom": "^19.1.5",
25
- "pkgroll": "^2.12.2",
23
+ "@types/react": "^19.1.8",
24
+ "@types/react-dom": "^19.1.6",
25
+ "pkgroll": "^2.13.1",
26
26
  "react": "^19.1.0",
27
- "vitest": "^3.1.4"
27
+ "vitest": "^3.2.4"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "@types/react": "^19",
31
31
  "react": "^19"
32
32
  },
33
33
  "scripts": {
34
- "build": "pkgroll --clean-dist --sourcemap"
34
+ "test": "vitest run",
35
+ "build": "pkgroll --clean-dist"
35
36
  },
36
37
  "homepage": "https://github.com/feunard/alepha",
37
38
  "repository": {
@@ -0,0 +1,35 @@
1
+ import {
2
+ type PropsWithChildren,
3
+ type ReactNode,
4
+ useEffect,
5
+ useState,
6
+ } from "react";
7
+
8
+ export interface ClientOnlyProps {
9
+ fallback?: ReactNode;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ /**
14
+ * A small utility component that renders its children only on the client side.
15
+ *
16
+ * Optionally, you can provide a fallback React node that will be rendered.
17
+ *
18
+ * You should use this component when
19
+ * - you have code that relies on browser-specific APIs
20
+ * - you want to avoid server-side rendering for a specific part of your application
21
+ * - you want to prevent pre-rendering of a component
22
+ */
23
+ const ClientOnly = (props: PropsWithChildren<ClientOnlyProps>) => {
24
+ const [mounted, setMounted] = useState(false);
25
+
26
+ useEffect(() => setMounted(true), []);
27
+
28
+ if (props.disabled) {
29
+ return props.children;
30
+ }
31
+
32
+ return mounted ? props.children : props.fallback;
33
+ };
34
+
35
+ export default ClientOnly;
@@ -1,7 +1,7 @@
1
1
  import React, {
2
- type ReactNode,
3
2
  type ErrorInfo,
4
3
  type PropsWithChildren,
4
+ type ReactNode,
5
5
  } from "react";
6
6
 
7
7
  /**