└── local-state
├── .gitignore
├── README.md
├── config
├── env.js
├── getHttpsConfig.js
├── jest
│ ├── cssTransform.js
│ └── fileTransform.js
├── modules.js
├── paths.js
├── pnpTs.js
├── webpack.config.js
└── webpackDevServer.config.js
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── schema.server.graphql
├── screenshot-01.PNG
├── scripts
├── build.js
└── start.js
├── server.js
├── src
├── __generated__
│ ├── AppQuery.graphql.js
│ ├── FooSwitcherQuery.graphql.js
│ ├── Navigation_currentFoo.graphql.js
│ ├── Navigation_foo_count.graphql.js
│ └── SubNavigationQuery.graphql.js
├── components
│ ├── App.js
│ ├── FooSwitcher.js
│ ├── Navigation
│ │ ├── MainNavigation.jsx
│ │ ├── Navigation.jsx
│ │ ├── SubNavigation.jsx
│ │ └── __generated__
│ │ │ ├── MainNavigation_currentFoo.graphql.js
│ │ │ ├── MainNavigation_fooCount.graphql.js
│ │ │ ├── Navigation_data.graphql.js
│ │ │ └── SubNavigationQuery.graphql.js
│ ├── Root.jsx
│ └── __generated__
│ │ ├── AppQuery.graphql.js
│ │ └── FooSwitcherQuery.graphql.js
├── index.js
├── relay
│ ├── RelayEnvironment.js
│ ├── fetchGraphQL.js
│ ├── handlers
│ │ └── CurrentFoo.js
│ ├── mutations
│ │ └── updateFoo.js
│ └── schema.client.graphql
└── serviceWorker.js
└── yarn.lock
/local-state/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/local-state/README.md:
--------------------------------------------------------------------------------
1 | # Local State
2 |
3 | 
4 |
5 | The purpose of this is trying to initialize currentFoo (local) with the value of primaryFoo (remote), so that currentFoo behaves just like if it was a remote Foo. Meaning you can query it exactly like you would if it was not local. And from anywhere in the App.
6 |
7 | ```
8 | # primaryFoo {
9 | # id
10 | # uuid
11 | # name
12 | # type
13 | # entities(types: [BAR, BAZ]) {
14 | # totalCount
15 | # edges {
16 | # node {
17 | # id
18 | # uuid
19 | # name
20 | # type
21 | # }
22 | # }
23 | # }
24 | # }
25 | ```
26 |
27 | Since Relay indexes Records with the full connection signature and parameters of the query/fragment it seems to be quite cumbesome to have eg. connections with differrent signatures, since those are not stored in the cache initially. It also goes against the Relay philosophy to query for more data than you need in a component, but this is necessary in AppQuery and FooSwitcher, to include all fields the "application" needs (not what the component needs).
28 |
29 | I made a few workarounds to just make it "work" for demo purposes but as you will see they have great limitations and the solution right now is very bad and I seek advice on how to do this in a proper way with Relay. I suspect that Relay might not be mature enough yet to handle local data things such as these, but please prove my fears wrong and send a PR, or create an issue and share your ideas and we can try them out :D.
30 |
31 | ## How it should work
32 |
33 | * currentFoo should be automatically initialized to the value of primaryFoo on page load/refresh (primaryFoo should only be used for setting currentFoo initially - nothing else!)
34 | * If currentFoo has only 1 entity of Bar or Baz it should not pop out the SubNavigation when you click "entities" but navigate directly to it (routing is deliberately left out for this example, since that is not important). If there are more than 1 entities of type Bar or Baz, the SubNavigation should pop open
35 | * You can switch currentFoo by selecting a Foo in the FooSwitcher component, which is both available from the MainNavigation and SubNavigation
36 | * currentFoo should behave just like any other record of type Foo from the server schema and not be limited to certain pre-defined combination of query args. If you ask for currentFoo in a query or fragment, all fields should be available - it should not only be stored under the exact query signature that it was fetched with when it got stored initially - there has to be some flexibility so we can query it like if it was the primaryFoo from the remote / server schema
37 |
38 | ## Hacks I had to make to make this "work" and the limitations that comes
39 |
40 | ### The first thing you will notice is in AppQuery
41 |
42 | ```graphql
43 | query AppQuery {
44 | primaryFoo @__clientField(handle: "currentFoo") {
45 | id
46 | uuid
47 | name
48 | type
49 | entities(types: [BAR, BAZ]) {
50 | totalCount
51 | edges {
52 | node {
53 | id
54 | uuid
55 | name
56 | type
57 | }
58 | }
59 | }
60 | }
61 | }
62 | ```
63 |
64 | #### Problems
65 |
66 | * This component has no direct requirement for primaryFoo, so this seems really awkward just to initialize currentFoo to primaryFoo
67 | * Also currentFoo is stored with a key signature matching the exact parameters (and directives if there were any), and it needs to include all fields for them to get saved.
68 | * It can only be fetched by including all fields with the exact same parameters (and directives if there were any).
69 | * This makes it very in-flexible and impossible to do paging over the entities connection in the SubNavigation component where a @connection directive and first pareter would be needed, but is simply not possible to add, without some very very nasty hacks in handler functions.
70 |
71 | ### The next big problem is in SubNavigation
72 |
73 | ```graphql
74 | query SubNavigationQuery {
75 | hello
76 | localState {
77 | currentFoo {
78 | name
79 | entities(types: [BAR, BAZ]) { # PROBLEM
80 | totalCount
81 | edges {
82 | node {
83 | uuid
84 | name
85 | }
86 | }
87 | }
88 | }
89 | }
90 | }
91 | ```
92 |
93 | #### Problems
94 |
95 | * You need to include something in the query that is not local state, which is the reason for including the hello field. This is not a big problem, but still something I hope gets fixed.
96 | * The ideal query would be something like:
97 | ```graphql
98 | query SubNavigationQuery {
99 | localState {
100 | currentFoo {
101 | name
102 | entities(first: 10, types: [BAR, BAZ]) @connection(key: "SubNavigation_entities") {
103 | totalCount
104 | edges {
105 | node {
106 | uuid
107 | name
108 | }
109 | }
110 | }
111 | }
112 | }
113 | }
114 | ```
115 | * I guess there is no way to add @connection and first: 10 to do pagination, since that is not how entities are stored in AppQuery and FooSwitcherQuery
116 |
117 | ### FooSwitcherQuery needs the exact signature and to include all fields as well
118 |
119 | ```graphql
120 | query FooSwitcherQuery {
121 | entities(types: [FOO]) {
122 | edges {
123 | node {
124 | id
125 | uuid
126 | name
127 | type
128 | ... on Foo {
129 | entities(types: [BAR, BAZ]) {
130 | totalCount
131 | edges {
132 | node {
133 | id
134 | uuid
135 | name
136 | type
137 | }
138 | }
139 | }
140 | }
141 | }
142 | }
143 | }
144 | }
145 | ```
146 |
147 | Where instead it should just be:
148 |
149 | ```graphql
150 | query FooSwitcherQuery {
151 | entities(types: [FOO]) {
152 | edges {
153 | node {
154 | id
155 | name
156 | }
157 | }
158 | }
159 | }
160 | ```
161 |
162 | ## How to install and run
163 | To install all dependencies
164 | ```
165 | yarn
166 | ```
167 |
168 | To start the graphql server
169 | ```
170 | node server
171 | ```
172 |
173 | To start the client
174 | ```
175 | yarn get-schema
176 | ```
177 | You will see an error saying "Syntax Error: Unexpected "$".", so just go to the schema.server.graphql file and remove the line at the top and save.` - didn't have time to fix this yet :D
178 |
179 | ```
180 | yarn relay
181 | yarn start
182 | ```
183 |
184 | Sorry for the poor formatting/linting and code quality. This was done super quick to demonstrate some serious issues I'm facing with trying to set currentFoo in localState and especially to initialize it from primaryFoo. But I have no clue why?
185 |
--------------------------------------------------------------------------------
/local-state/config/env.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const paths = require('./paths');
6 |
7 | // Make sure that including paths.js after env.js will read .env variables.
8 | delete require.cache[require.resolve('./paths')];
9 |
10 | const NODE_ENV = process.env.NODE_ENV;
11 | if (!NODE_ENV) {
12 | throw new Error(
13 | 'The NODE_ENV environment variable is required but was not specified.'
14 | );
15 | }
16 |
17 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
18 | const dotenvFiles = [
19 | `${paths.dotenv}.${NODE_ENV}.local`,
20 | `${paths.dotenv}.${NODE_ENV}`,
21 | // Don't include `.env.local` for `test` environment
22 | // since normally you expect tests to produce the same
23 | // results for everyone
24 | NODE_ENV !== 'test' && `${paths.dotenv}.local`,
25 | paths.dotenv,
26 | ].filter(Boolean);
27 |
28 | // Load environment variables from .env* files. Suppress warnings using silent
29 | // if this file is missing. dotenv will never modify any environment variables
30 | // that have already been set. Variable expansion is supported in .env files.
31 | // https://github.com/motdotla/dotenv
32 | // https://github.com/motdotla/dotenv-expand
33 | dotenvFiles.forEach(dotenvFile => {
34 | if (fs.existsSync(dotenvFile)) {
35 | require('dotenv-expand')(
36 | require('dotenv').config({
37 | path: dotenvFile,
38 | })
39 | );
40 | }
41 | });
42 |
43 | // We support resolving modules according to `NODE_PATH`.
44 | // This lets you use absolute paths in imports inside large monorepos:
45 | // https://github.com/facebook/create-react-app/issues/253.
46 | // It works similar to `NODE_PATH` in Node itself:
47 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
48 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
49 | // Otherwise, we risk importing Node.js core modules into an app instead of webpack shims.
50 | // https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
51 | // We also resolve them to make sure all tools using them work consistently.
52 | const appDirectory = fs.realpathSync(process.cwd());
53 | process.env.NODE_PATH = (process.env.NODE_PATH || '')
54 | .split(path.delimiter)
55 | .filter(folder => folder && !path.isAbsolute(folder))
56 | .map(folder => path.resolve(appDirectory, folder))
57 | .join(path.delimiter);
58 |
59 | // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
60 | // injected into the application via DefinePlugin in webpack configuration.
61 | const REACT_APP = /^REACT_APP_/i;
62 |
63 | function getClientEnvironment(publicUrl) {
64 | const raw = Object.keys(process.env)
65 | .filter(key => REACT_APP.test(key))
66 | .reduce(
67 | (env, key) => {
68 | env[key] = process.env[key];
69 | return env;
70 | },
71 | {
72 | // Useful for determining whether we’re running in production mode.
73 | // Most importantly, it switches React into the correct mode.
74 | NODE_ENV: process.env.NODE_ENV || 'development',
75 | // Useful for resolving the correct path to static assets in `public`.
76 | // For example,
.
77 | // This should only be used as an escape hatch. Normally you would put
78 | // images into the `src` and `import` them in code to get their paths.
79 | PUBLIC_URL: publicUrl,
80 | // We support configuring the sockjs pathname during development.
81 | // These settings let a developer run multiple simultaneous projects.
82 | // They are used as the connection `hostname`, `pathname` and `port`
83 | // in webpackHotDevClient. They are used as the `sockHost`, `sockPath`
84 | // and `sockPort` options in webpack-dev-server.
85 | WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST,
86 | WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH,
87 | WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT,
88 | }
89 | );
90 | // Stringify all values so we can feed into webpack DefinePlugin
91 | const stringified = {
92 | 'process.env': Object.keys(raw).reduce((env, key) => {
93 | env[key] = JSON.stringify(raw[key]);
94 | return env;
95 | }, {}),
96 | };
97 |
98 | return { raw, stringified };
99 | }
100 |
101 | module.exports = getClientEnvironment;
102 |
--------------------------------------------------------------------------------
/local-state/config/getHttpsConfig.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const crypto = require('crypto');
6 | const chalk = require('react-dev-utils/chalk');
7 | const paths = require('./paths');
8 |
9 | // Ensure the certificate and key provided are valid and if not
10 | // throw an easy to debug error
11 | function validateKeyAndCerts({ cert, key, keyFile, crtFile }) {
12 | let encrypted;
13 | try {
14 | // publicEncrypt will throw an error with an invalid cert
15 | encrypted = crypto.publicEncrypt(cert, Buffer.from('test'));
16 | } catch (err) {
17 | throw new Error(
18 | `The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}`
19 | );
20 | }
21 |
22 | try {
23 | // privateDecrypt will throw an error with an invalid key
24 | crypto.privateDecrypt(key, encrypted);
25 | } catch (err) {
26 | throw new Error(
27 | `The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${
28 | err.message
29 | }`
30 | );
31 | }
32 | }
33 |
34 | // Read file and throw an error if it doesn't exist
35 | function readEnvFile(file, type) {
36 | if (!fs.existsSync(file)) {
37 | throw new Error(
38 | `You specified ${chalk.cyan(
39 | type
40 | )} in your env, but the file "${chalk.yellow(file)}" can't be found.`
41 | );
42 | }
43 | return fs.readFileSync(file);
44 | }
45 |
46 | // Get the https config
47 | // Return cert files if provided in env, otherwise just true or false
48 | function getHttpsConfig() {
49 | const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env;
50 | const isHttps = HTTPS === 'true';
51 |
52 | if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) {
53 | const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE);
54 | const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE);
55 | const config = {
56 | cert: readEnvFile(crtFile, 'SSL_CRT_FILE'),
57 | key: readEnvFile(keyFile, 'SSL_KEY_FILE'),
58 | };
59 |
60 | validateKeyAndCerts({ ...config, keyFile, crtFile });
61 | return config;
62 | }
63 | return isHttps;
64 | }
65 |
66 | module.exports = getHttpsConfig;
67 |
--------------------------------------------------------------------------------
/local-state/config/jest/cssTransform.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // This is a custom Jest transformer turning style imports into empty objects.
4 | // http://facebook.github.io/jest/docs/en/webpack.html
5 |
6 | module.exports = {
7 | process() {
8 | return 'module.exports = {};';
9 | },
10 | getCacheKey() {
11 | // The output is always the same.
12 | return 'cssTransform';
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/local-state/config/jest/fileTransform.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const camelcase = require('camelcase');
5 |
6 | // This is a custom Jest transformer turning file imports into filenames.
7 | // http://facebook.github.io/jest/docs/en/webpack.html
8 |
9 | module.exports = {
10 | process(src, filename) {
11 | const assetFilename = JSON.stringify(path.basename(filename));
12 |
13 | if (filename.match(/\.svg$/)) {
14 | // Based on how SVGR generates a component name:
15 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6
16 | const pascalCaseFilename = camelcase(path.parse(filename).name, {
17 | pascalCase: true,
18 | });
19 | const componentName = `Svg${pascalCaseFilename}`;
20 | return `const React = require('react');
21 | module.exports = {
22 | __esModule: true,
23 | default: ${assetFilename},
24 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) {
25 | return {
26 | $$typeof: Symbol.for('react.element'),
27 | type: 'svg',
28 | ref: ref,
29 | key: null,
30 | props: Object.assign({}, props, {
31 | children: ${assetFilename}
32 | })
33 | };
34 | }),
35 | };`;
36 | }
37 |
38 | return `module.exports = ${assetFilename};`;
39 | },
40 | };
41 |
--------------------------------------------------------------------------------
/local-state/config/modules.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const paths = require('./paths');
6 | const chalk = require('react-dev-utils/chalk');
7 | const resolve = require('resolve');
8 |
9 | /**
10 | * Get additional module paths based on the baseUrl of a compilerOptions object.
11 | *
12 | * @param {Object} options
13 | */
14 | function getAdditionalModulePaths(options = {}) {
15 | const baseUrl = options.baseUrl;
16 |
17 | // We need to explicitly check for null and undefined (and not a falsy value) because
18 | // TypeScript treats an empty string as `.`.
19 | if (baseUrl == null) {
20 | // If there's no baseUrl set we respect NODE_PATH
21 | // Note that NODE_PATH is deprecated and will be removed
22 | // in the next major release of create-react-app.
23 |
24 | const nodePath = process.env.NODE_PATH || '';
25 | return nodePath.split(path.delimiter).filter(Boolean);
26 | }
27 |
28 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
29 |
30 | // We don't need to do anything if `baseUrl` is set to `node_modules`. This is
31 | // the default behavior.
32 | if (path.relative(paths.appNodeModules, baseUrlResolved) === '') {
33 | return null;
34 | }
35 |
36 | // Allow the user set the `baseUrl` to `appSrc`.
37 | if (path.relative(paths.appSrc, baseUrlResolved) === '') {
38 | return [paths.appSrc];
39 | }
40 |
41 | // If the path is equal to the root directory we ignore it here.
42 | // We don't want to allow importing from the root directly as source files are
43 | // not transpiled outside of `src`. We do allow importing them with the
44 | // absolute path (e.g. `src/Components/Button.js`) but we set that up with
45 | // an alias.
46 | if (path.relative(paths.appPath, baseUrlResolved) === '') {
47 | return null;
48 | }
49 |
50 | // Otherwise, throw an error.
51 | throw new Error(
52 | chalk.red.bold(
53 | "Your project's `baseUrl` can only be set to `src` or `node_modules`." +
54 | ' Create React App does not support other values at this time.'
55 | )
56 | );
57 | }
58 |
59 | /**
60 | * Get webpack aliases based on the baseUrl of a compilerOptions object.
61 | *
62 | * @param {*} options
63 | */
64 | function getWebpackAliases(options = {}) {
65 | const baseUrl = options.baseUrl;
66 |
67 | if (!baseUrl) {
68 | return {};
69 | }
70 |
71 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
72 |
73 | if (path.relative(paths.appPath, baseUrlResolved) === '') {
74 | return {
75 | src: paths.appSrc,
76 | };
77 | }
78 | }
79 |
80 | /**
81 | * Get jest aliases based on the baseUrl of a compilerOptions object.
82 | *
83 | * @param {*} options
84 | */
85 | function getJestAliases(options = {}) {
86 | const baseUrl = options.baseUrl;
87 |
88 | if (!baseUrl) {
89 | return {};
90 | }
91 |
92 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
93 |
94 | if (path.relative(paths.appPath, baseUrlResolved) === '') {
95 | return {
96 | '^src/(.*)$': '/src/$1',
97 | };
98 | }
99 | }
100 |
101 | function getModules() {
102 | // Check if TypeScript is setup
103 | const hasTsConfig = fs.existsSync(paths.appTsConfig);
104 | const hasJsConfig = fs.existsSync(paths.appJsConfig);
105 |
106 | if (hasTsConfig && hasJsConfig) {
107 | throw new Error(
108 | 'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.'
109 | );
110 | }
111 |
112 | let config;
113 |
114 | // If there's a tsconfig.json we assume it's a
115 | // TypeScript project and set up the config
116 | // based on tsconfig.json
117 | if (hasTsConfig) {
118 | const ts = require(resolve.sync('typescript', {
119 | basedir: paths.appNodeModules,
120 | }));
121 | config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config;
122 | // Otherwise we'll check if there is jsconfig.json
123 | // for non TS projects.
124 | } else if (hasJsConfig) {
125 | config = require(paths.appJsConfig);
126 | }
127 |
128 | config = config || {};
129 | const options = config.compilerOptions || {};
130 |
131 | const additionalModulePaths = getAdditionalModulePaths(options);
132 |
133 | return {
134 | additionalModulePaths: additionalModulePaths,
135 | webpackAliases: getWebpackAliases(options),
136 | jestAliases: getJestAliases(options),
137 | hasTsConfig,
138 | };
139 | }
140 |
141 | module.exports = getModules();
142 |
--------------------------------------------------------------------------------
/local-state/config/paths.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const fs = require('fs');
5 | const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath');
6 |
7 | // Make sure any symlinks in the project folder are resolved:
8 | // https://github.com/facebook/create-react-app/issues/637
9 | const appDirectory = fs.realpathSync(process.cwd());
10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
11 |
12 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer
13 | // "public path" at which the app is served.
14 | // webpack needs to know it to put the right