@farm.js/islx 0.0.7 → 0.0.9

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +56 -26
  3. package/SPEC.md +5 -4
  4. package/dist/_chunks/{actions-C-IwUCKU.mjs → actions-DAzd4PpV.mjs} +2 -2
  5. package/dist/_chunks/{actions-CvQ0-zjg.mjs → actions-DzIPDecd.mjs} +2 -2
  6. package/dist/_chunks/{codec-C8to-RQW.mjs → codec-DDpUvgRo.mjs} +1 -1
  7. package/dist/_chunks/{actions-Eutk6UzT.mjs → form-status-CRfnB7WT.mjs} +314 -1
  8. package/dist/_chunks/forms-CCDY9QHC.mjs +63 -0
  9. package/dist/_chunks/{tree-C47p6khN.mjs → island-viWIOM1k.mjs} +501 -8
  10. package/dist/_chunks/{prehydration-B1GLTLdk.mjs → prehydration-zEYsfFxW.mjs} +3 -2
  11. package/dist/_chunks/{schedule-CjoybS3b.mjs → schedule-IK9_RwZC.mjs} +13 -4
  12. package/dist/_chunks/{tree-B4RMU2UX.d.mts → tree-CV13RsJv.d.mts} +8 -1
  13. package/dist/_chunks/{values-37UIzxM0.mjs → values-gxhIei_m.mjs} +2 -2
  14. package/dist/_chunks/{vite-BTzYlEiY.mjs → vite-CHB6B8tP.mjs} +36 -12
  15. package/dist/_chunks/{wire-Dipqh-EB.d.mts → wire-1d6wqOia.d.mts} +16 -1
  16. package/dist/_chunks/{wire-QEQWBr5s.mjs → wire-UUwZlUJZ.mjs} +17 -1
  17. package/dist/boot.d.mts +31 -0
  18. package/dist/boot.mjs +177 -0
  19. package/dist/client.d.mts +2 -1
  20. package/dist/client.mjs +4 -28
  21. package/dist/compiler.d.mts +35 -1
  22. package/dist/compiler.mjs +1 -1
  23. package/dist/index.d.mts +2 -2
  24. package/dist/index.mjs +3 -3
  25. package/dist/react/client.d.mts +2 -2
  26. package/dist/react/client.mjs +88 -19
  27. package/dist/react/server.d.mts +1 -1
  28. package/dist/react/server.mjs +13 -10
  29. package/dist/react.d.mts +6 -2
  30. package/dist/react.mjs +22 -3
  31. package/dist/server.d.mts +5 -0
  32. package/dist/server.mjs +4 -4
  33. package/dist/tanstack-start/server.mjs +3 -3
  34. package/dist/tanstack-start.mjs +1 -1
  35. package/dist/vite.d.mts +16 -1
  36. package/dist/vite.mjs +1 -1
  37. package/package.json +5 -1
  38. package/dist/_chunks/island-DVOr-OUw.mjs +0 -262
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.9
4
+
5
+ Islands behave as React Server Components do in four cases they did not.
6
+
7
+ - `useId` in an island returns the ids its server HTML has, and two islands no longer share ids.
8
+ The props script carries the island's id position (`ids`), which the browser rebuilds before it
9
+ hydrates. The island wrapper no longer calls `useId` inside another island, where it moved every
10
+ id below it.
11
+ - Client components in server output passed to an island (`children`, or any element prop) are
12
+ the island's descendants: they read its context, as a consumer below a provider does, and keep
13
+ their own state. Such output travels as a server tree named after its slot; plain HTML still
14
+ travels as HTML only.
15
+ - An element prop the island does not render at first (a tab, a closed panel) renders when it
16
+ does. The server writes the slots it did not place in a `<template>` after the props.
17
+ - `useFormStatus` in a client component inside a server component's `<form>` reports that form's
18
+ submit. The compiler points `useFormStatus` from `react-dom` at `@farm.js/islx/react`'s, which
19
+ is React's anywhere else; `rewriteFormStatusImports` in `@farm.js/islx/compiler` does it for
20
+ other plugins. A namespace import (`ReactDOM.useFormStatus`) keeps React's.
21
+
22
+ ## 0.0.8
23
+
24
+ Pages load the runtime only when they first need it, and islands that only react to clicks wait
25
+ for one.
26
+
27
+ - Pages start with a 1.4 KB boot script (`@farm.js/islx/boot`) instead of the browser entry
28
+ (65.6 KB on the React example). It loads the entry at once when an island hydrates at load (such
29
+ an island also preloads it), and otherwise on an island's trigger, a click, press or focus on a
30
+ waiting island, a pointer over a link, or a focus in a form. A link clicked before then is held
31
+ and followed once navigation runs. `islx({ boot: false })` starts every page with the entry.
32
+ - A `"use client"` module without a `hydrate` export waits for a click when its code, and the app
33
+ files it imports, provably do nothing before one: no effect, external store or `use()`, no
34
+ handler but `onClick` and `onSubmit` on its elements, no form field, `ref` or `autoFocus`, no
35
+ props spread onto an element, and imports only from React, stylesheets and the app. The click is
36
+ held and replayed once it hydrates. A render that passes such an island `children` hydrates at
37
+ load. `islx({ infer: false })` keeps every island at load; `inferStrategy` in
38
+ `@farm.js/islx/compiler` runs the checks for other plugins.
39
+ - A click on a same-page link (`#…`) in an island that has not hydrated is held and replayed, as a
40
+ button's is, so a table of contents that marks its heading still sees it.
41
+ - Changed defaults: an island whose module declares nothing and passes the checks above now
42
+ waits for a click. Add `export const hydrate = "load"` to keep one hydrating at load.
43
+
3
44
  ## 0.0.7
4
45
 
5
46
  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_">{"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.7. React, Vite and TanStack Start are supported; other renderers and framework
18
+ > Status: 0.0.9. 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
@@ -29,9 +29,10 @@ Anything that writes and reads it, in any language or framework, is a islx imple
29
29
  | Name | Meaning |
30
30
  | --- | --- |
31
31
  | `<f-island>` | A client component's server HTML. `src` names the module the browser imports (its path from the app root; the runtime maps it to a URL), `export` the component's export name, `props` the id of its props script, `strategy` when it hydrates: `load`, `idle`, `visible` or `interaction`. A hydrated island gets a `hydrated` attribute. |
32
- | `<script type="f/props" for>` | An island's props, as JSON. `for` names the island's `props` id. |
32
+ | `<script type="f/props" for [ids]>` | An island's props, as JSON. `for` names the island's `props` id. `ids` is the `useId` React gave a probe the server renders first in the island, at index 0 of a two-child array whose second child is the component: its bits and length are the island's id position, which the browser rebuilds before it hydrates the component at index 1, so the component's `useId` calls match its HTML. Without it the browser hydrates with React's own ids. |
33
33
  | `<script type="f/value" name>` | A value sent once. `name` is its key. Written before the first island that uses it. |
34
- | `<f-slot name>` | Server output passed to an island as children. It stays server HTML; the island renders it in place. |
34
+ | `<f-slot name>` | Server output passed to an island as children (or any element prop). Plain HTML stays server HTML, and the island renders it in place. Output holding client components, Suspense boundaries, actions or `<f-html>` parts carries a server tree (`<script type="f/tree" name>` named after the slot, inside it), which the browser rebuilds inside the island's root: its client components are the island's descendants and read its context. |
35
+ | `<template for>` | After an island's props: the `<f-slot>`s the island did not place when the server rendered it (a closed panel, a tab not shown yet). `for` names the island's `props` id. The browser reads a slot from here when the island places it. |
35
36
  | `<script type="f/tree" [name] [partial]>` | A server tree, so the browser can rebuild the page inside an existing root without its HTML. A page's tree has no `name`; a layout's carries its scope and sits at its `children` outlet, or after the layout when it has none. `partial` marks a navigation that sent only the tree. |
36
37
  | `<script type="f/chunk" name>` | A part of a server tree that streamed in after the tree (a resolved Suspense boundary). `name` is its number, after `scope:` in a layout's tree. |
37
38
  | `<f-html name>` | Server HTML inside a server tree, named like a chunk. |
@@ -41,8 +42,8 @@ A value script's, props script's and promise script's JSON escapes `<`, U+2028 a
41
42
 
42
43
  ## JSON tags
43
44
 
44
- A tagged object is `{"$":tag}` or `{"$":tag,"v":data}`; an action may also carry `"b"`. An object
45
- with any other keys is data.
45
+ A tagged object is `{"$":tag}` or `{"$":tag,"v":data}`; an action may also carry `"b"`, and a slot
46
+ `"t":1` when it carries a server tree. An object with any other keys is data.
46
47
 
47
48
  | Tag | Stands for | Data |
48
49
  | --- | --- | --- |
@@ -1,5 +1,5 @@
1
- import { A as VALUES_HEADER, F as getTag, M as VALUE_SCRIPT_TYPE, N as attachmentField, j as VALUE_KEY_PATTERN, o as BOUND_FIELD, s as CALL_FIELD, t as ACTION_HEADER } from "./wire-QEQWBr5s.mjs";
2
- import { a as encode, r as decode, u as setOwn } from "./codec-C8to-RQW.mjs";
1
+ import { F as VALUE_SCRIPT_TYPE, I as attachmentField, N as VALUES_HEADER, P as VALUE_KEY_PATTERN, R as getTag, o as BOUND_FIELD, s as CALL_FIELD, t as ACTION_HEADER } from "./wire-UUwZlUJZ.mjs";
2
+ import { a as encode, r as decode, u as setOwn } from "./codec-DDpUvgRo.mjs";
3
3
  // src/client/value-store.ts
4
4
  /**
5
5
  * The browser's store of values sent once. It keeps every value it has read, parses each once,
@@ -1,6 +1,6 @@
1
- import { P as formFieldName, d as FORM_REF_PREFIX } from "./wire-QEQWBr5s.mjs";
1
+ import { L as formFieldName, d as FORM_REF_PREFIX } from "./wire-UUwZlUJZ.mjs";
2
2
  import { t as getBuildDefaults } from "./build-COIcs7wm.mjs";
3
- import { a as encode, r as decode, t as EncodeError } from "./codec-C8to-RQW.mjs";
3
+ import { a as encode, r as decode, t as EncodeError } from "./codec-DDpUvgRo.mjs";
4
4
  // src/server/seal.ts
5
5
  /**
6
6
  * Sealing an action's captured values. An inline `"use server"` function that reads a variable of
@@ -1,4 +1,4 @@
1
- import { F as getTag } from "./wire-QEQWBr5s.mjs";
1
+ import { R as getTag } from "./wire-UUwZlUJZ.mjs";
2
2
  // src/protocol/codec.ts
3
3
  /**
4
4
  * Values JSON cannot hold. Each travels as a tagged object: the reserved key `$` holds a short tag
@@ -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,316 @@ 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
+ // src/compiler/form-status.ts
595
+ /**
596
+ * Points a client module's `useFormStatus` import from `react-dom` at islx's hook, which also
597
+ * reports a server component's form around an island (`@farm.js/islx/react` `useFormStatus`).
598
+ * Both builds rewrite it, so the server and the browser render the same hook. A namespace import
599
+ * (`ReactDOM.useFormStatus()`) keeps React's.
600
+ */
601
+ const HOOK = "useFormStatus";
602
+ function importedName(specifier) {
603
+ const imported = specifier.imported;
604
+ return imported?.type === "Identifier" ? imported.name : imported?.value;
605
+ }
606
+ /** Applies the rewrite to `output`; true when the module imported the hook. */
607
+ function rewriteFormStatusImport(program, code, output, from) {
608
+ let changed = false;
609
+ for (const node of program.body ?? []) {
610
+ if (node.type !== "ImportDeclaration" || node.source?.value !== "react-dom") continue;
611
+ if (node.importKind === "type") continue;
612
+ const specifiers = node.specifiers ?? [];
613
+ const hooks = specifiers.filter((specifier) => specifier.type === "ImportSpecifier" && specifier.importKind !== "type" && importedName(specifier) === HOOK);
614
+ if (hooks.length === 0) continue;
615
+ const others = specifiers.filter((specifier) => !hooks.includes(specifier));
616
+ const defaults = others.filter((specifier) => specifier.type !== "ImportSpecifier");
617
+ const named = others.filter((specifier) => specifier.type === "ImportSpecifier");
618
+ const text = (specifier) => code.slice(specifier.start, specifier.end);
619
+ const clause = [...defaults.map(text), ...named.length > 0 ? [`{ ${named.map(text).join(", ")} }`] : []].join(", ");
620
+ const lines = [...clause ? [`import ${clause} from "react-dom";`] : [], `import { ${hooks.map((hook) => `${HOOK} as ${hook.local.name}`).join(", ")} } from ${JSON.stringify(from)};`];
621
+ output.overwrite(node.start, node.end, lines.join("\n"));
622
+ changed = true;
623
+ }
624
+ return changed;
625
+ }
626
+ /** The rewrite for a framework's own transform: the module's new code and map, or null. */
627
+ function rewriteFormStatusImports(options) {
628
+ if (!options.code.includes(HOOK)) return null;
629
+ const output = new MagicString(options.code);
630
+ if (!rewriteFormStatusImport(options.parse(options.code), options.code, output, options.from ?? "@farm.js/islx/react")) return null;
631
+ return {
632
+ code: output.toString(),
633
+ map: output.generateMap({
634
+ source: options.id,
635
+ hires: true
636
+ })
637
+ };
638
+ }
639
+ export { getActionId as a, ActionCompileError as i, rewriteFormStatusImports as n, hasModuleDirective as o, inferStrategy as r, transformActions as s, rewriteFormStatusImport as t };
@@ -0,0 +1,63 @@
1
+ import { B as stripRuntimeFields, z as readPostedAction } from "./wire-UUwZlUJZ.mjs";
2
+ import { t as createServerReference } from "./actions-DAzd4PpV.mjs";
3
+ // src/client/forms.ts
4
+ /**
5
+ * Forms written in server components submit without reloading the page once JavaScript runs.
6
+ * No island owns them, so nothing else would: a submit of a form that posts an action is sent as
7
+ * a call, and the page refreshes as after any action, keeping island state. Forms inside islands
8
+ * are React's, which handles them first.
9
+ */
10
+ let enhanced = false;
11
+ /** No submission in flight. */
12
+ const IDLE_FORM_STATUS = Object.freeze({
13
+ pending: false,
14
+ data: null,
15
+ method: null,
16
+ action: null
17
+ });
18
+ const statuses = /* @__PURE__ */ new WeakMap();
19
+ const listeners = /* @__PURE__ */ new Set();
20
+ function setStatus(form, status) {
21
+ if (status) statuses.set(form, status);
22
+ else statuses.delete(form);
23
+ for (const listener of listeners) listener();
24
+ }
25
+ /** The submission in flight for a server form, or the idle status. */
26
+ function getServerFormStatus(form) {
27
+ return statuses.get(form) ?? IDLE_FORM_STATUS;
28
+ }
29
+ /** Calls `listener` whenever a server form starts or settles a submission. */
30
+ function subscribeServerForms(listener) {
31
+ listeners.add(listener);
32
+ return () => {
33
+ listeners.delete(listener);
34
+ };
35
+ }
36
+ /** Starts handling server-component forms on this page. Safe to call more than once. */
37
+ function enhanceForms() {
38
+ if (enhanced) return;
39
+ enhanced = true;
40
+ document.addEventListener("submit", (event) => {
41
+ if (event.defaultPrevented || !(event.target instanceof HTMLFormElement)) return;
42
+ const form = event.target;
43
+ const submitter = event.submitter ?? null;
44
+ const data = new FormData(form, submitter);
45
+ const posted = readPostedAction(data);
46
+ if (!posted) return;
47
+ event.preventDefault();
48
+ stripRuntimeFields(data);
49
+ form.setAttribute("aria-busy", "true");
50
+ const action = createServerReference(posted.id, posted.token);
51
+ setStatus(form, {
52
+ pending: true,
53
+ data,
54
+ method: form.method || "get",
55
+ action
56
+ });
57
+ action(data).then(() => form.reset()).catch((error) => console.error("[islx] The form's action failed:", error)).finally(() => {
58
+ form.removeAttribute("aria-busy");
59
+ setStatus(form, null);
60
+ });
61
+ });
62
+ }
63
+ export { subscribeServerForms as i, enhanceForms as n, getServerFormStatus as r, IDLE_FORM_STATUS as t };