@ripple-ts/vite-plugin 0.3.64 → 0.3.66

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/src/routes.js CHANGED
@@ -5,6 +5,56 @@
5
5
  * @typedef {import('@ripple-ts/vite-plugin').ServerRouteOptions} ServerRouteOptions
6
6
  */
7
7
 
8
+ /**
9
+ * @typedef {string | readonly [string, string]} RenderRouteEntry
10
+ */
11
+
12
+ /**
13
+ * @param {RenderRouteEntry | undefined} entry
14
+ * @returns {string | undefined}
15
+ */
16
+ export function get_route_entry_path(entry) {
17
+ return typeof entry === 'string' ? entry : entry?.[1];
18
+ }
19
+
20
+ /**
21
+ * @param {RenderRouteEntry | undefined} entry
22
+ * @returns {string | undefined}
23
+ */
24
+ export function get_route_entry_export_name(entry) {
25
+ return typeof entry === 'string' ? undefined : entry?.[0];
26
+ }
27
+
28
+ /**
29
+ * @param {RenderRouteEntry | undefined} entry
30
+ * @returns {string | undefined}
31
+ */
32
+ export function get_route_entry_id(entry) {
33
+ const path = get_route_entry_path(entry);
34
+ const export_name = get_route_entry_export_name(entry);
35
+ return path && export_name ? `${path}#${export_name}` : path;
36
+ }
37
+
38
+ /**
39
+ * @param {Record<string, unknown>} module
40
+ * @param {string | undefined} export_name
41
+ * @returns {Function | null}
42
+ */
43
+ export function get_component_export(module, export_name) {
44
+ if (export_name && typeof module[export_name] === 'function') {
45
+ return module[export_name];
46
+ }
47
+ if (typeof module.default === 'function') {
48
+ return module.default;
49
+ }
50
+ for (const [key, value] of Object.entries(module)) {
51
+ if (typeof value === 'function' && /^[A-Z]/.test(key)) {
52
+ return value;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
8
58
  /**
9
59
  * Route for rendering Ripple components with SSR
10
60
  */
@@ -15,7 +65,7 @@ export class RenderRoute {
15
65
  /** @type {string} */
16
66
  path;
17
67
 
18
- /** @type {string} */
68
+ /** @type {RenderRouteEntry | undefined} */
19
69
  entry;
20
70
 
21
71
  /** @type {string | undefined} */
@@ -28,6 +78,10 @@ export class RenderRoute {
28
78
  * @param {RenderRouteOptions} options
29
79
  */
30
80
  constructor(options) {
81
+ if (!options.entry) {
82
+ throw new Error('RenderRoute requires an `entry`.');
83
+ }
84
+
31
85
  this.path = options.path;
32
86
  this.entry = options.entry;
33
87
  this.layout = options.layout;
@@ -12,6 +12,7 @@
12
12
  import { createRouter } from './router.js';
13
13
  import { createContext, runMiddlewareChain } from './middleware.js';
14
14
  import { createLayoutWrapper, createPropsWrapper } from './component-wrappers.js';
15
+ import { get_route_entry_id, get_route_entry_path } from '../routes.js';
15
16
  import {
16
17
  patch_global_fetch,
17
18
  build_rpc_lookup,
@@ -135,7 +136,7 @@ export function createHandler(manifest, options) {
135
136
  * @param {import('@ripple-ts/vite-plugin').Context} context
136
137
  * @param {ServerManifest} manifest
137
138
  * @param {Middleware[]} globalMiddlewares
138
- * @param {(component: Function) => Promise<RenderResult>} render
139
+ * @param {(component: Function, options?: { rootBoundary?: import('@ripple-ts/vite-plugin').RootBoundaryOptions }) => Promise<RenderResult>} render
139
140
  * @param {(css: Set<string>) => string} getCss
140
141
  * @param {string} htmlTemplate
141
142
  * @param {Record<string, ClientAssetEntry>} clientAssets
@@ -153,9 +154,11 @@ async function handleRenderRoute(
153
154
  ) {
154
155
  const renderHandler = async () => {
155
156
  // Get the page component
156
- const PageComponent = manifest.components[route.entry];
157
+ const entryId = get_route_entry_id(route.entry);
158
+ const entryPath = get_route_entry_path(route.entry);
159
+ const PageComponent = entryId ? manifest.components[entryId] : null;
157
160
  if (!PageComponent) {
158
- throw new Error(`Component not found: ${route.entry}`);
161
+ throw new Error(`Component not found for route ${route.path}`);
159
162
  }
160
163
 
161
164
  // Get layout if specified
@@ -170,7 +173,9 @@ async function handleRenderRoute(
170
173
  }
171
174
 
172
175
  // Render to HTML
173
- const { head, body, css } = await render(RootComponent);
176
+ const { head, body, css } = await render(RootComponent, {
177
+ rootBoundary: manifest.rootBoundary,
178
+ });
174
179
 
175
180
  // Generate inline scoped CSS (from SSR-rendered component hashes)
176
181
  let cssContent = '';
@@ -186,7 +191,7 @@ async function handleRenderRoute(
186
191
  // immediately, before the hydration script executes.
187
192
  /** @type {string[]} */
188
193
  const preloadTags = [];
189
- const entryAssets = clientAssets[route.entry];
194
+ const entryAssets = entryPath ? clientAssets[entryPath] : undefined;
190
195
 
191
196
  if (entryAssets?.css) {
192
197
  for (const cssFile of entryAssets.css) {
@@ -205,7 +210,8 @@ async function handleRenderRoute(
205
210
 
206
211
  // Build head content with hydration data
207
212
  const routeData = JSON.stringify({
208
- entry: route.entry,
213
+ entry: entryPath,
214
+ routeIndex: getRenderRouteIndex(manifest.routes, route),
209
215
  params: context.params,
210
216
  });
211
217
  const headContent = [
@@ -231,6 +237,17 @@ async function handleRenderRoute(
231
237
  return runMiddlewareChain(context, globalMiddlewares, route.before || [], renderHandler, []);
232
238
  }
233
239
 
240
+ /**
241
+ * @param {Route[]} routes
242
+ * @param {RenderRoute} route
243
+ * @returns {number | undefined}
244
+ */
245
+ function getRenderRouteIndex(routes, route) {
246
+ const renderRoutes = routes.filter((r) => r.type === 'render');
247
+ const index = renderRoutes.indexOf(route);
248
+ return index === -1 ? undefined : index;
249
+ }
250
+
234
251
  // ============================================================================
235
252
  // Server routes
236
253
  // ============================================================================
@@ -2,10 +2,16 @@
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { createLayoutWrapper, createPropsWrapper } from './component-wrappers.js';
5
+ import {
6
+ get_component_export,
7
+ get_route_entry_export_name,
8
+ get_route_entry_path,
9
+ } from '../routes.js';
5
10
 
6
11
  /**
7
12
  * @typedef {import('@ripple-ts/vite-plugin').Context} Context
8
13
  * @typedef {import('@ripple-ts/vite-plugin').RenderRoute} RenderRoute
14
+ * @typedef {import('@ripple-ts/vite-plugin').ResolvedRippleConfig} ResolvedRippleConfig
9
15
  * @typedef {import('vite').ViteDevServer} ViteDevServer
10
16
  */
11
17
 
@@ -22,9 +28,10 @@ import { createLayoutWrapper, createPropsWrapper } from './component-wrappers.js
22
28
  * @param {RenderRoute} route
23
29
  * @param {Context} context
24
30
  * @param {ViteDevServer} vite
31
+ * @param {ResolvedRippleConfig} [rippleConfig]
25
32
  * @returns {Promise<Response>}
26
33
  */
27
- export async function handleRenderRoute(route, context, vite) {
34
+ export async function handleRenderRoute(route, context, vite, rippleConfig) {
28
35
  try {
29
36
  // Initialize so the server can register
30
37
  // RPC functions from `module server` declarations during SSR module loading
@@ -36,11 +43,15 @@ export async function handleRenderRoute(route, context, vite) {
36
43
  const { render, get_css_for_hashes } = await vite.ssrLoadModule('ripple/server');
37
44
 
38
45
  // Load the page component
39
- const pageModule = await vite.ssrLoadModule(route.entry);
40
- const PageComponent = getDefaultExport(pageModule);
46
+ const entryPath = get_route_entry_path(route.entry);
47
+ const pageModule = await vite.ssrLoadModule(/** @type {string} */ (entryPath));
48
+ const PageComponent = get_component_export(
49
+ pageModule,
50
+ get_route_entry_export_name(route.entry),
51
+ );
41
52
 
42
53
  if (!PageComponent) {
43
- throw new Error(`No default export found in ${route.entry}`);
54
+ throw new Error(`No component found for route ${route.path}`);
44
55
  }
45
56
 
46
57
  // Build the component tree (with optional layout)
@@ -50,7 +61,7 @@ export async function handleRenderRoute(route, context, vite) {
50
61
  if (route.layout) {
51
62
  // Load layout component
52
63
  const layoutModule = await vite.ssrLoadModule(route.layout);
53
- const LayoutComponent = getDefaultExport(layoutModule);
64
+ const LayoutComponent = get_component_export(layoutModule, undefined);
54
65
 
55
66
  if (!LayoutComponent) {
56
67
  throw new Error(`No default export found in ${route.layout}`);
@@ -66,7 +77,9 @@ export async function handleRenderRoute(route, context, vite) {
66
77
 
67
78
  // Render to HTML
68
79
  /** @type {RenderResult} */
69
- const { head, body, css } = await render(RootComponent);
80
+ const { head, body, css } = await render(RootComponent, {
81
+ rootBoundary: rippleConfig?.rootBoundary,
82
+ });
70
83
 
71
84
  // Generate CSS tags
72
85
  let cssContent = '';
@@ -79,7 +92,8 @@ export async function handleRenderRoute(route, context, vite) {
79
92
 
80
93
  // Build head content with hydration data
81
94
  const routeData = JSON.stringify({
82
- entry: route.entry,
95
+ entry: entryPath,
96
+ routeIndex: getRenderRouteIndex(rippleConfig, route),
83
97
  params: context.params,
84
98
  });
85
99
  const headContent = [
@@ -124,23 +138,17 @@ export async function handleRenderRoute(route, context, vite) {
124
138
  }
125
139
 
126
140
  /**
127
- * Get the default export from a module
128
- * Handles both `export default` and `export { X as default }`
129
- *
130
- * @param {Record<string, unknown>} module
131
- * @returns {Function | null}
141
+ * @param {ResolvedRippleConfig | undefined} config
142
+ * @param {RenderRoute} route
143
+ * @returns {number | undefined}
132
144
  */
133
- function getDefaultExport(module) {
134
- if (typeof module.default === 'function') {
135
- return module.default;
136
- }
137
- // Look for a component-like export (capitalized function)
138
- for (const [key, value] of Object.entries(module)) {
139
- if (typeof value === 'function' && /^[A-Z]/.test(key)) {
140
- return value;
141
- }
145
+ function getRenderRouteIndex(config, route) {
146
+ if (!config) {
147
+ return undefined;
142
148
  }
143
- return null;
149
+ var renderRoutes = config.router.routes.filter((r) => r.type === 'render');
150
+ var index = renderRoutes.indexOf(route);
151
+ return index === -1 ? undefined : index;
144
152
  }
145
153
 
146
154
  /**
@@ -10,6 +10,12 @@
10
10
 
11
11
  /** @import { Route } from '@ripple-ts/vite-plugin' */
12
12
 
13
+ import {
14
+ get_route_entry_export_name,
15
+ get_route_entry_id,
16
+ get_route_entry_path,
17
+ } from '../routes.js';
18
+
13
19
  /**
14
20
  * @typedef {Object} ClientAssetEntry
15
21
  * @property {string} js - Path to the built JS file
@@ -63,10 +69,11 @@ export function generateServerEntry(options) {
63
69
 
64
70
  for (const route of routes) {
65
71
  if (route.type === 'render') {
66
- if (!component_imports.has(route.entry)) {
67
- component_imports.set(route.entry, `_page_${component_index++}`);
72
+ const entryPath = get_route_entry_path(route.entry);
73
+ if (entryPath && !component_imports.has(entryPath)) {
74
+ component_imports.set(entryPath, `_page_${component_index++}`);
68
75
  }
69
- if (route.layout && !layout_imports.has(route.layout)) {
76
+ if (typeof route.layout === 'string' && !layout_imports.has(route.layout)) {
70
77
  layout_imports.set(route.layout, `_layout_${layout_index++}`);
71
78
  }
72
79
  }
@@ -95,12 +102,25 @@ export function generateServerEntry(options) {
95
102
 
96
103
  // --- Dynamic map entries ---
97
104
 
98
- const component_entries = [...component_imports]
99
- .map(([entry, varName]) => ` ${JSON.stringify(entry)}: getDefaultExport(${varName}),`)
105
+ const component_entries = routes
106
+ .filter((route) => route.type === 'render')
107
+ .map((route) => {
108
+ const entryId = get_route_entry_id(route.entry);
109
+ const entryPath = get_route_entry_path(route.entry);
110
+ const exportName = get_route_entry_export_name(route.entry);
111
+ const varName = entryPath ? component_imports.get(entryPath) : undefined;
112
+
113
+ if (!entryId || !varName) {
114
+ return null;
115
+ }
116
+
117
+ return ` ${JSON.stringify(entryId)}: getComponentExport(${varName}, ${JSON.stringify(exportName)}),`;
118
+ })
119
+ .filter(Boolean)
100
120
  .join('\n');
101
121
 
102
122
  const layout_entries = [...layout_imports]
103
- .map(([layout, varName]) => ` ${JSON.stringify(layout)}: getDefaultExport(${varName}),`)
123
+ .map(([layout, varName]) => ` ${JSON.stringify(layout)}: getComponentExport(${varName}),`)
104
124
  .join('\n');
105
125
 
106
126
  // Only check _$_server_$_ on modules known to have `module server` declarations.
@@ -142,7 +162,8 @@ try {
142
162
  process.exit(1);
143
163
  }
144
164
 
145
- function getDefaultExport(mod) {
165
+ function getComponentExport(mod, exportName) {
166
+ if (exportName && typeof mod[exportName] === 'function') return mod[exportName];
146
167
  if (typeof mod.default === 'function') return mod.default;
147
168
  for (const [key, value] of Object.entries(mod)) {
148
169
  if (typeof value === 'function' && /^[A-Z]/.test(key)) return value;
@@ -178,6 +199,7 @@ const handler = createHandler(
178
199
  middlewares: rippleConfig.middlewares,
179
200
  rpcModules,
180
201
  trustProxy: rippleConfig.server.trustProxy,
202
+ rootBoundary: rippleConfig.rootBoundary,
181
203
  runtime: rippleConfig.adapter.runtime,
182
204
  clientAssets,
183
205
  },
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { create_client_entry_source } from '../src/project-codegen.js';
3
+ import { generateServerEntry } from '../src/server/virtual-entry.js';
4
+
5
+ describe('project codegen', () => {
6
+ it('generates a client entry that resolves route entries from ripple.config.ts', () => {
7
+ const source = create_client_entry_source({
8
+ configPath: '/project/ripple.config.ts',
9
+ staticEntries: ['/src/pages/index.tsrx'],
10
+ });
11
+
12
+ expect(source).toContain('import rippleConfig from "/project/ripple.config.ts";');
13
+ expect(source).toContain('"/src/pages/index.tsrx": () => import("/src/pages/index.tsrx")');
14
+ expect(source).not.toContain('route?.component');
15
+ expect(source).toContain('function getRouteEntryPath(entry)');
16
+ expect(source).toContain('return Array.isArray(entry) ? entry[1] : entry;');
17
+ expect(source).toContain('function getRouteEntryExportName(entry)');
18
+ expect(source).toContain('return Array.isArray(entry) ? entry[0] : undefined;');
19
+ expect(source).toContain('const rootBoundary = rippleConfig.rootBoundary;');
20
+ expect(source).toContain('rootBoundary,');
21
+ });
22
+
23
+ it('generates server components from named entry tuples', () => {
24
+ const source = generateServerEntry({
25
+ routes: [
26
+ {
27
+ type: 'render',
28
+ path: '/docs/guide/dom-refs',
29
+ entry: ['DomRefsPage', '/src/pages/docs/guide/dom-refs.tsrx'],
30
+ before: [],
31
+ },
32
+ ],
33
+ rippleConfigPath: '/project/ripple.config.ts',
34
+ htmlTemplatePath: './index.html',
35
+ });
36
+
37
+ expect(source).toContain('import * as _page_0 from "/src/pages/docs/guide/dom-refs.tsrx";');
38
+ expect(source).toContain(
39
+ '"/src/pages/docs/guide/dom-refs.tsrx#DomRefsPage": getComponentExport(_page_0, "DomRefsPage"),',
40
+ );
41
+ });
42
+ });
@@ -5,6 +5,19 @@ import path from 'node:path';
5
5
  import { createHandler } from '../src/server/production.js';
6
6
  import { handleRenderRoute } from '../src/server/render-route.js';
7
7
  import { createLayoutWrapper, createPropsWrapper } from '../src/server/component-wrappers.js';
8
+ import { RenderRoute } from '../src/routes.js';
9
+
10
+ /**
11
+ * @param {string} html
12
+ * @returns {any}
13
+ */
14
+ function parseRippleData(html) {
15
+ const match = html.match(/<script id="__ripple_data" type="application\/json">([^<]+)<\/script>/);
16
+ if (!match) {
17
+ throw new Error('Missing __ripple_data script');
18
+ }
19
+ return JSON.parse(match[1]);
20
+ }
8
21
 
9
22
  function createRuntime() {
10
23
  return {
@@ -29,6 +42,17 @@ function createHandlerOptions() {
29
42
  }
30
43
 
31
44
  describe('render route SSR props', () => {
45
+ it('allows render routes to prefer a named component export from an entry tuple', () => {
46
+ const route = new RenderRoute({
47
+ path: '/docs/guide/dom-refs',
48
+ entry: ['DomRefsPage', '/src/pages/docs/guide/dom-refs.tsrx'],
49
+ });
50
+
51
+ expect(route.entry).toEqual(['DomRefsPage', '/src/pages/docs/guide/dom-refs.tsrx']);
52
+ expect(route.before).toEqual([]);
53
+ expect(() => new RenderRoute({ path: '/missing' })).toThrow('RenderRoute requires an `entry`.');
54
+ });
55
+
32
56
  it('passes injected props as the first SSR component argument', () => {
33
57
  const Component = vi.fn();
34
58
  const Wrapped = createPropsWrapper(Component, {
@@ -79,6 +103,97 @@ describe('render route SSR props', () => {
79
103
  });
80
104
  });
81
105
 
106
+ it('passes route params to production render route components from named entry tuples', async () => {
107
+ let pageProps;
108
+ function Page(props) {
109
+ pageProps = props;
110
+ }
111
+
112
+ const handler = createHandler(
113
+ {
114
+ routes: [
115
+ {
116
+ type: 'render',
117
+ path: '/tool/:slug',
118
+ entry: ['NamedToolPage', '/src/ToolPage.tsrx'],
119
+ before: [],
120
+ },
121
+ ],
122
+ components: { '/src/ToolPage.tsrx#NamedToolPage': Page },
123
+ layouts: {},
124
+ middlewares: [],
125
+ runtime: createRuntime(),
126
+ },
127
+ createHandlerOptions(),
128
+ );
129
+
130
+ const response = await handler(new Request('https://example.test/tool/echo-api'));
131
+ const html = await response.text();
132
+
133
+ expect(response.status).toBe(200);
134
+ expect(pageProps).toEqual({
135
+ fromRender: true,
136
+ params: { slug: 'echo-api' },
137
+ });
138
+ expect(parseRippleData(html)).toEqual({
139
+ entry: '/src/ToolPage.tsrx',
140
+ routeIndex: 0,
141
+ params: { slug: 'echo-api' },
142
+ });
143
+ });
144
+
145
+ it('passes route params and root boundary to production render route components', async () => {
146
+ let pageProps;
147
+ let renderOptions;
148
+ function Page(props) {
149
+ pageProps = props;
150
+ }
151
+ const rootBoundary = {
152
+ pending() {},
153
+ catch() {},
154
+ };
155
+ const route = {
156
+ type: 'render',
157
+ path: '/tool/:slug',
158
+ entry: '/src/ToolPage.tsrx',
159
+ before: [],
160
+ };
161
+
162
+ const handler = createHandler(
163
+ {
164
+ routes: [route],
165
+ components: { '/src/ToolPage.tsrx': Page },
166
+ layouts: {},
167
+ middlewares: [],
168
+ runtime: createRuntime(),
169
+ rootBoundary,
170
+ },
171
+ {
172
+ ...createHandlerOptions(),
173
+ render: async (Component, options) => {
174
+ renderOptions = options;
175
+ Component({ fromRender: true });
176
+ return { head: '', body: '<div>ok</div>', css: new Set() };
177
+ },
178
+ },
179
+ );
180
+
181
+ const response = await handler(new Request('https://example.test/tool/echo-api'));
182
+ const html = await response.text();
183
+
184
+ expect(response.status).toBe(200);
185
+ expect(pageProps).toEqual({
186
+ fromRender: true,
187
+ params: { slug: 'echo-api' },
188
+ });
189
+ expect(renderOptions).toEqual({ rootBoundary });
190
+ expect(parseRippleData(html)).toEqual({
191
+ entry: '/src/ToolPage.tsrx',
192
+ routeIndex: 0,
193
+ params: { slug: 'echo-api' },
194
+ });
195
+ });
196
+
82
197
  it('passes route params to dev render route components', async () => {
83
198
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripple-render-route-'));
84
199
  fs.writeFileSync(
@@ -136,6 +251,147 @@ describe('render route SSR props', () => {
136
251
  }
137
252
  });
138
253
 
254
+ it('uses the named export from a dev render route entry tuple', async () => {
255
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripple-render-route-'));
256
+ fs.writeFileSync(
257
+ path.join(tmpDir, 'index.html'),
258
+ '<html><head><!--ssr-head--></head><body><div id="root"><!--ssr-body--></div></body></html>',
259
+ );
260
+
261
+ let pageProps;
262
+ function Page(props) {
263
+ pageProps = props;
264
+ }
265
+ function WrongPage() {
266
+ throw new Error('default export should not be used');
267
+ }
268
+
269
+ const vite = {
270
+ config: { root: tmpDir },
271
+ transformIndexHtml: vi.fn(async (_pathname, html) => html),
272
+ ssrLoadModule: vi.fn(async (id) => {
273
+ if (id === 'ripple/server') {
274
+ const { render } = createHandlerOptions();
275
+ return {
276
+ render,
277
+ get_css_for_hashes: () => '',
278
+ };
279
+ }
280
+ if (id === '/src/ToolPage.tsrx') {
281
+ return { default: WrongPage, NamedToolPage: Page };
282
+ }
283
+ throw new Error(`Unexpected module: ${id}`);
284
+ }),
285
+ };
286
+
287
+ try {
288
+ const response = await handleRenderRoute(
289
+ {
290
+ type: 'render',
291
+ path: '/tool/:slug',
292
+ entry: ['NamedToolPage', '/src/ToolPage.tsrx'],
293
+ before: [],
294
+ },
295
+ {
296
+ request: new Request('https://example.test/tool/echo-api'),
297
+ params: { slug: 'echo-api' },
298
+ url: new URL('https://example.test/tool/echo-api'),
299
+ state: new Map(),
300
+ },
301
+ vite,
302
+ );
303
+
304
+ expect(response.status).toBe(200);
305
+ expect(pageProps).toEqual({
306
+ fromRender: true,
307
+ params: { slug: 'echo-api' },
308
+ });
309
+ expect(vite.ssrLoadModule).toHaveBeenCalledWith('/src/ToolPage.tsrx');
310
+ } finally {
311
+ fs.rmSync(tmpDir, { recursive: true, force: true });
312
+ }
313
+ });
314
+
315
+ it('passes route params and root boundary to dev render route components', async () => {
316
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripple-render-route-'));
317
+ fs.writeFileSync(
318
+ path.join(tmpDir, 'index.html'),
319
+ '<html><head><!--ssr-head--></head><body><div id="root"><!--ssr-body--></div></body></html>',
320
+ );
321
+
322
+ let pageProps;
323
+ let renderOptions;
324
+ function Page(props) {
325
+ pageProps = props;
326
+ }
327
+ const rootBoundary = {
328
+ pending() {},
329
+ catch() {},
330
+ };
331
+ const route = {
332
+ type: 'render',
333
+ path: '/tool/:slug',
334
+ entry: '/src/ToolPage.tsrx',
335
+ before: [],
336
+ };
337
+
338
+ const vite = {
339
+ config: { root: tmpDir },
340
+ transformIndexHtml: vi.fn(async (_pathname, html) => html),
341
+ ssrLoadModule: vi.fn(async (id) => {
342
+ if (id === 'ripple/server') {
343
+ return {
344
+ render: async (Component, options) => {
345
+ renderOptions = options;
346
+ Component({ fromRender: true });
347
+ return { head: '', body: '<div>ok</div>', css: new Set() };
348
+ },
349
+ get_css_for_hashes: () => '',
350
+ };
351
+ }
352
+ if (id === '/src/ToolPage.tsrx') {
353
+ return { default: Page };
354
+ }
355
+ throw new Error(`Unexpected module: ${id}`);
356
+ }),
357
+ };
358
+
359
+ try {
360
+ const response = await handleRenderRoute(
361
+ route,
362
+ {
363
+ request: new Request('https://example.test/tool/echo-api'),
364
+ params: { slug: 'echo-api' },
365
+ url: new URL('https://example.test/tool/echo-api'),
366
+ state: new Map(),
367
+ },
368
+ vite,
369
+ {
370
+ router: { routes: [route] },
371
+ rootBoundary,
372
+ },
373
+ );
374
+ const html = await response.text();
375
+
376
+ expect(response.status).toBe(200);
377
+ expect(pageProps).toEqual({
378
+ fromRender: true,
379
+ params: { slug: 'echo-api' },
380
+ });
381
+ expect(renderOptions).toEqual({ rootBoundary });
382
+ expect(parseRippleData(html)).toEqual({
383
+ entry: '/src/ToolPage.tsrx',
384
+ routeIndex: 0,
385
+ params: { slug: 'echo-api' },
386
+ });
387
+ expect(vite.ssrLoadModule).toHaveBeenCalledTimes(2);
388
+ expect(vite.ssrLoadModule).toHaveBeenCalledWith('ripple/server');
389
+ expect(vite.ssrLoadModule).toHaveBeenCalledWith('/src/ToolPage.tsrx');
390
+ } finally {
391
+ fs.rmSync(tmpDir, { recursive: true, force: true });
392
+ }
393
+ });
394
+
139
395
  it('wraps layout children as a server TSRX element and passes page params through', () => {
140
396
  const Page = vi.fn();
141
397
  const Layout = vi.fn(({ children }) => {