Skip to content

Commit f585ce7

Browse files
authored
Extracted page scraping loop from react component to separate module, and added unit tests (#18)
1 parent ea3208a commit f585ce7

File tree

11 files changed

+189
-95
lines changed

11 files changed

+189
-95
lines changed

jest.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
module.exports = {
77
preset: 'ts-jest',
8-
testEnvironment: 'node',
8+
testEnvironment: 'jsdom',
99
setupFiles: [
1010
// v8 with built-in flat (v6.9.x) won't be in node until v11.
1111
// Do not remove this polyfill until node 12 is LTS

src/App/index.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export default class App extends React.Component<{}, State> {
6060
// each tab to persist its state without having to hoist
6161
// state to the root of the tree
6262
<div
63+
key={panel.title}
6364
className={styles.rightPanelItemWrapper}
6465
data-selected={panel === selectedPanel}
6566
>

src/RequireJS/BundleGenerator/ConfigGen.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@
44
*/
55

66
import * as React from 'react';
7-
import { RequireConfig, ModulesByPageType } from '../../types/require';
7+
import { RequireConfig, PageModule } from '../../types/require';
88
import generate from './generate';
99

1010
type Props = {
11-
modulesByPageType: ModulesByPageType;
11+
pageModules: PageModule[];
1212
requireConfig: RequireConfig;
1313
};
1414

1515
export default class ConfigGen extends React.Component<Props> {
1616
render() {
17-
const { modulesByPageType, requireConfig } = this.props;
18-
const config = generate(modulesByPageType, requireConfig);
17+
const { pageModules, requireConfig } = this.props;
18+
const config = generate(pageModules, requireConfig);
1919

2020
return <pre>{JSON.stringify(config, null, 2)}</pre>;
2121
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Copyright © Magento, Inc. All rights reserved.
3+
* See COPYING.txt for license details.
4+
*/
5+
6+
import { PageModule, RequireConfig } from '../../types/require';
7+
import { getBundlingData } from '../../interop';
8+
9+
type CollectVal = {
10+
config: RequireConfig;
11+
page: PageModule;
12+
};
13+
type Callback = (data: CollectVal) => void;
14+
15+
export default class PageCollector {
16+
timerID: number | null = null;
17+
private listeners: Set<Callback> = new Set();
18+
19+
private pollForModules() {
20+
this.timerID = window.setTimeout(async () => {
21+
try {
22+
const {
23+
modules,
24+
config,
25+
pageConfigType,
26+
url,
27+
} = await getBundlingData();
28+
29+
// TODO: Actually handle this case, since it will point to a bug
30+
// in the shaky logic of `_getPageConfigType` in `interop.ts`
31+
if (!pageConfigType) throw new Error('No pageConfigType found');
32+
33+
const page = {
34+
url,
35+
pageConfigType,
36+
// TODO: Address this hack better. m2 has a bug where
37+
// a superfluous mixin is added to the Require registry for any
38+
// require() with a relative URL (./).
39+
modules: modules.filter(m => !m.startsWith('mixins!')),
40+
};
41+
42+
this.notifyListeners({ page, config });
43+
} catch (err) {
44+
// TODO: Handle errors. Most common error is us attempting
45+
// to eval code in the currently-inspected page when it's
46+
// between pages, which can be safely ignored
47+
}
48+
49+
this.pollForModules();
50+
}, 200);
51+
}
52+
53+
private notifyListeners(val: CollectVal) {
54+
this.listeners.forEach(f => f(val));
55+
}
56+
57+
private stopPolling() {
58+
if (this.timerID) window.clearTimeout(this.timerID);
59+
}
60+
61+
unsubscribe(cb: Callback) {
62+
this.listeners.delete(cb);
63+
if (!this.listeners.size) this.stopPolling();
64+
}
65+
66+
subscribe(cb: Callback) {
67+
this.listeners.add(cb);
68+
if (this.listeners.size === 1) this.pollForModules();
69+
}
70+
}

src/RequireJS/BundleGenerator/RecordingProgress.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,21 @@
44
*/
55

66
import * as React from 'react';
7-
import { ModulesByPageType, PageModule } from '../../types/require';
7+
import { ModulesByURL, PageModule } from '../../types/require';
88
import styles from './RecordingProgress.css';
99

1010
type Props = {
11-
modulesByPageType: ModulesByPageType;
11+
modulesByURL: ModulesByURL;
1212
};
1313

1414
export default class RecordingProgress extends React.Component<Props> {
1515
render() {
16-
const { modulesByPageType } = this.props;
16+
const { modulesByURL } = this.props;
1717

1818
return (
1919
<div className={styles.wrapper}>
2020
<ul className={styles.pageList}>
21-
{modulesByPageType.map(mod => (
21+
{Object.values(modulesByURL).map(mod => (
2222
<PageTypeTile key={mod.url} mod={mod} />
2323
))}
2424
</ul>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Copyright © Magento, Inc. All rights reserved.
3+
* See COPYING.txt for license details.
4+
*/
5+
6+
import PageCollector from '../PageCollector';
7+
import { getBundlingData } from '../../../interop';
8+
9+
jest.mock('../../../interop');
10+
jest.useFakeTimers();
11+
12+
beforeEach(() => {
13+
jest.clearAllMocks();
14+
jest.clearAllTimers();
15+
});
16+
17+
test('Does not start polling until a subscription exists', () => {
18+
const collector = new PageCollector();
19+
jest.runAllTimers();
20+
expect(getBundlingData).not.toHaveBeenCalled();
21+
22+
const subscriber = jest.fn();
23+
collector.subscribe(subscriber);
24+
jest.runAllTimers();
25+
26+
expect(getBundlingData).toHaveBeenCalled();
27+
});
28+
29+
test('Stops notifying subscriber when it is unsubscribed', () => {
30+
const collector = new PageCollector();
31+
const subscriber = jest.fn();
32+
collector.subscribe(subscriber);
33+
34+
jest.runAllTimers();
35+
const callCountBeforeUnsubscribe = subscriber.mock.calls.length;
36+
collector.unsubscribe(subscriber);
37+
jest.runAllTimers();
38+
39+
expect(subscriber).toHaveBeenCalledTimes(callCountBeforeUnsubscribe);
40+
});
41+
42+
test('Removes modules that are prepended with "mixins!"', () => {
43+
const mock = (getBundlingData as unknown) as jest.MockInstance<
44+
typeof getBundlingData
45+
>;
46+
mock.mockReturnValue(
47+
Promise.resolve({
48+
url: 'https://www.site.com/product',
49+
config: {},
50+
pageConfigType: 'catalog-product-view',
51+
modules: ['foo', 'bar', 'mixins!bar', 'bizz'],
52+
}),
53+
);
54+
55+
const collector = new PageCollector();
56+
const subscriber = jest.fn();
57+
collector.subscribe(subscriber);
58+
59+
jest.runAllTimers();
60+
61+
// Unlike tests using the `jest.mock` automock functionality,
62+
// we need to wait briefly here because the Promise passed to `mock.mockReturnValue`
63+
// above uses a microtask to unwrap
64+
setImmediate(() => {
65+
expect(subscriber).toHaveBeenCalledTimes(1);
66+
});
67+
});

src/RequireJS/BundleGenerator/generate.ts

+3-9
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@
33
* See COPYING.txt for license details.
44
*/
55

6-
import {
7-
ModulesByPageType,
8-
ShimConfig,
9-
RequireConfig,
10-
} from '../../types/require';
6+
import { ShimConfig, RequireConfig, PageModule } from '../../types/require';
117
import intersection from 'lodash.intersection';
128
import difference from 'lodash.difference';
139

@@ -35,13 +31,11 @@ type BundleConfig = {
3531
};
3632

3733
export default function generate(
38-
modulesByPageType: ModulesByPageType,
34+
pageModules: PageModule[],
3935
requireConfig: RequireConfig,
4036
): BundleConfig {
4137
// Cheap clone so we can feel free to mutate
42-
const modules = JSON.parse(
43-
JSON.stringify(modulesByPageType),
44-
) as ModulesByPageType;
38+
const modules = JSON.parse(JSON.stringify(pageModules)) as PageModule[];
4539

4640
const commons: string[] = [];
4741
modules.forEach(mod => {

src/RequireJS/BundleGenerator/index.tsx

+25-76
Original file line numberDiff line numberDiff line change
@@ -8,119 +8,68 @@ import Toolbar from './Toolbar';
88
import ConfigGen from './ConfigGen';
99
import styles from './BundleGenerator.css';
1010
import c from '../../classjoin';
11-
import {
12-
getLoadedModules,
13-
getRequireConfig,
14-
getPageConfigType,
15-
getURL,
16-
} from '../../interop';
17-
import { ModulesByPageType, RequireConfig } from '../../types/require';
11+
import { RequireConfig, PageModule } from '../../types/require';
1812
import RecordingProgress from './RecordingProgress';
13+
import PageCollector from './PageCollector';
1914

2015
type States = 'RECORDING' | 'RESULTS' | 'WELCOME';
2116
type State = {
2217
currentState: States;
23-
modulesByPageType: ModulesByPageType;
18+
modulesByURL: { [key: string]: PageModule };
2419
requireConfig: RequireConfig | null;
2520
};
2621

27-
// TODO:
28-
// - Merge modules from multiple URLs with same layout handle, instead of taking the latest
29-
// - Track URLs, leave comments in `r.js` generated config
30-
// - Allow purging records for a visited URL. IOW, if you've visited 4 product pages, but
31-
// want to purge the results from 1 of them
32-
// - Support a user provided list of URLs to drive the browser too, instead of manual nav
33-
// for frequent users
3422
export default class BundleGenerator extends React.Component<{}, State> {
3523
state: State = {
36-
modulesByPageType: [],
24+
modulesByURL: {},
3725
currentState: 'WELCOME',
3826
requireConfig: null,
3927
};
40-
timerID: number | null = null;
28+
collector: PageCollector = new PageCollector();
4129

4230
onRecord = () => {
4331
this.setState(({ currentState }) => {
44-
const isStop = currentState === 'RECORDING';
45-
isStop ? this.stopPolling() : this.pollForModules();
32+
const shouldStopRecording = currentState === 'RECORDING';
33+
shouldStopRecording ? this.stopPolling() : this.pollForModules();
4634
return {
47-
currentState: isStop ? 'RESULTS' : 'RECORDING',
35+
currentState: shouldStopRecording ? 'RESULTS' : 'RECORDING',
4836
};
4937
});
5038
};
5139

5240
onClear = () => {
5341
this.setState({
5442
currentState: 'WELCOME',
55-
modulesByPageType: [],
43+
modulesByURL: {},
5644
requireConfig: null,
5745
});
5846
};
5947

6048
stopPolling() {
61-
this.timerID && window.clearTimeout(this.timerID);
49+
this.collector.unsubscribe(this.collectorCallback);
6250
}
6351

64-
// TODO: Abstract the body of this method outside of the component
65-
// and test it. Also clean it up because it's a mess
66-
pollForModules() {
67-
this.timerID = window.setTimeout(async () => {
68-
try {
69-
const [
70-
modules,
71-
requireConfig,
72-
pageConfigType,
73-
url,
74-
] = await Promise.all([
75-
getLoadedModules(),
76-
getRequireConfig(),
77-
getPageConfigType(),
78-
getURL(),
79-
]);
80-
81-
// TODO: Actually handle this case, since it will point to a bug
82-
// in the shaky logic of `_getPageConfigType` in `interop.ts`
83-
if (!pageConfigType) throw new Error('No pageConfigType found');
84-
85-
this.setState(({ modulesByPageType }) => {
86-
const [current] = modulesByPageType.filter(
87-
m => m.pageConfigType === pageConfigType,
88-
);
89-
const mod = {
90-
url,
91-
pageConfigType,
92-
// TODO: Address this hack better. m2 has a bug where
93-
// a superfluous mixin is added to the Require registry for any
94-
// require() with a relative URL (./).
95-
modules: modules.filter(m => !m.startsWith('mixins!')),
96-
};
97-
current
98-
? modulesByPageType.splice(
99-
modulesByPageType.indexOf(current),
100-
1,
101-
mod,
102-
)
103-
: modulesByPageType.push(mod);
104-
105-
return {
106-
requireConfig,
107-
modulesByPageType,
108-
};
109-
});
110-
} catch (err) {
111-
console.error('Failure in pollForModules()', err);
112-
}
52+
collectorCallback = (data: { page: PageModule; config: RequireConfig }) => {
53+
const { page, config } = data;
54+
this.setState(prevState => ({
55+
modulesByURL: {
56+
...prevState.modulesByURL,
57+
[page.url]: page,
58+
},
59+
requireConfig: config,
60+
}));
61+
};
11362

114-
this.pollForModules();
115-
}, 200);
63+
pollForModules() {
64+
this.collector.subscribe(this.collectorCallback);
11665
}
11766

11867
componentWillUnmount() {
11968
this.stopPolling();
12069
}
12170

12271
render() {
123-
const { currentState, modulesByPageType, requireConfig } = this.state;
72+
const { currentState, modulesByURL, requireConfig } = this.state;
12473

12574
return (
12675
<>
@@ -131,11 +80,11 @@ export default class BundleGenerator extends React.Component<{}, State> {
13180
/>
13281
{currentState === 'WELCOME' && welcomeContent}
13382
{currentState === 'RECORDING' && (
134-
<RecordingProgress modulesByPageType={modulesByPageType} />
83+
<RecordingProgress modulesByURL={modulesByURL} />
13584
)}
13685
{currentState === 'RESULTS' && requireConfig && (
13786
<ConfigGen
138-
modulesByPageType={modulesByPageType}
87+
pageModules={Object.values(modulesByURL)}
13988
requireConfig={requireConfig}
14089
/>
14190
)}

src/RequireJS/index.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export default class RequireJS extends React.Component<{}, State> {
4646
/>
4747
{tabs.map(tab => (
4848
<div
49+
key={tab.name}
4950
className={styles.tabWrapper}
5051
data-selected={tab === selectedTab}
5152
>

0 commit comments

Comments
 (0)