@farm.js/islx 0.0.6 → 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 +39 -0
- package/README.md +56 -26
- package/SPEC.md +7 -4
- package/dist/_chunks/{actions-BuS5jDi0.mjs → actions-CvQ0-zjg.mjs} +15 -14
- 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 +17 -9
- package/dist/react.d.mts +1 -1
- package/dist/react.mjs +2 -2
- package/dist/server.d.mts +9 -4
- package/dist/server.mjs +1 -1
- 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,44 @@
|
|
|
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
|
+
|
|
25
|
+
## 0.0.7
|
|
26
|
+
|
|
27
|
+
Cloudflare Workers start, and sealed captured values name their key with 4 bytes.
|
|
28
|
+
|
|
29
|
+
- `@farm.js/islx/react/server` no longer generates random values while it loads. It made the
|
|
30
|
+
promise-name prefix with `crypto.getRandomValues` at module scope, which Workers reject
|
|
31
|
+
("Disallowed operation called within global scope"), so no islx app started on Cloudflare
|
|
32
|
+
since promise props (0.0.4). The prefix is made at the first render now.
|
|
33
|
+
- An inline `"use server"` function's captured values are sealed with format version 2: the key id
|
|
34
|
+
is the first 4 bytes of the key's SHA-256 instead of the first byte. With one byte, a deploy that
|
|
35
|
+
changed keys without keeping the old one gave the new key the same id one time in 256, and
|
|
36
|
+
values from pages rendered before it failed as invalid (`400`) instead of stale (the page
|
|
37
|
+
reloads). An app that does not set `ISLX_ACTIONS_KEY` gets a new key on every build, so this hit
|
|
38
|
+
about one deploy in 256.
|
|
39
|
+
- Values sealed by 0.0.6 or earlier (format version 1) are stale: forms on pages open across the
|
|
40
|
+
upgrade reload once.
|
|
41
|
+
|
|
3
42
|
## 0.0.6
|
|
4
43
|
|
|
5
44
|
Server action interceptor, early clicks on TanStack Start, library islands under pnpm.
|
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
|
|
package/SPEC.md
CHANGED
|
@@ -198,14 +198,17 @@ travel with the action, sealed, so the browser can neither read nor change them:
|
|
|
198
198
|
sealed = base64url( version ‖ key id ‖ IV ‖ AES-256-GCM(ciphertext ‖ tag) )
|
|
199
199
|
```
|
|
200
200
|
|
|
201
|
-
- `version` is `
|
|
201
|
+
- `version` is `2` (one byte); `key id` is the first 4 bytes of SHA-256 of the key, big-endian;
|
|
202
|
+
the IV is 12 random bytes;
|
|
202
203
|
- the additional data is `islx:action:<id>`, so values sealed for one action never open for
|
|
203
204
|
another;
|
|
204
205
|
- the plaintext is the captured values as a tagged JSON array.
|
|
205
206
|
|
|
206
|
-
A value sealed with a key the server does not have is **stale** (the
|
|
207
|
-
does not open is **invalid** (`400`). Servers may hold previous keys,
|
|
208
|
-
key rotation keep working. Every server instance must share the keys.
|
|
207
|
+
A value sealed with a key the server does not have, or in another `version`, is **stale** (the
|
|
208
|
+
page reloads); anything that does not open is **invalid** (`400`). Servers may hold previous keys,
|
|
209
|
+
so pages rendered before a key rotation keep working. Every server instance must share the keys.
|
|
210
|
+
Version 1 named the key with one byte, so one key rotation in 256 made values from open pages
|
|
211
|
+
invalid instead of stale; its values are stale now.
|
|
209
212
|
|
|
210
213
|
## Integration model
|
|
211
214
|
|
|
@@ -24,10 +24,11 @@ import { a as encode, r as decode, t as EncodeError } from "./codec-C8to-RQW.mjs
|
|
|
24
24
|
* Sealing is asynchronous. React's server renderer waits for it: a form whose action's values
|
|
25
25
|
* are still sealing suspends, and renders once they are ready.
|
|
26
26
|
*
|
|
27
|
-
* sealed = base64url( version (1 byte) ‖ key id (
|
|
27
|
+
* sealed = base64url( version (1 byte) ‖ key id (4 bytes) ‖ IV (12 bytes) ‖ ciphertext+tag )
|
|
28
28
|
*/
|
|
29
|
-
const VERSION =
|
|
29
|
+
const VERSION = 2;
|
|
30
30
|
const IV_BYTES = 12;
|
|
31
|
+
const HEADER_BYTES = 5;
|
|
31
32
|
const KEY_BYTES = 32;
|
|
32
33
|
/** Thrown when a sealed value does not open: changed, made for another action, or stale. */
|
|
33
34
|
var SealError = class extends Error {
|
|
@@ -58,7 +59,7 @@ function fromBase64url(text) {
|
|
|
58
59
|
return null;
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
|
-
/** A new random key, as base64url: what `
|
|
62
|
+
/** A new random key, as base64url: what `ISLX_ACTIONS_KEY` holds. */
|
|
62
63
|
function generateActionKey() {
|
|
63
64
|
return base64url(crypto.getRandomValues(new Uint8Array(KEY_BYTES)));
|
|
64
65
|
}
|
|
@@ -67,10 +68,10 @@ async function importKey(secret) {
|
|
|
67
68
|
if (!bytes || bytes.length !== KEY_BYTES) throw new TypeError("an actions key must be 32 random bytes as base64url; create one with generateActionKey() from @farm.js/islx/server");
|
|
68
69
|
return crypto.subtle.importKey("raw", bytes, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
69
70
|
}
|
|
70
|
-
/** The key's id:
|
|
71
|
+
/** The key's id: the first 4 bytes of its SHA-256, so a value names the key that sealed it. */
|
|
71
72
|
async function keyId(secret) {
|
|
72
73
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret.trim()));
|
|
73
|
-
return new
|
|
74
|
+
return new DataView(digest).getUint32(0);
|
|
74
75
|
}
|
|
75
76
|
/**
|
|
76
77
|
* The keys values are sealed and opened with. `current` seals; `previous` keys still open
|
|
@@ -128,11 +129,11 @@ async function sealBound(keys, actionId, values, options = {}) {
|
|
|
128
129
|
iv,
|
|
129
130
|
additionalData: additionalData(actionId)
|
|
130
131
|
}, keys.current, new TextEncoder().encode(json)));
|
|
131
|
-
const sealed = new Uint8Array(
|
|
132
|
+
const sealed = new Uint8Array(17 + ciphertext.length);
|
|
132
133
|
sealed[0] = VERSION;
|
|
133
|
-
sealed
|
|
134
|
-
sealed.set(iv,
|
|
135
|
-
sealed.set(ciphertext,
|
|
134
|
+
new DataView(sealed.buffer).setUint32(1, keys.currentId);
|
|
135
|
+
sealed.set(iv, HEADER_BYTES);
|
|
136
|
+
sealed.set(ciphertext, 17);
|
|
136
137
|
return base64url(sealed);
|
|
137
138
|
}
|
|
138
139
|
/**
|
|
@@ -141,17 +142,17 @@ async function sealBound(keys, actionId, values, options = {}) {
|
|
|
141
142
|
*/
|
|
142
143
|
async function unsealBound(keys, actionId, sealed, options = {}) {
|
|
143
144
|
const bytes = fromBase64url(sealed);
|
|
144
|
-
if (
|
|
145
|
-
if (bytes
|
|
146
|
-
const candidates = keys.byId.get(bytes
|
|
145
|
+
if (bytes && bytes.length > 0 && bytes[0] !== VERSION) throw new SealError("stale", "sealed action values use another format version");
|
|
146
|
+
if (!bytes || bytes.length < 33) throw new SealError("invalid", "sealed action values are malformed");
|
|
147
|
+
const candidates = keys.byId.get(new DataView(bytes.buffer).getUint32(1));
|
|
147
148
|
if (!candidates) throw new SealError("stale", "sealed action values use a key this server does not have");
|
|
148
149
|
let plaintext;
|
|
149
150
|
for (const key of candidates) try {
|
|
150
151
|
plaintext = await crypto.subtle.decrypt({
|
|
151
152
|
name: "AES-GCM",
|
|
152
|
-
iv: bytes.subarray(
|
|
153
|
+
iv: bytes.subarray(HEADER_BYTES, 17),
|
|
153
154
|
additionalData: additionalData(actionId)
|
|
154
|
-
}, key, bytes.subarray(
|
|
155
|
+
}, key, bytes.subarray(17));
|
|
155
156
|
break;
|
|
156
157
|
} catch {}
|
|
157
158
|
if (!plaintext) throw new SealError("invalid", "sealed action values do not open for this action");
|
|
@@ -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];
|