hono-preact 0.2.0 → 0.3.0

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 (58) hide show
  1. package/dist/iso/action-result-context.d.ts +22 -0
  2. package/dist/iso/action-result-context.js +2 -0
  3. package/dist/iso/action.d.ts +52 -13
  4. package/dist/iso/action.js +204 -88
  5. package/dist/iso/cache.d.ts +9 -0
  6. package/dist/iso/cache.js +26 -0
  7. package/dist/iso/define-app.d.ts +7 -0
  8. package/dist/iso/define-loader.d.ts +12 -0
  9. package/dist/iso/define-loader.js +26 -16
  10. package/dist/iso/form.d.ts +13 -4
  11. package/dist/iso/form.js +115 -33
  12. package/dist/iso/index.d.ts +7 -4
  13. package/dist/iso/index.js +5 -2
  14. package/dist/iso/internal/action-envelope.d.ts +37 -0
  15. package/dist/iso/internal/action-envelope.js +47 -0
  16. package/dist/iso/internal/action-result-store.d.ts +28 -0
  17. package/dist/iso/internal/action-result-store.js +35 -0
  18. package/dist/iso/internal/envelope.js +1 -2
  19. package/dist/iso/internal/form-submit-store.d.ts +9 -0
  20. package/dist/iso/internal/form-submit-store.js +32 -0
  21. package/dist/iso/internal/loader-fetch.js +65 -34
  22. package/dist/iso/internal/loader.d.ts +3 -3
  23. package/dist/iso/internal/route-boundary.d.ts +4 -4
  24. package/dist/iso/internal/safe-redirect.d.ts +7 -0
  25. package/dist/iso/internal/safe-redirect.js +27 -0
  26. package/dist/iso/internal/sse-decoder.d.ts +1 -1
  27. package/dist/iso/internal/sse-decoder.js +40 -26
  28. package/dist/iso/internal.d.ts +7 -1
  29. package/dist/iso/internal.js +8 -1
  30. package/dist/iso/optimistic-action.d.ts +10 -1
  31. package/dist/iso/optimistic-action.js +11 -3
  32. package/dist/iso/optimistic.d.ts +10 -1
  33. package/dist/iso/optimistic.js +45 -5
  34. package/dist/iso/outcomes.d.ts +14 -2
  35. package/dist/iso/outcomes.js +14 -3
  36. package/dist/iso/use-action-result.d.ts +25 -0
  37. package/dist/iso/use-action-result.js +39 -0
  38. package/dist/iso/use-form-status.d.ts +5 -0
  39. package/dist/iso/use-form-status.js +13 -0
  40. package/dist/server/actions-handler.d.ts +7 -0
  41. package/dist/server/actions-handler.js +42 -9
  42. package/dist/server/index.d.ts +2 -1
  43. package/dist/server/index.js +2 -1
  44. package/dist/server/loaders-handler.d.ts +8 -0
  45. package/dist/server/loaders-handler.js +37 -4
  46. package/dist/server/page-action-handler.d.ts +63 -0
  47. package/dist/server/page-action-handler.js +274 -0
  48. package/dist/server/page-action-resolvers.d.ts +28 -0
  49. package/dist/server/page-action-resolvers.js +147 -0
  50. package/dist/server/render.js +41 -3
  51. package/dist/server/route-server-modules.d.ts +7 -8
  52. package/dist/server/route-server-modules.js +7 -8
  53. package/dist/server/speculation-rules.d.ts +3 -0
  54. package/dist/server/speculation-rules.js +8 -0
  55. package/dist/server/sse.d.ts +43 -28
  56. package/dist/server/sse.js +113 -88
  57. package/dist/vite/server-entry.js +10 -2
  58. package/package.json +2 -2
@@ -0,0 +1,22 @@
1
+ export type ActionResultContextValue = {
2
+ module: string;
3
+ action: string;
4
+ kind: 'success';
5
+ data: unknown;
6
+ submittedPayload: unknown;
7
+ } | {
8
+ module: string;
9
+ action: string;
10
+ kind: 'deny';
11
+ status: number;
12
+ message: string;
13
+ data?: unknown;
14
+ submittedPayload: unknown;
15
+ } | {
16
+ module: string;
17
+ action: string;
18
+ kind: 'error';
19
+ message: string;
20
+ submittedPayload: unknown;
21
+ } | null;
22
+ export declare const ActionResultContext: import("preact").Context<ActionResultContextValue>;
@@ -0,0 +1,2 @@
1
+ import { createContext } from 'preact';
2
+ export const ActionResultContext = createContext(null);
@@ -15,13 +15,34 @@ export type ActionFn<TPayload, TResult, TChunk = never> = ((ctx: ActionCtx, payl
15
15
  export type DefineActionOpts<TChunk = never, TResult = unknown> = {
16
16
  /**
17
17
  * Per-action middleware and (for streaming actions) stream observers.
18
- * Attached to the function as a non-enumerable-feeling property; the
19
- * actions-handler reads it via the dispatcher (Task 18).
18
+ * Attached to the function as a non-enumerable property; the
19
+ * page-action-handler reads it through the typed `ActionEntry` map built at
20
+ * module-load time (`packages/server/src/page-action-handler.ts`).
20
21
  */
21
22
  use?: ActionUse<TChunk, TResult, boolean>;
23
+ /**
24
+ * Per-action timeout in milliseconds. When omitted, the handler applies
25
+ * its configured default (30s). Pass `false` to disable the timeout for
26
+ * this action.
27
+ */
28
+ timeoutMs?: number | false;
29
+ /**
30
+ * The module key the client-side `useAction` hook will reference in its
31
+ * RPC envelope. Production wires this through the Vite plugin's
32
+ * client-stub emission; test code can pass it directly to construct a
33
+ * properly-shaped stub without bypassing the type system.
34
+ */
35
+ __module?: string;
36
+ /** The action name the client-side `useAction` hook will reference. */
37
+ __action?: string;
22
38
  };
39
+ export declare class TimeoutError extends Error {
40
+ readonly kind: "timeout";
41
+ readonly timeoutMs: number;
42
+ constructor(timeoutMs: number);
43
+ }
23
44
  export declare function defineAction<TPayload, TResult, TChunk = never>(fn: ActionFn<TPayload, TResult, TChunk>, opts?: DefineActionOpts<TChunk, TResult>): ActionStub<TPayload, TResult, TChunk>;
24
- export type UseActionOptions<TPayload, TResult, TChunk = never, TSnapshot = unknown> = {
45
+ type UseActionOptionsCommon<TChunk = never> = {
25
46
  /**
26
47
  * How to update loader caches after the action commits. Three modes:
27
48
  *
@@ -37,30 +58,47 @@ export type UseActionOptions<TPayload, TResult, TChunk = never, TSnapshot = unkn
37
58
  * See `/docs/reloading` for the full mental model.
38
59
  */
39
60
  invalidate?: 'auto' | false | ReadonlyArray<LoaderRef<unknown>>;
40
- onMutate?: (payload: TPayload) => TSnapshot;
41
61
  onChunk?: (chunk: TChunk) => void;
62
+ };
63
+ /**
64
+ * Options when `onMutate` is provided. `onSuccess` / `onError` receive the
65
+ * value `onMutate` returned for this specific mutation as the second
66
+ * parameter, so concurrent calls can be paired with their own snapshot.
67
+ */
68
+ type UseActionWithMutate<TPayload, TResult, TChunk, TSnapshot> = UseActionOptionsCommon<TChunk> & {
69
+ onMutate: (payload: TPayload) => TSnapshot;
42
70
  onError?: (err: Error, snapshot: TSnapshot) => void;
43
71
  onSuccess?: (data: TResult, snapshot: TSnapshot) => void;
44
72
  };
73
+ /**
74
+ * Options when `onMutate` is not provided. `onSuccess` / `onError` take
75
+ * only the result / error — there is no snapshot to thread through.
76
+ */
77
+ type UseActionWithoutMutate<TResult, TChunk> = UseActionOptionsCommon<TChunk> & {
78
+ onMutate?: undefined;
79
+ onError?: (err: Error) => void;
80
+ onSuccess?: (data: TResult) => void;
81
+ };
82
+ /**
83
+ * Discriminated by `onMutate`. Providing `onMutate` requires the
84
+ * `onSuccess` / `onError` callbacks to accept the snapshot; omitting
85
+ * `onMutate` types those callbacks as single-argument.
86
+ */
87
+ export type UseActionOptions<TPayload, TResult, TChunk = never, TSnapshot = unknown> = UseActionWithMutate<TPayload, TResult, TChunk, TSnapshot> | UseActionWithoutMutate<TResult, TChunk>;
45
88
  /**
46
89
  * The value `mutate` resolves to. A discriminated union so callers can
47
90
  * chain on success without awaiting then probing the hook's `data`/`error`
48
91
  * state, and without leaking unhandled rejections in fire-and-forget callers.
49
92
  *
50
- * - Success: `{ ok: true, data }`. For streaming actions that emit no
51
- * `result` SSE event, `data` is `undefined`; declare `TResult = void` (or
52
- * include `undefined` in its union) if your action doesn't emit a result.
93
+ * - Success: `{ ok: true, data }`. `data` is `undefined` for streaming
94
+ * actions that close without emitting a `result` SSE event (the type
95
+ * reflects this honestly: callers must narrow before using `data`).
53
96
  * - Failure: `{ ok: false, error }`. The same `Error` instance is also
54
97
  * written to the hook's `error` state and passed to `onError`.
55
- *
56
- * Returning a union (rather than throwing) keeps `mutate(...)` ergonomic
57
- * for non-awaiting call sites — the existing `error` state field is the
58
- * idiomatic way to render an error UI — while still letting awaiting
59
- * callers do `if (result.ok) navigate(...)`.
60
98
  */
61
99
  export type MutateResult<TResult> = {
62
100
  ok: true;
63
- data: TResult;
101
+ data: TResult | undefined;
64
102
  } | {
65
103
  ok: false;
66
104
  error: Error;
@@ -72,3 +110,4 @@ export type UseActionResult<TPayload, TResult> = {
72
110
  data: TResult | null;
73
111
  };
74
112
  export declare function useAction<TPayload, TResult, TChunk = never, TSnapshot = unknown>(stub: ActionStub<TPayload, TResult, TChunk>, options?: UseActionOptions<TPayload, TResult, TChunk, TSnapshot>): UseActionResult<TPayload, TResult>;
113
+ export {};
@@ -1,25 +1,62 @@
1
1
  import { useCallback, useContext, useRef, useState } from 'preact/hooks';
2
2
  import { ReloadContext } from './reload-context.js';
3
3
  import { ActiveLoaderIdContext } from './internal/contexts.js';
4
+ import { beginSubmit, endSubmit } from './internal/form-submit-store.js';
5
+ import { assignSafeRedirect } from './internal/safe-redirect.js';
6
+ import { setLastActionResult, } from './internal/action-result-store.js';
7
+ export class TimeoutError extends Error {
8
+ kind = 'timeout';
9
+ timeoutMs;
10
+ constructor(timeoutMs) {
11
+ super(`Request timed out after ${timeoutMs}ms`);
12
+ this.name = 'TimeoutError';
13
+ this.timeoutMs = timeoutMs;
14
+ }
15
+ }
16
+ function validateTimeoutMs(value, context) {
17
+ if (value === undefined || value === false)
18
+ return;
19
+ if (!Number.isFinite(value) || value < 0) {
20
+ throw new RangeError(`${context}: timeoutMs must be a non-negative finite number or false, got ${String(value)}`);
21
+ }
22
+ }
4
23
  export function defineAction(fn, opts) {
5
- // Runtime no-op for the call itself: returns fn as-is. The ActionStub type
6
- // is enforced only by TypeScript and the Vite plugin. The dispatcher reads
7
- // `use` off the function-as-stub when running the chain.
24
+ validateTimeoutMs(opts?.timeoutMs, 'defineAction');
25
+ // SHAPE NOTE: `ActionStub` describes the CLIENT-side shape produced by the
26
+ // Vite plugin (`packages/vite/src/server-only.ts`) an object with
27
+ // `__module`, `__action`, and a `useAction` method. `defineAction` runs on
28
+ // the SERVER side and returns the raw function with metadata attached via
29
+ // `Object.defineProperty`. These are two different runtime shapes unified
30
+ // under one type so consumers can import a server action and use it
31
+ // identically on both sides; the plugin handles the substitution at the
32
+ // value level. The `as unknown as` cast at the return is the single
33
+ // bounded acknowledgement of this dual-shape contract. A future cleanup
34
+ // could split into `ServerActionImpl` / `ActionStub` types if the lie
35
+ // starts to bite, but for now it's localized and documented.
8
36
  //
9
- // Use `Object.defineProperty` instead of direct assignment so a frozen
10
- // module export (strict ESM, HMR-frozen modules) doesn't throw. The
11
- // actions-handler reads via `(fn as { use?: ReadonlyArray<unknown> }).use`,
12
- // which works whether the property was set by assignment or defineProperty.
13
- if (opts?.use) {
14
- Object.defineProperty(fn, 'use', {
15
- value: opts.use,
37
+ // `Object.defineProperty` is used instead of direct assignment so a frozen
38
+ // module export (strict ESM, HMR-frozen modules) does not throw.
39
+ const attach = (key, value) => {
40
+ Object.defineProperty(fn, key, {
41
+ value,
16
42
  configurable: true,
17
43
  writable: true,
18
44
  enumerable: false,
19
45
  });
20
- }
46
+ };
47
+ if (opts?.use)
48
+ attach('use', opts.use);
49
+ if (opts?.timeoutMs !== undefined)
50
+ attach('timeoutMs', opts.timeoutMs);
51
+ if (opts?.__module !== undefined)
52
+ attach('__module', opts.__module);
53
+ if (opts?.__action !== undefined)
54
+ attach('__action', opts.__action);
21
55
  return fn;
22
56
  }
57
+ function recordOutcome(module, action, result) {
58
+ setLastActionResult(module, action, result);
59
+ }
23
60
  function hasFileValues(payload) {
24
61
  if (typeof File === 'undefined')
25
62
  return false;
@@ -42,18 +79,49 @@ export function useAction(stub, options) {
42
79
  setError(null);
43
80
  const currentStub = stubRef.current;
44
81
  const currentOptions = optionsRef.current;
45
- let snapshot;
46
- if (currentOptions?.onMutate) {
47
- snapshot = currentOptions.onMutate(payload);
48
- }
82
+ const callbacks = currentOptions?.onMutate
83
+ ? {
84
+ kind: 'with-mutate',
85
+ snapshot: currentOptions.onMutate(payload),
86
+ onSuccess: currentOptions.onSuccess,
87
+ onError: currentOptions.onError,
88
+ }
89
+ : {
90
+ kind: 'without-mutate',
91
+ onSuccess: currentOptions?.onSuccess,
92
+ onError: currentOptions?.onError,
93
+ };
94
+ const invokeSuccess = (data) => {
95
+ if (callbacks.kind === 'with-mutate') {
96
+ callbacks.onSuccess?.(data, callbacks.snapshot);
97
+ }
98
+ else {
99
+ callbacks.onSuccess?.(data);
100
+ }
101
+ };
102
+ const invokeError = (err) => {
103
+ if (callbacks.kind === 'with-mutate') {
104
+ callbacks.onError?.(err, callbacks.snapshot);
105
+ }
106
+ else {
107
+ callbacks.onError?.(err);
108
+ }
109
+ };
110
+ const target = typeof window !== 'undefined'
111
+ ? window.location.pathname + window.location.search
112
+ : '/';
49
113
  let finalResult;
114
+ // Tracks whether a branch has already written to the action-result store.
115
+ // The outer catch writes for unclassified errors (network failures, parse
116
+ // errors) only when no branch has already recorded the outcome.
117
+ let outcomeRecorded = false;
118
+ beginSubmit(currentStub.__module, currentStub.__action);
50
119
  try {
51
- const stub = currentStub;
52
120
  let response;
53
121
  if (hasFileValues(payload)) {
54
122
  const fd = new FormData();
55
- fd.append('__module', stub.__module);
56
- fd.append('__action', stub.__action);
123
+ fd.append('__module', currentStub.__module);
124
+ fd.append('__action', currentStub.__action);
57
125
  for (const [key, value] of Object.entries(payload)) {
58
126
  if (key === '__module' || key === '__action')
59
127
  continue;
@@ -67,75 +135,27 @@ export function useAction(stub, options) {
67
135
  fd.append(key, JSON.stringify(value));
68
136
  }
69
137
  }
70
- response = await fetch('/__actions', { method: 'POST', body: fd });
138
+ response = await fetch(target, {
139
+ method: 'POST',
140
+ headers: { Accept: 'application/json, text/event-stream;q=0.9' },
141
+ body: fd,
142
+ });
71
143
  }
72
144
  else {
73
- response = await fetch('/__actions', {
145
+ response = await fetch(target, {
74
146
  method: 'POST',
75
- headers: { 'Content-Type': 'application/json' },
147
+ headers: {
148
+ 'Content-Type': 'application/json',
149
+ Accept: 'application/json, text/event-stream;q=0.9',
150
+ },
76
151
  body: JSON.stringify({
77
- module: stub.__module,
78
- action: stub.__action,
152
+ module: currentStub.__module,
153
+ action: currentStub.__action,
79
154
  payload,
80
155
  }),
81
156
  });
82
157
  }
83
- if (!response.ok) {
84
- const body = (await response.json().catch(() => ({})));
85
- // Deny outcomes carry `message` instead of the legacy `error`
86
- // field; prefer the descriptive message when present. The deny()
87
- // constructor defaults the message for first-party callers, but a
88
- // hand-rolled envelope from custom server middleware might still
89
- // ship without one; fall back to a deny-aware label so the user
90
- // sees a hint that the status came from an explicit deny rather
91
- // than a generic transport failure.
92
- let msg;
93
- if (body.__outcome === 'deny') {
94
- msg =
95
- typeof body.message === 'string'
96
- ? body.message
97
- : `Request denied (${response.status})`;
98
- }
99
- else {
100
- msg = body.error ?? `Action failed with status ${response.status}`;
101
- }
102
- throw new Error(msg);
103
- }
104
158
  const contentType = response.headers.get('Content-Type') ?? '';
105
- // Server-side middleware that throws `redirect(...)` comes back as
106
- // a redirect outcome envelope. Hand off to the browser; the rest of
107
- // this promise will never settle, but the page is navigating away
108
- // anyway.
109
- //
110
- // Trust boundary: `to` is taken straight from the JSON body and
111
- // passed to `window.location.assign`. The framework's own handlers
112
- // emit safe (typically same-origin) values, but a compromised or
113
- // misconfigured server (or a proxy injecting JSON) could push the
114
- // client anywhere. We don't validate origin here for v0.1; treat
115
- // your own server as part of the trusted boundary. A same-origin
116
- // check is a deferred enhancement (see C4 in the middleware review).
117
- //
118
- // We use `response.clone().json()` to peek at the body without
119
- // consuming it: if the response is NOT a redirect outcome the
120
- // downstream `await response.json()` still needs to read it. Clone
121
- // is cheap on a small JSON payload.
122
- if (!contentType.includes('text/event-stream')) {
123
- const peek = (await response
124
- .clone()
125
- .json()
126
- .catch(() => undefined));
127
- if (peek !== null &&
128
- typeof peek === 'object' &&
129
- peek.__outcome === 'redirect' &&
130
- typeof peek.to === 'string') {
131
- const to = peek.to;
132
- if (typeof window !== 'undefined') {
133
- window.location.assign(to);
134
- }
135
- // Cast through `as` because TS can't see this promise never settles.
136
- return await new Promise(() => { });
137
- }
138
- }
139
159
  if (contentType.includes('text/event-stream') && response.body) {
140
160
  const { readSSE } = await import('./internal/sse-decoder.js');
141
161
  let resultValue;
@@ -157,6 +177,15 @@ export function useAction(stub, options) {
157
177
  streamError = new Error(`Malformed result event in stream: ${e instanceof Error ? e.message : String(e)}`);
158
178
  }
159
179
  }
180
+ else if (ev.event === 'timeout') {
181
+ try {
182
+ const parsed = JSON.parse(ev.data);
183
+ streamError = new TimeoutError(parsed.timeoutMs ?? 0);
184
+ }
185
+ catch (e) {
186
+ streamError = new Error(`Malformed timeout event in stream: ${e instanceof Error ? e.message : String(e)}`);
187
+ }
188
+ }
160
189
  else if (ev.event === 'error') {
161
190
  try {
162
191
  const parsed = JSON.parse(ev.data);
@@ -170,26 +199,101 @@ export function useAction(stub, options) {
170
199
  }
171
200
  }
172
201
  if (streamError) {
202
+ recordOutcome(currentStub.__module, currentStub.__action, {
203
+ kind: 'error',
204
+ message: streamError.message,
205
+ submittedPayload: payload,
206
+ });
207
+ outcomeRecorded = true;
173
208
  throw streamError;
174
209
  }
175
210
  if (resultValue !== undefined) {
176
211
  setData(resultValue);
177
- currentOptions?.onSuccess?.(resultValue, snapshot);
212
+ invokeSuccess(resultValue);
178
213
  finalResult = resultValue;
214
+ recordOutcome(currentStub.__module, currentStub.__action, {
215
+ kind: 'success',
216
+ data: resultValue,
217
+ submittedPayload: payload,
218
+ });
219
+ outcomeRecorded = true;
179
220
  }
180
221
  else {
181
- currentOptions?.onSuccess?.(undefined, snapshot);
182
- // Streaming actions with no `result` event resolve with undefined.
183
- // Consumers should type `TResult = void` (or include `undefined`)
184
- // when their action doesn't emit a result.
222
+ // Streaming action closed without emitting a `result` event;
223
+ // resolve with `data: undefined`. `onSuccess` is not called
224
+ // in this branch since there is no result value to deliver
225
+ // (matches the static-action path where onSuccess only fires
226
+ // with a real value).
185
227
  finalResult = undefined;
186
228
  }
187
229
  }
188
230
  else {
189
- const result = (await response.json());
190
- setData(result);
191
- currentOptions?.onSuccess?.(result, snapshot);
192
- finalResult = result;
231
+ // Uniform envelope path. All non-streaming responses carry a JSON
232
+ // body shaped as { __outcome, ... } regardless of HTTP status.
233
+ let env;
234
+ try {
235
+ env = (await response.json());
236
+ }
237
+ catch {
238
+ throw new Error(`Malformed envelope (HTTP ${response.status})`);
239
+ }
240
+ if (env.__outcome === 'success') {
241
+ const data = env.data;
242
+ setData(data);
243
+ invokeSuccess(data);
244
+ finalResult = data;
245
+ recordOutcome(currentStub.__module, currentStub.__action, {
246
+ kind: 'success',
247
+ data,
248
+ submittedPayload: payload,
249
+ });
250
+ outcomeRecorded = true;
251
+ }
252
+ else if (env.__outcome === 'redirect' &&
253
+ typeof env.to === 'string') {
254
+ if (assignSafeRedirect(env.to)) {
255
+ // Navigation issued; this promise never settles.
256
+ return await new Promise(() => { });
257
+ }
258
+ // Cross-origin: surface as an error so the caller can handle it.
259
+ throw new Error(`Refused cross-origin redirect to ${env.to}`);
260
+ }
261
+ else if (env.__outcome === 'deny') {
262
+ const msg = env.message ??
263
+ `Request denied (${env.status ?? response.status})`;
264
+ recordOutcome(currentStub.__module, currentStub.__action, {
265
+ kind: 'deny',
266
+ status: env.status ?? response.status,
267
+ message: msg,
268
+ data: env.data,
269
+ submittedPayload: payload,
270
+ });
271
+ outcomeRecorded = true;
272
+ throw new Error(msg);
273
+ }
274
+ else if (env.__outcome === 'timeout' &&
275
+ typeof env.timeoutMs === 'number') {
276
+ recordOutcome(currentStub.__module, currentStub.__action, {
277
+ kind: 'error',
278
+ message: `Request timed out after ${env.timeoutMs}ms`,
279
+ submittedPayload: payload,
280
+ });
281
+ outcomeRecorded = true;
282
+ throw new TimeoutError(env.timeoutMs);
283
+ }
284
+ else if (env.__outcome === 'error') {
285
+ const msg = env.message ?? 'Action failed';
286
+ recordOutcome(currentStub.__module, currentStub.__action, {
287
+ kind: 'error',
288
+ message: msg,
289
+ submittedPayload: payload,
290
+ });
291
+ outcomeRecorded = true;
292
+ throw new Error(msg);
293
+ }
294
+ else {
295
+ throw new Error(`Unknown action outcome: ${env.__outcome}`);
296
+ }
193
297
  }
194
298
  if (currentOptions?.invalidate === 'auto') {
195
299
  reloadCtx?.reload();
@@ -213,11 +317,23 @@ export function useAction(stub, options) {
213
317
  }
214
318
  catch (err) {
215
319
  const e = err instanceof Error ? err : new Error(String(err));
320
+ // Write to the store only for unclassified errors (network failures,
321
+ // parse errors). Per-branch errors set outcomeRecorded before throwing.
322
+ if (!outcomeRecorded) {
323
+ recordOutcome(currentStub.__module, currentStub.__action, {
324
+ kind: 'error',
325
+ message: e.message,
326
+ submittedPayload: payload,
327
+ });
328
+ }
216
329
  setError(e);
217
- currentOptions?.onError?.(e, snapshot);
330
+ invokeError(e);
218
331
  setPending(false);
219
332
  return { ok: false, error: e };
220
333
  }
334
+ finally {
335
+ endSubmit(currentStub.__module, currentStub.__action);
336
+ }
221
337
  setPending(false);
222
338
  return { ok: true, data: finalResult };
223
339
  }, []);
@@ -1,4 +1,5 @@
1
1
  import type { Loader } from './define-loader.js';
2
+ import type { ActionResolution } from './internal/action-envelope.js';
2
3
  export interface LoaderCache<T> {
3
4
  get(locKey?: string): T | null;
4
5
  set(value: T, locKey?: string): void;
@@ -9,6 +10,14 @@ export interface LoaderCache<T> {
9
10
  type RequestStore = Map<symbol, unknown>;
10
11
  export declare function getRequestStore(): RequestStore | undefined;
11
12
  export declare function getRequestHonoContext<T = unknown>(): T | undefined;
13
+ export type ActionResultSlot = {
14
+ module: string;
15
+ action: string;
16
+ resolution: ActionResolution;
17
+ submittedPayload: unknown;
18
+ };
19
+ export declare function getActionResultSlot(): ActionResultSlot | null;
20
+ export declare function setActionResultSlot(slot: ActionResultSlot): void;
12
21
  export declare function runRequestScope<R>(fn: () => R | Promise<R>, initial?: {
13
22
  honoContext?: unknown;
14
23
  }): R | Promise<R>;
package/dist/iso/cache.js CHANGED
@@ -18,6 +18,7 @@ if (!looksLikeBrowser) {
18
18
  }
19
19
  }
20
20
  const HONO_CONTEXT_KEY = Symbol('@hono-preact/iso/honoContext');
21
+ const ACTION_RESULT_KEY = Symbol('@hono-preact/iso/actionResult');
21
22
  export function getRequestStore() {
22
23
  return alsInstance?.getStore();
23
24
  }
@@ -36,9 +37,34 @@ export function getRequestHonoContext() {
36
37
  }
37
38
  return ctx;
38
39
  }
40
+ export function getActionResultSlot() {
41
+ const store = getRequestStore();
42
+ if (!store)
43
+ return null;
44
+ const slot = store.get(ACTION_RESULT_KEY);
45
+ return (slot ?? null);
46
+ }
47
+ export function setActionResultSlot(slot) {
48
+ const store = getRequestStore();
49
+ if (!store) {
50
+ throw new Error('setActionResultSlot must be called inside runRequestScope');
51
+ }
52
+ store.set(ACTION_RESULT_KEY, slot);
53
+ }
39
54
  export function runRequestScope(fn, initial) {
40
55
  if (!alsInstance)
41
56
  return fn();
57
+ const existing = alsInstance.getStore();
58
+ if (existing) {
59
+ // Nested call: inherit the parent store. Seeded values are written
60
+ // additively so the inner caller's overrides take effect without
61
+ // wiping the parent's per-request state (e.g. the action-result
62
+ // slot set by pageActionHandler before it invokes renderPage).
63
+ if (initial?.honoContext !== undefined) {
64
+ existing.set(HONO_CONTEXT_KEY, initial.honoContext);
65
+ }
66
+ return fn();
67
+ }
42
68
  const store = new Map();
43
69
  if (initial?.honoContext !== undefined) {
44
70
  store.set(HONO_CONTEXT_KEY, initial.honoContext);
@@ -3,5 +3,12 @@ import type { StreamObserver } from './define-stream-observer.js';
3
3
  export type AppUseElement = ServerMiddleware<'page'> | ClientMiddleware | StreamObserver<unknown, never>;
4
4
  export type AppConfig = {
5
5
  use?: ReadonlyArray<AppUseElement>;
6
+ /**
7
+ * When `true`, the server emits a `<script type="speculationrules">` tag
8
+ * into `<head>` that instructs supporting browsers to prefetch same-origin
9
+ * `<a href>` links on moderate eagerness. Defaults to `false`. Individual
10
+ * links opt out with `data-no-prefetch`.
11
+ */
12
+ speculation?: boolean;
6
13
  };
7
14
  export declare function defineApp(config: AppConfig): AppConfig;
@@ -18,6 +18,12 @@ export interface LoaderRef<T> {
18
18
  readonly fn: Loader<T>;
19
19
  readonly cache: LoaderCache<T>;
20
20
  readonly params: string[] | '*';
21
+ /**
22
+ * Raw value as authored on `defineLoader({ timeoutMs })`. `undefined`
23
+ * means "use the handler's configured default"; `false` means "no
24
+ * timeout, only the request signal aborts".
25
+ */
26
+ readonly timeoutMs?: number | false;
21
27
  /**
22
28
  * Per-loader middleware and (for streaming loaders) stream observers,
23
29
  * exactly as authored on `defineLoader({ use })`. The handler-side
@@ -55,6 +61,12 @@ export type DefineLoaderOpts<T> = {
55
61
  __loaderName?: string;
56
62
  cache?: LoaderCache<T>;
57
63
  params?: string[] | '*';
64
+ /**
65
+ * Per-loader timeout in milliseconds. When omitted, the handler applies
66
+ * its configured default (30s). Pass `false` to disable the timeout for
67
+ * this loader (rely solely on the request signal).
68
+ */
69
+ timeoutMs?: number | false;
58
70
  /**
59
71
  * Per-loader middleware and (for streaming loaders) stream observers.
60
72
  * The element type LoaderUse<T, Streaming> structurally gates stream