@alepha/react 0.9.2 → 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.
@@ -18,10 +18,12 @@ import { RouterContext } from "../contexts/RouterContext.ts";
18
18
  import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
19
19
  import {
20
20
  $page,
21
+ type ErrorHandler,
21
22
  type PageDescriptor,
22
23
  type PageDescriptorOptions,
23
24
  } from "../descriptors/$page.ts";
24
- import { RedirectionError } from "../errors/RedirectionError.ts";
25
+ import { Redirection } from "../errors/Redirection.ts";
26
+ import type { HrefLike } from "../hooks/RouterHookApi.ts";
25
27
 
26
28
  const envSchema = t.object({
27
29
  REACT_STRICT_MODE: t.boolean({ default: true }),
@@ -51,10 +53,13 @@ export class PageDescriptorProvider {
51
53
  throw new Error(`Page ${name} not found`);
52
54
  }
53
55
 
54
- public url(
56
+ public pathname(
55
57
  name: string,
56
- options: { params?: Record<string, string>; base?: string } = {},
57
- ): URL {
58
+ options: {
59
+ params?: Record<string, string>;
60
+ query?: Record<string, string>;
61
+ } = {},
62
+ ) {
58
63
  const page = this.page(name);
59
64
  if (!page) {
60
65
  throw new Error(`Page ${name} not found`);
@@ -69,8 +74,23 @@ export class PageDescriptorProvider {
69
74
 
70
75
  url = this.compile(url, options.params ?? {});
71
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 {
72
91
  return new URL(
73
- url.replace(/\/\/+/g, "/") || "/",
92
+ this.pathname(name, options),
93
+ // use provided base or default to http://localhost
74
94
  options.base ?? `http://localhost`,
75
95
  );
76
96
  }
@@ -200,13 +220,11 @@ export class PageDescriptorProvider {
200
220
  };
201
221
  } catch (e) {
202
222
  // check if we need to redirect
203
- if (e instanceof RedirectionError) {
204
- return {
205
- layers: [],
206
- redirect: typeof e.page === "string" ? e.page : this.href(e.page),
223
+ if (e instanceof Redirection) {
224
+ return this.createRedirectionLayer(e.page, {
207
225
  pathname,
208
226
  search,
209
- };
227
+ });
210
228
  }
211
229
 
212
230
  this.log.error(e);
@@ -231,28 +249,56 @@ export class PageDescriptorProvider {
231
249
  const path = acc.replace(/\/+/, "/");
232
250
  const localErrorHandler = this.getErrorHandler(it.route);
233
251
  if (localErrorHandler) {
234
- 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
+ };
235
261
  }
236
262
 
237
263
  // handler has thrown an error, render an error view
238
264
  if (it.error) {
239
- let element: ReactNode = await request.onError(it.error);
240
- if (element === null) {
241
- element = this.renderError(it.error);
242
- }
265
+ try {
266
+ let element: ReactNode | Redirection | undefined =
267
+ await request.onError(it.error, request);
243
268
 
244
- layers.push({
245
- props,
246
- error: it.error,
247
- name: it.route.name,
248
- part: it.route.path,
249
- config: it.config,
250
- element: this.renderView(i + 1, path, element, it.route),
251
- index: i + 1,
252
- path,
253
- route: it.route,
254
- });
255
- 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
+ }
256
302
  }
257
303
 
258
304
  // normal use case
@@ -278,7 +324,22 @@ export class PageDescriptorProvider {
278
324
  return { layers, pathname, search };
279
325
  }
280
326
 
281
- 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 {
282
343
  if (route.errorHandler) return route.errorHandler;
283
344
  let parent = route.parent;
284
345
  while (parent) {
@@ -374,6 +435,10 @@ export class PageDescriptorProvider {
374
435
  const pages = this.alepha.descriptors($page);
375
436
 
376
437
  const hasParent = (it: PageDescriptor) => {
438
+ if (it.options.parent) {
439
+ return true;
440
+ }
441
+
377
442
  for (const page of pages) {
378
443
  const children = page.options.children
379
444
  ? Array.isArray(page.options.children)
@@ -406,7 +471,7 @@ export class PageDescriptorProvider {
406
471
  name: "notFound",
407
472
  cache: true,
408
473
  component: NotFoundPage,
409
- afterHandler: ({ reply }) => {
474
+ onServerResponse: ({ reply }) => {
410
475
  reply.status = 404;
411
476
  },
412
477
  });
@@ -424,6 +489,18 @@ export class PageDescriptorProvider {
424
489
  : target.options.children()
425
490
  : [];
426
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
+
427
504
  return {
428
505
  ...target.options,
429
506
  name: target.name,
@@ -521,7 +598,7 @@ export type PreviousLayerData = Omit<Layer, "element" | "index" | "path">;
521
598
 
522
599
  export interface AnchorProps {
523
600
  href: string;
524
- onClick: (ev: any) => any;
601
+ onClick: (ev?: any) => any;
525
602
  }
526
603
 
527
604
  export interface RouterState {
@@ -568,6 +645,9 @@ export interface CreateLayersResult extends RouterState {
568
645
  */
569
646
  export interface PageReactContext {
570
647
  url: URL;
571
- onError: (error: Error) => ReactNode;
648
+ onError: ErrorHandler;
572
649
  links?: ApiLinksResponse;
650
+
651
+ params: Record<string, any>;
652
+ query: Record<string, string>;
573
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
  }