Skip to content

Commit

Permalink
feat: remove uSES usages and support strict mode (#86)
Browse files Browse the repository at this point in the history
@affects atoms, react
  • Loading branch information
bowheart committed Nov 14, 2023
1 parent ce83347 commit d766df0
Show file tree
Hide file tree
Showing 15 changed files with 334 additions and 375 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pnpm add @zedux/react # pnpm

The React package (`@zedux/react`) contains everything you need to use Zedux in a React app - the [core store model](https://www.npmjs.com/package/@zedux/core), the [core atomic model](https://www.npmjs.com/package/@zedux/atoms), and the React-specific APIs.

`@zedux/react` has a peer dependency on React. It's heavily tested on React 18+ and makes use of the [`useSyncExternalStore()` shim](https://www.npmjs.com/package/use-sync-external-store) to support React versions 16.3.0 and up.
`@zedux/react` has a peer dependency on React. It supports React version 18 and up.

## Intro

Expand Down
36 changes: 0 additions & 36 deletions packages/atoms/src/classes/IdGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,42 +37,6 @@ export class IdGenerator {
return this.generateId('no')
}

/**
* Generate a graph node key for a React component
*/
public generateReactComponentId() {
if (!DEV) return this.generateId('rc')

const { stack } = new Error()

if (!stack) return ''

const lines = stack
.split('\n')
.slice(2)
.map(line =>
line
.trim()
// V8/JavaScriptCore:
.replace('at ', '')
.replace(/ \(.*\)/, '')
// SpiderMonkey:
.replace(/@.*/, '')
)

const componentName = lines
.find(line => {
if (!/\w/.test(line[0])) return false

const identifiers = line.split('.')
const fn = identifiers[identifiers.length - 1]
return fn[0]?.toUpperCase() === fn[0]
})
?.split(' ')[0]

return this.generateId(componentName || 'UnknownComponent')
}

/**
* Turn an array of anything into a predictable string. If any item is an atom
* instance, it will be serialized as the instance's id. If
Expand Down
4 changes: 1 addition & 3 deletions packages/atoms/src/classes/Selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,9 +397,7 @@ export class Selectors {

if (existingId || weak) return existingId

const selectorName =
this._getIdealCacheId(selectorOrConfig) ||
(DEV ? 'unknownSelector' : 'as')
const selectorName = this._getIdealCacheId(selectorOrConfig) || 'unnamed'

const key = this.ecosystem._idGenerator.generateId(
`@@selector-${selectorName}`
Expand Down
6 changes: 2 additions & 4 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,12 @@
"url": "https://github.com/Omnistac/zedux/issues"
},
"dependencies": {
"@zedux/atoms": "^1.1.1",
"use-sync-external-store": "^1.2.0"
"@zedux/atoms": "^1.1.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/react-dom": "^18.0.11",
"@types/use-sync-external-store": "^0.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
Expand Down Expand Up @@ -57,7 +55,7 @@
],
"license": "MIT",
"peerDependencies": {
"react": ">=16.3.0"
"react": ">=18.0.0"
},
"repository": {
"directory": "packages/react",
Expand Down
25 changes: 10 additions & 15 deletions packages/react/src/components/EcosystemProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createEcosystem, Ecosystem, EcosystemConfig } from '@zedux/atoms'
import React, { ReactNode, useMemo } from 'react'
import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'
import React, { ReactNode, useEffect, useMemo } from 'react'
import { ecosystemContext } from '../utils'

/**
Expand Down Expand Up @@ -35,25 +34,21 @@ export const EcosystemProvider = ({
children?: ReactNode
ecosystem?: undefined
})) => {
const [subscribe, getSnapshot] = useMemo(() => {
const resolvedEcosystem =
const ecosystem = useMemo(
() =>
passedEcosystem ||
createEcosystem({
destroyOnUnmount: true,
...ecosystemConfig,
})
}),
[ecosystemConfig.id, passedEcosystem]
) // don't pass other vals; just get snapshot when these change

return [
() => {
resolvedEcosystem._incrementRefCount()
useEffect(() => {
ecosystem._incrementRefCount()

return () => resolvedEcosystem._decrementRefCount()
},
() => resolvedEcosystem,
]
}, [ecosystemConfig.id, passedEcosystem]) // don't pass other vals; just get snapshot when these change

const ecosystem = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
return () => ecosystem._decrementRefCount()
}, [ecosystem])

return (
<ecosystemContext.Provider value={ecosystem.id}>
Expand Down
132 changes: 58 additions & 74 deletions packages/react/src/hooks/useAtomInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import {
AtomParamsType,
ParamlessTemplate,
} from '@zedux/atoms'
import { useMemo } from 'react'
import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'
import { useEffect, useState } from 'react'
import { ZeduxHookConfig } from '../types'
import { destroyed, External, Static } from '../utils'
import { External, Static } from '../utils'
import { useEcosystem } from './useEcosystem'
import { useReactComponentId } from './useReactComponentId'

Expand Down Expand Up @@ -72,81 +71,66 @@ export const useAtomInstance: {
) => {
const ecosystem = useEcosystem()
const dependentKey = useReactComponentId()
const [, render] = useState<undefined | object>()

// it should be fine for this to run every render. It's possible to change
// It should be fine for this to run every render. It's possible to change
// approaches if it is too heavy sometimes. But don't memoize this call:
const instance = ecosystem.getInstance(atom as A, params as AtomParamsType<A>)

const [subscribeFn, getSnapshot] = useMemo(() => {
let tuple = [instance, instance.getState()]

return [
(onStoreChange: () => void) => {
// this function must be idempotent
if (
!ecosystem._graph.nodes[instance.id]?.dependents.get(dependentKey)
) {
// React can unmount other components before calling this subscribe
// function but after we got the instance above. Re-get the instance
// if such unmountings destroyed it in the meantime:
if (instance.status === 'Destroyed') {
tuple[1] = destroyed
onStoreChange()

return () => {} // let the next render register the graph edge
}

ecosystem._graph.addEdge(
dependentKey,
instance.id,
operation,
External | (subscribe ? 0 : Static),
signal => {
// returning a unique symbol from `getSnapshot` after we call
// `onStoreChange` causes the component to rerender. On rerender,
// instance will be set again, so `useSyncExternalStore` will
// never actually return that symbol.
if (signal === 'Destroyed') tuple[1] = destroyed

onStoreChange()
}
)
}

return () => {
ecosystem._graph.removeEdge(dependentKey, instance.id)
const renderedState = instance.getState()

const addEdge = () => {
if (!ecosystem._graph.nodes[instance.id]?.dependents.get(dependentKey)) {
ecosystem._graph.addEdge(
dependentKey,
instance.id,
operation,
External | (subscribe ? 0 : Static),
() => {
if ((render as any).mounted) render({})
}
},
// this getSnapshot has to return a different val if either the instance
// or the state change (since in the case of primitive values, the new
// instance's state could be exactly the same (===) as the previous
// instance's value)
() => {
// This hack should work 'cause React can't use the return value unless
// it renders this component. And when it rerenders,
// `tuple[1]` will get defined again before this point
if (tuple[1] === destroyed) return destroyed as any

if (suspend !== false) {
const status = tuple[0]._promiseStatus

if (status === 'loading') {
throw tuple[0].promise
} else if (status === 'error') {
throw tuple[0]._promiseError
}
}

if (!subscribe) return tuple

const state = tuple[0].getState()

if (state === tuple[1]) return tuple
)
}
}

return (tuple = [tuple[0], state])
},
]
}, [instance, subscribe, suspend])
// Yes, subscribe during render. This operation is idempotent and we handle
// React's StrictMode specifically.
addEdge()

// Only remove the graph edge when the instance id changes or on component
// destruction.
useEffect(() => {
// Try adding the edge again (will be a no-op unless React's StrictMode ran
// this effect's cleanup unnecessarily)
addEdge()

// use the referentially stable render function as a ref :O
;(render as any).mounted = true

// an unmounting component's effect cleanup can update or force-destroy the
// atom instance before this component is mounted. If that happened, trigger
// a rerender to recreate the atom instance and/or get its new state
if (
instance.getState() !== renderedState ||
instance.status === 'Destroyed'
) {
render({})
}

return () => {
// no need to set the "ref"'s `.mounted` property to false here
ecosystem._graph.removeEdge(dependentKey, instance.id)
}
}, [instance.id])

if (suspend !== false) {
const status = instance._promiseStatus

if (status === 'loading') {
throw instance.promise
} else if (status === 'error') {
throw instance._promiseError
}
}

return useSyncExternalStore(subscribeFn, getSnapshot, getSnapshot)[0]
return instance
}
Loading

0 comments on commit d766df0

Please sign in to comment.