@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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/dist/IndividualIslandRenderer.d.ts +4 -0
  3. package/dist/IndividualIslandRenderer.d.ts.map +1 -0
  4. package/dist/IslandRenderer.d.ts +11 -0
  5. package/dist/IslandRenderer.d.ts.map +1 -0
  6. package/dist/PortalIslandsRenderer.d.ts +4 -0
  7. package/dist/PortalIslandsRenderer.d.ts.map +1 -0
  8. package/dist/PortalIslandsRenderer.test.d.ts +2 -0
  9. package/dist/PortalIslandsRenderer.test.d.ts.map +1 -0
  10. package/dist/client/IndividualIslandRenderer.d.ts +14 -0
  11. package/dist/client/IndividualIslandRenderer.d.ts.map +1 -0
  12. package/dist/client/PortalIslandsRenderer.d.ts +4 -0
  13. package/dist/client/PortalIslandsRenderer.d.ts.map +1 -0
  14. package/dist/client/islandsManager.d.ts +23 -0
  15. package/dist/client/islandsManager.d.ts.map +1 -0
  16. package/dist/client.d.ts +23 -0
  17. package/dist/client.d.ts.map +1 -0
  18. package/dist/context.d.ts +7 -0
  19. package/dist/context.d.ts.map +1 -0
  20. package/dist/createHooks.d.ts +25 -0
  21. package/dist/createHooks.d.ts.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.esm.js +680 -0
  25. package/dist/index.esm.js.map +1 -0
  26. package/dist/index.js +684 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/island-manager.d.ts +59 -0
  29. package/dist/island-manager.d.ts.map +1 -0
  30. package/dist/islandsManager.d.ts +32 -0
  31. package/dist/islandsManager.d.ts.map +1 -0
  32. package/dist/islandsManager.test.d.ts +2 -0
  33. package/dist/islandsManager.test.d.ts.map +1 -0
  34. package/dist/server.d.ts +18 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/setupTests.d.ts +2 -0
  37. package/dist/setupTests.d.ts.map +1 -0
  38. package/dist/ssr.d.ts +15 -0
  39. package/dist/ssr.d.ts.map +1 -0
  40. package/dist/ssr.esm.js +92 -0
  41. package/dist/ssr.esm.js.map +1 -0
  42. package/dist/ssr.js +94 -0
  43. package/dist/ssr.js.map +1 -0
  44. package/dist/types.d.ts +65 -0
  45. package/dist/types.d.ts.map +1 -0
  46. package/dist/useForm.d.ts +60 -0
  47. package/dist/useForm.d.ts.map +1 -0
  48. package/dist/useIslandStream.d.ts +15 -0
  49. package/dist/useIslandStream.d.ts.map +1 -0
  50. package/dist/useStream.d.ts +9 -0
  51. package/dist/useStream.d.ts.map +1 -0
  52. package/package.json +66 -0
  53. package/src/IndividualIslandRenderer.tsx +80 -0
  54. package/src/PortalIslandsRenderer.test.tsx +304 -0
  55. package/src/PortalIslandsRenderer.tsx +118 -0
  56. package/src/context.ts +9 -0
  57. package/src/createHooks.ts +341 -0
  58. package/src/index.ts +4 -0
  59. package/src/islandsManager.test.ts +454 -0
  60. package/src/islandsManager.ts +356 -0
  61. package/src/setupTests.ts +1 -0
  62. package/src/ssr.ts +118 -0
  63. package/src/types.ts +91 -0
  64. package/src/useForm.ts +253 -0
  65. 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
+ }
@@ -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
+ }