@farm.js/islx 0.0.7 → 0.0.8
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/README.md +56 -26
- package/dist/_chunks/{actions-Eutk6UzT.mjs → infer-BIVT5aDz.mjs} +269 -1
- package/dist/_chunks/{island-DVOr-OUw.mjs → island-DXAGP4FN.mjs} +10 -3
- package/dist/_chunks/{prehydration-B1GLTLdk.mjs → prehydration-zEYsfFxW.mjs} +3 -2
- package/dist/_chunks/{schedule-CjoybS3b.mjs → schedule-Cwn9vQL0.mjs} +12 -3
- package/dist/_chunks/{tree-C47p6khN.mjs → tree-BQphP23Y.mjs} +1 -1
- package/dist/_chunks/{tree-B4RMU2UX.d.mts → tree-CV13RsJv.d.mts} +8 -1
- package/dist/_chunks/{vite-BTzYlEiY.mjs → vite-0bH_ZDys.mjs} +27 -12
- package/dist/boot.d.mts +31 -0
- package/dist/boot.mjs +177 -0
- package/dist/client.d.mts +2 -1
- package/dist/client.mjs +1 -1
- package/dist/compiler.d.mts +23 -1
- package/dist/compiler.mjs +1 -1
- package/dist/react/client.d.mts +1 -1
- package/dist/react/client.mjs +3 -3
- package/dist/react/server.d.mts +1 -1
- package/dist/react/server.mjs +10 -6
- package/dist/react.d.mts +1 -1
- package/dist/react.mjs +2 -2
- package/dist/server.d.mts +5 -0
- package/dist/tanstack-start/server.mjs +1 -1
- package/dist/tanstack-start.mjs +1 -1
- package/dist/vite.d.mts +16 -1
- package/dist/vite.mjs +1 -1
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.8
|
|
4
|
+
|
|
5
|
+
Pages load the runtime only when they first need it, and islands that only react to clicks wait
|
|
6
|
+
for one.
|
|
7
|
+
|
|
8
|
+
- Pages start with a 1.4 KB boot script (`@farm.js/islx/boot`) instead of the browser entry
|
|
9
|
+
(65.6 KB on the React example). It loads the entry at once when an island hydrates at load (such
|
|
10
|
+
an island also preloads it), and otherwise on an island's trigger, a click, press or focus on a
|
|
11
|
+
waiting island, a pointer over a link, or a focus in a form. A link clicked before then is held
|
|
12
|
+
and followed once navigation runs. `islx({ boot: false })` starts every page with the entry.
|
|
13
|
+
- A `"use client"` module without a `hydrate` export waits for a click when its code, and the app
|
|
14
|
+
files it imports, provably do nothing before one: no effect, external store or `use()`, no
|
|
15
|
+
handler but `onClick` and `onSubmit` on its elements, no form field, `ref` or `autoFocus`, no
|
|
16
|
+
props spread onto an element, and imports only from React, stylesheets and the app. The click is
|
|
17
|
+
held and replayed once it hydrates. A render that passes such an island `children` hydrates at
|
|
18
|
+
load. `islx({ infer: false })` keeps every island at load; `inferStrategy` in
|
|
19
|
+
`@farm.js/islx/compiler` runs the checks for other plugins.
|
|
20
|
+
- A click on a same-page link (`#…`) in an island that has not hydrated is held and replayed, as a
|
|
21
|
+
button's is, so a table of contents that marks its heading still sees it.
|
|
22
|
+
- Changed defaults: an island whose module declares nothing and passes the checks above now
|
|
23
|
+
waits for a click. Add `export const hydrate = "load"` to keep one hydrating at load.
|
|
24
|
+
|
|
3
25
|
## 0.0.7
|
|
4
26
|
|
|
5
27
|
Cloudflare Workers start, and sealed captured values name their key with 4 bytes.
|
package/README.md
CHANGED
|
@@ -6,14 +6,16 @@ send only what changed.
|
|
|
6
6
|
|
|
7
7
|
```html
|
|
8
8
|
<f-island src="/src/cart.tsx" export="Cart" props="_R_1_" strategy="load">…server HTML…</f-island>
|
|
9
|
-
<script type="f/props" for="_R_1_">
|
|
9
|
+
<script type="f/props" for="_R_1_">
|
|
10
|
+
{"updated":{"$":"D","v":"2026-10-28T09:00:00.000Z"}}
|
|
11
|
+
</script>
|
|
10
12
|
```
|
|
11
13
|
|
|
12
14
|
islx started as [Farm.js](https://github.com/farming-labs/farm.js)'s islands mode, where it
|
|
13
15
|
replaces React Server Components' Flight payload. On a 300-page docs app it ships 26% less
|
|
14
16
|
JavaScript, and a client navigation sends 2.5 KB instead of 12 KB.
|
|
15
17
|
|
|
16
|
-
> Status: 0.0.
|
|
18
|
+
> Status: 0.0.8. React, Vite and TanStack Start are supported; other renderers and framework
|
|
17
19
|
> integrations follow. The wire format is in [SPEC.md](./SPEC.md), changes in
|
|
18
20
|
> [CHANGELOG.md](./CHANGELOG.md).
|
|
19
21
|
|
|
@@ -108,19 +110,36 @@ export async function handle(request: Request) {
|
|
|
108
110
|
|
|
109
111
|
async function page(request: Request, formState?) {
|
|
110
112
|
const stream = await renderToStream(<App />, { request, formState });
|
|
111
|
-
return new Response(stream, {
|
|
113
|
+
return new Response(stream, {
|
|
114
|
+
headers: { "content-type": "text/html", "cache-control": "private" },
|
|
115
|
+
});
|
|
112
116
|
}
|
|
113
117
|
```
|
|
114
118
|
|
|
115
119
|
```tsx
|
|
116
120
|
// src/components/chart.tsx
|
|
117
121
|
"use client";
|
|
118
|
-
export const hydrate = "visible"; // or { Chart: "visible", Legend: "idle" };
|
|
122
|
+
export const hydrate = "visible"; // or { Chart: "visible", Legend: "idle" }; see below
|
|
119
123
|
export function Chart(props: { points: Map<string, number> }) { … }
|
|
120
124
|
```
|
|
121
125
|
|
|
122
126
|
- Components are server components unless their module says `"use client"`; those render as
|
|
123
127
|
islands, and the page needs no other setup: `renderToStream` adds the browser entry.
|
|
128
|
+
- A module without a `hydrate` export hydrates at load, unless its code provably does nothing
|
|
129
|
+
before a click: then it waits for one (`"interaction"`), and the click is held and replayed
|
|
130
|
+
once it hydrates. That means no effect, external store or `use()`, no handler but `onClick` and
|
|
131
|
+
`onSubmit` on its elements (a handled link only with a `#` link), no form field, `ref` or
|
|
132
|
+
`autoFocus`, no props spread onto an element, and imports only from React, stylesheets and the
|
|
133
|
+
app's own files that pass the same checks: copy buttons, like buttons, menu toggles and tables
|
|
134
|
+
of contents usually do. A render that gives the island server output (`children`) hydrates at
|
|
135
|
+
load. `islx({ infer: false })` turns this off; `inferStrategy` in `@farm.js/islx/compiler`
|
|
136
|
+
runs the same checks for other plugins.
|
|
137
|
+
- Pages start with a 1.4 KB boot script instead of the browser entry. It loads the entry at once
|
|
138
|
+
when an island hydrates at load (which also preloads it), and otherwise when an island's
|
|
139
|
+
trigger comes, a waiting island is clicked, pressed or focused, the pointer reaches a link, or
|
|
140
|
+
a form gets focus. A link clicked before then is held and followed once navigation runs. So a
|
|
141
|
+
page whose islands all wait loads 1.4 KB of JavaScript until one is used. `islx({ boot: false })`
|
|
142
|
+
starts every page with the entry.
|
|
124
143
|
- `vite build` builds the server, then the browser with exactly the client components the server
|
|
125
144
|
rendered, each in its own chunk. `islx({ clientComponents: { include, exclude } })` adds
|
|
126
145
|
components the server build cannot see (chosen by name at runtime) or leaves some out.
|
|
@@ -142,19 +161,29 @@ export function Chart(props: { points: Map<string, number> }) { … }
|
|
|
142
161
|
navigation: { viewTransition: true },
|
|
143
162
|
hooks: {
|
|
144
163
|
client: {
|
|
145
|
-
navigate(event) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
164
|
+
navigate(event) {
|
|
165
|
+
if (window.unsaved) event.preventDefault();
|
|
166
|
+
}, // stay on the page
|
|
167
|
+
navigated({ url, duration }) {
|
|
168
|
+
window.plausible?.("pageview", { u: url.href });
|
|
169
|
+
},
|
|
170
|
+
hydrate({ export: name, duration }) {
|
|
171
|
+
console.debug(name, "interactive in", duration);
|
|
172
|
+
},
|
|
173
|
+
islandError({ src, error }) {
|
|
174
|
+
console.error(src, error);
|
|
175
|
+
},
|
|
149
176
|
actionEnd({ name, ok, duration }) {},
|
|
150
177
|
},
|
|
151
178
|
server: {
|
|
152
|
-
async action({ request, name, module }, run) {
|
|
179
|
+
async action({ request, name, module }, run) {
|
|
180
|
+
/* check, log, time */ return run();
|
|
181
|
+
},
|
|
153
182
|
actionError: (error) => (error instanceof Unauthorized ? { status: 401 } : undefined),
|
|
154
183
|
error(error, { name }) {},
|
|
155
184
|
},
|
|
156
185
|
},
|
|
157
|
-
})
|
|
186
|
+
});
|
|
158
187
|
```
|
|
159
188
|
|
|
160
189
|
Callbacks are copied into the browser or server build as source, so they may use only their
|
|
@@ -163,6 +192,7 @@ export function Chart(props: { points: Map<string, number> }) { … }
|
|
|
163
192
|
exporting `defineServerHooks({ … })` (or `defineClientHooks`). Every client hook also fires as a
|
|
164
193
|
DOM event on `document`: `f:navigate`, `f:navigated`, `f:navigate-error`, `f:hydrate`,
|
|
165
194
|
`f:island-error`, `f:action`, `f:action-end`.
|
|
195
|
+
|
|
166
196
|
- Each build makes its own key for sealed action values; set `ISLX_ACTIONS_KEY` to keep forms
|
|
167
197
|
working across deploys.
|
|
168
198
|
|
|
@@ -209,26 +239,26 @@ unawaited to a client component streams in. Islands call Start server functions
|
|
|
209
239
|
A framework that renders pages itself (Farm does) uses the pieces directly instead of
|
|
210
240
|
`renderToStream` and the Vite plugin's entry:
|
|
211
241
|
|
|
212
|
-
| Need
|
|
213
|
-
|
|
|
214
|
-
| Compile client and action modules
|
|
215
|
-
| Render islands in its own render
|
|
216
|
-
| A layout that hydrates as one root
|
|
217
|
-
| An async page under such a layout
|
|
242
|
+
| Need | API |
|
|
243
|
+
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
244
|
+
| Compile client and action modules | `transformClientModule({ …, reference, strategy })`, `transformActions` from `@farm.js/islx/compiler` |
|
|
245
|
+
| Render islands in its own render | wrap the page in `<IslandsProvider held={parseHeldValues(request)}>` |
|
|
246
|
+
| A layout that hydrates as one root | `withinClientRoot(layout)`; its server-HTML page: `isolateIslands(page)` |
|
|
247
|
+
| An async page under such a layout | `renderServerTree(page, { held, payloadOnly })` on the server; `createServerTreeElement(source, { load, ServerHtml })` in the browser |
|
|
218
248
|
| A server layout around a client provider, without shipping the layout | `renderServerTree(<Layout><Outlet /></Layout>, { outlets: { children: page }, scope })` on the server; `createServerTreeLayout(source, scope, { load, ServerHtml, onUpdate })` in the browser, then `layout.render({ children: page })` |
|
|
219
|
-
| Hydrate
|
|
220
|
-
| Hold early clicks
|
|
221
|
-
| Actions
|
|
222
|
-
| Navigation with its router
|
|
249
|
+
| Hydrate | `createHydrationRuntime({ load, schedule?, wrap?, report? })`, `createServerHtml(runtime)` |
|
|
250
|
+
| Hold early clicks | inline `PREHYDRATION_SCRIPT` in the page |
|
|
251
|
+
| Actions | `handleAction(request, { allowedOrigins, bodySizeLimit, run, mapError, renderFormState })`; `setActionNavigator({ refresh, navigate })` |
|
|
252
|
+
| Navigation with its router | send `F-Values` (`valueHeaders()`), `F-Layouts`, `F-Render`; `refreshDocument` / `swapDocument` or its own swap |
|
|
223
253
|
|
|
224
254
|
## Planned
|
|
225
255
|
|
|
226
|
-
| Entry
|
|
227
|
-
|
|
|
228
|
-
| `@farm.js/islx/react`
|
|
229
|
-
| `@farm.js/islx/webpack`
|
|
230
|
-
| `@farm.js/islx/next`, `@farm.js/islx/astro`, `@farm.js/islx/nuxt` | thin framework integrations
|
|
231
|
-
| `@farm.js/islx/testing`
|
|
256
|
+
| Entry | For |
|
|
257
|
+
| ----------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
258
|
+
| `@farm.js/islx/react` | server trees (a page rebuilt inside a hydrated layout), partial navigations |
|
|
259
|
+
| `@farm.js/islx/webpack` | webpack, Rspack, and Next.js (Turbopack uses the loader) |
|
|
260
|
+
| `@farm.js/islx/next`, `@farm.js/islx/astro`, `@farm.js/islx/nuxt` | thin framework integrations |
|
|
261
|
+
| `@farm.js/islx/testing` | the conformance suite every integration must pass |
|
|
232
262
|
|
|
233
263
|
## License
|
|
234
264
|
|
|
@@ -4,6 +4,7 @@ import { walk } from "estree-walker";
|
|
|
4
4
|
import isReference from "is-reference";
|
|
5
5
|
import MagicString from "magic-string";
|
|
6
6
|
import { analyze } from "periscopic";
|
|
7
|
+
import fs from "node:fs";
|
|
7
8
|
// src/compiler/actions.ts
|
|
8
9
|
/**
|
|
9
10
|
* The server-action compiler: a pure function from a module's source to its source for the
|
|
@@ -323,4 +324,271 @@ function transformActions(options) {
|
|
|
323
324
|
})
|
|
324
325
|
};
|
|
325
326
|
}
|
|
326
|
-
|
|
327
|
+
// src/compiler/infer.ts
|
|
328
|
+
/**
|
|
329
|
+
* When an island hydrates if its module sets no `hydrate` export: `"interaction"` when its code
|
|
330
|
+
* provably does nothing before a click, `"load"` otherwise. Such an island renders exactly its
|
|
331
|
+
* server HTML until it is clicked, so it can wait: the scheduler holds that click (or a submit),
|
|
332
|
+
* hydrates the island, and replays it. A press or a focus on the island starts hydrating it early.
|
|
333
|
+
*
|
|
334
|
+
* The checks read the module and the app files it imports. They prove the island can wait when:
|
|
335
|
+
*
|
|
336
|
+
* - it calls no hook that runs code after a render or reads outside React: `useEffect`,
|
|
337
|
+
* `useLayoutEffect`, `useInsertionEffect`, `useSyncExternalStore`, `useImperativeHandle`, `use`,
|
|
338
|
+
* `useActionState`, `useOptimistic`, `useFormStatus`, `useDeferredValue`, `useEffectEvent`;
|
|
339
|
+
* - its HTML elements handle only `onClick` and `onSubmit` (a handled link only when its `href` is a
|
|
340
|
+
* `#` link on the page), and none is a form field, takes a `ref`, `autoFocus`, `contentEditable`,
|
|
341
|
+
* a form `action` or `formAction`, or props spread onto it;
|
|
342
|
+
* - it declares no class, calls no `createElement`, `cloneElement`, `forwardRef` or `createRef`,
|
|
343
|
+
* and imports nothing at runtime;
|
|
344
|
+
* - its top level only declares values (`createContext()` and `memo()` included);
|
|
345
|
+
* - it imports only React, stylesheets and assets, and app files that pass the same checks.
|
|
346
|
+
*
|
|
347
|
+
* Anything the checks cannot prove, a package import or a file they cannot read included, keeps
|
|
348
|
+
* `"load"`. Renderer-neutral as a contract: the rules here are React's.
|
|
349
|
+
*/
|
|
350
|
+
const EFFECT_HOOKS = /* @__PURE__ */ new Set([
|
|
351
|
+
"useEffect",
|
|
352
|
+
"useLayoutEffect",
|
|
353
|
+
"useInsertionEffect",
|
|
354
|
+
"useSyncExternalStore",
|
|
355
|
+
"useImperativeHandle",
|
|
356
|
+
"use",
|
|
357
|
+
"useActionState",
|
|
358
|
+
"useOptimistic",
|
|
359
|
+
"useFormStatus",
|
|
360
|
+
"useDeferredValue",
|
|
361
|
+
"useEffectEvent"
|
|
362
|
+
]);
|
|
363
|
+
const UNREADABLE_CALLS = /* @__PURE__ */ new Set([
|
|
364
|
+
"createElement",
|
|
365
|
+
"cloneElement",
|
|
366
|
+
"forwardRef",
|
|
367
|
+
"createRef"
|
|
368
|
+
]);
|
|
369
|
+
const REACT_MODULES = /* @__PURE__ */ new Set([
|
|
370
|
+
"react",
|
|
371
|
+
"react/jsx-runtime",
|
|
372
|
+
"react/jsx-dev-runtime"
|
|
373
|
+
]);
|
|
374
|
+
const HELD_EVENTS = /* @__PURE__ */ new Set(["onClick", "onSubmit"]);
|
|
375
|
+
const FORM_CONTROLS = /* @__PURE__ */ new Set([
|
|
376
|
+
"input",
|
|
377
|
+
"textarea",
|
|
378
|
+
"select"
|
|
379
|
+
]);
|
|
380
|
+
const ASSET_IMPORT = /\.(?:css|scss|sass|less|styl|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|json)(?:\?.*)?$/;
|
|
381
|
+
const EXTENSIONS = [
|
|
382
|
+
".tsx",
|
|
383
|
+
".ts",
|
|
384
|
+
".jsx",
|
|
385
|
+
".js",
|
|
386
|
+
".mjs"
|
|
387
|
+
];
|
|
388
|
+
function defaultResolve(specifier, importer) {
|
|
389
|
+
if (!specifier.startsWith(".")) return null;
|
|
390
|
+
const base = path.resolve(path.dirname(importer), specifier);
|
|
391
|
+
for (const candidate of [
|
|
392
|
+
base,
|
|
393
|
+
...EXTENSIONS.map((extension) => base + extension),
|
|
394
|
+
...EXTENSIONS.map((extension) => path.join(base, `index${extension}`))
|
|
395
|
+
]) try {
|
|
396
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
397
|
+
} catch {}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
/** Infers when the islands `file` exports hydrate; `code` is its source. */
|
|
401
|
+
function inferStrategy(file, code, options) {
|
|
402
|
+
const resolve = options.resolve ?? defaultResolve;
|
|
403
|
+
const files = [];
|
|
404
|
+
const seen = /* @__PURE__ */ new Set();
|
|
405
|
+
const queue = [{
|
|
406
|
+
file,
|
|
407
|
+
code
|
|
408
|
+
}];
|
|
409
|
+
while (queue.length > 0) {
|
|
410
|
+
const next = queue.shift();
|
|
411
|
+
if (seen.has(next.file)) continue;
|
|
412
|
+
seen.add(next.file);
|
|
413
|
+
files.push(next.file);
|
|
414
|
+
let source = next.code;
|
|
415
|
+
if (source === null) try {
|
|
416
|
+
source = fs.readFileSync(next.file, "utf8");
|
|
417
|
+
} catch {
|
|
418
|
+
return {
|
|
419
|
+
strategy: null,
|
|
420
|
+
files,
|
|
421
|
+
reason: `${path.basename(next.file)} could not be read`
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const imports = [];
|
|
425
|
+
const reason = inspect(next.file, source, options.parse, (specifier) => {
|
|
426
|
+
if (REACT_MODULES.has(specifier) || ASSET_IMPORT.test(specifier)) return null;
|
|
427
|
+
const resolved = resolve(specifier, next.file);
|
|
428
|
+
if (!resolved || resolved.includes(`${path.sep}node_modules${path.sep}`)) return `imports ${specifier}`;
|
|
429
|
+
imports.push(resolved);
|
|
430
|
+
return null;
|
|
431
|
+
});
|
|
432
|
+
if (reason) return {
|
|
433
|
+
strategy: null,
|
|
434
|
+
files,
|
|
435
|
+
reason: `${path.basename(next.file)}: ${reason}`
|
|
436
|
+
};
|
|
437
|
+
for (const imported of imports) queue.push({
|
|
438
|
+
file: imported,
|
|
439
|
+
code: null
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
strategy: "interaction",
|
|
444
|
+
files
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
function inspect(file, source, parse, importSource) {
|
|
448
|
+
let program;
|
|
449
|
+
try {
|
|
450
|
+
program = parse(file, source);
|
|
451
|
+
} catch {
|
|
452
|
+
return "could not be parsed";
|
|
453
|
+
}
|
|
454
|
+
for (const statement of program.body ?? []) {
|
|
455
|
+
const problem = topLevelProblem(statement, importSource);
|
|
456
|
+
if (problem) return problem;
|
|
457
|
+
}
|
|
458
|
+
return findProblem(program);
|
|
459
|
+
}
|
|
460
|
+
function isTypeOnly(node) {
|
|
461
|
+
if (node.importKind === "type" || node.exportKind === "type") return true;
|
|
462
|
+
const specifiers = node.specifiers ?? [];
|
|
463
|
+
return specifiers.length > 0 && specifiers.every((specifier) => specifier.importKind === "type");
|
|
464
|
+
}
|
|
465
|
+
/** What a top-level statement does as the module loads, which waiting would delay. */
|
|
466
|
+
function topLevelProblem(statement, importSource) {
|
|
467
|
+
switch (statement.type) {
|
|
468
|
+
case "ImportDeclaration": return isTypeOnly(statement) ? null : importSource(statement.source.value);
|
|
469
|
+
case "ExportAllDeclaration": return statement.exportKind === "type" ? null : importSource(statement.source.value);
|
|
470
|
+
case "ExportNamedDeclaration":
|
|
471
|
+
if (statement.source) return isTypeOnly(statement) ? null : importSource(statement.source.value);
|
|
472
|
+
return statement.declaration ? topLevelProblem(statement.declaration, importSource) : null;
|
|
473
|
+
case "ExportDefaultDeclaration": return statement.declaration.type === "FunctionDeclaration" || String(statement.declaration.type).startsWith("TS") ? null : pureValueProblem(statement.declaration);
|
|
474
|
+
case "ExpressionStatement": return typeof statement.directive === "string" ? null : "runs code as it loads";
|
|
475
|
+
case "VariableDeclaration":
|
|
476
|
+
for (const declarator of statement.declarations ?? []) {
|
|
477
|
+
const problem = declarator.init ? pureValueProblem(declarator.init) : null;
|
|
478
|
+
if (problem) return problem;
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
case "FunctionDeclaration":
|
|
482
|
+
case "EmptyStatement": return null;
|
|
483
|
+
case "ClassDeclaration": return "declares a class";
|
|
484
|
+
default: return String(statement.type).startsWith("TS") ? null : `has a top-level ${statement.type}`;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/** Whether evaluating `node` at the top level does anything but build a value. */
|
|
488
|
+
function pureValueProblem(node) {
|
|
489
|
+
switch (node.type) {
|
|
490
|
+
case "Literal":
|
|
491
|
+
case "Identifier":
|
|
492
|
+
case "ArrowFunctionExpression":
|
|
493
|
+
case "FunctionExpression":
|
|
494
|
+
case "MemberExpression": return null;
|
|
495
|
+
case "TemplateLiteral": return firstProblem(node.expressions, pureValueProblem);
|
|
496
|
+
case "ArrayExpression": return firstProblem(node.elements.filter(Boolean), pureValueProblem);
|
|
497
|
+
case "ObjectExpression": return firstProblem(node.properties, (property) => property.type === "SpreadElement" ? pureValueProblem(property.argument) : property.computed ? "has a computed key" : pureValueProblem(property.value));
|
|
498
|
+
case "UnaryExpression":
|
|
499
|
+
case "SpreadElement": return pureValueProblem(node.argument);
|
|
500
|
+
case "BinaryExpression":
|
|
501
|
+
case "LogicalExpression": return pureValueProblem(node.left) ?? pureValueProblem(node.right);
|
|
502
|
+
case "ConditionalExpression": return pureValueProblem(node.test) ?? pureValueProblem(node.consequent) ?? pureValueProblem(node.alternate);
|
|
503
|
+
case "CallExpression": {
|
|
504
|
+
const name = memberCallName(node.callee);
|
|
505
|
+
if (name === "createContext" || name === "memo" || name === "Object.freeze") return firstProblem(node.arguments, pureValueProblem);
|
|
506
|
+
return "runs code as it loads";
|
|
507
|
+
}
|
|
508
|
+
default: return String(node.type).startsWith("TS") && node.expression ? pureValueProblem(node.expression) : `evaluates a ${node.type} as it loads`;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function firstProblem(items, check) {
|
|
512
|
+
for (const item of items) {
|
|
513
|
+
const problem = check(item);
|
|
514
|
+
if (problem) return problem;
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
function memberCallName(callee) {
|
|
519
|
+
if (callee.type === "Identifier") return callee.name;
|
|
520
|
+
if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier") return callee.object.type === "Identifier" && callee.object.name === "Object" ? `Object.${callee.property.name}` : callee.property.name;
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
/** A call to one of React's functions: `name()` or `React.name()`, not `document.name()`. */
|
|
524
|
+
function reactCallName(callee) {
|
|
525
|
+
if (callee.type === "Identifier") return callee.name;
|
|
526
|
+
if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.object.name === "React" && callee.property.type === "Identifier") return callee.property.name;
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
function isHashHref(value) {
|
|
530
|
+
if (!value) return false;
|
|
531
|
+
if (value.type === "Literal") return typeof value.value === "string" && value.value.startsWith("#");
|
|
532
|
+
if (value.type !== "JSXExpressionContainer") return false;
|
|
533
|
+
const expression = value.expression;
|
|
534
|
+
if (expression.type === "Literal") return typeof expression.value === "string" && expression.value.startsWith("#");
|
|
535
|
+
return expression.type === "TemplateLiteral" && String(expression.quasis[0]?.value?.cooked ?? "").startsWith("#");
|
|
536
|
+
}
|
|
537
|
+
/** What an HTML element does that an island waiting for a click would get wrong. */
|
|
538
|
+
function hostElementProblem(element) {
|
|
539
|
+
if (element.name.type !== "JSXIdentifier") return null;
|
|
540
|
+
const tag = element.name.name;
|
|
541
|
+
if (!/^[a-z]/.test(tag)) return null;
|
|
542
|
+
if (FORM_CONTROLS.has(tag)) return `renders <${tag}>`;
|
|
543
|
+
let href;
|
|
544
|
+
let clicks = false;
|
|
545
|
+
for (const attribute of element.attributes ?? []) {
|
|
546
|
+
if (attribute.type === "JSXSpreadAttribute") return `spreads props onto <${tag}>`;
|
|
547
|
+
const name = attribute.name?.type === "JSXIdentifier" ? attribute.name.name : null;
|
|
548
|
+
if (name === null) continue;
|
|
549
|
+
if (name === "ref") return `sets a ref on <${tag}>`;
|
|
550
|
+
if (name === "autoFocus") return `sets autoFocus on <${tag}>`;
|
|
551
|
+
if (name === "contentEditable") return `makes <${tag}> editable`;
|
|
552
|
+
if (tag === "form" && name === "action") return "gives a form an action";
|
|
553
|
+
if (name === "formAction") return `sets formAction on <${tag}>`;
|
|
554
|
+
if (name === "href") href = attribute.value;
|
|
555
|
+
if (/^on[A-Z]/.test(name)) {
|
|
556
|
+
if (!HELD_EVENTS.has(name)) return `handles ${name}`;
|
|
557
|
+
if (name === "onClick") clicks = true;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (tag === "a" && clicks && !isHashHref(href)) return "handles a click on a link";
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
function findProblem(node) {
|
|
564
|
+
switch (node.type) {
|
|
565
|
+
case "CallExpression": {
|
|
566
|
+
const name = reactCallName(node.callee);
|
|
567
|
+
if (name && (EFFECT_HOOKS.has(name) || UNREADABLE_CALLS.has(name))) return `calls ${name}`;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
case "JSXOpeningElement": {
|
|
571
|
+
const problem = hostElementProblem(node);
|
|
572
|
+
if (problem) return problem;
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
case "ClassDeclaration":
|
|
576
|
+
case "ClassExpression": return "declares a class";
|
|
577
|
+
case "ImportExpression": return "imports a module at runtime";
|
|
578
|
+
}
|
|
579
|
+
for (const key of Object.keys(node)) {
|
|
580
|
+
if (key === "start" || key === "end" || key === "range" || key === "loc") continue;
|
|
581
|
+
const value = node[key];
|
|
582
|
+
if (Array.isArray(value)) {
|
|
583
|
+
for (const child of value) if (child && typeof child.type === "string") {
|
|
584
|
+
const problem = findProblem(child);
|
|
585
|
+
if (problem) return problem;
|
|
586
|
+
}
|
|
587
|
+
} else if (value && typeof value === "object" && typeof value.type === "string") {
|
|
588
|
+
const problem = findProblem(value);
|
|
589
|
+
if (problem) return problem;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
export { transformActions as a, hasModuleDirective as i, ActionCompileError as n, getActionId as r, inferStrategy as t };
|
|
@@ -204,7 +204,7 @@ function isThenable(value) {
|
|
|
204
204
|
* Wraps a client component so the server renders it as an island. `src` is the module the browser
|
|
205
205
|
* imports, `exportName` the component's export there.
|
|
206
206
|
*/
|
|
207
|
-
function createIsland(Component, src, exportName,
|
|
207
|
+
function createIsland(Component, src, exportName, declared = "load", options = {}) {
|
|
208
208
|
function Island(props) {
|
|
209
209
|
const { graph, render } = getContexts();
|
|
210
210
|
const insideIsland = React.useContext(graph);
|
|
@@ -215,9 +215,12 @@ function createIsland(Component, src, exportName, strategy = "load") {
|
|
|
215
215
|
if (!hook) throw new Error("islx: an island rendered on a server that did not import @farm.js/islx/react/server");
|
|
216
216
|
let encoded;
|
|
217
217
|
let renderProps;
|
|
218
|
+
let strategy = declared;
|
|
218
219
|
try {
|
|
219
|
-
const
|
|
220
|
+
const slots = { next: 0 };
|
|
221
|
+
const { render: rendered, wire } = splitSlots(props, `${id}s`, slots);
|
|
220
222
|
renderProps = rendered;
|
|
223
|
+
if (options.inferred && slots.next > 0) strategy = "load";
|
|
221
224
|
const skip = state ? /* @__PURE__ */ new Set([...state.held ?? [], ...state.sent]) : null;
|
|
222
225
|
encoded = hook.encode(wire, skip);
|
|
223
226
|
for (const value of encoded.values) state?.sent.add(value.key);
|
|
@@ -231,7 +234,11 @@ function createIsland(Component, src, exportName, strategy = "load") {
|
|
|
231
234
|
}
|
|
232
235
|
return React.createElement(Component, props);
|
|
233
236
|
}
|
|
234
|
-
if (strategy === "load")
|
|
237
|
+
if (strategy === "load") {
|
|
238
|
+
const defaults = getBuildDefaults();
|
|
239
|
+
if (defaults.boot && defaults.entry) ReactDOM.preloadModule?.(defaults.entry, { as: "script" });
|
|
240
|
+
for (const href of defaults.modules?.[src] ?? []) ReactDOM.preloadModule?.(href, { as: "script" });
|
|
241
|
+
}
|
|
235
242
|
return React.createElement(React.Fragment, null, ...encoded.values.map((value) => React.createElement("script", {
|
|
236
243
|
key: value.key,
|
|
237
244
|
type: VALUE_SCRIPT_TYPE,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// src/client/prehydration.ts
|
|
2
2
|
/**
|
|
3
3
|
* Holding interactions that happen before the islx script has run. An inline script in the page
|
|
4
|
-
* captures a click or a submit on an island that has not hydrated (it is prevented and queued
|
|
4
|
+
* captures a click or a submit on an island that has not hydrated (it is prevented and queued; a
|
|
5
|
+
* click on a link only when the link stays on the page, `#…`),
|
|
5
6
|
* and announces a pointerdown or a focus (which go through). The runtime then hydrates those
|
|
6
7
|
* islands first and replays what was held.
|
|
7
8
|
*
|
|
@@ -14,7 +15,7 @@ const INTERACTION_EVENT = "f:island-interaction";
|
|
|
14
15
|
/** Elements whose click is held and replayed rather than merely observed. */
|
|
15
16
|
const REPLAYABLE_SELECTOR = "button,[role=\"button\"],input[type=\"button\"],input[type=\"submit\"],input[type=\"reset\"]";
|
|
16
17
|
/** The inline script's source (no `<script>` tag): pass it to the page's head or bootstrap. */
|
|
17
|
-
const PREHYDRATION_SCRIPT = `(function(){if(window.${PENDING_INTERACTIONS})return;var q=window.${PENDING_INTERACTIONS}=[];function island(n){var i=n&&n.closest&&n.closest("f-island");return i&&!i.hasAttribute("hydrated")?i:null}function tell(t,k){document.dispatchEvent(new CustomEvent("${INTERACTION_EVENT}",{detail:{target:t,kind:k}}))}function hold(e,t,k){if(!q.some(function(p){return p.target===t})){q.push({target:t,kind:k});tell(t,k)}e.preventDefault();e.stopImmediatePropagation()}document.addEventListener("click",function(e){if(e.defaultPrevented||e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;var
|
|
18
|
+
const PREHYDRATION_SCRIPT = `(function(){if(window.${PENDING_INTERACTIONS})return;var q=window.${PENDING_INTERACTIONS}=[];function island(n){var i=n&&n.closest&&n.closest("f-island");return i&&!i.hasAttribute("hydrated")?i:null}function tell(t,k){document.dispatchEvent(new CustomEvent("${INTERACTION_EVENT}",{detail:{target:t,kind:k}}))}function hold(e,t,k){if(!q.some(function(p){return p.target===t})){q.push({target:t,kind:k});tell(t,k)}e.preventDefault();e.stopImmediatePropagation()}document.addEventListener("click",function(e){if(e.defaultPrevented||e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;var n=e.target&&e.target.closest?e.target:null,a=n&&n.closest("a[href]"),t=a?(a.getAttribute("href").charAt(0)==="#"?a:null):n&&n.closest(${JSON.stringify(REPLAYABLE_SELECTOR)});if(!t||!island(t))return;hold(e,t,"click")},true);document.addEventListener("submit",function(e){var f=e.target;if(e.defaultPrevented||!f||f.nodeName!=="FORM"||!island(f))return;hold(e,f,"submit")},true);function seen(e){if(island(e.target))tell(e.target,null)}document.addEventListener("pointerdown",seen,true);document.addEventListener("focusin",seen,true)})();`;
|
|
18
19
|
/** Takes the interactions the inline script held inside `container`, oldest first. */
|
|
19
20
|
function takePendingInteractions(container) {
|
|
20
21
|
const queue = globalThis[PENDING_INTERACTIONS];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as hasPendingInteraction, o as takePendingInteractions, t as INTERACTION_EVENT } from "./prehydration-
|
|
1
|
+
import { a as hasPendingInteraction, o as takePendingInteractions, t as INTERACTION_EVENT } from "./prehydration-zEYsfFxW.mjs";
|
|
2
2
|
import { m as HYDRATED_ATTRIBUTE } from "./wire-QEQWBr5s.mjs";
|
|
3
3
|
// src/client/schedule.ts
|
|
4
4
|
/**
|
|
@@ -24,6 +24,15 @@ function isIslandStrategy(value) {
|
|
|
24
24
|
}
|
|
25
25
|
/** Elements whose click is held and replayed rather than merely observed. */
|
|
26
26
|
const REPLAYABLE = "button,[role=\"button\"],input[type=\"button\"],input[type=\"submit\"],input[type=\"reset\"]";
|
|
27
|
+
/**
|
|
28
|
+
* The element a click before hydration is held on: a control, or a same-page link, whose handler
|
|
29
|
+
* the island may need (a table of contents marking its heading). Any other link navigates.
|
|
30
|
+
*/
|
|
31
|
+
function heldTarget(target) {
|
|
32
|
+
const link = target.closest("a[href]");
|
|
33
|
+
if (link) return link.getAttribute("href")?.startsWith("#") ? link : null;
|
|
34
|
+
return target.closest(REPLAYABLE);
|
|
35
|
+
}
|
|
27
36
|
function whenIdle(callback) {
|
|
28
37
|
const scope = window;
|
|
29
38
|
if (typeof scope.requestIdleCallback === "function") {
|
|
@@ -190,8 +199,8 @@ function scheduleHydration({ container, strategy, signal, hydrate }) {
|
|
|
190
199
|
};
|
|
191
200
|
const onClick = (event) => {
|
|
192
201
|
const mouse = event;
|
|
193
|
-
const target = event.target instanceof Element ? event.target
|
|
194
|
-
if (!target || !container.contains(target) ||
|
|
202
|
+
const target = event.target instanceof Element ? heldTarget(event.target) : null;
|
|
203
|
+
if (!target || !container.contains(target) || mouse.button !== 0 || mouse.metaKey || mouse.altKey || mouse.ctrlKey || mouse.shiftKey) return;
|
|
195
204
|
hold(event, target, "click");
|
|
196
205
|
};
|
|
197
206
|
const onSubmit = (event) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { i as decodeTagged, n as NOT_A_VALUE, o as encodeLeaf } from "./codec-C8to-RQW.mjs";
|
|
2
2
|
import { M as VALUE_SCRIPT_TYPE, c as CHUNK_SCRIPT_TYPE, k as TREE_SCRIPT_TYPE, p as HTML_ELEMENT } from "./wire-QEQWBr5s.mjs";
|
|
3
3
|
import { t as createServerReference, u as getValueStore } from "./actions-C-IwUCKU.mjs";
|
|
4
|
-
import { a as getIslandServerHook, o as isolateIslands, r as getClientReference } from "./island-
|
|
4
|
+
import { a as getIslandServerHook, o as isolateIslands, r as getClientReference } from "./island-DXAGP4FN.mjs";
|
|
5
5
|
import * as React from "react";
|
|
6
6
|
// src/react/tree.ts
|
|
7
7
|
/**
|
|
@@ -52,7 +52,14 @@ interface IslandRenderState {
|
|
|
52
52
|
* Wraps a client component so the server renders it as an island. `src` is the module the browser
|
|
53
53
|
* imports, `exportName` the component's export there.
|
|
54
54
|
*/
|
|
55
|
-
declare function createIsland<P extends object>(Component: React.ComponentType<P>, src: string, exportName: string,
|
|
55
|
+
declare function createIsland<P extends object>(Component: React.ComponentType<P>, src: string, exportName: string, declared?: IslandStrategy,
|
|
56
|
+
/**
|
|
57
|
+
* `inferred`: the compiler inferred `declared` (`"interaction"`). A render that passes the island
|
|
58
|
+
* server output hydrates at load instead: islands inside that output would wait with it.
|
|
59
|
+
*/
|
|
60
|
+
options?: {
|
|
61
|
+
inferred?: boolean;
|
|
62
|
+
}): React.ComponentType<P>;
|
|
56
63
|
/**
|
|
57
64
|
* Marks a navigation response that carries only the page's payload. The browser rebuilds such a
|
|
58
65
|
* page in its layout's React root and never shows its HTML, so the server leaves the HTML out.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as ISLAND_STRATEGIES } from "./schedule-
|
|
1
|
+
import { a as transformActions, i as hasModuleDirective, n as ActionCompileError, t as inferStrategy } from "./infer-BIVT5aDz.mjs";
|
|
2
|
+
import { t as ISLAND_STRATEGIES } from "./schedule-Cwn9vQL0.mjs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { walk } from "estree-walker";
|
|
@@ -78,10 +78,12 @@ function transformClientModule(options) {
|
|
|
78
78
|
throw new ActionCompileError(message, file, code, node.start);
|
|
79
79
|
};
|
|
80
80
|
const strategies = readStrategies(program, fail);
|
|
81
|
+
const inference = options.infer && strategies.all === void 0 && strategies.byName.size === 0 ? inferStrategy(file, code, options.infer) : void 0;
|
|
82
|
+
const inferred = (name) => options.strategy?.(name) === void 0 && inference?.strategy === "interaction";
|
|
81
83
|
const strategyOf = (name) => {
|
|
82
84
|
const configured = options.strategy?.(name);
|
|
83
85
|
if (configured !== void 0 && !ISLAND_STRATEGIES.includes(configured)) throw new Error(`islx: strategy for ${name} must be one of ${ISLAND_STRATEGIES.join(", ")}`);
|
|
84
|
-
return configured ?? strategies.byName.get(name) ?? strategies.all ?? "load";
|
|
86
|
+
return configured ?? strategies.byName.get(name) ?? strategies.all ?? (inference?.strategy === "interaction" ? "interaction" : "load");
|
|
85
87
|
};
|
|
86
88
|
const output = new MagicString(code);
|
|
87
89
|
const wrapped = [];
|
|
@@ -140,7 +142,9 @@ function transformClientModule(options) {
|
|
|
140
142
|
const runtime = options.runtime ?? "@farm.js/islx/react";
|
|
141
143
|
const src = options.reference(relative);
|
|
142
144
|
const lines = [...wrapped.map(({ local, exportName }, index) => {
|
|
143
|
-
|
|
145
|
+
const strategy = JSON.stringify(strategyOf(exportName));
|
|
146
|
+
const flags = inferred(exportName) ? ", { inferred: true }" : "";
|
|
147
|
+
return `const $$islx_island_${index} = $$islx_island(${local}, ${src}, ${JSON.stringify(exportName)}, ${strategy}${flags});`;
|
|
144
148
|
}), `export { ${wrapped.map(({ exportName }, index) => `$$islx_island_${index} as ${exportName}`).join(", ")} };`];
|
|
145
149
|
const imports = [`import { createIsland as $$islx_island } from ${JSON.stringify(runtime)};`, ...(options.sideEffects ?? []).map((module) => `import ${JSON.stringify(module)};`)];
|
|
146
150
|
output.prepend(`${imports.join("\n")}\n`);
|
|
@@ -154,7 +158,8 @@ function transformClientModule(options) {
|
|
|
154
158
|
islands: wrapped.map(({ exportName }) => ({
|
|
155
159
|
exportName,
|
|
156
160
|
strategy: strategyOf(exportName)
|
|
157
|
-
}))
|
|
161
|
+
})),
|
|
162
|
+
...inference ? { inference } : {}
|
|
158
163
|
};
|
|
159
164
|
}
|
|
160
165
|
// src/vite.ts
|
|
@@ -177,6 +182,8 @@ function transformClientModule(options) {
|
|
|
177
182
|
const RUNTIME_ID = "virtual:islx/runtime";
|
|
178
183
|
/** @internal The browser entry, for a framework whose own entry imports it. */
|
|
179
184
|
const ENTRY_ID = "virtual:islx/client-entry";
|
|
185
|
+
/** The boot entry: pages start with it, and it loads ENTRY_ID when the page first needs it. */
|
|
186
|
+
const BOOT_ID = "virtual:islx/boot";
|
|
180
187
|
const LIBRARY_RUNTIME_ID = "virtual:islx/react-for-libraries";
|
|
181
188
|
const CLIENT_HOOKS_ID = "virtual:islx/hooks-client";
|
|
182
189
|
const SERVER_HOOKS_ID = "virtual:islx/hooks-server";
|
|
@@ -244,6 +251,7 @@ function serializeHooks(hooks, where, parse) {
|
|
|
244
251
|
* build until the browser build names them. */
|
|
245
252
|
const ENTRY_PLACEHOLDER = "__islx_client_entry_url__";
|
|
246
253
|
const MODULES_PLACEHOLDER = "__islx_client_modules__";
|
|
254
|
+
const BOOT_PLACEHOLDER = "__islx_boot_url__";
|
|
247
255
|
const SCRIPT = /\.[cm]?[jt]sx?$/;
|
|
248
256
|
async function createParser() {
|
|
249
257
|
const withErrors = (result) => Object.assign(result.program, { errors: result.errors ?? [] });
|
|
@@ -315,6 +323,7 @@ function islx(options = {}, host = {}) {
|
|
|
315
323
|
const discovered = /* @__PURE__ */ new Set();
|
|
316
324
|
/** The browser entry's URL, once the browser build named it. */
|
|
317
325
|
let entryUrl;
|
|
326
|
+
let bootUrl;
|
|
318
327
|
/** The files each client module needs, once the browser build named them. */
|
|
319
328
|
const moduleFiles = {};
|
|
320
329
|
/** Whether the browser build ran first (a framework that orders the build). */
|
|
@@ -355,7 +364,8 @@ function islx(options = {}, host = {}) {
|
|
|
355
364
|
outDir: clientBuild?.outDir ?? "dist/client",
|
|
356
365
|
rollupOptions: { input: host.frameworkEntry ? input : {
|
|
357
366
|
...typeof input === "string" ? { index: input } : Array.isArray(input) ? Object.fromEntries(input.map((entry, index) => [`input${index}`, entry])) : input,
|
|
358
|
-
"islx-entry": ENTRY_ID
|
|
367
|
+
"islx-entry": ENTRY_ID,
|
|
368
|
+
...options.boot === false ? {} : { "islx-boot": BOOT_ID }
|
|
359
369
|
} }
|
|
360
370
|
}
|
|
361
371
|
} }
|
|
@@ -368,17 +378,18 @@ function islx(options = {}, host = {}) {
|
|
|
368
378
|
},
|
|
369
379
|
resolveId(id) {
|
|
370
380
|
if (id === LIBRARY_RUNTIME_ID) return this.resolve("@farm.js/islx/react", path.join(config.root, "package.json"), { skipSelf: true });
|
|
371
|
-
if (id === RUNTIME_ID || id === "virtual:islx/client-entry" || id === CLIENT_HOOKS_ID || id === SERVER_HOOKS_ID) return `\0${id}`;
|
|
381
|
+
if (id === RUNTIME_ID || id === "virtual:islx/client-entry" || id === "virtual:islx/boot" || id === CLIENT_HOOKS_ID || id === SERVER_HOOKS_ID) return `\0${id}`;
|
|
372
382
|
},
|
|
373
383
|
async load(id) {
|
|
374
384
|
if (id === `\0${RUNTIME_ID}`) {
|
|
375
385
|
const entry = host.frameworkEntry ? void 0 : !isBuild() ? `${config.base}@id/__x00__${ENTRY_ID}` : clientBuilt ? entryUrl : ENTRY_PLACEHOLDER;
|
|
386
|
+
const boot = host.frameworkEntry || options.boot === false ? void 0 : !isBuild() ? `${config.base}@id/__x00__${BOOT_ID}` : clientBuilt ? bootUrl : BOOT_PLACEHOLDER;
|
|
376
387
|
const modules = !isBuild() ? "{}" : clientBuilt ? JSON.stringify(moduleFiles) : `JSON.parse(${JSON.stringify(MODULES_PLACEHOLDER)})`;
|
|
377
388
|
return [
|
|
378
389
|
...host.frameworkRender ? [`import "@farm.js/islx/react/server";`] : [],
|
|
379
390
|
`import { setBuildDefaults, setServerHooks } from "@farm.js/islx/server";`,
|
|
380
391
|
`import hooks from ${JSON.stringify(SERVER_HOOKS_ID)};`,
|
|
381
|
-
`setBuildDefaults({ entry: ${JSON.stringify(entry)}, key: ${JSON.stringify(buildKey)}, modules: ${modules} });`,
|
|
392
|
+
`setBuildDefaults({ entry: ${JSON.stringify(entry)}, boot: ${JSON.stringify(boot)}, key: ${JSON.stringify(buildKey)}, modules: ${modules} });`,
|
|
382
393
|
`setServerHooks(hooks);`
|
|
383
394
|
].join("\n");
|
|
384
395
|
}
|
|
@@ -389,6 +400,7 @@ function islx(options = {}, host = {}) {
|
|
|
389
400
|
if (typeof hooks === "string") return `export { default } from ${JSON.stringify(path.resolve(config.root, hooks))};`;
|
|
390
401
|
return serializeHooks(hooks, where, parse);
|
|
391
402
|
}
|
|
403
|
+
if (id === `\0virtual:islx/boot`) return `import { startBoot } from "@farm.js/islx/boot";\nstartBoot();\n`;
|
|
392
404
|
if (id === `\0virtual:islx/client-entry`) {
|
|
393
405
|
const load = isBuild() ? `const modules = {\n${(await clientInputs()).map((relative) => ` ${JSON.stringify(relative)}: () => import(${JSON.stringify(path.resolve(config.root, relative))}),`).join("\n")}\n};\nconst load = (src) => modules[src] ? modules[src]() : Promise.reject(new Error("islx: no client module " + src));` : `const root = new URL(${JSON.stringify(`file://${toPosix(config.root)}/`)});\nconst load = (src) => import(/* @vite-ignore */ src.startsWith("../") ? "/@fs" + new URL(src, root).pathname : ${JSON.stringify(config.base)} + src);`;
|
|
394
406
|
return [
|
|
@@ -428,8 +440,10 @@ function islx(options = {}, host = {}) {
|
|
|
428
440
|
},
|
|
429
441
|
sideEffects: [RUNTIME_ID],
|
|
430
442
|
directive: clientDirective,
|
|
431
|
-
...library ? { runtime: LIBRARY_RUNTIME_ID } : {}
|
|
443
|
+
...library ? { runtime: LIBRARY_RUNTIME_ID } : {},
|
|
444
|
+
...options.infer !== false && !library ? { infer: { parse } } : {}
|
|
432
445
|
});
|
|
446
|
+
for (const read of island?.inference?.files ?? []) if (read !== file) this.addWatchFile(read);
|
|
433
447
|
if (island) {
|
|
434
448
|
if (!library) transformActions({
|
|
435
449
|
code,
|
|
@@ -484,9 +498,9 @@ function islx(options = {}, host = {}) {
|
|
|
484
498
|
const outDir = path.resolve(config.root, environment.config.build.outDir);
|
|
485
499
|
for (const file of await listOutput(outDir)) {
|
|
486
500
|
const source = await readFile(file, "utf8");
|
|
487
|
-
if (!source.includes(ENTRY_PLACEHOLDER) && !source.includes(MODULES_PLACEHOLDER)) continue;
|
|
501
|
+
if (!source.includes(ENTRY_PLACEHOLDER) && !source.includes(MODULES_PLACEHOLDER) && !source.includes(BOOT_PLACEHOLDER)) continue;
|
|
488
502
|
const modules = JSON.stringify(JSON.stringify(moduleFiles)).slice(1, -1);
|
|
489
|
-
await writeFile(file, source.replaceAll(ENTRY_PLACEHOLDER, entryUrl).replaceAll(MODULES_PLACEHOLDER, modules));
|
|
503
|
+
await writeFile(file, source.replaceAll(ENTRY_PLACEHOLDER, entryUrl).replaceAll(MODULES_PLACEHOLDER, modules).replaceAll(BOOT_PLACEHOLDER, bootUrl ?? ""));
|
|
490
504
|
}
|
|
491
505
|
}
|
|
492
506
|
},
|
|
@@ -496,6 +510,7 @@ function islx(options = {}, host = {}) {
|
|
|
496
510
|
for (const chunk of Object.values(bundle)) {
|
|
497
511
|
if (chunk.type !== "chunk" || !chunk.facadeModuleId) continue;
|
|
498
512
|
if (chunk.isEntry && chunk.facadeModuleId === `\0virtual:islx/client-entry`) entryUrl = url(chunk.fileName);
|
|
513
|
+
else if (chunk.isEntry && chunk.facadeModuleId === `\0virtual:islx/boot`) bootUrl = url(chunk.fileName);
|
|
499
514
|
else if (chunk.isDynamicEntry && !chunk.facadeModuleId.startsWith("\0")) moduleFiles[relativeOf(chunk.facadeModuleId)] = [chunk.fileName, ...chunk.imports].map(url);
|
|
500
515
|
}
|
|
501
516
|
clientBuilt = true;
|
|
@@ -503,4 +518,4 @@ function islx(options = {}, host = {}) {
|
|
|
503
518
|
}
|
|
504
519
|
];
|
|
505
520
|
}
|
|
506
|
-
export { serializeHooks as i,
|
|
521
|
+
export { serializeHooks as a, resolveDirectives as i, ENTRY_ID as n, islx as r, BOOT_ID as t };
|
package/dist/boot.d.mts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @farm.js/islx/boot: the first script of a page, in place of the browser entry. It loads the entry
|
|
3
|
+
* (the runtime: the renderer, islx's hydration, navigation and forms) when the page first needs
|
|
4
|
+
* it, so a page whose islands all wait loads only this script until one is used:
|
|
5
|
+
*
|
|
6
|
+
* - at once, when an island hydrates at load (`strategy="load"`, also one that streams in later)
|
|
7
|
+
* or a Suspense boundary is left for the browser to finish;
|
|
8
|
+
* - otherwise on the first island trigger (`idle`; `visible`, with the scheduler's 200px margin;
|
|
9
|
+
* an interaction the inline pre-hydration script saw) or on intent to follow a link (a pointer
|
|
10
|
+
* over it, a press, a focus), or a focus in a form.
|
|
11
|
+
*
|
|
12
|
+
* The inline pre-hydration script holds the clicks and submits on waiting islands until then. A
|
|
13
|
+
* same-site link clicked before the entry has run is held too, and clicked again once it has, so
|
|
14
|
+
* navigation decides as for any click; if the entry fails to load, the link navigates natively.
|
|
15
|
+
*
|
|
16
|
+
* Renderer-neutral, and it imports nothing: a module it shared with the entry would change how the
|
|
17
|
+
* entry splits into chunks. The server names the entry in `window.__islxRuntime`.
|
|
18
|
+
*/
|
|
19
|
+
/** Kept equal to the island element (protocol/wire.ts); a test compares them. */
|
|
20
|
+
declare const BOOT_ISLAND_ELEMENT = "f-island";
|
|
21
|
+
/** Kept equal to HYDRATED_ATTRIBUTE (protocol/wire.ts). */
|
|
22
|
+
declare const BOOT_HYDRATED_ATTRIBUTE = "hydrated";
|
|
23
|
+
/** Kept equal to INTERACTION_EVENT (client/prehydration.ts). */
|
|
24
|
+
declare const BOOT_INTERACTION_EVENT = "f:island-interaction";
|
|
25
|
+
/** Kept equal to the scheduler's visibility margin (client/schedule.ts). */
|
|
26
|
+
declare const BOOT_VISIBLE_MARGIN = "200px";
|
|
27
|
+
/** Where the server names the browser entry for the boot. */
|
|
28
|
+
declare const RUNTIME_GLOBAL = "__islxRuntime";
|
|
29
|
+
/** Starts the runtime when the page first needs it. `load` runs it; it starts as it runs. */
|
|
30
|
+
declare function startBoot(load?: () => Promise<unknown>): void;
|
|
31
|
+
export { BOOT_HYDRATED_ATTRIBUTE, BOOT_INTERACTION_EVENT, BOOT_ISLAND_ELEMENT, BOOT_VISIBLE_MARGIN, RUNTIME_GLOBAL, startBoot };
|
package/dist/boot.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// src/boot.ts
|
|
2
|
+
/**
|
|
3
|
+
* @farm.js/islx/boot: the first script of a page, in place of the browser entry. It loads the entry
|
|
4
|
+
* (the runtime: the renderer, islx's hydration, navigation and forms) when the page first needs
|
|
5
|
+
* it, so a page whose islands all wait loads only this script until one is used:
|
|
6
|
+
*
|
|
7
|
+
* - at once, when an island hydrates at load (`strategy="load"`, also one that streams in later)
|
|
8
|
+
* or a Suspense boundary is left for the browser to finish;
|
|
9
|
+
* - otherwise on the first island trigger (`idle`; `visible`, with the scheduler's 200px margin;
|
|
10
|
+
* an interaction the inline pre-hydration script saw) or on intent to follow a link (a pointer
|
|
11
|
+
* over it, a press, a focus), or a focus in a form.
|
|
12
|
+
*
|
|
13
|
+
* The inline pre-hydration script holds the clicks and submits on waiting islands until then. A
|
|
14
|
+
* same-site link clicked before the entry has run is held too, and clicked again once it has, so
|
|
15
|
+
* navigation decides as for any click; if the entry fails to load, the link navigates natively.
|
|
16
|
+
*
|
|
17
|
+
* Renderer-neutral, and it imports nothing: a module it shared with the entry would change how the
|
|
18
|
+
* entry splits into chunks. The server names the entry in `window.__islxRuntime`.
|
|
19
|
+
*/
|
|
20
|
+
/** Kept equal to the island element (protocol/wire.ts); a test compares them. */
|
|
21
|
+
const BOOT_ISLAND_ELEMENT = "f-island";
|
|
22
|
+
/** Kept equal to HYDRATED_ATTRIBUTE (protocol/wire.ts). */
|
|
23
|
+
const BOOT_HYDRATED_ATTRIBUTE = "hydrated";
|
|
24
|
+
/** Kept equal to INTERACTION_EVENT (client/prehydration.ts). */
|
|
25
|
+
const BOOT_INTERACTION_EVENT = "f:island-interaction";
|
|
26
|
+
/** Kept equal to the scheduler's visibility margin (client/schedule.ts). */
|
|
27
|
+
const BOOT_VISIBLE_MARGIN = "200px";
|
|
28
|
+
/** Where the server names the browser entry for the boot. */
|
|
29
|
+
const RUNTIME_GLOBAL = "__islxRuntime";
|
|
30
|
+
function isModified(event) {
|
|
31
|
+
return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey;
|
|
32
|
+
}
|
|
33
|
+
/** A link islx navigation takes: the same checks as its click handler. */
|
|
34
|
+
function navigableLink(target) {
|
|
35
|
+
const link = target instanceof Element ? target.closest("a[href]") : null;
|
|
36
|
+
if (!(link instanceof HTMLAnchorElement)) return null;
|
|
37
|
+
if (link.target && link.target !== "_self") return null;
|
|
38
|
+
if (link.hasAttribute("download") || link.relList.contains("external")) return null;
|
|
39
|
+
const url = new URL(link.href);
|
|
40
|
+
if (url.origin !== location.origin || !/^https?:$/.test(url.protocol)) return null;
|
|
41
|
+
if (url.pathname === location.pathname && url.search === location.search && url.hash) return null;
|
|
42
|
+
return link;
|
|
43
|
+
}
|
|
44
|
+
/** Runs the browser entry the server named, as a module script: the same URL, so the same module. */
|
|
45
|
+
function loadRuntime() {
|
|
46
|
+
const src = window[RUNTIME_GLOBAL];
|
|
47
|
+
if (typeof src !== "string" || src === "") return Promise.reject(/* @__PURE__ */ new Error(`islx: the page names no browser entry (${RUNTIME_GLOBAL}).`));
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const script = document.createElement("script");
|
|
50
|
+
script.type = "module";
|
|
51
|
+
script.src = src;
|
|
52
|
+
const nonce = document.querySelector("script[nonce]")?.nonce;
|
|
53
|
+
if (nonce) script.nonce = nonce;
|
|
54
|
+
script.addEventListener("load", () => resolve(), { once: true });
|
|
55
|
+
script.addEventListener("error", () => reject(/* @__PURE__ */ new Error(`islx: ${src} failed to load.`)), { once: true });
|
|
56
|
+
document.head.append(script);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Starts the runtime when the page first needs it. `load` runs it; it starts as it runs. */
|
|
60
|
+
function startBoot(load = loadRuntime) {
|
|
61
|
+
const cleanups = [];
|
|
62
|
+
let loading;
|
|
63
|
+
const release = () => {
|
|
64
|
+
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
65
|
+
};
|
|
66
|
+
const start = () => {
|
|
67
|
+
if (!loading) {
|
|
68
|
+
loading = load().then(release, (error) => {
|
|
69
|
+
release();
|
|
70
|
+
throw error;
|
|
71
|
+
});
|
|
72
|
+
loading.catch((error) => console.error(error));
|
|
73
|
+
}
|
|
74
|
+
return loading;
|
|
75
|
+
};
|
|
76
|
+
const listen = (type, handler, capture) => {
|
|
77
|
+
document.addEventListener(type, handler, capture);
|
|
78
|
+
cleanups.push(() => document.removeEventListener(type, handler, capture));
|
|
79
|
+
};
|
|
80
|
+
listen(BOOT_INTERACTION_EVENT, () => void start(), false);
|
|
81
|
+
const intent = (event) => {
|
|
82
|
+
if (navigableLink(event.target)) start();
|
|
83
|
+
else if (event.type !== "mouseover" && event.target?.closest?.("form")) start();
|
|
84
|
+
};
|
|
85
|
+
for (const type of [
|
|
86
|
+
"pointerdown",
|
|
87
|
+
"focusin",
|
|
88
|
+
"mouseover"
|
|
89
|
+
]) listen(type, intent, true);
|
|
90
|
+
listen("click", (event) => {
|
|
91
|
+
const click = event;
|
|
92
|
+
if (click.defaultPrevented || click.button !== 0 || isModified(click)) return;
|
|
93
|
+
const link = navigableLink(click.target);
|
|
94
|
+
if (!link) return;
|
|
95
|
+
click.preventDefault();
|
|
96
|
+
start().then(() => {
|
|
97
|
+
if (link.isConnected) link.dispatchEvent(new MouseEvent("click", {
|
|
98
|
+
bubbles: true,
|
|
99
|
+
cancelable: true
|
|
100
|
+
}));
|
|
101
|
+
else location.assign(link.href);
|
|
102
|
+
}, () => location.assign(link.href));
|
|
103
|
+
}, false);
|
|
104
|
+
const visibility = typeof IntersectionObserver === "function" ? new IntersectionObserver((entries) => {
|
|
105
|
+
if (entries.some((entry) => entry.isIntersecting)) start();
|
|
106
|
+
}, { rootMargin: BOOT_VISIBLE_MARGIN }) : null;
|
|
107
|
+
if (visibility) cleanups.push(() => visibility.disconnect());
|
|
108
|
+
let idle = false;
|
|
109
|
+
const startWhenIdle = () => {
|
|
110
|
+
if (idle) return;
|
|
111
|
+
idle = true;
|
|
112
|
+
const scope = window;
|
|
113
|
+
if (typeof scope.requestIdleCallback === "function") {
|
|
114
|
+
const handle = scope.requestIdleCallback(() => void start(), { timeout: 2e3 });
|
|
115
|
+
cleanups.push(() => scope.cancelIdleCallback?.(handle));
|
|
116
|
+
} else {
|
|
117
|
+
const handle = window.setTimeout(() => void start(), 1);
|
|
118
|
+
cleanups.push(() => window.clearTimeout(handle));
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const parsedVisible = [];
|
|
122
|
+
const observeVisible = (island) => {
|
|
123
|
+
const targets = island.getClientRects().length > 0 ? [island] : Array.from(island.children);
|
|
124
|
+
if (!visibility || targets.length === 0) start();
|
|
125
|
+
else for (const target of targets) visibility.observe(target);
|
|
126
|
+
};
|
|
127
|
+
const considerIsland = (island) => {
|
|
128
|
+
if (island.hasAttribute("hydrated")) return;
|
|
129
|
+
if (island.parentElement?.closest("f-island")) return;
|
|
130
|
+
const strategy = island.getAttribute("strategy");
|
|
131
|
+
if (strategy === "interaction") return;
|
|
132
|
+
if (strategy === "idle") startWhenIdle();
|
|
133
|
+
else if (strategy !== "visible") start();
|
|
134
|
+
else if (document.readyState === "loading") parsedVisible.push(island);
|
|
135
|
+
else observeVisible(island);
|
|
136
|
+
};
|
|
137
|
+
const consider = (node) => {
|
|
138
|
+
if (loading) return;
|
|
139
|
+
if (node.nodeType === Node.COMMENT_NODE) {
|
|
140
|
+
if (node.data === "$!") start();
|
|
141
|
+
} else if (node instanceof Element) {
|
|
142
|
+
if (node.matches("f-island")) considerIsland(node);
|
|
143
|
+
for (const island of Array.from(node.querySelectorAll(BOOT_ISLAND_ELEMENT))) considerIsland(island);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const pendingBoundary = () => {
|
|
147
|
+
const walker = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
|
|
148
|
+
while (walker.nextNode()) if (walker.currentNode.data === "$?") return true;
|
|
149
|
+
return false;
|
|
150
|
+
};
|
|
151
|
+
const walker = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
|
|
152
|
+
while (!loading && walker.nextNode()) consider(walker.currentNode);
|
|
153
|
+
consider(document.documentElement);
|
|
154
|
+
const parsed = () => {
|
|
155
|
+
if (pendingBoundary()) start();
|
|
156
|
+
for (const island of parsedVisible.splice(0)) if (!loading) observeVisible(island);
|
|
157
|
+
};
|
|
158
|
+
if (!loading && document.readyState === "loading") {
|
|
159
|
+
const observer = new MutationObserver((records) => {
|
|
160
|
+
for (const record of records) for (const node of Array.from(record.addedNodes)) consider(node);
|
|
161
|
+
});
|
|
162
|
+
observer.observe(document, {
|
|
163
|
+
childList: true,
|
|
164
|
+
subtree: true
|
|
165
|
+
});
|
|
166
|
+
const done = () => {
|
|
167
|
+
observer.disconnect();
|
|
168
|
+
parsed();
|
|
169
|
+
};
|
|
170
|
+
document.addEventListener("DOMContentLoaded", done, { once: true });
|
|
171
|
+
cleanups.push(() => {
|
|
172
|
+
observer.disconnect();
|
|
173
|
+
document.removeEventListener("DOMContentLoaded", done);
|
|
174
|
+
});
|
|
175
|
+
} else if (!loading) parsed();
|
|
176
|
+
}
|
|
177
|
+
export { BOOT_HYDRATED_ATTRIBUTE, BOOT_INTERACTION_EVENT, BOOT_ISLAND_ELEMENT, BOOT_VISIBLE_MARGIN, RUNTIME_GLOBAL, startBoot };
|
package/dist/client.d.mts
CHANGED
|
@@ -57,7 +57,8 @@ declare function startNavigation(options: NavigationOptions): {
|
|
|
57
57
|
declare function navigate(href: string, options?: NavigateOptions): Promise<void>;
|
|
58
58
|
/**
|
|
59
59
|
* Holding interactions that happen before the islx script has run. An inline script in the page
|
|
60
|
-
* captures a click or a submit on an island that has not hydrated (it is prevented and queued
|
|
60
|
+
* captures a click or a submit on an island that has not hydrated (it is prevented and queued; a
|
|
61
|
+
* click on a link only when the link stays on the page, `#…`),
|
|
61
62
|
* and announces a pointerdown or a focus (which go through). The runtime then hydrates those
|
|
62
63
|
* islands first and replays what was held.
|
|
63
64
|
*
|
package/dist/client.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { a as defineClientHooks, c as setClientHooks, d as valueHeaders, i as setActionNavigator, l as createValueStore, n as decodeActionReference, o as emit, r as setActionInterceptor, s as hookEventName, t as createServerReference, u as getValueStore } from "./_chunks/actions-C-IwUCKU.mjs";
|
|
2
2
|
import { C as RENDER_HEADER, I as readPostedAction, L as stripRuntimeFields } from "./_chunks/wire-QEQWBr5s.mjs";
|
|
3
|
-
import { i as REPLAYABLE_SELECTOR, n as PENDING_INTERACTIONS, r as PREHYDRATION_SCRIPT, t as INTERACTION_EVENT } from "./_chunks/prehydration-
|
|
3
|
+
import { i as REPLAYABLE_SELECTOR, n as PENDING_INTERACTIONS, r as PREHYDRATION_SCRIPT, t as INTERACTION_EVENT } from "./_chunks/prehydration-zEYsfFxW.mjs";
|
|
4
4
|
// src/client/forms.ts
|
|
5
5
|
/**
|
|
6
6
|
* Forms written in server components submit without reloading the page once JavaScript runs.
|
package/dist/compiler.d.mts
CHANGED
|
@@ -1,2 +1,24 @@
|
|
|
1
1
|
import { a as getActionId, i as TransformActionsResult, n as AstNode, o as hasModuleDirective, r as TransformActionsOptions, s as transformActions, t as ActionCompileError } from "./_chunks/actions-BCtIGvrP.mjs";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
// src/compiler/infer.d.ts
|
|
4
|
+
interface InferOptions {
|
|
5
|
+
/** Parses a module's source (TypeScript and JSX included) to an ESTree program. */
|
|
6
|
+
parse(file: string, code: string): AstNode;
|
|
7
|
+
/**
|
|
8
|
+
* Resolves an import to a source file of the app's own, or null for a package or anything
|
|
9
|
+
* unknown (which keeps `"load"`). Default: relative and root-absolute paths, with the usual
|
|
10
|
+
* extensions and index files.
|
|
11
|
+
*/
|
|
12
|
+
resolve?(specifier: string, importer: string): string | null;
|
|
13
|
+
}
|
|
14
|
+
interface Inference {
|
|
15
|
+
/** `"interaction"` when the islands can wait; null to keep `"load"`. */
|
|
16
|
+
strategy: "interaction" | null;
|
|
17
|
+
/** The source files the result read, the module itself first. */
|
|
18
|
+
files: string[];
|
|
19
|
+
/** Why the islands keep `"load"`. */
|
|
20
|
+
reason?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Infers when the islands `file` exports hydrate; `code` is its source. */
|
|
23
|
+
declare function inferStrategy(file: string, code: string, options: InferOptions): Inference;
|
|
24
|
+
export { ActionCompileError, type AstNode, type InferOptions, type Inference, type TransformActionsOptions, type TransformActionsResult, getActionId, hasModuleDirective, inferStrategy, transformActions };
|
package/dist/compiler.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { n as ActionCompileError, r as getActionId, i as hasModuleDirective, t as inferStrategy, a as transformActions } from "./_chunks/infer-BIVT5aDz.mjs";
|
package/dist/react/client.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { a as scheduleHydration, i as isIslandStrategy, n as IslandStrategy, r as ScheduleOptions, t as ISLAND_STRATEGIES } from "../_chunks/schedule-Dw14dW0H.mjs";
|
|
2
2
|
import { m as HYDRATED_ATTRIBUTE } from "../_chunks/wire-Dipqh-EB.mjs";
|
|
3
3
|
import { t as ValueStore } from "../_chunks/value-store-Dm-7xJ6W.mjs";
|
|
4
|
-
import { a as ServerTreeLayoutOptions, c as createServerTreeLayout, g as isolateIslands, i as ServerTreeLayout, n as PAYLOAD_ONLY_ATTRIBUTE, r as ServerTreeElementOptions, s as createServerTreeElement, u as updateServerTrees, v as withinClientRoot } from "../_chunks/tree-
|
|
4
|
+
import { a as ServerTreeLayoutOptions, c as createServerTreeLayout, g as isolateIslands, i as ServerTreeLayout, n as PAYLOAD_ONLY_ATTRIBUTE, r as ServerTreeElementOptions, s as createServerTreeElement, u as updateServerTrees, v as withinClientRoot } from "../_chunks/tree-CV13RsJv.mjs";
|
|
5
5
|
import * as React from "react";
|
|
6
6
|
|
|
7
7
|
// src/react/hydrate.d.ts
|
package/dist/react/client.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { d as valueHeaders, o as emit, t as createServerReference, u as getValueStore } from "../_chunks/actions-C-IwUCKU.mjs";
|
|
2
2
|
import { i as decodeTagged, n as NOT_A_VALUE, r as decode, u as setOwn } from "../_chunks/codec-C8to-RQW.mjs";
|
|
3
3
|
import { F as getTag, f as FORM_STATE_SCRIPT_TYPE, h as ISLAND_ELEMENT, m as HYDRATED_ATTRIBUTE, w as SLOT_ELEMENT, x as PROPS_SCRIPT_TYPE, y as PROMISE_SCRIPT_TYPE } from "../_chunks/wire-QEQWBr5s.mjs";
|
|
4
|
-
import { i as getContexts, o as isolateIslands, u as withinClientRoot } from "../_chunks/island-
|
|
5
|
-
import { i as createServerTreeLayout, n as PAYLOAD_ONLY_ATTRIBUTE, o as updateServerTrees, r as createServerTreeElement } from "../_chunks/tree-
|
|
6
|
-
import { n as isIslandStrategy, r as scheduleHydration, t as ISLAND_STRATEGIES } from "../_chunks/schedule-
|
|
4
|
+
import { i as getContexts, o as isolateIslands, u as withinClientRoot } from "../_chunks/island-DXAGP4FN.mjs";
|
|
5
|
+
import { i as createServerTreeLayout, n as PAYLOAD_ONLY_ATTRIBUTE, o as updateServerTrees, r as createServerTreeElement } from "../_chunks/tree-BQphP23Y.mjs";
|
|
6
|
+
import { n as isIslandStrategy, r as scheduleHydration, t as ISLAND_STRATEGIES } from "../_chunks/schedule-Cwn9vQL0.mjs";
|
|
7
7
|
import * as React from "react";
|
|
8
8
|
import { hydrateRoot } from "react-dom/client";
|
|
9
9
|
// src/react/hydrate.ts
|
package/dist/react/server.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { r as ReactFormState } from "../_chunks/handle-C0N9ZyNV.mjs";
|
|
2
|
-
import { _ as setRenderStateResolver, d as ClientReference, f as IslandRenderState, g as isolateIslands, h as getClientReference, l as renderServerTree, m as createIsland, n as PAYLOAD_ONLY_ATTRIBUTE, o as ServerTreeRenderOptions, p as IslandsProvider, t as Outlet, v as withinClientRoot } from "../_chunks/tree-
|
|
2
|
+
import { _ as setRenderStateResolver, d as ClientReference, f as IslandRenderState, g as isolateIslands, h as getClientReference, l as renderServerTree, m as createIsland, n as PAYLOAD_ONLY_ATTRIBUTE, o as ServerTreeRenderOptions, p as IslandsProvider, t as Outlet, v as withinClientRoot } from "../_chunks/tree-CV13RsJv.mjs";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { RenderToReadableStreamOptions } from "react-dom/server";
|
|
5
5
|
|
package/dist/react/server.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { a as encode } from "../_chunks/codec-C8to-RQW.mjs";
|
|
2
2
|
import { f as FORM_STATE_SCRIPT_TYPE } from "../_chunks/wire-QEQWBr5s.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { RUNTIME_GLOBAL } from "../boot.mjs";
|
|
4
|
+
import { r as PREHYDRATION_SCRIPT } from "../_chunks/prehydration-zEYsfFxW.mjs";
|
|
4
5
|
import { i as encodeActionReference } from "../_chunks/actions-CvQ0-zjg.mjs";
|
|
5
6
|
import { t as getBuildDefaults } from "../_chunks/build-COIcs7wm.mjs";
|
|
6
7
|
import { a as parseHeldValues, n as addressValues, o as selectValuesToSend, r as escapeScriptJson } from "../_chunks/values-37UIzxM0.mjs";
|
|
7
|
-
import { c as setIslandServerHook, i as getContexts, l as setRenderStateResolver, n as createIsland, o as isolateIslands, r as getClientReference, s as runtimeTagged, t as IslandsProvider, u as withinClientRoot } from "../_chunks/island-
|
|
8
|
-
import { a as renderServerTree, n as PAYLOAD_ONLY_ATTRIBUTE, t as Outlet } from "../_chunks/tree-
|
|
8
|
+
import { c as setIslandServerHook, i as getContexts, l as setRenderStateResolver, n as createIsland, o as isolateIslands, r as getClientReference, s as runtimeTagged, t as IslandsProvider, u as withinClientRoot } from "../_chunks/island-DXAGP4FN.mjs";
|
|
9
|
+
import { a as renderServerTree, n as PAYLOAD_ONLY_ATTRIBUTE, t as Outlet } from "../_chunks/tree-BQphP23Y.mjs";
|
|
9
10
|
import * as React from "react";
|
|
10
11
|
// src/react/server.ts
|
|
11
12
|
/**
|
|
@@ -88,9 +89,12 @@ async function renderToStream(element, { request, formState, bootstrap = true, p
|
|
|
88
89
|
held: held && held.size > 0 ? held : null,
|
|
89
90
|
sent: /* @__PURE__ */ new Set()
|
|
90
91
|
} }, element, formState ? React.createElement(FormStateScript, { formState }) : null);
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
92
|
+
const defaults = getBuildDefaults();
|
|
93
|
+
const entry = bootstrap ? defaults.entry : void 0;
|
|
94
|
+
const boot = entry !== void 0 ? defaults.boot : void 0;
|
|
95
|
+
const bootstrapModules = entry ? [...options.bootstrapModules ?? [], boot ?? entry] : options.bootstrapModules;
|
|
96
|
+
const runtime = boot ? `window.${RUNTIME_GLOBAL}=${escapeScriptJson(JSON.stringify(entry))};` : "";
|
|
97
|
+
const bootstrapScriptContent = prehydration ?? entry !== void 0 ? `${PREHYDRATION_SCRIPT}${runtime}${options.bootstrapScriptContent ?? ""}` : runtime || options.bootstrapScriptContent ? `${runtime}${options.bootstrapScriptContent ?? ""}` : void 0;
|
|
94
98
|
const { renderToReadableStream } = await import("react-dom/server");
|
|
95
99
|
const stream = await renderToReadableStream(tree, {
|
|
96
100
|
...options,
|
package/dist/react.d.mts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { f as IslandRenderState, g as isolateIslands, l as renderServerTree, m as createIsland, o as ServerTreeRenderOptions, p as IslandsProvider, t as Outlet, v as withinClientRoot } from "./_chunks/tree-
|
|
1
|
+
import { f as IslandRenderState, g as isolateIslands, l as renderServerTree, m as createIsland, o as ServerTreeRenderOptions, p as IslandsProvider, t as Outlet, v as withinClientRoot } from "./_chunks/tree-CV13RsJv.mjs";
|
|
2
2
|
import { n as IslandStrategy } from "./_chunks/schedule-Dw14dW0H.mjs";
|
|
3
3
|
export { type IslandRenderState, type IslandStrategy, IslandsProvider, Outlet, type ServerTreeRenderOptions, createIsland, isolateIslands, renderServerTree, withinClientRoot };
|
package/dist/react.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { n as createIsland, o as isolateIslands, t as IslandsProvider, u as withinClientRoot } from "./_chunks/island-
|
|
2
|
-
import { a as renderServerTree, t as Outlet } from "./_chunks/tree-
|
|
1
|
+
import { n as createIsland, o as isolateIslands, t as IslandsProvider, u as withinClientRoot } from "./_chunks/island-DXAGP4FN.mjs";
|
|
2
|
+
import { a as renderServerTree, t as Outlet } from "./_chunks/tree-BQphP23Y.mjs";
|
|
3
3
|
export { IslandsProvider, Outlet, createIsland, isolateIslands, renderServerTree, withinClientRoot };
|
package/dist/server.d.mts
CHANGED
|
@@ -187,6 +187,11 @@ declare function isSameOriginRequest(request: Request, allowedOrigins: readonly
|
|
|
187
187
|
interface BuildDefaults {
|
|
188
188
|
/** The URL of the browser entry `renderToStream` adds to pages. */
|
|
189
189
|
entry?: string;
|
|
190
|
+
/**
|
|
191
|
+
* The URL of the boot entry (`@farm.js/islx/boot`): pages start with it instead, and it loads
|
|
192
|
+
* `entry` when the page first needs it. An island that hydrates at load preloads `entry`.
|
|
193
|
+
*/
|
|
194
|
+
boot?: string;
|
|
190
195
|
/** A key made for this build: every instance of one build shares it. */
|
|
191
196
|
key?: string;
|
|
192
197
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as IslandsProvider } from "../_chunks/island-
|
|
1
|
+
import { t as IslandsProvider } from "../_chunks/island-DXAGP4FN.mjs";
|
|
2
2
|
import { A as VALUES_HEADER, C as RENDER_HEADER } from "../_chunks/wire-QEQWBr5s.mjs";
|
|
3
3
|
import { a as parseHeldValues } from "../_chunks/values-37UIzxM0.mjs";
|
|
4
4
|
import * as React from "react";
|
package/dist/tanstack-start.mjs
CHANGED
package/dist/vite.d.mts
CHANGED
|
@@ -42,9 +42,24 @@ interface IslxOptions {
|
|
|
42
42
|
client?: string | ClientHooks;
|
|
43
43
|
server?: string | ServerHooks;
|
|
44
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* A client module without a `hydrate` export waits for a click when its code provably does
|
|
47
|
+
* nothing before one (`inferStrategy` in `@farm.js/islx/compiler`): a page of such islands loads
|
|
48
|
+
* the runtime only when one is used. Default true; false hydrates them at load.
|
|
49
|
+
*/
|
|
50
|
+
infer?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Pages start with a small boot script (`@farm.js/islx/boot`) instead of the browser entry,
|
|
53
|
+
* and load the entry when the page first needs it: at once when an island hydrates at load,
|
|
54
|
+
* otherwise on an island's trigger or a link. Default true; false starts every page with the
|
|
55
|
+
* entry.
|
|
56
|
+
*/
|
|
57
|
+
boot?: boolean;
|
|
45
58
|
}
|
|
46
59
|
/** @internal The browser entry, for a framework whose own entry imports it. */
|
|
47
60
|
declare const ENTRY_ID = "virtual:islx/client-entry";
|
|
61
|
+
/** The boot entry: pages start with it, and it loads ENTRY_ID when the page first needs it. */
|
|
62
|
+
declare const BOOT_ID = "virtual:islx/boot";
|
|
48
63
|
/**
|
|
49
64
|
* The source of a hooks module made from callbacks. Each callback is copied as written, so it
|
|
50
65
|
* must not use anything from where it was written: an outside variable, an import.
|
|
@@ -73,4 +88,4 @@ interface IslxHost {
|
|
|
73
88
|
frameworkBuild?: boolean;
|
|
74
89
|
}
|
|
75
90
|
declare function islx(options?: IslxOptions, host?: IslxHost): Plugin[];
|
|
76
|
-
export { ENTRY_ID, IslxHost, IslxOptions, islx as default, islx, resolveDirectives, serializeHooks };
|
|
91
|
+
export { BOOT_ID, ENTRY_ID, IslxHost, IslxOptions, islx as default, islx, resolveDirectives, serializeHooks };
|
package/dist/vite.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { t as
|
|
1
|
+
export { t as BOOT_ID, n as ENTRY_ID, r as default, r as islx, i as resolveDirectives, a as serializeHooks } from "./_chunks/vite-0bH_ZDys.mjs";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farm.js/islx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "A renderer-agnostic wire format for server-rendered islands: typed props, values sent once, server actions, and partial navigations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"islands",
|
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
"types": "./dist/client.d.mts",
|
|
40
40
|
"default": "./dist/client.mjs"
|
|
41
41
|
},
|
|
42
|
+
"./boot": {
|
|
43
|
+
"types": "./dist/boot.d.mts",
|
|
44
|
+
"default": "./dist/boot.mjs"
|
|
45
|
+
},
|
|
42
46
|
"./server": {
|
|
43
47
|
"types": "./dist/server.d.mts",
|
|
44
48
|
"default": "./dist/server.mjs"
|