@alepha/react 0.9.1 → 0.9.3

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.
@@ -0,0 +1,47 @@
1
+ import type { State } from "@alepha/core";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { useAlepha } from "./useAlepha.ts";
4
+
5
+ /**
6
+ * Hook to access and mutate the Alepha state.
7
+ */
8
+ export const useStore = <Key extends keyof State>(
9
+ key: Key,
10
+ defaultValue?: State[Key],
11
+ ): [State[Key], (value: State[Key]) => void] => {
12
+ const alepha = useAlepha();
13
+
14
+ useMemo(() => {
15
+ if (defaultValue != null && alepha.state(key) == null) {
16
+ alepha.state(key, defaultValue);
17
+ }
18
+ }, [defaultValue]);
19
+
20
+ const [state, setState] = useState(alepha.state(key));
21
+
22
+ useEffect(() => {
23
+ if (!alepha.isBrowser()) {
24
+ return;
25
+ }
26
+
27
+ return alepha.on("state:mutate", (ev) => {
28
+ if (ev.key === key) {
29
+ setState(ev.value);
30
+ }
31
+ });
32
+ }, []);
33
+
34
+ if (!alepha.isBrowser()) {
35
+ const value = alepha.context.get(key) as State[Key];
36
+ if (value !== null) {
37
+ return [value, (_: State[Key]) => {}] as const;
38
+ }
39
+ }
40
+
41
+ return [
42
+ state,
43
+ (value: State[Key]) => {
44
+ alepha.state(key, value);
45
+ },
46
+ ] as const;
47
+ };
@@ -1,13 +1,14 @@
1
1
  export { default as ClientOnly } from "./components/ClientOnly.tsx";
2
2
  export { default as ErrorBoundary } from "./components/ErrorBoundary.tsx";
3
3
  export * from "./components/ErrorViewer.tsx";
4
- export { default as Link } from "./components/Link.tsx";
4
+ export { default as Link, type LinkProps } from "./components/Link.tsx";
5
5
  export { default as NestedView } from "./components/NestedView.tsx";
6
6
  export { default as NotFound } from "./components/NotFound.tsx";
7
+ export * from "./contexts/AlephaContext.ts";
7
8
  export * from "./contexts/RouterContext.ts";
8
9
  export * from "./contexts/RouterLayerContext.ts";
9
10
  export * from "./descriptors/$page.ts";
10
- export * from "./errors/RedirectionError.ts";
11
+ export * from "./errors/Redirection.ts";
11
12
  export * from "./hooks/RouterHookApi.ts";
12
13
  export * from "./hooks/useActive.ts";
13
14
  export * from "./hooks/useAlepha.ts";
@@ -17,3 +18,5 @@ export * from "./hooks/useQueryParams.ts";
17
18
  export * from "./hooks/useRouter.ts";
18
19
  export * from "./hooks/useRouterEvents.ts";
19
20
  export * from "./hooks/useRouterState.ts";
21
+ export * from "./hooks/useSchema.ts";
22
+ export * from "./hooks/useStore.ts";
@@ -129,6 +129,15 @@ export class BrowserRouterProvider extends RouterProvider<BrowserRoute> {
129
129
  options.state.search = state.search;
130
130
  }
131
131
 
132
+ if (options.previous) {
133
+ for (let i = 0; i < options.previous.length; i++) {
134
+ const layer = options.previous[i];
135
+ if (state.layers[i]?.name !== layer.name) {
136
+ this.pageDescriptorProvider.page(layer.name)?.onLeave?.();
137
+ }
138
+ }
139
+ }
140
+
132
141
  await this.alepha.emit("react:transition:end", {
133
142
  state: options.state,
134
143
  context,
@@ -13,14 +13,17 @@ import ClientOnly from "../components/ClientOnly.tsx";
13
13
  import ErrorViewer from "../components/ErrorViewer.tsx";
14
14
  import NestedView from "../components/NestedView.tsx";
15
15
  import NotFoundPage from "../components/NotFound.tsx";
16
+ import { AlephaContext } from "../contexts/AlephaContext.ts";
16
17
  import { RouterContext } from "../contexts/RouterContext.ts";
17
18
  import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
18
19
  import {
19
20
  $page,
21
+ type ErrorHandler,
20
22
  type PageDescriptor,
21
23
  type PageDescriptorOptions,
22
24
  } from "../descriptors/$page.ts";
23
- import { RedirectionError } from "../errors/RedirectionError.ts";
25
+ import { Redirection } from "../errors/Redirection.ts";
26
+ import type { HrefLike } from "../hooks/RouterHookApi.ts";
24
27
 
25
28
  const envSchema = t.object({
26
29
  REACT_STRICT_MODE: t.boolean({ default: true }),
@@ -50,10 +53,13 @@ export class PageDescriptorProvider {
50
53
  throw new Error(`Page ${name} not found`);
51
54
  }
52
55
 
53
- public url(
56
+ public pathname(
54
57
  name: string,
55
- options: { params?: Record<string, string>; base?: string } = {},
56
- ): URL {
58
+ options: {
59
+ params?: Record<string, string>;
60
+ query?: Record<string, string>;
61
+ } = {},
62
+ ) {
57
63
  const page = this.page(name);
58
64
  if (!page) {
59
65
  throw new Error(`Page ${name} not found`);
@@ -68,23 +74,41 @@ export class PageDescriptorProvider {
68
74
 
69
75
  url = this.compile(url, options.params ?? {});
70
76
 
77
+ if (options.query) {
78
+ const query = new URLSearchParams(options.query);
79
+ if (query.toString()) {
80
+ url += `?${query.toString()}`;
81
+ }
82
+ }
83
+
84
+ return url.replace(/\/\/+/g, "/") || "/";
85
+ }
86
+
87
+ public url(
88
+ name: string,
89
+ options: { params?: Record<string, string>; base?: string } = {},
90
+ ): URL {
71
91
  return new URL(
72
- url.replace(/\/\/+/g, "/") || "/",
92
+ this.pathname(name, options),
93
+ // use provided base or default to http://localhost
73
94
  options.base ?? `http://localhost`,
74
95
  );
75
96
  }
76
97
 
77
98
  public root(state: RouterState, context: PageReactContext): ReactNode {
78
99
  const root = createElement(
79
- RouterContext.Provider,
80
- {
81
- value: {
82
- alepha: this.alepha,
83
- state,
84
- context,
100
+ AlephaContext.Provider,
101
+ { value: this.alepha },
102
+ createElement(
103
+ RouterContext.Provider,
104
+ {
105
+ value: {
106
+ state,
107
+ context,
108
+ },
85
109
  },
86
- },
87
- createElement(NestedView, {}, state.layers[0]?.element),
110
+ createElement(NestedView, {}, state.layers[0]?.element),
111
+ ),
88
112
  );
89
113
 
90
114
  if (this.env.REACT_STRICT_MODE) {
@@ -196,13 +220,11 @@ export class PageDescriptorProvider {
196
220
  };
197
221
  } catch (e) {
198
222
  // check if we need to redirect
199
- if (e instanceof RedirectionError) {
200
- return {
201
- layers: [],
202
- redirect: typeof e.page === "string" ? e.page : this.href(e.page),
223
+ if (e instanceof Redirection) {
224
+ return this.createRedirectionLayer(e.page, {
203
225
  pathname,
204
226
  search,
205
- };
227
+ });
206
228
  }
207
229
 
208
230
  this.log.error(e);
@@ -227,28 +249,56 @@ export class PageDescriptorProvider {
227
249
  const path = acc.replace(/\/+/, "/");
228
250
  const localErrorHandler = this.getErrorHandler(it.route);
229
251
  if (localErrorHandler) {
230
- request.onError = localErrorHandler;
252
+ const onErrorParent = request.onError;
253
+ request.onError = (error, context) => {
254
+ const result = localErrorHandler(error, context);
255
+ // if nothing happen, call the parent
256
+ if (result === undefined) {
257
+ return onErrorParent(error, context);
258
+ }
259
+ return result;
260
+ };
231
261
  }
232
262
 
233
263
  // handler has thrown an error, render an error view
234
264
  if (it.error) {
235
- let element: ReactNode = await request.onError(it.error);
236
- if (element === null) {
237
- element = this.renderError(it.error);
238
- }
265
+ try {
266
+ let element: ReactNode | Redirection | undefined =
267
+ await request.onError(it.error, request);
239
268
 
240
- layers.push({
241
- props,
242
- error: it.error,
243
- name: it.route.name,
244
- part: it.route.path,
245
- config: it.config,
246
- element: this.renderView(i + 1, path, element, it.route),
247
- index: i + 1,
248
- path,
249
- route: it.route,
250
- });
251
- break;
269
+ if (element === undefined) {
270
+ throw it.error;
271
+ }
272
+
273
+ if (element instanceof Redirection) {
274
+ return this.createRedirectionLayer(element.page, {
275
+ pathname,
276
+ search,
277
+ });
278
+ }
279
+
280
+ if (element === null) {
281
+ element = this.renderError(it.error);
282
+ }
283
+
284
+ layers.push({
285
+ props,
286
+ error: it.error,
287
+ name: it.route.name,
288
+ part: it.route.path,
289
+ config: it.config,
290
+ element: this.renderView(i + 1, path, element, it.route),
291
+ index: i + 1,
292
+ path,
293
+ route: it.route,
294
+ });
295
+ break;
296
+ } catch (e) {
297
+ if (e instanceof Redirection) {
298
+ return this.createRedirectionLayer(e.page, { pathname, search });
299
+ }
300
+ throw e;
301
+ }
252
302
  }
253
303
 
254
304
  // normal use case
@@ -274,7 +324,22 @@ export class PageDescriptorProvider {
274
324
  return { layers, pathname, search };
275
325
  }
276
326
 
277
- protected getErrorHandler(route: PageRoute) {
327
+ protected createRedirectionLayer(
328
+ href: HrefLike,
329
+ context: {
330
+ pathname: string;
331
+ search: string;
332
+ },
333
+ ) {
334
+ return {
335
+ layers: [],
336
+ redirect: typeof href === "string" ? href : this.href(href),
337
+ pathname: context.pathname,
338
+ search: context.search,
339
+ };
340
+ }
341
+
342
+ protected getErrorHandler(route: PageRoute): ErrorHandler | undefined {
278
343
  if (route.errorHandler) return route.errorHandler;
279
344
  let parent = route.parent;
280
345
  while (parent) {
@@ -370,6 +435,10 @@ export class PageDescriptorProvider {
370
435
  const pages = this.alepha.descriptors($page);
371
436
 
372
437
  const hasParent = (it: PageDescriptor) => {
438
+ if (it.options.parent) {
439
+ return true;
440
+ }
441
+
373
442
  for (const page of pages) {
374
443
  const children = page.options.children
375
444
  ? Array.isArray(page.options.children)
@@ -402,7 +471,7 @@ export class PageDescriptorProvider {
402
471
  name: "notFound",
403
472
  cache: true,
404
473
  component: NotFoundPage,
405
- afterHandler: ({ reply }) => {
474
+ onServerResponse: ({ reply }) => {
406
475
  reply.status = 404;
407
476
  },
408
477
  });
@@ -420,6 +489,18 @@ export class PageDescriptorProvider {
420
489
  : target.options.children()
421
490
  : [];
422
491
 
492
+ const getChildrenFromParent = (it: PageDescriptor): PageDescriptor[] => {
493
+ const children = [];
494
+ for (const page of pages) {
495
+ if (page.options.parent === it) {
496
+ children.push(page);
497
+ }
498
+ }
499
+ return children;
500
+ };
501
+
502
+ children.push(...getChildrenFromParent(target));
503
+
423
504
  return {
424
505
  ...target.options,
425
506
  name: target.name,
@@ -517,7 +598,7 @@ export type PreviousLayerData = Omit<Layer, "element" | "index" | "path">;
517
598
 
518
599
  export interface AnchorProps {
519
600
  href: string;
520
- onClick: (ev: any) => any;
601
+ onClick: (ev?: any) => any;
521
602
  }
522
603
 
523
604
  export interface RouterState {
@@ -564,6 +645,9 @@ export interface CreateLayersResult extends RouterState {
564
645
  */
565
646
  export interface PageReactContext {
566
647
  url: URL;
567
- onError: (error: Error) => ReactNode;
648
+ onError: ErrorHandler;
568
649
  links?: ApiLinksResponse;
650
+
651
+ params: Record<string, any>;
652
+ query: Record<string, string>;
569
653
  }
@@ -1,4 +1,4 @@
1
- import { $hook, $inject, $logger, Alepha } from "@alepha/core";
1
+ import { $hook, $inject, $logger, Alepha, type State } from "@alepha/core";
2
2
  import type { ApiLinksResponse } from "@alepha/server";
3
3
  import { LinkProvider } from "@alepha/server-links";
4
4
  import type { Root } from "react-dom/client";
@@ -97,18 +97,12 @@ export class ReactBrowserProvider {
97
97
  });
98
98
 
99
99
  // when redirecting in browser
100
- if (result.context.url.pathname !== url) {
101
- // TODO: check if losing search params is acceptable?
102
- this.pushState(result.context.url.pathname);
100
+ if (result.context.url.pathname + result.context.url.search !== url) {
101
+ this.pushState(result.context.url.pathname + result.context.url.search);
103
102
  return;
104
103
  }
105
104
 
106
- if (options.replace) {
107
- this.pushState(url);
108
- return;
109
- }
110
-
111
- this.pushState(url);
105
+ this.pushState(url, options.replace);
112
106
  }
113
107
 
114
108
  protected async render(
@@ -157,9 +151,20 @@ export class ReactBrowserProvider {
157
151
  const hydration = this.getHydrationState();
158
152
  const previous = hydration?.layers ?? [];
159
153
 
154
+ if (hydration) {
155
+ for (const [key, value] of Object.entries(hydration)) {
156
+ if (key !== "layers" && key !== "links") {
157
+ this.alepha.state(key as keyof State, value);
158
+ }
159
+ }
160
+ }
161
+
160
162
  if (hydration?.links) {
161
163
  for (const link of hydration.links.links) {
162
- this.client.pushLink(link);
164
+ this.client.pushLink({
165
+ ...link,
166
+ prefix: hydration.links.prefix,
167
+ });
163
168
  }
164
169
  }
165
170
 
@@ -190,6 +195,7 @@ export interface RouterGoOptions {
190
195
  replace?: boolean;
191
196
  match?: TransitionOptions;
192
197
  params?: Record<string, string>;
198
+ query?: Record<string, string>;
193
199
  }
194
200
 
195
201
  export interface ReactHydrationState {
@@ -22,6 +22,7 @@ import {
22
22
  $page,
23
23
  type PageDescriptorRenderOptions,
24
24
  } from "../descriptors/$page.ts";
25
+ import { Redirection } from "../errors/Redirection.ts";
25
26
  import {
26
27
  PageDescriptorProvider,
27
28
  type PageReactContext,
@@ -231,6 +232,10 @@ export class ReactServerProvider {
231
232
  options.hydration,
232
233
  );
233
234
 
235
+ if (html instanceof Redirection) {
236
+ throw new Error("Redirection is not supported in this context");
237
+ }
238
+
234
239
  const result = {
235
240
  context,
236
241
  state,
@@ -254,6 +259,10 @@ export class ReactServerProvider {
254
259
  throw new Error("Template not found");
255
260
  }
256
261
 
262
+ this.log.trace("Rendering page", {
263
+ name: page.name,
264
+ });
265
+
257
266
  const context: PageRequest = {
258
267
  url,
259
268
  params,
@@ -300,6 +309,11 @@ export class ReactServerProvider {
300
309
  // return;
301
310
  // }
302
311
 
312
+ await this.alepha.emit("react:transition:begin", {
313
+ request: serverRequest,
314
+ context,
315
+ });
316
+
303
317
  await this.alepha.emit("react:server:render:begin", {
304
318
  request: serverRequest,
305
319
  context,
@@ -333,17 +347,31 @@ export class ReactServerProvider {
333
347
  }
334
348
 
335
349
  const html = this.renderToHtml(template, state, context);
350
+ if (html instanceof Redirection) {
351
+ reply.redirect(
352
+ typeof html.page === "string"
353
+ ? html.page
354
+ : this.pageDescriptorProvider.href(html.page),
355
+ );
356
+ return;
357
+ }
336
358
 
337
- await this.alepha.emit("react:server:render:end", {
359
+ const event = {
338
360
  request: serverRequest,
339
361
  context,
340
362
  state,
341
363
  html,
342
- });
364
+ };
365
+
366
+ await this.alepha.emit("react:server:render:end", event);
343
367
 
344
- page.afterHandler?.(serverRequest);
368
+ page.onServerResponse?.(serverRequest);
369
+
370
+ this.log.trace("Page rendered", {
371
+ name: page.name,
372
+ });
345
373
 
346
- return html;
374
+ return event.html;
347
375
  };
348
376
  }
349
377
 
@@ -352,7 +380,7 @@ export class ReactServerProvider {
352
380
  state: RouterState,
353
381
  context: PageReactContext,
354
382
  hydration = true,
355
- ) {
383
+ ): string | Redirection {
356
384
  const element = this.pageDescriptorProvider.root(state, context);
357
385
 
358
386
  this.serverTimingProvider.beginTiming("renderToString");
@@ -362,7 +390,13 @@ export class ReactServerProvider {
362
390
  app = renderToString(element);
363
391
  } catch (error) {
364
392
  this.log.error("Error during SSR", error);
365
- app = renderToString(context.onError(error as Error));
393
+ const element = context.onError(error as Error, context);
394
+ if (element instanceof Redirection) {
395
+ // if the error is a redirection, return the redirection URL
396
+ return element;
397
+ }
398
+
399
+ app = renderToString(element);
366
400
  }
367
401
 
368
402
  this.serverTimingProvider.endTiming("renderToString");
@@ -372,8 +406,11 @@ export class ReactServerProvider {
372
406
  };
373
407
 
374
408
  if (hydration) {
409
+ const { request, context, ...rest } =
410
+ this.alepha.context.als?.getStore() ?? {};
411
+
375
412
  const hydrationData: ReactHydrationState = {
376
- links: context.links,
413
+ ...rest,
377
414
  layers: state.layers.map((it) => ({
378
415
  ...it,
379
416
  error: it.error
@@ -381,7 +418,7 @@ export class ReactServerProvider {
381
418
  ...it.error,
382
419
  name: it.error.name,
383
420
  message: it.error.message,
384
- stack: it.error.stack, // TODO: Hide stack in production ?
421
+ stack: !this.alepha.isProduction() ? it.error.stack : undefined,
385
422
  }
386
423
  : undefined,
387
424
  index: undefined,
@@ -418,7 +455,7 @@ export class ReactServerProvider {
418
455
  const bodyOpenTag = /<body([^>]*)>/i;
419
456
  if (bodyOpenTag.test(response.html)) {
420
457
  response.html = response.html.replace(bodyOpenTag, (match) => {
421
- return `${match}\n<div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
458
+ return `${match}<div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
422
459
  });
423
460
  }
424
461
  }
@@ -427,7 +464,7 @@ export class ReactServerProvider {
427
464
  if (bodyCloseTagRegex.test(response.html)) {
428
465
  response.html = response.html.replace(
429
466
  bodyCloseTagRegex,
430
- `${script}\n</body>`,
467
+ `${script}</body>`,
431
468
  );
432
469
  }
433
470
  }