Skip to content

Commit dcbb831

Browse files
authored
FSCache transitioned to use createListenerMiddleware from @reduxjs/toolkit (#691)
* FSCache transitioned to use createListenerMiddleware from @reduxjs/toolkit * Remove unused import
1 parent e02f51a commit dcbb831

File tree

5 files changed

+288
-88
lines changed

5 files changed

+288
-88
lines changed
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import React from 'react';
2+
import { cleanup, render } from '@testing-library/react';
3+
import { Provider } from 'react-redux';
4+
import { DateTime } from 'luxon';
5+
import { RootState } from '../store';
6+
import { metafileAdded, metafileRemoved } from '../slices/metafiles';
7+
import { mockStore } from '../../test-utils/mock-store';
8+
import { DndProvider } from 'react-dnd';
9+
import Canvas from '../../components/Canvas';
10+
import { HTML5Backend } from 'react-dnd-html5-backend';
11+
import { removeUndefined } from '../../containers/format';
12+
import { fetchMetafile, fetchNewMetafile, FileMetafile } from '../thunks/metafiles';
13+
import { file, mock, MockInstance } from '../../test-utils/mock-fs';
14+
15+
const mockedStore: RootState = {
16+
stacks: {
17+
ids: [],
18+
entities: {}
19+
},
20+
cards: {
21+
ids: [],
22+
entities: {}
23+
},
24+
filetypes: {
25+
ids: [],
26+
entities: {}
27+
},
28+
metafiles: {
29+
ids: ['821c9159-292b-4639-b90e-e84fc12740ee', '46ae0111-0c82-4ee2-9ee5-cd5bdf8d8a71', '88e2gd50-3a5q-6401-b5b3-203c6710e35c'],
30+
entities: {
31+
'821c9159-292b-4639-b90e-e84fc12740ee': {
32+
id: '821c9159-292b-4639-b90e-e84fc12740ee',
33+
name: 'test.js',
34+
modified: DateTime.fromISO('2019-11-19T19:19:47.572-08:00').valueOf(),
35+
content: 'var rand: number = Math.floor(Math.random() * 6) + 1;'
36+
},
37+
'46ae0111-0c82-4ee2-9ee5-cd5bdf8d8a71': {
38+
id: '46ae0111-0c82-4ee2-9ee5-cd5bdf8d8a71',
39+
name: 'example.ts',
40+
path: 'foo/example.ts',
41+
modified: DateTime.fromISO('2015-06-19T19:10:47.319-08:00').valueOf(),
42+
content: 'const rand = Math.floor(Math.random() * 6) + 1;',
43+
repo: '23',
44+
branch: 'master'
45+
},
46+
'88e2gd50-3a5q-6401-b5b3-203c6710e35c': {
47+
id: '88e2gd50-3a5q-6401-b5b3-203c6710e35c',
48+
name: 'bar.js',
49+
path: 'foo/bar.js',
50+
modified: DateTime.fromISO('2015-06-19T19:10:47.319-08:00').valueOf(),
51+
content: 'file contents',
52+
}
53+
}
54+
},
55+
cached: {
56+
ids: [],
57+
entities: {}
58+
},
59+
repos: {
60+
ids: [],
61+
entities: {}
62+
},
63+
branches: {
64+
ids: [],
65+
entities: {}
66+
},
67+
modals: {
68+
ids: [],
69+
entities: {}
70+
}
71+
}
72+
73+
const metafile1: FileMetafile = {
74+
id: 'b859d4e8-b932-4fc7-a2f7-29a8ef8cd8f8',
75+
name: 'turtle.asp',
76+
modified: DateTime.fromISO('2017-01-05T19:09:22.744-08:00').valueOf(),
77+
path: 'test/turtle.asp',
78+
content: 'example'
79+
}
80+
81+
const metafile2: FileMetafile = {
82+
id: 'h8114d71-b100-fg9a-0c1d3516d991',
83+
name: 'turtle.asp',
84+
modified: DateTime.fromISO('2017-01-05T19:09:22.744-08:00').valueOf(),
85+
path: 'test/turtle.asp',
86+
content: 'example'
87+
}
88+
89+
describe('cacheMiddleware Redux middleware', () => {
90+
const store = mockStore(mockedStore);
91+
92+
let mockedInstance: MockInstance;
93+
94+
beforeAll(async () => {
95+
const instance = await mock({
96+
'test/turtle.asp': file({ content: 'example', mtime: new Date(1) })
97+
});
98+
return mockedInstance = instance;
99+
});
100+
afterAll(() => mockedInstance.reset());
101+
102+
afterEach(() => {
103+
cleanup;
104+
store.clearActions();
105+
jest.resetAllMocks();
106+
});
107+
108+
const produceComponent = () => {
109+
render(
110+
<Provider store={store} >
111+
<DndProvider backend={HTML5Backend}>
112+
<Canvas />
113+
</DndProvider>
114+
</Provider>
115+
);
116+
};
117+
118+
it('initial state has no cached files', () => {
119+
produceComponent();
120+
expect(store.getState().cached.ids).toHaveLength(0);
121+
});
122+
123+
it('adding a metafile triggers the creation of a file cache', async () => {
124+
produceComponent();
125+
store.dispatch(metafileAdded(metafile1));
126+
expect(removeUndefined(Object.values(store.getState().cached.entities))).toStrictEqual(
127+
expect.arrayContaining([expect.objectContaining({
128+
path: metafile1.path,
129+
reserves: 0
130+
})])
131+
);
132+
});
133+
134+
it('fetching a new file triggers the creation of a file cache', async () => {
135+
produceComponent();
136+
await store.dispatch(fetchNewMetafile({ filepath: metafile1.path })).unwrap();
137+
expect(store.getActions()).toStrictEqual(
138+
expect.arrayContaining([expect.objectContaining({ type: 'cached/cachedAdded' })])
139+
);
140+
expect(removeUndefined(Object.values(store.getState().cached.entities))).toStrictEqual(
141+
expect.arrayContaining([expect.objectContaining({ path: metafile1.path })])
142+
);
143+
});
144+
145+
it('fetching multiple metafiles for the same file increases reserves in the cached file', async () => {
146+
produceComponent();
147+
await store.dispatch(fetchMetafile({ filepath: metafile1.path })).unwrap();
148+
await store.dispatch(fetchMetafile({ filepath: metafile2.path })).unwrap();
149+
const cached = removeUndefined(Object.values(store.getState().cached.entities));
150+
expect(cached).toHaveLength(1);
151+
expect(cached).toStrictEqual(
152+
expect.arrayContaining([
153+
expect.objectContaining({
154+
path: metafile1.path,
155+
reserves: 2
156+
})
157+
])
158+
);
159+
});
160+
161+
it('removing a metafile decreases reserves in the cached files', async () => {
162+
produceComponent();
163+
const metafile = await store.dispatch(fetchMetafile({ filepath: metafile1.path })).unwrap();
164+
await store.dispatch(fetchMetafile({ filepath: metafile2.path })).unwrap();
165+
expect(removeUndefined(Object.values(store.getState().cached.entities))).toStrictEqual(
166+
expect.arrayContaining([
167+
expect.objectContaining({
168+
path: metafile.path,
169+
reserves: 2
170+
})
171+
])
172+
);
173+
store.dispatch(metafileRemoved(metafile.id));
174+
expect(removeUndefined(Object.values(store.getState().cached.entities))).toStrictEqual(
175+
expect.arrayContaining([
176+
expect.objectContaining({
177+
path: metafile1.path,
178+
reserves: 1
179+
})
180+
])
181+
);
182+
});
183+
});

src/store/cache/cacheMiddleware.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { createListenerMiddleware, isAnyOf, isFulfilled, TypedStartListening } from '@reduxjs/toolkit';
2+
import { relative } from 'path';
3+
import { v4 } from 'uuid';
4+
import { removeUndefined } from '../../containers/format';
5+
import { cachedAdded, cachedRemoved, cachedSubscribed, cachedUnsubscribed } from '../slices/cached';
6+
import { cardRemoved } from '../slices/cards';
7+
import { Metafile, metafileAdded, metafileRemoved } from '../slices/metafiles';
8+
import { RootState } from '../store';
9+
import { fetchMetafilesByFilepath, fetchMetafilesByVirtual, fetchNewMetafile, isDirectoryMetafile, isFilebasedMetafile, isFileMetafile } from '../thunks/metafiles';
10+
11+
export const cacheMiddleware = createListenerMiddleware();
12+
13+
const startAppListening = cacheMiddleware.startListening as TypedStartListening<RootState>;
14+
15+
startAppListening({
16+
actionCreator: metafileRemoved,
17+
effect: async (action, listenerApi) => {
18+
const metafile = listenerApi.getOriginalState().metafiles.entities[action.payload];
19+
if (metafile && isFileMetafile(metafile)) {
20+
const cached = removeUndefined(Object.values(listenerApi.getState().cached.entities))
21+
.find(cachedFile => cachedFile.path === metafile.path);
22+
if (cached) {
23+
if (cached.reserves > 1) {
24+
listenerApi.dispatch(cachedUnsubscribed(cached));
25+
} else {
26+
listenerApi.dispatch(cachedRemoved(cached.id));
27+
}
28+
}
29+
}
30+
}
31+
});
32+
33+
startAppListening({
34+
actionCreator: cardRemoved,
35+
effect: async (action, listenerApi) => {
36+
const card = listenerApi.getOriginalState().cards.entities[action.payload];
37+
const metafile = card ? listenerApi.getOriginalState().metafiles.entities[card.metafile] : undefined;
38+
39+
if (card && metafile) {
40+
const filepaths = isDirectoryMetafile(metafile) ? metafile.contains :
41+
isFileMetafile(metafile) ? [metafile.path.toString()] : [];
42+
const metafiles = removeUndefined(Object.values(listenerApi.getOriginalState().metafiles.entities));
43+
44+
const targetMetafiles = metafiles.filter(m =>
45+
filepaths.find(f => m.path && relative(f, m.path.toString()).length === 0));
46+
const cached = removeUndefined(Object.values(listenerApi.getState().cached.entities));
47+
targetMetafiles.filter(isFilebasedMetafile)
48+
.forEach(metafile => {
49+
const existing = cached.find(cachedFile =>
50+
relative(cachedFile.path.toString(), metafile.path.toString()).length === 0);
51+
if (existing) {
52+
if (existing.reserves > 1) {
53+
listenerApi.dispatch(cachedUnsubscribed(existing));
54+
} else {
55+
listenerApi.dispatch(metafileRemoved(metafile.id));
56+
listenerApi.dispatch(cachedRemoved(existing.id));
57+
}
58+
}
59+
});
60+
}
61+
}
62+
});
63+
64+
startAppListening({
65+
actionCreator: metafileAdded,
66+
effect: (action, listenerApi) => {
67+
const metafile: Metafile = action.payload;
68+
if (isFileMetafile(metafile)) {
69+
listenerApi.dispatch(cachedAdded({
70+
id: v4(),
71+
reserves: 0,
72+
path: metafile.path,
73+
metafile: metafile.id
74+
}));
75+
}
76+
}
77+
});
78+
79+
startAppListening({
80+
matcher: isFulfilled(fetchNewMetafile),
81+
effect: async (action, listenerApi) => {
82+
const metafile: Metafile = action.payload;
83+
if (isFileMetafile(metafile)) {
84+
const cached = removeUndefined(Object.values(listenerApi.getState().cached.entities))
85+
.find(cachedFile => cachedFile.path === metafile.path);
86+
if (cached) listenerApi.dispatch(cachedSubscribed(cached));
87+
}
88+
}
89+
});
90+
91+
startAppListening({
92+
matcher: isAnyOf(isFulfilled(fetchMetafilesByFilepath), isFulfilled(fetchMetafilesByVirtual)),
93+
effect: async (action, listenerApi) => {
94+
const metafile: Metafile = action.payload[0];
95+
if (isFileMetafile(metafile)) {
96+
const cached = removeUndefined(Object.values(listenerApi.getState().cached.entities))
97+
.find(cachedFile => cachedFile.path === metafile.path);
98+
if (cached) listenerApi.dispatch(cachedSubscribed(cached));
99+
}
100+
}
101+
});

src/store/middleware/cache.ts

Lines changed: 0 additions & 85 deletions
This file was deleted.

src/store/store.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import branchesReducer from './slices/branches';
99
import modalsReducer from './slices/modals';
1010
import { FLUSH, PAUSE, PERSIST, persistReducer, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist';
1111
import storage from 'redux-persist/lib/storage';
12-
import { cache } from './middleware/cache';
12+
import { cacheMiddleware } from './cache/cacheMiddleware';
1313

1414
export const rootReducer = combineReducers({
1515
stacks: stacksReducer,
@@ -38,7 +38,7 @@ const store = configureStore({
3838
serializableCheck: {
3939
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
4040
}
41-
}).concat([cache])
41+
}).concat([cacheMiddleware.middleware])
4242
});
4343
const persistor = persistStore(store);
4444

0 commit comments

Comments
 (0)