@live-react-islands/core 0.1.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/LICENSE +21 -0
- package/dist/IndividualIslandRenderer.d.ts +4 -0
- package/dist/IndividualIslandRenderer.d.ts.map +1 -0
- package/dist/IslandRenderer.d.ts +11 -0
- package/dist/IslandRenderer.d.ts.map +1 -0
- package/dist/PortalIslandsRenderer.d.ts +4 -0
- package/dist/PortalIslandsRenderer.d.ts.map +1 -0
- package/dist/PortalIslandsRenderer.test.d.ts +2 -0
- package/dist/PortalIslandsRenderer.test.d.ts.map +1 -0
- package/dist/client/IndividualIslandRenderer.d.ts +14 -0
- package/dist/client/IndividualIslandRenderer.d.ts.map +1 -0
- package/dist/client/PortalIslandsRenderer.d.ts +4 -0
- package/dist/client/PortalIslandsRenderer.d.ts.map +1 -0
- package/dist/client/islandsManager.d.ts +23 -0
- package/dist/client/islandsManager.d.ts.map +1 -0
- package/dist/client.d.ts +23 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/createHooks.d.ts +25 -0
- package/dist/createHooks.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +680 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +684 -0
- package/dist/index.js.map +1 -0
- package/dist/island-manager.d.ts +59 -0
- package/dist/island-manager.d.ts.map +1 -0
- package/dist/islandsManager.d.ts +32 -0
- package/dist/islandsManager.d.ts.map +1 -0
- package/dist/islandsManager.test.d.ts +2 -0
- package/dist/islandsManager.test.d.ts.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/setupTests.d.ts +2 -0
- package/dist/setupTests.d.ts.map +1 -0
- package/dist/ssr.d.ts +15 -0
- package/dist/ssr.d.ts.map +1 -0
- package/dist/ssr.esm.js +92 -0
- package/dist/ssr.esm.js.map +1 -0
- package/dist/ssr.js +94 -0
- package/dist/ssr.js.map +1 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/useForm.d.ts +60 -0
- package/dist/useForm.d.ts.map +1 -0
- package/dist/useIslandStream.d.ts +15 -0
- package/dist/useIslandStream.d.ts.map +1 -0
- package/dist/useStream.d.ts +9 -0
- package/dist/useStream.d.ts.map +1 -0
- package/package.json +66 -0
- package/src/IndividualIslandRenderer.tsx +80 -0
- package/src/PortalIslandsRenderer.test.tsx +304 -0
- package/src/PortalIslandsRenderer.tsx +118 -0
- package/src/context.ts +9 -0
- package/src/createHooks.ts +341 -0
- package/src/index.ts +4 -0
- package/src/islandsManager.test.ts +454 -0
- package/src/islandsManager.ts +356 -0
- package/src/setupTests.ts +1 -0
- package/src/ssr.ts +118 -0
- package/src/types.ts +91 -0
- package/src/useForm.ts +253 -0
- package/src/useStream.ts +36 -0
package/src/useForm.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export interface FormProps {
|
|
4
|
+
/** Internal: prop name for event routing (set by server) */
|
|
5
|
+
__prop?: string;
|
|
6
|
+
types?: Record<string, string>;
|
|
7
|
+
required?: string[];
|
|
8
|
+
values: Record<string, any>;
|
|
9
|
+
errors: Record<string, string[]>;
|
|
10
|
+
isValid?: boolean;
|
|
11
|
+
version?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type PushEventFn = (event: string, payload: Record<string, any>) => void;
|
|
15
|
+
|
|
16
|
+
export interface FieldProps {
|
|
17
|
+
name: string;
|
|
18
|
+
value: any;
|
|
19
|
+
checked?: boolean;
|
|
20
|
+
onChange: (
|
|
21
|
+
e: React.ChangeEvent<
|
|
22
|
+
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
|
23
|
+
>
|
|
24
|
+
) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseFormReturn {
|
|
28
|
+
/** Current form field values */
|
|
29
|
+
values: Record<string, any>;
|
|
30
|
+
/** Current form errors by field name */
|
|
31
|
+
errors: Record<string, string[]>;
|
|
32
|
+
/** Field types from schema */
|
|
33
|
+
types: Record<string, string>;
|
|
34
|
+
/** Required field names */
|
|
35
|
+
required: string[];
|
|
36
|
+
/** Field names that have been modified by the user */
|
|
37
|
+
touched: string[];
|
|
38
|
+
/** Get props to spread on an input element */
|
|
39
|
+
getFieldProps: (name: string) => FieldProps;
|
|
40
|
+
/** Get the first error message for a field */
|
|
41
|
+
getError: (name: string) => string | undefined;
|
|
42
|
+
/** Check if a field is required */
|
|
43
|
+
isRequired: (name: string) => boolean;
|
|
44
|
+
/** Check if a field has been modified by the user */
|
|
45
|
+
isTouched: (name: string) => boolean;
|
|
46
|
+
/** Set a field value programmatically */
|
|
47
|
+
setField: (name: string, value: any) => void;
|
|
48
|
+
/** Handle form submission */
|
|
49
|
+
handleSubmit: (e?: React.FormEvent) => void;
|
|
50
|
+
/** Reset form to server values */
|
|
51
|
+
reset: () => void;
|
|
52
|
+
/**
|
|
53
|
+
* True when the server hasn't acknowledged the latest client changes.
|
|
54
|
+
* Use this to show a syncing indicator (e.g., spinner, "Validating...").
|
|
55
|
+
*/
|
|
56
|
+
isSyncing: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* True only when form is valid AND fully synced with server.
|
|
59
|
+
* Use this to enable/disable the submit button.
|
|
60
|
+
* This is the "Validation Lock" - users cannot submit until server confirms validity.
|
|
61
|
+
*/
|
|
62
|
+
isValid: boolean;
|
|
63
|
+
/** Current local version counter */
|
|
64
|
+
localVersion: number;
|
|
65
|
+
/** Last acknowledged server version */
|
|
66
|
+
serverVersion: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useForm(
|
|
70
|
+
form: FormProps,
|
|
71
|
+
pushEvent?: PushEventFn
|
|
72
|
+
): UseFormReturn {
|
|
73
|
+
// 1. Version Tracking
|
|
74
|
+
// Local version increments on each field change
|
|
75
|
+
// Server version comes from form.version prop
|
|
76
|
+
const localVersionRef = useRef(form.version ?? 0);
|
|
77
|
+
const lastSentVersionRef = useRef(0); // Prevents duplicate sends in StrictMode
|
|
78
|
+
const [localVersion, setLocalVersion] = useState(form.version ?? 0);
|
|
79
|
+
const serverVersion = form.version ?? 0;
|
|
80
|
+
|
|
81
|
+
// Prop name comes from server (embedded in form data)
|
|
82
|
+
const propName = form.__prop;
|
|
83
|
+
|
|
84
|
+
// Reset local version when server sends a reset (version 0 with new schema)
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (form.version === 0 && form.types) {
|
|
87
|
+
localVersionRef.current = 0;
|
|
88
|
+
setLocalVersion(0);
|
|
89
|
+
}
|
|
90
|
+
}, [form.version, form.types]);
|
|
91
|
+
|
|
92
|
+
// 3. Persistent Schema
|
|
93
|
+
// We only update these when the server explicitly sends them (usually on init)
|
|
94
|
+
const [schema, setSchema] = useState({
|
|
95
|
+
types: form.types ?? {},
|
|
96
|
+
required: form.required ?? [],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (form.types && form.types !== schema.types) {
|
|
100
|
+
setSchema((prev) => ({ ...prev, types: form.types! }));
|
|
101
|
+
}
|
|
102
|
+
if (form.required && form.required !== schema.required) {
|
|
103
|
+
setSchema((prev) => ({ ...prev, required: form.required! }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4. Values Management
|
|
107
|
+
const [localValues, setLocalValues] = useState<Record<string, any>>(
|
|
108
|
+
form.values ?? {}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// 5. Touched Tracking (client-side only)
|
|
112
|
+
const [touched, setTouched] = useState<string[]>([]);
|
|
113
|
+
|
|
114
|
+
// Sync from server: Update local values when server sends updates
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
setLocalValues((prev) => ({ ...prev, ...form.values }));
|
|
117
|
+
}, [form.values]);
|
|
118
|
+
|
|
119
|
+
// 6. Versioned Change Handler
|
|
120
|
+
const setField = useCallback((name: string, value: any) => {
|
|
121
|
+
// Mark field as touched
|
|
122
|
+
setTouched((prev) => (prev.includes(name) ? prev : [...prev, name]));
|
|
123
|
+
|
|
124
|
+
// Increment version BEFORE state update to avoid StrictMode double-increment
|
|
125
|
+
localVersionRef.current += 1;
|
|
126
|
+
const newVersion = localVersionRef.current;
|
|
127
|
+
|
|
128
|
+
setLocalValues((prev) => {
|
|
129
|
+
const next = { ...prev, [name]: value };
|
|
130
|
+
|
|
131
|
+
// Update version state (will be batched with values update)
|
|
132
|
+
setLocalVersion(newVersion);
|
|
133
|
+
|
|
134
|
+
// Only send event once per version (prevents duplicate sends in StrictMode)
|
|
135
|
+
if (lastSentVersionRef.current < newVersion) {
|
|
136
|
+
lastSentVersionRef.current = newVersion;
|
|
137
|
+
|
|
138
|
+
pushEvent?.("lri_form", {
|
|
139
|
+
event: "validate",
|
|
140
|
+
prop: propName,
|
|
141
|
+
attrs: next,
|
|
142
|
+
_v: newVersion,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return next;
|
|
147
|
+
});
|
|
148
|
+
}, [propName]);
|
|
149
|
+
|
|
150
|
+
// 6. Identity-Stable Field Props
|
|
151
|
+
const getFieldProps = useCallback(
|
|
152
|
+
(name: string): FieldProps => {
|
|
153
|
+
const type = schema.types[name];
|
|
154
|
+
const value = localValues[name];
|
|
155
|
+
|
|
156
|
+
const base = { name };
|
|
157
|
+
|
|
158
|
+
if (type === "boolean" || type === "bool") {
|
|
159
|
+
return {
|
|
160
|
+
...base,
|
|
161
|
+
checked: !!value,
|
|
162
|
+
value: undefined as any,
|
|
163
|
+
onChange: (e) =>
|
|
164
|
+
setField(name, (e.target as HTMLInputElement).checked),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
...base,
|
|
170
|
+
value: value ?? "",
|
|
171
|
+
onChange: (e) => setField(name, e.target.value),
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
[localValues, schema.types, setField]
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const getError = useCallback(
|
|
178
|
+
(name: string) => form.errors?.[name]?.[0],
|
|
179
|
+
[form.errors]
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const isRequired = useCallback(
|
|
183
|
+
(name: string) => schema.required.includes(name),
|
|
184
|
+
[schema.required]
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const isTouched = useCallback(
|
|
188
|
+
(name: string) => touched.includes(name),
|
|
189
|
+
[touched]
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// 7. Versioned Submit Handler
|
|
193
|
+
const handleSubmit = useCallback(
|
|
194
|
+
(e?: React.FormEvent) => {
|
|
195
|
+
e?.preventDefault();
|
|
196
|
+
|
|
197
|
+
// Increment version for submit as well
|
|
198
|
+
localVersionRef.current += 1;
|
|
199
|
+
const newVersion = localVersionRef.current;
|
|
200
|
+
setLocalVersion(newVersion);
|
|
201
|
+
|
|
202
|
+
// Only send once (prevents duplicate sends if called twice)
|
|
203
|
+
if (lastSentVersionRef.current < newVersion) {
|
|
204
|
+
lastSentVersionRef.current = newVersion;
|
|
205
|
+
|
|
206
|
+
pushEvent?.("lri_form", {
|
|
207
|
+
event: "submit",
|
|
208
|
+
prop: propName,
|
|
209
|
+
attrs: localValues,
|
|
210
|
+
_v: newVersion,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
[localValues, propName]
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const reset = useCallback(() => {
|
|
218
|
+
setLocalValues(form.values ?? {});
|
|
219
|
+
setTouched([]);
|
|
220
|
+
// Reset local version to match server
|
|
221
|
+
localVersionRef.current = form.version ?? 0;
|
|
222
|
+
setLocalVersion(form.version ?? 0);
|
|
223
|
+
}, [form.values, form.version]);
|
|
224
|
+
|
|
225
|
+
// 8. Derived States - The "Validation Lock"
|
|
226
|
+
// isSyncing: True when client has sent changes that server hasn't acknowledged
|
|
227
|
+
const isSyncing = localVersion > serverVersion;
|
|
228
|
+
|
|
229
|
+
// isValid: True only when:
|
|
230
|
+
// 1. Form is in sync (server has seen all changes)
|
|
231
|
+
// 2. Server says the form is valid
|
|
232
|
+
// This ensures users cannot submit until server validates the latest data
|
|
233
|
+
const isValid = !isSyncing && (form.isValid ?? false);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
values: localValues,
|
|
237
|
+
errors: form.errors ?? {},
|
|
238
|
+
types: schema.types,
|
|
239
|
+
required: schema.required,
|
|
240
|
+
touched,
|
|
241
|
+
getFieldProps,
|
|
242
|
+
getError,
|
|
243
|
+
isRequired,
|
|
244
|
+
isTouched,
|
|
245
|
+
setField,
|
|
246
|
+
handleSubmit,
|
|
247
|
+
reset,
|
|
248
|
+
isSyncing,
|
|
249
|
+
isValid,
|
|
250
|
+
localVersion,
|
|
251
|
+
serverVersion,
|
|
252
|
+
};
|
|
253
|
+
}
|
package/src/useStream.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useContext, useSyncExternalStore, useMemo } from "react";
|
|
2
|
+
import type { StreamHandle } from "./types";
|
|
3
|
+
import { IslandContextProvider } from "./context";
|
|
4
|
+
|
|
5
|
+
export interface UseStreamOptions<T> {
|
|
6
|
+
limit?: number;
|
|
7
|
+
capper?: (items: T[]) => T[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useStream<T extends { id: string | number }>(
|
|
11
|
+
handle: StreamHandle,
|
|
12
|
+
options?: UseStreamOptions<T>
|
|
13
|
+
): T[] {
|
|
14
|
+
const context = useContext(IslandContextProvider);
|
|
15
|
+
if (!context) {
|
|
16
|
+
throw new Error("useStream must be used within an Island component");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { id: islandId, storeAccess } = context;
|
|
20
|
+
|
|
21
|
+
const subscribe = useMemo(
|
|
22
|
+
() => (cb: () => void) =>
|
|
23
|
+
storeAccess.subscribeToStream(islandId, handle.name, cb, {
|
|
24
|
+
limit: options?.limit,
|
|
25
|
+
capper: options?.capper as ((items: any[]) => any[]) | undefined,
|
|
26
|
+
}),
|
|
27
|
+
[storeAccess, islandId, handle.name, options?.limit, options?.capper]
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const getSnapshot = useMemo(
|
|
31
|
+
() => () => storeAccess.getStreamItems(islandId, handle.name) as T[],
|
|
32
|
+
[storeAccess, islandId, handle.name]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return useSyncExternalStore(subscribe, getSnapshot, () => handle.initial as T[]);
|
|
36
|
+
}
|