Skip to content

Commit

Permalink
fix: full rewrite of ScopeProvider to address known issues
Browse files Browse the repository at this point in the history
- fixes: #25, #36
  • Loading branch information
dmaskasky committed Feb 17, 2025
1 parent 5c8bbcd commit ac569ad
Show file tree
Hide file tree
Showing 70 changed files with 10,846 additions and 48 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
*~
*.swp
.vscode
.DS_Store
dist
jotai
node_modules
.DS_Store
13 changes: 13 additions & 0 deletions approaches/readAtomState.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Objectives

1. Derived atoms are copied even if they don’t depend on scoped atoms.
2. If the derived atom has already mounted, don't call onMount again.
Fixes:

- [Scope caused atomWithObservable to be out of sync](https://github.com/jotaijs/jotai-scope/issues/36)
- [Computed atoms get needlessly triggered again](https://github.com/jotaijs/jotai-scope/issues/25)

## Requirements

1. Some way to get whether the atom has been mounted.
2. Some way to bypass the onMount call if the atom is already mounted.
81 changes: 81 additions & 0 deletions approaches/unstable_derive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Objectives

1. Derived atoms are not copied if they don’t depend on scoped atoms.
2. When a derived atom starts depending on a scoped atom, a new atom state is created as the scoped atom state.
3. When a derived atom stops depending on a scoped atom, it must be removed from the scope state and restored to the original atom state.
a. When changing between scoped and unscoped, all subscibers must be notified.

Fixes:

- [Scope caused atomWithObservable to be out of sync](https://github.com/jotaijs/jotai-scope/issues/36)
- [Computed atoms get needlessly triggered again](https://github.com/jotaijs/jotai-scope/issues/25)

# Requirements

1. Some way to track dependencies of computed atoms not in the scope without copying them.
2. Some way to get whether the atom has been mounted.

# Problem Statement

A computed atom may or may not consume scoped atoms. This may also change as state changes.

```tsx
const providerAtom = atom('unscoped')
const scopedProviderAtom = atom('scoped')
const shouldConsumeScopedAtom = atom(false)
const consumerAtom = atom((get) => {
if (get(shouldConsumeScopedAtom)) {
return get(scopedProviderAtom)
}
return get(providerAtom)
})

function Component() {
const value = useAtomValue(consumerAtom)
return value
}

function App() {
const setShouldConsumeScopedAtom = useSetAtom(shouldConsumeScopedAtom)
useEffect(() => {
const timeoutId = setTimeout(setShouldConsumeScopedAtom, 1000, true)
return () => clearTimeout(timeoutId)
}, [])

return (
<ScopeProvider atoms={[scopedProviderAtom]}>
<Component />
</ScopeProvider>
)
}
```

To properly handle `consumerAtom`, we need to track the dependencies of the computed atom.

# Proxy State

Atom state has the following shape;

```ts
type AtomState = {
d: Map<AnyAtom, number>; // map of atom consumers to their epoch number
p: Set<AnyAtom>; // set of pending atom consumers
n: number; // epoch number
m?: {
l: Set<() => void>; // set of listeners
d: Set<AnyAtom>; // set of mounted atom consumers
t: Set<AnyAtom>; // set of mounted atom providers
u?: (setSelf: () => any) => (void | () => void); // unmount function
};
v?: any; // value
e?: any; // error
};
```

All computed atoms (`atom.read !== defaultRead`) will have their base atomState converted to a proxy state. The proxy state will track dependencies and notify when they change.

0. Update all computed atoms with a proxy state in the parent store.
1. If a computer atom does not depend on any scoped atoms, remove it from the unscopedComputed set
2. If a computed atom starts depending on a scoped atom, add it to the scopedComputed set.
a. If the scoped state does not already exist, create a new scoped atom state.
3. If a computed atom stops depending on a scoped atom, remove it from the scopedComputed set.
1 change: 1 addition & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default tseslint.config(
eqeqeq: 'error',
'no-console': 'off',
'no-inner-declarations': 'off',
'no-sparse-arrays': 'off',
'no-var': 'error',
'prefer-const': 'error',
'sort-imports': [
Expand Down
43 changes: 43 additions & 0 deletions jotai/Provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createContext, createElement, useContext, useRef } from 'react'
import type { FunctionComponent, ReactElement, ReactNode } from 'react'
import { createStore, getDefaultStore } from './store'

type Store = ReturnType<typeof createStore>

type StoreContextType = ReturnType<typeof createContext<Store | undefined>>
const StoreContext: StoreContextType = createContext<Store | undefined>(
undefined
)

type Options = {
store?: Store
}

export const useStore = (options?: Options): Store => {
const store = useContext(StoreContext)
return options?.store || store || getDefaultStore()
}

// TODO should we consider using useState instead of useRef?
export const Provider = ({
children,
store,
}: {
children?: ReactNode
store?: Store
}): ReactElement<
{ value: Store | undefined },
FunctionComponent<{ value: Store | undefined }>
> => {
const storeRef = useRef<Store>(undefined!)
if (!store && !storeRef.current) {
storeRef.current = createStore()
}
return createElement(
StoreContext.Provider,
{
value: store || storeRef.current,
},
children
)
}
139 changes: 139 additions & 0 deletions jotai/atom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { INTERNAL_PrdStore as Store } from './store'

type Getter = <Value>(atom: Atom<Value>) => Value

type Setter = <Value, Args extends unknown[], Result>(
atom: WritableAtom<Value, Args, Result>,
...args: Args
) => Result

type SetAtom<Args extends unknown[], Result> = <A extends Args>(
...args: A
) => Result

/**
* setSelf is for internal use only and subject to change without notice.
*/
type Read<Value, SetSelf = never> = (
get: Getter,
options: { readonly signal: AbortSignal; readonly setSelf: SetSelf }
) => Value

type Write<Args extends unknown[], Result> = (
get: Getter,
set: Setter,
...args: Args
) => Result

// This is an internal type and not part of public API.
// Do not depend on it as it can change without notice.
type WithInitialValue<Value> = {
init: Value
}

type OnUnmount = () => void

type OnMount<Args extends unknown[], Result> = <
S extends SetAtom<Args, Result>,
>(
setAtom: S
) => OnUnmount | void

export interface Atom<Value> {
toString: () => string
read: Read<Value>
unstable_is?(a: Atom<unknown>): boolean
debugLabel?: string
/**
* To ONLY be used by Jotai libraries to mark atoms as private. Subject to change.
* @private
*/
debugPrivate?: boolean
/**
* Fires after atom is referenced by the store for the first time
* This is still an experimental API and subject to change without notice.
*/
unstable_onInit?: (store: Store) => void
}

export interface WritableAtom<Value, Args extends unknown[], Result>
extends Atom<Value> {
read: Read<Value, SetAtom<Args, Result>>
write: Write<Args, Result>
onMount?: OnMount<Args, Result>
}

type SetStateAction<Value> = Value | ((prev: Value) => Value)

export type PrimitiveAtom<Value> = WritableAtom<
Value,
[SetStateAction<Value>],
void
>

let keyCount = 0 // global key count for all atoms

// writable derived atom
export function atom<Value, Args extends unknown[], Result>(
read: Read<Value, SetAtom<Args, Result>>,
write: Write<Args, Result>
): WritableAtom<Value, Args, Result>

// read-only derived atom
export function atom<Value>(read: Read<Value>): Atom<Value>

// write-only derived atom
export function atom<Value, Args extends unknown[], Result>(
initialValue: Value,
write: Write<Args, Result>
): WritableAtom<Value, Args, Result> & WithInitialValue<Value>

// primitive atom without initial value
export function atom<Value>(): PrimitiveAtom<Value | undefined> &
WithInitialValue<Value | undefined>

// primitive atom
export function atom<Value>(
initialValue: Value
): PrimitiveAtom<Value> & WithInitialValue<Value>

export function atom<Value, Args extends unknown[], Result>(
read?: Value | Read<Value, SetAtom<Args, Result>>,
write?: Write<Args, Result>
) {
const key = `atom${++keyCount}`
const config = {
toString() {
return process.env?.MODE !== 'production' && this.debugLabel
? key + ':' + this.debugLabel
: key
},
} as WritableAtom<Value, Args, Result> & { init?: Value | undefined }
if (typeof read === 'function') {
config.read = read as Read<Value, SetAtom<Args, Result>>
} else {
config.init = read
config.read = defaultRead
config.write = defaultWrite as unknown as Write<Args, Result>
}
if (write) {
config.write = write
}
return config
}

function defaultRead<Value>(this: Atom<Value>, get: Getter) {
return get(this)
}

function defaultWrite<Value>(
this: PrimitiveAtom<Value>,
get: Getter,
set: Setter,
arg: SetStateAction<Value>
) {
return set(
this,
typeof arg === 'function' ? (arg as (prev: Value) => Value)(get(this)) : arg
)
}
8 changes: 8 additions & 0 deletions jotai/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export * from './atom'
export * from './internals'
export * from './store'
export * from './typeUtils'
export * from './useAtom'
export * from './useAtomValue'
export * from './useSetAtom'
export * from './Provider'
Loading

0 comments on commit ac569ad

Please sign in to comment.