@nestjs-ssr/react 0.1.12 → 0.2.0

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.
@@ -2,7 +2,7 @@ import { Injectable, Logger, Optional, Inject, Global, Module } from '@nestjs/co
2
2
  import { HttpAdapterHost, APP_INTERCEPTOR, Reflector } from '@nestjs/core';
3
3
  import { existsSync, readFileSync } from 'fs';
4
4
  import { join, relative } from 'path';
5
- import serialize from 'serialize-javascript';
5
+ import { uneval } from 'devalue';
6
6
  import escapeHtml from 'escape-html';
7
7
  import { renderToStaticMarkup } from 'react-dom/server';
8
8
  import { createElement } from 'react';
@@ -98,21 +98,20 @@ var TemplateParserService = class {
98
98
  /**
99
99
  * Build inline script that provides initial state to the client
100
100
  *
101
- * Safely serializes data using serialize-javascript to avoid XSS vulnerabilities.
102
- * This library handles all edge cases including escaping dangerous characters,
103
- * functions, dates, regexes, and prevents prototype pollution.
101
+ * Safely serializes data using devalue to avoid XSS vulnerabilities.
102
+ * Devalue is designed specifically for SSR, handling complex types safely
103
+ * while being faster and more secure than alternatives.
104
104
  */
105
- buildInlineScripts(data, context, componentName) {
105
+ buildInlineScripts(data, context, componentName, layouts) {
106
+ const layoutMetadata = layouts ? layouts.map((l) => ({
107
+ name: l.layout.displayName || l.layout.name || "default",
108
+ props: l.props
109
+ })) : [];
106
110
  return `<script>
107
- window.__INITIAL_STATE__ = ${serialize(data, {
108
- isJSON: true
109
- })};
110
- window.__CONTEXT__ = ${serialize(context, {
111
- isJSON: true
112
- })};
113
- window.__COMPONENT_NAME__ = ${serialize(componentName, {
114
- isJSON: true
115
- })};
111
+ window.__INITIAL_STATE__ = ${uneval(data)};
112
+ window.__CONTEXT__ = ${uneval(context)};
113
+ window.__COMPONENT_NAME__ = ${uneval(componentName)};
114
+ window.__LAYOUTS__ = ${uneval(layoutMetadata)};
116
115
  </script>`;
117
116
  }
118
117
  /**
@@ -421,12 +420,14 @@ var RenderService = class _RenderService {
421
420
  isDevelopment;
422
421
  ssrMode;
423
422
  entryServerPath;
424
- constructor(templateParser, streamingErrorHandler, ssrMode, defaultHead) {
423
+ rootLayout = void 0;
424
+ rootLayoutChecked = false;
425
+ constructor(templateParser, streamingErrorHandler, ssrMode, defaultHead, customTemplate) {
425
426
  this.templateParser = templateParser;
426
427
  this.streamingErrorHandler = streamingErrorHandler;
427
428
  this.defaultHead = defaultHead;
428
429
  this.isDevelopment = process.env.NODE_ENV !== "production";
429
- this.ssrMode = ssrMode || process.env.SSR_MODE || "string";
430
+ this.ssrMode = ssrMode || process.env.SSR_MODE || "stream";
430
431
  const absoluteServerPath = join(__dirname, "/templates/entry-server.tsx");
431
432
  const relativeServerPath = relative(process.cwd(), absoluteServerPath);
432
433
  if (relativeServerPath.startsWith("..")) {
@@ -434,36 +435,54 @@ var RenderService = class _RenderService {
434
435
  } else {
435
436
  this.entryServerPath = "/" + relativeServerPath.replace(/\\/g, "/");
436
437
  }
437
- let templatePath;
438
- if (this.isDevelopment) {
439
- const packageTemplatePaths = [
440
- join(__dirname, "../templates/index.html"),
441
- join(__dirname, "../src/templates/index.html"),
442
- join(__dirname, "../../src/templates/index.html")
443
- ];
444
- const localTemplatePath = join(process.cwd(), "src/views/index.html");
445
- const foundPackageTemplate = packageTemplatePaths.find((p) => existsSync(p));
446
- if (foundPackageTemplate) {
447
- templatePath = foundPackageTemplate;
448
- } else if (existsSync(localTemplatePath)) {
449
- templatePath = localTemplatePath;
438
+ if (customTemplate) {
439
+ if (customTemplate.includes("<!DOCTYPE") || customTemplate.includes("<html")) {
440
+ this.template = customTemplate;
441
+ this.logger.log(`\u2713 Loaded custom template (inline)`);
450
442
  } else {
451
- throw new Error(`Template file not found. Tried:
443
+ const customTemplatePath = customTemplate.startsWith("/") ? customTemplate : join(process.cwd(), customTemplate);
444
+ if (!existsSync(customTemplatePath)) {
445
+ throw new Error(`Custom template file not found at ${customTemplatePath}`);
446
+ }
447
+ try {
448
+ this.template = readFileSync(customTemplatePath, "utf-8");
449
+ this.logger.log(`\u2713 Loaded custom template from ${customTemplatePath}`);
450
+ } catch (error) {
451
+ throw new Error(`Failed to read custom template file at ${customTemplatePath}: ${error.message}`);
452
+ }
453
+ }
454
+ } else {
455
+ let templatePath;
456
+ if (this.isDevelopment) {
457
+ const packageTemplatePaths = [
458
+ join(__dirname, "../templates/index.html"),
459
+ join(__dirname, "../src/templates/index.html"),
460
+ join(__dirname, "../../src/templates/index.html")
461
+ ];
462
+ const localTemplatePath = join(process.cwd(), "src/views/index.html");
463
+ const foundPackageTemplate = packageTemplatePaths.find((p) => existsSync(p));
464
+ if (foundPackageTemplate) {
465
+ templatePath = foundPackageTemplate;
466
+ } else if (existsSync(localTemplatePath)) {
467
+ templatePath = localTemplatePath;
468
+ } else {
469
+ throw new Error(`Template file not found. Tried:
452
470
  ` + packageTemplatePaths.map((p) => ` - ${p} (package template)`).join("\n") + `
453
471
  - ${localTemplatePath} (local template)`);
472
+ }
473
+ } else {
474
+ templatePath = join(process.cwd(), "dist/client/index.html");
475
+ if (!existsSync(templatePath)) {
476
+ throw new Error(`Template file not found at ${templatePath}. Make sure to run the build process first.`);
477
+ }
454
478
  }
455
- } else {
456
- templatePath = join(process.cwd(), "dist/client/index.html");
457
- if (!existsSync(templatePath)) {
458
- throw new Error(`Template file not found at ${templatePath}. Make sure to run the build process first.`);
479
+ try {
480
+ this.template = readFileSync(templatePath, "utf-8");
481
+ this.logger.log(`\u2713 Loaded template from ${templatePath}`);
482
+ } catch (error) {
483
+ throw new Error(`Failed to read template file at ${templatePath}: ${error.message}`);
459
484
  }
460
485
  }
461
- try {
462
- this.template = readFileSync(templatePath, "utf-8");
463
- this.logger.log(`\u2713 Loaded template from ${templatePath}`);
464
- } catch (error) {
465
- throw new Error(`Failed to read template file at ${templatePath}: ${error.message}`);
466
- }
467
486
  if (!this.isDevelopment) {
468
487
  const manifestPath = join(process.cwd(), "dist/client/.vite/manifest.json");
469
488
  if (existsSync(manifestPath)) {
@@ -483,6 +502,52 @@ var RenderService = class _RenderService {
483
502
  this.vite = vite;
484
503
  }
485
504
  /**
505
+ * Get the root layout component if it exists
506
+ * Auto-discovers layout files at conventional paths:
507
+ * - src/views/layout.tsx
508
+ * - src/views/layout/index.tsx
509
+ * - src/views/_layout.tsx
510
+ */
511
+ async getRootLayout() {
512
+ if (this.rootLayoutChecked) {
513
+ return this.rootLayout;
514
+ }
515
+ this.rootLayoutChecked = true;
516
+ const conventionalPaths = [
517
+ "src/views/layout.tsx",
518
+ "src/views/layout/index.tsx",
519
+ "src/views/_layout.tsx"
520
+ ];
521
+ try {
522
+ for (const path of conventionalPaths) {
523
+ const absolutePath = join(process.cwd(), path);
524
+ if (!existsSync(absolutePath)) {
525
+ continue;
526
+ }
527
+ this.logger.log(`\u2713 Found root layout at ${path}`);
528
+ if (this.vite) {
529
+ const layoutModule = await this.vite.ssrLoadModule("/" + path);
530
+ this.rootLayout = layoutModule.default;
531
+ return this.rootLayout;
532
+ } else {
533
+ const prodPath = path.replace("src/views", "dist/server/views").replace(".tsx", ".js");
534
+ const absoluteProdPath = join(process.cwd(), prodPath);
535
+ if (existsSync(absoluteProdPath)) {
536
+ const layoutModule = await import(absoluteProdPath);
537
+ this.rootLayout = layoutModule.default;
538
+ return this.rootLayout;
539
+ }
540
+ }
541
+ }
542
+ this.rootLayout = null;
543
+ return null;
544
+ } catch (error) {
545
+ this.logger.warn(`\u26A0\uFE0F Error loading root layout: ${error.message}`);
546
+ this.rootLayout = null;
547
+ return null;
548
+ }
549
+ }
550
+ /**
486
551
  * Main render method that routes to string or stream mode
487
552
  */
488
553
  async render(viewComponent, data = {}, res, head) {
@@ -544,20 +609,19 @@ var RenderService = class _RenderService {
544
609
  throw new Error("Server bundle not found in manifest. Run `pnpm build:server` to generate the server bundle.");
545
610
  }
546
611
  }
547
- const { data: pageData, __context: context } = data;
612
+ const { data: pageData, __context: context, __layouts: layouts } = data;
548
613
  const appHtml = await renderModule.renderComponent(viewComponent, data);
549
614
  const componentName = viewComponent.displayName || viewComponent.name || "Component";
615
+ const layoutMetadata = layouts ? layouts.map((l) => ({
616
+ name: l.layout.displayName || l.layout.name || "default",
617
+ props: l.props
618
+ })) : [];
550
619
  const initialStateScript = `
551
620
  <script>
552
- window.__INITIAL_STATE__ = ${serialize(pageData, {
553
- isJSON: true
554
- })};
555
- window.__CONTEXT__ = ${serialize(context, {
556
- isJSON: true
557
- })};
558
- window.__COMPONENT_NAME__ = ${serialize(componentName, {
559
- isJSON: true
560
- })};
621
+ window.__INITIAL_STATE__ = ${uneval(pageData)};
622
+ window.__CONTEXT__ = ${uneval(context)};
623
+ window.__COMPONENT_NAME__ = ${uneval(componentName)};
624
+ window.__LAYOUTS__ = ${uneval(layoutMetadata)};
561
625
  </script>
562
626
  `;
563
627
  let clientScript = "";
@@ -630,9 +694,9 @@ var RenderService = class _RenderService {
630
694
  throw new Error("Server bundle not found in manifest. Run `pnpm build:server` to generate the server bundle.");
631
695
  }
632
696
  }
633
- const { data: pageData, __context: context } = data;
697
+ const { data: pageData, __context: context, __layouts: layouts } = data;
634
698
  const componentName = viewComponent.displayName || viewComponent.name || "Component";
635
- const inlineScripts = this.templateParser.buildInlineScripts(pageData, context, componentName);
699
+ const inlineScripts = this.templateParser.buildInlineScripts(pageData, context, componentName, layouts);
636
700
  const clientScript = this.templateParser.getClientScriptTag(this.isDevelopment, this.manifest);
637
701
  const stylesheetTags = this.templateParser.getStylesheetTags(this.isDevelopment, this.manifest);
638
702
  const headTags = this.templateParser.buildHeadTags(head);
@@ -688,15 +752,20 @@ RenderService = _ts_decorate3([
688
752
  _ts_param2(2, Inject("SSR_MODE")),
689
753
  _ts_param2(3, Optional()),
690
754
  _ts_param2(3, Inject("DEFAULT_HEAD")),
755
+ _ts_param2(4, Optional()),
756
+ _ts_param2(4, Inject("CUSTOM_TEMPLATE")),
691
757
  _ts_metadata2("design:type", Function),
692
758
  _ts_metadata2("design:paramtypes", [
693
759
  typeof TemplateParserService === "undefined" ? Object : TemplateParserService,
694
760
  typeof StreamingErrorHandler === "undefined" ? Object : StreamingErrorHandler,
695
761
  typeof SSRMode === "undefined" ? Object : SSRMode,
696
- typeof HeadData === "undefined" ? Object : HeadData
762
+ typeof HeadData === "undefined" ? Object : HeadData,
763
+ String
697
764
  ])
698
765
  ], RenderService);
699
766
  var RENDER_KEY = "render";
767
+ var RENDER_OPTIONS_KEY = "render_options";
768
+ var LAYOUT_KEY = "layout";
700
769
 
701
770
  // src/render/render.interceptor.ts
702
771
  function _ts_decorate4(decorators, target, key, desc) {
@@ -710,6 +779,12 @@ function _ts_metadata3(k, v) {
710
779
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
711
780
  }
712
781
  __name(_ts_metadata3, "_ts_metadata");
782
+ function _ts_param3(paramIndex, decorator) {
783
+ return function(target, key) {
784
+ decorator(target, key, paramIndex);
785
+ };
786
+ }
787
+ __name(_ts_param3, "_ts_param");
713
788
  function isRenderResponse(data) {
714
789
  return data && typeof data === "object" && "props" in data;
715
790
  }
@@ -720,9 +795,60 @@ var RenderInterceptor = class {
720
795
  }
721
796
  reflector;
722
797
  renderService;
723
- constructor(reflector, renderService) {
798
+ allowedHeaders;
799
+ allowedCookies;
800
+ constructor(reflector, renderService, allowedHeaders, allowedCookies) {
724
801
  this.reflector = reflector;
725
802
  this.renderService = renderService;
803
+ this.allowedHeaders = allowedHeaders;
804
+ this.allowedCookies = allowedCookies;
805
+ }
806
+ /**
807
+ * Resolve the layout hierarchy for a given route
808
+ * Hierarchy: Root Layout → Controller Layout → Method Layout → Page
809
+ *
810
+ * Props are merged in priority order:
811
+ * 1. Static props from @Layout decorator (base)
812
+ * 2. Static props from @Render decorator (override)
813
+ * 3. Dynamic props from controller return (final override)
814
+ */
815
+ async resolveLayoutChain(context, dynamicLayoutProps) {
816
+ const layouts = [];
817
+ const rootLayout = await this.renderService.getRootLayout();
818
+ if (rootLayout) {
819
+ layouts.push({
820
+ layout: rootLayout,
821
+ props: dynamicLayoutProps || {}
822
+ });
823
+ }
824
+ const controllerLayoutMeta = this.reflector.get(LAYOUT_KEY, context.getClass());
825
+ const renderOptions = this.reflector.get(RENDER_OPTIONS_KEY, context.getHandler());
826
+ if (renderOptions?.layout === null) {
827
+ return [];
828
+ } else if (renderOptions?.layout === false) {
829
+ return layouts;
830
+ }
831
+ if (controllerLayoutMeta) {
832
+ const mergedProps = {
833
+ ...controllerLayoutMeta.options?.props || {},
834
+ ...dynamicLayoutProps || {}
835
+ };
836
+ layouts.push({
837
+ layout: controllerLayoutMeta.layout,
838
+ props: mergedProps
839
+ });
840
+ }
841
+ if (renderOptions?.layout) {
842
+ const mergedProps = {
843
+ ...renderOptions.layoutProps || {},
844
+ ...dynamicLayoutProps || {}
845
+ };
846
+ layouts.push({
847
+ layout: renderOptions.layout,
848
+ props: mergedProps
849
+ });
850
+ }
851
+ return layouts;
726
852
  }
727
853
  intercept(context, next) {
728
854
  const viewPathOrComponent = this.reflector.get(RENDER_KEY, context.getHandler());
@@ -738,16 +864,36 @@ var RenderInterceptor = class {
738
864
  path: request.path,
739
865
  query: request.query,
740
866
  params: request.params,
741
- userAgent: request.headers["user-agent"],
742
- acceptLanguage: request.headers["accept-language"],
743
- referer: request.headers.referer
867
+ method: request.method
744
868
  };
869
+ if (this.allowedHeaders?.length) {
870
+ for (const headerName of this.allowedHeaders) {
871
+ const value = request.headers[headerName.toLowerCase()];
872
+ if (value) {
873
+ renderContext[headerName] = Array.isArray(value) ? value.join(", ") : value;
874
+ }
875
+ }
876
+ }
877
+ if (this.allowedCookies?.length && request.cookies) {
878
+ const cookies = {};
879
+ for (const cookieName of this.allowedCookies) {
880
+ const value = request.cookies[cookieName];
881
+ if (value !== void 0) {
882
+ cookies[cookieName] = value;
883
+ }
884
+ }
885
+ if (Object.keys(cookies).length > 0) {
886
+ renderContext.cookies = cookies;
887
+ }
888
+ }
745
889
  const renderResponse = isRenderResponse(data) ? data : {
746
890
  props: data
747
891
  };
892
+ const layoutChain = await this.resolveLayoutChain(context, renderResponse.layoutProps);
748
893
  const fullData = {
749
894
  data: renderResponse.props,
750
- __context: renderContext
895
+ __context: renderContext,
896
+ __layouts: layoutChain
751
897
  };
752
898
  try {
753
899
  const html = await this.renderService.render(viewPathOrComponent, fullData, response, renderResponse.head);
@@ -764,10 +910,16 @@ var RenderInterceptor = class {
764
910
  };
765
911
  RenderInterceptor = _ts_decorate4([
766
912
  Injectable(),
913
+ _ts_param3(2, Optional()),
914
+ _ts_param3(2, Inject("ALLOWED_HEADERS")),
915
+ _ts_param3(3, Optional()),
916
+ _ts_param3(3, Inject("ALLOWED_COOKIES")),
767
917
  _ts_metadata3("design:type", Function),
768
918
  _ts_metadata3("design:paramtypes", [
769
919
  typeof Reflector === "undefined" ? Object : Reflector,
770
- typeof RenderService === "undefined" ? Object : RenderService
920
+ typeof RenderService === "undefined" ? Object : RenderService,
921
+ Array,
922
+ Array
771
923
  ])
772
924
  ], RenderInterceptor);
773
925
  function _ts_decorate5(decorators, target, key, desc) {
@@ -781,12 +933,12 @@ function _ts_metadata4(k, v) {
781
933
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
782
934
  }
783
935
  __name(_ts_metadata4, "_ts_metadata");
784
- function _ts_param3(paramIndex, decorator) {
936
+ function _ts_param4(paramIndex, decorator) {
785
937
  return function(target, key) {
786
938
  decorator(target, key, paramIndex);
787
939
  };
788
940
  }
789
- __name(_ts_param3, "_ts_param");
941
+ __name(_ts_param4, "_ts_param");
790
942
  var ViteInitializerService = class _ViteInitializerService {
791
943
  static {
792
944
  __name(this, "ViteInitializerService");
@@ -902,8 +1054,8 @@ var ViteInitializerService = class _ViteInitializerService {
902
1054
  };
903
1055
  ViteInitializerService = _ts_decorate5([
904
1056
  Injectable(),
905
- _ts_param3(2, Optional()),
906
- _ts_param3(2, Inject("VITE_CONFIG")),
1057
+ _ts_param4(2, Optional()),
1058
+ _ts_param4(2, Inject("VITE_CONFIG")),
907
1059
  _ts_metadata4("design:type", Function),
908
1060
  _ts_metadata4("design:paramtypes", [
909
1061
  typeof RenderService === "undefined" ? Object : RenderService,
@@ -996,6 +1148,20 @@ var RenderModule = class _RenderModule {
996
1148
  useValue: config.defaultHead
997
1149
  });
998
1150
  }
1151
+ if (config?.template) {
1152
+ providers.push({
1153
+ provide: "CUSTOM_TEMPLATE",
1154
+ useValue: config.template
1155
+ });
1156
+ }
1157
+ providers.push({
1158
+ provide: "ALLOWED_HEADERS",
1159
+ useValue: config?.allowedHeaders || []
1160
+ });
1161
+ providers.push({
1162
+ provide: "ALLOWED_COOKIES",
1163
+ useValue: config?.allowedCookies || []
1164
+ });
999
1165
  return {
1000
1166
  global: true,
1001
1167
  module: _RenderModule,
@@ -1088,6 +1254,30 @@ var RenderModule = class _RenderModule {
1088
1254
  inject: [
1089
1255
  "RENDER_CONFIG"
1090
1256
  ]
1257
+ },
1258
+ // Custom template provider - reads from config
1259
+ {
1260
+ provide: "CUSTOM_TEMPLATE",
1261
+ useFactory: /* @__PURE__ */ __name((config) => config?.template, "useFactory"),
1262
+ inject: [
1263
+ "RENDER_CONFIG"
1264
+ ]
1265
+ },
1266
+ // Allowed headers provider - reads from config
1267
+ {
1268
+ provide: "ALLOWED_HEADERS",
1269
+ useFactory: /* @__PURE__ */ __name((config) => config?.allowedHeaders || [], "useFactory"),
1270
+ inject: [
1271
+ "RENDER_CONFIG"
1272
+ ]
1273
+ },
1274
+ // Allowed cookies provider - reads from config
1275
+ {
1276
+ provide: "ALLOWED_COOKIES",
1277
+ useFactory: /* @__PURE__ */ __name((config) => config?.allowedCookies || [], "useFactory"),
1278
+ inject: [
1279
+ "RENDER_CONFIG"
1280
+ ]
1091
1281
  }
1092
1282
  ];
1093
1283
  return {
@@ -1,6 +1,7 @@
1
1
  /// <reference path="../global.d.ts" />
2
2
  import React, { StrictMode } from 'react';
3
3
  import { hydrateRoot } from 'react-dom/client';
4
+ import { PageContextProvider } from '../react/hooks/use-page-context';
4
5
 
5
6
  const componentName = window.__COMPONENT_NAME__;
6
7
  const initialProps = window.__INITIAL_STATE__ || {};
@@ -8,7 +9,8 @@ const renderContext = window.__CONTEXT__ || {};
8
9
 
9
10
  // Auto-import all view components using Vite's glob feature
10
11
  // @ts-ignore - Vite-specific API
11
- const modules: Record<string, { default: React.ComponentType<any> }> = import.meta.glob('@/views/**/*.tsx', { eager: true });
12
+ const modules: Record<string, { default: React.ComponentType<any> }> =
13
+ import.meta.glob('@/views/**/*.tsx', { eager: true });
12
14
 
13
15
  // Build a map of components with their metadata
14
16
  // Filter out entry files and modules without default exports
@@ -26,7 +28,9 @@ const componentMap = Object.entries(modules)
26
28
  const component = module.default;
27
29
  const name = component.displayName || component.name;
28
30
  const filename = path.split('/').pop()?.replace('.tsx', '');
29
- const normalizedFilename = filename ? filename.charAt(0).toUpperCase() + filename.slice(1) : undefined;
31
+ const normalizedFilename = filename
32
+ ? filename.charAt(0).toUpperCase() + filename.slice(1)
33
+ : undefined;
30
34
 
31
35
  return { path, component, name, filename, normalizedFilename };
32
36
  });
@@ -40,7 +44,10 @@ let ViewComponent: React.ComponentType<any> | undefined;
40
44
 
41
45
  // Try exact name match first
42
46
  ViewComponent = componentMap.find(
43
- (c) => c.name === componentName || c.normalizedFilename === componentName || c.filename === componentName.toLowerCase()
47
+ (c) =>
48
+ c.name === componentName ||
49
+ c.normalizedFilename === componentName ||
50
+ c.filename === componentName.toLowerCase(),
44
51
  )?.component;
45
52
 
46
53
  // If no match found and component name looks like a generic/minified name (default, default_1, etc.)
@@ -56,7 +63,7 @@ if (!ViewComponent && /^default(_\d+)?$/.test(componentName)) {
56
63
 
57
64
  // Get all components with name "default" (anonymous functions), sorted by path for consistency
58
65
  const defaultComponents = componentMap
59
- .filter(c => c.name === 'default')
66
+ .filter((c) => c.name === 'default')
60
67
  .sort((a, b) => a.path.localeCompare(b.path));
61
68
 
62
69
  // Try to match by index
@@ -67,17 +74,77 @@ if (!ViewComponent && /^default(_\d+)?$/.test(componentName)) {
67
74
  }
68
75
 
69
76
  if (!ViewComponent) {
70
- const availableComponents = Object.entries(modules).map(([path, m]) => {
71
- const filename = path.split('/').pop()?.replace('.tsx', '');
72
- const name = m.default.displayName || m.default.name;
73
- return `${filename} (${name})`;
74
- }).join(', ');
75
- throw new Error(`Component "${componentName}" not found in views directory. Available: ${availableComponents}`);
77
+ const availableComponents = Object.entries(modules)
78
+ .map(([path, m]) => {
79
+ const filename = path.split('/').pop()?.replace('.tsx', '');
80
+ const name = m.default.displayName || m.default.name;
81
+ return `${filename} (${name})`;
82
+ })
83
+ .join(', ');
84
+ throw new Error(
85
+ `Component "${componentName}" not found in views directory. Available: ${availableComponents}`,
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Check if a component has a layout property
91
+ */
92
+ function hasLayout(
93
+ component: any,
94
+ ): component is { layout: React.ComponentType<any>; layoutProps?: any } {
95
+ return component && typeof component.layout === 'function';
96
+ }
97
+
98
+ /**
99
+ * Compose a component with its layout (and nested layouts if any)
100
+ * This must match the server-side composition in entry-server.tsx
101
+ */
102
+ function composeWithLayout(
103
+ ViewComponent: React.ComponentType<any>,
104
+ props: any,
105
+ ): React.ReactElement {
106
+ const element = <ViewComponent {...props} />;
107
+
108
+ // Check if component has a layout
109
+ if (!hasLayout(ViewComponent)) {
110
+ return element;
111
+ }
112
+
113
+ // Collect all layouts in the chain (innermost to outermost)
114
+ const layoutChain: Array<{
115
+ Layout: React.ComponentType<any>;
116
+ layoutProps: any;
117
+ }> = [];
118
+ let currentComponent: any = ViewComponent;
119
+
120
+ while (hasLayout(currentComponent)) {
121
+ layoutChain.push({
122
+ Layout: currentComponent.layout,
123
+ layoutProps: currentComponent.layoutProps || {},
124
+ });
125
+ currentComponent = currentComponent.layout;
126
+ }
127
+
128
+ // Wrap the element with layouts from innermost to outermost
129
+ let result = element;
130
+ for (const { Layout, layoutProps } of layoutChain) {
131
+ result = <Layout layoutProps={layoutProps}>{result}</Layout>;
132
+ }
133
+
134
+ return result;
76
135
  }
77
136
 
137
+ // Compose the component with its layout (if any)
138
+ const composedElement = composeWithLayout(ViewComponent, initialProps);
139
+
140
+ // Wrap with PageContextProvider to make context available via hooks
141
+ const wrappedElement = (
142
+ <PageContextProvider context={renderContext}>
143
+ {composedElement}
144
+ </PageContextProvider>
145
+ );
146
+
78
147
  hydrateRoot(
79
148
  document.getElementById('root')!,
80
- <StrictMode>
81
- <ViewComponent {...initialProps} context={renderContext} />
82
- </StrictMode>,
149
+ <StrictMode>{wrappedElement}</StrictMode>,
83
150
  );
@@ -1,10 +1,41 @@
1
1
  import React from 'react';
2
2
  import { renderToString } from 'react-dom/server';
3
+ import { PageContextProvider } from '../react/hooks/use-page-context';
4
+
5
+ /**
6
+ * Compose a component with its layouts from the interceptor
7
+ * Layouts are passed from the RenderInterceptor based on decorators
8
+ */
9
+ function composeWithLayouts(
10
+ ViewComponent: React.ComponentType<any>,
11
+ props: any,
12
+ layouts: Array<{ layout: React.ComponentType<any>; props?: any }> = [],
13
+ ): React.ReactElement {
14
+ // Start with the page component
15
+ let result = <ViewComponent {...props} />;
16
+
17
+ // Wrap with each layout in the chain (outermost to innermost in array)
18
+ // We iterate normally because layouts are already in correct order from interceptor
19
+ for (const { layout: Layout, props: layoutProps } of layouts) {
20
+ result = <Layout layoutProps={layoutProps}>{result}</Layout>;
21
+ }
22
+
23
+ return result;
24
+ }
3
25
 
4
26
  export function renderComponent(
5
27
  ViewComponent: React.ComponentType<any>,
6
28
  data: any,
7
29
  ) {
8
- const { data: pageData, __context: context } = data;
9
- return renderToString(<ViewComponent {...pageData} context={context} />);
30
+ const { data: pageData, __context: context, __layouts: layouts } = data;
31
+ const composedElement = composeWithLayouts(ViewComponent, pageData, layouts);
32
+
33
+ // Wrap with PageContextProvider to make context available via hooks
34
+ const wrappedElement = (
35
+ <PageContextProvider context={context}>
36
+ {composedElement}
37
+ </PageContextProvider>
38
+ );
39
+
40
+ return renderToString(wrappedElement);
10
41
  }
@@ -4,9 +4,6 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>NestJS React SSR</title>
7
- <link rel="preconnect" href="https://fonts.googleapis.com" />
8
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
10
7
  <!--styles-->
11
8
  </head>
12
9
  <body>