1// Copyright (C) 2023 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import {Draft} from 'immer'; 16import {createStore} from './store'; 17import {exists} from './utils'; 18 19interface Bar { 20 value: number; 21} 22 23interface Foo { 24 counter: number; 25 nested: Bar; 26} 27 28function migrateFoo(init: unknown): Foo { 29 const migrated: Foo = { 30 counter: 123, 31 nested: { 32 value: 456, 33 }, 34 }; 35 if (exists(init) && typeof init === 'object') { 36 if ('counter' in init && typeof init.counter === 'number') { 37 migrated.counter = init.counter; 38 } 39 if ('nested' in init && typeof init.nested === 'object' && init.nested) { 40 if ('value' in init.nested && typeof init.nested.value === 'number') { 41 migrated.nested.value = init.nested.value; 42 } 43 } 44 } 45 46 console.log('migrating', init); 47 48 return migrated; 49} 50 51interface State { 52 foo: Foo; 53} 54 55const initialState: State = { 56 foo: { 57 counter: 0, 58 nested: { 59 value: 42, 60 }, 61 }, 62}; 63 64describe('root store', () => { 65 test('edit', () => { 66 const store = createStore(initialState); 67 store.edit((draft) => { 68 draft.foo.counter += 123; 69 }); 70 71 expect(store.state).toEqual({ 72 foo: { 73 counter: 123, 74 nested: { 75 value: 42, 76 }, 77 }, 78 }); 79 }); 80 81 test('state [in]equality', () => { 82 const store = createStore(initialState); 83 store.edit((draft) => { 84 draft.foo.counter = 88; 85 }); 86 expect(store.state).not.toBe(initialState); 87 expect(store.state.foo).not.toBe(initialState.foo); 88 expect(store.state.foo.nested).toBe(initialState.foo.nested); 89 }); 90 91 it('can take multiple edits at once', () => { 92 const store = createStore(initialState); 93 const callback = jest.fn(); 94 95 store.subscribe(callback); 96 97 store.edit([ 98 (draft) => { 99 draft.foo.counter += 10; 100 }, 101 (draft) => { 102 draft.foo.counter += 10; 103 }, 104 ]); 105 106 expect(callback).toHaveBeenCalledTimes(1); 107 expect(callback).toHaveBeenCalledWith(store, initialState); 108 expect(store.state).toEqual({ 109 foo: { 110 counter: 20, 111 nested: { 112 value: 42, 113 }, 114 }, 115 }); 116 }); 117 118 it('can support a huge number of edits', () => { 119 const store = createStore(initialState); 120 const N = 100_000; 121 const edits = Array(N).fill((draft: Draft<State>) => { 122 draft.foo.counter++; 123 }); 124 store.edit(edits); 125 expect(store.state.foo.counter).toEqual(N); 126 }); 127 128 it('notifies subscribers', () => { 129 const store = createStore(initialState); 130 const callback = jest.fn(); 131 132 store.subscribe(callback); 133 134 store.edit((draft) => { 135 draft.foo.counter += 1; 136 }); 137 138 expect(callback).toHaveBeenCalledTimes(1); 139 expect(callback).toHaveBeenCalledWith(store, initialState); 140 }); 141 142 it('does not notify unsubscribed subscribers', () => { 143 const store = createStore(initialState); 144 const callback = jest.fn(); 145 146 // Subscribe then immediately unsubscribe 147 store.subscribe(callback)[Symbol.dispose](); 148 149 // Make an arbitrary edit 150 store.edit((draft) => { 151 draft.foo.counter += 1; 152 }); 153 154 expect(callback).not.toHaveBeenCalled(); 155 }); 156}); 157 158describe('sub-store', () => { 159 test('edit', () => { 160 const store = createStore(initialState); 161 const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo); 162 163 subStore.edit((draft) => { 164 draft.counter += 1; 165 }); 166 167 expect(subStore.state).toEqual({ 168 counter: 1, 169 nested: { 170 value: 42, 171 }, 172 }); 173 174 expect(store.state).toEqual({ 175 foo: { 176 counter: 1, 177 nested: { 178 value: 42, 179 }, 180 }, 181 }); 182 }); 183 184 test('edit from root store', () => { 185 const store = createStore(initialState); 186 const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo); 187 188 store.edit((draft) => { 189 draft.foo.counter += 1; 190 }); 191 192 expect(subStore.state).toEqual({ 193 counter: 1, 194 nested: { 195 value: 42, 196 }, 197 }); 198 }); 199 200 it('can create more substores and edit', () => { 201 const store = createStore(initialState); 202 const fooState = store.createSubStore<Foo>(['foo'], (x) => x as Foo); 203 const nestedStore = fooState.createSubStore<Bar>( 204 ['nested'], 205 (x) => x as Bar, 206 ); 207 208 nestedStore.edit((draft) => { 209 draft.value += 1; 210 }); 211 212 expect(nestedStore.state).toEqual({ 213 value: 43, 214 }); 215 }); 216 217 it('notifies subscribers', () => { 218 const store = createStore(initialState); 219 const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo); 220 const callback = jest.fn(); 221 222 subStore.subscribe(callback); 223 224 subStore.edit((draft) => { 225 draft.counter += 1; 226 }); 227 228 expect(callback).toHaveBeenCalledTimes(1); 229 expect(callback).toHaveBeenCalledWith(subStore, initialState.foo); 230 }); 231 232 it('does not notify unsubscribed subscribers', () => { 233 const store = createStore(initialState); 234 const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo); 235 const callback = jest.fn(); 236 237 // Subscribe then immediately unsubscribe 238 subStore.subscribe(callback)[Symbol.dispose](); 239 240 // Make an arbitrary edit 241 subStore.edit((draft) => { 242 draft.counter += 1; 243 }); 244 245 expect(callback).not.toHaveBeenCalled(); 246 }); 247 248 it('handles reading when path doesn\t exist in root store', () => { 249 const store = createStore(initialState); 250 251 // This target node is missing - baz doesn't exist in State 252 const subStore = store.createSubStore<Foo>(['baz'], (x) => x as Foo); 253 expect(subStore.state).toBe(undefined); 254 }); 255 256 it("handles edit when path doesn't exist in root store", () => { 257 const store = createStore(initialState); 258 const value: Foo = { 259 counter: 123, 260 nested: { 261 value: 456, 262 }, 263 }; 264 265 // This target node is missing - baz doesn't exist in State 266 const subStore = store.createSubStore<Foo>(['baz', 'quux'], () => value); 267 268 // Edits should work just fine, but the root store will not be modified. 269 subStore.edit((draft) => { 270 draft.counter += 1; 271 }); 272 }); 273 274 it('check subscriber only called once when edits made to undefined root path', () => { 275 const store = createStore(initialState); 276 const value: Foo = { 277 counter: 123, 278 nested: { 279 value: 456, 280 }, 281 }; 282 283 const callback = jest.fn(); 284 285 // This target node is missing - baz doesn't exist in State 286 const subStore = store.createSubStore<Foo>(['baz', 'quux'], () => value); 287 subStore.subscribe(callback); 288 289 // Edits should work just fine, but the root store will not be modified. 290 subStore.edit((draft) => { 291 draft.counter += 1; 292 }); 293 294 expect(callback).toHaveBeenCalledTimes(1); 295 expect(callback).toHaveBeenCalledWith(subStore, value); 296 }); 297 298 it("notifies subscribers even when path doesn't exist in root store", () => { 299 const store = createStore(initialState); 300 const value: Foo = { 301 counter: 123, 302 nested: { 303 value: 456, 304 }, 305 }; 306 const subStore = store.createSubStore<Foo>(['baz', 'quux'], () => value); 307 308 const callback = jest.fn(); 309 subStore.subscribe(callback); 310 311 subStore.edit((draft) => { 312 draft.counter += 1; 313 }); 314 315 expect(callback).toHaveBeenCalledTimes(1); 316 expect(callback).toHaveBeenCalledWith(subStore, value); 317 }); 318 319 it('notifies when relevant edits are made from root store', () => { 320 const store = createStore(initialState); 321 const subStore = store.createSubStore<Foo>(['foo'], (x) => x as Foo); 322 const callback = jest.fn(); 323 324 // Subscribe on the proxy store 325 subStore.subscribe(callback); 326 327 // Edit the subtree from the root store 328 store.edit((draft) => { 329 draft.foo.counter++; 330 }); 331 332 // Expect proxy callback called with correct subtree 333 expect(callback).toHaveBeenCalledTimes(1); 334 expect(callback).toHaveBeenCalledWith(subStore, initialState.foo); 335 }); 336 337 it('ignores irrelevant edits from the root store', () => { 338 const store = createStore(initialState); 339 const nestedStore = store.createSubStore<Bar>( 340 ['foo', 'nested'], 341 (x) => x as Bar, 342 ); 343 const callback = jest.fn(); 344 345 // Subscribe on the proxy store 346 nestedStore.subscribe(callback); 347 348 // Edit an irrelevant subtree on the root store 349 store.edit((draft) => { 350 draft.foo.counter++; 351 }); 352 353 // Ensure proxy callback hasn't been called 354 expect(callback).not.toHaveBeenCalled(); 355 }); 356 357 it('immutable [in]equality works', () => { 358 const store = createStore(initialState); 359 const subStore = store.createSubStore<Foo>(['foo'], migrateFoo); 360 const before = subStore.state; 361 362 subStore.edit((draft) => { 363 draft.counter += 1; 364 }); 365 366 const after = subStore.state; 367 368 // something has changed so root should not equal 369 expect(before).not.toBe(after); 370 371 // nested has not changed and so should be the before version. 372 expect(before.nested).toBe(after.nested); 373 }); 374 375 // This test depends on the migrate function - if it attempts to preserve 376 // equality then we might have a chance, but our migrate function here does 377 // not, and I'm not sure we can expect people do provide one that does. 378 // TODO(stevegolton): See if we can get this working, regardless of migrate 379 // function implementation. 380 it.skip('unrelated state refs are still equal when modified from root store', () => { 381 const store = createStore(initialState); 382 const subStore = store.createSubStore<Foo>(['foo'], migrateFoo); 383 const before = subStore.state; 384 385 // Check that unrelated state is still the same even though subtree is 386 // modified from the root store 387 store.edit((draft) => { 388 draft.foo.counter = 1234; 389 }); 390 391 expect(before.nested).toBe(subStore.state.nested); 392 expect(subStore.state.counter).toBe(1234); 393 }); 394 395 it('works when underlying state is undefined', () => { 396 interface RootState { 397 dict: {[key: string]: unknown}; 398 } 399 interface ProxyState { 400 bar: string; 401 } 402 403 const store = createStore<RootState>({dict: {}}); 404 const migrate = (init: unknown) => (init ?? {bar: 'bar'}) as ProxyState; 405 const subStore = store.createSubStore(['dict', 'foo'], migrate); 406 407 // Check initial migration works, yet underlying store is untouched 408 expect(subStore.state.bar).toBe('bar'); 409 expect(store.state.dict['foo']).toBe(undefined); 410 411 // Check updates work 412 subStore.edit((draft) => { 413 draft.bar = 'baz'; 414 }); 415 expect(subStore.state.bar).toBe('baz'); 416 expect((store.state.dict['foo'] as ProxyState).bar).toBe('baz'); 417 }); 418 419 test('chained substores', () => { 420 interface State { 421 dict: {[key: string]: unknown}; 422 } 423 424 interface FooState { 425 bar: { 426 baz: string; 427 }; 428 } 429 430 const store = createStore<State>({dict: {}}); 431 432 const DEFAULT_FOO_STATE: FooState = {bar: {baz: 'abc'}}; 433 const fooStore = store.createSubStore( 434 ['dict', 'foo'], 435 (init) => init ?? DEFAULT_FOO_STATE, 436 ); 437 438 const subFooStore = fooStore.createSubStore( 439 ['bar'], 440 (x) => x as FooState['bar'], 441 ); 442 443 // Since the entry for 'foo' will be undefined in the dict, we expect the 444 // migrate function on fooStore to return DEFAULT_FOO_STATE, and thus the 445 // state of the subFooStore will be DEFAULT_FOO_STATE.bar. 446 expect(subFooStore.state).toEqual({baz: 'abc'}); 447 }); 448}); 449