@void/solid 0.0.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.
- package/README.md +116 -0
- package/dist/plugin.d.mts +16 -0
- package/dist/plugin.mjs +708 -0
- package/package.json +53 -0
- package/src/runtime/action.ts +50 -0
- package/src/runtime/context.ts +14 -0
- package/src/runtime/index.ts +9 -0
- package/src/runtime/link.tsx +365 -0
- package/src/runtime/prefetch.ts +2 -0
- package/src/runtime/use-form.ts +244 -0
- package/src/runtime/use-island-form.ts +183 -0
- package/src/runtime/use-navigation.ts +23 -0
- package/src/runtime/use-router.ts +10 -0
- package/src/runtime/use-shared.ts +11 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createSignal, createMemo, useContext } from 'solid-js';
|
|
2
|
+
import { createStore, reconcile } from 'solid-js/store';
|
|
3
|
+
import {
|
|
4
|
+
VoidActionError,
|
|
5
|
+
isAbortError,
|
|
6
|
+
isEqualFormValue,
|
|
7
|
+
submitAction,
|
|
8
|
+
type VisitOptions,
|
|
9
|
+
} from 'void/pages-client';
|
|
10
|
+
import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes';
|
|
11
|
+
import { RouterContext, ErrorsContext } from './context.ts';
|
|
12
|
+
|
|
13
|
+
type FormSubmitOptions = Omit<VisitOptions, 'method' | 'data'> & {
|
|
14
|
+
data?: Record<string, unknown>;
|
|
15
|
+
};
|
|
16
|
+
type UntypedForm = {
|
|
17
|
+
data: Record<string, unknown>;
|
|
18
|
+
setData(field: string, value: unknown): void;
|
|
19
|
+
errors: Record<string, string>;
|
|
20
|
+
error: VoidActionError | null;
|
|
21
|
+
pending: boolean;
|
|
22
|
+
hasChanges: boolean;
|
|
23
|
+
wasSuccessful: boolean;
|
|
24
|
+
recentlySuccessful: boolean;
|
|
25
|
+
post(opts?: FormSubmitOptions): Promise<void>;
|
|
26
|
+
put(opts?: FormSubmitOptions): Promise<void>;
|
|
27
|
+
patch(opts?: FormSubmitOptions): Promise<void>;
|
|
28
|
+
delete(opts?: FormSubmitOptions): Promise<void>;
|
|
29
|
+
reset(...fields: Array<string>): void;
|
|
30
|
+
clearErrors(...fields: Array<string>): void;
|
|
31
|
+
clearError(): void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function getValidationErrors(error: VoidActionError): Record<string, string> | null {
|
|
35
|
+
const body = error.body;
|
|
36
|
+
if (
|
|
37
|
+
body &&
|
|
38
|
+
typeof body === 'object' &&
|
|
39
|
+
'errors' in body &&
|
|
40
|
+
body.errors &&
|
|
41
|
+
typeof body.errors === 'object' &&
|
|
42
|
+
!Array.isArray(body.errors)
|
|
43
|
+
) {
|
|
44
|
+
return body.errors as Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Typed overload — full inference from PageActionMap (requires codegen)
|
|
50
|
+
export function useForm<U extends ActionUrl & string>(
|
|
51
|
+
url: U,
|
|
52
|
+
defaults: ResolveActionBody<U>,
|
|
53
|
+
options?: ActionFormOptions<U>,
|
|
54
|
+
): {
|
|
55
|
+
data: ResolveActionBody<U>;
|
|
56
|
+
setData: <K extends keyof ResolveActionBody<U> & string>(
|
|
57
|
+
field: K,
|
|
58
|
+
value: ResolveActionBody<U>[K],
|
|
59
|
+
) => void;
|
|
60
|
+
errors: Partial<Record<keyof ResolveActionBody<U> & string, string>>;
|
|
61
|
+
error: VoidActionError | null;
|
|
62
|
+
pending: boolean;
|
|
63
|
+
hasChanges: boolean;
|
|
64
|
+
wasSuccessful: boolean;
|
|
65
|
+
recentlySuccessful: boolean;
|
|
66
|
+
post(opts?: FormSubmitOptions): Promise<void>;
|
|
67
|
+
put(opts?: FormSubmitOptions): Promise<void>;
|
|
68
|
+
patch(opts?: FormSubmitOptions): Promise<void>;
|
|
69
|
+
delete(opts?: FormSubmitOptions): Promise<void>;
|
|
70
|
+
reset(...fields: Array<keyof ResolveActionBody<U> & string>): void;
|
|
71
|
+
clearErrors(...fields: Array<keyof ResolveActionBody<U> & string>): void;
|
|
72
|
+
clearError(): void;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function useForm(
|
|
76
|
+
url: string,
|
|
77
|
+
defaults: Record<string, unknown>,
|
|
78
|
+
options?: { params?: Record<string, string> },
|
|
79
|
+
): UntypedForm {
|
|
80
|
+
let resolvedUrl = url;
|
|
81
|
+
|
|
82
|
+
// Strip ?action query before param resolution, then re-append
|
|
83
|
+
let actionQuery = '';
|
|
84
|
+
const qIdx = resolvedUrl.indexOf('?');
|
|
85
|
+
if (qIdx !== -1) {
|
|
86
|
+
actionQuery = resolvedUrl.slice(qIdx);
|
|
87
|
+
resolvedUrl = resolvedUrl.slice(0, qIdx);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (options?.params) {
|
|
91
|
+
for (const [key, value] of Object.entries(options.params)) {
|
|
92
|
+
resolvedUrl = resolvedUrl.replace(`:${key}`, encodeURIComponent(value));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
resolvedUrl = resolvedUrl + actionQuery;
|
|
97
|
+
|
|
98
|
+
const router = useContext(RouterContext);
|
|
99
|
+
// ErrorsContext provides the errors signal accessor — a reactive function
|
|
100
|
+
// that returns the current global errors object. This allows useForm to
|
|
101
|
+
// derive errors from the global state, surviving component re-creation
|
|
102
|
+
// during Solid's reactive tree rebuild after router.visit().
|
|
103
|
+
const globalErrors: Record<string, string> | (() => Record<string, string>) | null =
|
|
104
|
+
useContext(ErrorsContext);
|
|
105
|
+
const [data, setDataStore] = createStore<Record<string, unknown>>({ ...defaults });
|
|
106
|
+
const [localErrors, setLocalErrors] = createSignal<Record<string, string>>({});
|
|
107
|
+
const errors = createMemo(() => {
|
|
108
|
+
const local = localErrors();
|
|
109
|
+
if (Object.keys(local).length > 0) {
|
|
110
|
+
return local;
|
|
111
|
+
}
|
|
112
|
+
// globalErrors is a signal accessor (function) on the client, or a plain
|
|
113
|
+
// object on the server. Handle both cases.
|
|
114
|
+
if (!globalErrors) {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
if (typeof globalErrors === 'function') {
|
|
118
|
+
return globalErrors();
|
|
119
|
+
}
|
|
120
|
+
return globalErrors as unknown as Record<string, string>;
|
|
121
|
+
});
|
|
122
|
+
const [pending, setPending] = createSignal(false);
|
|
123
|
+
const [error, setError] = createSignal<VoidActionError | null>(null);
|
|
124
|
+
const [wasSuccessful, setWasSuccessful] = createSignal(false);
|
|
125
|
+
const [recentlySuccessful, setRecentlySuccessful] = createSignal(false);
|
|
126
|
+
|
|
127
|
+
let defaults_ = { ...defaults };
|
|
128
|
+
let successTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
129
|
+
|
|
130
|
+
const hasChanges = createMemo(() => {
|
|
131
|
+
return Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key]));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function setData(field: string, value: unknown) {
|
|
135
|
+
setDataStore(field, value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function reset(...fields: Array<string>) {
|
|
139
|
+
if (fields.length === 0) {
|
|
140
|
+
setDataStore(reconcile({ ...defaults_ }));
|
|
141
|
+
} else {
|
|
142
|
+
for (const field of fields) {
|
|
143
|
+
setDataStore(field, defaults_[field]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function clearErrors(...fields: Array<string>) {
|
|
149
|
+
if (fields.length === 0) {
|
|
150
|
+
setLocalErrors({});
|
|
151
|
+
} else {
|
|
152
|
+
setLocalErrors((prev) => {
|
|
153
|
+
const next = { ...prev };
|
|
154
|
+
for (const field of fields) {
|
|
155
|
+
delete next[field];
|
|
156
|
+
}
|
|
157
|
+
return next;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function clearError() {
|
|
163
|
+
setError(null);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function submit(method: string, opts: FormSubmitOptions = {}) {
|
|
167
|
+
setPending(true);
|
|
168
|
+
setWasSuccessful(false);
|
|
169
|
+
setRecentlySuccessful(false);
|
|
170
|
+
if (successTimeout) {
|
|
171
|
+
clearTimeout(successTimeout);
|
|
172
|
+
}
|
|
173
|
+
setLocalErrors({});
|
|
174
|
+
clearError();
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
if (!router) {
|
|
178
|
+
throw new Error('useForm(): requires the Void Router.');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const result = await submitAction(router, resolvedUrl, {
|
|
182
|
+
method,
|
|
183
|
+
data: { ...data },
|
|
184
|
+
...opts,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (!result.ok) {
|
|
188
|
+
const validationErrors = getValidationErrors(result.error);
|
|
189
|
+
if (validationErrors) {
|
|
190
|
+
setLocalErrors(validationErrors);
|
|
191
|
+
} else {
|
|
192
|
+
setError(result.error);
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
setWasSuccessful(true);
|
|
198
|
+
setRecentlySuccessful(true);
|
|
199
|
+
defaults_ = { ...data };
|
|
200
|
+
successTimeout = setTimeout(() => {
|
|
201
|
+
setRecentlySuccessful(false);
|
|
202
|
+
}, 2000);
|
|
203
|
+
} catch (submitError) {
|
|
204
|
+
if (isAbortError(submitError)) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
throw submitError;
|
|
208
|
+
} finally {
|
|
209
|
+
setPending(false);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
get data() {
|
|
215
|
+
return data;
|
|
216
|
+
},
|
|
217
|
+
setData,
|
|
218
|
+
get errors() {
|
|
219
|
+
return errors();
|
|
220
|
+
},
|
|
221
|
+
get error() {
|
|
222
|
+
return error();
|
|
223
|
+
},
|
|
224
|
+
get pending() {
|
|
225
|
+
return pending();
|
|
226
|
+
},
|
|
227
|
+
get hasChanges() {
|
|
228
|
+
return hasChanges();
|
|
229
|
+
},
|
|
230
|
+
get wasSuccessful() {
|
|
231
|
+
return wasSuccessful();
|
|
232
|
+
},
|
|
233
|
+
get recentlySuccessful() {
|
|
234
|
+
return recentlySuccessful();
|
|
235
|
+
},
|
|
236
|
+
reset,
|
|
237
|
+
clearErrors,
|
|
238
|
+
clearError,
|
|
239
|
+
post: (opts?: FormSubmitOptions) => submit('POST', opts),
|
|
240
|
+
put: (opts?: FormSubmitOptions) => submit('PUT', opts),
|
|
241
|
+
patch: (opts?: FormSubmitOptions) => submit('PATCH', opts),
|
|
242
|
+
delete: (opts?: FormSubmitOptions) => submit('DELETE', opts),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createSignal, createMemo } from 'solid-js';
|
|
2
|
+
import { createStore, reconcile } from 'solid-js/store';
|
|
3
|
+
import { VoidActionError, categorizeActionError, isEqualFormValue } from 'void/pages-client';
|
|
4
|
+
|
|
5
|
+
function getValidationErrors(body: unknown): Record<string, string> | null {
|
|
6
|
+
if (
|
|
7
|
+
body &&
|
|
8
|
+
typeof body === 'object' &&
|
|
9
|
+
'errors' in body &&
|
|
10
|
+
body.errors &&
|
|
11
|
+
typeof body.errors === 'object' &&
|
|
12
|
+
!Array.isArray(body.errors)
|
|
13
|
+
) {
|
|
14
|
+
return body.errors as Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readActionErrorBody(response: Response): Promise<unknown> {
|
|
20
|
+
const contentType = response.headers.get('content-type') || '';
|
|
21
|
+
try {
|
|
22
|
+
if (contentType.includes('application/json')) {
|
|
23
|
+
return await response.json();
|
|
24
|
+
}
|
|
25
|
+
const text = await response.text();
|
|
26
|
+
return text || null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IslandFormReturn<T extends Record<string, unknown>> {
|
|
33
|
+
data: T;
|
|
34
|
+
setData: <K extends keyof T & string>(field: K, value: T[K]) => void;
|
|
35
|
+
errors: Record<string, string>;
|
|
36
|
+
error: VoidActionError | null;
|
|
37
|
+
pending: boolean;
|
|
38
|
+
hasChanges: boolean;
|
|
39
|
+
wasSuccessful: boolean;
|
|
40
|
+
recentlySuccessful: boolean;
|
|
41
|
+
reset: (...fields: Array<keyof T & string>) => void;
|
|
42
|
+
clearErrors: (...fields: Array<string>) => void;
|
|
43
|
+
clearError: () => void;
|
|
44
|
+
post: (url: string) => Promise<void>;
|
|
45
|
+
put: (url: string) => Promise<void>;
|
|
46
|
+
patch: (url: string) => Promise<void>;
|
|
47
|
+
delete: (url: string) => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Form composable for island pages. Uses fetch + page reload instead of the
|
|
52
|
+
* Void Router (which is not available in island mode).
|
|
53
|
+
*/
|
|
54
|
+
export function useIslandForm<T extends Record<string, unknown>>(defaults: T): IslandFormReturn<T> {
|
|
55
|
+
const [data, setDataStore] = createStore<Record<string, unknown>>({ ...defaults });
|
|
56
|
+
const [errors, setErrors] = createSignal<Record<string, string>>({});
|
|
57
|
+
const [error, setError] = createSignal<VoidActionError | null>(null);
|
|
58
|
+
const [pending, setPending] = createSignal(false);
|
|
59
|
+
const [wasSuccessful, setWasSuccessful] = createSignal(false);
|
|
60
|
+
const [recentlySuccessful, setRecentlySuccessful] = createSignal(false);
|
|
61
|
+
|
|
62
|
+
let defaults_ = { ...defaults };
|
|
63
|
+
let successTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
64
|
+
|
|
65
|
+
const hasChanges = createMemo(() => {
|
|
66
|
+
return Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key]));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function setData(field: string, value: unknown) {
|
|
70
|
+
setDataStore(field, value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function reset(...fields: Array<string>) {
|
|
74
|
+
if (fields.length === 0) {
|
|
75
|
+
setDataStore(reconcile({ ...defaults_ }));
|
|
76
|
+
} else {
|
|
77
|
+
for (const field of fields) {
|
|
78
|
+
setDataStore(field, defaults_[field]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clearErrors(...fields: Array<string>) {
|
|
84
|
+
if (fields.length === 0) {
|
|
85
|
+
setErrors({});
|
|
86
|
+
} else {
|
|
87
|
+
setErrors((prev) => {
|
|
88
|
+
const next = { ...prev };
|
|
89
|
+
for (const f of fields) {
|
|
90
|
+
delete next[f];
|
|
91
|
+
}
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clearError() {
|
|
98
|
+
setError(null);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function submit(url: string, method: string) {
|
|
102
|
+
setPending(true);
|
|
103
|
+
setWasSuccessful(false);
|
|
104
|
+
setRecentlySuccessful(false);
|
|
105
|
+
if (successTimeout) {
|
|
106
|
+
clearTimeout(successTimeout);
|
|
107
|
+
}
|
|
108
|
+
setErrors({});
|
|
109
|
+
clearError();
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetch(url, {
|
|
113
|
+
method,
|
|
114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
body: JSON.stringify(data),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (response.redirected) {
|
|
119
|
+
window.location.href = response.url;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (response.ok) {
|
|
124
|
+
setWasSuccessful(true);
|
|
125
|
+
setRecentlySuccessful(true);
|
|
126
|
+
defaults_ = { ...data } as typeof defaults_;
|
|
127
|
+
successTimeout = setTimeout(() => setRecentlySuccessful(false), 2000);
|
|
128
|
+
window.location.reload();
|
|
129
|
+
} else if (!response.ok) {
|
|
130
|
+
const body = await readActionErrorBody(response);
|
|
131
|
+
const actionError = new VoidActionError({
|
|
132
|
+
body,
|
|
133
|
+
status: response.status,
|
|
134
|
+
statusText: response.statusText,
|
|
135
|
+
url,
|
|
136
|
+
});
|
|
137
|
+
const validationErrors = response.status === 422 ? getValidationErrors(body) : null;
|
|
138
|
+
if (validationErrors) {
|
|
139
|
+
setErrors(validationErrors);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (categorizeActionError(response.status) === 'boundary') {
|
|
143
|
+
throw actionError;
|
|
144
|
+
}
|
|
145
|
+
setError(actionError);
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
setPending(false);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
get data() {
|
|
154
|
+
return data as T;
|
|
155
|
+
},
|
|
156
|
+
setData: setData as IslandFormReturn<T>['setData'],
|
|
157
|
+
get errors() {
|
|
158
|
+
return errors();
|
|
159
|
+
},
|
|
160
|
+
get error() {
|
|
161
|
+
return error();
|
|
162
|
+
},
|
|
163
|
+
get pending() {
|
|
164
|
+
return pending();
|
|
165
|
+
},
|
|
166
|
+
get hasChanges() {
|
|
167
|
+
return hasChanges();
|
|
168
|
+
},
|
|
169
|
+
get wasSuccessful() {
|
|
170
|
+
return wasSuccessful();
|
|
171
|
+
},
|
|
172
|
+
get recentlySuccessful() {
|
|
173
|
+
return recentlySuccessful();
|
|
174
|
+
},
|
|
175
|
+
reset,
|
|
176
|
+
clearErrors,
|
|
177
|
+
clearError,
|
|
178
|
+
post: (url: string) => submit(url, 'POST'),
|
|
179
|
+
put: (url: string) => submit(url, 'PUT'),
|
|
180
|
+
patch: (url: string) => submit(url, 'PATCH'),
|
|
181
|
+
delete: (url: string) => submit(url, 'DELETE'),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useContext } from 'solid-js';
|
|
2
|
+
import { idleNavigationState, type NavigationState } from 'void/pages-client';
|
|
3
|
+
import { NavigationContext } from './context.ts';
|
|
4
|
+
|
|
5
|
+
export type { NavigationState } from 'void/pages-client';
|
|
6
|
+
|
|
7
|
+
export function useNavigation(): NavigationState {
|
|
8
|
+
const navigation = useContext(NavigationContext);
|
|
9
|
+
if (!navigation) {
|
|
10
|
+
return idleNavigationState();
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
get state() {
|
|
14
|
+
return navigation().state;
|
|
15
|
+
},
|
|
16
|
+
get location() {
|
|
17
|
+
return navigation().location;
|
|
18
|
+
},
|
|
19
|
+
get method() {
|
|
20
|
+
return navigation().method;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useContext } from 'solid-js';
|
|
2
|
+
import { ssrProxy } from 'void/pages-client';
|
|
3
|
+
import type { VoidRouter } from 'void/pages-client';
|
|
4
|
+
import { RouterContext } from './context.ts';
|
|
5
|
+
|
|
6
|
+
export type { VoidRouter } from 'void/pages-client';
|
|
7
|
+
|
|
8
|
+
export function useRouter(): VoidRouter {
|
|
9
|
+
return useContext(RouterContext) ?? ssrProxy;
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useContext } from 'solid-js';
|
|
2
|
+
import type { CloudContextVariables } from 'void';
|
|
3
|
+
import { SharedContext } from './context.ts';
|
|
4
|
+
|
|
5
|
+
export function useShared<T = CloudContextVariables['shared']>(): T {
|
|
6
|
+
const shared = useContext(SharedContext);
|
|
7
|
+
if (shared === null) {
|
|
8
|
+
throw new Error('useShared(): must be used inside a pages component.');
|
|
9
|
+
}
|
|
10
|
+
return shared as T;
|
|
11
|
+
}
|