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.
- package/CHANGELOG.md +22 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +1 -1
- package/build/client/routing/hooks.d.ts +1 -0
- package/build/client/routing/hooks.js +7 -1
- package/build/client/routing/mount.js +0 -26
- package/build/client/ssr/markers.js +7 -3
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/index.d.ts +4 -1
- package/build/compiler/index.js +46 -18
- package/build/compiler/template-build.d.ts +5 -4
- package/build/compiler/template-build.js +26 -9
- package/examples/basic/server/services/Stats.ts +2 -3
- package/examples/basic/server/services/remotes.ts +2 -2
- package/package.json +1 -1
- package/src/client/index.ts +1 -0
- package/src/client/routing/hooks.ts +16 -3
- package/src/client/routing/mount.tsx +8 -36
- package/src/client/ssr/markers.tsx +15 -5
- package/src/compiler/index.ts +104 -53
- package/src/compiler/template-build.ts +62 -14
- package/test/daemon-build.test.ts +31 -12
- package/test/ssr-hydration.test.tsx +122 -0
- package/test/ssr-render.test.ts +4 -2
- package/test/ssr-template.test.tsx +5 -1
- package/examples/basic/server/streams/Echo.ts +0 -49
package/build/compiler/index.js
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
155
|
+
stream.push(rel);
|
|
156
|
+
request.push(rel);
|
|
139
157
|
continue;
|
|
140
158
|
}
|
|
141
|
-
const isCold = COLD_DECORATOR.test(src);
|
|
142
|
-
const
|
|
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 (
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
33
|
-
const
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}*/
|
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
106
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
70
|
-
// That lets
|
|
71
|
-
//
|
|
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
|
-
//
|
|
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
|
|
96
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
158
|
-
*
|
|
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
|
-
|
|
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
|
}
|