├── .babelrc
├── .eslintignore
├── .eslintrc.js
├── .github
├── FUNDING.yml
└── workflows
│ └── checks.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── .vscode
├── launch.json
└── settings.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── babel.ts
├── benchmarks
├── README.md
├── data
│ ├── largeNumberArray.json
│ ├── largeObjectSmallArray.json
│ ├── smallNumberArray.json
│ └── smallObjectSmallArray.json
└── micro-optimizations
│ └── loops
│ ├── bench.sh
│ ├── forEach.js
│ └── forLoop.js
├── bun.lock
├── bunfig.toml
├── docs
└── README.md
├── examples
└── middleware.ts
├── index.ts
├── jest.config.json
├── lefthook.yml
├── package.json
├── posttsup.ts
├── react-native.ts
├── react-web.ts
├── react.ts
├── src
├── ObservableHint.ts
├── ObservableObject.ts
├── ObservablePrimitive.ts
├── as
│ ├── arrayAsRecord.ts
│ ├── arrayAsSet.ts
│ ├── arrayAsString.ts
│ ├── numberAsString.ts
│ ├── recordAsArray.ts
│ ├── recordAsString.ts
│ ├── setAsArray.ts
│ ├── setAsString.ts
│ ├── stringAsArray.ts
│ ├── stringAsNumber.ts
│ ├── stringAsRecord.ts
│ └── stringAsSet.ts
├── babel
│ └── index.ts
├── batching.ts
├── computed.ts
├── config
│ ├── configureLegendState.ts
│ ├── enable$GetSet.ts
│ ├── enableReactComponents.ts
│ ├── enableReactNativeComponents.ts
│ ├── enableReactTracking.ts
│ ├── enableReactUse.ts
│ └── enable_PeekAssign.ts
├── createObservable.ts
├── event.ts
├── globals.ts
├── helpers.ts
├── helpers
│ ├── pageHash.ts
│ ├── pageHashParams.ts
│ ├── time.ts
│ ├── trackHistory.ts
│ └── undoRedo.ts
├── is.ts
├── linked.ts
├── middleware.ts
├── observable.ts
├── observableInterfaces.ts
├── observableTypes.ts
├── observe.ts
├── onChange.ts
├── persist-plugins
│ ├── async-storage.ts
│ ├── expo-sqlite.ts
│ ├── indexeddb.ts
│ ├── local-storage.ts
│ └── mmkv.ts
├── proxy.ts
├── react-hooks
│ ├── createObservableHook.ts
│ ├── useHover.ts
│ ├── useMeasure.ts
│ └── useObservableNextRouter.ts
├── react-reactive
│ ├── Components.ts
│ ├── enableReactComponents.ts
│ ├── enableReactNativeComponents.ts
│ ├── enableReactive.native.ts
│ ├── enableReactive.ts
│ └── enableReactive.web.ts
├── react-web
│ └── $React.tsx
├── react
│ ├── Computed.tsx
│ ├── For.tsx
│ ├── Memo.tsx
│ ├── Reactive.tsx
│ ├── Show.tsx
│ ├── Switch.tsx
│ ├── configureReactive.ts
│ ├── react-globals.ts
│ ├── reactInterfaces.ts
│ ├── reactive-observer.tsx
│ ├── useComputed.ts
│ ├── useEffectOnce.ts
│ ├── useIsMounted.ts
│ ├── useMount.ts
│ ├── useObservable.ts
│ ├── useObservableReducer.ts
│ ├── useObservableState.ts
│ ├── useObserve.ts
│ ├── useObserveEffect.ts
│ ├── usePauseProvider.tsx
│ ├── useSelector.ts
│ ├── useUnmount.ts
│ └── useWhen.ts
├── setupTracking.ts
├── sync-plugins
│ ├── crud.ts
│ ├── fetch.ts
│ ├── firebase.ts
│ ├── keel.ts
│ ├── supabase.ts
│ ├── tanstack-query.ts
│ └── tanstack-react-query.ts
├── sync
│ ├── activateSyncedNode.ts
│ ├── configureObservableSync.ts
│ ├── configureSynced.ts
│ ├── persistTypes.ts
│ ├── retry.ts
│ ├── revertChanges.ts
│ ├── syncHelpers.ts
│ ├── syncObservable.ts
│ ├── syncTypes.ts
│ ├── synced.ts
│ ├── transformObjectFields.ts
│ └── waitForSet.ts
├── syncState.ts
├── trace
│ ├── traceHelpers.ts
│ ├── useTraceListeners.ts
│ ├── useTraceUpdates.ts
│ ├── useVerifyNotTracking.ts
│ └── useVerifyOneRender.ts
├── trackSelector.ts
├── tracking.ts
├── types
│ ├── babel.d.ts
│ ├── reactive-native.d.ts
│ └── reactive-web.d.ts
└── when.ts
├── sync.ts
├── testbundles
├── bundlecore.js
├── bundlereact.js
└── bundlesync.js
├── tests
├── babel.test.ts
├── computed-old.test.ts
├── computed-persist.test.ts
├── computed.test.ts
├── crud.test.ts
├── happydom.ts
├── helpers.test.ts
├── history.test.ts
├── keel.test.ts
├── mapset.test.ts
├── middleware.test.ts
├── perf.test.ts
├── persist-indexeddb.test.ts
├── persist-localstorage.test.ts
├── persist.test.ts
├── react.test.tsx
├── sync.test.ts
├── synced.test.ts
├── testglobals.ts
├── tests.test.ts
├── tracking.test.ts
├── transform.test.ts
└── types.test.ts
├── trace.ts
├── tsconfig.esm.json
├── tsconfig.json
└── tsup.config.ts
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "es2015",
4 | "react"
5 | ]
6 | }
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | benchmarks/*
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-undef
2 | module.exports = {
3 | env: {
4 | browser: true,
5 | es2021: true,
6 | },
7 | extends: ['eslint:recommended', 'plugin:react/recommended', 'plugin:@typescript-eslint/recommended'],
8 | overrides: [],
9 | parser: '@typescript-eslint/parser',
10 | parserOptions: {
11 | ecmaVersion: 'latest',
12 | sourceType: 'module',
13 | },
14 | plugins: ['react', '@typescript-eslint'],
15 | rules: {
16 | '@typescript-eslint/no-explicit-any': 'off',
17 | '@typescript-eslint/no-non-null-assertion': 'off', // Since we dont use strictNullChecks
18 | 'react/prop-types': 'off',
19 | '@typescript-eslint/no-namespace': 'off',
20 | '@typescript-eslint/ban-types': 'off',
21 | },
22 | settings: {
23 | react: {
24 | version: 'detect',
25 | },
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [jmeistrich]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
15 |
--------------------------------------------------------------------------------
/.github/workflows/checks.yml:
--------------------------------------------------------------------------------
1 | name: Run Checks
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | Test:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - uses: actions/setup-node@v4
11 | with:
12 | node-version: '20'
13 | - name: Install modules
14 | run: npm install
15 | - name: Run tests
16 | run: npm test
17 | Format:
18 | runs-on: ubuntu-latest
19 | steps:
20 | - uses: actions/checkout@v4
21 | - uses: actions/setup-node@v4
22 | with:
23 | node-version: '20'
24 | - name: Install modules
25 | run: npm install
26 | - name: Check formatting
27 | run: npm run format:check
28 | Lint:
29 | runs-on: ubuntu-latest
30 | steps:
31 | - uses: actions/checkout@v4
32 | - uses: actions/setup-node@v4
33 | with:
34 | node-version: '20'
35 | - name: Install modules
36 | run: npm install
37 | - name: Run linter
38 | run: npm run lint:check
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .rollup.cache
4 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Ignore artifacts:
2 | node_modules
3 | .vscode
4 |
5 | # Ignore files
6 | *.json
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "printWidth": 120,
4 | "singleQuote": true
5 | }
6 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "node",
9 | "request": "launch",
10 | "name": "Jest Tests",
11 | "program": "${workspaceRoot}/node_modules/jest/bin/jest.js",
12 | "args": [
13 | "--watch",
14 | "-i",
15 | ],
16 | "cwd": "${workspaceFolder}/src",
17 | "console": "integratedTerminal"
18 | },
19 | ]
20 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true,
4 | "[markdown]": {
5 | "editor.defaultFormatter": "vscode.markdown-language-features"
6 | },
7 | "typescript.tsdk": "node_modules/typescript/lib",
8 | "[typescript]": {
9 | "editor.defaultFormatter": "esbenp.prettier-vscode"
10 | }
11 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Moo.do LLC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Legend-State
2 |
3 | Legend-State is a super fast all-in-one state and sync library that lets you write less code to make faster apps. Legend-State has four primary goals:
4 |
5 | ### 1. 🦄 As easy as possible to use
6 |
7 | There is no boilerplate and there are no contexts, actions, reducers, dispatchers, sagas, thunks, or epics. It doesn't modify your data at all, and you can just call `get()` to get the raw data and `set()` to change it.
8 |
9 | In React components you can call `use()` on any observable to get the raw data and automatically re-render whenever it changes.
10 |
11 | ```jsx
12 | import { observable, observe } from "@legendapp/state"
13 | import { observer } from "@legendapp/state/react"
14 |
15 | const settings$ = observable({ theme: 'dark' })
16 |
17 | // get returns the raw data
18 | settings$.theme.get() // 'dark'
19 | // set sets
20 | settings$.theme.set('light')
21 |
22 | // Computed observables with just a function
23 | const isDark$ = observable(() => settings$.theme.get() === 'dark')
24 |
25 | // observing contexts re-run when tracked observables change
26 | observe(() => {
27 | console.log(settings$.theme.get())
28 | })
29 |
30 | const Component = observer(function Component() {
31 | const theme = state$.settings.theme.get()
32 |
33 | return
Theme: {theme}
34 | })
35 | ```
36 |
37 | ### 2. ⚡️ The fastest React state library
38 |
39 | Legend-State beats every other state library on just about every metric and is so optimized for arrays that it even beats vanilla JS on the "swap" and "replace all rows" benchmarks. At only `4kb` and with the massive reduction in boilerplate code, you'll have big savings in file size too.
40 |
41 |
42 |
43 |
44 |
45 | See [Fast 🔥](https://www.legendapp.com/open-source/state/v3/intro/fast/) for more details of why Legend-State is so fast.
46 |
47 | ### 3. 🔥 Fine-grained reactivity for minimal renders
48 |
49 | Legend-State lets you make your renders super fine-grained, so your apps will be much faster because React has to do less work. The best way to be fast is to render less, less often.
50 |
51 | ```jsx
52 | function FineGrained() {
53 | const count$ = useObservable(0)
54 |
55 | useInterval(() => {
56 | count$.set(v => v + 1)
57 | }, 600)
58 |
59 | // The text updates itself so the component doesn't re-render
60 | return (
61 |
62 | Count: {count$}
63 |
64 | )
65 | }
66 | ```
67 |
68 | ### 4. 💾 Powerful sync and persistence
69 |
70 | Legend-State includes a powerful [sync and persistence system](../../usage/persist-sync). It easily enables local-first apps by optimistically applying all changes locally first, retrying changes even after restart until they eventually sync, and syncing minimal diffs. We use Legend-State as the sync systems in [Legend](https://legendapp.com) and [Bravely](https://bravely.io), so it is by necessity very full featured while being simple to set up.
71 |
72 | Local persistence plugins for the browser and React Native are included, with sync plugins for [Keel](https://www.keel.so), [Supabase](https://www.supabase.com), [TanStack Query](https://tanstack.com/query), and `fetch`.
73 |
74 | ```js
75 | const state$ = observable(
76 | users: syncedKeel({
77 | list: queries.getUsers,
78 | create: mutations.createUsers,
79 | update: mutations.updateUsers,
80 | delete: mutations.deleteUsers,
81 | persist: { name: 'users', retrySync: true },
82 | debounceSet: 500,
83 | retry: {
84 | infinite: true,
85 | },
86 | changesSince: 'last-sync',
87 | }),
88 | // direct link to my user within the users observable
89 | me: () => state$.users['myuid']
90 | )
91 |
92 | observe(() => {
93 | // get() activates through to state$.users and starts syncing.
94 | // it updates itself and re-runs observers when name changes
95 | const name = me$.name.get()
96 | })
97 |
98 | // Setting a value goes through to state$.users and saves update to server
99 | me$.name.set('Annyong')
100 | ```
101 |
102 | ## Install
103 |
104 | `bun add @legendapp/state` or `npm install @legendapp/state` or `yarn add @legendapp/state`
105 |
106 | ## Highlights
107 |
108 | - ✨ Super easy to use 😌
109 | - ✨ Super fast ⚡️
110 | - ✨ Super small at 4kb 🐥
111 | - ✨ Fine-grained reactivity 🔥
112 | - ✨ No boilerplate
113 | - ✨ Designed for maximum performance and scalability
114 | - ✨ React components re-render only on changes
115 | - ✨ Very strongly typed with TypeScript
116 | - ✨ Persistence plugins for automatically saving/loading from storage
117 | - ✨ State can be global or within components
118 |
119 | [Read more](https://www.legendapp.com/open-source/state/v3/intro/why/) about why Legend-State might be right for you.
120 |
121 | ## Documentation
122 |
123 | See [the documentation site](https://www.legendapp.com/open-source/state/).
124 |
125 | ## Community
126 |
127 | Join us on [Discord](https://discord.gg/5CBaNtADNX) to get involved with the Legend community.
128 |
129 | ## 👩⚖️ License
130 |
131 | [MIT](LICENSE)
132 |
133 | ---
134 |
135 | Legend-State is created and maintained by [Jay Meistrich](https://github.com/jmeistrich) with [Legend](https://www.legendapp.com) and [Bravely](https://www.bravely.io).
136 |
137 |
138 |
139 |
140 |
141 |
142 |
--------------------------------------------------------------------------------
/babel.ts:
--------------------------------------------------------------------------------
1 | import babel from './src/babel';
2 | export default babel;
3 |
--------------------------------------------------------------------------------
/benchmarks/README.md:
--------------------------------------------------------------------------------
1 | # Legend-State Benchmarks
2 |
3 | Legend-state seeks to be the fastest React state management library. Achieving this requires numerous optimizations which we put in the following categories:
4 |
5 | - Architecture Optimizations: Optimizations related to the core design of Legend-state (i.e. how `Proxy` is used)
6 | - Micro-optimizations: Optimizations related to JavaScript primitives (iteration, Object types, etc.)
7 | - Array Optimizations: Optimizations related to the efficient rendering of large lists of data.
8 |
9 | ## Running Benchmarks
10 |
11 | We use [hyperfine](https://github.com/sharkdp/hyperfine) for running benchmarks. You will need this installed in order to run the benchmark script.
12 |
13 | All benchmarked optimizations will have a directory within the relevant optimization category directory (i.e. `architecture-optimizations`, `micro-optimizations`, `array-optimizations`). Each optimization directory will have a list of JavaScript files that each implement an approach and then a `bench.sh` that uses `hyperfine` to run the benchmark.
14 |
15 | You can inspect in the data used for benchmarking in the `data` directory.
16 |
17 | After installing `hyperfine` it should be as simple as a `bench.sh` script to see the results of a benchmark.
18 |
--------------------------------------------------------------------------------
/benchmarks/data/smallNumberArray.json:
--------------------------------------------------------------------------------
1 | [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,995,996,997,998,999]
--------------------------------------------------------------------------------
/benchmarks/micro-optimizations/loops/bench.sh:
--------------------------------------------------------------------------------
1 | hyperfine --warmup 3 "DATA='../../data/smallNumberArray' node forLoop.js" "DATA='../../data/smallNumberArray' node forEach.js"
2 | echo ""
3 | echo ""
4 | echo ""
5 | hyperfine --warmup 3 "DATA='../../data/largeNumberArray' node forLoop.js" "DATA='../../data/largeNumberArray' node forEach.js"
6 | echo ""
7 | echo ""
8 | echo ""
9 | hyperfine --warmup 3 "DATA='../../data/smallObjectSmallArray' node forLoop.js" "DATA='../../data/smallObjectSmallArray' node forEach.js"
10 | echo ""
11 | echo ""
12 | echo ""
13 | hyperfine --warmup 3 "DATA='../../data/largeObjectSmallArray' node forLoop.js" "DATA='../../data/largeObjectSmallArray' node forEach.js"
--------------------------------------------------------------------------------
/benchmarks/micro-optimizations/loops/forEach.js:
--------------------------------------------------------------------------------
1 | const data = require(process.env.DATA);
2 |
3 | const noOp = () => {};
4 |
5 | data.forEach(noOp);
6 |
--------------------------------------------------------------------------------
/benchmarks/micro-optimizations/loops/forLoop.js:
--------------------------------------------------------------------------------
1 | const data = require(process.env.DATA);
2 |
3 | for (let i = 0; i < data.length; i++) {
4 | continue;
5 | }
6 |
--------------------------------------------------------------------------------
/bunfig.toml:
--------------------------------------------------------------------------------
1 | [install]
2 | saveTextLockfile = true
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | See the docs repo: https://github.com/legendapp/legend-docs
2 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | export * from './src/ObservableHint';
2 | export { isObserved, shouldIgnoreUnobserved } from './src/ObservableObject';
3 | export { batch, beginBatch, endBatch } from './src/batching';
4 | export { computed } from './src/computed';
5 | export { event } from './src/event';
6 | export { isObservable } from './src/globals';
7 | export {
8 | applyChange,
9 | applyChanges,
10 | computeSelector,
11 | constructObjectWithPath,
12 | deconstructObjectWithPath,
13 | getObservableIndex,
14 | isObservableValueReady,
15 | mergeIntoObservable,
16 | opaqueObject,
17 | setAtPath,
18 | setSilently,
19 | } from './src/helpers';
20 | export {
21 | hasOwnProperty,
22 | isArray,
23 | isBoolean,
24 | isDate,
25 | isEmpty,
26 | isFunction,
27 | isMap,
28 | isNullOrUndefined,
29 | isNumber,
30 | isObject,
31 | isPlainObject,
32 | isPrimitive,
33 | isPromise,
34 | isSet,
35 | isString,
36 | isSymbol,
37 | } from './src/is';
38 | export { linked } from './src/linked';
39 | export { observable, observablePrimitive } from './src/observable';
40 | export type * from './src/observableInterfaces';
41 | export * from './src/observableTypes';
42 | export { observe } from './src/observe';
43 | export { proxy } from './src/proxy';
44 | export { syncState } from './src/syncState';
45 | export { trackSelector } from './src/trackSelector';
46 | export { when, whenReady } from './src/when';
47 |
48 | /** @internal */
49 | export { beginTracking, endTracking, tracking, updateTracking } from './src/tracking';
50 | /** @internal */
51 | export { setupTracking } from './src/setupTracking';
52 | /** @internal */
53 | export { findIDKey, getNode, getNodeValue, optimized, symbolDelete } from './src/globals';
54 | /** @internal */
55 | export { ObservablePrimitiveClass } from './src/ObservablePrimitive';
56 |
57 | // Internal:
58 | import { get, getProxy, observableFns, observableProperties, peek, set } from './src/ObservableObject';
59 | import { createPreviousHandler } from './src/batching';
60 | import {
61 | clone,
62 | ensureNodeValue,
63 | findIDKey,
64 | getKeys,
65 | getNode,
66 | getNodeValue,
67 | getPathType,
68 | globalState,
69 | optimized,
70 | safeParse,
71 | safeStringify,
72 | setNodeValue,
73 | symbolDelete,
74 | symbolLinked,
75 | } from './src/globals';
76 | import { deepMerge, getValueAtPath, initializePathType, setAtPath } from './src/helpers';
77 | import { tracking } from './src/tracking';
78 | import { ObservablePrimitiveClass } from './src/ObservablePrimitive';
79 | import { registerMiddleware } from './src/middleware';
80 |
81 | export const internal = {
82 | createPreviousHandler,
83 | clone,
84 | deepMerge,
85 | ensureNodeValue,
86 | findIDKey,
87 | get,
88 | getKeys,
89 | getNode,
90 | getNodeValue,
91 | getPathType,
92 | getProxy,
93 | getValueAtPath,
94 | globalState,
95 | initializePathType,
96 | ObservablePrimitiveClass,
97 | observableProperties,
98 | observableFns,
99 | optimized,
100 | peek,
101 | registerMiddleware,
102 | safeParse,
103 | safeStringify,
104 | set,
105 | setAtPath,
106 | setNodeValue,
107 | symbolLinked,
108 | symbolDelete,
109 | tracking,
110 | };
111 |
--------------------------------------------------------------------------------
/jest.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "preset": "ts-jest",
3 | "testEnvironment": "node",
4 | "modulePathIgnorePatterns": ["/dist"],
5 | "moduleNameMapper": {
6 | "@legendapp/state/sync-plugins/crud": "/src/sync-plugins/crud",
7 | "@legendapp/state/sync": "/sync",
8 | "@legendapp/state/config/configureLegendState": "/src/config/configureLegendState",
9 | "@legendapp/state": "/index"
10 | },
11 | "transform": {
12 | "^.+\\.tsx?$": ["ts-jest", { "tsconfig": { "jsx": "react" } }]
13 | }
14 | }
--------------------------------------------------------------------------------
/lefthook.yml:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LegendApp/legend-state/a6f997f1b990ceeba189253e17d5bf8cc9fa55ed/lefthook.yml
--------------------------------------------------------------------------------
/posttsup.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import fs from 'node:fs';
3 | import pkg from './package.json';
4 |
5 | async function copy(...files: string[]) {
6 | return files.map((file) => Bun.write('dist/' + file.replace('src/', ''), Bun.file(file), { createPath: true }));
7 | }
8 |
9 | copy(
10 | 'LICENSE',
11 | 'CHANGELOG.md',
12 | 'README.md',
13 | 'src/types/babel.d.ts',
14 | 'src/types/reactive-native.d.ts',
15 | 'src/types/reactive-web.d.ts',
16 | );
17 |
18 | const lsexports = pkg.lsexports;
19 | const exports: Record = {
20 | './package.json': './package.json',
21 | './babel': './babel.js',
22 | './types/babel': {
23 | types: './types/babel.d.ts',
24 | },
25 | './types/reactive-web': {
26 | types: './types/reactive-web.d.ts',
27 | },
28 | './types/reactive-native': {
29 | types: './types/reactive-native.d.ts',
30 | },
31 | };
32 | function addExport(key: string, file: string) {
33 | exports[key] = {
34 | import: `./${file}.mjs`,
35 | require: `./${file}.js`,
36 | types: `./${file}.d.ts`,
37 | };
38 | }
39 | lsexports.forEach((exp) => {
40 | if (exp.endsWith('/*')) {
41 | const p = exp.replace('/*', '');
42 | const files = fs.readdirSync(path.join('src', p));
43 |
44 | files.forEach((filename) => {
45 | const file = filename.replace(/\.[^/.]+$/, '');
46 | if (!file.startsWith('_')) {
47 | addExport(`${file === '.' ? '' : './'}${p}/${file}`, `${p}/${file}`);
48 | }
49 | });
50 | } else {
51 | addExport(exp === '.' ? exp : './' + exp, exp === '.' ? 'index' : exp);
52 | }
53 | });
54 |
55 | const pkgOut = pkg as Record;
56 |
57 | pkg.private = false;
58 | pkgOut.exports = exports;
59 | delete pkgOut.lsexports;
60 | delete pkgOut.devDependencies;
61 | delete pkgOut.overrides;
62 | delete pkgOut.scripts;
63 | delete pkgOut.engines;
64 |
65 | Bun.write('dist/package.json', JSON.stringify(pkg, undefined, 2));
66 |
67 | async function fix_To$(path: string) {
68 | const pathOld = path.replace('$', '_');
69 | await Bun.write(path, Bun.file(pathOld));
70 | fs.unlinkSync(pathOld);
71 | }
72 | fix_To$('dist/config/enable$GetSet.d.ts');
73 | fix_To$('dist/config/enable$GetSet.d.mts');
74 |
--------------------------------------------------------------------------------
/react-native.ts:
--------------------------------------------------------------------------------
1 | export * from './src/react-reactive/Components';
2 |
--------------------------------------------------------------------------------
/react-web.ts:
--------------------------------------------------------------------------------
1 | export { $React } from './src/react-web/$React';
2 |
--------------------------------------------------------------------------------
/react.ts:
--------------------------------------------------------------------------------
1 | export * from './src/react/Computed';
2 | export * from './src/react/For';
3 | export { usePauseProvider } from './src/react/usePauseProvider';
4 | export * from './src/react/Memo';
5 | export { Reactive } from './src/react/Reactive';
6 | export type { IReactive } from './src/react/Reactive';
7 | export * from './src/react/Show';
8 | export * from './src/react/Switch';
9 | export * from './src/react/reactInterfaces';
10 | export * from './src/react/reactive-observer';
11 | export * from './src/react/useComputed';
12 | export * from './src/react/useEffectOnce';
13 | export * from './src/react/useIsMounted';
14 | export * from './src/react/useMount';
15 | export * from './src/react/useObservable';
16 | export * from './src/react/useObservableReducer';
17 | export * from './src/react/useObserve';
18 | export * from './src/react/useObserveEffect';
19 | export * from './src/react/useSelector';
20 | export * from './src/react/useUnmount';
21 | export * from './src/react/useWhen';
22 | export { configureReactive } from './src/react/configureReactive';
23 |
--------------------------------------------------------------------------------
/src/ObservableHint.ts:
--------------------------------------------------------------------------------
1 | import { symbolOpaque, symbolPlain } from './globals';
2 | import type { OpaqueObject, PlainObject } from './observableInterfaces';
3 |
4 | function addSymbol(value: object, symbol: symbol) {
5 | if (value) {
6 | Object.defineProperty(value, symbol, {
7 | value: true,
8 | enumerable: false,
9 | writable: true,
10 | configurable: true,
11 | });
12 | }
13 | return value as T;
14 | }
15 |
16 | export const ObservableHint = {
17 | opaque: function opaqueObject(value: T): OpaqueObject {
18 | return addSymbol(value, symbolOpaque);
19 | },
20 | plain: function plainObject(value: T): PlainObject {
21 | return addSymbol(value, symbolPlain);
22 | },
23 | function: function plainObject(value: T): PlainObject {
24 | return addSymbol(value, symbolPlain);
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/src/ObservablePrimitive.ts:
--------------------------------------------------------------------------------
1 | import { set, get, peek, flushPending } from './ObservableObject';
2 | import { symbolGetNode } from './globals';
3 | import { isBoolean } from './is';
4 | import type { NodeInfo, TrackingType } from './observableInterfaces';
5 | import type { ObservablePrimitive, ObservableBoolean } from './observableTypes';
6 | import { onChange } from './onChange';
7 |
8 | interface ObservablePrimitiveState {
9 | _node: NodeInfo;
10 | toggle: () => void;
11 | }
12 |
13 | const fns: (keyof ObservableBoolean)[] = ['get', 'set', 'peek', 'onChange', 'toggle'];
14 |
15 | export function ObservablePrimitiveClass(this: ObservablePrimitive & ObservablePrimitiveState, node: NodeInfo) {
16 | this._node = node;
17 |
18 | // Bind to this
19 | for (let i = 0; i < fns.length; i++) {
20 | const key: keyof typeof this = fns[i];
21 | this[key] = (this[key] as Function).bind(this);
22 | }
23 | }
24 |
25 | // Add observable functions to prototype
26 | function proto(key: string, fn: Function) {
27 | ObservablePrimitiveClass.prototype[key] = function (...args: any[]) {
28 | return fn.call(this, this._node, ...args);
29 | };
30 | }
31 | proto('peek', (node: NodeInfo) => {
32 | flushPending();
33 | return peek(node);
34 | });
35 | proto('get', (node: NodeInfo, options?: TrackingType) => {
36 | flushPending();
37 | return get(node, options);
38 | });
39 | proto('set', set);
40 | proto('onChange', onChange);
41 |
42 | // Getters
43 | Object.defineProperty(ObservablePrimitiveClass.prototype, symbolGetNode, {
44 | configurable: true,
45 | get() {
46 | return this._node;
47 | },
48 | });
49 |
50 | ObservablePrimitiveClass.prototype.toggle = function (): void {
51 | const value = this.peek();
52 | if (value === undefined || value === null || isBoolean(value)) {
53 | this.set(!value);
54 | } else if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') {
55 | throw new Error('[legend-state] Cannot toggle a non-boolean value');
56 | }
57 | };
58 | ObservablePrimitiveClass.prototype.delete = function () {
59 | this.set(undefined);
60 |
61 | return this;
62 | };
63 |
--------------------------------------------------------------------------------
/src/as/arrayAsRecord.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, linked } from '@legendapp/state';
2 |
3 | export function arrayAsRecord(
4 | arr$: ObservableParam,
5 | keyField: TKey = 'id' as TKey,
6 | ): Linked> {
7 | return linked({
8 | get: () => {
9 | const record = {};
10 | const value = arr$.get();
11 | for (let i = 0; i < value.length; i++) {
12 | const v = value[i];
13 | const child = v[keyField];
14 | (record as any)[child[keyField]] = child;
15 | }
16 | return record;
17 | },
18 | set: ({ value }) => {
19 | if (value) {
20 | arr$.set(Object.values(value));
21 | } else {
22 | arr$.set(value);
23 | }
24 | },
25 | });
26 | }
27 |
--------------------------------------------------------------------------------
/src/as/arrayAsSet.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, linked } from '@legendapp/state';
2 |
3 | export function arrayAsSet(arr$: ObservableParam): Linked> {
4 | return linked({
5 | get: () => new Set(arr$.get()),
6 | set: ({ value }) => arr$.set(Array.from(value)),
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/src/as/arrayAsString.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, linked } from '@legendapp/state';
2 |
3 | export function arrayAsString(arr$: ObservableParam): Linked {
4 | return linked({
5 | get: () => JSON.stringify(arr$?.get()),
6 | set: ({ value }) => arr$.set(JSON.parse(value || '[]')),
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/src/as/numberAsString.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, linked } from '@legendapp/state';
2 |
3 | export function numberAsString(num$: ObservableParam): Linked {
4 | return linked({
5 | get: () => num$.get() + '',
6 | set: ({ value }) => num$?.set(+value),
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/src/as/recordAsArray.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, linked } from '@legendapp/state';
2 |
3 | export function recordAsArray(
4 | record$: ObservableParam>,
5 | keyField: TKey = 'id' as TKey,
6 | ): Linked {
7 | return linked({
8 | get: () => Object.values(record$),
9 | set: ({ value }) => {
10 | if (value) {
11 | const record = {};
12 | for (let i = 0; i < value.length; i++) {
13 | const v = value[i];
14 | const child = v[keyField];
15 | (record as any)[child[keyField]] = child;
16 | }
17 | record$.set(record);
18 | } else {
19 | record$.set(value);
20 | }
21 | },
22 | });
23 | }
24 |
--------------------------------------------------------------------------------
/src/as/recordAsString.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, linked } from '@legendapp/state';
2 |
3 | export function recordAsString(record$: ObservableParam>): Linked {
4 | return linked({
5 | get: () => JSON.stringify(record$.get()),
6 | set: ({ value }) => record$?.set(JSON.parse(value || '{}')),
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/src/as/setAsArray.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, linked } from '@legendapp/state';
2 |
3 | export function setAsArray(set$: ObservableParam>): Linked {
4 | return linked({
5 | get: () => Array.from(set$?.get()),
6 | set: ({ value }) => set$.set(new Set(value)),
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/src/as/setAsString.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, linked } from '@legendapp/state';
2 |
3 | export function setAsString(set$: ObservableParam>): Linked {
4 | return linked({
5 | get: () => JSON.stringify(Array.from(set$?.get())),
6 | set: ({ value }) => set$.set(new Set(JSON.parse(value || '[]'))),
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/src/as/stringAsArray.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, linked } from '@legendapp/state';
2 |
3 | export function stringAsArray(str$: ObservableParam): Linked {
4 | return linked({
5 | get: () => JSON.parse(str$?.get() || '[]') as T[],
6 | set: ({ value }) => str$?.set(JSON.stringify(value)),
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/src/as/stringAsNumber.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, isNumber, linked } from '@legendapp/state';
2 |
3 | export function stringAsNumber(num$: ObservableParam): Linked {
4 | return linked({
5 | get: () => {
6 | const num = +num$.get();
7 | return isNumber(num) ? +num : 0;
8 | },
9 | set: ({ value }) => num$?.set(value + ''),
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/src/as/stringAsRecord.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, linked } from '@legendapp/state';
2 |
3 | export function stringAsRecord>(str$: ObservableParam): Linked {
4 | return linked({
5 | get: () => {
6 | return JSON.parse(str$?.get() || '{}') as T;
7 | },
8 | set: ({ value }) => str$?.set(JSON.stringify(value)),
9 | });
10 | }
11 |
--------------------------------------------------------------------------------
/src/as/stringAsSet.ts:
--------------------------------------------------------------------------------
1 | import { Linked, ObservableParam, linked } from '@legendapp/state';
2 |
3 | export function stringAsSet(str$: ObservableParam): Linked> {
4 | return linked({
5 | get: () => new Set(JSON.parse(str$?.get() || '[]')),
6 | set: ({ value }) => str$?.set(JSON.stringify(Array.from(value))),
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/src/babel/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | arrowFunctionExpression,
3 | jsxClosingElement,
4 | jsxClosingFragment,
5 | jsxElement,
6 | jsxExpressionContainer,
7 | jsxFragment,
8 | jsxIdentifier,
9 | jsxOpeningElement,
10 | jsxOpeningFragment,
11 | } from '@babel/types';
12 |
13 | export default function () {
14 | let hasLegendImport = false;
15 | return {
16 | visitor: {
17 | ImportDeclaration: {
18 | enter(path: { node: any; replaceWith: (param: any) => any; skip: () => void }) {
19 | if (path.node.source.value === '@legendapp/state/react') {
20 | const specifiers = path.node.specifiers;
21 | for (let i = 0; i < specifiers.length; i++) {
22 | const s = specifiers[i].imported.name;
23 | if (!hasLegendImport && (s === 'Computed' || s === 'Memo' || s === 'Show')) {
24 | hasLegendImport = true;
25 | break;
26 | }
27 | }
28 | }
29 | },
30 | },
31 | JSXElement: {
32 | enter(path: {
33 | node: any;
34 | replaceWith: (param: any) => any;
35 | skip: () => void;
36 | traverse: (path: any) => any;
37 | }) {
38 | if (!hasLegendImport) {
39 | return;
40 | }
41 |
42 | const openingElement = path.node.openingElement;
43 | const name = openingElement.name.name;
44 |
45 | if (name === 'Computed' || name === 'Memo' || name === 'Show') {
46 | const children = removeEmptyText(path.node.children);
47 | if (children.length === 0) return;
48 |
49 | if (
50 | children[0].type === 'JSXElement' ||
51 | (children[0].type === 'JSXExpressionContainer' &&
52 | children[0].expression.type !== 'ArrowFunctionExpression' &&
53 | children[0].expression.type !== 'FunctionExpression' &&
54 | children[0].expression.type !== 'MemberExpression' &&
55 | children[0].expression.type !== 'Identifier')
56 | ) {
57 | const attrs = openingElement.attributes;
58 | path.replaceWith(
59 | jsxElement(
60 | jsxOpeningElement(jsxIdentifier(name), attrs),
61 | jsxClosingElement(jsxIdentifier(name)),
62 | [jsxExpressionContainer(arrowFunctionExpression([], maybeWrapFragment(children)))],
63 | ),
64 | );
65 | }
66 | }
67 | },
68 | },
69 | },
70 | };
71 | }
72 |
73 | function maybeWrapFragment(children: any[]) {
74 | if (children.length === 1 && children[0].type == 'JSXElement') return children[0];
75 | if (children.length === 1 && children[0].type == 'JSXExpressionContainer') return children[0].expression;
76 | return jsxFragment(jsxOpeningFragment(), jsxClosingFragment(), children);
77 | }
78 |
79 | function removeEmptyText(nodes: any[]) {
80 | return nodes.filter((node) => !(node.type === 'JSXText' && node.value.trim().length === 0));
81 | }
82 |
--------------------------------------------------------------------------------
/src/computed.ts:
--------------------------------------------------------------------------------
1 | import { linked } from './linked';
2 | import { observable } from './observable';
3 | import type { LinkedOptions } from './observableInterfaces';
4 | import { Observable, ObservableParam, RecursiveValueOrFunction } from './observableTypes';
5 |
6 | export function computed(get: () => RecursiveValueOrFunction): Observable;
7 | export function computed(
8 | get: (() => RecursiveValueOrFunction) | RecursiveValueOrFunction,
9 | set: (value: T2) => void,
10 | ): Observable;
11 | export function computed(
12 | get: (() => T | Promise) | ObservableParam | LinkedOptions,
13 | set?: (value: T2) => void,
14 | ): Observable {
15 | return observable(
16 | set ? linked({ get: get as LinkedOptions['get'], set: ({ value }: any) => set(value) }) : get,
17 | ) as any;
18 | }
19 |
--------------------------------------------------------------------------------
/src/config/configureLegendState.ts:
--------------------------------------------------------------------------------
1 | import { internal } from '@legendapp/state';
2 | import type { NodeInfo } from '@legendapp/state';
3 |
4 | const { globalState, observableProperties: _observableProperties, observableFns, ObservablePrimitiveClass } = internal;
5 |
6 | export function configureLegendState({
7 | observableFunctions,
8 | observableProperties,
9 | jsonReplacer,
10 | jsonReviver,
11 | }: {
12 | observableFunctions?: Record any>;
13 | observableProperties?: Record any; set: (node: NodeInfo, value: any) => any }>;
14 | jsonReplacer?: (this: any, key: string, value: any) => any;
15 | jsonReviver?: (this: any, key: string, value: any) => any;
16 | }) {
17 | if (observableFunctions) {
18 | for (const key in observableFunctions) {
19 | const fn = observableFunctions[key];
20 | observableFns.set(key, fn);
21 | ObservablePrimitiveClass.prototype[key] = function (...args: any[]) {
22 | return fn.call(this, this._node, ...args);
23 | };
24 | }
25 | }
26 | if (observableProperties) {
27 | for (const key in observableProperties) {
28 | const fns = observableProperties[key];
29 | _observableProperties.set(key, fns);
30 | Object.defineProperty(ObservablePrimitiveClass.prototype, key, {
31 | configurable: true,
32 | get() {
33 | return fns.get.call(this, this._node);
34 | },
35 | set(value: any) {
36 | return fns.set.call(this, this._node, value);
37 | },
38 | });
39 | }
40 | }
41 | if (jsonReplacer) {
42 | globalState.replacer = jsonReplacer;
43 | }
44 | if (jsonReviver) {
45 | globalState.reviver = jsonReviver;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/config/enable$GetSet.ts:
--------------------------------------------------------------------------------
1 | import { internal } from '@legendapp/state';
2 | import { configureLegendState } from '@legendapp/state/config/configureLegendState';
3 |
4 | export function enable$GetSet() {
5 | configureLegendState({
6 | observableProperties: {
7 | $: {
8 | get(node) {
9 | return internal.get(node);
10 | },
11 | set(node, value) {
12 | internal.set(node, value);
13 | },
14 | },
15 | },
16 | });
17 | }
18 | // TODOv4 deprecate
19 | export const enableDirectAccess = enable$GetSet;
20 |
21 | // Types:
22 |
23 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
24 | import type { ImmutableObservableBase } from '@legendapp/state';
25 |
26 | declare module '@legendapp/state' {
27 | interface ImmutableObservableBase {
28 | get $(): T;
29 | set $(value: T | null | undefined);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/config/enableReactComponents.ts:
--------------------------------------------------------------------------------
1 | import { BindKeys, FCReactiveObject, configureReactive } from '@legendapp/state/react';
2 |
3 | // TODOV3 Remove this
4 |
5 | export function enableReactComponents() {
6 | const bindInfo: BindKeys = {
7 | value: { handler: 'onChange', getValue: (e: any) => e.target.value, defaultValue: '' },
8 | };
9 | const bindInfoInput: BindKeys = Object.assign(
10 | { checked: { handler: 'onChange', getValue: (e: { target: { checked: boolean } }) => e.target.checked } },
11 | bindInfo,
12 | );
13 | configureReactive({
14 | binders: {
15 | input: bindInfoInput,
16 | textarea: bindInfo,
17 | select: bindInfo,
18 | },
19 | });
20 | }
21 |
22 | // Types:
23 |
24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
25 | import type { IReactive } from '@legendapp/state/react';
26 |
27 | declare module '@legendapp/state/react' {
28 | // eslint-disable-next-line @typescript-eslint/no-empty-interface
29 | interface IReactive extends FCReactiveObject {}
30 | }
31 |
--------------------------------------------------------------------------------
/src/config/enableReactNativeComponents.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 | import type { Observable } from '@legendapp/state';
3 | import { FCReactive, FCReactiveObject, configureReactive, useSelector } from '@legendapp/state/react';
4 | import {
5 | ActivityIndicator,
6 | ActivityIndicatorProps,
7 | Button,
8 | ButtonProps,
9 | FlatList,
10 | FlatListProps,
11 | Image,
12 | ImageProps,
13 | Pressable,
14 | PressableProps,
15 | ScrollView,
16 | ScrollViewProps,
17 | SectionList,
18 | SectionListProps,
19 | Switch,
20 | SwitchProps,
21 | Text,
22 | TextInput,
23 | TextInputProps,
24 | TextProps,
25 | TouchableWithoutFeedback,
26 | TouchableWithoutFeedbackProps,
27 | View,
28 | ViewProps,
29 | } from 'react-native';
30 |
31 | // TODOV3 Remove this
32 |
33 | export function enableReactNativeComponents() {
34 | configureReactive({
35 | components: {
36 | ActivityIndicator: ActivityIndicator,
37 | Button: Button,
38 | FlatList: FlatList,
39 | Image: Image,
40 | Pressable: Pressable,
41 | ScrollView: ScrollView,
42 | SectionList: SectionList,
43 | Switch: Switch,
44 | Text: Text,
45 | TextInput: TextInput,
46 | TouchableWithoutFeedback: TouchableWithoutFeedback,
47 | View: View,
48 | },
49 | binders: {
50 | TextInput: {
51 | value: {
52 | handler: 'onChange',
53 | getValue: (e: any) => e.nativeEvent.text,
54 | defaultValue: '',
55 | },
56 | },
57 | Switch: {
58 | value: {
59 | handler: 'onValueChange',
60 | getValue: (e: any) => e,
61 | defaultValue: false,
62 | },
63 | },
64 | FlatList: {
65 | data: {
66 | selector: (propsOut: Record, p: Observable) => {
67 | const state = useRef(0);
68 | // Increment renderNum whenever the array changes shallowly
69 | const [renderNum, value] = useSelector(() => [state.current++, p.get(true)]);
70 |
71 | // Set extraData to renderNum so that it will re-render when renderNum changes.
72 | // This is necessary because the observable array is mutable so changes to it
73 | // won't trigger re-renders by default.
74 | propsOut.extraData = renderNum;
75 |
76 | return value;
77 | },
78 | },
79 | },
80 | },
81 | });
82 | }
83 |
84 | // Types:
85 |
86 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
87 | import type { IReactive } from '@legendapp/state/react';
88 |
89 | declare module '@legendapp/state/react' {
90 | interface IReactive extends FCReactiveObject {
91 | ActivityIndicator: FCReactive;
92 | Button: FCReactive;
93 | FlatList: FCReactive>;
94 | Image: FCReactive;
95 | Pressable: FCReactive;
96 | ScrollView: FCReactive;
97 | SectionList: FCReactive>;
98 | Switch: FCReactive;
99 | Text: FCReactive;
100 | TextInput: FCReactive;
101 | TouchableWithoutFeedback: FCReactive;
102 | View: FCReactive;
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/config/enableReactTracking.ts:
--------------------------------------------------------------------------------
1 | import { type GetOptions, internal, isObject, tracking, type NodeInfo, type TrackingType } from '@legendapp/state';
2 | import { configureLegendState } from '@legendapp/state/config/configureLegendState';
3 | import { UseSelectorOptions, useSelector } from '@legendapp/state/react';
4 | import { createContext, useContext } from 'react';
5 | // @ts-expect-error Internals
6 | import { __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED as ReactInternals } from 'react';
7 |
8 | interface ReactTrackingOptions {
9 | auto?: boolean; // Make all get() calls act as useSelector() hooks
10 | warnUnobserved?: boolean; // Warn if get() is used outside of an observer
11 | warnMissingUse?: boolean; // Warn if get() is used in a component
12 | }
13 |
14 | export function enableReactTracking({ auto, warnUnobserved, warnMissingUse }: ReactTrackingOptions) {
15 | const { get } = internal;
16 |
17 | if (auto || (process.env.NODE_ENV === 'development' && (warnUnobserved || warnMissingUse))) {
18 | const ReactRenderContext = createContext(0);
19 |
20 | const isInRender = () => {
21 | // If we're already tracking then we definitely don't need useSelector
22 | try {
23 | // If there's no dispatcher we're definitely not in React
24 | // This is an optimization to not need to run useContext. If in a future React version
25 | // this works differently we can change it or just remove it.
26 | const dispatcher = ReactInternals.ReactCurrentDispatcher.current;
27 | if (dispatcher) {
28 | // If there's a dispatcher then we may be inside of a hook.
29 | // Attempt a useContext hook, which will throw an error if outside of render.
30 | useContext(ReactRenderContext);
31 | return true;
32 | }
33 | } catch {} // eslint-disable-line no-empty
34 | return false;
35 | };
36 |
37 | const isObserved = () => {
38 | // If we're already tracking then we definitely don't need useSelector
39 | return !!tracking.current;
40 | };
41 |
42 | const needsSelector = () => {
43 | // If we're already tracking then we definitely don't need useSelector
44 | if (!isObserved()) {
45 | return isInRender();
46 | }
47 | return false;
48 | };
49 |
50 | configureLegendState({
51 | observableFunctions: {
52 | get: (node: NodeInfo, options?: TrackingType | (GetOptions & UseSelectorOptions)) => {
53 | if (process.env.NODE_ENV === 'development' && warnMissingUse) {
54 | if (isInRender()) {
55 | if (isObserved()) {
56 | console.warn(
57 | '[legend-state] Detected a `get()` call in an observer component. It is recommended to use the `use$` hook instead to be compatible with React Compiler: https://legendapp.com/open-source/state/v3/react/react-api/#use$',
58 | );
59 | } else {
60 | console.warn(
61 | '[legend-state] Detected a `get()` call in a component. You likely want to use the `use$` hook to be reactive to it changing, or change `get()` to `peek()` to get the value without tracking: https://legendapp.com/open-source/state/v3/react/react-api/#use$',
62 | );
63 | }
64 | }
65 | } else if (needsSelector()) {
66 | if (auto) {
67 | return useSelector(() => get(node, options), isObject(options) ? options : undefined);
68 | } else if (process.env.NODE_ENV === 'development' && warnUnobserved) {
69 | console.warn(
70 | '[legend-state] Detected a `get()` call in an unobserved component. You may want to wrap it in observer: https://legendapp.com/open-source/state/v3/react/react-api/#observer',
71 | );
72 | }
73 | }
74 | return get(node, options);
75 | },
76 | },
77 | });
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/config/enableReactUse.ts:
--------------------------------------------------------------------------------
1 | import { internal, NodeInfo } from '@legendapp/state';
2 | import { configureLegendState } from '@legendapp/state/config/configureLegendState';
3 | import { useSelector, UseSelectorOptions } from '@legendapp/state/react';
4 |
5 | // TODO: Deprecated, remove in v4
6 | let didWarn = false;
7 |
8 | export function enableReactUse() {
9 | configureLegendState({
10 | observableFunctions: {
11 | use: (node: NodeInfo, options?: UseSelectorOptions) => {
12 | if (process.env.NODE_ENV === 'development' && !didWarn) {
13 | didWarn = true;
14 | console.warn(
15 | '[legend-state] enableReactUse() is deprecated. Please switch to using get() with observer, which is safer and more efficient. See https://legendapp.com/open-source/state/v3/react/react-api/',
16 | );
17 | }
18 | return useSelector(internal.getProxy(node), options);
19 | },
20 | },
21 | });
22 | }
23 |
24 | // Types:
25 |
26 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
27 | import type { ImmutableObservableBase } from '@legendapp/state';
28 |
29 | declare module '@legendapp/state' {
30 | interface ImmutableObservableBase {
31 | use(options?: UseSelectorOptions): T;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/config/enable_PeekAssign.ts:
--------------------------------------------------------------------------------
1 | import { internal } from '@legendapp/state';
2 | import { configureLegendState } from '@legendapp/state/config/configureLegendState';
3 |
4 | export function enable_PeekAssign() {
5 | configureLegendState({
6 | observableProperties: {
7 | _: {
8 | get(node) {
9 | return internal.peek(node);
10 | },
11 | set(node, value) {
12 | internal.setNodeValue(node, value);
13 | },
14 | },
15 | },
16 | });
17 | }
18 |
19 | // TODOv4 deprecate
20 | export const enableDirectAccess = enable_PeekAssign;
21 |
22 | // Types:
23 |
24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
25 | import type { ImmutableObservableBase } from '@legendapp/state';
26 |
27 | declare module '@legendapp/state' {
28 | interface ImmutableObservableBase {
29 | get _(): T;
30 | set _(value: T | null | undefined);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/createObservable.ts:
--------------------------------------------------------------------------------
1 | import { isObservable, setNodeValue } from './globals';
2 | import { isActualPrimitive, isFunction, isPromise } from './is';
3 | import type { ClassConstructor, NodeInfo, ObservableRoot } from './observableInterfaces';
4 | import { Observable, ObservablePrimitive } from './observableTypes';
5 |
6 | export function createObservable(
7 | value: T | undefined,
8 | makePrimitive: boolean,
9 | extractPromise: Function,
10 | createObject: Function,
11 | createPrimitive?: Function,
12 | ): Observable {
13 | if (isObservable(value)) {
14 | return value as Observable;
15 | }
16 | const valueIsPromise = isPromise(value);
17 | const valueIsFunction = isFunction(value);
18 |
19 | const root: ObservableRoot = {
20 | _: value,
21 | };
22 |
23 | let node: NodeInfo = {
24 | root,
25 | lazy: true,
26 | numListenersRecursive: 0,
27 | };
28 |
29 | if (valueIsFunction) {
30 | node = Object.assign(() => {}, node);
31 | node.lazyFn = value;
32 | }
33 |
34 | const prim = makePrimitive || isActualPrimitive(value);
35 |
36 | const obs = prim
37 | ? (new (createPrimitive as ClassConstructor)(node) as ObservablePrimitive)
38 | : (createObject(node) as Observable);
39 |
40 | if (valueIsPromise) {
41 | setNodeValue(node, undefined);
42 | extractPromise(node, value);
43 | }
44 |
45 | return obs as any;
46 | }
47 |
--------------------------------------------------------------------------------
/src/event.ts:
--------------------------------------------------------------------------------
1 | import { getNode, symbolGetNode } from './globals';
2 | import { observable } from './observable';
3 | import type { ObservableEvent } from './observableInterfaces';
4 |
5 | export function event(): ObservableEvent {
6 | // event simply wraps around a number observable
7 | // which increments its value to dispatch change events
8 | const obs = observable(0);
9 | const node = getNode(obs);
10 | node.isEvent = true;
11 |
12 | return {
13 | fire: function () {
14 | // Notify increments the value so that the observable changes
15 | obs.set((v) => v + 1);
16 | },
17 | on: function (cb: () => void) {
18 | return obs.onChange(cb);
19 | },
20 | get: function () {
21 | // Return the value so that when will be truthy
22 | return obs.get();
23 | },
24 | // @ts-expect-error eslint doesn't like adding symbols to the object but this does work
25 | [symbolGetNode]: node,
26 | };
27 | }
28 |
--------------------------------------------------------------------------------
/src/helpers/pageHash.ts:
--------------------------------------------------------------------------------
1 | import { observable, Observable } from '@legendapp/state';
2 |
3 | interface Options {
4 | setter: 'pushState' | 'replaceState' | 'hash';
5 | }
6 | let _options: Options = { setter: 'hash' };
7 |
8 | function configurePageHash(options: Options) {
9 | _options = options;
10 | }
11 |
12 | const hasWindow = typeof window !== 'undefined';
13 | const pageHash: Observable = observable(hasWindow ? window.location.hash.slice(1) : '');
14 |
15 | if (hasWindow) {
16 | let isSetting = false;
17 | // Set the page hash when the observable changes
18 | pageHash.onChange(({ value }) => {
19 | if (!isSetting) {
20 | const hash = '#' + value;
21 | const setter = _options?.setter || 'hash';
22 | if (setter === 'pushState') {
23 | history.pushState(null, null as any, hash);
24 | } else if (setter === 'replaceState') {
25 | history.replaceState(null, null as any, hash);
26 | } else {
27 | location.hash = hash;
28 | }
29 | }
30 | });
31 | // Update the observable whenever the hash changes
32 | const cb = () => {
33 | isSetting = true;
34 | pageHash.set(window.location.hash.slice(1));
35 | isSetting = false;
36 | };
37 | // Subscribe to window hashChange event
38 | window.addEventListener('hashchange', cb);
39 | }
40 |
41 | export { configurePageHash, pageHash };
42 |
--------------------------------------------------------------------------------
/src/helpers/pageHashParams.ts:
--------------------------------------------------------------------------------
1 | import { observable, Observable } from '@legendapp/state';
2 |
3 | interface Options {
4 | setter: 'pushState' | 'replaceState' | 'hash';
5 | }
6 | let _options: Options = { setter: 'hash' };
7 |
8 | function configurePageHashParams(options: Options) {
9 | _options = options;
10 | }
11 |
12 | function toParams(str: string) {
13 | const ret: Record = {};
14 | const searchParams = new URLSearchParams(str);
15 | for (const [key, value] of searchParams) {
16 | ret[key] = value;
17 | }
18 | return ret;
19 | }
20 | function toString(params: Record) {
21 | return new URLSearchParams(params).toString().replace(/=$/, '');
22 | }
23 |
24 | const hasWindow = typeof window !== 'undefined';
25 | const pageHashParams: Observable> = observable(
26 | hasWindow ? toParams(window.location.hash.slice(1)) : {},
27 | );
28 |
29 | if (hasWindow) {
30 | let isSetting = false;
31 | // Set the page hash when the observable changes
32 | pageHashParams.onChange(({ value }) => {
33 | if (!isSetting) {
34 | const hash = '#' + toString(value);
35 | const setter = _options?.setter || 'hash';
36 | if (setter === 'pushState') {
37 | history.pushState(null, null as any, hash);
38 | } else if (setter === 'replaceState') {
39 | history.replaceState(null, null as any, hash);
40 | } else {
41 | location.hash = hash;
42 | }
43 | }
44 | });
45 | // Update the observable whenever the hash changes
46 | const cb = () => {
47 | isSetting = true;
48 | pageHashParams.set(toParams(window.location.hash.slice(1)));
49 | isSetting = false;
50 | };
51 | // Subscribe to window hashChange event
52 | window.addEventListener('hashchange', cb);
53 | }
54 |
55 | export { configurePageHashParams, pageHashParams };
56 |
--------------------------------------------------------------------------------
/src/helpers/time.ts:
--------------------------------------------------------------------------------
1 | import { observable } from '@legendapp/state';
2 |
3 | const MSPerMinute = 60000;
4 |
5 | function clearTime(date: Date | number) {
6 | date = new Date(date);
7 | date.setHours(0, 0, 0, 0);
8 | return date;
9 | }
10 |
11 | let time = new Date();
12 | const currentTime = observable(time);
13 | const currentDay = observable(clearTime(time));
14 | const timeToSecond = (60 - time.getSeconds() + 1) * 1000;
15 | function update() {
16 | const now = new Date();
17 | currentTime.set(now);
18 |
19 | if (now.getDate() !== time.getDate()) {
20 | currentDay.set(clearTime(now));
21 | }
22 |
23 | time = now;
24 | }
25 | setTimeout(() => {
26 | update();
27 | setInterval(update, MSPerMinute);
28 | }, timeToSecond);
29 |
30 | export { currentTime, currentDay };
31 |
--------------------------------------------------------------------------------
/src/helpers/trackHistory.ts:
--------------------------------------------------------------------------------
1 | import { ObservableParam, constructObjectWithPath, mergeIntoObservable, observable } from '@legendapp/state';
2 |
3 | // This type is purely for documentation.
4 | type TimestampAsString = string;
5 |
6 | export function trackHistory(
7 | value$: ObservableParam,
8 | targetObservable?: ObservableParam>>,
9 | ): ObservableParam> {
10 | const history = targetObservable ?? observable>>();
11 |
12 | value$.onChange(({ isFromPersist, isFromSync, changes }) => {
13 | // Don't save history if this is a remote change.
14 | // History will be saved remotely by the client making the local change.
15 | if (!isFromPersist && !isFromSync) {
16 | const time: TimestampAsString = Date.now().toString();
17 |
18 | // Save to history observable by date, with the previous value
19 | for (let i = 0; i < changes.length; i++) {
20 | const { path, prevAtPath, pathTypes } = changes[i];
21 |
22 | const obj = constructObjectWithPath(path, pathTypes, prevAtPath);
23 | mergeIntoObservable((history as any)[time], obj);
24 | }
25 | }
26 | });
27 |
28 | return history as any;
29 | }
30 |
--------------------------------------------------------------------------------
/src/helpers/undoRedo.ts:
--------------------------------------------------------------------------------
1 | import { type ObservablePrimitive, internal, observable } from '@legendapp/state';
2 |
3 | type UndoRedoOptions = {
4 | limit?: number;
5 | };
6 |
7 | /**
8 | * Usage:
9 | *
10 | * Use this function to add undo/redo functionality to an observable.
11 | *
12 | * You can monitor how many undos or redos are available to enable/disable undo/redo
13 | * UI elements with undo$ and redo$.
14 | *
15 | * If you undo and then make a change, it'll delete any redos and add the change, as expected.
16 | *
17 | * If you don't pass in a limit, it will keep all history. This means it can grow indefinitely.
18 | *
19 | * ```typescript
20 | * const obs$ = observable({ test: 'hi', test2: 'a' });
21 | * const { undo, redo, undos$, redos$, getHistory } = undoRedo(obs$, { limit: 40 });
22 | * obs$.test.set('hello');
23 | * undo();
24 | * redo();
25 | * // observables for # of undos/redos available
26 | * undos$.get();
27 | * redos$.get();
28 | * ```
29 | */
30 | export function undoRedo(obs$: ObservablePrimitive, options?: UndoRedoOptions) {
31 | let history = [] as T[];
32 | let historyPointer = 0;
33 | let restoringFromHistory = false;
34 |
35 | const undos$ = observable(0);
36 | const redos$ = observable(0);
37 |
38 | function updateUndoRedo() {
39 | undos$.set(historyPointer);
40 | redos$.set(history.length - historyPointer - 1);
41 | }
42 |
43 | obs$.onChange(({ getPrevious }) => {
44 | // Don't save history if we're restoring from history.
45 | if (restoringFromHistory) return;
46 |
47 | // Don't save history if this is a remote change.
48 | // History will be saved remotely by the client making the local change.
49 | if (internal.globalState.isLoadingRemote || internal.globalState.isLoadingLocal) return;
50 |
51 | // if the history array is empty, grab the previous value as the initial value
52 | if (!history.length) {
53 | const previous = getPrevious();
54 | if (previous) history.push(internal.clone(previous));
55 | historyPointer = 0;
56 | }
57 |
58 | // We're just going to store a copy of the whole object every time it changes.
59 | const snapshot = internal.clone(obs$.get());
60 |
61 | if (options?.limit) {
62 | // limit means the number of undos
63 | history = history.slice(Math.max(0, history.length - options.limit));
64 | } else {
65 | history = history.slice(0, historyPointer + 1);
66 | }
67 |
68 | // we add another history item, which is limit + 1 -- but it's the current one
69 | history.push(snapshot);
70 |
71 | // We're going to keep a pointer to the current history state.
72 | // This way, we can undo to many previous states, and redo.
73 | historyPointer = history.length - 1;
74 |
75 | updateUndoRedo();
76 | });
77 |
78 | return {
79 | undo() {
80 | if (historyPointer > 0) {
81 | historyPointer--;
82 |
83 | const snapshot = internal.clone(history[historyPointer]);
84 | restoringFromHistory = true;
85 | obs$.set(snapshot);
86 | restoringFromHistory = false;
87 | } else {
88 | console.warn('Already at the beginning of undo history');
89 | }
90 |
91 | updateUndoRedo();
92 | },
93 | redo() {
94 | if (historyPointer < history.length - 1) {
95 | historyPointer++;
96 |
97 | const snapshot = internal.clone(history[historyPointer]);
98 | restoringFromHistory = true;
99 | obs$.set(snapshot);
100 | restoringFromHistory = false;
101 | } else {
102 | console.warn('Already at the end of undo history');
103 | }
104 |
105 | updateUndoRedo();
106 | },
107 | undos$: undos$,
108 | redos$: redos$,
109 | getHistory: () => history,
110 | };
111 | }
112 |
--------------------------------------------------------------------------------
/src/is.ts:
--------------------------------------------------------------------------------
1 | import type { ChildNodeInfo, NodeInfo } from './observableInterfaces';
2 |
3 | export const hasOwnProperty = Object.prototype.hasOwnProperty;
4 |
5 | export function isArray(obj: unknown): obj is Array {
6 | return Array.isArray(obj);
7 | }
8 | export function isString(obj: unknown): obj is string {
9 | return typeof obj === 'string';
10 | }
11 | export function isObject(obj: unknown): obj is Record {
12 | return !!obj && typeof obj === 'object' && !(obj instanceof Date) && !isArray(obj);
13 | }
14 | export function isPlainObject(obj: unknown): obj is Record {
15 | return isObject(obj) && obj.constructor === Object;
16 | }
17 | export function isFunction(obj: unknown): obj is Function {
18 | return typeof obj === 'function';
19 | }
20 | export function isPrimitive(arg: unknown): arg is string | number | bigint | boolean | symbol {
21 | const type = typeof arg;
22 | return arg !== undefined && (isDate(arg) || (type !== 'object' && type !== 'function'));
23 | }
24 | export function isDate(obj: unknown): obj is Date {
25 | return obj instanceof Date;
26 | }
27 | export function isSymbol(obj: unknown): obj is symbol {
28 | return typeof obj === 'symbol';
29 | }
30 | export function isBoolean(obj: unknown): obj is boolean {
31 | return typeof obj === 'boolean';
32 | }
33 | export function isPromise(obj: unknown): obj is Promise {
34 | return obj instanceof Promise;
35 | }
36 | export function isMap(obj: unknown): obj is Map {
37 | return obj instanceof Map || obj instanceof WeakMap;
38 | }
39 | export function isSet(obj: unknown): obj is Set {
40 | return obj instanceof Set || obj instanceof WeakSet;
41 | }
42 | export function isNumber(obj: unknown): obj is number {
43 | const n = obj as number;
44 | return typeof n === 'number' && n - n < 1;
45 | }
46 | export function isEmpty(obj: object): boolean {
47 | // Looping and returning false on the first property is faster than Object.keys(obj).length === 0
48 | // https://jsbench.me/qfkqv692c8
49 | if (!obj) return false;
50 | if (isArray(obj)) return obj.length === 0;
51 | if (isMap(obj) || isSet(obj)) return obj.size === 0;
52 | for (const key in obj) {
53 | if (hasOwnProperty.call(obj, key)) {
54 | return false;
55 | }
56 | }
57 | return true;
58 | }
59 | export function isNullOrUndefined(value: any): value is undefined | null {
60 | return value === undefined || value === null;
61 | }
62 | const setPrimitives = new Set(['boolean', 'string', 'number']);
63 | /** @internal */
64 | export function isActualPrimitive(arg: unknown): arg is boolean | string | number {
65 | return setPrimitives.has(typeof arg);
66 | }
67 | /** @internal */
68 | export function isChildNode(node: NodeInfo): node is ChildNodeInfo {
69 | return !!node.parent;
70 | }
71 |
--------------------------------------------------------------------------------
/src/linked.ts:
--------------------------------------------------------------------------------
1 | import { symbolLinked } from './globals';
2 | import { isFunction } from './is';
3 | import type { Linked, LinkedOptions } from './observableInterfaces';
4 |
5 | export function linked(params: LinkedOptions | (() => T), options?: LinkedOptions): Linked {
6 | if (isFunction(params)) {
7 | params = { get: params };
8 | }
9 | if (options) {
10 | params = { ...params, ...options };
11 | }
12 | const ret = function () {
13 | return { [symbolLinked]: params };
14 | };
15 | ret.prototype[symbolLinked] = params;
16 | return ret as Linked;
17 | }
18 |
--------------------------------------------------------------------------------
/src/observable.ts:
--------------------------------------------------------------------------------
1 | import { extractPromise, getProxy } from './ObservableObject';
2 | import { ObservablePrimitiveClass } from './ObservablePrimitive';
3 | import { createObservable } from './createObservable';
4 | import type { Observable, ObservablePrimitive, RecursiveValueOrFunction } from './observableTypes';
5 |
6 | export function observable(): Observable;
7 | export function observable(
8 | value: Promise> | (() => RecursiveValueOrFunction) | RecursiveValueOrFunction,
9 | ): Observable;
10 | export function observable(value: T): Observable;
11 | export function observable(value?: T): Observable {
12 | return createObservable(value, false, extractPromise, getProxy, ObservablePrimitiveClass) as any;
13 | }
14 |
15 | export function observablePrimitive(value: Promise): ObservablePrimitive;
16 | export function observablePrimitive(value?: T): ObservablePrimitive;
17 | export function observablePrimitive(value?: T | Promise): ObservablePrimitive {
18 | return createObservable(value, true, extractPromise, getProxy, ObservablePrimitiveClass) as any;
19 | }
20 |
--------------------------------------------------------------------------------
/src/observe.ts:
--------------------------------------------------------------------------------
1 | import { beginBatch, endBatch } from './batching';
2 | import { isEvent } from './globals';
3 | import { isFunction } from './is';
4 | import type { ObserveEvent, ObserveEventCallback, ObserveOptions, Selector } from './observableInterfaces';
5 | import { trackSelector } from './trackSelector';
6 |
7 | export function observe(run: (e: ObserveEvent) => T | void, options?: ObserveOptions): () => void;
8 | export function observe(
9 | selector: Selector | ((e: ObserveEvent) => any),
10 | reaction?: (e: ObserveEventCallback) => any,
11 | options?: ObserveOptions,
12 | ): () => void;
13 | export function observe(
14 | selectorOrRun: Selector | ((e: ObserveEvent) => any),
15 | reactionOrOptions?: ((e: ObserveEventCallback) => any) | ObserveOptions,
16 | options?: ObserveOptions,
17 | ) {
18 | let reaction: (e: ObserveEventCallback) => any;
19 | if (isFunction(reactionOrOptions)) {
20 | reaction = reactionOrOptions;
21 | } else {
22 | options = reactionOrOptions;
23 | }
24 | let dispose: (() => void) | undefined;
25 | let isRunning = false;
26 | const e: ObserveEventCallback = { num: 0 } as ObserveEventCallback;
27 | // Wrap it in a function so it doesn't pass all the arguments to run()
28 | const update = function () {
29 | if (isRunning) {
30 | // Prevent observe from triggering itself when it activates a node
31 | return;
32 | }
33 |
34 | if (e.onCleanup) {
35 | e.onCleanup();
36 | e.onCleanup = undefined;
37 | }
38 |
39 | isRunning = true;
40 |
41 | // Run in a batch so changes don't happen until we're done tracking here
42 | beginBatch();
43 |
44 | // Run the function/selector
45 | delete e.value;
46 |
47 | // Dispose listeners from previous run
48 | dispose?.();
49 |
50 | const {
51 | dispose: _dispose,
52 | value,
53 | nodes,
54 | } = trackSelector(selectorOrRun as Selector, update, undefined, e, options);
55 | dispose = _dispose;
56 |
57 | e.value = value;
58 | e.nodes = nodes;
59 | e.refresh = update;
60 |
61 | if (e.onCleanupReaction) {
62 | e.onCleanupReaction();
63 | e.onCleanupReaction = undefined;
64 | }
65 |
66 | endBatch();
67 |
68 | isRunning = false;
69 |
70 | // Call the reaction if there is one and the value changed
71 | if (
72 | reaction &&
73 | (options?.fromComputed ||
74 | ((e.num > 0 || !isEvent(selectorOrRun as any)) &&
75 | (e.previous !== e.value || typeof e.value === 'object')))
76 | ) {
77 | reaction(e);
78 | }
79 |
80 | // Update the previous value
81 | e.previous = e.value;
82 |
83 | // Increment the counter
84 | e.num++;
85 | };
86 |
87 | update();
88 |
89 | // Return function calling dispose because dispose may be changed in update()
90 | return () => {
91 | e.onCleanup?.();
92 | e.onCleanup = undefined;
93 | e.onCleanupReaction?.();
94 | e.onCleanupReaction = undefined;
95 | dispose?.();
96 | };
97 | }
98 |
--------------------------------------------------------------------------------
/src/onChange.ts:
--------------------------------------------------------------------------------
1 | import { getNodeValue } from './globals';
2 | import { deconstructObjectWithPath } from './helpers';
3 | import { dispatchMiddlewareEvent } from './middleware';
4 | import type { ListenerFn, ListenerParams, NodeInfo, NodeListener, TrackingType } from './observableInterfaces';
5 |
6 | export function onChange(
7 | node: NodeInfo,
8 | callback: ListenerFn,
9 | options: { trackingType?: TrackingType; initial?: boolean; immediate?: boolean; noArgs?: boolean } = {},
10 | fromLinks?: Set,
11 | ): () => void {
12 | const { initial, immediate, noArgs } = options;
13 | const { trackingType } = options;
14 |
15 | let listeners = immediate ? node.listenersImmediate : node.listeners;
16 | if (!listeners) {
17 | listeners = new Set();
18 | if (immediate) {
19 | node.listenersImmediate = listeners;
20 | } else {
21 | node.listeners = listeners;
22 | }
23 | }
24 |
25 | const listener: NodeListener = {
26 | listener: callback,
27 | track: trackingType,
28 | noArgs,
29 | };
30 |
31 | listeners.add(listener);
32 |
33 | if (initial) {
34 | const value = getNodeValue(node);
35 | callback({
36 | value,
37 | isFromPersist: true,
38 | isFromSync: false,
39 | changes: [
40 | {
41 | path: [],
42 | pathTypes: [],
43 | prevAtPath: value,
44 | valueAtPath: value,
45 | },
46 | ],
47 | getPrevious: () => undefined,
48 | });
49 | }
50 |
51 | let extraDisposes: (() => void)[];
52 |
53 | function addLinkedNodeListeners(childNode: NodeInfo, cb: ListenerFn = callback, from?: NodeInfo) {
54 | // Don't add listeners for the same node more than once
55 | if (!fromLinks?.has(childNode)) {
56 | fromLinks ||= new Set();
57 | fromLinks.add(from || node);
58 | cb ||= callback;
59 | const childOptions: Parameters[2] = {
60 | trackingType: true,
61 | ...options,
62 | };
63 | // onChange for the linked node
64 | extraDisposes = [...(extraDisposes || []), onChange(childNode, cb as ListenerFn, childOptions, fromLinks)];
65 | }
66 | }
67 |
68 | // Add listeners for linked to nodes
69 | if (node.linkedToNode) {
70 | addLinkedNodeListeners(node.linkedToNode);
71 | }
72 |
73 | // Add listeners for linked from nodes
74 | node.linkedFromNodes?.forEach((linkedFromNode) => addLinkedNodeListeners(linkedFromNode));
75 |
76 | // Go up through the parents and add listeners for linked from nodes
77 | node.numListenersRecursive++;
78 | let parent = node.parent;
79 | let pathParent: string[] = [node!.key!];
80 |
81 | while (parent) {
82 | if (parent.linkedFromNodes) {
83 | for (const linkedFromNode of parent.linkedFromNodes) {
84 | if (!fromLinks?.has(linkedFromNode)) {
85 | const cb = createCb(linkedFromNode, pathParent, callback);
86 | addLinkedNodeListeners(linkedFromNode, cb, parent);
87 | }
88 | }
89 | }
90 | parent.numListenersRecursive++;
91 |
92 | pathParent = [parent!.key!, ...pathParent];
93 | parent = parent.parent;
94 | }
95 |
96 | // Queue middleware event for listener added
97 | dispatchMiddlewareEvent(node, listener, 'listener-added');
98 |
99 | return () => {
100 | // Remove the listener from the set
101 | listeners.delete(listener);
102 |
103 | // Clean up linked node listeners
104 | extraDisposes?.forEach((fn) => fn());
105 |
106 | // Update listener counts up the tree
107 | let parent = node;
108 | while (parent) {
109 | parent.numListenersRecursive--;
110 | parent = parent.parent!;
111 | }
112 |
113 | // Queue middleware event for listener removed
114 | dispatchMiddlewareEvent(node, listener, 'listener-removed');
115 |
116 | // If there are no more listeners in this set, queue the listeners-cleared event
117 | if (listeners.size === 0) {
118 | dispatchMiddlewareEvent(node, undefined, 'listeners-cleared');
119 | }
120 | };
121 | }
122 |
123 | function createCb(linkedFromNode: NodeInfo, path: string[], callback: ListenerFn) {
124 | // Create a callback for a path that calls it with the current value at the path
125 | let prevAtPath = deconstructObjectWithPath(path, [], getNodeValue(linkedFromNode));
126 |
127 | return function ({ value: valueA, isFromPersist, isFromSync }: ListenerParams) {
128 | const valueAtPath = deconstructObjectWithPath(path, [], valueA);
129 | if (valueAtPath !== prevAtPath) {
130 | callback({
131 | value: valueAtPath,
132 | isFromPersist,
133 | isFromSync,
134 | changes: [
135 | {
136 | path: [],
137 | pathTypes: [],
138 | prevAtPath,
139 | valueAtPath,
140 | },
141 | ],
142 | getPrevious: () => prevAtPath,
143 | });
144 | }
145 | prevAtPath = valueAtPath;
146 | };
147 | }
148 |
--------------------------------------------------------------------------------
/src/persist-plugins/async-storage.ts:
--------------------------------------------------------------------------------
1 | import type { Change } from '@legendapp/state';
2 | import { applyChanges, internal, isArray } from '@legendapp/state';
3 | import type {
4 | ObservablePersistAsyncStoragePluginOptions,
5 | ObservablePersistPlugin,
6 | ObservablePersistPluginOptions,
7 | PersistMetadata,
8 | } from '@legendapp/state/sync';
9 | import type { AsyncStorageStatic } from '@react-native-async-storage/async-storage';
10 |
11 | const MetadataSuffix = '__m';
12 |
13 | let AsyncStorage: AsyncStorageStatic;
14 |
15 | const { safeParse, safeStringify } = internal;
16 |
17 | export class ObservablePersistAsyncStorage implements ObservablePersistPlugin {
18 | private data: Record = {};
19 | private configuration: ObservablePersistAsyncStoragePluginOptions;
20 |
21 | constructor(configuration: ObservablePersistAsyncStoragePluginOptions) {
22 | this.configuration = configuration;
23 | }
24 | public async initialize(configOptions: ObservablePersistPluginOptions) {
25 | const storageConfig = this.configuration || configOptions.asyncStorage;
26 |
27 | let tables: readonly string[] = [];
28 | if (storageConfig) {
29 | AsyncStorage = storageConfig.AsyncStorage;
30 | const { preload } = storageConfig;
31 | try {
32 | if (preload === true) {
33 | // If preloadAllKeys, load all keys and preload tables on startup
34 | tables = await AsyncStorage.getAllKeys();
35 | } else if (isArray(preload)) {
36 | // If preloadKeys, preload load the tables on startup
37 | const metadataTables = preload.map((table) =>
38 | table.endsWith(MetadataSuffix) ? undefined : table + MetadataSuffix,
39 | );
40 | tables = [...preload, ...(metadataTables.filter(Boolean) as string[])];
41 | }
42 | if (tables) {
43 | const values = await AsyncStorage.multiGet(tables);
44 |
45 | values.forEach(([table, value]) => {
46 | this.data[table] = value ? safeParse(value) : undefined;
47 | });
48 | }
49 | } catch (e) {
50 | console.error('[legend-state] ObservablePersistAsyncStorage failed to initialize', e);
51 | }
52 | } else {
53 | console.error('[legend-state] Missing asyncStorage configuration');
54 | }
55 | }
56 | public loadTable(table: string): void | Promise {
57 | if (this.data[table] === undefined) {
58 | return AsyncStorage.multiGet([table, table + MetadataSuffix])
59 | .then((values) => {
60 | try {
61 | values.forEach(([table, value]) => {
62 | this.data[table] = value ? safeParse(value) : undefined;
63 | });
64 | } catch (err) {
65 | console.error('[legend-state] ObservablePersistLocalAsyncStorage failed to parse', table, err);
66 | }
67 | })
68 | .catch((err: Error) => {
69 | if (err?.message !== 'window is not defined') {
70 | console.error('[legend-state] AsyncStorage.multiGet failed', table, err);
71 | }
72 | });
73 | }
74 | }
75 | // Gets
76 | public getTable(table: string, init: object) {
77 | return this.data[table] ?? init ?? {};
78 | }
79 | public getMetadata(table: string): PersistMetadata {
80 | return this.getTable(table + MetadataSuffix, {});
81 | }
82 | // Sets
83 | public set(table: string, changes: Change[]): Promise {
84 | if (!this.data[table]) {
85 | this.data[table] = {};
86 | }
87 |
88 | this.data[table] = applyChanges(this.data[table], changes);
89 | return this.save(table);
90 | }
91 | public setMetadata(table: string, metadata: PersistMetadata) {
92 | return this.setValue(table + MetadataSuffix, metadata);
93 | }
94 | public async deleteTable(table: string) {
95 | return AsyncStorage.removeItem(table);
96 | }
97 | public deleteMetadata(table: string) {
98 | return this.deleteTable(table + MetadataSuffix);
99 | }
100 | // Private
101 | private async setValue(table: string, value: any) {
102 | this.data[table] = value;
103 | await this.save(table);
104 | }
105 | private async save(table: string) {
106 | const v = this.data[table];
107 |
108 | if (v !== undefined && v !== null) {
109 | return AsyncStorage.setItem(table, safeStringify(v));
110 | } else {
111 | return AsyncStorage.removeItem(table);
112 | }
113 | }
114 | }
115 |
116 | export function observablePersistAsyncStorage(configuration: ObservablePersistAsyncStoragePluginOptions) {
117 | return new ObservablePersistAsyncStorage(configuration);
118 | }
119 |
--------------------------------------------------------------------------------
/src/persist-plugins/expo-sqlite.ts:
--------------------------------------------------------------------------------
1 | import type { Change } from '@legendapp/state';
2 | import { applyChanges, internal } from '@legendapp/state';
3 | import type { ObservablePersistPlugin, PersistMetadata } from '@legendapp/state/sync';
4 | import type { SQLiteStorage } from 'expo-sqlite/kv-store';
5 |
6 | const { safeParse, safeStringify } = internal;
7 |
8 | const MetadataSuffix = '__m';
9 |
10 | export class ObservablePersistSqlite implements ObservablePersistPlugin {
11 | private data: Record = {};
12 | private storage: SQLiteStorage;
13 | constructor(storage: SQLiteStorage) {
14 | if (!storage) {
15 | console.error(
16 | '[legend-state] ObservablePersistSqlite failed to initialize. You need to pass the SQLiteStorage instance.',
17 | );
18 | }
19 | this.storage = storage;
20 | }
21 | public getTable(table: string, init: any) {
22 | if (!this.storage) return undefined;
23 | if (this.data[table] === undefined) {
24 | try {
25 | const value = this.storage.getItemSync(table);
26 | this.data[table] = value ? safeParse(value) : init;
27 | } catch {
28 | console.error('[legend-state] ObservablePersistSqlite failed to parse', table);
29 | }
30 | }
31 | return this.data[table];
32 | }
33 | public getMetadata(table: string): PersistMetadata {
34 | return this.getTable(table + MetadataSuffix, {});
35 | }
36 | public set(table: string, changes: Change[]): void {
37 | if (!this.data[table]) {
38 | this.data[table] = {};
39 | }
40 | this.data[table] = applyChanges(this.data[table], changes);
41 | this.save(table);
42 | }
43 | public setMetadata(table: string, metadata: PersistMetadata) {
44 | table = table + MetadataSuffix;
45 | this.data[table] = metadata;
46 | this.save(table);
47 | }
48 | public deleteTable(table: string) {
49 | if (!this.storage) return undefined;
50 | delete this.data[table];
51 | this.storage.removeItemSync(table);
52 | }
53 | public deleteMetadata(table: string) {
54 | this.deleteTable(table + MetadataSuffix);
55 | }
56 | // Private
57 | private save(table: string) {
58 | if (!this.storage) return undefined;
59 |
60 | const v = this.data[table];
61 |
62 | if (v !== undefined && v !== null) {
63 | this.storage.setItemSync(table, safeStringify(v));
64 | } else {
65 | this.storage.removeItemSync(table);
66 | }
67 | }
68 | }
69 |
70 | export function observablePersistSqlite(storage: SQLiteStorage) {
71 | return new ObservablePersistSqlite(storage);
72 | }
73 |
--------------------------------------------------------------------------------
/src/persist-plugins/local-storage.ts:
--------------------------------------------------------------------------------
1 | import type { Change } from '@legendapp/state';
2 | import { applyChanges, internal } from '@legendapp/state';
3 | import type { ObservablePersistPlugin, PersistMetadata } from '@legendapp/state/sync';
4 |
5 | const { safeParse, safeStringify } = internal;
6 |
7 | const MetadataSuffix = '__m';
8 |
9 | export class ObservablePersistLocalStorageBase implements ObservablePersistPlugin {
10 | private data: Record = {};
11 | private storage: Storage | undefined;
12 | constructor(storage: Storage | undefined) {
13 | this.storage = storage;
14 | }
15 | public getTable(table: string, init: any) {
16 | if (!this.storage) return undefined;
17 | if (this.data[table] === undefined) {
18 | try {
19 | const value = this.storage.getItem(table);
20 | this.data[table] = value ? safeParse(value) : init;
21 | } catch {
22 | console.error('[legend-state] ObservablePersistLocalStorageBase failed to parse', table);
23 | }
24 | }
25 | return this.data[table];
26 | }
27 | public getMetadata(table: string): PersistMetadata {
28 | return this.getTable(table + MetadataSuffix, {});
29 | }
30 | public set(table: string, changes: Change[]): void {
31 | if (!this.data[table]) {
32 | this.data[table] = {};
33 | }
34 | this.data[table] = applyChanges(this.data[table], changes);
35 | this.save(table);
36 | }
37 | public setMetadata(table: string, metadata: PersistMetadata) {
38 | table = table + MetadataSuffix;
39 | this.data[table] = metadata;
40 | this.save(table);
41 | }
42 | public deleteTable(table: string) {
43 | if (!this.storage) return undefined;
44 | delete this.data[table];
45 | this.storage.removeItem(table);
46 | }
47 | public deleteMetadata(table: string) {
48 | this.deleteTable(table + MetadataSuffix);
49 | }
50 | // Private
51 | private save(table: string) {
52 | if (!this.storage) return undefined;
53 |
54 | const v = this.data[table];
55 |
56 | if (v !== undefined && v !== null) {
57 | this.storage.setItem(table, safeStringify(v));
58 | } else {
59 | this.storage.removeItem(table);
60 | }
61 | }
62 | }
63 | export class ObservablePersistLocalStorage extends ObservablePersistLocalStorageBase {
64 | constructor() {
65 | super(
66 | typeof localStorage !== 'undefined'
67 | ? localStorage
68 | : process.env.NODE_ENV === 'test'
69 | ? // @ts-expect-error This is ok to do in jest
70 | globalThis._testlocalStorage
71 | : undefined,
72 | );
73 | }
74 | }
75 | export class ObservablePersistSessionStorage extends ObservablePersistLocalStorageBase {
76 | constructor() {
77 | super(
78 | typeof sessionStorage !== 'undefined'
79 | ? sessionStorage
80 | : process.env.NODE_ENV === 'test'
81 | ? // @ts-expect-error This is ok to do in jest
82 | globalThis._testlocalStorage
83 | : undefined,
84 | );
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/persist-plugins/mmkv.ts:
--------------------------------------------------------------------------------
1 | import type { Change } from '@legendapp/state';
2 | import { internal, setAtPath } from '@legendapp/state';
3 | import type { ObservablePersistPlugin, PersistMetadata, PersistOptions } from '@legendapp/state/sync';
4 | import { MMKV, MMKVConfiguration } from 'react-native-mmkv';
5 |
6 | const symbolDefault = Symbol();
7 | const MetadataSuffix = '__m';
8 |
9 | const { safeParse, safeStringify } = internal;
10 |
11 | export class ObservablePersistMMKV implements ObservablePersistPlugin {
12 | private data: Record = {};
13 | private storages = new Map([
14 | [
15 | symbolDefault,
16 | new MMKV({
17 | id: `obsPersist`,
18 | }),
19 | ],
20 | ]);
21 | private configuration: MMKVConfiguration;
22 |
23 | constructor(configuration: MMKVConfiguration) {
24 | this.configuration = configuration;
25 | }
26 | // Gets
27 | public getTable(table: string, init: object, config: PersistOptions): T {
28 | const storage = this.getStorage(config);
29 | if (this.data[table] === undefined) {
30 | try {
31 | const value = storage.getString(table);
32 | this.data[table] = value ? safeParse(value) : init;
33 | } catch {
34 | console.error('[legend-state] MMKV failed to parse', table);
35 | }
36 | }
37 | return this.data[table];
38 | }
39 | public getMetadata(table: string, config: PersistOptions): PersistMetadata {
40 | return this.getTable(table + MetadataSuffix, {}, config);
41 | }
42 | // Sets
43 | public set(table: string, changes: Change[], config: PersistOptions) {
44 | if (!this.data[table]) {
45 | this.data[table] = {};
46 | }
47 | for (let i = 0; i < changes.length; i++) {
48 | const { path, valueAtPath, pathTypes } = changes[i];
49 | this.data[table] = setAtPath(this.data[table], path as string[], pathTypes, valueAtPath);
50 | }
51 | this.save(table, config);
52 | }
53 | public setMetadata(table: string, metadata: PersistMetadata, config: PersistOptions) {
54 | return this.setValue(table + MetadataSuffix, metadata, config);
55 | }
56 | public deleteTable(table: string, config: PersistOptions): void {
57 | const storage = this.getStorage(config);
58 | delete this.data[table];
59 | storage.delete(table);
60 | }
61 | public deleteMetadata(table: string, config: PersistOptions) {
62 | this.deleteTable(table + MetadataSuffix, config);
63 | }
64 | // Private
65 | private getStorage(config: PersistOptions): MMKV {
66 | const configuration = config.mmkv || this.configuration;
67 | if (configuration) {
68 | const key = JSON.stringify(configuration);
69 | let storage = this.storages.get(key);
70 | if (!storage) {
71 | storage = new MMKV(configuration);
72 | this.storages.set(key, storage);
73 | }
74 | return storage;
75 | } else {
76 | return this.storages.get(symbolDefault)!;
77 | }
78 | }
79 | private async setValue(table: string, value: any, config: PersistOptions) {
80 | this.data[table] = value;
81 | this.save(table, config);
82 | }
83 | private save(table: string, config: PersistOptions) {
84 | const storage = this.getStorage(config);
85 | const v = this.data[table];
86 | if (v !== undefined) {
87 | try {
88 | storage.set(table, safeStringify(v));
89 | } catch (err) {
90 | console.error(err);
91 | }
92 | } else {
93 | storage.delete(table);
94 | }
95 | }
96 | }
97 |
98 | export function observablePersistMMKV(configuration: MMKVConfiguration) {
99 | return new ObservablePersistMMKV(configuration);
100 | }
101 |
--------------------------------------------------------------------------------
/src/proxy.ts:
--------------------------------------------------------------------------------
1 | import { linked } from './linked';
2 | import { observable } from './observable';
3 | import { Observable, ObservableParam } from './observableTypes';
4 |
5 | // Deprecated. Remove in v4
6 |
7 | export function proxy(
8 | get: (key: string) => T,
9 | set: (key: string, value: T2) => void,
10 | ): Observable>;
11 | export function proxy>(
12 | get: (key: K) => ObservableParam,
13 | ): Observable;
14 | export function proxy(get: (key: string) => ObservableParam): Observable>;
15 | export function proxy(get: (key: string) => T): Observable>;
16 | export function proxy, T2 = T>(
17 | get: (key: any) => ObservableParam,
18 | set?: (key: any, value: T2) => void,
19 | ): any {
20 | return observable((key: string) =>
21 | set
22 | ? linked({
23 | get: () => get(key),
24 | set: ({ value }) => set(key, value as any),
25 | })
26 | : get(key),
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/react-hooks/createObservableHook.ts:
--------------------------------------------------------------------------------
1 | import { isFunction, Observable, observable } from '@legendapp/state';
2 | import React, { MutableRefObject, Reducer, ReducerState } from 'react';
3 |
4 | function overrideHooks(refObs: MutableRefObject | undefined>) {
5 | // @ts-expect-error Types don't match React's expected types
6 | React.useState = function useState(initialState: TRet | (() => TRet)) {
7 | const obs =
8 | refObs.current ??
9 | (refObs.current = observable((isFunction(initialState) ? initialState() : initialState) as any) as any);
10 | return [obs.get() as TRet, obs.set] as [TRet, React.Dispatch>];
11 | };
12 | // @ts-expect-error Types don't match React's expected types
13 | React.useReducer = function useReducer>(
14 | reducer: R,
15 | initializerArg: ReducerState,
16 | initializer: (arg: ReducerState) => ReducerState,
17 | ) {
18 | const obs =
19 | refObs.current ??
20 | (refObs.current = observable(
21 | initializerArg !== undefined && isFunction(initializerArg)
22 | ? initializer(initializerArg)
23 | : initializerArg,
24 | ) as any);
25 | const dispatch = (action: any) => {
26 | obs.set(reducer(obs.get(), action));
27 | };
28 | return [obs, dispatch];
29 | };
30 | }
31 |
32 | export function createObservableHook(
33 | fn: (...args: TArgs) => TRet,
34 | ): (...args: TArgs) => Observable {
35 | const _useState = React.useState;
36 | const _useReducer = React.useReducer;
37 |
38 | return function (...args: TArgs) {
39 | const refObs = React.useRef>();
40 |
41 | // First override the built-in hooks to create/update observables
42 | overrideHooks(refObs);
43 |
44 | // Then call the original hook
45 | fn(...args);
46 |
47 | // And reset back to the built-in hooks
48 | React.useState = _useState;
49 | React.useReducer = _useReducer;
50 |
51 | return refObs.current as Observable;
52 | };
53 | }
54 |
--------------------------------------------------------------------------------
/src/react-hooks/useHover.ts:
--------------------------------------------------------------------------------
1 | import type { Observable } from '@legendapp/state';
2 | import { useObservable } from '@legendapp/state/react';
3 | import { useEffect } from 'react';
4 |
5 | export function useHover(ref: React.MutableRefObject): Observable {
6 | const obs = useObservable(false);
7 |
8 | useEffect(() => {
9 | const handleMouseOver = () => obs.set(true);
10 | const handleMouseOut = (e: MouseEvent) => {
11 | if (obs.peek() === true) {
12 | let parent = (e as any).toElement as HTMLElement | null;
13 | let foundRef = false;
14 | while (parent && !foundRef) {
15 | if (parent === ref.current) {
16 | foundRef = true;
17 | }
18 | parent = parent.parentElement;
19 | }
20 |
21 | if (!foundRef) {
22 | obs.set(false);
23 | }
24 | }
25 | };
26 |
27 | const node = ref.current;
28 | if (node) {
29 | node.addEventListener('mouseover', handleMouseOver);
30 | node.addEventListener('mouseout', handleMouseOut);
31 |
32 | return () => {
33 | node.removeEventListener('mouseover', handleMouseOver);
34 | node.removeEventListener('mouseout', handleMouseOut);
35 | };
36 | }
37 | }, [ref.current]);
38 |
39 | return obs;
40 | }
41 |
--------------------------------------------------------------------------------
/src/react-hooks/useMeasure.ts:
--------------------------------------------------------------------------------
1 | import type { ObservableObject } from '@legendapp/state';
2 | import { useObservable } from '@legendapp/state/react';
3 | import { RefObject, useLayoutEffect } from 'react';
4 |
5 | function getSize(el: HTMLElement): { width: number; height: number } | undefined {
6 | return el
7 | ? {
8 | width: el.offsetWidth,
9 | height: el.offsetHeight,
10 | }
11 | : undefined;
12 | }
13 |
14 | export function useMeasure(ref: RefObject): ObservableObject<{
15 | width: number | undefined;
16 | height: number | undefined;
17 | }> {
18 | const obs = useObservable<{ width: number | undefined; height: number | undefined }>({
19 | width: undefined,
20 | height: undefined,
21 | });
22 |
23 | useLayoutEffect(() => {
24 | const el = ref.current;
25 | if (el) {
26 | const handleResize = () => {
27 | if (ref.current) {
28 | const oldSize = obs.peek();
29 | const newSize = getSize(ref.current);
30 | if (newSize && (newSize.width !== oldSize.width || newSize.height !== oldSize.height)) {
31 | obs.set(newSize);
32 | }
33 | }
34 | };
35 | handleResize();
36 |
37 | let resizeObserver = new ResizeObserver(handleResize);
38 | resizeObserver.observe(el);
39 |
40 | return () => {
41 | resizeObserver.disconnect();
42 | (resizeObserver as any) = undefined;
43 | };
44 | }
45 | }, [ref.current]);
46 |
47 | return obs;
48 | }
49 |
--------------------------------------------------------------------------------
/src/react-hooks/useObservableNextRouter.ts:
--------------------------------------------------------------------------------
1 | import { isEmpty, observable, Observable, setSilently } from '@legendapp/state';
2 | import Router, { NextRouter, useRouter } from 'next/router';
3 |
4 | type ParsedUrlQuery = { [key: string]: string | string[] | undefined };
5 |
6 | interface TransitionOptions {
7 | shallow?: boolean;
8 | locale?: string | false;
9 | scroll?: boolean;
10 | unstable_skipClientCache?: boolean;
11 | }
12 | export interface ObservableNextRouterState {
13 | pathname: string;
14 | hash: string;
15 | query: ParsedUrlQuery;
16 | }
17 | type RouteInfo = Partial;
18 | export interface ParamsUseObservableNextRouterBase {
19 | transitionOptions?: TransitionOptions;
20 | method?: 'push' | 'replace';
21 | subscribe?: boolean;
22 | }
23 | export interface ParamsUseObservableNextRouter extends ParamsUseObservableNextRouterBase {
24 | compute: (value: ObservableNextRouterState) => T;
25 | set: (
26 | value: T,
27 | previous: T,
28 | router: NextRouter,
29 | ) => RouteInfo & {
30 | transitionOptions?: TransitionOptions;
31 | method?: 'push' | 'replace';
32 | };
33 | }
34 |
35 | function isShallowEqual(query1: ParsedUrlQuery, query2: ParsedUrlQuery) {
36 | if (!query1 !== !query2) {
37 | return false;
38 | }
39 | const keys1 = Object.keys(query1);
40 | const keys2 = Object.keys(query2);
41 |
42 | if (keys1.length !== keys2.length) {
43 | return false;
44 | }
45 |
46 | for (const key of keys1) {
47 | if (query1[key] !== query2[key]) {
48 | return false;
49 | }
50 | }
51 |
52 | return true;
53 | }
54 |
55 | const routes$ = observable({});
56 | let routeParams = {} as ParamsUseObservableNextRouter;
57 | let router: NextRouter;
58 |
59 | routes$.onChange(({ value, getPrevious }) => {
60 | // Only run this if being manually changed by the user
61 | let setter = routeParams?.set;
62 | if (!setter) {
63 | if ((value as any).pathname) {
64 | setter = () => value;
65 | } else {
66 | console.error('[legend-state]: Must provide a set method to useObservableNextRouter');
67 | }
68 | }
69 | const setReturn = setter(value, getPrevious(), router);
70 | const { pathname, hash, query } = setReturn;
71 | let { transitionOptions, method } = setReturn;
72 |
73 | method = method || routeParams?.method;
74 | transitionOptions = transitionOptions || routeParams?.transitionOptions;
75 |
76 | const prevHash = router.asPath.split('#')[1] || '';
77 |
78 | const change: RouteInfo = {};
79 | // Only include changes that were meant to be changed. For example the user may have
80 | // only changed the hash so we don't need to push a pathname change.
81 | if (pathname !== undefined && pathname !== router.pathname) {
82 | change.pathname = pathname;
83 | }
84 | if (hash !== undefined && hash !== prevHash) {
85 | change.hash = hash;
86 | }
87 | if (query !== undefined && !isShallowEqual(query, router.query)) {
88 | change.query = query;
89 | }
90 | // Only push if there are changes
91 | if (!isEmpty(change)) {
92 | const fn = method === 'replace' ? 'replace' : 'push';
93 | router[fn](change, undefined, transitionOptions).catch((e) => {
94 | // workaround for https://github.com/vercel/next.js/issues/37362
95 | if (!e.cancelled) throw e;
96 | });
97 | }
98 | });
99 |
100 | export function useObservableNextRouter(): Observable;
101 | export function useObservableNextRouter(params: ParamsUseObservableNextRouter): Observable;
102 | export function useObservableNextRouter(
103 | params: ParamsUseObservableNextRouterBase,
104 | ): Observable;
105 | export function useObservableNextRouter(
106 | params?: ParamsUseObservableNextRouter | ParamsUseObservableNextRouterBase,
107 | ): Observable | Observable {
108 | const { subscribe, compute } = (params as ParamsUseObservableNextRouter) || {};
109 |
110 | try {
111 | // Use the useRouter hook if we're on the client side and want to subscribe to changes.
112 | // Otherwise use the Router object so that this does not subscribe to router changes.
113 | router = typeof window !== 'undefined' && !subscribe ? Router : useRouter();
114 | } finally {
115 | router = router || useRouter();
116 | }
117 |
118 | // Update the local state with the new functions and options. This can happen when being run
119 | // on a new page or if the user just changes it on the current page.
120 | // It's better for performance than creating new observables or hooks for every use, since there may be
121 | // many uses of useObservableRouter in the lifecycle of a page.
122 | routeParams = params as ParamsUseObservableNextRouter;
123 |
124 | // Get the pathname and hash
125 | const { asPath, pathname, query } = router;
126 | const hash = asPath.split('#')[1] || '';
127 |
128 | // Run the compute function to get the value of the object
129 | const computeParams = { pathname, hash, query };
130 | const obj = compute ? compute(computeParams) : computeParams;
131 |
132 | // Set the object without triggering router.push
133 | setSilently(routes$, obj);
134 |
135 | // Return the observable with the computed values
136 | return routes$ as any;
137 | }
138 |
--------------------------------------------------------------------------------
/src/react-reactive/Components.ts:
--------------------------------------------------------------------------------
1 | import type { Observable } from '@legendapp/state';
2 | import { reactive, use$ } from '@legendapp/state/react';
3 | import { useRef } from 'react';
4 | import {
5 | ActivityIndicator,
6 | Button,
7 | FlatList,
8 | Image,
9 | Pressable,
10 | ScrollView,
11 | SectionList,
12 | Switch,
13 | Text,
14 | TextInput,
15 | TouchableWithoutFeedback,
16 | View,
17 | } from 'react-native';
18 |
19 | const $ActivityIndicator = reactive(ActivityIndicator);
20 | const $Button = reactive(Button);
21 | const $FlatList = reactive(FlatList, undefined, {
22 | data: {
23 | selector: (propsOut: Record, p: Observable) => {
24 | const state = useRef(0);
25 | // Increment renderNum whenever the array changes shallowly
26 | const [renderNum, value] = use$(() => [state.current++, p.get(true)]);
27 |
28 | // Set extraData to renderNum so that it will re-render when renderNum changes.
29 | // This is necessary because the observable array is mutable so changes to it
30 | // won't trigger re-renders by default.
31 | propsOut.extraData = renderNum;
32 |
33 | return value;
34 | },
35 | },
36 | });
37 | const $Image = reactive(Image);
38 | const $Pressable = reactive(Pressable);
39 | const $ScrollView = reactive(ScrollView);
40 | const $SectionList = reactive(SectionList);
41 | const $Switch = reactive(Switch, undefined, {
42 | value: {
43 | handler: 'onValueChange',
44 | getValue: (e: any) => e,
45 | defaultValue: false,
46 | },
47 | });
48 | const $Text = reactive(Text);
49 | const $TextInput = reactive(TextInput, undefined, {
50 | value: {
51 | handler: 'onChange',
52 | getValue: (e: any) => e.nativeEvent.text,
53 | defaultValue: '',
54 | },
55 | });
56 | const $TouchableWithoutFeedback = reactive(TouchableWithoutFeedback);
57 | const $View = reactive(View);
58 |
59 | export {
60 | $ActivityIndicator,
61 | $Button,
62 | $FlatList,
63 | $Image,
64 | $Pressable,
65 | $ScrollView,
66 | $SectionList,
67 | $Switch,
68 | $Text,
69 | $TextInput,
70 | $TouchableWithoutFeedback,
71 | $View,
72 | };
73 |
--------------------------------------------------------------------------------
/src/react-reactive/enableReactComponents.ts:
--------------------------------------------------------------------------------
1 | import { BindKeys, configureReactive } from '@legendapp/state/react';
2 |
3 | let isEnabled = false;
4 |
5 | export function enableReactComponents_(config: typeof configureReactive) {
6 | if (isEnabled) {
7 | return;
8 | }
9 | isEnabled = true;
10 |
11 | const bindInfo: BindKeys = {
12 | value: { handler: 'onChange', getValue: (e: any) => e.target.value, defaultValue: '' },
13 | };
14 | const bindInfoInput: BindKeys = Object.assign(
15 | { checked: { handler: 'onChange', getValue: (e: { target: { checked: boolean } }) => e.target.checked } },
16 | bindInfo,
17 | );
18 | config({
19 | binders: {
20 | input: bindInfoInput,
21 | textarea: bindInfo,
22 | select: bindInfo,
23 | },
24 | });
25 | }
26 |
27 | // TODOV3 Remove this in favor of importing from /types/reactive-web
28 |
29 | // Types:
30 |
31 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
32 | import type { FCReactiveObject, IReactive } from '@legendapp/state/react';
33 |
34 | declare module '@legendapp/state/react' {
35 | // eslint-disable-next-line @typescript-eslint/no-empty-interface
36 | interface IReactive extends FCReactiveObject {}
37 | }
38 |
--------------------------------------------------------------------------------
/src/react-reactive/enableReactNativeComponents.ts:
--------------------------------------------------------------------------------
1 | import type { Observable } from '@legendapp/state';
2 | import type { configureReactive } from '@legendapp/state/react';
3 | import { useSelector } from '@legendapp/state/react';
4 | import { useRef } from 'react';
5 | import {
6 | ActivityIndicator,
7 | Button,
8 | FlatList,
9 | Image,
10 | Pressable,
11 | ScrollView,
12 | SectionList,
13 | Switch,
14 | Text,
15 | TextInput,
16 | TouchableWithoutFeedback,
17 | View,
18 | } from 'react-native';
19 |
20 | let isEnabled = false;
21 |
22 | export function enableReactNativeComponents_(configure: typeof configureReactive) {
23 | if (isEnabled) {
24 | return;
25 | }
26 | isEnabled = true;
27 |
28 | configure({
29 | components: {
30 | ActivityIndicator: ActivityIndicator,
31 | Button: Button,
32 | FlatList: FlatList,
33 | Image: Image,
34 | Pressable: Pressable,
35 | ScrollView: ScrollView,
36 | SectionList: SectionList,
37 | Switch: Switch,
38 | Text: Text,
39 | TextInput: TextInput,
40 | TouchableWithoutFeedback: TouchableWithoutFeedback,
41 | View: View,
42 | },
43 | binders: {
44 | TextInput: {
45 | value: {
46 | handler: 'onChange',
47 | getValue: (e: any) => e.nativeEvent.text,
48 | defaultValue: '',
49 | },
50 | },
51 | Switch: {
52 | value: {
53 | handler: 'onValueChange',
54 | getValue: (e: any) => e,
55 | defaultValue: false,
56 | },
57 | },
58 | FlatList: {
59 | data: {
60 | selector: (propsOut: Record, p: Observable) => {
61 | const state = useRef(0);
62 | // Increment renderNum whenever the array changes shallowly
63 | const [renderNum, value] = useSelector(() => [state.current++, p.get(true)]);
64 |
65 | // Set extraData to renderNum so that it will re-render when renderNum changes.
66 | // This is necessary because the observable array is mutable so changes to it
67 | // won't trigger re-renders by default.
68 | propsOut.extraData = renderNum;
69 |
70 | return value;
71 | },
72 | },
73 | },
74 | },
75 | });
76 | }
77 |
--------------------------------------------------------------------------------
/src/react-reactive/enableReactive.native.ts:
--------------------------------------------------------------------------------
1 | import { configureReactive } from '@legendapp/state/react';
2 | import { enableReactNativeComponents_ } from './enableReactNativeComponents';
3 |
4 | export function enableReactive(configure: typeof configureReactive) {
5 | enableReactNativeComponents_(configure);
6 | }
7 |
--------------------------------------------------------------------------------
/src/react-reactive/enableReactive.ts:
--------------------------------------------------------------------------------
1 | import type { configureReactive } from '@legendapp/state/react';
2 | import { enableReactComponents_ } from './enableReactComponents';
3 |
4 | export function enableReactive(config: typeof configureReactive) {
5 | enableReactComponents_(config);
6 | }
7 |
--------------------------------------------------------------------------------
/src/react-reactive/enableReactive.web.ts:
--------------------------------------------------------------------------------
1 | import { configureReactive } from '@legendapp/state/react';
2 | import { enableReactNativeComponents_ } from './enableReactNativeComponents';
3 |
4 | // Enable React Native Web Components
5 | export function enableReactive(configure: typeof configureReactive) {
6 | enableReactNativeComponents_(configure);
7 | }
8 |
--------------------------------------------------------------------------------
/src/react-web/$React.tsx:
--------------------------------------------------------------------------------
1 | import { isEmpty, isFunction } from '@legendapp/state';
2 | import { reactive, BindKeys, FCReactiveObject } from '@legendapp/state/react';
3 | import { createElement, FC, forwardRef } from 'react';
4 |
5 | type IReactive = FCReactiveObject;
6 |
7 | const bindInfoOneWay: BindKeys = {
8 | value: { handler: 'onChange', getValue: (e: any) => e.target.value, defaultValue: '' },
9 | };
10 | const bindInfoInput: BindKeys = Object.assign(
11 | { checked: { handler: 'onChange', getValue: (e: { target: { checked: boolean } }) => e.target.checked } },
12 | bindInfoOneWay,
13 | );
14 | const binders = new Map([
15 | ['input', bindInfoInput],
16 | ['textarea', bindInfoOneWay],
17 | ['select', bindInfoOneWay],
18 | ]);
19 |
20 | export const $React: IReactive = new Proxy(
21 | {},
22 | {
23 | get(target: Record, p: string) {
24 | if (!target[p]) {
25 | // Create a wrapper around createElement with the string so we can proxy it
26 | // eslint-disable-next-line react/display-name
27 | const render = forwardRef((props, ref) => {
28 | const propsOut = { ...props } as any;
29 | if (ref && (isFunction(ref) || !isEmpty(ref))) {
30 | propsOut.ref = ref;
31 | }
32 | return createElement(p, propsOut);
33 | });
34 |
35 | target[p] = reactive(render, [], binders.get(p));
36 | }
37 | return target[p];
38 | },
39 | },
40 | ) as unknown as IReactive;
41 |
--------------------------------------------------------------------------------
/src/react/Computed.tsx:
--------------------------------------------------------------------------------
1 | import { computeSelector } from '@legendapp/state';
2 | import { ReactElement, ReactNode } from 'react';
3 | import type { ObservableParam } from '@legendapp/state';
4 | import { useSelector } from './useSelector';
5 |
6 | export function Computed({ children }: { children: ObservableParam | (() => ReactNode) }): ReactElement {
7 | return useSelector(() => computeSelector(computeSelector(children)), { skipCheck: true }) as ReactElement;
8 | }
9 |
--------------------------------------------------------------------------------
/src/react/For.tsx:
--------------------------------------------------------------------------------
1 | import type { Observable, ObservableObject, ObservableParam } from '@legendapp/state';
2 | import { internal, isArray, isFunction, isMap } from '@legendapp/state';
3 | import { FC, ReactElement, createElement, memo, useMemo, useRef } from 'react';
4 | import { observer } from './reactive-observer';
5 | import { useSelector } from './useSelector';
6 | const { findIDKey, getNode, optimized } = internal;
7 |
8 | const autoMemoCache = new Map, FC>();
9 |
10 | type ForItemProps = {
11 | item$: Observable;
12 | id?: string;
13 | } & TProps;
14 |
15 | export function For({
16 | each,
17 | optimized: isOptimized,
18 | item,
19 | itemProps,
20 | sortValues,
21 | children,
22 | }: {
23 | each?: ObservableParam | Map>;
24 | optimized?: boolean;
25 | item?: FC>;
26 | itemProps?: TProps;
27 | sortValues?: (A: T, B: T, AKey: string, BKey: string) => number;
28 | children?: (value: Observable, id: string | undefined) => ReactElement;
29 | }): ReactElement | null {
30 | if (!each) return null;
31 |
32 | // Get the raw value with a shallow listener so this list only re-renders
33 | // when the array length changes
34 | const value = useSelector(() => each!.get(isOptimized ? optimized : true));
35 |
36 | // The child function gets wrapped in a memoized observer component
37 | if (!item && children) {
38 | // Update the ref so the generated component uses the latest function
39 | const refChildren = useRef<(value: Observable, id: string | undefined) => ReactElement>();
40 | refChildren.current = children;
41 |
42 | item = useMemo(() => observer(({ item$, id }) => refChildren.current!(item$, id)), []);
43 | } else {
44 | // @ts-expect-error $$typeof is private
45 | if (item.$$typeof !== Symbol.for('react.memo')) {
46 | let memod = autoMemoCache.get(item!);
47 | if (!memod) {
48 | memod = memo(item!);
49 | autoMemoCache.set(item!, memod);
50 | }
51 | item = memod;
52 | }
53 | }
54 |
55 | // This early out needs to be after any hooks
56 | if (!value) return null;
57 |
58 | // Create the child elements
59 | const out: ReactElement[] = [];
60 |
61 | const isArr = isArray(value);
62 |
63 | if (isArr) {
64 | // Get the appropriate id field
65 | const v0 = value[0] as any;
66 | const node = getNode(each!);
67 | const length = (value as any[]).length;
68 |
69 | const idField =
70 | length > 0
71 | ? (node && findIDKey(v0, node)) ||
72 | (v0.id !== undefined ? 'id' : v0.key !== undefined ? 'key' : undefined)
73 | : undefined;
74 |
75 | const isIdFieldFunction = isFunction(idField);
76 |
77 | for (let i = 0; i < length; i++) {
78 | if (value[i]) {
79 | const val = value[i];
80 | const key = (isIdFieldFunction ? idField(val) : (val as Record)[idField as string]) ?? i;
81 | const item$ = (each as Observable)[i];
82 | // TODOV3 Remove item
83 | const props: ForItemProps & { key: string; item: Observable } = {
84 | key,
85 | id: key,
86 | item$,
87 | item: item$,
88 | };
89 |
90 | out.push(createElement(item as FC, itemProps ? Object.assign(props, itemProps) : props));
91 | }
92 | }
93 | } else {
94 | // Render the values of the object / Map
95 | const asMap = isMap(value);
96 | const keys = asMap ? Array.from(value.keys()) : Object.keys(value);
97 | if (sortValues) {
98 | keys.sort((A, B) => sortValues(asMap ? value.get(A)! : value[A], asMap ? value.get(B)! : value[B], A, B));
99 | }
100 | for (let i = 0; i < keys.length; i++) {
101 | const key = keys[i];
102 | if (asMap ? value.get(key) : value[key]) {
103 | const item$ = asMap ? each!.get(key) : (each as ObservableObject>)[key];
104 | const props: ForItemProps & { key: string; item: Observable } = {
105 | key,
106 | id: key,
107 | item$,
108 | item: item$,
109 | };
110 | out.push(createElement(item as FC, itemProps ? Object.assign(props, itemProps) : props));
111 | }
112 | }
113 | }
114 |
115 | return out as unknown as ReactElement;
116 | }
117 |
--------------------------------------------------------------------------------
/src/react/Memo.tsx:
--------------------------------------------------------------------------------
1 | import { memo, ReactElement, NamedExoticComponent, ComponentProps } from 'react';
2 | import { Computed } from './Computed';
3 |
4 | type ComputedWithMemo = (params: {
5 | children: ComponentProps['children'];
6 | scoped?: boolean;
7 | }) => ReactElement;
8 |
9 | export const Memo = memo(Computed as ComputedWithMemo, (prev, next) =>
10 | next.scoped ? prev.children === next.children : true,
11 | ) as NamedExoticComponent<{
12 | children: any;
13 | scoped?: boolean;
14 | }>;
15 |
--------------------------------------------------------------------------------
/src/react/Reactive.tsx:
--------------------------------------------------------------------------------
1 | import { isEmpty, isFunction } from '@legendapp/state';
2 | import { enableReactive } from '@legendapp/state/react-reactive/enableReactive';
3 | import { FC, createElement, forwardRef } from 'react';
4 | import { configureReactive, ReactiveFnBinders, ReactiveFns } from './configureReactive';
5 | import { reactive } from './reactive-observer';
6 |
7 | // eslint-disable-next-line @typescript-eslint/no-empty-interface
8 | export interface IReactive {}
9 |
10 | export const Reactive: IReactive = new Proxy(
11 | {},
12 | {
13 | get(target: Record, p: string) {
14 | if (!target[p]) {
15 | const Component = ReactiveFns.get(p) || p;
16 |
17 | // Create a wrapper around createElement with the string so we can proxy it
18 | // eslint-disable-next-line react/display-name
19 | const render = forwardRef((props, ref) => {
20 | const propsOut = { ...props } as any;
21 | if (ref && (isFunction(ref) || !isEmpty(ref))) {
22 | propsOut.ref = ref;
23 | }
24 | return createElement(Component, propsOut);
25 | });
26 |
27 | target[p] = reactive(render, [], ReactiveFnBinders.get(p));
28 | }
29 | return target[p];
30 | },
31 | },
32 | ) as unknown as IReactive;
33 |
34 | if (process.env.NODE_ENV !== 'test') {
35 | enableReactive(configureReactive);
36 | }
37 |
--------------------------------------------------------------------------------
/src/react/Show.tsx:
--------------------------------------------------------------------------------
1 | import type { Observable, Selector } from '@legendapp/state';
2 | import { isFunction, isObservableValueReady } from '@legendapp/state';
3 | import { FC, ReactElement, ReactNode, createElement } from 'react';
4 | import { useSelector } from './useSelector';
5 |
6 | interface PropsIf {
7 | if: Selector;
8 | ifReady?: never;
9 | }
10 | interface PropsIfReady {
11 | if?: never;
12 | ifReady: Selector;
13 | }
14 |
15 | interface PropsBase {
16 | else?: ReactNode | (() => ReactNode);
17 | $value?: Observable;
18 | wrap?: FC<{ children: ReactNode }>;
19 | children: ReactNode | ((value?: T) => ReactNode);
20 | }
21 |
22 | type Props = PropsBase & (PropsIf | PropsIfReady);
23 |
24 | export function Show(props: Props): ReactElement;
25 | export function Show({ if: if_, ifReady, else: else_, $value, wrap, children }: Props): ReactElement {
26 | const value = useSelector(if_ ?? ifReady);
27 | const show = ifReady !== undefined ? isObservableValueReady(value) : value;
28 | const child = useSelector(
29 | show
30 | ? isFunction(children)
31 | ? () => children($value ? $value.get() : value)
32 | : (children as any)
33 | : (else_ ?? null),
34 | { skipCheck: true },
35 | );
36 |
37 | return wrap ? createElement(wrap, undefined, child) : child;
38 | }
39 |
--------------------------------------------------------------------------------
/src/react/Switch.tsx:
--------------------------------------------------------------------------------
1 | import type { Selector } from '@legendapp/state';
2 | import { ReactElement, ReactNode } from 'react';
3 | import { useSelector } from './useSelector';
4 |
5 | export function Switch({
6 | value,
7 | children,
8 | }: {
9 | value?: Selector;
10 | children: Partial ReactNode>>;
11 | }): ReactElement | null;
12 | export function Switch({
13 | value,
14 | children,
15 | }: {
16 | value?: Selector;
17 | children: Partial ReactNode>>;
18 | }): ReactElement | null;
19 | export function Switch({
20 | value,
21 | children,
22 | }: {
23 | value?: Selector;
24 | children: Partial ReactNode>>;
25 | }): ReactElement | null;
26 | export function Switch({
27 | value,
28 | children,
29 | }: {
30 | value?: Selector;
31 | children: Partial