@alepha/react 0.9.4 → 0.9.5

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/index.js CHANGED
@@ -1,16 +1,15 @@
1
- import { $env, $hook, $inject, $module, Alepha, AlephaError, Descriptor, KIND, createDescriptor, t } from "@alepha/core";
2
- import { AlephaServer, HttpClient, ServerRouterProvider, ServerTimingProvider } from "@alepha/server";
1
+ import { $env, $hook, $inject, $module, Alepha, AlephaError, Descriptor, KIND, TypeGuard, createDescriptor, t } from "@alepha/core";
2
+ import { AlephaServer, HttpClient, ServerProvider, ServerRouterProvider, ServerTimingProvider } from "@alepha/server";
3
3
  import { AlephaServerCache } from "@alepha/server-cache";
4
4
  import { AlephaServerLinks, LinkProvider, ServerLinksProvider } from "@alepha/server-links";
5
5
  import { $logger } from "@alepha/logger";
6
- import React, { StrictMode, createContext, createElement, useContext, useEffect, useMemo, useState } from "react";
7
- import { jsx, jsxs } from "react/jsx-runtime";
6
+ import React, { StrictMode, createContext, createElement, memo, use, useContext, useEffect, useMemo, useRef, useState } from "react";
7
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
8
  import { existsSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { ServerStaticProvider } from "@alepha/server-static";
11
11
  import { renderToString } from "react-dom/server";
12
12
  import { DateTimeProvider } from "@alepha/datetime";
13
- import { createRoot, hydrateRoot } from "react-dom/client";
14
13
  import { RouterProvider } from "@alepha/router";
15
14
 
16
15
  //#region src/descriptors/$page.ts
@@ -35,7 +34,10 @@ var PageDescriptor = class extends Descriptor {
35
34
  * Only valid for server-side rendering, it will throw an error if called on the client-side.
36
35
  */
37
36
  async render(options) {
38
- throw new Error("render method is not implemented in this environment");
37
+ throw new AlephaError("render() method is not implemented in this environment");
38
+ }
39
+ async fetch(options) {
40
+ throw new AlephaError("fetch() method is not implemented in this environment");
39
41
  }
40
42
  match(url) {
41
43
  return false;
@@ -64,7 +66,6 @@ const ClientOnly = (props) => {
64
66
  if (props.disabled) return props.children;
65
67
  return mounted ? props.children : props.fallback;
66
68
  };
67
- var ClientOnly_default = ClientOnly;
68
69
 
69
70
  //#endregion
70
71
  //#region src/components/ErrorViewer.tsx
@@ -172,7 +173,6 @@ const ErrorViewer = ({ error, alepha }) => {
172
173
  })] })]
173
174
  });
174
175
  };
175
- var ErrorViewer_default = ErrorViewer;
176
176
  const ErrorViewerProduction = () => {
177
177
  const styles = {
178
178
  container: {
@@ -266,19 +266,55 @@ const useRouterEvents = (opts = {}, deps = []) => {
266
266
  const alepha = useAlepha();
267
267
  useEffect(() => {
268
268
  if (!alepha.isBrowser()) return;
269
+ const cb = (callback) => {
270
+ if (typeof callback === "function") return { callback };
271
+ return callback;
272
+ };
269
273
  const subs = [];
270
274
  const onBegin = opts.onBegin;
271
275
  const onEnd = opts.onEnd;
272
276
  const onError = opts.onError;
273
- if (onBegin) subs.push(alepha.on("react:transition:begin", { callback: onBegin }));
274
- if (onEnd) subs.push(alepha.on("react:transition:end", { callback: onEnd }));
275
- if (onError) subs.push(alepha.on("react:transition:error", { callback: onError }));
277
+ const onSuccess = opts.onSuccess;
278
+ if (onBegin) subs.push(alepha.on("react:transition:begin", cb(onBegin)));
279
+ if (onEnd) subs.push(alepha.on("react:transition:end", cb(onEnd)));
280
+ if (onError) subs.push(alepha.on("react:transition:error", cb(onError)));
281
+ if (onSuccess) subs.push(alepha.on("react:transition:success", cb(onSuccess)));
276
282
  return () => {
277
283
  for (const sub of subs) sub();
278
284
  };
279
285
  }, deps);
280
286
  };
281
287
 
288
+ //#endregion
289
+ //#region src/hooks/useStore.ts
290
+ /**
291
+ * Hook to access and mutate the Alepha state.
292
+ */
293
+ const useStore = (key, defaultValue) => {
294
+ const alepha = useAlepha();
295
+ useMemo(() => {
296
+ if (defaultValue != null && alepha.state(key) == null) alepha.state(key, defaultValue);
297
+ }, [defaultValue]);
298
+ const [state, setState] = useState(alepha.state(key));
299
+ useEffect(() => {
300
+ if (!alepha.isBrowser()) return;
301
+ return alepha.on("state:mutate", (ev) => {
302
+ if (ev.key === key) setState(ev.value);
303
+ });
304
+ }, []);
305
+ return [state, (value) => {
306
+ alepha.state(key, value);
307
+ }];
308
+ };
309
+
310
+ //#endregion
311
+ //#region src/hooks/useRouterState.ts
312
+ const useRouterState = () => {
313
+ const [state] = useStore("react.router.state");
314
+ if (!state) throw new AlephaError("Missing react router state");
315
+ return state;
316
+ };
317
+
282
318
  //#endregion
283
319
  //#region src/components/ErrorBoundary.tsx
284
320
  /**
@@ -308,7 +344,6 @@ var ErrorBoundary = class extends React.Component {
308
344
  return this.props.children;
309
345
  }
310
346
  };
311
- var ErrorBoundary_default = ErrorBoundary;
312
347
 
313
348
  //#endregion
314
349
  //#region src/components/NestedView.tsx
@@ -319,7 +354,7 @@ var ErrorBoundary_default = ErrorBoundary;
319
354
  *
320
355
  * @example
321
356
  * ```tsx
322
- * import { NestedView } from "@alepha/react";
357
+ * import { NestedView } from "alepha/react";
323
358
  *
324
359
  * class App {
325
360
  * parent = $page({
@@ -334,17 +369,69 @@ var ErrorBoundary_default = ErrorBoundary;
334
369
  * ```
335
370
  */
336
371
  const NestedView = (props) => {
337
- const layer = useContext(RouterLayerContext);
338
- const index = layer?.index ?? 0;
339
- const alepha = useAlepha();
340
- const state = alepha.state("react.router.state");
341
- if (!state) throw new Error("<NestedView/> must be used inside a RouterLayerContext.");
372
+ const index = use(RouterLayerContext)?.index ?? 0;
373
+ const state = useRouterState();
342
374
  const [view, setView] = useState(state.layers[index]?.element);
343
- useRouterEvents({ onEnd: ({ state: state$1 }) => {
344
- if (!state$1.layers[index]?.cache) setView(state$1.layers[index]?.element);
345
- } }, []);
346
- const element = view ?? props.children ?? null;
347
- return /* @__PURE__ */ jsx(ErrorBoundary_default, {
375
+ const [animation, setAnimation] = useState("");
376
+ const animationExitDuration = useRef(0);
377
+ const animationExitNow = useRef(0);
378
+ useRouterEvents({
379
+ onBegin: async ({ previous, state: state$1 }) => {
380
+ const layer = previous.layers[index];
381
+ if (`${state$1.url.pathname}/`.startsWith(`${layer?.path}/`)) return;
382
+ const animationExit = parseAnimation(layer.route?.animation, state$1, "exit");
383
+ if (animationExit) {
384
+ const duration = animationExit.duration || 200;
385
+ animationExitNow.current = Date.now();
386
+ animationExitDuration.current = duration;
387
+ setAnimation(animationExit.animation);
388
+ } else {
389
+ animationExitNow.current = 0;
390
+ animationExitDuration.current = 0;
391
+ setAnimation("");
392
+ }
393
+ },
394
+ onEnd: async ({ state: state$1 }) => {
395
+ const layer = state$1.layers[index];
396
+ if (animationExitNow.current) {
397
+ const duration = animationExitDuration.current;
398
+ const diff = Date.now() - animationExitNow.current;
399
+ if (diff < duration) await new Promise((resolve) => setTimeout(resolve, duration - diff));
400
+ }
401
+ if (!layer?.cache) {
402
+ setView(layer?.element);
403
+ const animationEnter = parseAnimation(layer?.route?.animation, state$1, "enter");
404
+ if (animationEnter) setAnimation(animationEnter.animation);
405
+ else setAnimation("");
406
+ }
407
+ }
408
+ }, []);
409
+ let element = view ?? props.children ?? null;
410
+ if (animation) element = /* @__PURE__ */ jsx("div", {
411
+ style: {
412
+ display: "flex",
413
+ flex: 1,
414
+ height: "100%",
415
+ width: "100%",
416
+ position: "relative",
417
+ overflow: "hidden"
418
+ },
419
+ children: /* @__PURE__ */ jsx("div", {
420
+ style: {
421
+ height: "100%",
422
+ width: "100%",
423
+ display: "flex",
424
+ animation
425
+ },
426
+ children: element
427
+ })
428
+ });
429
+ if (props.errorBoundary === false) return /* @__PURE__ */ jsx(Fragment, { children: element });
430
+ if (props.errorBoundary) return /* @__PURE__ */ jsx(ErrorBoundary, {
431
+ fallback: props.errorBoundary,
432
+ children: element
433
+ });
434
+ return /* @__PURE__ */ jsx(ErrorBoundary, {
348
435
  fallback: (error) => {
349
436
  const result = state.onError(error, state);
350
437
  if (result instanceof Redirection) return "Redirection inside ErrorBoundary is not allowed.";
@@ -353,7 +440,37 @@ const NestedView = (props) => {
353
440
  children: element
354
441
  });
355
442
  };
356
- var NestedView_default = NestedView;
443
+ var NestedView_default = memo(NestedView);
444
+ function parseAnimation(animationLike, state, type = "enter") {
445
+ if (!animationLike) return void 0;
446
+ const DEFAULT_DURATION = 300;
447
+ const animation = typeof animationLike === "function" ? animationLike(state) : animationLike;
448
+ if (typeof animation === "string") {
449
+ if (type === "exit") return;
450
+ return {
451
+ duration: DEFAULT_DURATION,
452
+ animation: `${DEFAULT_DURATION}ms ${animation}`
453
+ };
454
+ }
455
+ if (typeof animation === "object") {
456
+ const anim = animation[type];
457
+ const duration = typeof anim === "object" ? anim.duration ?? DEFAULT_DURATION : DEFAULT_DURATION;
458
+ const name = typeof anim === "object" ? anim.name : anim;
459
+ if (type === "exit") {
460
+ const timing$1 = typeof anim === "object" ? anim.timing ?? "" : "";
461
+ return {
462
+ duration,
463
+ animation: `${duration}ms ${timing$1} ${name}`
464
+ };
465
+ }
466
+ const timing = typeof anim === "object" ? anim.timing ?? "" : "";
467
+ return {
468
+ duration,
469
+ animation: `${duration}ms ${timing} ${name}`
470
+ };
471
+ }
472
+ return void 0;
473
+ }
357
474
 
358
475
  //#endregion
359
476
  //#region src/components/NotFound.tsx
@@ -419,6 +536,14 @@ var ReactPageProvider = class {
419
536
  if (this.env.REACT_STRICT_MODE) return createElement(StrictMode, {}, root);
420
537
  return root;
421
538
  }
539
+ convertStringObjectToObject = (schema, value) => {
540
+ if (TypeGuard.IsObject(schema) && typeof value === "object") {
541
+ for (const key in schema.properties) if (TypeGuard.IsObject(schema.properties[key]) && typeof value[key] === "string") try {
542
+ value[key] = this.alepha.parse(schema.properties[key], decodeURIComponent(value[key]));
543
+ } catch (e) {}
544
+ }
545
+ return value;
546
+ };
422
547
  /**
423
548
  * Create a new RouterState based on a given route and request.
424
549
  * This method resolves the layers for the route, applying any query and params schemas defined in the route.
@@ -438,6 +563,7 @@ var ReactPageProvider = class {
438
563
  const route$1 = it.route;
439
564
  const config = {};
440
565
  try {
566
+ this.convertStringObjectToObject(route$1.schema?.query, state.query);
441
567
  config.query = route$1.schema?.query ? this.alepha.parse(route$1.schema.query, state.query) : {};
442
568
  } catch (e) {
443
569
  it.error = e;
@@ -564,6 +690,7 @@ var ReactPageProvider = class {
564
690
  }
565
691
  }
566
692
  async createElement(page, props) {
693
+ if (page.lazy && page.component) this.log.warn(`Page ${page.name} has both lazy and component options, lazy will be used`);
567
694
  if (page.lazy) {
568
695
  const component = await page.lazy();
569
696
  return createElement(component.default, props);
@@ -572,7 +699,7 @@ var ReactPageProvider = class {
572
699
  return void 0;
573
700
  }
574
701
  renderError(error) {
575
- return createElement(ErrorViewer_default, {
702
+ return createElement(ErrorViewer, {
576
703
  error,
577
704
  alepha: this.alepha
578
705
  });
@@ -598,7 +725,7 @@ var ReactPageProvider = class {
598
725
  }
599
726
  renderView(index, path, view, page) {
600
727
  view ??= this.renderEmptyView();
601
- const element = page.client ? createElement(ClientOnly_default, typeof page.client === "object" ? page.client : {}, view) : view;
728
+ const element = page.client ? createElement(ClientOnly, typeof page.client === "object" ? page.client : {}, view) : view;
602
729
  return createElement(RouterLayerContext.Provider, { value: {
603
730
  index,
604
731
  path
@@ -692,6 +819,7 @@ var ReactServerProvider = class {
692
819
  log = $logger();
693
820
  alepha = $inject(Alepha);
694
821
  pageApi = $inject(ReactPageProvider);
822
+ serverProvider = $inject(ServerProvider);
695
823
  serverStaticProvider = $inject(ServerStaticProvider);
696
824
  serverRouterProvider = $inject(ServerRouterProvider);
697
825
  serverTimingProvider = $inject(ServerTimingProvider);
@@ -703,7 +831,23 @@ var ReactServerProvider = class {
703
831
  const pages = this.alepha.descriptors($page);
704
832
  const ssrEnabled = pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
705
833
  this.alepha.state("react.server.ssr", ssrEnabled);
706
- for (const page of pages) page.render = this.createRenderFunction(page.name);
834
+ for (const page of pages) {
835
+ page.render = this.createRenderFunction(page.name);
836
+ page.fetch = async (options) => {
837
+ const response = await fetch(`${this.serverProvider.hostname}/${page.pathname(options)}`);
838
+ const html = await response.text();
839
+ if (options?.html) return {
840
+ html,
841
+ response
842
+ };
843
+ const match = html.match(this.ROOT_DIV_REGEX);
844
+ if (match) return {
845
+ html: match[3],
846
+ response
847
+ };
848
+ throw new AlephaError("Invalid HTML response");
849
+ };
850
+ }
707
851
  if (this.alepha.isServerless() === "vite") {
708
852
  await this.configureVite(ssrEnabled);
709
853
  return;
@@ -783,13 +927,18 @@ var ReactServerProvider = class {
783
927
  params: options.params ?? {},
784
928
  query: options.query ?? {},
785
929
  onError: () => null,
786
- layers: []
930
+ layers: [],
931
+ meta: {}
787
932
  };
788
933
  const state = entry;
789
934
  this.log.trace("Rendering", { url });
790
935
  await this.alepha.emit("react:server:render:begin", { state });
791
936
  const { redirect } = await this.pageApi.createLayers(page, state);
792
- if (redirect) throw new AlephaError("Redirection is not supported in this context");
937
+ if (redirect) return {
938
+ state,
939
+ html: "",
940
+ redirect
941
+ };
793
942
  if (!withIndex && !options.html) {
794
943
  this.alepha.state("react.router.state", state);
795
944
  return {
@@ -798,7 +947,11 @@ var ReactServerProvider = class {
798
947
  };
799
948
  }
800
949
  const html = this.renderToHtml(this.template ?? "", state, options.hydration);
801
- if (html instanceof Redirection) throw new Error("Redirection is not supported in this context");
950
+ if (html instanceof Redirection) return {
951
+ state,
952
+ html: "",
953
+ redirect
954
+ };
802
955
  const result = {
803
956
  state,
804
957
  html
@@ -935,17 +1088,21 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
935
1088
  });
936
1089
  }
937
1090
  });
938
- async transition(url, previous = []) {
1091
+ async transition(url, previous = [], meta = {}) {
939
1092
  const { pathname, search } = url;
940
1093
  const entry = {
941
1094
  url,
942
1095
  query: {},
943
1096
  params: {},
944
1097
  layers: [],
945
- onError: () => null
1098
+ onError: () => null,
1099
+ meta
946
1100
  };
947
1101
  const state = entry;
948
- await this.alepha.emit("react:transition:begin", { state });
1102
+ await this.alepha.emit("react:transition:begin", {
1103
+ previous: this.alepha.state("react.router.state"),
1104
+ state
1105
+ });
949
1106
  try {
950
1107
  const { route, params } = this.match(pathname);
951
1108
  const query = {};
@@ -980,8 +1137,8 @@ var ReactBrowserRouterProvider = class extends RouterProvider {
980
1137
  const layer = previous[i];
981
1138
  if (state.layers[i]?.name !== layer.name) this.pageApi.page(layer.name)?.onLeave?.();
982
1139
  }
983
- await this.alepha.emit("react:transition:end", { state });
984
1140
  this.alepha.state("react.router.state", state);
1141
+ await this.alepha.emit("react:transition:end", { state });
985
1142
  }
986
1143
  root(state) {
987
1144
  return this.pageApi.root(state);
@@ -998,7 +1155,6 @@ var ReactBrowserProvider = class {
998
1155
  alepha = $inject(Alepha);
999
1156
  router = $inject(ReactBrowserRouterProvider);
1000
1157
  dateTimeProvider = $inject(DateTimeProvider);
1001
- root;
1002
1158
  options = { scrollRestoration: "top" };
1003
1159
  getRootElement() {
1004
1160
  const root = this.document.getElementById(this.env.REACT_ROOT_ID);
@@ -1074,7 +1230,8 @@ var ReactBrowserProvider = class {
1074
1230
  });
1075
1231
  await this.render({
1076
1232
  url,
1077
- previous: options.force ? [] : this.state.layers
1233
+ previous: options.force ? [] : this.state.layers,
1234
+ meta: options.meta
1078
1235
  });
1079
1236
  if (this.state.url.pathname + this.state.url.search !== url) {
1080
1237
  this.pushState(this.state.url.pathname + this.state.url.search);
@@ -1091,7 +1248,7 @@ var ReactBrowserProvider = class {
1091
1248
  from: this.state?.url.pathname
1092
1249
  };
1093
1250
  this.log.debug("Transitioning...", { to: url });
1094
- const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous);
1251
+ const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous, options.meta);
1095
1252
  if (redirect) {
1096
1253
  this.log.info("Redirecting to", { redirect });
1097
1254
  return await this.render({ url: redirect });
@@ -1113,7 +1270,7 @@ var ReactBrowserProvider = class {
1113
1270
  onTransitionEnd = $hook({
1114
1271
  on: "react:transition:end",
1115
1272
  handler: () => {
1116
- if (this.options.scrollRestoration === "top" && typeof window !== "undefined") {
1273
+ if (this.options.scrollRestoration === "top" && typeof window !== "undefined" && !this.alepha.isTest()) {
1117
1274
  this.log.trace("Restoring scroll position to top");
1118
1275
  window.scrollTo(0, 0);
1119
1276
  }
@@ -1129,14 +1286,12 @@ var ReactBrowserProvider = class {
1129
1286
  }
1130
1287
  await this.render({ previous });
1131
1288
  const element = this.router.root(this.state);
1132
- if (hydration?.layers) {
1133
- this.root = hydrateRoot(this.getRootElement(), element);
1134
- this.log.info("Hydrated root element");
1135
- } else {
1136
- this.root ??= createRoot(this.getRootElement());
1137
- this.root.render(element);
1138
- this.log.info("Created root element");
1139
- }
1289
+ await this.alepha.emit("react:browser:render", {
1290
+ element,
1291
+ root: this.getRootElement(),
1292
+ hydration,
1293
+ state: this.state
1294
+ });
1140
1295
  window.addEventListener("popstate", () => {
1141
1296
  if (this.base + this.state.url.pathname === this.location.pathname) return;
1142
1297
  this.log.debug("Popstate event triggered - rendering new state", { url: this.location.pathname + this.location.search });
@@ -1275,44 +1430,12 @@ const useRouter = () => {
1275
1430
  //#region src/components/Link.tsx
1276
1431
  const Link = (props) => {
1277
1432
  const router = useRouter();
1278
- const { to,...anchorProps } = props;
1279
1433
  return /* @__PURE__ */ jsx("a", {
1280
- ...router.anchor(to),
1281
- ...anchorProps,
1434
+ ...props,
1435
+ ...router.anchor(props.href),
1282
1436
  children: props.children
1283
1437
  });
1284
1438
  };
1285
- var Link_default = Link;
1286
-
1287
- //#endregion
1288
- //#region src/hooks/useStore.ts
1289
- /**
1290
- * Hook to access and mutate the Alepha state.
1291
- */
1292
- const useStore = (key, defaultValue) => {
1293
- const alepha = useAlepha();
1294
- useMemo(() => {
1295
- if (defaultValue != null && alepha.state(key) == null) alepha.state(key, defaultValue);
1296
- }, [defaultValue]);
1297
- const [state, setState] = useState(alepha.state(key));
1298
- useEffect(() => {
1299
- if (!alepha.isBrowser()) return;
1300
- return alepha.on("state:mutate", (ev) => {
1301
- if (ev.key === key) setState(ev.value);
1302
- });
1303
- }, []);
1304
- return [state, (value) => {
1305
- alepha.state(key, value);
1306
- }];
1307
- };
1308
-
1309
- //#endregion
1310
- //#region src/hooks/useRouterState.ts
1311
- const useRouterState = () => {
1312
- const [state] = useStore("react.router.state");
1313
- if (!state) throw new AlephaError("Missing react router state");
1314
- return state;
1315
- };
1316
1439
 
1317
1440
  //#endregion
1318
1441
  //#region src/hooks/useActive.ts
@@ -1454,5 +1577,5 @@ const AlephaReact = $module({
1454
1577
  });
1455
1578
 
1456
1579
  //#endregion
1457
- export { $page, AlephaContext, AlephaReact, ClientOnly_default as ClientOnly, ErrorBoundary_default as ErrorBoundary, ErrorViewer_default as ErrorViewer, Link_default as Link, NestedView_default as NestedView, NotFoundPage as NotFound, PageDescriptor, ReactBrowserProvider, ReactPageProvider, ReactRouter, ReactServerProvider, Redirection, RouterLayerContext, isPageRoute, ssrSchemaLoading, useActive, useAlepha, useClient, useInject, useQueryParams, useRouter, useRouterEvents, useRouterState, useSchema, useStore };
1580
+ export { $page, AlephaContext, AlephaReact, ClientOnly, ErrorBoundary, ErrorViewer, Link, NestedView_default as NestedView, NotFoundPage as NotFound, PageDescriptor, ReactBrowserProvider, ReactPageProvider, ReactRouter, ReactServerProvider, Redirection, RouterLayerContext, isPageRoute, ssrSchemaLoading, useActive, useAlepha, useClient, useInject, useQueryParams, useRouter, useRouterEvents, useRouterState, useSchema, useStore };
1458
1581
  //# sourceMappingURL=index.js.map