@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.
- package/CHANGELOG.md +41 -0
- package/README.md +56 -26
- package/SPEC.md +5 -4
- package/dist/_chunks/{actions-C-IwUCKU.mjs → actions-DAzd4PpV.mjs} +2 -2
- package/dist/_chunks/{actions-CvQ0-zjg.mjs → actions-DzIPDecd.mjs} +2 -2
- package/dist/_chunks/{codec-C8to-RQW.mjs → codec-DDpUvgRo.mjs} +1 -1
- package/dist/_chunks/{actions-Eutk6UzT.mjs → form-status-CRfnB7WT.mjs} +314 -1
- package/dist/_chunks/forms-CCDY9QHC.mjs +63 -0
- package/dist/_chunks/{tree-C47p6khN.mjs → island-viWIOM1k.mjs} +501 -8
- package/dist/_chunks/{prehydration-B1GLTLdk.mjs → prehydration-zEYsfFxW.mjs} +3 -2
- package/dist/_chunks/{schedule-CjoybS3b.mjs → schedule-IK9_RwZC.mjs} +13 -4
- package/dist/_chunks/{tree-B4RMU2UX.d.mts → tree-CV13RsJv.d.mts} +8 -1
- package/dist/_chunks/{values-37UIzxM0.mjs → values-gxhIei_m.mjs} +2 -2
- package/dist/_chunks/{vite-BTzYlEiY.mjs → vite-CHB6B8tP.mjs} +36 -12
- package/dist/_chunks/{wire-Dipqh-EB.d.mts → wire-1d6wqOia.d.mts} +16 -1
- package/dist/_chunks/{wire-QEQWBr5s.mjs → wire-UUwZlUJZ.mjs} +17 -1
- package/dist/boot.d.mts +31 -0
- package/dist/boot.mjs +177 -0
- package/dist/client.d.mts +2 -1
- package/dist/client.mjs +4 -28
- package/dist/compiler.d.mts +35 -1
- package/dist/compiler.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +3 -3
- package/dist/react/client.d.mts +2 -2
- package/dist/react/client.mjs +88 -19
- package/dist/react/server.d.mts +1 -1
- package/dist/react/server.mjs +13 -10
- package/dist/react.d.mts +6 -2
- package/dist/react.mjs +22 -3
- package/dist/server.d.mts +5 -0
- package/dist/server.mjs +4 -4
- package/dist/tanstack-start/server.mjs +3 -3
- 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/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_">
|
|
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.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, {
|
|
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
|
@@ -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.
|
|
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"
|
|
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 {
|
|
2
|
-
import { a as encode, r as decode, u as setOwn } from "./codec-
|
|
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 {
|
|
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-
|
|
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
|
|
@@ -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
|
-
|
|
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 };
|