Skip to content

Commit f6b251d

Browse files
[Persistence]: Adjustments to persistableFixableAtom to change source flag when becoming valid (#1343)
Co-authored-by: jorgenherje <[email protected]>
1 parent 476a109 commit f6b251d

File tree

3 files changed

+515
-2
lines changed

3 files changed

+515
-2
lines changed

PERSISTENCE_ARCHITECTURE.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,146 @@ Each module can persist two separate state objects:
746746

747747
Both use the same pattern: define a serialized state type, create a JTD schema, implement serialize/deserialize functions, and register with the module.
748748

749+
### Understanding persistableFixableAtom
750+
751+
The `persistableFixableAtom` utility provides state management for values that need validation and automatic correction when loaded from persistence or when dependencies change. It's essential for robust module state persistence.
752+
753+
#### Core Features
754+
755+
**1. Source Tracking**
756+
757+
Every value has a source that determines how invalid values are handled:
758+
- `Source.USER`: Value set by direct user interaction → auto-fixes when invalid
759+
- `Source.PERSISTENCE`: Value loaded from saved session → shows warnings when invalid
760+
- `Source.TEMPLATE`: Value loaded from template → same behavior as PERSISTENCE
761+
762+
**2. Validation & Fixup**
763+
764+
```typescript
765+
persistableFixableAtom({
766+
initialValue: defaultValue,
767+
768+
// Check if value is valid in current context
769+
isValidFunction: ({ value, get }) => {
770+
// Return true if value is valid, false otherwise
771+
// Can read other atoms via get()
772+
},
773+
774+
// Provide fallback when USER-source value is invalid
775+
fixupFunction: ({ value, get }) => {
776+
// Return a valid replacement value
777+
// Only called for USER source, never for PERSISTENCE
778+
},
779+
})
780+
```
781+
782+
**3. Auto-Transition Behavior**
783+
784+
When a value with `Source.PERSISTENCE` or `Source.TEMPLATE` becomes valid, it automatically transitions to `Source.USER`:
785+
786+
- Transition happens in next microtask after validation passes
787+
- Powered by `atomEffect` from `jotai-effect`
788+
- Enables cascading dependency auto-fixes
789+
790+
**Example Timeline:**
791+
```
792+
Load session → Source: PERSISTENCE, Valid: true
793+
↓ (microtask)
794+
Source: USER, Valid: true
795+
↓ (user changes dependency)
796+
Source: USER, Valid: false → Auto-fixes immediately
797+
```
798+
799+
**4. Dependency Awareness**
800+
801+
```typescript
802+
persistableFixableAtom({
803+
initialValue: value,
804+
805+
// Optional: Track loading/error state of dependencies
806+
computeDependenciesState: ({ get }) => {
807+
const queryAtom = get(someQueryAtom);
808+
if (queryAtom.isPending) return "loading";
809+
if (queryAtom.isError) return "error";
810+
return "loaded";
811+
},
812+
813+
isValidFunction: ({ value, get }) => { /* ... */ },
814+
fixupFunction: ({ get }) => { /* ... */ },
815+
})
816+
```
817+
818+
Auto-transition only happens when:
819+
- `isValidInContext === true`
820+
- `isLoading === false`
821+
- `depsHaveError === false`
822+
823+
**5. Precomputation (Optional)**
824+
825+
For expensive computations needed by both validation and fixup:
826+
827+
```typescript
828+
persistableFixableAtom({
829+
// Compute once, use in both functions
830+
precomputeFunction: ({ value, get }) => {
831+
const expensiveData = computeExpensiveData(value, get);
832+
return { expensiveData };
833+
},
834+
835+
isValidFunction: ({ value, get, precomputedValue }) => {
836+
// Use precomputedValue.expensiveData
837+
return checkValidity(value, precomputedValue.expensiveData);
838+
},
839+
840+
fixupFunction: ({ value, get, precomputedValue }) => {
841+
// Reuse same precomputed data
842+
return createFallback(precomputedValue.expensiveData);
843+
},
844+
})
845+
```
846+
847+
**6. Reading Atom State**
848+
849+
The atom returns a rich state object:
850+
851+
```typescript
852+
const atomState = useAtomValue(myAtom);
853+
854+
atomState.value // Current value (auto-fixed if source is USER)
855+
atomState.isValidInContext // false if PERSISTENCE source + invalid
856+
atomState.isLoading // true if dependencies loading
857+
atomState.depsHaveError // true if dependencies errored
858+
atomState._source // "user" | "persistence" | "template"
859+
```
860+
861+
#### Why Auto-Transition Matters
862+
863+
**Problem Without Auto-Transition:**
864+
865+
Consider two atoms where B depends on A:
866+
1. Load session: A=10, B=20 (both valid, both PERSISTENCE source)
867+
2. User changes A to 50
868+
3. B becomes invalid (20 is not > 50)
869+
4. ❌ B shows warning even though user caused the invalidity
870+
5. ❌ User must manually fix B even though it could auto-fix
871+
872+
**Solution With Auto-Transition:**
873+
874+
1. Load session: A=10, B=20 (both valid, both PERSISTENCE source)
875+
2. Auto-transition: A and B both become USER source (after validation)
876+
3. User changes A to 50
877+
4. B becomes invalid (20 is not > 50)
878+
5. ✅ B auto-fixes to 51 immediately (no warning!)
879+
880+
**When Warnings Are Shown:**
881+
882+
Warnings only appear for genuinely problematic persisted state:
883+
- Session was saved with invalid data
884+
- Data that existed when session was saved no longer exists
885+
- Session is from incompatible module version
886+
887+
These require user attention and shouldn't be silently fixed.
888+
749889
### Step-by-Step Implementation
750890

751891
#### 1. Define Persistable Atoms

frontend/src/framework/utils/atomUtils.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { DefinedInitialDataOptions, UndefinedInitialDataOptions } from "@ta
33
import type { Atom, Getter, Setter, WritableAtom } from "jotai";
44
import { atom } from "jotai";
55
import { atomWithReducer } from "jotai/utils";
6+
import { atomEffect } from "jotai-effect";
67
import type { AtomWithQueryOptions } from "jotai-tanstack-query";
78
import { atomWithQuery } from "jotai-tanstack-query";
89

@@ -371,12 +372,46 @@ export function persistableFixableAtom<TValue, TPrecomputedValue>(
371372
},
372373
);
373374

374-
Object.defineProperty(fixableAtom, PERSISTABLE_ATOM, {
375+
// Create an effect that auto-transitions PERSISTENCE/TEMPLATE → USER when valid
376+
const transitionEffect = atomEffect((get, set) => {
377+
const currentRead = get(fixableAtom);
378+
const internalState = get(internalStateAtom);
379+
380+
// Only transition if:
381+
// 1. Source is PERSISTENCE or TEMPLATE
382+
// 2. Atom is valid in context
383+
// 3. Not loading
384+
// 4. Dependencies don't have errors
385+
if (
386+
(internalState._source === Source.PERSISTENCE || internalState._source === Source.TEMPLATE) &&
387+
currentRead.isValidInContext &&
388+
!currentRead.isLoading &&
389+
!currentRead.depsHaveError
390+
) {
391+
set(internalStateAtom, {
392+
value: internalState.value,
393+
_source: Source.USER,
394+
});
395+
}
396+
});
397+
398+
// Wrap the atom to automatically mount the effect
399+
const atomWithEffect = atom(
400+
(get) => {
401+
get(transitionEffect); // Subscribe to effect
402+
return get(fixableAtom);
403+
},
404+
(_get, set, update: TValue | PersistableAtomState<TValue>) => {
405+
set(fixableAtom, update);
406+
},
407+
);
408+
409+
Object.defineProperty(atomWithEffect, PERSISTABLE_ATOM, {
375410
value: true,
376411
enumerable: false,
377412
});
378413

379-
return fixableAtom;
414+
return atomWithEffect;
380415
}
381416

382417
type PersistableFlagged = { [PERSISTABLE_ATOM]: true };

0 commit comments

Comments
 (0)