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
@@ -42,7 +42,15 @@ function ViewRenderer({ loaderRef, props, render, }) {
42
42
  const reload = reloadCtx?.reload ?? (() => { });
43
43
  return render({ data, error, reload, ...props });
44
44
  }
45
+ function validateTimeoutMs(value, context) {
46
+ if (value === undefined || value === false)
47
+ return;
48
+ if (!Number.isFinite(value) || value < 0) {
49
+ throw new RangeError(`${context}: timeoutMs must be a non-negative finite number or false, got ${String(value)}`);
50
+ }
51
+ }
45
52
  export function defineLoader(fn, opts) {
53
+ validateTimeoutMs(opts?.timeoutMs, 'defineLoader');
46
54
  const idKey = opts?.__moduleKey
47
55
  ? opts.__loaderName
48
56
  ? `${opts.__moduleKey}::${opts.__loaderName}`
@@ -80,6 +88,7 @@ export function defineLoader(fn, opts) {
80
88
  fn,
81
89
  cache: cache,
82
90
  params: opts?.params ?? [],
91
+ timeoutMs: opts?.timeoutMs,
83
92
  // LoaderUse<T, boolean> structurally collapses to the same shape the
84
93
  // partitioner accepts; the cast hides only the generic narrowing on
85
94
  // StreamObserver's TChunk/TResult which is invariant. Identity-preserving.
@@ -97,26 +106,27 @@ export function defineLoader(fn, opts) {
97
106
  invalidate() {
98
107
  cache.invalidate();
99
108
  },
100
- Boundary: null,
101
- View: null,
102
- };
103
- const Boundary = ({ fallback, errorFallback, children, }) => {
104
- return h(LoaderHost, {
109
+ // `Boundary` and `View` close over `ref`. The captures are by reference
110
+ // and only deref at call time (component render), so the cycle is safe;
111
+ // both are fully initialized before any consumer can invoke them.
112
+ Boundary: ({ fallback, errorFallback, children }) => h((LoaderHost), {
105
113
  loader: ref,
106
114
  fallback,
107
115
  errorFallback,
108
116
  children,
109
- });
110
- };
111
- ref.Boundary = Boundary;
112
- const View = (render, viewOpts) => {
113
- const Wrapped = (props) => h(ref.Boundary, {
114
- fallback: viewOpts?.fallback,
115
- errorFallback: viewOpts?.errorFallback,
116
- children: h((ViewRenderer), { loaderRef: ref, props, render }),
117
- });
118
- return Wrapped;
117
+ }),
118
+ View: (render, viewOpts) => {
119
+ const Wrapped = (props) => h(ref.Boundary, {
120
+ fallback: viewOpts?.fallback,
121
+ errorFallback: viewOpts?.errorFallback,
122
+ children: h((ViewRenderer), {
123
+ loaderRef: ref,
124
+ props,
125
+ render,
126
+ }),
127
+ });
128
+ return Wrapped;
129
+ },
119
130
  };
120
- ref.View = View;
121
131
  return ref;
122
132
  }
@@ -1,7 +1,16 @@
1
1
  import type { JSX, ComponentChildren } from 'preact';
2
- export type FormProps<TPayload extends Record<string, unknown>> = Omit<JSX.HTMLAttributes<HTMLFormElement>, 'onSubmit'> & {
3
- mutate: (payload: TPayload) => Promise<unknown> | unknown;
4
- pending?: boolean;
2
+ import type { ActionStub } from './action.js';
3
+ import { type UseOptimisticActionResult } from './optimistic-action.js';
4
+ /**
5
+ * The `action` prop accepts either a plain action stub or the branded value
6
+ * returned by `useOptimisticAction`. The union lets `<Form>` discover the
7
+ * optimistic apply via `OPTIMISTIC_BRAND in action` narrowing without
8
+ * casting away the type.
9
+ */
10
+ type FormActionInput<TPayload, TResult> = ActionStub<TPayload, TResult, never> | UseOptimisticActionResult<TPayload, TResult, unknown>;
11
+ export type FormProps<TPayload, TResult> = Omit<JSX.HTMLAttributes<HTMLFormElement>, 'action' | 'method' | 'onSubmit' | 'enctype'> & {
12
+ action: FormActionInput<TPayload, TResult>;
5
13
  children?: ComponentChildren;
6
14
  };
7
- export declare function Form<TPayload extends Record<string, unknown>>({ mutate, pending, children, ...rest }: FormProps<TPayload>): JSX.Element;
15
+ export declare function Form<TPayload, TResult>({ action, children, ...rest }: FormProps<TPayload, TResult>): JSX.Element;
16
+ export {};
package/dist/iso/form.js CHANGED
@@ -1,40 +1,122 @@
1
- import { jsx as _jsx } from "preact/jsx-runtime";
2
- /**
3
- * Collect a FormData into a plain payload object.
4
- *
5
- * Repeated field names (checkboxes sharing a name, multi-select, multiple
6
- * `<input type="file" multiple>` entries) collect into an array. The old
7
- * `Object.fromEntries(fd)` produced the LAST value only, which was silent
8
- * data loss: a four-checkbox group would submit one value with no warning.
9
- *
10
- * Single-value fields stay scalar. Files survive as `File` instances.
11
- *
12
- * Consumers should type their `defineAction<TPayload, ...>` to match:
13
- * `tags: string[]`, `photos: File[]`, etc. for fields that may have multiple
14
- * values; scalar types for fields that won't.
15
- */
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ import { useState, useCallback, useMemo } from 'preact/hooks';
3
+ import { OPTIMISTIC_BRAND, } from './optimistic-action.js';
4
+ import { beginSubmit, endSubmit } from './internal/form-submit-store.js';
5
+ import { setLastActionResult } from './internal/action-result-store.js';
6
+ import { assignSafeRedirect } from './internal/safe-redirect.js';
7
+ function hasOptimisticBrand(action) {
8
+ return OPTIMISTIC_BRAND in action;
9
+ }
16
10
  function collectFormData(fd) {
17
- const payload = {};
11
+ const out = {};
18
12
  for (const [key, value] of fd.entries()) {
19
- if (key in payload) {
20
- const existing = payload[key];
21
- payload[key] = Array.isArray(existing)
22
- ? [...existing, value]
23
- : [existing, value];
24
- }
25
- else {
26
- payload[key] = value;
27
- }
13
+ if (key === '__module' || key === '__action')
14
+ continue;
15
+ const existing = out[key];
16
+ out[key] =
17
+ existing === undefined
18
+ ? value
19
+ : Array.isArray(existing)
20
+ ? [...existing, value]
21
+ : [existing, value];
28
22
  }
29
- return payload;
23
+ return out;
30
24
  }
31
- export function Form({ mutate, pending, children, ...rest }) {
32
- const handleSubmit = (e) => {
25
+ export function Form({ action, children, ...rest }) {
26
+ const [pending, setPending] = useState(false);
27
+ const moduleKey = action.__module;
28
+ const actionName = action.__action;
29
+ const optimistic = useMemo(() => (hasOptimisticBrand(action) ? action[OPTIMISTIC_BRAND] : undefined), [action]);
30
+ const handleSubmit = useCallback(async (e) => {
33
31
  e.preventDefault();
34
32
  const formEl = e.currentTarget;
35
- const formData = new FormData(formEl);
36
- const payload = collectFormData(formData);
37
- void mutate(payload);
38
- };
39
- return (_jsx("form", { ...rest, onSubmit: handleSubmit, children: _jsx("fieldset", { disabled: pending, class: "hp-form-fieldset", children: children }) }));
33
+ const target = typeof window !== 'undefined'
34
+ ? window.location.pathname + window.location.search
35
+ : '/';
36
+ const fd = new FormData(formEl);
37
+ const payload = collectFormData(fd);
38
+ let handle;
39
+ if (optimistic)
40
+ handle = optimistic.addOptimistic(payload);
41
+ setPending(true);
42
+ beginSubmit(moduleKey, actionName);
43
+ try {
44
+ const res = await fetch(target, {
45
+ method: 'POST',
46
+ body: fd,
47
+ headers: { Accept: 'application/json' },
48
+ });
49
+ const env = (await res.json().catch(() => null));
50
+ if (!env) {
51
+ handle?.revert();
52
+ if (typeof window !== 'undefined')
53
+ window.location.reload();
54
+ return;
55
+ }
56
+ if (env.__outcome === 'redirect' && typeof env.to === 'string') {
57
+ const navigated = assignSafeRedirect(env.to);
58
+ if (navigated) {
59
+ handle?.settle();
60
+ return;
61
+ }
62
+ // Cross-origin: revert optimistic, surface as error result so useActionResult sees it.
63
+ handle?.revert();
64
+ setLastActionResult(moduleKey, actionName, {
65
+ kind: 'error',
66
+ message: `Refused cross-origin redirect to ${env.to}`,
67
+ submittedPayload: payload,
68
+ });
69
+ return;
70
+ }
71
+ if (env.__outcome === 'success') {
72
+ handle?.settle();
73
+ setLastActionResult(moduleKey, actionName, {
74
+ kind: 'success',
75
+ data: env.data,
76
+ submittedPayload: payload,
77
+ });
78
+ return;
79
+ }
80
+ if (env.__outcome === 'deny') {
81
+ handle?.revert();
82
+ setLastActionResult(moduleKey, actionName, {
83
+ kind: 'deny',
84
+ status: env.status ?? res.status,
85
+ message: env.message ?? `Request denied (${env.status ?? res.status})`,
86
+ data: env.data,
87
+ submittedPayload: payload,
88
+ });
89
+ return;
90
+ }
91
+ if (env.__outcome === 'error') {
92
+ handle?.revert();
93
+ setLastActionResult(moduleKey, actionName, {
94
+ kind: 'error',
95
+ message: env.message ?? 'Action failed',
96
+ submittedPayload: payload,
97
+ });
98
+ return;
99
+ }
100
+ // Unknown outcome (e.g. 'timeout'): treat as error.
101
+ handle?.revert();
102
+ setLastActionResult(moduleKey, actionName, {
103
+ kind: 'error',
104
+ message: env.message ?? `Unexpected outcome: ${env.__outcome ?? 'unknown'}`,
105
+ submittedPayload: payload,
106
+ });
107
+ }
108
+ catch (err) {
109
+ handle?.revert();
110
+ setLastActionResult(moduleKey, actionName, {
111
+ kind: 'error',
112
+ message: err instanceof Error ? err.message : String(err),
113
+ submittedPayload: payload,
114
+ });
115
+ }
116
+ finally {
117
+ setPending(false);
118
+ endSubmit(moduleKey, actionName);
119
+ }
120
+ }, [moduleKey, actionName, optimistic]);
121
+ return (_jsxs("form", { ...rest, method: "post", enctype: "multipart/form-data", onSubmit: handleSubmit, children: [_jsx("input", { type: "hidden", name: "__module", value: moduleKey }), _jsx("input", { type: "hidden", name: "__action", value: actionName }), _jsx("fieldset", { disabled: pending, class: "hp-form-fieldset", children: children })] }));
40
122
  }
@@ -7,15 +7,18 @@ export { defineRoutes, Routes } from './define-routes.js';
7
7
  export type { RouteDef, RoutesManifest, FlatRoute, ServerRoute, LayoutProps, ViewProps, } from './define-routes.js';
8
8
  export { defineLoader } from './define-loader.js';
9
9
  export type { LoaderRef, LoaderCtx, Loader as LoaderFn, } from './define-loader.js';
10
- export { defineAction, useAction } from './action.js';
10
+ export { defineAction, useAction, TimeoutError } from './action.js';
11
11
  export type { ActionStub, UseActionOptions, UseActionResult, MutateResult, } from './action.js';
12
12
  export type { ContentfulStatusCode } from 'hono/utils/http-status';
13
13
  export { useReload } from './reload-context.js';
14
14
  export { useOptimistic } from './optimistic.js';
15
- export type { OptimisticHandle } from './optimistic.js';
15
+ export type { OptimisticHandle, UseOptimisticOptions } from './optimistic.js';
16
16
  export { useOptimisticAction } from './optimistic-action.js';
17
17
  export type { UseOptimisticActionOptions, UseOptimisticActionResult, } from './optimistic-action.js';
18
18
  export { Form } from './form.js';
19
+ export { useActionResult, type ActionResult } from './use-action-result.js';
20
+ export { ActionResultContext, type ActionResultContextValue, } from './action-result-context.js';
21
+ export { useFormStatus, type FormStatus } from './use-form-status.js';
19
22
  export { createCache } from './cache.js';
20
23
  export type { LoaderCache } from './cache.js';
21
24
  export { defineServerMiddleware, defineClientMiddleware, } from './define-middleware.js';
@@ -24,8 +27,8 @@ export { defineStreamObserver } from './define-stream-observer.js';
24
27
  export type { StreamObserver, ServerStreamCtx, } from './define-stream-observer.js';
25
28
  export { defineApp } from './define-app.js';
26
29
  export type { AppConfig, AppUseElement } from './define-app.js';
27
- export { redirect, deny, isOutcome, isRedirect, isDeny, isRender, } from './outcomes.js';
28
- export type { Outcome, RedirectOutcome, DenyOutcome, RenderOutcome, RedirectStatusCode, ErrorStatusCode, } from './outcomes.js';
30
+ export { redirect, deny, timeoutOutcome, isOutcome, isRedirect, isDeny, isRender, isTimeout, } from './outcomes.js';
31
+ export type { Outcome, RedirectOutcome, DenyOutcome, RenderOutcome, TimeoutOutcome, RedirectStatusCode, ErrorStatusCode, } from './outcomes.js';
29
32
  export { prefetch } from './prefetch.js';
30
33
  export { isBrowser, env } from './is-browser.js';
31
34
  export { useRouteChange } from './route-change.js';
package/dist/iso/index.js CHANGED
@@ -8,20 +8,23 @@ export { Route, Router, lazy, useLocation, useRoute } from 'preact-iso';
8
8
  export { defineRoutes, Routes } from './define-routes.js';
9
9
  // Server bindings.
10
10
  export { defineLoader } from './define-loader.js';
11
- export { defineAction, useAction } from './action.js';
11
+ export { defineAction, useAction, TimeoutError } from './action.js';
12
12
  // Hooks.
13
13
  export { useReload } from './reload-context.js';
14
14
  export { useOptimistic } from './optimistic.js';
15
15
  export { useOptimisticAction } from './optimistic-action.js';
16
16
  // Forms.
17
17
  export { Form } from './form.js';
18
+ export { useActionResult } from './use-action-result.js';
19
+ export { ActionResultContext, } from './action-result-context.js';
20
+ export { useFormStatus } from './use-form-status.js';
18
21
  // Cache + invalidation.
19
22
  export { createCache } from './cache.js';
20
23
  // Middleware + outcomes (the new system).
21
24
  export { defineServerMiddleware, defineClientMiddleware, } from './define-middleware.js';
22
25
  export { defineStreamObserver } from './define-stream-observer.js';
23
26
  export { defineApp } from './define-app.js';
24
- export { redirect, deny, isOutcome, isRedirect, isDeny, isRender, } from './outcomes.js';
27
+ export { redirect, deny, timeoutOutcome, isOutcome, isRedirect, isDeny, isRender, isTimeout, } from './outcomes.js';
25
28
  // Utilities.
26
29
  export { prefetch } from './prefetch.js';
27
30
  export { isBrowser, env } from './is-browser.js';
@@ -0,0 +1,37 @@
1
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
2
+ import type { Outcome } from '../outcomes.js';
3
+ export type ActionEnvelope = {
4
+ __outcome: 'success';
5
+ data: unknown;
6
+ } | {
7
+ __outcome: 'redirect';
8
+ to: string;
9
+ status: number;
10
+ } | {
11
+ __outcome: 'deny';
12
+ status: number;
13
+ message: string;
14
+ data?: unknown;
15
+ } | {
16
+ __outcome: 'error';
17
+ message: string;
18
+ } | {
19
+ __outcome: 'timeout';
20
+ timeoutMs: number;
21
+ };
22
+ export type ActionResolution = {
23
+ kind: 'success';
24
+ data: unknown;
25
+ } | {
26
+ kind: 'outcome';
27
+ outcome: Outcome;
28
+ } | {
29
+ kind: 'error';
30
+ message: string;
31
+ };
32
+ export type SerializedEnvelope = {
33
+ body: ActionEnvelope;
34
+ status: ContentfulStatusCode;
35
+ headers: Record<string, string> | undefined;
36
+ };
37
+ export declare function serializeActionOutcome(resolution: ActionResolution): SerializedEnvelope;
@@ -0,0 +1,47 @@
1
+ export function serializeActionOutcome(resolution) {
2
+ if (resolution.kind === 'success') {
3
+ return {
4
+ body: { __outcome: 'success', data: resolution.data },
5
+ status: 200,
6
+ headers: undefined,
7
+ };
8
+ }
9
+ if (resolution.kind === 'error') {
10
+ return {
11
+ body: { __outcome: 'error', message: resolution.message },
12
+ status: 500,
13
+ headers: undefined,
14
+ };
15
+ }
16
+ const { outcome } = resolution;
17
+ if (outcome.__outcome === 'redirect') {
18
+ return {
19
+ body: { __outcome: 'redirect', to: outcome.to, status: outcome.status },
20
+ status: 200,
21
+ headers: outcome.headers,
22
+ };
23
+ }
24
+ if (outcome.__outcome === 'deny') {
25
+ const body = {
26
+ __outcome: 'deny',
27
+ status: outcome.status,
28
+ message: outcome.message,
29
+ };
30
+ if (outcome.data !== undefined)
31
+ body.data = outcome.data;
32
+ return { body, status: outcome.status, headers: outcome.headers };
33
+ }
34
+ if (outcome.__outcome === 'timeout') {
35
+ return {
36
+ body: { __outcome: 'timeout', timeoutMs: outcome.timeoutMs },
37
+ status: 504,
38
+ headers: undefined,
39
+ };
40
+ }
41
+ // 'render' outcome is page-scope only; should never reach an action.
42
+ return {
43
+ body: { __outcome: 'error', message: 'render outcome is page-scope only' },
44
+ status: 500,
45
+ headers: undefined,
46
+ };
47
+ }
@@ -0,0 +1,28 @@
1
+ type Listener = () => void;
2
+ export type StoredActionResult = {
3
+ kind: 'success';
4
+ data: unknown;
5
+ submittedPayload: unknown;
6
+ } | {
7
+ kind: 'deny';
8
+ status: number;
9
+ message: string;
10
+ data?: unknown;
11
+ submittedPayload: unknown;
12
+ } | {
13
+ kind: 'error';
14
+ message: string;
15
+ submittedPayload: unknown | null;
16
+ };
17
+ type Entry = StoredActionResult & {
18
+ module: string;
19
+ action: string;
20
+ };
21
+ export declare function setLastActionResult(module: string, action: string, result: StoredActionResult): void;
22
+ export declare function clearLastActionResult(module: string, action: string): void;
23
+ export declare function getLastActionResult(stub?: {
24
+ __module: string;
25
+ __action: string;
26
+ }): Entry | null;
27
+ export declare function subscribeActionResults(listener: Listener): () => void;
28
+ export {};
@@ -0,0 +1,35 @@
1
+ const results = new Map();
2
+ const listeners = new Set();
3
+ function key(module, action) {
4
+ return `${module}::${action}`;
5
+ }
6
+ export function setLastActionResult(module, action, result) {
7
+ const k = key(module, action);
8
+ // Delete-then-set to bump to most-recent position in Map iteration order,
9
+ // so no-stub readers see the latest action result, not the earliest.
10
+ results.delete(k);
11
+ results.set(k, { ...result, module, action });
12
+ for (const l of listeners)
13
+ l();
14
+ }
15
+ export function clearLastActionResult(module, action) {
16
+ if (results.delete(key(module, action))) {
17
+ for (const l of listeners)
18
+ l();
19
+ }
20
+ }
21
+ export function getLastActionResult(stub) {
22
+ if (stub)
23
+ return results.get(key(stub.__module, stub.__action)) ?? null;
24
+ // No stub: return the most recently written entry.
25
+ let last = null;
26
+ for (const entry of results.values())
27
+ last = entry;
28
+ return last;
29
+ }
30
+ export function subscribeActionResults(listener) {
31
+ listeners.add(listener);
32
+ return () => {
33
+ listeners.delete(listener);
34
+ };
35
+ }
@@ -13,8 +13,7 @@ export const Envelope = ({ as = 'section', children, }) => {
13
13
  const dataLoader = isBrowser() ? 'null' : JSON.stringify(ctx.data ?? null);
14
14
  if (typeof as === 'string') {
15
15
  const Tag = as;
16
- const props = { id, 'data-loader': dataLoader };
17
- return (_jsx(Tag, { ...props, children: children }));
16
+ return (_jsx(Tag, { id: id, "data-loader": dataLoader, children: children }));
18
17
  }
19
18
  const Wrapper = as;
20
19
  return (_jsx(Wrapper, { id: id, "data-loader": dataLoader, children: children }));
@@ -0,0 +1,9 @@
1
+ type Listener = () => void;
2
+ export declare function beginSubmit(module: string, action: string): void;
3
+ export declare function endSubmit(module: string, action: string): void;
4
+ export declare function isPending(stub?: {
5
+ __module: string;
6
+ __action: string;
7
+ }): boolean;
8
+ export declare function subscribe(listener: Listener): () => void;
9
+ export {};
@@ -0,0 +1,32 @@
1
+ const counts = new Map();
2
+ const listeners = new Set();
3
+ function key(module, action) {
4
+ return `${module}::${action}`;
5
+ }
6
+ export function beginSubmit(module, action) {
7
+ const k = key(module, action);
8
+ counts.set(k, (counts.get(k) ?? 0) + 1);
9
+ for (const l of listeners)
10
+ l();
11
+ }
12
+ export function endSubmit(module, action) {
13
+ const k = key(module, action);
14
+ const n = (counts.get(k) ?? 0) - 1;
15
+ if (n <= 0)
16
+ counts.delete(k);
17
+ else
18
+ counts.set(k, n);
19
+ for (const l of listeners)
20
+ l();
21
+ }
22
+ export function isPending(stub) {
23
+ if (stub)
24
+ return (counts.get(key(stub.__module, stub.__action)) ?? 0) > 0;
25
+ return counts.size > 0;
26
+ }
27
+ export function subscribe(listener) {
28
+ listeners.add(listener);
29
+ return () => {
30
+ listeners.delete(listener);
31
+ };
32
+ }
@@ -1,4 +1,5 @@
1
1
  import { readSSE } from './sse-decoder.js';
2
+ import { TimeoutError } from '../action.js';
2
3
  /**
3
4
  * POST to /__loaders and consume the response.
4
5
  *
@@ -18,6 +19,9 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
18
19
  // Try to parse a deny outcome envelope first; it carries `message`
19
20
  // rather than `error`. Fall back to the legacy `{ error }` shape.
20
21
  const body = (await res.json().catch(() => ({})));
22
+ if (body.__outcome === 'timeout' && typeof body.timeoutMs === 'number') {
23
+ throw new TimeoutError(body.timeoutMs);
24
+ }
21
25
  if (body.__outcome === 'deny') {
22
26
  // The `deny()` constructor defaults `message` for first-party
23
27
  // callers, but a hand-rolled envelope from custom server middleware
@@ -74,40 +78,7 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
74
78
  // SSE: read the first message event synchronously (await first chunk),
75
79
  // then kick off an async loop that pushes subsequent chunks to callbacks.
76
80
  const iter = readSSE(res.body);
77
- let firstChunk;
78
- while (true) {
79
- const step = await iter.next();
80
- if (step.done) {
81
- // Stream closed before any data event: error
82
- throw new Error('Streaming loader closed before emitting any data');
83
- }
84
- const ev = step.value;
85
- if (ev.event === 'message') {
86
- try {
87
- firstChunk = JSON.parse(ev.data);
88
- }
89
- catch {
90
- throw new Error('Malformed first chunk in streaming loader');
91
- }
92
- break;
93
- }
94
- if (ev.event === 'error') {
95
- try {
96
- const parsed = JSON.parse(ev.data);
97
- const err = new Error(parsed.message ?? 'Streamed error');
98
- if (parsed.name)
99
- err.name = parsed.name;
100
- throw err;
101
- }
102
- catch (e) {
103
- if (e instanceof Error && e.message.startsWith('Malformed')) {
104
- throw new Error('Malformed error event in streaming loader');
105
- }
106
- throw e;
107
- }
108
- }
109
- // Other events (result, etc.): ignore for loaders
110
- }
81
+ const firstChunk = await readFirstChunk(iter);
111
82
  // Continue consuming chunks in the background. Each subsequent message
112
83
  // pushes a value via onChunk. Errors fire onError. End fires onEnd.
113
84
  (async () => {
@@ -127,6 +98,16 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
127
98
  // malformed mid-stream chunk: skip
128
99
  }
129
100
  }
101
+ else if (ev.event === 'timeout') {
102
+ try {
103
+ const parsed = JSON.parse(ev.data);
104
+ callbacks.onError(new TimeoutError(parsed.timeoutMs ?? 0));
105
+ }
106
+ catch {
107
+ callbacks.onError(new Error('Malformed timeout event in streaming loader'));
108
+ }
109
+ return;
110
+ }
130
111
  else if (ev.event === 'error') {
131
112
  try {
132
113
  const parsed = JSON.parse(ev.data);
@@ -151,3 +132,53 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
151
132
  })();
152
133
  return firstChunk;
153
134
  }
135
+ /**
136
+ * Drain the SSE stream until the first `message` event and parse it as `T`.
137
+ * `timeout` / `error` events before the first message reject with the
138
+ * appropriate error. Other event types are ignored.
139
+ */
140
+ async function readFirstChunk(iter) {
141
+ while (true) {
142
+ const step = await iter.next();
143
+ if (step.done) {
144
+ throw new Error('Streaming loader closed before emitting any data');
145
+ }
146
+ const ev = step.value;
147
+ if (ev.event === 'message') {
148
+ try {
149
+ return JSON.parse(ev.data);
150
+ }
151
+ catch {
152
+ throw new Error('Malformed first chunk in streaming loader');
153
+ }
154
+ }
155
+ if (ev.event === 'timeout') {
156
+ let timeoutMs = 0;
157
+ try {
158
+ const parsed = JSON.parse(ev.data);
159
+ timeoutMs = parsed.timeoutMs ?? 0;
160
+ }
161
+ catch (e) {
162
+ throw new Error(`Malformed timeout event in streaming loader: ${e instanceof Error ? e.message : String(e)}`);
163
+ }
164
+ throw new TimeoutError(timeoutMs);
165
+ }
166
+ if (ev.event === 'error') {
167
+ let message = 'Streamed error';
168
+ let name;
169
+ try {
170
+ const parsed = JSON.parse(ev.data);
171
+ message = parsed.message ?? message;
172
+ name = parsed.name;
173
+ }
174
+ catch {
175
+ throw new Error('Malformed error event in streaming loader');
176
+ }
177
+ const err = new Error(message);
178
+ if (name)
179
+ err.name = name;
180
+ throw err;
181
+ }
182
+ // Other events (result, etc.): ignore for loaders
183
+ }
184
+ }
@@ -1,13 +1,13 @@
1
- import type { ComponentChildren, JSX } from 'preact';
1
+ import type { ComponentChildren } from 'preact';
2
2
  import type { RouteHook } from 'preact-iso';
3
3
  import type { LoaderRef } from '../define-loader.js';
4
4
  export { serializeLocationForCache } from './cache-key.js';
5
5
  type LoaderHostProps<T> = {
6
6
  loader: LoaderRef<T>;
7
7
  location?: RouteHook;
8
- fallback?: JSX.Element;
8
+ fallback?: ComponentChildren;
9
9
  errorFallback?: ComponentChildren | ((err: Error, reset: () => void) => ComponentChildren);
10
10
  children: ComponentChildren;
11
11
  };
12
- export declare function LoaderHost<T>({ loader: loaderRef, location: locationProp, fallback, errorFallback, children, }: LoaderHostProps<T>): JSX.Element;
12
+ export declare function LoaderHost<T>({ loader: loaderRef, location: locationProp, fallback, errorFallback, children, }: LoaderHostProps<T>): import("preact").JSX.Element;
13
13
  export { LoaderHost as Loader };