toiljs 0.0.61 → 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.
@@ -90,15 +90,22 @@ export async function buildServer(root) {
90
90
  const binJs = resolveToilscriptBin(root);
91
91
  const files = serverEntryFiles(root);
92
92
  const split = splitSurfaceFiles(root, files);
93
- if (split.hasDaemon) {
93
+ if (split.hasDaemon || split.hasStream) {
94
94
  const artifacts = serverArtifacts(root);
95
- await runToilscriptPass(root, binJs, split.cold, {
96
- mode: 'cold',
97
- outFile: artifacts.cold,
98
- withRpc: false,
99
- });
100
- if (split.hot.length > 0)
101
- await runToilscriptPass(root, binJs, split.hot, {
95
+ if (split.hasDaemon)
96
+ await runToilscriptPass(root, binJs, split.cold, {
97
+ mode: 'cold',
98
+ outFile: artifacts.cold,
99
+ withRpc: false,
100
+ });
101
+ if (split.hasStream && split.stream.length > 0)
102
+ await runToilscriptPass(root, binJs, split.stream, {
103
+ mode: 'hot',
104
+ outFile: artifacts.stream,
105
+ withRpc: false,
106
+ });
107
+ if (split.request.length > 0)
108
+ await runToilscriptPass(root, binJs, split.request, {
102
109
  mode: 'hot',
103
110
  outFile: serverWasmFile(root),
104
111
  withRpc: true,
@@ -123,11 +130,21 @@ function resolveToilscriptBin(root) {
123
130
  }
124
131
  }
125
132
  const COLD_DECORATOR = /^[ \t]*@(daemon|scheduled)\b/m;
126
- const HOT_DECORATOR = /^[ \t]*@(rest|route|stream|service|remote)\b/m;
133
+ const STREAM_DECORATOR = /^[ \t]*@stream\b/m;
134
+ const REQUEST_DECORATOR = /^[ \t]*@(rest|route|service|remote)\b/m;
135
+ const RUNTIME_ENTRY = /from\s+['"]toiljs\/server\/runtime\/exports['"]/;
136
+ function isStreamEntryFile(rel) {
137
+ return rel.endsWith('.stream.ts');
138
+ }
139
+ function isDaemonEntryFile(rel) {
140
+ return rel.endsWith('.daemon.ts');
141
+ }
127
142
  export function splitSurfaceFiles(root, files) {
128
143
  let hasDaemon = false;
144
+ let hasStream = false;
129
145
  const cold = [];
130
- const hot = [];
146
+ const stream = [];
147
+ const request = [];
131
148
  for (const rel of files) {
132
149
  let src = '';
133
150
  try {
@@ -135,19 +152,27 @@ export function splitSurfaceFiles(root, files) {
135
152
  }
136
153
  catch {
137
154
  cold.push(rel);
138
- hot.push(rel);
155
+ stream.push(rel);
156
+ request.push(rel);
139
157
  continue;
140
158
  }
141
- const isCold = COLD_DECORATOR.test(src);
142
- const isHot = HOT_DECORATOR.test(src);
159
+ const isCold = COLD_DECORATOR.test(src) || isDaemonEntryFile(rel);
160
+ const isStream = STREAM_DECORATOR.test(src) || isStreamEntryFile(rel);
161
+ const isRequest = REQUEST_DECORATOR.test(src) ||
162
+ (RUNTIME_ENTRY.test(src) && !isStreamEntryFile(rel) && !isDaemonEntryFile(rel));
143
163
  if (isCold)
144
- hasDaemon ||= /^[ \t]*@daemon\b/m.test(src);
145
- if (!(isCold && !isHot))
146
- hot.push(rel);
147
- if (!(isHot && !isCold))
164
+ hasDaemon ||= /^[ \t]*@daemon\b/m.test(src) || isDaemonEntryFile(rel);
165
+ if (isStream)
166
+ hasStream = true;
167
+ const shared = !isCold && !isStream && !isRequest;
168
+ if (isCold || shared)
148
169
  cold.push(rel);
170
+ if (isStream || shared)
171
+ stream.push(rel);
172
+ if (isRequest || shared)
173
+ request.push(rel);
149
174
  }
150
- return { hasDaemon, cold, hot };
175
+ return { hasDaemon, hasStream, cold, stream, request };
151
176
  }
152
177
  function runToilscriptPass(root, binJs, files, opts) {
153
178
  const args = [binJs, ...files, '--target', 'release'];
@@ -259,11 +284,13 @@ export function serverArtifacts(root) {
259
284
  let out = 'build/server/release.wasm';
260
285
  let hot;
261
286
  let cold;
287
+ let stream;
262
288
  try {
263
289
  const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8'));
264
290
  out = cfg.targets?.release?.outFile ?? out;
265
291
  hot = cfg.targets?.release?.hotFile;
266
292
  cold = cfg.targets?.release?.coldFile;
293
+ stream = cfg.targets?.release?.streamFile;
267
294
  }
268
295
  catch {
269
296
  }
@@ -274,6 +301,7 @@ export function serverArtifacts(root) {
274
301
  return {
275
302
  hot: path.resolve(root, hot ?? ins('hot')),
276
303
  cold: path.resolve(root, cold ?? ins('cold')),
304
+ stream: path.resolve(root, stream ?? ins('stream')),
277
305
  };
278
306
  }
279
307
  async function freeLoopbackPort() {
@@ -1,5 +1,5 @@
1
- import { type ComponentType, type Context, createElement, type ReactNode } from 'react';
2
- import { renderToStaticMarkup } from 'react-dom/server';
1
+ import { type ComponentType, type Context, createElement, type ReactNode, Suspense } from 'react';
2
+ import { renderToString } from 'react-dom/server';
3
3
  import { type ResolvedToilConfig } from './config.js';
4
4
  export interface RouteRenderInput {
5
5
  name: string;
@@ -12,7 +12,8 @@ export interface RouteRenderInput {
12
12
  setSsrBuild: (on: boolean) => void;
13
13
  shell: string;
14
14
  createElement?: typeof createElement;
15
- renderToStaticMarkup?: typeof renderToStaticMarkup;
15
+ renderToString?: typeof renderToString;
16
+ Suspense?: typeof Suspense;
16
17
  }
17
18
  export interface TemplateArtifacts {
18
19
  name: string;
@@ -24,7 +25,7 @@ export interface TemplateArtifacts {
24
25
  }
25
26
  export declare function assembleRouteElement(Page: ComponentType, layouts: ComponentType<{
26
27
  children?: ReactNode;
27
- }>[], loaderData: unknown, loaderContext: Context<unknown> | null, h?: typeof createElement): ReactNode;
28
+ }>[], loaderData: unknown, loaderContext: Context<unknown> | null, h?: typeof createElement, SuspenseComp?: typeof Suspense): ReactNode;
28
29
  export declare function injectIntoShell(shell: string, routeHtml: string): string;
29
30
  export declare function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts;
30
31
  export declare function writeTemplateArtifacts(ssrDir: string, art: TemplateArtifacts): void;
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
- import { createElement } from 'react';
5
- import { renderToStaticMarkup } from 'react-dom/server';
4
+ import { createElement, Suspense } from 'react';
5
+ import { renderToString } from 'react-dom/server';
6
6
  import { createServer } from 'vite';
7
7
  import { findLayout, findSpecialChain } from './generate.js';
8
8
  import { scanRoutes } from './routes.js';
@@ -11,13 +11,14 @@ import { assignSlotIds, coherenceHash, encodeSlots, extractFromHtml, } from './t
11
11
  import { createViteConfig } from './vite.js';
12
12
  const SSR_MARKER = '<template id="__toil_ssr"></template>';
13
13
  const ROOT_DIV = '<div id="root"></div>';
14
- export function assembleRouteElement(Page, layouts, loaderData, loaderContext, h = createElement) {
14
+ export function assembleRouteElement(Page, layouts, loaderData, loaderContext, h = createElement, SuspenseComp = Suspense) {
15
15
  let node = h(Page);
16
16
  if (loaderContext) {
17
17
  node = h(loaderContext.Provider, { value: loaderData }, node);
18
18
  }
19
+ node = h(SuspenseComp, { fallback: null }, node);
19
20
  for (let i = layouts.length - 1; i >= 0; i--) {
20
- node = h(layouts[i], null, node);
21
+ node = h(SuspenseComp, { fallback: null }, h(layouts[i], null, node));
21
22
  }
22
23
  return node;
23
24
  }
@@ -27,10 +28,17 @@ export function injectIntoShell(shell, routeHtml) {
27
28
  }
28
29
  return shell.replace(ROOT_DIV, `<div id="root">${routeHtml}</div>${SSR_MARKER}`);
29
30
  }
31
+ function stripHoistedResourceTags(html) {
32
+ return html
33
+ .replace(/<link\b[^>]*>/gi, '')
34
+ .replace(/<meta\b[^>]*>/gi, '')
35
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, '');
36
+ }
30
37
  export function extractRouteTemplate(input) {
31
38
  const h = input.createElement ?? createElement;
32
- const render = input.renderToStaticMarkup ?? renderToStaticMarkup;
33
- const element = assembleRouteElement(input.Page, input.layouts, input.loaderData, input.loaderContext, h);
39
+ const render = input.renderToString ?? renderToString;
40
+ const SuspenseComp = input.Suspense ?? Suspense;
41
+ const element = assembleRouteElement(input.Page, input.layouts, input.loaderData, input.loaderContext, h, SuspenseComp);
34
42
  input.setSsrBuild(true);
35
43
  let routeHtml;
36
44
  try {
@@ -39,7 +47,7 @@ export function extractRouteTemplate(input) {
39
47
  finally {
40
48
  input.setSsrBuild(false);
41
49
  }
42
- const full = injectIntoShell(input.shell, routeHtml);
50
+ const full = injectIntoShell(input.shell, stripHoistedResourceTags(routeHtml));
43
51
  const extracted = extractFromHtml(full);
44
52
  const ids = assignSlotIds(extracted.slots);
45
53
  const hash = coherenceHash(extracted.tmpl, extracted.slots);
@@ -133,8 +141,9 @@ async function renderSsrRoutes(cfg, shell) {
133
141
  const loaderData = typeof mod.loader === 'function'
134
142
  ? await mod.loader({ params, searchParams: new URLSearchParams() })
135
143
  : undefined;
144
+ const rootLayout = findLayout(cfg);
136
145
  const layoutFiles = [
137
- ...(findLayout(cfg) ? [findLayout(cfg)] : []),
146
+ ...(rootLayout ? [rootLayout] : []),
138
147
  ...findSpecialChain(cfg, r.file, 'layout', false),
139
148
  ];
140
149
  const layouts = [];
@@ -143,6 +152,7 @@ async function renderSsrRoutes(cfg, shell) {
143
152
  layouts.push(lm.default);
144
153
  }
145
154
  const name = routeTemplateName(r.pattern);
155
+ client.__setSsrLocation(samplePath(r.pattern));
146
156
  const art = extractRouteTemplate({
147
157
  name,
148
158
  Page: mod.default,
@@ -152,13 +162,17 @@ async function renderSsrRoutes(cfg, shell) {
152
162
  setSsrBuild: client.__setSsrBuild,
153
163
  shell,
154
164
  createElement: react.createElement,
155
- renderToStaticMarkup: reactDomServer.renderToStaticMarkup,
165
+ renderToString: reactDomServer.renderToString,
166
+ Suspense: react.Suspense,
156
167
  });
157
168
  rendered.push({ pattern: r.pattern, art });
158
169
  }
159
170
  catch (err) {
160
171
  warn(`skipped ${r.pattern} (render failed: ${err instanceof Error ? err.message : String(err)}) — falls back to client rendering`);
161
172
  }
173
+ finally {
174
+ client.__setSsrLocation(null);
175
+ }
162
176
  }
163
177
  }
164
178
  finally {
@@ -213,6 +227,9 @@ function sampleParams(pattern) {
213
227
  }
214
228
  return params;
215
229
  }
230
+ function samplePath(pattern) {
231
+ return pattern.replace(/[:*]+([A-Za-z0-9_]+)/g, 'sample');
232
+ }
216
233
  export async function extractTemplates(cfg, hostName = 'edge', priorServerSlots = new Map()) {
217
234
  const shell = resolveShell(cfg, true);
218
235
  if (shell === null)
@@ -1,11 +1,10 @@
1
1
  import { store } from '../core/store';
2
2
 
3
3
  /** Typed RPC service (transport still a TODO): reached as `Server.stats.playerCount()` on the client. */
4
- @service
4
+ /*@service
5
5
  class Stats {
6
- /** Number of seeded players (the RPC transport is a TODO, so this throws on the client for now). */
7
6
  @remote
8
7
  public playerCount(): i32 {
9
8
  return store.size;
10
9
  }
11
- }
10
+ }*/
@@ -1,7 +1,7 @@
1
1
  /** Free `@remote` functions: callable as `Server.<name>()` on the client. */
2
2
 
3
3
  /** `Server.ping(n)` on the client. */
4
- @remote
4
+ /*@remote
5
5
  function ping(n: i32): i32 {
6
6
  return n + 1;
7
- }
7
+ }*/
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.61",
4
+ "version": "0.0.63",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -40,6 +40,7 @@ export {
40
40
  useSearchParams,
41
41
  useRouter,
42
42
  useNavigationPending,
43
+ __setSsrLocation,
43
44
  } from './routing/hooks.js';
44
45
  export type { RouterInstance } from './routing/hooks.js';
45
46
  export {
@@ -101,12 +101,25 @@ function useLocationSubscription(): void {
101
101
  );
102
102
  }
103
103
 
104
+ /** Build-only override for the SSR pathname, set by the template extractor per route via
105
+ * {@link __setSsrLocation}. Lets location-dependent markup (a `NavLink`'s active class /
106
+ * `aria-current`) render as the route's own URL so it matches what the client computes on
107
+ * hydration, instead of the `/` default. Ignored in the browser (the live URL wins). */
108
+ let ssrLocationOverride: string | null = null;
109
+
110
+ /** Build-only: set the pathname the extractor is currently rendering (or `null` to clear).
111
+ * No effect in the browser. Exported through `toiljs/client` for the compiler. */
112
+ export function __setSsrLocation(path: string | null): void {
113
+ ssrLocationOverride = path;
114
+ }
115
+
104
116
  /** Subscribes to and returns the current `location.pathname`. SSR-safe: during a
105
- * server render (build-time template extraction / edge SSR) there is no `window`,
106
- * so it reports `/`; the client recomputes on hydration. */
117
+ * server render there is no `window`, so it reports the extractor's override (the route
118
+ * being rendered) or `/`; the client recomputes on hydration. */
107
119
  export function useLocation(): string {
108
120
  useLocationSubscription();
109
- return typeof window === 'undefined' ? '/' : window.location.pathname;
121
+ if (typeof window === 'undefined') return ssrLocationOverride ?? '/';
122
+ return window.location.pathname;
110
123
  }
111
124
 
112
125
  /** Alias of {@link useLocation}: the current `location.pathname`. */
@@ -4,39 +4,16 @@ import { DevToolbar } from '../dev/devtools.js';
4
4
  import { DevErrorBoundary, DevErrorOverlay, initDevErrorOverlay } from '../dev/error-overlay.js';
5
5
  import { initNavigation } from '../navigation/navigation.js';
6
6
  import { startPrefetcher } from '../navigation/prefetch.js';
7
- import { hydrateLoaderData } from './loader.js';
8
- import { matchRoute } from './match.js';
9
7
  import { Router } from './Router.js';
10
8
  import type { ErrorComponentLoader, LayoutLoader, NotFoundLoader, RouteDef } from '../types.js';
11
9
 
12
10
  /** An edge-SSR document carries a `<* id="__toil_ssr">` marker baked into the
13
- * template; its presence flips `mount` to `hydrateRoot`. */
11
+ * template; its presence means the server rendered real first-paint HTML into
12
+ * `#root`, so `mount` hydrates it in place rather than client-rendering. */
14
13
  function isSsrDocument(): boolean {
15
14
  return typeof document !== 'undefined' && document.getElementById('__toil_ssr') !== null;
16
15
  }
17
16
 
18
- /** Seed the loader cache from the server's `#__toil_state` JSON so the first
19
- * client render uses the same data the server stamped (clean hydration). */
20
- function seedSsrHydration(routes: RouteDef[]): void {
21
- if (typeof document === 'undefined' || typeof window === 'undefined') return;
22
- const el = document.getElementById('__toil_state');
23
- if (!el || !el.textContent) return;
24
- let state: { data?: unknown };
25
- try {
26
- state = JSON.parse(el.textContent) as { data?: unknown };
27
- } catch {
28
- return;
29
- }
30
- const { pathname, search } = window.location;
31
- for (const route of routes) {
32
- const params = matchRoute(route.pattern, pathname);
33
- if (params) {
34
- hydrateLoaderData(route, params, pathname, search, state.data);
35
- return;
36
- }
37
- }
38
- }
39
-
40
17
  /**
41
18
  * Mounts the toil client app into `#root` and starts idle link prefetching. Called by the
42
19
  * compiler-generated `.toil/entry.tsx`.
@@ -66,9 +43,9 @@ export function mount(
66
43
  if ((import.meta as unknown as { env: { DEV: boolean } }).env.DEV) {
67
44
  initDevErrorOverlay();
68
45
  // Dev tools (error overlay + toolbar) render into their OWN body-level
69
- // container, never inside #root, so they don't perturb #root's markup.
70
- // That lets a dev SSR document hydrate cleanly (the server only rendered
71
- // the app into #root), and is harmless for a plain client-rendered page.
46
+ // container, never inside `#root`, so `#root` holds only the app markup.
47
+ // That lets an SSR document hydrate cleanly (the server only rendered the
48
+ // app into `#root`), and is harmless for a plain client-rendered page.
72
49
  const devEl = document.createElement('div');
73
50
  devEl.id = '__toil_dev';
74
51
  document.body.appendChild(devEl);
@@ -83,19 +60,14 @@ export function mount(
83
60
  );
84
61
  const tree = <DevErrorBoundary>{app}</DevErrorBoundary>;
85
62
  if (isSsrDocument()) {
86
- // Dev edge-SSR: the dev server served real server-rendered markup
87
- // (guest `render` + splice); hydrate it in place, same as production,
88
- // instead of client-rendering from scratch.
89
- seedSsrHydration(routes);
63
+ // Edge-SSR: hydrate the server-rendered markup in place.
90
64
  hydrateRoot(el, tree);
91
65
  } else {
92
66
  createRoot(el).render(tree);
93
67
  }
94
68
  } else if (isSsrDocument()) {
95
- // Edge-SSR: the document already holds server-rendered markup. Seed the
96
- // loader cache from `#__toil_state` and hydrate in place (reuse the DOM)
97
- // rather than client-rendering from scratch.
98
- seedSsrHydration(routes);
69
+ // Edge-SSR: the document already holds server-rendered markup; hydrate it
70
+ // (reuse the DOM) rather than client-rendering from scratch.
99
71
  hydrateRoot(el, app);
100
72
  } else {
101
73
  createRoot(el).render(app);
@@ -21,7 +21,7 @@
21
21
  * does (it does: see `server/runtime/ssr/escape.ts`).
22
22
  */
23
23
 
24
- import { createElement, Fragment, type ReactNode } from 'react';
24
+ import { createElement, Fragment, type ReactNode, useEffect, useState } from 'react';
25
25
 
26
26
  /** Token framing codepoints (Unicode Private Use Area). */
27
27
  export const SENTINEL_START = String.fromCharCode(0xe000);
@@ -145,7 +145,10 @@ export function Repeat<T>(props: RepeatProps<T>): ReactNode {
145
145
  return createElement(
146
146
  Fragment,
147
147
  null,
148
- props.each.map((item, i) => props.children(item, i)),
148
+ // Each row is wrapped in a keyed Fragment so React has a stable list key (the
149
+ // row markup itself need not carry one). Index keys are fine here: an SSR'd
150
+ // region hydrates 1:1 against the host's pre-stamped rows and does not reorder.
151
+ props.each.map((item, i) => createElement(Fragment, { key: i }, props.children(item, i))),
149
152
  );
150
153
  }
151
154
 
@@ -154,9 +157,16 @@ export interface IslandProps {
154
157
  }
155
158
 
156
159
  /** A client-only escape hatch for content outside the server-template subset.
157
- * Client: renders `children`. Build: renders nothing (the block is empty in the
158
- * server HTML and appears after hydration; it gets no first-paint/SEO). */
160
+ * Build: renders nothing (empty in the server HTML). Client: ALSO renders nothing
161
+ * on the first (hydration) render, so it matches the empty server markup, then a
162
+ * mount effect reveals `children` on the next commit. Rendering the children
163
+ * during hydration instead would diverge from the server's empty markup and trip
164
+ * a hydration mismatch. So an island gets no first-paint / SEO, by design. */
159
165
  export function Island(props: IslandProps): ReactNode {
160
- if (ssrBuild) return null;
166
+ const [hydrated, setHydrated] = useState(false);
167
+ useEffect(() => {
168
+ setHydrated(true);
169
+ }, []);
170
+ if (ssrBuild || !hydrated) return null;
161
171
  return createElement(Fragment, null, props.children);
162
172
  }