Skip to content

Commit a3d9a9e

Browse files
committed
chore: save serena memories
1 parent 78d3c3b commit a3d9a9e

3 files changed

Lines changed: 298 additions & 0 deletions

File tree

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
# Form Hydration Flow Analysis
2+
3+
## Problem Summary
4+
When form data is fetched from the backend and hydrated into the form, onChange events are NOT fired. This leaves the UI inconsistent - e.g., a field that should be hidden based on another field's value remains visible.
5+
6+
Example from `packages/demo/src/domain/person/events.ts`:
7+
```typescript
8+
active: {
9+
change ({ state, schema }) {
10+
schema.birthDate.hidden = !state.active; // Hides birthDate when active is false
11+
schema.street.disabled = !state.active; // Disables address fields
12+
schema.city.disabled = !state.active;
13+
},
14+
}
15+
```
16+
17+
When `active` is hydrated from backend (e.g., `active: false`), this event is never fired, so `birthDate` remains visible when it should be hidden.
18+
19+
## Complete Hydration Flow
20+
21+
### 1. Hooks Definition (in schema)
22+
**File: `packages/core/src/schema.ts` (lines 46-66)**
23+
- `BootstrapHookContext` provides a `hydrate(data)` callback
24+
- `HookBootstrapFn` is called once per scope during component mount
25+
- Bootstrap hooks run for view/edit/add scopes
26+
27+
### 2. Demo Bootstrap Hook
28+
**File: `packages/demo/src/settings/hooks.ts`**
29+
```typescript
30+
bootstrap: {
31+
async [Scope.view] ({ context, schema, hydrate }: BootstrapHookContext) {
32+
if (!context.id) return;
33+
const data = await service.read(context.id as string);
34+
hydrate(data); // ← Called here to set initial data
35+
for (const field of Object.values(schema)) {
36+
field.disabled = true; // ← Can set field overrides here
37+
}
38+
},
39+
async [Scope.edit] ({ context, hydrate }: BootstrapHookContext) {
40+
if (!context.id) return;
41+
const data = await service.read(context.id as string);
42+
hydrate(data); // ← Called here
43+
},
44+
}
45+
```
46+
47+
### 3. How Each Adapter Processes Hydration
48+
49+
#### React: `packages/react/src/use-data-form.ts` (lines 28-55)
50+
```typescript
51+
useEffect(() => {
52+
const hook = hooks?.bootstrap?.[scope];
53+
if (!hook) return;
54+
55+
const run = async () => {
56+
let hydratedData: Record<string, unknown> | undefined;
57+
const schemaResult = createSchemaProxy(schema.fields, {});
58+
const hydrate = (data: Record<string, unknown>) => { hydratedData = data; };
59+
60+
await hook({ context: context ?? {}, hydrate, schema: schemaResult.proxy, component });
61+
62+
if (hydratedData) {
63+
const newState = buildInitialState(schema.fields, hydratedData);
64+
setState(newState); // ← State set WITHOUT firing events
65+
initialStateRef.current = newState;
66+
}
67+
68+
const overrides = schemaResult.getOverrides();
69+
if (Object.keys(overrides).length > 0) {
70+
setFieldOverrides(overrides);
71+
}
72+
73+
setLoading(false);
74+
};
75+
76+
run();
77+
}, []);
78+
```
79+
80+
**Problem**: `setState(newState)` directly sets state without calling `fireEvent()`.
81+
82+
#### Svelte: `packages/svelte/src/use-data-form.ts` (lines 35-59)
83+
```typescript
84+
const hook = hooks?.bootstrap?.[scope]
85+
if (hook) {
86+
const run = async () => {
87+
let hydratedData: Record<string, unknown> | undefined
88+
const schemaResult = createSchemaProxy(schema.fields, {})
89+
const hydrate = (data: Record<string, unknown>) => { hydratedData = data }
90+
91+
await hook({ context: context ?? {}, hydrate, schema: schemaResult.proxy, component })
92+
93+
if (hydratedData) {
94+
const newState = buildInitialState(schema.fields, hydratedData)
95+
state.set(newState) // ← State set WITHOUT firing events
96+
Object.assign(initialState, newState)
97+
}
98+
99+
const overrides = schemaResult.getOverrides()
100+
if (Object.keys(overrides).length > 0) {
101+
fieldOverrides.set(overrides)
102+
}
103+
104+
loading.set(false)
105+
}
106+
107+
run()
108+
}
109+
```
110+
111+
**Problem**: `state.set(newState)` directly sets state without calling `fireEvent()`.
112+
113+
#### Vue: `packages/vue/src/use-data-form.ts` (lines 26-52)
114+
```typescript
115+
onMounted(() => {
116+
const hook = hooks?.bootstrap?.[scope]
117+
if (!hook) return
118+
119+
const run = async () => {
120+
let hydratedData: Record<string, unknown> | undefined
121+
const schemaResult = createSchemaProxy(schema.fields, {})
122+
const hydrate = (data: Record<string, unknown>) => { hydratedData = data }
123+
124+
await hook({ context: context ?? {}, hydrate, schema: schemaResult.proxy, component })
125+
126+
if (hydratedData) {
127+
const newState = buildInitialState(schema.fields, hydratedData)
128+
state.value = newState // ← State set WITHOUT firing events
129+
Object.assign(initialState, newState)
130+
}
131+
132+
const overrides = schemaResult.getOverrides()
133+
if (Object.keys(overrides).length > 0) {
134+
fieldOverrides.value = overrides
135+
}
136+
137+
loading.value = false
138+
}
139+
140+
run()
141+
})
142+
```
143+
144+
**Problem**: `state.value = newState` directly sets state without calling `fireEvent()`.
145+
146+
### 4. Event System (setValue correctly fires events)
147+
148+
**React Example - setValue does fire events (line 182-204):**
149+
```typescript
150+
const setValue = useCallback(
151+
(field: string, value: unknown) => {
152+
const nextState = { ...state, [field]: value };
153+
setState(nextState);
154+
155+
// Validation...
156+
fireEvent(field, "change", nextState); // ← Events fired for user input
157+
},
158+
[state, schema.fields, fireEvent, t],
159+
);
160+
```
161+
162+
**The fireEvent function (lines 148-180):**
163+
```typescript
164+
const fireEvent = useCallback(
165+
(fieldName: string, eventName: string, nextState: Record<string, unknown>) => {
166+
const handler = events?.[fieldName]?.[eventName];
167+
if (!handler) return nextState;
168+
169+
const stateResult = createStateProxy(nextState);
170+
const schemaResult = createSchemaProxy(schema.fields, fieldOverrides);
171+
172+
handler({ state: stateResult.proxy, schema: schemaResult.proxy });
173+
174+
const stateChanges = stateResult.getChanges();
175+
const schemaOverrides = schemaResult.getOverrides();
176+
177+
const mergedState = { ...nextState, ...stateChanges };
178+
179+
if (Object.keys(stateChanges).length > 0) {
180+
setState(mergedState);
181+
}
182+
183+
if (Object.keys(schemaOverrides).length > 0) {
184+
setFieldOverrides((prev) => {
185+
const next = { ...prev };
186+
for (const [name, overrides] of Object.entries(schemaOverrides)) {
187+
next[name] = { ...next[name], ...overrides };
188+
}
189+
return next;
190+
});
191+
}
192+
193+
return mergedState;
194+
},
195+
[events, schema.fields, fieldOverrides],
196+
);
197+
```
198+
199+
### 5. Sample Scenario (Person Schema)
200+
201+
**Schema with events that hide fields:**
202+
- File: `packages/demo/src/domain/person/schema.ts`
203+
- Field: `active` (toggle) with `birthDate` field
204+
205+
**Events:**
206+
- File: `packages/demo/src/domain/person/events.ts`
207+
- When `active` changes to false:
208+
- `birthDate` should be hidden
209+
- `street` and `city` should be disabled
210+
211+
**Hydration sequence for Scope.edit:**
212+
1. User navigates to `/person/edit/123`
213+
2. `useDataForm()` creates bootstrap hook
214+
3. Hook calls `service.read('123')` → returns `{ id: '123', active: false, birthDate: '2000-01-01', ... }`
215+
4. Hook calls `hydrate(data)`
216+
5. Data is processed by `buildInitialState()` to set form state
217+
6. **BUG**: State is set directly WITHOUT firing `active` field's `change` event
218+
7. Result: `birthDate` remains visible, `street`/`city` remain enabled (incorrect UI state)
219+
220+
## Root Cause
221+
222+
All three adapters (React, Svelte, Vue) call `hydrate()` and set state with the result, but **none of them fire the field change events afterward**.
223+
224+
The `fireEvent()` function exists and works correctly for user input (onChange, onBlur, onFocus), but it's never called during hydration.
225+
226+
## Files Involved
227+
228+
### Core Package
229+
- **`packages/core/src/schema.ts`** - Defines hook types (BootstrapHookContext, HookBootstrapFn)
230+
- **`packages/core/src/scope.ts`** - `buildInitialState()` function
231+
- **`packages/core/src/types.ts`** - Type contracts (FormContract, etc.)
232+
233+
### React Package
234+
- **`packages/react/src/use-data-form.ts`** - Main hook with fireEvent system (BUG location: lines 28-55)
235+
236+
### Svelte Package
237+
- **`packages/svelte/src/use-data-form.ts`** - Svelte version (BUG location: lines 35-59)
238+
239+
### Vue Package
240+
- **`packages/vue/src/use-data-form.ts`** - Vue version (BUG location: lines 26-52)
241+
242+
### Demo
243+
- **`packages/demo/src/domain/person/events.ts`** - Example events that show the issue
244+
- **`packages/demo/src/domain/person/schema.ts`** - Person schema definition
245+
- **`packages/demo/src/settings/hooks.ts`** - Bootstrap hook implementation
246+
247+
### Playgrounds
248+
- **`playground/react-web/src/pages/person/PersonEdit.tsx`** - Real usage example
249+
250+
## Key Insight
251+
252+
The hydration process needs to trigger field change events so that:
253+
1. Field visibility/disabled state gets recalculated
254+
2. Any derived field state (colors, messages, etc.) gets set
255+
3. The UI reflects the correct state based on the data dependencies
256+
257+
Currently, only the raw field values are set, but the dependent field metadata (hidden, disabled, state, width, height) is not recalculated during hydration.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Project Overview
2+
3+
## Purpose
4+
Anhanga is a schema-driven UI framework for building data forms and tables across multiple frontend frameworks (React, React Native, Vue/Quasar, Svelte/SvelteKit).
5+
6+
## Tech Stack
7+
- TypeScript, pnpm workspaces monorepo
8+
- Bundler: tsup (ESM) for packages, Vite for vue-quasar/sveltekit packages and web playgrounds
9+
- Testing: Vitest
10+
- Frameworks: React, React Native (Expo), Vue 3 + Quasar, Svelte 5 + SvelteKit
11+
12+
## Key Packages
13+
- `@anhanga/core` — Schema definition, field types, actions, groups, permissions, i18n locales
14+
- `@anhanga/react`, `@anhanga/vue`, `@anhanga/svelte` — Framework hooks (useDataForm, useDataTable)
15+
- `@anhanga/react-native`, `@anhanga/react-web`, `@anhanga/vue-quasar`, `@anhanga/sveltekit` — UI component packages
16+
- `@anhanga/demo` — Shared demo code (person domain schema, services, i18n setup)
17+
- `@anhanga/persistence` — Storage drivers
18+
19+
## Code Style
20+
- No comments unless necessary
21+
- No labels in code — i18n handles all labels
22+
- Object literals for fields/groups/actions
23+
- Direct factory imports: `text()`, `date()`, `toggle()`
24+
- Conventional commits: `feat:`, `fix:`, `refactor:`, etc.
25+
- No co-author footers in commits
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Suggested Commands
2+
3+
## Build
4+
- `pnpm build` — Build all packages
5+
- `pnpm --filter @anhanga/core build` — Build core only
6+
7+
## Test
8+
- `pnpm --filter @anhanga/core test` — Run core tests (38 tests, reliable)
9+
- `pnpm --filter @anhanga/playground-react-web test` — React web playground tests (reliable)
10+
- `pnpm --filter @anhanga/playground-vue-quasar test` — Vue Quasar playground tests (reliable)
11+
- Note: sveltekit and react-native playground route tests have pre-existing module resolution failures
12+
13+
## Dev
14+
- `pnpm --filter @anhanga/playground-web dev` — React web playground dev server
15+
- `pnpm --filter @anhanga/playground start` — React Native playground dev server
16+
- `pnpm docs:dev` — Docs dev server

0 commit comments

Comments
 (0)