@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 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_">{"updated":{"$":"D","v":"2026-10-28T09:00:00.000Z"}}</script>
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.6. React, Vite and TanStack Start are supported; other renderers and framework
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, { headers: { "content-type": "text/html", "cache-control": "private" } });
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" }; default "load"
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) { if (window.unsaved) event.preventDefault(); }, // stay on the page
146
- navigated({ url, duration }) { window.plausible?.("pageview", { u: url.href }); },
147
- hydrate({ export: name, duration }) { console.debug(name, "interactive in", duration); },
148
- islandError({ src, error }) { console.error(src, error); },
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) { /* check, log, time */ return 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 | API |
213
- | --- | --- |
214
- | Compile client and action modules | `transformClientModule({ …, reference, strategy })`, `transformActions` from `@farm.js/islx/compiler` |
215
- | Render islands in its own render | wrap the page in `<IslandsProvider held={parseHeldValues(request)}>` |
216
- | A layout that hydrates as one root | `withinClientRoot(layout)`; its server-HTML page: `isolateIslands(page)` |
217
- | An async page under such a layout | `renderServerTree(page, { held, payloadOnly })` on the server; `createServerTreeElement(source, { load, ServerHtml })` in the browser |
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 | `createHydrationRuntime({ load, schedule?, wrap?, report? })`, `createServerHtml(runtime)` |
220
- | Hold early clicks | inline `PREHYDRATION_SCRIPT` in the page |
221
- | Actions | `handleAction(request, { allowedOrigins, bodySizeLimit, run, mapError, renderFormState })`; `setActionNavigator({ refresh, navigate })` |
222
- | Navigation with its router | send `F-Values` (`valueHeaders()`), `F-Layouts`, `F-Render`; `refreshDocument` / `swapDocument` or its own swap |
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 | For |
227
- | --- | --- |
228
- | `@farm.js/islx/react` | server trees (a page rebuilt inside a hydrated layout), partial navigations |
229
- | `@farm.js/islx/webpack` | webpack, Rspack, and Next.js (Turbopack uses the loader) |
230
- | `@farm.js/islx/next`, `@farm.js/islx/astro`, `@farm.js/islx/nuxt` | thin framework integrations |
231
- | `@farm.js/islx/testing` | the conformance suite every integration must pass |
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 `1`; `key id` is the first byte of SHA-256 of the key; the IV is 12 random bytes;
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 page reloads); anything that
207
- does not open is **invalid** (`400`). Servers may hold previous keys, so pages rendered before a
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 (1 byte) ‖ IV (12 bytes) ‖ ciphertext+tag )
27
+ * sealed = base64url( version (1 byte) ‖ key id (4 bytes) ‖ IV (12 bytes) ‖ ciphertext+tag )
28
28
  */
29
- const VERSION = 1;
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 `FURROW_ACTIONS_KEY` holds. */
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: its first byte of SHA-256, so a value names the key that sealed it. */
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 Uint8Array(digest)[0];
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(14 + ciphertext.length);
132
+ const sealed = new Uint8Array(17 + ciphertext.length);
132
133
  sealed[0] = VERSION;
133
- sealed[1] = keys.currentId;
134
- sealed.set(iv, 2);
135
- sealed.set(ciphertext, 14);
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 (!bytes || bytes.length < 30) throw new SealError("invalid", "sealed action values are malformed");
145
- if (bytes[0] !== VERSION) throw new SealError("stale", "sealed action values use another format version");
146
- const candidates = keys.byId.get(bytes[1]);
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(2, 14),
153
+ iv: bytes.subarray(HEADER_BYTES, 17),
153
154
  additionalData: additionalData(actionId)
154
- }, key, bytes.subarray(14));
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
- export { transformActions as i, getActionId as n, hasModuleDirective as r, ActionCompileError as t };
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, strategy = "load") {
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 { render: rendered, wire } = splitSlots(props, `${id}s`, { next: 0 });
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") for (const href of getBuildDefaults().modules?.[src] ?? []) ReactDOM.preloadModule?.(href, { as: "script" });
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 t=e.target&&e.target.closest&&e.target.closest(${JSON.stringify(REPLAYABLE_SELECTOR)});if(!t||t.closest("a[href]")||!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
+ 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];