Skip to content

Commit 619c987

Browse files
authored
Merge pull request #11075 from marmelab/fix-memory-store
Fix regression in `memoryStore`
2 parents 6364076 + e8b1cc4 commit 619c987

File tree

6 files changed

+157
-138
lines changed

6 files changed

+157
-138
lines changed

packages/ra-core/src/store/memoryStore.spec.ts renamed to packages/ra-core/src/store/memoryStore.spec.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1+
import * as React from 'react';
12
import expect from 'expect';
2-
3+
import { render, screen } from '@testing-library/react';
34
import { memoryStore } from './memoryStore';
5+
import { useStore } from './useStore';
6+
import { StoreContextProvider } from './StoreContextProvider';
47

58
describe('memoryStore', () => {
69
it('should allow to store and retrieve a value', () => {
710
const store = memoryStore();
11+
store.setup();
812
store.setItem('foo', 'bar');
913
expect(store.getItem('foo')).toEqual('bar');
1014
});
1115
describe('removeItem', () => {
1216
it('should remove an item', () => {
1317
const store = memoryStore();
18+
store.setup();
1419
store.setItem('foo', 'bar');
1520
store.setItem('hello', 'world');
1621
store.removeItem('foo');
@@ -21,6 +26,7 @@ describe('memoryStore', () => {
2126
describe('removeItems', () => {
2227
it('should remove all items with the given prefix', () => {
2328
const store = memoryStore();
29+
store.setup();
2430
store.setItem('foo', 'bar');
2531
store.setItem('foo2', 'bar2');
2632
store.setItem('foo3', 'bar3');
@@ -35,6 +41,7 @@ describe('memoryStore', () => {
3541
describe('reset', () => {
3642
it('should reset the store', () => {
3743
const store = memoryStore();
44+
store.setup();
3845
store.setItem('foo', 'bar');
3946
store.reset();
4047
expect(store.getItem('foo')).toEqual(undefined);
@@ -44,6 +51,7 @@ describe('memoryStore', () => {
4451
describe('nested-looking keys', () => {
4552
it('should store and retrieve values in keys that appear nested without overriding content', () => {
4653
const store = memoryStore();
54+
store.setup();
4755
store.setItem('foo', 'parent value');
4856
store.setItem('foo.bar', 'nested value');
4957

@@ -55,16 +63,34 @@ describe('memoryStore', () => {
5563
const initialStorage = {
5664
user: {
5765
name: 'John',
58-
settings: {
59-
theme: 'dark',
60-
},
66+
},
67+
'user.settings': {
68+
theme: 'dark',
6169
},
6270
};
6371

6472
const store = memoryStore(initialStorage);
73+
store.setup();
6574

66-
expect(store.getItem('user.name')).toEqual('John');
67-
expect(store.getItem('user.settings.theme')).toEqual('dark');
75+
expect(store.getItem('user')).toEqual({ name: 'John' });
76+
expect(store.getItem('user.settings')).toEqual({ theme: 'dark' });
6877
});
6978
});
79+
80+
it('preserves the initial value in StrictMode', async () => {
81+
const Component = () => {
82+
const [value] = useStore('user', 'Not me');
83+
return <>{value}</>;
84+
};
85+
86+
render(
87+
<React.StrictMode>
88+
<StoreContextProvider value={memoryStore({ user: 'John' })}>
89+
<Component />
90+
</StoreContextProvider>
91+
</React.StrictMode>
92+
);
93+
94+
await screen.findByText('John');
95+
});
7096
});

packages/ra-core/src/store/memoryStore.tsx

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ export const memoryStore = (
2222
initialStorage: Record<string, any> = {}
2323
): Store => {
2424
// Use a flat Map to store key-value pairs directly without treating dots as nested paths
25-
const storage = new Map<string, any>(
26-
Object.entries(flatten(initialStorage))
27-
);
25+
let storage = new Map<string, any>(Object.entries(initialStorage ?? {}));
2826
const subscriptions: { [key: string]: Subscription } = {};
27+
let initialized = false;
28+
let itemsToSetAfterInitialization: Record<string, unknown> = {};
2929

3030
const publish = (key: string, value: any) => {
3131
Object.keys(subscriptions).forEach(id => {
@@ -37,7 +37,22 @@ export const memoryStore = (
3737
};
3838

3939
return {
40-
setup: () => {},
40+
setup: () => {
41+
storage = new Map<string, any>(Object.entries(initialStorage));
42+
43+
// Because children might call setItem before the store is initialized,
44+
// we store those calls parameters and apply them once the store is ready
45+
if (Object.keys(itemsToSetAfterInitialization).length > 0) {
46+
const items = Object.entries(itemsToSetAfterInitialization);
47+
for (const [key, value] of items) {
48+
storage.set(key, value);
49+
publish(key, value);
50+
}
51+
itemsToSetAfterInitialization = {};
52+
}
53+
54+
initialized = true;
55+
},
4156
teardown: () => {
4257
storage.clear();
4358
},
@@ -47,6 +62,12 @@ export const memoryStore = (
4762
: (defaultValue as T);
4863
},
4964
setItem<T = any>(key: string, value: T): void {
65+
// Because children might call setItem before the store is initialized,
66+
// we store those calls parameters and apply them once the store is ready
67+
if (!initialized) {
68+
itemsToSetAfterInitialization[key] = value;
69+
return;
70+
}
5071
storage.set(key, value);
5172
publish(key, value);
5273
},
@@ -88,27 +109,3 @@ export const memoryStore = (
88109
},
89110
};
90111
};
91-
92-
// taken from https://stackoverflow.com/a/19101235/1333479
93-
const flatten = (data: any) => {
94-
const result = {};
95-
function doFlatten(current, prop) {
96-
if (Object(current) !== current) {
97-
// scalar value
98-
result[prop] = current;
99-
} else if (Array.isArray(current)) {
100-
// array
101-
result[prop] = current;
102-
} else {
103-
// object
104-
let isEmpty = true;
105-
for (const p in current) {
106-
isEmpty = false;
107-
doFlatten(current[p], prop ? prop + '.' + p : p);
108-
}
109-
if (isEmpty && prop) result[prop] = {};
110-
}
111-
}
112-
doFlatten(data, '');
113-
return result;
114-
};

packages/ra-core/src/store/useStore.spec.tsx

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import * as React from 'react';
2-
import {
3-
render,
4-
screen,
5-
fireEvent,
6-
waitFor,
7-
renderHook,
8-
} from '@testing-library/react';
2+
import { render, screen, fireEvent, renderHook } from '@testing-library/react';
93

104
import { useStore } from './useStore';
115
import { StoreContextProvider } from './StoreContextProvider';
@@ -68,23 +62,13 @@ describe('useStore', () => {
6862
expect(unsubscribe).toHaveBeenCalled();
6963
});
7064

71-
it('should allow to set values', async () => {
72-
const { result } = renderHook(() => useStore('foo.bar'));
73-
await waitFor(() => {
74-
result.current[1]('hello');
75-
expect(result.current[0]).toBe('hello');
76-
});
77-
});
78-
7965
it('should update all components using the same store key on update', () => {
8066
const UpdateStore = () => {
81-
const [, setValue] = useStore('foo.bar');
67+
const [, setValue] = useStore<string>('foo.bar');
8268
return <button onClick={() => setValue('world')}>update</button>;
8369
};
8470
render(
85-
<StoreContextProvider
86-
value={memoryStore({ foo: { bar: 'hello' } })}
87-
>
71+
<StoreContextProvider value={memoryStore({ 'foo.bar': 'hello' })}>
8872
<StoreReader name="foo.bar" />
8973
<UpdateStore />
9074
</StoreContextProvider>
@@ -94,15 +78,13 @@ describe('useStore', () => {
9478
screen.getByText('world');
9579
});
9680

97-
it('should not update components using other store key on update', () => {
81+
it('should not update components using other store key on update', async () => {
9882
const UpdateStore = () => {
99-
const [, setValue] = useStore('other.key');
83+
const [, setValue] = useStore<string>('other.key');
10084
return <button onClick={() => setValue('world')}>update</button>;
10185
};
10286
render(
103-
<StoreContextProvider
104-
value={memoryStore({ foo: { bar: 'hello' } })}
105-
>
87+
<StoreContextProvider value={memoryStore({ 'foo.bar': 'hello' })}>
10688
<StoreReader name="foo.bar" />
10789
<UpdateStore />
10890
</StoreContextProvider>
@@ -113,22 +95,28 @@ describe('useStore', () => {
11395
});
11496

11597
it('should accept an updater function as parameter', async () => {
116-
const { result } = renderHook(() => useStore('foo.bar'));
117-
result.current[1]('hello');
118-
let innerValue;
119-
result.current[1](value => {
120-
innerValue = value;
121-
return 'world';
122-
});
123-
await waitFor(() => {
124-
expect(innerValue).toBe('hello');
125-
});
126-
expect(result.current[0]).toBe('world');
98+
const UpdateStore = () => {
99+
const [, setValue] = useStore<string>('foo.bar');
100+
return (
101+
<button onClick={() => setValue(current => `${current} world`)}>
102+
update
103+
</button>
104+
);
105+
};
106+
render(
107+
<StoreContextProvider value={memoryStore({ 'foo.bar': 'hello' })}>
108+
<StoreReader name="foo.bar" />
109+
<UpdateStore />
110+
</StoreContextProvider>
111+
);
112+
screen.getByText('hello');
113+
fireEvent.click(screen.getByText('update'));
114+
screen.getByText('hello world');
127115
});
128116

129117
it('should clear its value when the key changes', () => {
130118
const StoreConsumer = ({ storeKey }: { storeKey: string }) => {
131-
const [value, setValue] = useStore(storeKey);
119+
const [value, setValue] = useStore<string>(storeKey);
132120
return (
133121
<>
134122
<p>{value}</p>

packages/ra-ui-materialui/src/form/SimpleFormConfigurable.stories.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
I18nContextProvider,
55
TestMemoryRouter,
66
ResourceContextProvider,
7+
StoreContextProvider,
8+
memoryStore,
79
} from 'ra-core';
810
import { ThemeProvider, createTheme, Box, Paper } from '@mui/material';
911
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
@@ -29,13 +31,15 @@ const Wrapper = ({ children }) => (
2931
<ThemeProvider theme={createTheme(defaultTheme)}>
3032
<PreferencesEditorContextProvider>
3133
<TestMemoryRouter>
32-
<ResourceContextProvider value="posts">
33-
<Inspector />
34-
<Box display="flex" justifyContent="flex-end">
35-
<InspectorButton />
36-
</Box>
37-
<Paper sx={{ width: 600, m: 2 }}>{children}</Paper>
38-
</ResourceContextProvider>
34+
<StoreContextProvider value={memoryStore()}>
35+
<ResourceContextProvider value="posts">
36+
<Inspector />
37+
<Box display="flex" justifyContent="flex-end">
38+
<InspectorButton />
39+
</Box>
40+
<Paper sx={{ width: 600, m: 2 }}>{children}</Paper>
41+
</ResourceContextProvider>
42+
</StoreContextProvider>
3943
</TestMemoryRouter>
4044
</PreferencesEditorContextProvider>
4145
</ThemeProvider>

packages/ra-ui-materialui/src/list/datagrid/SelectColumnsButton.stories.tsx

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import * as React from 'react';
2-
import { PreferencesEditorContextProvider, TestMemoryRouter } from 'ra-core';
2+
import {
3+
memoryStore,
4+
PreferencesEditorContextProvider,
5+
StoreContextProvider,
6+
TestMemoryRouter,
7+
} from 'ra-core';
38
import { Box } from '@mui/material';
49
import { createTheme, ThemeProvider } from '@mui/material/styles';
510

@@ -44,22 +49,27 @@ export const Basic = () => (
4449
<PreferencesEditorContextProvider>
4550
<QueryClientProvider client={new QueryClient()}>
4651
<TestMemoryRouter>
47-
<Box p={2}>
48-
<Box textAlign="right">
49-
<SelectColumnsButton resource="books" />
52+
<StoreContextProvider value={memoryStore()}>
53+
<Box p={2}>
54+
<Box textAlign="right">
55+
<SelectColumnsButton resource="books" />
56+
</Box>
57+
<DatagridConfigurable
58+
resource="books"
59+
data={data}
60+
sort={{ field: 'title', order: 'ASC' }}
61+
bulkActionButtons={false}
62+
>
63+
<TextField source="id" />
64+
<TextField
65+
source="title"
66+
label="Original title"
67+
/>
68+
<TextField source="author" />
69+
<TextField source="year" />
70+
</DatagridConfigurable>
5071
</Box>
51-
<DatagridConfigurable
52-
resource="books"
53-
data={data}
54-
sort={{ field: 'title', order: 'ASC' }}
55-
bulkActionButtons={false}
56-
>
57-
<TextField source="id" />
58-
<TextField source="title" label="Original title" />
59-
<TextField source="author" />
60-
<TextField source="year" />
61-
</DatagridConfigurable>
62-
</Box>
72+
</StoreContextProvider>
6373
</TestMemoryRouter>
6474
</QueryClientProvider>
6575
</PreferencesEditorContextProvider>
@@ -71,23 +81,28 @@ export const WithPreferenceKey = () => (
7181
<PreferencesEditorContextProvider>
7282
<QueryClientProvider client={new QueryClient()}>
7383
<TestMemoryRouter>
74-
<Box p={2}>
75-
<Box textAlign="right">
76-
<SelectColumnsButton preferenceKey="just-a-key.to_test_with" />
84+
<StoreContextProvider value={memoryStore()}>
85+
<Box p={2}>
86+
<Box textAlign="right">
87+
<SelectColumnsButton preferenceKey="just-a-key.to_test_with" />
88+
</Box>
89+
<DatagridConfigurable
90+
resource="books"
91+
preferenceKey="just-a-key.to_test_with"
92+
data={data}
93+
sort={{ field: 'title', order: 'ASC' }}
94+
bulkActionButtons={false}
95+
>
96+
<TextField source="id" />
97+
<TextField
98+
source="title"
99+
label="Original title"
100+
/>
101+
<TextField source="author" />
102+
<TextField source="year" />
103+
</DatagridConfigurable>
77104
</Box>
78-
<DatagridConfigurable
79-
resource="books"
80-
preferenceKey="just-a-key.to_test_with"
81-
data={data}
82-
sort={{ field: 'title', order: 'ASC' }}
83-
bulkActionButtons={false}
84-
>
85-
<TextField source="id" />
86-
<TextField source="title" label="Original title" />
87-
<TextField source="author" />
88-
<TextField source="year" />
89-
</DatagridConfigurable>
90-
</Box>
105+
</StoreContextProvider>
91106
</TestMemoryRouter>
92107
</QueryClientProvider>
93108
</PreferencesEditorContextProvider>

0 commit comments

Comments
 (0)