Skip to content

Commit db7ec07

Browse files
Allow state and rendering to be managed separately in the standalone version (#53)
* feat: separate the state and the rendering in the standalone version * fix: initialization of AppStateHOC * fix: fix bad import * chore: add tests working with the standalone initialization path * fix: linter
1 parent 9b8f5cb commit db7ec07

File tree

10 files changed

+661
-106
lines changed

10 files changed

+661
-106
lines changed

packages/scratch-gui/src/.eslintrc.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ module.exports = {
4040
files: ['**/*.ts', '**/*.tsx'],
4141
extends: ['plugin:@typescript-eslint/recommended'],
4242
rules: {
43-
'react/jsx-filename-extension': ['error', {extensions: ['.tsx']}]
43+
'react/jsx-filename-extension': ['error', {extensions: ['.tsx']}],
44+
45+
// This is handled by TypeScript
46+
'import/named': 'off'
4447
}
4548
}
4649
],

packages/scratch-gui/src/components/menu-bar/user-avatar.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const UserAvatar = ({
1414
styles.userThumbnail
1515
)}
1616
src={imageUrl}
17+
referrerPolicy="no-referrer"
1718
/>
1819
);
1920

packages/scratch-gui/src/index-standalone.tsx

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import React from 'react';
22
import ReactDOM from 'react-dom';
3-
import {setAppElement} from 'react-modal';
43
import GUI from './containers/gui';
5-
import {GUIConfig} from './gui-config';
6-
import AppStateHOC from './lib/app-state-hoc';
4+
import {AppStateProviderHOC} from './lib/app-state-provider-hoc';
5+
import {EditorState} from './lib/editor-state';
6+
import {ReactComponentLike} from 'prop-types';
7+
import {compose} from 'redux';
8+
9+
export {EditorState, EditorStateParams} from './lib/editor-state';
710

811
export {setAppElement} from 'react-modal';
912

@@ -18,27 +21,38 @@ export {default as buildDefaultProject} from './lib/default-project';
1821
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1922
export type GUIProps = any; // ComponentPropsWithoutRef<typeof ScratchGUI>;
2023

24+
export type HigherOrderComponent = (component: ReactComponentLike) => ReactComponentLike;
25+
2126
/**
2227
* Creates a "root" for the editor to be hosted in.
2328
*
24-
* @param {GUIConfig} config The configuration for the editor.
25-
* @param {HTMLElement} rootAppElement The main app element, set to ReactModal.setAppElement.
29+
* @param {EditorState} state The editor state. Create by new-ing EditorState.
2630
* @param {HTMLElement} container The container the editor should be hosted under.
2731
*
2832
* @returns {{ render: function(props: GUIProps): void, unmount: function(): void }} The mounted root.
2933
*/
3034
export const createStandaloneRoot = (
31-
config: GUIConfig,
32-
rootAppElement: HTMLElement,
33-
container: HTMLElement
35+
state: EditorState,
36+
container: HTMLElement,
37+
{wrappers}: {wrappers?: HigherOrderComponent[]} = {}
3438
) => {
35-
setAppElement(rootAppElement);
36-
37-
const GUIWithState = AppStateHOC(GUI, false, () => config);
39+
// note that redux's 'compose' function is just being used as a general utility to make
40+
// the hierarchy of HOC constructor calls clearer here; it has nothing to do with redux's
41+
// ability to compose reducers.
42+
const WrappedGui = compose(
43+
AppStateProviderHOC,
44+
...(wrappers ?? [])
45+
)(GUI) as ReactComponentLike;
3846

3947
return {
4048
render (props: GUIProps) {
41-
ReactDOM.render(<GUIWithState {...props} />, container);
49+
ReactDOM.render(
50+
<WrappedGui
51+
appState={state}
52+
{...props}
53+
/>,
54+
container
55+
);
4256
},
4357

4458
unmount () {

packages/scratch-gui/src/lib/app-state-hoc.jsx

Lines changed: 16 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
import React from 'react';
22
import PropTypes from 'prop-types';
3-
import {Provider} from 'react-redux';
4-
import {createStore, combineReducers, compose} from 'redux';
5-
import ConnectedIntlProvider from './connected-intl-provider.jsx';
63

7-
import localesReducer, {initLocale, localesInitialState} from '../reducers/locales';
8-
9-
import {setPlayer, setFullScreen} from '../reducers/mode.js';
10-
11-
import locales from 'scratch-l10n';
12-
import {detectLocale} from './detect-locale';
13-
14-
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
4+
import {EditorState} from './editor-state';
5+
import {AppStateProviderHOC} from './app-state-provider-hoc';
156

167
/**
178
* Higher Order Component to provide redux state. If an `intl` prop is provided
@@ -26,95 +17,27 @@ const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
2617
* @returns {React.Component} component with redux and intl state provided
2718
*/
2819
const AppStateHOC = function (WrappedComponent, localesOnly, configFactory) {
20+
const AppStateProvider = AppStateProviderHOC(WrappedComponent);
21+
2922
class AppStateWrapper extends React.Component {
3023
constructor (props) {
3124
super(props);
32-
let initialState = {};
33-
let reducers = {};
34-
let enhancer;
35-
36-
let initializedLocales = localesInitialState;
37-
const locale = detectLocale(Object.keys(locales));
38-
if (locale !== 'en') {
39-
initializedLocales = initLocale(initializedLocales, locale);
40-
}
41-
if (localesOnly) {
42-
// Used for instantiating minimal state for the unsupported
43-
// browser modal
44-
reducers = {locales: localesReducer};
45-
initialState = {locales: initializedLocales};
46-
enhancer = composeEnhancers();
47-
} else {
48-
// You are right, this is gross. But it's necessary to avoid
49-
// importing unneeded code that will crash unsupported browsers.
50-
const guiRedux = require('../reducers/gui');
51-
const guiReducer = guiRedux.default;
52-
const {
53-
buildInitialState,
54-
guiMiddleware,
55-
initFullScreen,
56-
initPlayer,
57-
initTelemetryModal
58-
} = guiRedux;
59-
const {ScratchPaintReducer} = require('scratch-paint');
6025

61-
const configOrLegacy = configFactory ?
62-
configFactory() :
63-
require('../legacy-config').legacyConfig;
64-
65-
let initializedGui = buildInitialState(configOrLegacy);
66-
if (props.isFullScreen || props.isPlayerOnly) {
67-
if (props.isFullScreen) {
68-
initializedGui = initFullScreen(initializedGui);
69-
}
70-
if (props.isPlayerOnly) {
71-
initializedGui = initPlayer(initializedGui);
72-
}
73-
} else if (props.showTelemetryModal) {
74-
initializedGui = initTelemetryModal(initializedGui);
75-
}
76-
reducers = {
77-
locales: localesReducer,
78-
scratchGui: guiReducer,
79-
scratchPaint: ScratchPaintReducer
80-
};
81-
initialState = {
82-
locales: initializedLocales,
83-
scratchGui: initializedGui
84-
};
85-
enhancer = composeEnhancers(guiMiddleware);
86-
}
87-
const reducer = combineReducers(reducers);
88-
this.store = createStore(
89-
reducer,
90-
initialState,
91-
enhancer
92-
);
93-
}
94-
componentDidUpdate (prevProps) {
95-
if (localesOnly) return;
96-
if (prevProps.isPlayerOnly !== this.props.isPlayerOnly) {
97-
this.store.dispatch(setPlayer(this.props.isPlayerOnly));
98-
}
99-
if (prevProps.isFullScreen !== this.props.isFullScreen) {
100-
this.store.dispatch(setFullScreen(this.props.isFullScreen));
101-
}
26+
this.appState = new EditorState({
27+
localesOnly,
28+
isFullScreen: props.isFullScreen,
29+
isPlayerOnly: props.isPlayerOnly,
30+
showTelemetryModal: props.showTelemetryModal
31+
}, configFactory);
10232
}
33+
10334
render () {
104-
const {
105-
isFullScreen, // eslint-disable-line no-unused-vars
106-
isPlayerOnly, // eslint-disable-line no-unused-vars
107-
showTelemetryModal, // eslint-disable-line no-unused-vars
108-
...componentProps
109-
} = this.props;
11035
return (
111-
<Provider store={this.store}>
112-
<ConnectedIntlProvider>
113-
<WrappedComponent
114-
{...componentProps}
115-
/>
116-
</ConnectedIntlProvider>
117-
</Provider>
36+
<AppStateProvider
37+
appState={this.appState}
38+
localesOnly={localesOnly}
39+
{...this.props}
40+
/>
11841
);
11942
}
12043
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React from 'react';
2+
import {Provider} from 'react-redux';
3+
import PropTypes from 'prop-types';
4+
5+
import {EditorState} from './editor-state';
6+
import {setPlayer, setFullScreen} from '../reducers/mode.js';
7+
import ConnectedIntlProvider from './connected-intl-provider.jsx';
8+
9+
/**
10+
* Wraps the editor into the redux state contained within an EditorState instance.
11+
*
12+
* @param {React.Component} WrappedComponent - component to provide state for
13+
*
14+
* @returns {React.Component} component with redux and intl state provided
15+
*/
16+
export const AppStateProviderHOC = function (WrappedComponent) {
17+
class AppStateWrapper extends React.Component {
18+
componentDidUpdate (prevProps) {
19+
if (this.props.localesOnly) return;
20+
if (prevProps.isPlayerOnly !== this.props.isPlayerOnly) {
21+
this.props.appState.store.dispatch(setPlayer(this.props.isPlayerOnly));
22+
}
23+
if (prevProps.isFullScreen !== this.props.isFullScreen) {
24+
this.props.appState.store.dispatch(setFullScreen(this.props.isFullScreen));
25+
}
26+
}
27+
28+
render () {
29+
const {
30+
appState,
31+
isFullScreen, // eslint-disable-line no-unused-vars
32+
isPlayerOnly, // eslint-disable-line no-unused-vars
33+
showTelemetryModal, // eslint-disable-line no-unused-vars
34+
...componentProps
35+
} = this.props;
36+
return (
37+
<Provider store={appState.store}>
38+
<ConnectedIntlProvider>
39+
<WrappedComponent
40+
{...componentProps}
41+
/>
42+
</ConnectedIntlProvider>
43+
</Provider>
44+
);
45+
}
46+
}
47+
AppStateWrapper.propTypes = {
48+
appState: PropTypes.instanceOf(EditorState),
49+
localesOnly: PropTypes.bool,
50+
isFullScreen: PropTypes.bool,
51+
isPlayerOnly: PropTypes.bool,
52+
isTelemetryEnabled: PropTypes.bool,
53+
showTelemetryModal: PropTypes.bool
54+
};
55+
return AppStateWrapper;
56+
};
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {createStore, combineReducers, compose, Store} from 'redux';
2+
import localesReducer, {initLocale, localesInitialState} from '../reducers/locales';
3+
import locales from 'scratch-l10n';
4+
import {detectLocale} from './detect-locale';
5+
import {GUIConfig} from '../gui-config';
6+
7+
interface WindowWithDevtools {
8+
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose;
9+
}
10+
11+
const composeEnhancers = (window as WindowWithDevtools).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
12+
13+
// TypeScript doesn't know about require here, and we don't want to change behavior, so...
14+
declare function require(path: '../reducers/gui'): typeof import('../reducers/gui');
15+
declare function require(path: 'scratch-paint'): typeof import('scratch-paint');
16+
declare function require(path: '../legacy-config'): typeof import('../legacy-config');
17+
18+
export interface EditorStateParams {
19+
localesOnly?: boolean;
20+
isFullScreen?: boolean;
21+
isPlayerOnly?: boolean;
22+
showTelemetryModal?: boolean;
23+
}
24+
25+
/**
26+
* Manages an editor's Redux state.
27+
*
28+
* To be used in tandem with an AppStateHOC component to be provided to the editor.
29+
*/
30+
export class EditorState {
31+
/**
32+
* The redux store that this class wraps.
33+
*/
34+
public readonly store: Store<unknown>;
35+
36+
constructor (params: EditorStateParams, configFactory: () => GUIConfig) {
37+
let initialState = {};
38+
let reducers = {};
39+
let enhancer;
40+
41+
let initializedLocales = localesInitialState;
42+
const locale = detectLocale(Object.keys(locales));
43+
if (locale !== 'en') {
44+
initializedLocales = initLocale(initializedLocales, locale);
45+
}
46+
if (params.localesOnly) {
47+
// Used for instantiating minimal state for the unsupported
48+
// browser modal
49+
reducers = {locales: localesReducer};
50+
initialState = {locales: initializedLocales};
51+
enhancer = composeEnhancers();
52+
} else {
53+
// You are right, this is gross. But it's necessary to avoid
54+
// importing unneeded code that will crash unsupported browsers.
55+
const guiRedux = require('../reducers/gui');
56+
const guiReducer = guiRedux.default;
57+
const {
58+
buildInitialState,
59+
guiMiddleware,
60+
initFullScreen,
61+
initPlayer,
62+
initTelemetryModal
63+
} = guiRedux;
64+
const {ScratchPaintReducer} = require('scratch-paint');
65+
66+
const configOrLegacy = configFactory ?
67+
configFactory() :
68+
require('../legacy-config').legacyConfig;
69+
70+
let initializedGui = buildInitialState(configOrLegacy);
71+
if (params.isFullScreen || params.isPlayerOnly) {
72+
if (params.isFullScreen) {
73+
initializedGui = initFullScreen(initializedGui);
74+
}
75+
if (params.isPlayerOnly) {
76+
initializedGui = initPlayer(initializedGui);
77+
}
78+
} else if (params.showTelemetryModal) {
79+
initializedGui = initTelemetryModal(initializedGui);
80+
}
81+
reducers = {
82+
locales: localesReducer,
83+
scratchGui: guiReducer,
84+
scratchPaint: ScratchPaintReducer
85+
};
86+
initialState = {
87+
locales: initializedLocales,
88+
scratchGui: initializedGui
89+
};
90+
enhancer = composeEnhancers(guiMiddleware);
91+
}
92+
const reducer = combineReducers(reducers);
93+
this.store = createStore(reducer, initialState, enhancer);
94+
}
95+
96+
dispatch (action) {
97+
this.store.dispatch(action);
98+
}
99+
}

0 commit comments

Comments
 (0)