├── .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 | Legend 139 |      140 | Bravely 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 ReactNode>>; 32 | }): ReactElement | null; 33 | export function Switch({ 34 | value, 35 | children, 36 | }: { 37 | value?: Selector; 38 | children: Partial ReactNode>>; 39 | }): ReactNode { 40 | // Select from an object of cases 41 | const child = children[useSelector(value)!]; 42 | return (child ? child() : children['default']?.()) ?? null; 43 | } 44 | -------------------------------------------------------------------------------- /src/react/configureReactive.ts: -------------------------------------------------------------------------------- 1 | import { ComponentClass, FC } from 'react'; 2 | import { BindKeys } from './reactInterfaces'; 3 | 4 | export const ReactiveFns = new Map(); 5 | export const ReactiveFnBinders = new Map(); 6 | 7 | export function configureReactive({ 8 | components, 9 | binders, 10 | }: { 11 | components?: Record>; 12 | binders?: Record; 13 | }) { 14 | if (components) { 15 | for (const key in components) { 16 | ReactiveFns.set(key, components[key]); 17 | } 18 | } 19 | if (binders) { 20 | for (const key in binders) { 21 | ReactiveFnBinders.set(key, binders[key]); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/react/react-globals.ts: -------------------------------------------------------------------------------- 1 | export const reactGlobals = { 2 | inObserver: false, 3 | }; 4 | -------------------------------------------------------------------------------- /src/react/reactInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type { GetOptions, Observable, Selector } from '@legendapp/state'; 2 | import type { FC, LegacyRef, ReactNode } from 'react'; 3 | 4 | export type ShapeWithNew$ = Partial> & { 5 | [K in keyof T as K extends `$${string & K}` ? K : `$${string & K}`]?: Selector; 6 | } & { children?: Selector }; 7 | 8 | export interface BindKey { 9 | handler?: K; 10 | getValue?: P[K] extends infer T 11 | ? T extends (...args: any) => any 12 | ? (params: Parameters[0]) => any 13 | : (e: any) => any 14 | : (e: any) => any; 15 | defaultValue?: any; 16 | selector?: (propsOut: Record, p: Observable) => any; 17 | } 18 | 19 | export type BindKeys

= Partial>>; 20 | 21 | export type FCReactiveObject = { 22 | [K in keyof T]: FC>; 23 | }; 24 | 25 | export type FCReactive = P & 26 | FC< 27 | ShapeWithNew$ & { 28 | ref?: LegacyRef

| undefined; 29 | } 30 | >; 31 | 32 | export interface UseSelectorOptions extends GetOptions { 33 | suspense?: boolean; 34 | skipCheck?: boolean; 35 | } 36 | -------------------------------------------------------------------------------- /src/react/useComputed.ts: -------------------------------------------------------------------------------- 1 | import { isArray, linked, Observable, ObservableParam } from '@legendapp/state'; 2 | import { useObservable } from './useObservable'; 3 | 4 | // TODO: Deprecate this? 5 | export function useComputed(get: () => T | Promise): Observable; 6 | export function useComputed(get: () => T | Promise, deps: any[]): Observable; 7 | export function useComputed( 8 | get: (() => T | Promise) | ObservableParam, 9 | set: (value: T2) => void, 10 | ): Observable; 11 | export function useComputed( 12 | get: (() => T | Promise) | ObservableParam, 13 | set: (value: T2) => void, 14 | deps: any[], 15 | ): Observable; 16 | export function useComputed( 17 | get: (() => T | Promise) | ObservableParam, 18 | set?: ((value: T2) => void) | any[], 19 | deps?: any[], 20 | ): Observable { 21 | if (!deps && isArray(set)) { 22 | deps = set; 23 | set = undefined; 24 | } 25 | return useObservable( 26 | set ? (linked({ get: get as () => T, set: ({ value }) => (set as (value: any) => void)(value) }) as any) : get, 27 | deps, 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/react/useEffectOnce.ts: -------------------------------------------------------------------------------- 1 | import { isFunction } from '@legendapp/state'; 2 | import { useEffect, useRef } from 'react'; 3 | 4 | export const useEffectOnce = (effect: () => void | (() => void), deps: any[]) => { 5 | if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { 6 | const refDispose = useRef<{ dispose?: void | (() => void); num: number }>({ num: 0 }); 7 | 8 | useEffect(() => { 9 | // This is a hack to work around StrictMode running effects twice. 10 | // On the first run it returns a cleanup function that queues the dispose function 11 | // in a microtask. This way it will run at the end of the frame after StrictMode's second 12 | // run of the effect. If it's run a second time then the microtasked dispose will do nothing, 13 | // but the effect will return the dispose again so that when it actually unmounts it will dispose. 14 | // If not in StrictMode, then the dispose function will run in the microtask. 15 | // It's possible that this is not safe in 100% of cases, but I'm not sure what the 16 | // dangerous cases would be. The side effect is that the listener is still active 17 | // until the end of the frame, but that's probably not a problem. 18 | const { current } = refDispose; 19 | current.num++; 20 | const dispose = () => { 21 | if (current.dispose && current.num < 2) { 22 | (current.dispose as () => void)(); 23 | current.dispose = undefined; 24 | } 25 | current.num--; 26 | }; 27 | if (current.dispose === undefined) { 28 | const ret = effect() ?? null; 29 | // If ret is a function, then it's a dispose function. 30 | if (ret && isFunction(ret)) { 31 | current.dispose = ret; 32 | return () => queueMicrotask(dispose); 33 | } 34 | } else { 35 | return dispose; 36 | } 37 | }, deps); 38 | } else { 39 | useEffect(effect, deps); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src/react/useIsMounted.ts: -------------------------------------------------------------------------------- 1 | import type { Observable } from '@legendapp/state'; 2 | import { useMountOnce } from './useMount'; 3 | import { useObservable } from './useObservable'; 4 | 5 | export function useIsMounted(): Observable { 6 | const obs = useObservable(false); 7 | 8 | const { set } = obs; 9 | useMountOnce(() => { 10 | set(true); 11 | 12 | return () => set(false); 13 | }); 14 | 15 | return obs; 16 | } 17 | -------------------------------------------------------------------------------- /src/react/useMount.ts: -------------------------------------------------------------------------------- 1 | import { isPromise } from '@legendapp/state'; 2 | import { useEffectOnce } from './useEffectOnce'; 3 | 4 | export function useMount(fn: () => (void | (() => void)) | Promise) { 5 | return useEffectOnce(() => { 6 | const ret = fn(); 7 | // Allow the function to be async but if so ignore its return value 8 | if (!isPromise(ret)) { 9 | return ret; 10 | } 11 | }, []); 12 | } 13 | 14 | // TODOV4 Deprecate 15 | export const useMountOnce = useMount; 16 | -------------------------------------------------------------------------------- /src/react/useObservable.ts: -------------------------------------------------------------------------------- 1 | import type { Observable } from '@legendapp/state'; 2 | import { computeSelector, isFunction, observable, RecursiveValueOrFunction } from '@legendapp/state'; 3 | import { DependencyList, useRef } from 'react'; 4 | 5 | /** 6 | * A React hook that creates a new observable 7 | * 8 | * @param initialValue The initial value of the observable or a function that returns the initial value 9 | * 10 | * @see https://www.legendapp.com/dev/state/react/#useObservable 11 | */ 12 | export function useObservable(): Observable; 13 | export function useObservable( 14 | value: Promise> | (() => RecursiveValueOrFunction) | RecursiveValueOrFunction, 15 | deps?: DependencyList, 16 | ): Observable; 17 | export function useObservable(value: T, deps?: DependencyList): Observable; 18 | export function useObservable(value?: T, deps?: DependencyList): Observable; 19 | export function useObservable( 20 | initialValue?: T | (() => T) | (() => Promise), 21 | deps?: DependencyList, 22 | ): Observable { 23 | // Create a ref to contain the observable and initialValue function 24 | const ref = useRef<{ obs$: Observable; value: T }>({} as any); 25 | ref.current.value = initialValue as T; 26 | 27 | // Create a deps observable to be watched by the created observable 28 | const depsObs$ = deps ? useObservable(deps) : undefined; 29 | if (!ref.current?.obs$) { 30 | // Create the observable from the default value. If the selector function is a lookup table 31 | // then it needs to be a function taking a string to pass it through. 32 | const value = depsObs$ 33 | ? isFunction(initialValue) && initialValue.length === 1 34 | ? (p: string) => { 35 | depsObs$.get(); 36 | return (ref.current.value as (p: string) => any)(p); 37 | } 38 | : () => { 39 | depsObs$.get(); 40 | return computeSelector(ref.current.value); 41 | } 42 | : initialValue; 43 | 44 | ref.current.obs$ = observable(value as T); 45 | } 46 | // Update depsObs with the deps array 47 | if (depsObs$) { 48 | depsObs$.set(deps! as any[]); 49 | } 50 | 51 | return ref.current.obs$; 52 | } 53 | -------------------------------------------------------------------------------- /src/react/useObservableReducer.ts: -------------------------------------------------------------------------------- 1 | import type { Observable } from '@legendapp/state'; 2 | import { isFunction } from '@legendapp/state'; 3 | import type { 4 | Dispatch, 5 | DispatchWithoutAction, 6 | Reducer, 7 | ReducerAction, 8 | ReducerState, 9 | ReducerStateWithoutAction, 10 | ReducerWithoutAction, 11 | } from 'react'; 12 | import { useObservable } from './useObservable'; 13 | 14 | export function useObservableReducer, I>( 15 | reducer: R, 16 | initializerArg: I, 17 | initializer: (arg: I) => ReducerStateWithoutAction, 18 | ): [Observable>, DispatchWithoutAction]; 19 | export function useObservableReducer>( 20 | reducer: R, 21 | initializerArg: ReducerStateWithoutAction, 22 | initializer?: undefined, 23 | ): [Observable>, DispatchWithoutAction]; 24 | export function useObservableReducer, I>( 25 | reducer: R, 26 | initializerArg: I & ReducerState, 27 | initializer: (arg: I & ReducerState) => ReducerState, 28 | ): [Observable>, Dispatch>]; 29 | export function useObservableReducer, I>( 30 | reducer: R, 31 | initializerArg: I, 32 | initializer: (arg: I) => ReducerState, 33 | ): [Observable>, Dispatch>]; 34 | export function useObservableReducer>( 35 | reducer: R, 36 | initialState: ReducerState, 37 | initializer?: undefined, 38 | ): [Observable>, Dispatch>]; 39 | export function useObservableReducer, I>( 40 | reducer: R, 41 | initializerArg: I & ReducerState, 42 | initializer: ((arg: I & ReducerState) => ReducerState) | undefined, 43 | ): [Observable>, Dispatch>] { 44 | const obs = useObservable(() => 45 | initializerArg !== undefined && isFunction(initializerArg) ? initializer!(initializerArg) : initializerArg, 46 | ); 47 | const dispatch = (action: any) => { 48 | obs.set(reducer(obs.get(), action)); 49 | }; 50 | 51 | return [obs as Observable>, dispatch]; 52 | } 53 | -------------------------------------------------------------------------------- /src/react/useObservableState.ts: -------------------------------------------------------------------------------- 1 | import { isFunction, observable, type Observable } from '@legendapp/state'; 2 | import { useMemo } from 'react'; 3 | import { useSelector } from './useSelector'; 4 | 5 | export function useObservableState(initialValue?: T | (() => T) | (() => Promise)): [Observable, T] { 6 | // Create a memoized observable 7 | return useMemo( 8 | () => 9 | // Wrap the return array in a Proxy to detect whether the value is accessed 10 | new Proxy( 11 | [ 12 | observable((isFunction(initialValue) ? initialValue() : initialValue) as any), 13 | // Second element of the array just needs to exist for the Proxy to access it 14 | // but we can't ensure it's updated with the real value, and it doesn't really matter since it's proxied, 15 | // so just make it undefined. Alternatively the Proxy handler could manually return 2 for the "length" prop 16 | // but this seems easier and less code. 17 | undefined, 18 | ], 19 | proxyHandler, 20 | ), 21 | [], 22 | ) as [Observable, T]; 23 | } 24 | 25 | const proxyHandler: ProxyHandler = { 26 | get(target, prop, receiver) { 27 | // If the value is accessed at index 1 then `useSelector` to track it for changes 28 | return prop === '1' ? useSelector(target[0]) : Reflect.get(target, prop, receiver); 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /src/react/useObserve.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computeSelector, 3 | isFunction, 4 | ObservableParam, 5 | observe, 6 | ObserveEvent, 7 | ObserveEventCallback, 8 | Selector, 9 | ObserveOptions, 10 | } from '@legendapp/state'; 11 | import { useRef } from 'react'; 12 | import { useUnmountOnce } from './useUnmount'; 13 | import { useObservable } from './useObservable'; 14 | 15 | export interface UseObserveOptions extends ObserveOptions { 16 | deps?: any[]; 17 | } 18 | 19 | export function useObserve(run: (e: ObserveEvent) => T | void, options?: UseObserveOptions): () => void; 20 | export function useObserve( 21 | selector: Selector, 22 | reaction?: (e: ObserveEventCallback) => any, 23 | options?: UseObserveOptions, 24 | ): () => void; 25 | export function useObserve( 26 | selector: Selector | ((e: ObserveEvent) => any), 27 | reactionOrOptions?: ((e: ObserveEventCallback) => any) | UseObserveOptions, 28 | options?: UseObserveOptions, 29 | ): () => void { 30 | let reaction: ((e: ObserveEventCallback) => any) | undefined; 31 | if (isFunction(reactionOrOptions)) { 32 | reaction = reactionOrOptions; 33 | } else { 34 | options = reactionOrOptions; 35 | } 36 | 37 | const deps = options?.deps; 38 | 39 | // Create a deps observable to be watched by the created observable 40 | const depsObs$ = deps ? useObservable(deps) : undefined; 41 | // Update depsObs with the deps array 42 | if (depsObs$) { 43 | depsObs$.set(deps! as any[]); 44 | } 45 | 46 | const ref = useRef<{ 47 | selector?: Selector | ((e: ObserveEvent) => T | void) | ObservableParam; 48 | reaction?: (e: ObserveEventCallback) => any; 49 | dispose?: () => void; 50 | }>({}); 51 | 52 | ref.current.selector = deps 53 | ? () => { 54 | depsObs$?.get(); 55 | return computeSelector(selector); 56 | } 57 | : selector; 58 | ref.current.reaction = reaction; 59 | 60 | if (!ref.current.dispose) { 61 | ref.current.dispose = observe( 62 | ((e: ObserveEventCallback) => computeSelector(ref.current.selector, undefined, e)) as any, 63 | (e) => ref.current.reaction?.(e), 64 | options, 65 | ); 66 | } 67 | 68 | useUnmountOnce(() => { 69 | ref.current?.dispose?.(); 70 | }); 71 | 72 | return ref.current.dispose; 73 | } 74 | -------------------------------------------------------------------------------- /src/react/useObserveEffect.ts: -------------------------------------------------------------------------------- 1 | import { ObservableParam, ObserveEvent, ObserveEventCallback, Selector, isFunction, observe } from '@legendapp/state'; 2 | import { useRef } from 'react'; 3 | import { useMountOnce } from './useMount'; 4 | import { useObservable } from './useObservable'; 5 | import type { UseObserveOptions } from './useObserve'; 6 | 7 | export function useObserveEffect(run: (e: ObserveEvent) => T | void, options?: UseObserveOptions): void; 8 | export function useObserveEffect( 9 | selector: Selector, 10 | reaction?: (e: ObserveEventCallback) => any, 11 | options?: UseObserveOptions, 12 | ): void; 13 | export function useObserveEffect( 14 | selector: Selector | ((e: ObserveEvent) => any), 15 | reactionOrOptions?: ((e: ObserveEventCallback) => any) | UseObserveOptions, 16 | options?: UseObserveOptions, 17 | ): void { 18 | let reaction: ((e: ObserveEventCallback) => any) | undefined; 19 | if (isFunction(reactionOrOptions)) { 20 | reaction = reactionOrOptions; 21 | } else { 22 | options = reactionOrOptions; 23 | } 24 | 25 | const deps = options?.deps; 26 | 27 | // Create a deps observable to be watched by the created observable 28 | const depsObs$ = deps ? useObservable(deps) : undefined; 29 | // Update depsObs with the deps array 30 | if (depsObs$) { 31 | depsObs$.set(deps! as any[]); 32 | } 33 | 34 | const ref = useRef<{ 35 | selector: Selector | ((e: ObserveEvent) => T | void) | ObservableParam; 36 | reaction?: (e: ObserveEventCallback) => any; 37 | }>({ selector }); 38 | ref.current = { selector, reaction }; 39 | 40 | useMountOnce(() => 41 | observe( 42 | ((e: ObserveEventCallback) => { 43 | const { selector } = ref.current as { selector: (e: ObserveEvent) => T | void }; 44 | depsObs$?.get(); 45 | return isFunction(selector) ? selector(e) : selector; 46 | }) as any, 47 | (e) => ref.current.reaction?.(e), 48 | options, 49 | ), 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/react/usePauseProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Observable, ObservableBoolean, observable } from '@legendapp/state'; 2 | import { Context, ReactNode, createContext, createElement, useState } from 'react'; 3 | 4 | let pauseContext: Context | undefined = undefined; 5 | export const getPauseContext = () => { 6 | return (pauseContext ||= createContext>(null as any)); 7 | }; 8 | 9 | export function usePauseProvider() { 10 | const [value] = useState(() => observable(false)); 11 | return { 12 | PauseProvider: ({ children }: { children: ReactNode }) => 13 | createElement(getPauseContext().Provider, { value }, children), 14 | isPaused$: value, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/react/useSelector.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ListenerParams, 3 | Observable, 4 | Selector, 5 | computeSelector, 6 | isObservable, 7 | isPrimitive, 8 | trackSelector, 9 | when, 10 | } from '@legendapp/state'; 11 | import React, { useContext, useMemo } from 'react'; 12 | import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'; 13 | import { reactGlobals } from './react-globals'; 14 | import type { UseSelectorOptions } from './reactInterfaces'; 15 | import { getPauseContext } from './usePauseProvider'; 16 | 17 | interface SelectorFunctions { 18 | subscribe: (onStoreChange: () => void) => () => void; 19 | getVersion: () => number; 20 | run: (selector: Selector) => T; 21 | } 22 | 23 | function createSelectorFunctions( 24 | options: UseSelectorOptions | undefined, 25 | isPaused$: Observable, 26 | ): SelectorFunctions { 27 | let version = 0; 28 | let notify: () => void; 29 | let dispose: (() => void) | undefined; 30 | let resubscribe: (() => () => void) | undefined; 31 | let _selector: Selector; 32 | let prev: T; 33 | 34 | let pendingUpdate: any | undefined = undefined; 35 | 36 | const run = () => { 37 | // Dispose if already listening 38 | dispose?.(); 39 | 40 | const { 41 | value, 42 | dispose: _dispose, 43 | resubscribe: _resubscribe, 44 | } = trackSelector(_selector, _update, options, undefined, undefined, /*createResubscribe*/ true); 45 | 46 | dispose = _dispose; 47 | resubscribe = _resubscribe; 48 | 49 | return value; 50 | }; 51 | 52 | const _update = ({ value }: { value: ListenerParams['value'] }) => { 53 | if (isPaused$?.peek()) { 54 | const next = pendingUpdate; 55 | pendingUpdate = value; 56 | if (next === undefined) { 57 | when( 58 | () => !isPaused$.get(), 59 | () => { 60 | const latest = pendingUpdate; 61 | pendingUpdate = undefined; 62 | _update({ value: latest }); 63 | }, 64 | ); 65 | } 66 | } else { 67 | // If skipCheck then don't need to re-run selector 68 | let changed = options?.skipCheck; 69 | if (!changed) { 70 | const newValue = run(); 71 | 72 | // If newValue is different than previous value then it's changed. 73 | // Also if the selector returns an observable directly then its value will be the same as 74 | // the value from the listener, and that should always re-render. 75 | if (newValue !== prev || (!isPrimitive(newValue) && newValue === value)) { 76 | changed = true; 77 | } 78 | } 79 | if (changed) { 80 | version++; 81 | notify?.(); 82 | } 83 | } 84 | }; 85 | 86 | return { 87 | subscribe: (onStoreChange: () => void) => { 88 | notify = onStoreChange; 89 | 90 | // Workaround for React 18 running twice in dev (part 2) 91 | if ( 92 | (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') && 93 | !dispose && 94 | resubscribe 95 | ) { 96 | dispose = resubscribe(); 97 | } 98 | 99 | return () => { 100 | dispose?.(); 101 | dispose = undefined; 102 | }; 103 | }, 104 | getVersion: () => version, 105 | run: (selector: Selector) => { 106 | // Update the cached selector 107 | _selector = selector; 108 | 109 | return (prev = run()); 110 | }, 111 | }; 112 | } 113 | 114 | function doSuspense(selector: Selector) { 115 | const vProm = when(selector); 116 | if ((React as any).use) { 117 | (React as any).use(vProm); 118 | } else { 119 | throw vProm; 120 | } 121 | } 122 | 123 | export function useSelector(selector: Selector, options?: UseSelectorOptions): T { 124 | let value; 125 | 126 | // Short-circuit to skip creating the hook if selector is an observable 127 | // and running in an observer. If selector is a function it needs to run in its own context. 128 | if (reactGlobals.inObserver && isObservable(selector) && !options?.suspense) { 129 | value = computeSelector(selector, options); 130 | if (options?.suspense && value === undefined) { 131 | doSuspense(selector); 132 | } 133 | return value; 134 | } 135 | 136 | try { 137 | const isPaused$ = useContext(getPauseContext()); 138 | const selectorFn = useMemo(() => createSelectorFunctions(options, isPaused$), []); 139 | const { subscribe, getVersion, run } = selectorFn; 140 | 141 | // Run the selector 142 | // Note: The selector needs to run on every render because it may have different results 143 | // than the previous run if it uses local state 144 | value = run(selector) as any; 145 | 146 | useSyncExternalStore(subscribe, getVersion, getVersion); 147 | } catch (err: unknown) { 148 | if ( 149 | (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') && 150 | (err as Error)?.message?.includes('Rendered more') 151 | ) { 152 | console.warn( 153 | `[legend-state]: You may want to wrap this component in \`observer\` to fix the error of ${ 154 | (err as Error).message 155 | }`, 156 | ); 157 | } 158 | throw err; 159 | } 160 | 161 | // Suspense support 162 | if (options?.suspense && value === undefined) { 163 | doSuspense(selector); 164 | } 165 | 166 | return value; 167 | } 168 | 169 | export { useSelector as use$ }; 170 | -------------------------------------------------------------------------------- /src/react/useUnmount.ts: -------------------------------------------------------------------------------- 1 | import { useMount } from './useMount'; 2 | 3 | export function useUnmount(fn: () => void) { 4 | return useMount(() => fn); 5 | } 6 | 7 | // TODOV4 Deprecate 8 | export const useUnmountOnce = useUnmount; 9 | -------------------------------------------------------------------------------- /src/react/useWhen.ts: -------------------------------------------------------------------------------- 1 | import { Selector, when, whenReady } from '@legendapp/state'; 2 | import { useMemo } from 'react'; 3 | 4 | export function useWhen(predicate: Selector): Promise; 5 | export function useWhen(predicate: Selector, effect: (value: T) => T2): Promise; 6 | export function useWhen(predicate: Selector, effect?: (value: T) => T2): Promise { 7 | return useMemo(() => when(predicate, effect as any), []); 8 | } 9 | export function useWhenReady(predicate: Selector): Promise; 10 | export function useWhenReady(predicate: Selector, effect: (value: T) => T2 | (() => T2)): Promise; 11 | export function useWhenReady(predicate: Selector, effect?: (value: T) => T2 | (() => T2)): Promise { 12 | return useMemo(() => whenReady(predicate, effect as any), []); 13 | } 14 | -------------------------------------------------------------------------------- /src/setupTracking.ts: -------------------------------------------------------------------------------- 1 | import type { ListenerFn, NodeInfo, TrackingNode } from './observableInterfaces'; 2 | import { onChange } from './onChange'; 3 | 4 | export function setupTracking( 5 | nodes: Map | undefined, 6 | update: ListenerFn, 7 | noArgs?: boolean, 8 | immediate?: boolean, 9 | ) { 10 | let listeners: (() => void)[] | undefined = []; 11 | 12 | // Listen to tracked nodes 13 | nodes?.forEach((tracked) => { 14 | const { node, track } = tracked; 15 | listeners!.push(onChange(node, update, { trackingType: track, immediate, noArgs })); 16 | }); 17 | 18 | return () => { 19 | if (listeners) { 20 | for (let i = 0; i < listeners.length; i++) { 21 | listeners[i](); 22 | } 23 | listeners = undefined; 24 | } 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/sync-plugins/fetch.ts: -------------------------------------------------------------------------------- 1 | import { Selector, computeSelector, getNodeValue, isString } from '@legendapp/state'; 2 | import { type Synced, type SyncedOptions, type SyncedSetParams, synced } from '@legendapp/state/sync'; 3 | 4 | export interface SyncedFetchOnSavedParams { 5 | saved: TLocal; 6 | input: TRemote; 7 | currentValue: TLocal; 8 | props: SyncedFetchProps; 9 | } 10 | 11 | export interface SyncedFetchProps 12 | extends Omit, 'get' | 'set'> { 13 | get: Selector; 14 | set?: Selector; 15 | getInit?: RequestInit; 16 | setInit?: RequestInit; 17 | valueType?: 'arrayBuffer' | 'blob' | 'formData' | 'json' | 'text'; 18 | onSavedValueType?: 'arrayBuffer' | 'blob' | 'formData' | 'json' | 'text'; 19 | onSaved?: (params: SyncedFetchOnSavedParams) => Partial | void; 20 | } 21 | 22 | export function syncedFetch(props: SyncedFetchProps): Synced { 23 | const { 24 | get: getParam, 25 | set: setParam, 26 | getInit, 27 | setInit, 28 | valueType, 29 | onSaved, 30 | onSavedValueType, 31 | transform, 32 | ...rest 33 | } = props; 34 | const get = async () => { 35 | const url = computeSelector(getParam); 36 | if (url && isString(url)) { 37 | const response = await fetch(url, getInit); 38 | 39 | if (!response.ok) { 40 | throw new Error(response.statusText); 41 | } 42 | 43 | let value = await response[valueType || 'json'](); 44 | 45 | if (transform?.load) { 46 | value = transform?.load(value, 'get'); 47 | } 48 | 49 | return value; 50 | } else { 51 | return null; 52 | } 53 | }; 54 | 55 | let set: ((params: SyncedSetParams) => void | Promise) | undefined = undefined; 56 | if (setParam) { 57 | set = async ({ value, node, update }: SyncedSetParams) => { 58 | const url = computeSelector(setParam); 59 | 60 | const response = await fetch( 61 | url, 62 | Object.assign({ method: 'POST' }, setInit, { body: JSON.stringify(value) }), 63 | ); 64 | if (!response.ok) { 65 | throw new Error(response.statusText); 66 | } 67 | if (onSaved) { 68 | const responseValue = await response[onSavedValueType || valueType || 'json'](); 69 | const transformed = transform?.load ? await transform.load(responseValue, 'set') : responseValue; 70 | const currentValue = getNodeValue(node); 71 | const valueSave = onSaved({ input: value, saved: transformed, currentValue, props }); 72 | update({ 73 | value: valueSave, 74 | }); 75 | } 76 | }; 77 | } 78 | 79 | return synced({ 80 | ...rest, 81 | get, 82 | set, 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /src/sync-plugins/tanstack-react-query.ts: -------------------------------------------------------------------------------- 1 | import { useObservable } from '@legendapp/state/react'; 2 | import { SyncedQueryParams, syncedQuery } from '@legendapp/state/sync-plugins/tanstack-query'; 3 | import { DefaultError, QueryKey, QueryClient } from '@tanstack/query-core'; 4 | import { useQueryClient } from '@tanstack/react-query'; 5 | import type { Observable } from '@legendapp/state'; 6 | import type { Synced } from '@legendapp/state/sync'; 7 | 8 | export function useObservableSyncedQuery< 9 | TQueryFnData = unknown, 10 | TError = DefaultError, 11 | TData = TQueryFnData, 12 | TQueryKey extends QueryKey = QueryKey, 13 | >( 14 | params: Omit, 'queryClient'> & { 15 | queryClient?: QueryClient; 16 | }, 17 | ): Observable> { 18 | const queryClient = params.queryClient || useQueryClient(); 19 | 20 | return useObservable( 21 | syncedQuery({ 22 | ...params, 23 | queryClient, 24 | }), 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/sync/activateSyncedNode.ts: -------------------------------------------------------------------------------- 1 | import type { NodeInfo, UpdateFn } from '@legendapp/state'; 2 | import { internal, isFunction, isPromise } from '@legendapp/state'; 3 | import { syncObservable } from './syncObservable'; 4 | import type { SyncedGetParams, SyncedOptions } from './syncTypes'; 5 | const { getProxy, globalState, setNodeValue, getNodeValue } = internal; 6 | 7 | export function enableActivateSyncedNode() { 8 | globalState.activateSyncedNode = function activateSyncedNode(node: NodeInfo, newValue: any) { 9 | const obs$ = getProxy(node); 10 | if (node.activationState) { 11 | // If it is a Synced 12 | const { 13 | get: getOrig, 14 | initial, 15 | set, 16 | onChange, 17 | } = node.activationState! as NodeInfo['activationState'] & SyncedOptions; 18 | 19 | let promiseReturn: any = undefined; 20 | 21 | const get = getOrig 22 | ? (((params: SyncedGetParams) => { 23 | return (promiseReturn = getOrig!(params as any)); 24 | }) as typeof getOrig) 25 | : undefined; 26 | 27 | const nodeVal = getNodeValue(node); 28 | if (promiseReturn !== undefined) { 29 | newValue = promiseReturn; 30 | } else if (nodeVal !== undefined && !isFunction(nodeVal)) { 31 | newValue = nodeVal; 32 | } else { 33 | newValue = initial; 34 | } 35 | setNodeValue(node, promiseReturn ? undefined : newValue); 36 | 37 | syncObservable(obs$, { ...node.activationState, get, set }); 38 | 39 | return { update: onChange!, value: newValue }; 40 | } else { 41 | // If it is not a Synced 42 | 43 | let update: UpdateFn | undefined = undefined; 44 | const get = async (params: SyncedGetParams) => { 45 | update = params.refresh; 46 | if (isPromise(newValue)) { 47 | try { 48 | newValue = await newValue; 49 | } catch { 50 | // TODO Once we have global retry settings this should retry 51 | } 52 | } 53 | return newValue; 54 | }; 55 | 56 | syncObservable(obs$, { 57 | get, 58 | }); 59 | 60 | return { update: update!, value: newValue }; 61 | } 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/sync/configureObservableSync.ts: -------------------------------------------------------------------------------- 1 | import type { SyncedOptionsGlobal } from '@legendapp/state/sync'; 2 | 3 | export const observableSyncConfiguration: SyncedOptionsGlobal = {}; 4 | 5 | export function configureObservableSync(options?: SyncedOptionsGlobal) { 6 | Object.assign(observableSyncConfiguration, options); 7 | } 8 | -------------------------------------------------------------------------------- /src/sync/configureSynced.ts: -------------------------------------------------------------------------------- 1 | import { internal } from '@legendapp/state'; 2 | import type { SyncedOptions } from './syncTypes'; 3 | import { synced } from './synced'; 4 | 5 | const { deepMerge } = internal; 6 | 7 | export function configureSynced any>(fn: T, origOptions: Partial[0]>): T; 8 | export function configureSynced(origOptions: SyncedOptions): typeof synced; 9 | export function configureSynced any>( 10 | fnOrOrigOptions: T, 11 | origOptions?: Partial[0]>, 12 | ) { 13 | const fn = origOptions ? (fnOrOrigOptions as T) : synced; 14 | origOptions = origOptions ?? fnOrOrigOptions; 15 | 16 | return (options: SyncedOptions) => { 17 | const merged = deepMerge(origOptions as any, options); 18 | return fn(merged); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/sync/persistTypes.ts: -------------------------------------------------------------------------------- 1 | import type { ArrayValue, RecordValue } from '@legendapp/state'; 2 | 3 | // This converts the state object's shape to the field transformer's shape 4 | // TODO: FieldTransformer and this shape can likely be refactored to be simpler 5 | declare type ObjectKeys = Pick< 6 | T, 7 | { 8 | [K in keyof T]-?: K extends string 9 | ? T[K] extends Record 10 | ? T[K] extends any[] 11 | ? never 12 | : K 13 | : never 14 | : never; 15 | }[keyof T] 16 | >; 17 | declare type DictKeys = Pick< 18 | T, 19 | { 20 | [K in keyof T]-?: K extends string ? (T[K] extends Record> ? K : never) : never; 21 | }[keyof T] 22 | >; 23 | declare type ArrayKeys = Pick< 24 | T, 25 | { 26 | [K in keyof T]-?: K extends string | number ? (T[K] extends any[] ? K : never) : never; 27 | }[keyof T] 28 | >; 29 | export declare type FieldTransforms = 30 | | (T extends Record> ? { _dict: FieldTransformsInner> } : never) 31 | | FieldTransformsInner; 32 | export declare type FieldTransformsInner = { 33 | [K in keyof T]: string; 34 | } & ( 35 | | { 36 | [K in keyof ObjectKeys as `${K}_obj`]?: FieldTransforms; 37 | } 38 | | { 39 | [K in keyof DictKeys as `${K}_dict`]?: FieldTransforms>; 40 | } 41 | ) & { 42 | [K in keyof ArrayKeys as `${K}_arr`]?: FieldTransforms>; 43 | } & { 44 | [K in keyof ArrayKeys as `${K}_val`]?: FieldTransforms>; 45 | }; 46 | 47 | export type QueryByModified = 48 | | boolean 49 | | { 50 | [K in keyof T]?: QueryByModified; 51 | } 52 | | { 53 | '*'?: boolean; 54 | }; 55 | -------------------------------------------------------------------------------- /src/sync/retry.ts: -------------------------------------------------------------------------------- 1 | import type { RetryOptions } from '@legendapp/state'; 2 | import { isPromise } from '../is'; 3 | import type { OnErrorRetryParams, SyncedGetSetBaseParams } from './syncTypes'; 4 | 5 | function calculateRetryDelay(retryOptions: RetryOptions, retryNum: number): number | null { 6 | const { backoff, delay = 1000, infinite, times = 3, maxDelay = 30000 } = retryOptions; 7 | if (infinite || retryNum < times) { 8 | const delayTime = Math.min(delay * (backoff === 'constant' ? 1 : 2 ** retryNum), maxDelay); 9 | return delayTime; 10 | } 11 | return null; 12 | } 13 | 14 | function createRetryTimeout(retryOptions: RetryOptions, retryNum: number, fn: () => void): number | false { 15 | const delayTime = calculateRetryDelay(retryOptions, retryNum); 16 | if (delayTime) { 17 | return setTimeout(fn, delayTime) as unknown as number; 18 | } else { 19 | return false; 20 | } 21 | } 22 | 23 | const mapRetryTimeouts = new Map(); 24 | 25 | export function runWithRetry( 26 | state: SyncedGetSetBaseParams, 27 | retryOptions: RetryOptions | undefined, 28 | retryId: any, 29 | fn: (params: OnErrorRetryParams) => Promise, 30 | ): Promise; 31 | export function runWithRetry( 32 | state: SyncedGetSetBaseParams, 33 | retryOptions: RetryOptions | undefined, 34 | retryId: any, 35 | fn: (params: OnErrorRetryParams) => T, 36 | ): T; 37 | export function runWithRetry( 38 | state: SyncedGetSetBaseParams, 39 | retryOptions: RetryOptions | undefined, 40 | retryId: any, 41 | fn: (params: OnErrorRetryParams) => T | Promise, 42 | ): T | Promise { 43 | try { 44 | let value = fn(state); 45 | 46 | if (isPromise(value) && retryOptions) { 47 | let timeoutRetry: number; 48 | if (mapRetryTimeouts.has(retryId)) { 49 | clearTimeout(mapRetryTimeouts.get(retryId)); 50 | } 51 | return new Promise((resolve, reject) => { 52 | const run = () => { 53 | (value as Promise) 54 | .then((val: any) => { 55 | resolve(val); 56 | }) 57 | .catch((error: Error) => { 58 | state.retryNum++; 59 | if (timeoutRetry) { 60 | clearTimeout(timeoutRetry); 61 | } 62 | if (!state.cancelRetry) { 63 | const timeout = createRetryTimeout(retryOptions, state.retryNum, () => { 64 | value = fn(state); 65 | run(); 66 | }); 67 | 68 | if (timeout === false) { 69 | state.cancelRetry = true; 70 | reject(error); 71 | } else { 72 | mapRetryTimeouts.set(retryId, timeout); 73 | timeoutRetry = timeout; 74 | } 75 | } 76 | }); 77 | }; 78 | run(); 79 | }); 80 | } 81 | 82 | return value; 83 | } catch (error) { 84 | return Promise.reject(error); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/sync/revertChanges.ts: -------------------------------------------------------------------------------- 1 | import { applyChanges, Change, internal, ObservableParam } from '@legendapp/state'; 2 | import { onChangeRemote } from '@legendapp/state/sync'; 3 | 4 | const { clone } = internal; 5 | 6 | export function createRevertChanges(obs$: ObservableParam, changes: Change[]) { 7 | return () => { 8 | const previous = applyChanges(clone(obs$.peek()), changes, /*applyPrevious*/ true); 9 | onChangeRemote(() => { 10 | obs$.set(previous); 11 | }); 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/sync/synced.ts: -------------------------------------------------------------------------------- 1 | import { isFunction, linked } from '@legendapp/state'; 2 | import { enableActivateSyncedNode } from './activateSyncedNode'; 3 | import type { Synced, SyncedOptions } from './syncTypes'; 4 | 5 | export function synced( 6 | params: SyncedOptions | (() => TRemote), 7 | ): Synced { 8 | installPersistActivateNode(); 9 | if (isFunction(params)) { 10 | params = { get: params }; 11 | } 12 | return linked({ ...params, synced: true } as any); 13 | } 14 | 15 | let didInstall = false; 16 | function installPersistActivateNode() { 17 | if (!didInstall) { 18 | enableActivateSyncedNode(); 19 | didInstall = true; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/sync/transformObjectFields.ts: -------------------------------------------------------------------------------- 1 | import { isArray, isObject, isString, symbolDelete } from '@legendapp/state'; 2 | 3 | let validateMap: (map: Record) => void; 4 | export function transformObjectFields(dataIn: Record, map: Record) { 5 | if (process.env.NODE_ENV === 'development') { 6 | validateMap(map); 7 | } 8 | let ret = dataIn; 9 | if (dataIn) { 10 | if ((dataIn as unknown) === symbolDelete) return dataIn; 11 | if (isString(dataIn)) { 12 | return map[dataIn]; 13 | } 14 | 15 | ret = {}; 16 | 17 | const dict = Object.keys(map).length === 1 && map['_dict']; 18 | 19 | for (const key in dataIn) { 20 | let v = dataIn[key]; 21 | 22 | if (dict) { 23 | ret[key] = transformObjectFields(v, dict); 24 | } else { 25 | const mapped = map[key]; 26 | if (mapped === undefined) { 27 | // Don't transform dateModified if user doesn't want it 28 | if (key !== '@') { 29 | ret[key] = v; 30 | if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { 31 | console.error('A fatal field transformation error has occurred', key, dataIn, map); 32 | } 33 | } 34 | } else if (mapped !== null) { 35 | if (v !== undefined && v !== null) { 36 | if (map[key + '_val']) { 37 | const mapChild = map[key + '_val']; 38 | if (isArray(v)) { 39 | v = v.map((vChild) => mapChild[vChild]); 40 | } else { 41 | v = mapChild[v]; 42 | } 43 | } else if (map[key + '_arr'] && isArray(v)) { 44 | const mapChild = map[key + '_arr']; 45 | v = v.map((vChild) => transformObjectFields(vChild, mapChild)); 46 | } else if (isObject(v)) { 47 | if (map[key + '_obj']) { 48 | v = transformObjectFields(v, map[key + '_obj']); 49 | } else if (map[key + '_dict']) { 50 | const mapChild = map[key + '_dict']; 51 | const out: Record = {}; 52 | for (const keyChild in v) { 53 | out[keyChild] = transformObjectFields(v[keyChild], mapChild); 54 | } 55 | v = out; 56 | } 57 | } 58 | } 59 | ret[mapped] = v; 60 | } 61 | } 62 | } 63 | } 64 | 65 | return ret; 66 | } 67 | const invertedMaps = new WeakMap(); 68 | export function invertFieldMap(obj: Record) { 69 | const existing = invertedMaps.get(obj); 70 | if (existing) return existing; 71 | 72 | const target: Record = {} as any; 73 | 74 | for (const key in obj) { 75 | const val = obj[key]; 76 | if (key === '_dict') { 77 | target[key] = invertFieldMap(val); 78 | } else if (key.endsWith('_obj') || key.endsWith('_dict') || key.endsWith('_arr') || key.endsWith('_val')) { 79 | const keyMapped = obj[key.replace(/_obj|_dict|_arr|_val$/, '')]; 80 | const suffix = key.match(/_obj|_dict|_arr|_val$/)![0]; 81 | target[keyMapped + suffix] = invertFieldMap(val); 82 | } else if (typeof val === 'string') { 83 | target[val] = key; 84 | } 85 | } 86 | invertedMaps.set(obj, target); 87 | 88 | return target; 89 | } 90 | if (process.env.NODE_ENV === 'development') { 91 | validateMap = function (record: Record) { 92 | const values = Object.values(record).filter((value) => { 93 | if (isObject(value)) { 94 | validateMap(value); 95 | } else { 96 | return isString(value); 97 | } 98 | }); 99 | 100 | const uniques = Array.from(new Set(values)); 101 | if (values.length !== uniques.length) { 102 | console.error('Field transform map has duplicate values', record, values.length, uniques.length); 103 | } 104 | return record; 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /src/sync/waitForSet.ts: -------------------------------------------------------------------------------- 1 | import { Change, isFunction, WaitForSet, when } from '@legendapp/state'; 2 | 3 | export async function waitForSet( 4 | waitForSet: WaitForSet, 5 | changes: Change[], 6 | value: any, 7 | params: Record = {}, 8 | ) { 9 | const waitFn = isFunction(waitForSet) ? waitForSet({ changes, value, ...params }) : waitForSet; 10 | 11 | if (waitFn) { 12 | await when(waitFn); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/syncState.ts: -------------------------------------------------------------------------------- 1 | import type { ObservableSyncState } from './observableInterfaces'; 2 | import { getNode } from './globals'; 3 | import { observable } from './observable'; 4 | import type { ObservableParam } from './observableTypes'; 5 | import { ObservableHint } from './ObservableHint'; 6 | import { when } from './when'; 7 | 8 | export function syncState(obs: ObservableParam) { 9 | const node = getNode(obs); 10 | if (!node.state) { 11 | node.state = observable( 12 | ObservableHint.plain({ 13 | isPersistLoaded: false, 14 | isLoaded: false, 15 | isPersistEnabled: true, 16 | isSyncEnabled: true, 17 | isGetting: false, 18 | isSetting: false, 19 | numPendingGets: 0, 20 | numPendingSets: 0, 21 | syncCount: 0, 22 | resetPersistence: undefined as unknown as () => Promise, 23 | reset: () => Promise.resolve(), 24 | sync: () => { 25 | // sync() may be called before peek/get so check to see if it should activate 26 | obs.peek(); 27 | 28 | // If it's now activating, it should return a promise that resolves when it's loaded 29 | if (node.state?.isGetting.peek()) { 30 | return when(node.state.isLoaded) as any; 31 | } 32 | 33 | return Promise.resolve(); 34 | }, 35 | getPendingChanges: () => ({}), 36 | // TODOV3 remove 37 | clearPersist: undefined as unknown as () => Promise, 38 | }), 39 | ); 40 | } 41 | return node.state!; 42 | } 43 | -------------------------------------------------------------------------------- /src/trace/traceHelpers.ts: -------------------------------------------------------------------------------- 1 | import type { NodeInfo } from '@legendapp/state'; 2 | 3 | export function getNodePath(node: NodeInfo) { 4 | const arr: (string | number)[] = []; 5 | let n = node; 6 | while (n?.key !== undefined) { 7 | arr.splice(0, 0, n.key); 8 | n = n.parent; 9 | } 10 | return arr.join('.'); 11 | } 12 | -------------------------------------------------------------------------------- /src/trace/useTraceListeners.ts: -------------------------------------------------------------------------------- 1 | import { NodeInfo, internal, TrackingNode } from '@legendapp/state'; 2 | import { getNodePath } from './traceHelpers'; 3 | const { optimized, tracking } = internal; 4 | 5 | export function useTraceListeners(this: any, name?: string) { 6 | if ((process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') && tracking.current) { 7 | tracking.current.traceListeners = traceNodes.bind(this, name); 8 | } 9 | } 10 | 11 | function traceNodes(name: string | undefined, nodes: Map) { 12 | if ((process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') && nodes.size) { 13 | const arr: string[] = []; 14 | if (nodes) { 15 | for (const tracked of nodes.values()) { 16 | const { node, track } = tracked; 17 | const shallow = track === true; 18 | const isOptimized = track === optimized; 19 | arr.push( 20 | `${arr.length + 1}: ${getNodePath(node)}${shallow ? ' (shallow)' : ''}${ 21 | isOptimized ? ' (optimized)' : '' 22 | }`, 23 | ); 24 | } 25 | } 26 | 27 | console.log( 28 | `[legend-state] ${name ? name + ' ' : ''}tracking ${arr.length} observable${ 29 | arr.length !== 1 ? 's' : '' 30 | }:\n${arr.join('\n')}`, 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/trace/useTraceUpdates.ts: -------------------------------------------------------------------------------- 1 | import { ListenerParams, internal } from '@legendapp/state'; 2 | const { tracking } = internal; 3 | 4 | export function useTraceUpdates(name?: string) { 5 | if ((process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') && tracking.current) { 6 | tracking.current.traceUpdates = replaceUpdateFn.bind(undefined, name); 7 | } 8 | } 9 | 10 | function replaceUpdateFn(name: string | undefined, updateFn: Function) { 11 | return onChange.bind(undefined, name, updateFn); 12 | } 13 | 14 | function onChange(name: string | undefined, updateFn: Function, params: ListenerParams) { 15 | const { changes } = params; 16 | if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { 17 | changes.forEach(({ path, valueAtPath, prevAtPath }) => { 18 | console.log(`[legend-state] Rendering ${name ? name + ' ' : ''}because "${path}" changed: 19 | from: ${JSON.stringify(prevAtPath)} 20 | to: ${JSON.stringify(valueAtPath)}`); 21 | }); 22 | return updateFn(params); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/trace/useVerifyNotTracking.ts: -------------------------------------------------------------------------------- 1 | import { internal, NodeInfo, TrackingNode } from '@legendapp/state'; 2 | import { getNodePath } from './traceHelpers'; 3 | const { optimized, tracking } = internal; 4 | 5 | export function useVerifyNotTracking(this: any, name?: string) { 6 | if (process.env.NODE_ENV === 'development') { 7 | tracking.current!.traceListeners = traceNodes.bind(this, name); 8 | } 9 | } 10 | 11 | function traceNodes(name: string | undefined, nodes: Map) { 12 | if (process.env.NODE_ENV === 'development') { 13 | tracking.current!.traceListeners = undefined; 14 | const arr: string[] = []; 15 | if (nodes) { 16 | for (const tracked of nodes.values()) { 17 | const { node, track } = tracked; 18 | const shallow = track === true; 19 | const isOptimized = track === optimized; 20 | arr.push( 21 | `${arr.length + 1}: ${getNodePath(node)}${shallow ? ' (shallow)' : ''}${ 22 | isOptimized ? ' (optimized)' : '' 23 | }`, 24 | ); 25 | } 26 | console.error( 27 | `[legend-state] ${name ? name + ' ' : ''}tracking ${arr.length} observable${ 28 | arr.length !== 1 ? 's' : '' 29 | } when it should not be:\n${arr.join('\n')}`, 30 | ); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/trace/useVerifyOneRender.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | export function useVerifyOneRender(name?: string) { 4 | if (process.env.NODE_ENV === 'development') { 5 | const numRenders = ++useRef(0).current; 6 | if (numRenders > 1) { 7 | console.error(`[legend-state] ${name ? name + ' ' : ''}Component rendered more than once`); 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/trackSelector.ts: -------------------------------------------------------------------------------- 1 | import { computeSelector } from './helpers'; 2 | import type { ObserveOptions, ListenerParams, ObserveEvent, Selector, GetOptions } from './observableInterfaces'; 3 | import { setupTracking } from './setupTracking'; 4 | import { beginTracking, endTracking, tracking } from './tracking'; 5 | 6 | export function trackSelector( 7 | selector: Selector, 8 | update: (params: ListenerParams) => void, 9 | getOptions?: GetOptions, 10 | observeEvent?: ObserveEvent, 11 | observeOptions?: ObserveOptions, 12 | createResubscribe?: boolean, 13 | ) { 14 | let dispose: undefined | (() => void); 15 | let resubscribe: (() => () => void) | undefined; 16 | let updateFn = update; 17 | 18 | beginTracking(); 19 | const value = selector 20 | ? computeSelector(selector, getOptions, observeEvent, observeOptions?.fromComputed) 21 | : selector; 22 | const tracker = tracking.current; 23 | const nodes = tracker!.nodes; 24 | endTracking(); 25 | 26 | if ((process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') && tracker && nodes) { 27 | tracker.traceListeners?.(nodes); 28 | if (tracker.traceUpdates) { 29 | updateFn = tracker.traceUpdates(update) as () => void; 30 | } 31 | // Clear tracing so it doesn't leak to other components 32 | tracker.traceListeners = undefined; 33 | tracker.traceUpdates = undefined; 34 | } 35 | 36 | if (!observeEvent?.cancel) { 37 | // Do tracing if it was requested 38 | 39 | // useSyncExternalStore doesn't subscribe until after the component mount. 40 | // We want to subscribe immediately so we don't miss any updates 41 | dispose = setupTracking(nodes, updateFn, false, observeOptions?.immediate); 42 | if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { 43 | resubscribe = createResubscribe 44 | ? () => { 45 | dispose?.(); 46 | dispose = setupTracking(nodes, updateFn); 47 | return dispose; 48 | } 49 | : undefined; 50 | } 51 | } 52 | 53 | return { nodes, value, dispose, resubscribe }; 54 | } 55 | -------------------------------------------------------------------------------- /src/tracking.ts: -------------------------------------------------------------------------------- 1 | import type { NodeInfo, TrackingState, TrackingType } from './observableInterfaces'; 2 | 3 | let trackCount = 0; 4 | const trackingQueue: (TrackingState | undefined)[] = []; 5 | 6 | export const tracking = { 7 | current: undefined as TrackingState | undefined, 8 | }; 9 | 10 | export function beginTracking() { 11 | // Keep a copy of the previous tracking context so it can be restored 12 | // when this context is complete 13 | trackingQueue.push(tracking.current); 14 | trackCount++; 15 | tracking.current = {}; 16 | } 17 | export function endTracking() { 18 | // Restore the previous tracking context 19 | trackCount--; 20 | if (trackCount < 0) { 21 | trackCount = 0; 22 | } 23 | tracking.current = trackingQueue.pop(); 24 | } 25 | 26 | export function updateTracking(node: NodeInfo, track?: TrackingType) { 27 | if (trackCount) { 28 | const tracker = tracking.current; 29 | if (tracker) { 30 | if (!tracker.nodes) { 31 | tracker.nodes = new Map(); 32 | } 33 | 34 | const existing = tracker.nodes.get(node); 35 | if (existing) { 36 | existing.track = existing.track || track; 37 | existing.num++; 38 | } else { 39 | tracker.nodes.set(node, { node, track, num: 1 }); 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/types/babel.d.ts: -------------------------------------------------------------------------------- 1 | import type { ObservableParam } from '@legendapp/state'; 2 | import { ReactNode } from 'react'; 3 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 4 | import type { Computed, Memo } from '@legendapp/state/react'; 5 | declare module '@legendapp/state/react' { 6 | export declare const Computed: (props: { 7 | children: ObservableParam | (() => ReactNode) | ReactNode; 8 | }) => React.ReactElement; 9 | export declare const Memo: (props: { 10 | children: ObservableParam | (() => ReactNode) | ReactNode; 11 | }) => React.ReactElement; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/reactive-native.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | import type { IReactive } from '@legendapp/state/react'; 3 | 4 | declare module '@legendapp/state/react' { 5 | interface IReactive { 6 | ActivityIndicator: FCReactive; 7 | Button: FCReactive; 8 | FlatList: FCReactive>; 9 | Image: FCReactive; 10 | Pressable: FCReactive; 11 | ScrollView: FCReactive; 12 | SectionList: FCReactive>; 13 | Switch: FCReactive; 14 | Text: FCReactive; 15 | TextInput: FCReactive; 16 | TouchableWithoutFeedback: FCReactive; 17 | View: FCReactive; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/types/reactive-web.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | import type { IReactive, FCReactiveObject } from '@legendapp/state/react'; 3 | 4 | declare module '@legendapp/state/react' { 5 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 6 | interface IReactive extends FCReactiveObject {} 7 | } 8 | -------------------------------------------------------------------------------- /src/when.ts: -------------------------------------------------------------------------------- 1 | import { isObservable } from './globals'; 2 | import { computeSelector, isObservableValueReady } from './helpers'; 3 | import { isArray, isFunction, isPromise } from './is'; 4 | import type { ObserveEvent, Selector } from './observableInterfaces'; 5 | import { observe } from './observe'; 6 | 7 | // Modify the _when function 8 | function _when(predicate: Selector | Selector[], effect?: (value: T) => T2, checkReady?: boolean): any { 9 | // If predicate is a regular Promise skip all the observable stuff 10 | if (isPromise(predicate)) { 11 | return effect ? predicate.then(effect) : (predicate as any); 12 | } 13 | 14 | const isPredicateArray = isArray(predicate); 15 | 16 | let value: T | undefined; 17 | let effectValue: T2 | undefined; 18 | 19 | // Create a wrapping fn that calls the effect if predicate returns true 20 | function run(e: ObserveEvent) { 21 | const ret = isPredicateArray ? predicate.map((p) => computeSelector(p)) : computeSelector(predicate); 22 | 23 | if (isPromise(ret)) { 24 | value = ret as any; 25 | // We want value to be the Promise but return undefined 26 | // so it doesn't run the effect with the Promise as the value 27 | return undefined; 28 | } else { 29 | let isOk: any = true; 30 | if (isArray(ret)) { 31 | for (let i = 0; i < ret.length; i++) { 32 | let item = ret[i]; 33 | if (isObservable(item)) { 34 | item = computeSelector(item); 35 | } else if (isFunction(item)) { 36 | item = item(); 37 | } 38 | isOk = isOk && !!(checkReady ? isObservableValueReady(item) : item); 39 | } 40 | } else { 41 | isOk = checkReady ? isObservableValueReady(ret) : ret; 42 | } 43 | if (isOk) { 44 | value = ret as T; 45 | 46 | // Set cancel so that observe does not track anymore 47 | e.cancel = true; 48 | } 49 | } 50 | 51 | return value; 52 | } 53 | function doEffect() { 54 | // If value is truthy then run the effect 55 | effectValue = effect?.(value!); 56 | } 57 | // Run in an observe 58 | observe(run, doEffect); 59 | 60 | // If first run resulted in a truthy value just return it. 61 | // It will have set e.cancel so no need to dispose 62 | if (isPromise(value)) { 63 | return effect ? value.then(effect) : (value as any); 64 | } else if (value !== undefined) { 65 | return effect ? effectValue : Promise.resolve(value); 66 | } else { 67 | // Wrap it in a promise 68 | const promise = new Promise((resolve) => { 69 | if (effect) { 70 | const originalEffect = effect; 71 | effect = ((value: T) => { 72 | const effectValue = originalEffect(value); 73 | resolve(isPromise(effectValue) ? effectValue.then((value) => value as T2) : effectValue); 74 | }) as any; 75 | } else { 76 | effect = resolve as any; 77 | } 78 | }); 79 | 80 | return promise; 81 | } 82 | } 83 | 84 | export function when(predicate: Promise, effect: (value: T) => T2): Promise; 85 | export function when(predicate: Selector[], effect: (value: T[]) => T2): Promise; 86 | export function when(predicate: Selector, effect: (value: T) => T2): Promise; 87 | export function when(predicate: Selector[]): Promise; 88 | export function when(predicate: Selector): Promise; 89 | export function when(predicate: Selector, effect?: (value: T) => T2): Promise { 90 | return _when(predicate, effect, false); 91 | } 92 | 93 | export function whenReady(predicate: Promise, effect: (value: T) => T2): Promise; 94 | export function whenReady(predicate: Selector[], effect: (value: T[]) => T2): Promise; 95 | export function whenReady(predicate: Selector, effect: (value: T) => T2): Promise; 96 | export function whenReady(predicate: Selector[]): Promise; 97 | export function whenReady(predicate: Selector): Promise; 98 | export function whenReady(predicate: Selector, effect?: (value: T) => T2): Promise { 99 | return _when(predicate, effect, true); 100 | } 101 | -------------------------------------------------------------------------------- /sync.ts: -------------------------------------------------------------------------------- 1 | import type { SyncedOptionsGlobal } from './src/sync/syncTypes'; 2 | export { configureObservableSync } from './src/sync/configureObservableSync'; 3 | export * from './src/sync/persistTypes'; 4 | export * from './src/sync/syncHelpers'; 5 | export { mapSyncPlugins, onChangeRemote, syncObservable } from './src/sync/syncObservable'; 6 | export * from './src/sync/syncTypes'; 7 | export { synced } from './src/sync/synced'; 8 | export * from './src/sync/configureSynced'; 9 | export { createRevertChanges } from './src/sync/revertChanges'; 10 | 11 | import { waitForSet } from './src/sync/waitForSet'; 12 | import { observableSyncConfiguration } from './src/sync/configureObservableSync'; 13 | import { runWithRetry } from './src/sync/retry'; 14 | export const internal: { 15 | observableSyncConfiguration: SyncedOptionsGlobal; 16 | waitForSet: typeof waitForSet; 17 | runWithRetry: typeof runWithRetry; 18 | } = { 19 | observableSyncConfiguration, 20 | waitForSet, 21 | runWithRetry, 22 | }; 23 | -------------------------------------------------------------------------------- /testbundles/bundlecore.js: -------------------------------------------------------------------------------- 1 | import { observable } from '../dist/index.mjs'; 2 | 3 | observable(0); 4 | -------------------------------------------------------------------------------- /testbundles/bundlereact.js: -------------------------------------------------------------------------------- 1 | import { observable } from '../dist/index.mjs'; 2 | import { useSelector } from '../dist/react.mjs'; 3 | 4 | observable(0); 5 | useSelector(); 6 | -------------------------------------------------------------------------------- /testbundles/bundlesync.js: -------------------------------------------------------------------------------- 1 | import { observable } from '../dist/index.mjs'; 2 | import { syncObservable } from '../dist/sync.mjs'; 3 | 4 | observable(0); 5 | syncObservable(); 6 | -------------------------------------------------------------------------------- /tests/happydom.ts: -------------------------------------------------------------------------------- 1 | import { GlobalRegistrator } from '@happy-dom/global-registrator'; 2 | 3 | GlobalRegistrator.register(); 4 | -------------------------------------------------------------------------------- /tests/perf.test.ts: -------------------------------------------------------------------------------- 1 | import { ObservableHint } from '../src/ObservableHint'; 2 | import { linked } from '../src/linked'; 3 | import { observable } from '../src/observable'; 4 | 5 | describe('Perf', () => { 6 | test('Array perf', () => { 7 | const obs = observable({ arr: [] as { id: number; value: number }[] }); 8 | for (let i = 0; i < 10000; i++) { 9 | obs.arr[i].set({ id: i, value: i }); 10 | obs.arr[i].onChange(() => {}); 11 | } 12 | const now = performance.now(); 13 | obs.arr.splice(1, 1); 14 | const then = performance.now(); 15 | 16 | expect(then - now).toBeLessThan(process.env.CI === 'true' ? 100 : 30); 17 | }); 18 | test('Lazy activation perf', () => { 19 | const obj: Record = {}; 20 | const Num = 10000; 21 | for (let i = 0; i < Num; i++) { 22 | obj['key' + i] = { 23 | child: { 24 | grandchild: { 25 | value: 'hi', 26 | }, 27 | }, 28 | }; 29 | } 30 | 31 | const obs = observable(obj); 32 | 33 | const now = performance.now(); 34 | obs.get(); 35 | const then = performance.now(); 36 | 37 | expect(then - now).toBeLessThan(process.env.CI === 'true' ? 100 : 25); 38 | }); 39 | test('Lazy activation perf with plain hint', () => { 40 | const obj: Record = {}; 41 | const Num = 10000; 42 | for (let i = 0; i < Num; i++) { 43 | obj['key' + i] = { 44 | child: { 45 | grandchild: { 46 | value: 'hi', 47 | }, 48 | }, 49 | }; 50 | } 51 | 52 | const obs = observable(ObservableHint.plain(obj)); 53 | 54 | const now = performance.now(); 55 | obs.get(); 56 | const then = performance.now(); 57 | 58 | expect(then - now).toBeLessThan(1); 59 | }); 60 | test('Lazy activation perf2', () => { 61 | const obj: Record = {}; 62 | const Num = 10000; 63 | let numCalled = 0; 64 | for (let i = 0; i < Num; i++) { 65 | obj['key' + i] = { 66 | child: { 67 | grandchild: { 68 | value: 'hi', 69 | fn: () => { 70 | numCalled++; 71 | return 'test'; 72 | }, 73 | }, 74 | }, 75 | }; 76 | } 77 | 78 | const obs = observable(obj); 79 | 80 | const now = performance.now(); 81 | obs.get(); 82 | const then = performance.now(); 83 | 84 | expect(numCalled).toEqual(0); 85 | expect(then - now).toBeLessThan(process.env.CI === 'true' ? 100 : 25); 86 | }); 87 | test('Lazy activation perf3', () => { 88 | const obj: Record = {}; 89 | const Num = 1000; 90 | let numCalled = 0; 91 | let numActivated = 0; 92 | for (let i = 0; i < Num; i++) { 93 | obj['key' + i] = { 94 | child: { 95 | grandchild: { 96 | value: 'hi', 97 | fn: () => { 98 | numCalled++; 99 | return 'test'; 100 | }, 101 | arr: [ 102 | { 103 | link: linked({ 104 | get: () => { 105 | numActivated++; 106 | return 'got'; 107 | }, 108 | }), 109 | }, 110 | ], 111 | }, 112 | }, 113 | }; 114 | } 115 | 116 | const obs = observable(obj); 117 | 118 | const now = performance.now(); 119 | obs.get(); 120 | const then = performance.now(); 121 | 122 | expect(numCalled).toEqual(0); 123 | expect(numActivated).toEqual(Num); 124 | expect(then - now).toBeLessThan(process.env.CI === 'true' ? 400 : 300); 125 | 126 | const now2 = performance.now(); 127 | obs.get(); 128 | const then2 = performance.now(); 129 | 130 | expect(then2 - now2).toBeLessThan(process.env.CI === 'true' ? 5 : 1); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /tests/sync.test.ts: -------------------------------------------------------------------------------- 1 | import { observable, syncState } from '@legendapp/state'; 2 | import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; 3 | import { BasicValue, getPersistName, ObservablePersistLocalStorage, promiseTimeout } from './testglobals'; 4 | 5 | jest?.setTimeout?.(1000); 6 | 7 | const ItemBasicValue: () => BasicValue = () => ({ 8 | id: 'id1', 9 | test: 'hi', 10 | }); 11 | 12 | describe('sync()', () => { 13 | test('sync() triggers sync', async () => { 14 | const persistName = getPersistName(); 15 | let numLists = 0; 16 | const serverState = [{ ...ItemBasicValue(), updatedAt: 1 }]; 17 | const obs$ = observable>( 18 | syncedCrud({ 19 | list: () => { 20 | numLists++; 21 | return promiseTimeout(0, serverState); 22 | }, 23 | create: async (input) => { 24 | return { ...input, updatedAt: 2 }; 25 | }, 26 | as: 'object', 27 | fieldUpdatedAt: 'updatedAt', 28 | changesSince: 'last-sync', 29 | persist: { 30 | name: persistName, 31 | plugin: ObservablePersistLocalStorage, 32 | }, 33 | }), 34 | ); 35 | 36 | const state$ = syncState(obs$); 37 | 38 | expect(numLists).toEqual(0); 39 | 40 | obs$.get(); 41 | 42 | expect(numLists).toEqual(1); 43 | 44 | const doSync = () => { 45 | serverState[0].updatedAt++; 46 | state$.sync(); 47 | }; 48 | doSync(); 49 | expect(numLists).toEqual(2); 50 | doSync(); 51 | expect(numLists).toEqual(3); 52 | doSync(); 53 | expect(numLists).toEqual(4); 54 | }); 55 | test('sync() triggers sync without needing a get', async () => { 56 | const persistName = getPersistName(); 57 | let numLists = 0; 58 | const serverState = [{ ...ItemBasicValue(), updatedAt: 1 }]; 59 | const obs$ = observable>( 60 | syncedCrud({ 61 | list: () => { 62 | numLists++; 63 | return promiseTimeout(0, serverState); 64 | }, 65 | create: async (input) => { 66 | return { ...input, updatedAt: 2 }; 67 | }, 68 | as: 'object', 69 | fieldUpdatedAt: 'updatedAt', 70 | changesSince: 'last-sync', 71 | persist: { 72 | name: persistName, 73 | plugin: ObservablePersistLocalStorage, 74 | }, 75 | }), 76 | ); 77 | 78 | const state$ = syncState(obs$); 79 | 80 | expect(numLists).toEqual(0); 81 | 82 | const doSync = () => { 83 | serverState[0].updatedAt++; 84 | state$.sync(); 85 | }; 86 | doSync(); 87 | expect(numLists).toEqual(1); 88 | doSync(); 89 | expect(numLists).toEqual(2); 90 | doSync(); 91 | expect(numLists).toEqual(3); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /tests/synced.test.ts: -------------------------------------------------------------------------------- 1 | import { observable, observe } from '@legendapp/state'; 2 | import { synced } from '@legendapp/state/sync'; 3 | import { promiseTimeout } from './testglobals'; 4 | 5 | describe('unsubscribe', () => { 6 | test('Canceling observe unsubscribes', async () => { 7 | let numObserves = 0; 8 | let numSubscribes = 0; 9 | let numUnsubscribes = 0; 10 | 11 | const obs$ = observable( 12 | synced({ 13 | get: () => 'foo', 14 | subscribe: () => { 15 | numSubscribes++; 16 | 17 | return () => { 18 | numUnsubscribes++; 19 | }; 20 | }, 21 | }), 22 | ); 23 | 24 | const unsubscribe = observe(() => { 25 | numObserves++; 26 | obs$.get(); 27 | }); 28 | 29 | expect(numObserves).toEqual(1); 30 | expect(numSubscribes).toEqual(1); 31 | expect(numUnsubscribes).toEqual(0); 32 | 33 | unsubscribe(); 34 | 35 | await promiseTimeout(0); 36 | 37 | expect(numObserves).toEqual(1); 38 | expect(numSubscribes).toEqual(1); 39 | expect(numUnsubscribes).toEqual(1); 40 | 41 | obs$.get(); 42 | 43 | expect(numObserves).toEqual(1); 44 | expect(numSubscribes).toEqual(1); 45 | expect(numUnsubscribes).toEqual(1); 46 | 47 | observe(() => { 48 | numObserves++; 49 | obs$.get(); 50 | }); 51 | 52 | await promiseTimeout(0); 53 | 54 | expect(numObserves).toEqual(2); 55 | expect(numSubscribes).toEqual(2); 56 | expect(numUnsubscribes).toEqual(1); 57 | }); 58 | }); 59 | 60 | describe('synced', () => { 61 | test('observing synced linked observable', () => { 62 | const obs$ = observable({ 63 | count: synced({ 64 | initial: 0, 65 | }), 66 | total: (): number => { 67 | return obs$.count as any; 68 | }, 69 | }); 70 | 71 | let total = 0; 72 | observe(() => { 73 | total = obs$.total.get(); 74 | }); 75 | 76 | expect(total).toEqual(0); 77 | 78 | obs$.count.set(1); 79 | 80 | expect(total).toEqual(1); 81 | }); 82 | 83 | test('observing synced array length', () => { 84 | const obs$ = observable({ 85 | arr: synced({ 86 | initial: [ 87 | { id: 1, text: 'a' }, 88 | { id: 2, text: 'b' }, 89 | { id: 3, text: 'c' }, 90 | ], 91 | }), 92 | total: (): number => { 93 | return obs$.arr.length; 94 | }, 95 | }); 96 | 97 | let total = 0; 98 | observe(() => { 99 | total = obs$.total.get(); 100 | }); 101 | 102 | expect(total).toEqual(3); 103 | 104 | obs$.arr.push({ id: 4, text: 'd' }); 105 | 106 | expect(total).toEqual(4); 107 | }); 108 | test('observing synced array length 2', () => { 109 | const obs$ = observable({ 110 | arr: synced({ 111 | initial: [ 112 | { id: 1, text: 'a' }, 113 | { id: 2, text: 'b' }, 114 | { id: 3, text: 'c' }, 115 | ], 116 | }), 117 | total: () => { 118 | return obs$.arr; 119 | }, 120 | }); 121 | 122 | let total = 0; 123 | observe(() => { 124 | total = obs$.total.length; 125 | }); 126 | 127 | expect(total).toEqual(3); 128 | 129 | obs$.arr.push({ id: 4, text: 'd' }); 130 | 131 | expect(total).toEqual(4); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /tests/testglobals.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { ObservablePersistLocalStorageBase } from '../src/persist-plugins/local-storage'; 3 | import type { Change, TrackingType } from '../src/observableInterfaces'; 4 | import type { Observable } from '../src/observableTypes'; 5 | 6 | export interface BasicValue { 7 | id: string; 8 | test: string; 9 | createdAt?: string | number | null; 10 | updatedAt?: string | number | null; 11 | parent?: { 12 | child: { 13 | baby: string; 14 | }; 15 | }; 16 | } 17 | export interface BasicValue2 { 18 | id: string; 19 | test?: string; 20 | test2: string; 21 | createdAt?: string | number | null; 22 | updatedAt?: string | number | null; 23 | } 24 | 25 | export function mockLocalStorage() { 26 | class LocalStorageMock { 27 | store: Record; 28 | constructor() { 29 | this.store = {}; 30 | } 31 | clear() { 32 | this.store = {}; 33 | } 34 | getItem(key: string) { 35 | return this.store[key] || null; 36 | } 37 | setItem(key: string, value: any) { 38 | this.store[key] = String(value); 39 | } 40 | removeItem(key: string) { 41 | delete this.store[key]; 42 | } 43 | } 44 | return new LocalStorageMock() as unknown as Storage; 45 | } 46 | 47 | export function promiseTimeout(time?: number, value?: T) { 48 | return new Promise((resolve) => setTimeout(() => resolve(value!), time || 0)); 49 | } 50 | 51 | let localNum = 0; 52 | export const getPersistName = () => 'jestlocal' + localNum++; 53 | 54 | export function expectChangeHandler(value$: Observable, track?: TrackingType) { 55 | const ret = jest.fn(); 56 | 57 | function handler({ value, getPrevious, changes }: { value: any; getPrevious: () => any; changes: Change[] }) { 58 | const prev = getPrevious(); 59 | 60 | ret(value, prev, changes); 61 | } 62 | 63 | value$.onChange(handler, { trackingType: track }); 64 | 65 | return ret; 66 | } 67 | 68 | export const localStorage = mockLocalStorage(); 69 | export class ObservablePersistLocalStorage extends ObservablePersistLocalStorageBase { 70 | constructor() { 71 | super(localStorage); 72 | } 73 | } 74 | 75 | export function supressActWarning(fn: () => void) { 76 | const originalError = console.error; 77 | console.error = (...args) => { 78 | if (/act/.test(args[0])) { 79 | return; 80 | } 81 | originalError.call(console, ...args); 82 | }; 83 | 84 | fn(); 85 | 86 | console.error = originalError; 87 | } 88 | 89 | export function expectLog(fn: () => any, msg: string, logType: 'log' | 'warn' | 'error', expecter: (prop: any) => any) { 90 | jest.spyOn(console, logType).mockImplementation(() => {}); 91 | fn(); 92 | expecter(console[logType]).toHaveBeenCalledWith(msg); 93 | (console[logType] as jest.Mock).mockRestore(); 94 | } 95 | -------------------------------------------------------------------------------- /tests/tracking.test.ts: -------------------------------------------------------------------------------- 1 | import { event } from '../src/event'; 2 | import { observable } from '../src/observable'; 3 | import { beginTracking, endTracking, tracking } from '../src/tracking'; 4 | 5 | beforeEach(() => { 6 | beginTracking(); 7 | }); 8 | afterEach(() => { 9 | endTracking(); 10 | }); 11 | 12 | describe('Tracking', () => { 13 | test('get() observes', () => { 14 | const obs = observable({ test: { test2: { test3: 'hi' } } }); 15 | obs.test.test2.test3.get(); 16 | 17 | expect(tracking.current!.nodes!.size).toEqual(1); 18 | }); 19 | test('peek() does not observe', () => { 20 | const obs = observable({ test: { test2: { test3: 'hi' } } }); 21 | obs.test.test2.test3.peek(); 22 | 23 | expect(tracking.current?.nodes).toEqual(undefined); 24 | }); 25 | test('set() does not observe', () => { 26 | const obs = observable({ test: { test2: { test3: 'hi' } } }); 27 | 28 | obs.test.test2.test3.set('hello'); 29 | 30 | expect(tracking.current?.nodes).toEqual(undefined); 31 | }); 32 | test('primitive access observes', () => { 33 | const obs = observable({ test: 'hi' }); 34 | obs.test.get(); 35 | 36 | expect(tracking.current!.nodes!.size).toEqual(1); 37 | }); 38 | test('object access does not observe', () => { 39 | const obs = observable({ test: { text: 'hi' } }); 40 | obs.test; 41 | 42 | expect(tracking.current?.nodes).toEqual(undefined); 43 | }); 44 | test('get() observes2', () => { 45 | const obs = observable({ test: { text: 'hi' } }); 46 | 47 | obs.test.get(); 48 | 49 | expect(tracking.current!.nodes!.size).toEqual(1); 50 | 51 | const nodes = [...tracking.current!.nodes!.values()]; 52 | expect(nodes[0].track).toEqual(undefined); 53 | }); 54 | test('get() shallow', () => { 55 | const obs = observable({ test: { text: 'hi' } }); 56 | 57 | obs.test.get(true); 58 | 59 | expect(tracking.current!.nodes!.size).toEqual(1); 60 | 61 | const nodes = [...tracking.current!.nodes!.values()]; 62 | expect(nodes[0].track).toEqual(true); 63 | }); 64 | test('primitive get access observes', () => { 65 | const obs = observable({ test: 'hi' }); 66 | obs.test.get(); 67 | 68 | expect(tracking.current!.nodes!.size).toEqual(1); 69 | }); 70 | test('Object.keys(obs) observes shallow', () => { 71 | const obs = observable({ test: { text: 'hi' } }); 72 | 73 | Object.keys(obs); 74 | 75 | expect(tracking.current!.nodes!.size).toEqual(1); 76 | 77 | const nodes = [...tracking.current!.nodes!.values()]; 78 | 79 | expect(nodes[0].node.key).toEqual(undefined); 80 | expect(nodes[0].track).toEqual(true); 81 | }); 82 | test('Object.entries(obs) observes shallow', () => { 83 | const obs = observable({ test: { text: 'hi' } }); 84 | 85 | Object.entries(obs); 86 | 87 | expect(tracking.current!.nodes!.size).toEqual(1); 88 | 89 | const nodes = [...tracking.current!.nodes!.values()]; 90 | 91 | expect(nodes[0].node.key).toEqual(undefined); 92 | expect(nodes[0].track).toEqual(true); 93 | }); 94 | test('Accessing undefined observes', () => { 95 | const obs = observable({ test: {} as Record }); 96 | 97 | obs.test['a'].get(); 98 | 99 | expect(tracking.current!.nodes!.size).toEqual(1); 100 | 101 | const nodes = [...tracking.current!.nodes!.values()]; 102 | 103 | expect(nodes[0].node.key).toEqual('a'); 104 | }); 105 | test('get() an event observes', () => { 106 | const evt = event(); 107 | 108 | evt.get(); 109 | 110 | expect(tracking.current!.nodes!.size).toEqual(1); 111 | }); 112 | test('Array map observes arary', () => { 113 | const obs = observable({ 114 | arr: [ 115 | { id: 1, text: 'hi1' }, 116 | { id: 2, text: 'hi2' }, 117 | ], 118 | }); 119 | 120 | obs.arr.map((it) => it); 121 | 122 | expect(tracking.current!.nodes!.size).toEqual(1); 123 | 124 | const nodes = [...tracking.current!.nodes!.values()]; 125 | 126 | expect(nodes[0].node.key).toEqual('arr'); 127 | }); 128 | test('Array length observes array shallow', () => { 129 | const obs = observable({ 130 | arr: [{ id: 1, text: 'hi1' }], 131 | }); 132 | 133 | obs.arr.length; 134 | 135 | expect(tracking.current!.nodes!.size).toEqual(1); 136 | 137 | const nodes = [...tracking.current!.nodes!.values()]; 138 | 139 | expect(nodes[0].node.key).toEqual('arr'); 140 | expect(nodes[0].track).toEqual(true); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /trace.ts: -------------------------------------------------------------------------------- 1 | export * from './src/trace/useTraceListeners'; 2 | export * from './src/trace/useTraceUpdates'; 3 | export * from './src/trace/useVerifyNotTracking'; 4 | export * from './src/trace/useVerifyOneRender'; 5 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ES2015", 5 | "outDir": "dist/esm" 6 | } 7 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es2018", 5 | "module": "ES2015", 6 | "lib": [ 7 | "dom", 8 | "dom.iterable", 9 | "esnext" 10 | ], 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "incremental": true, 16 | "tsBuildInfoFile": "./tsconfig.tsbuildinfo", // Specify the build info file 17 | "esModuleInterop": true, 18 | "moduleResolution": "node", 19 | "isolatedModules": true, 20 | "jsx": "preserve", 21 | "stripInternal": true, 22 | "baseUrl": ".", 23 | "rootDirs": [ 24 | "./src", 25 | ], 26 | "declaration": true, 27 | "declarationMap": false, 28 | "sourceMap": true, 29 | "paths": { 30 | "react": [ 31 | "node_modules/react" 32 | ], 33 | "react-native": [ 34 | "node_modules/react-native" 35 | ], 36 | "react-native-mmkv": [ 37 | "node_modules/react-native-mmkv" 38 | ], 39 | "next": [ 40 | "node_modules/next" 41 | ], 42 | "next/router": [ 43 | "node_modules/next/router" 44 | ], 45 | "@babel/types": [ 46 | "node_modules/@babel/types" 47 | ], 48 | "@tanstack/react-query": [ 49 | "node_modules/@tanstack/react-query" 50 | ], 51 | "firebase/auth": [ 52 | "node_modules/firebase/auth" 53 | ], 54 | "firebase/database": [ 55 | "node_modules/firebase/database" 56 | ], 57 | "@legendapp/state": [ 58 | "index" 59 | ], 60 | "@legendapp/state/config/*": [ 61 | "src/config/*" 62 | ], 63 | "@legendapp/state/persist": [ 64 | "persist" 65 | ], 66 | "@legendapp/state/sync": [ 67 | "sync" 68 | ], 69 | "@legendapp/state/react": [ 70 | "react" 71 | ], 72 | "@legendapp/state/helpers/fetch": [ 73 | "src/helpers/fetch" 74 | ], 75 | "@legendapp/state/sync-plugins/crud": [ 76 | "src/sync-plugins/crud" 77 | ], 78 | "@legendapp/state/sync-plugins/tanstack-query": [ 79 | "src/sync-plugins/tanstack-query" 80 | ], 81 | "@legendapp/state/react-reactive/enableReactive": [ 82 | "src/react-reactive/enableReactive" 83 | ], 84 | "@legendapp/state/react-reactive/enableReactComponents": [ 85 | "src/react-reactive/enableReactComponents" 86 | ], 87 | "@legendapp/state/react-reactive/enableReactNativeComponents": [ 88 | "src/react-reactive/enableReactNativeComponents" 89 | ] 90 | }, 91 | "resolveJsonModule": true, 92 | "noEmit": true 93 | }, 94 | "include": [ 95 | "**/*.ts", 96 | "**/*.tsx" 97 | ], 98 | "exclude": [ 99 | "node_modules", 100 | "dist", 101 | "types.d.ts", 102 | "src/types" 103 | ], 104 | } -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs'; 3 | import { defineConfig } from 'tsup'; 4 | // @ts-expect-error It says import assertions don't work, but they do 5 | import pkg from './package.json' assert { type: 'json' }; 6 | 7 | const Exclude = new Set(['.DS_Store']); 8 | 9 | const external = [ 10 | '@babel/types', 11 | 'next', 12 | 'next/router', 13 | 'react', 14 | 'react-native', 15 | 'react-native-mmkv', 16 | '@react-native-async-storage/async-storage', 17 | '@tanstack/react-query', 18 | '@tanstack/query-core', 19 | '@legendapp/state', 20 | '@legendapp/state/config', 21 | '@legendapp/state/persist', 22 | '@legendapp/state/sync', 23 | '@legendapp/state/sync-plugins/crud', 24 | '@legendapp/state/sync-plugins/tanstack-query', 25 | '@legendapp/state/react', 26 | '@legendapp/state/helpers/fetch', 27 | '@legendapp/state/react-reactive/enableReactive', 28 | 'firebase/auth', 29 | 'firebase/database', 30 | ]; 31 | 32 | const keys = pkg['lsexports'] 33 | .filter((exp) => !exp.endsWith('.d.ts')) 34 | .flatMap((exp) => { 35 | if (exp === '.') { 36 | exp = 'index'; 37 | } 38 | if (exp.endsWith('/*')) { 39 | const expPath = exp.replace('/*', ''); 40 | 41 | const files = fs.readdirSync(path.join('src', expPath)); 42 | const mapped = files.map((file) => !Exclude.has(file) && `src/${expPath}/${file}`); 43 | return mapped; 44 | } else { 45 | return exp + '.ts'; 46 | } 47 | }) as string[]; 48 | 49 | const entry: Record = {}; 50 | keys.forEach((key) => { 51 | entry[key.replace('src/', '').replace('.ts', '')] = key; 52 | }); 53 | 54 | export default defineConfig({ 55 | entry, 56 | format: ['cjs', 'esm'], 57 | external, 58 | dts: true, 59 | treeshake: true, 60 | splitting: false, 61 | clean: true, 62 | }); 63 | --------------------------------------------------------------------------------