└── 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 | ![alt text](https://raw.githubusercontent.com/sorenhoyer/react-relay-examples/master/local-state/screenshot-01.PNG) 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