toiljs 0.0.62 → 0.0.63

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.
@@ -29,7 +29,7 @@ import fs from 'node:fs';
29
29
  import { createRequire } from 'node:module';
30
30
  import path from 'node:path';
31
31
 
32
- import { type ComponentType, type Context, createElement, type ReactNode } from 'react';
32
+ import { type ComponentType, type Context, createElement, type ReactNode, Suspense } from 'react';
33
33
  import { renderToString } from 'react-dom/server';
34
34
  import { createServer } from 'vite';
35
35
 
@@ -75,6 +75,9 @@ export interface RouteRenderInput {
75
75
  * `renderToStaticMarkup`) because hydration needs the `<!-- -->` text-node
76
76
  * boundary markers it emits, so `hydrateRoot` can align "text + hole" runs. */
77
77
  renderToString?: typeof renderToString;
78
+ /** React's `Suspense` from the SAME instance as {@link createElement}, so the
79
+ * Suspense dehydration markers (`<!--$-->`) emitted match the client's. */
80
+ Suspense?: typeof Suspense;
78
81
  }
79
82
 
80
83
  export interface TemplateArtifacts {
@@ -88,22 +91,29 @@ export interface TemplateArtifacts {
88
91
  slotCount: number;
89
92
  }
90
93
 
91
- /** Build the route element tree: layouts (outermost first) wrapping the page,
92
- * under the loader-data provider. The Suspense/RoutePage wrappers the client
93
- * adds contribute no DOM, so this reproduces the client's markup. */
94
+ /** Build the route element tree, mirroring the client Router so `renderToString`
95
+ * emits the SAME Suspense dehydration markers (`<!--$-->`) `hydrateRoot` expects.
96
+ * The page sits under the loader-data provider inside a route `Suspense`, and EACH
97
+ * layout (outermost first) gets its own `Suspense`, exactly as `renderMatched` +
98
+ * `Router` wrap them. Without these markers the client's Suspense boundaries have
99
+ * nothing to align to and hydration regenerates the whole tree. (The ErrorBoundary
100
+ * / context wrappers the client adds emit no DOM and no markers, so they are
101
+ * omitted here.) The `Suspense` component must come from the SAME React as `h`. */
94
102
  export function assembleRouteElement(
95
103
  Page: ComponentType,
96
104
  layouts: ComponentType<{ children?: ReactNode }>[],
97
105
  loaderData: unknown,
98
106
  loaderContext: Context<unknown> | null,
99
107
  h: typeof createElement = createElement,
108
+ SuspenseComp: typeof Suspense = Suspense,
100
109
  ): ReactNode {
101
110
  let node: ReactNode = h(Page);
102
111
  if (loaderContext) {
103
112
  node = h(loaderContext.Provider, { value: loaderData }, node);
104
113
  }
114
+ node = h(SuspenseComp, { fallback: null }, node); // route Suspense (mirrors RoutePage's boundary)
105
115
  for (let i = layouts.length - 1; i >= 0; i--) {
106
- node = h(layouts[i], null, node);
116
+ node = h(SuspenseComp, { fallback: null }, h(layouts[i], null, node));
107
117
  }
108
118
  return node;
109
119
  }
@@ -136,12 +146,14 @@ function stripHoistedResourceTags(html: string): string {
136
146
  export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts {
137
147
  const h = input.createElement ?? createElement;
138
148
  const render = input.renderToString ?? renderToString;
149
+ const SuspenseComp = input.Suspense ?? Suspense;
139
150
  const element = assembleRouteElement(
140
151
  input.Page,
141
152
  input.layouts,
142
153
  input.loaderData,
143
154
  input.loaderContext,
144
155
  h,
156
+ SuspenseComp,
145
157
  );
146
158
  input.setSsrBuild(true);
147
159
  let routeHtml: string;
@@ -259,6 +271,7 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
259
271
 
260
272
  const client = (await server.ssrLoadModule('toiljs/client')) as unknown as {
261
273
  __setSsrBuild: (on: boolean) => void;
274
+ __setSsrLocation: (path: string | null) => void;
262
275
  LoaderDataContext: Context<unknown>;
263
276
  };
264
277
 
@@ -279,7 +292,10 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
279
292
  // (`useLocation` -> `useRef`) throws. (`ssrLoadModule('react')` can't be used:
280
293
  // Vite's SSR runner cannot evaluate the CJS module -> "module is not defined".)
281
294
  const appRequire = createRequire(path.join(cfg.root, 'package.json'));
282
- const react = appRequire('react') as { createElement: typeof createElement };
295
+ const react = appRequire('react') as {
296
+ createElement: typeof createElement;
297
+ Suspense: typeof Suspense;
298
+ };
283
299
  const reactDomServer = appRequire('react-dom/server') as {
284
300
  renderToString: typeof renderToString;
285
301
  };
@@ -303,8 +319,9 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
303
319
  ? await mod.loader({ params, searchParams: new URLSearchParams() })
304
320
  : undefined;
305
321
 
322
+ const rootLayout = findLayout(cfg);
306
323
  const layoutFiles = [
307
- ...(findLayout(cfg) ? [findLayout(cfg)!] : []),
324
+ ...(rootLayout ? [rootLayout] : []),
308
325
  ...findSpecialChain(cfg, r.file, 'layout', false),
309
326
  ];
310
327
  const layouts: ComponentType<{ children?: ReactNode }>[] = [];
@@ -316,6 +333,10 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
316
333
  }
317
334
 
318
335
  const name = routeTemplateName(r.pattern);
336
+ // Tell location hooks which URL this template is for, so a NavLink's active
337
+ // class / aria-current render as they will on the client at this route (else
338
+ // the `/` default mismatches on hydration). Cleared in `finally`.
339
+ client.__setSsrLocation(samplePath(r.pattern));
319
340
  const art = extractRouteTemplate({
320
341
  name,
321
342
  Page: mod.default,
@@ -326,6 +347,7 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
326
347
  shell,
327
348
  createElement: react.createElement,
328
349
  renderToString: reactDomServer.renderToString,
350
+ Suspense: react.Suspense,
329
351
  });
330
352
  rendered.push({ pattern: r.pattern, art });
331
353
  } catch (err) {
@@ -334,6 +356,8 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
334
356
  err instanceof Error ? err.message : String(err)
335
357
  }) — falls back to client rendering`,
336
358
  );
359
+ } finally {
360
+ client.__setSsrLocation(null);
337
361
  }
338
362
  }
339
363
  } finally {
@@ -440,6 +464,13 @@ function sampleParams(pattern: string): Record<string, string> {
440
464
  return params;
441
465
  }
442
466
 
467
+ /** The concrete pathname for a route pattern, dynamic segments filled with the same `sample`
468
+ * value {@link sampleParams} uses, so location-dependent markup renders consistently. Static
469
+ * routes return their pattern unchanged. */
470
+ function samplePath(pattern: string): string {
471
+ return pattern.replace(/[:*]+([A-Za-z0-9_]+)/g, 'sample');
472
+ }
473
+
443
474
  interface RouteModule {
444
475
  default: ComponentType;
445
476
  ssr?: boolean;
@@ -84,9 +84,10 @@ describe('serverArtifacts path derivation', () => {
84
84
  const a = serverArtifacts(tmp);
85
85
  expect(a.hot).toBe(join(tmp, 'build/server/release-hot.wasm'));
86
86
  expect(a.cold).toBe(join(tmp, 'build/server/release-cold.wasm'));
87
+ expect(a.stream).toBe(join(tmp, 'build/server/release-stream.wasm'));
87
88
  });
88
89
 
89
- it('honors explicit hotFile/coldFile when present', () => {
90
+ it('honors explicit hotFile/coldFile/streamFile when present', () => {
90
91
  writeFileSync(
91
92
  join(tmp, 'toilconfig.json'),
92
93
  JSON.stringify({
@@ -95,6 +96,7 @@ describe('serverArtifacts path derivation', () => {
95
96
  outFile: 'build/server/release.wasm',
96
97
  hotFile: 'out/hot.wasm',
97
98
  coldFile: 'out/cold.wasm',
99
+ streamFile: 'out/stream.wasm',
98
100
  },
99
101
  },
100
102
  }),
@@ -102,6 +104,7 @@ describe('serverArtifacts path derivation', () => {
102
104
  const a = serverArtifacts(tmp);
103
105
  expect(a.hot).toBe(join(tmp, 'out/hot.wasm'));
104
106
  expect(a.cold).toBe(join(tmp, 'out/cold.wasm'));
107
+ expect(a.stream).toBe(join(tmp, 'out/stream.wasm'));
105
108
  });
106
109
  });
107
110
 
@@ -131,30 +134,46 @@ describe('splitSurfaceFiles per-pass classification', () => {
131
134
  return rels;
132
135
  }
133
136
 
134
- it('drops daemon-only files from the hot pass and hot-only files from the cold pass', () => {
137
+ it('routes each surface to its tier (request/stream/cold) and shares plain helpers', () => {
135
138
  const rels = lay({
136
139
  'server/jobs.ts': '@daemon\nclass J { @scheduled("1s") t(): void {} }\n',
137
140
  'server/api.ts': '@rest\nclass A {}\n',
141
+ 'server/chat.ts': "@stream('chat')\nclass C {}\n",
138
142
  'server/model.ts': '@data\nclass M {}\n',
139
143
  'server/util.ts': 'export function helper(): i32 { return 1; }\n',
140
144
  });
141
145
  const split = splitSurfaceFiles(tmp, rels);
142
146
  expect(split.hasDaemon).toBe(true);
143
- // hot pass: everything except the daemon-only jobs.ts.
144
- expect(split.hot.sort()).toEqual(
145
- ['server/api.ts', 'server/model.ts', 'server/util.ts'].sort(),
146
- );
147
- // cold pass: everything except the hot-only api.ts.
148
- expect(split.cold.sort()).toEqual(
149
- ['server/jobs.ts', 'server/model.ts', 'server/util.ts'].sort(),
150
- );
147
+ expect(split.hasStream).toBe(true);
148
+ const shared = ['server/model.ts', 'server/util.ts'];
149
+ // Each surface goes to ONLY its tier; the @data/helper files are shared into all three.
150
+ expect(split.cold.sort()).toEqual(['server/jobs.ts', ...shared].sort());
151
+ expect(split.stream.sort()).toEqual(['server/chat.ts', ...shared].sort());
152
+ expect(split.request.sort()).toEqual(['server/api.ts', ...shared].sort());
153
+ });
154
+
155
+ it('routes entries by the *.stream.ts / *.daemon.ts naming and the runtime-export request entry', () => {
156
+ const RT = "export * from 'toiljs/server/runtime/exports';\n";
157
+ const rels = lay({
158
+ 'server/main.ts': RT, // runtime entry, not *.stream/.daemon -> request
159
+ 'server/main.stream.ts': RT, // *.stream.ts -> stream (not request, despite the runtime export)
160
+ 'server/main.daemon.ts': "import './daemon/Jobs';\n", // *.daemon.ts -> cold
161
+ });
162
+ const split = splitSurfaceFiles(tmp, rels);
163
+ expect(split.hasStream).toBe(true);
164
+ expect(split.hasDaemon).toBe(true);
165
+ // Each entry is routed to EXACTLY one tier (so two entries never collide on `export *`).
166
+ expect(split.request).toEqual(['server/main.ts']);
167
+ expect(split.stream).toEqual(['server/main.stream.ts']);
168
+ expect(split.cold).toEqual(['server/main.daemon.ts']);
151
169
  });
152
170
 
153
- it('keeps a file that mixes both surfaces in both passes', () => {
171
+ it('keeps a file that mixes daemon + request surfaces in both of those passes (not stream)', () => {
154
172
  const rels = lay({ 'server/both.ts': '@daemon\nclass J {}\n@rest\nclass A {}\n' });
155
173
  const split = splitSurfaceFiles(tmp, rels);
156
- expect(split.hot).toContain('server/both.ts');
157
174
  expect(split.cold).toContain('server/both.ts');
175
+ expect(split.request).toContain('server/both.ts');
176
+ expect(split.stream).not.toContain('server/both.ts');
158
177
  });
159
178
  });
160
179
 
@@ -4,13 +4,17 @@
4
4
  * renders with `renderToString`) -> splice -> `hydrateRoot` path in jsdom and
5
5
  * assert there is NO hydration mismatch and the content is present. This is the
6
6
  * failure users hit ("server rendered HTML didn't match the client"), and it
7
- * covers the three things that broke it:
7
+ * covers the things that broke it:
8
8
  * - "text + hole + text" (`Hello, <Hole>{name}</Hole>!`): needs the `<!-- -->`
9
9
  * text-boundary markers `renderToString` emits.
10
10
  * - an `<img>`, whose React 19 auto-preload `<link>` must be kept OUT of `#root`.
11
11
  * - an `<Island>`, which must be empty on the first (hydration) render.
12
+ * - the Suspense dehydration markers (`<!--$-->`): `assembleRouteElement` wraps the
13
+ * route (and each layout) in `Suspense`, so we hydrate through the SAME `Suspense`
14
+ * structure the client Router renders. Remove that wrapping and this test fails
15
+ * with "server rendered HTML didn't match the client", the exact regression.
12
16
  */
13
- import { act } from 'react';
17
+ import { act, Suspense } from 'react';
14
18
  import { hydrateRoot } from 'react-dom/client';
15
19
  import { describe, expect, it, vi } from 'vitest';
16
20
 
@@ -87,9 +91,20 @@ describe('ssr hydration (real hydrateRoot, no mismatch)', () => {
87
91
  });
88
92
  try {
89
93
  await act(async () => {
90
- hydrateRoot(rootEl, <Page />, {
91
- onRecoverableError: (e) => recoverable.push(String(e)),
92
- });
94
+ // Hydrate through the route Suspense `assembleRouteElement` emits (layouts: []
95
+ // here -> just the route boundary), so the client's Suspense markers line up
96
+ // with the server's. This is what the real client Router does.
97
+ hydrateRoot(
98
+ rootEl,
99
+ (
100
+ <Suspense fallback={null}>
101
+ <Page />
102
+ </Suspense>
103
+ ),
104
+ {
105
+ onRecoverableError: (e) => recoverable.push(String(e)),
106
+ },
107
+ );
93
108
  });
94
109
  } finally {
95
110
  spy.mockRestore();
@@ -356,10 +356,12 @@ describe('ssr build orchestration', () => {
356
356
 
357
357
  const tmpl = art.tmpl.toString('utf8');
358
358
  // Full document with the layout + page scaffold spliced into #root, holes removed.
359
- // The `<!-- -->` after `@` is React's text-boundary marker (renderToString emits
360
- // it so hydrateRoot can align the `username` hole); the hole text itself is stripped.
359
+ // `<!--$-->` / `<!--/$-->` are the Suspense dehydration markers `assembleRouteElement`
360
+ // emits by wrapping each layout AND the route in `Suspense` (mirroring the client
361
+ // Router), so `hydrateRoot` can align its Suspense boundaries. The `<!-- -->` after `@`
362
+ // is React's text-boundary marker for the `username` hole; the hole text is stripped.
361
363
  expect(tmpl).toContain(
362
- '<div id="root"><div class="app"><main><h1>@<!-- --></h1><div></div><ul></ul></main></div></div>',
364
+ '<div id="root"><!--$--><div class="app"><!--$--><main><h1>@<!-- --></h1><div></div><ul></ul></main><!--/$--></div><!--/$--></div>',
363
365
  );
364
366
  expect(tmpl).toContain('<template id="__toil_ssr"></template>');
365
367
  expect(tmpl).toContain('/assets/app-abc123.js'); // bootstrap script preserved
@@ -1,49 +0,0 @@
1
- /**
2
- * A `@stream` protocol handler mounted at `/echo`, running as a RESIDENT wasm box
3
- * per WebTransport connection on the Toil edge - distributed across the eligible
4
- * L2/L3 nodes and pinned to ONE worker for the connection's lifetime via QUIC
5
- * connection-id steering.
6
- *
7
- * The defining property of a `@stream` (vs a `@rest` handler): the box is
8
- * RESIDENT, so instance state PERSISTS across events on the same connection. Here
9
- * `count` survives every `@message` because the box is never reset between events
10
- * - unlike a `@rest` handler, which is fresh per request. On the client:
11
- *
12
- * const stream = await Server.STREAM.echo.connect();
13
- * stream.send(new TextEncoder().encode('hi'));
14
- *
15
- * Lifecycle hooks: `@connect` (open), `@message` (an inbound frame), `@close`
16
- * (graceful close), `@disconnect` (abrupt transport loss).
17
- *
18
- * NOTE: reading the inbound frame and replying is the NEXT increment (the
19
- * `StreamPacket` / `StreamOutbound` message bridge). The intended shape is:
20
- *
21
- * @message reply(packet: StreamPacket): StreamOutbound {
22
- * return StreamOutbound.reply(packet.bytes()); // echo the bytes back
23
- * }
24
- *
25
- * Until that lands, the hooks run on the connection lifecycle; this example counts
26
- * frames to demonstrate that the resident box keeps state across them.
27
- */
28
- @stream('echo')
29
- class Echo {
30
- // Resident per-connection state: survives across events (ResetMode::None).
31
- private count: i32 = 0;
32
-
33
- @connect
34
- onConnect(): void {
35
- // A fresh connection: its dedicated box starts the counter at 0.
36
- this.count = 0;
37
- }
38
-
39
- @message
40
- onMessage(): void {
41
- // Persists across frames because the box is resident, not reset per event.
42
- this.count = this.count + 1;
43
- }
44
-
45
- @close
46
- onClose(): void {
47
- // Graceful close: the per-connection box is torn down after this hook.
48
- }
49
- }