Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ extends:
- eslint-config-shakacode
- prettier

globals:
__DEBUG_SERVER_ERRORS__: true
__SERVER_ERRORS__: true
Comment on lines -10 to -12
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not used.


parserOptions:
# We have @babel/eslint-parser from eslint-config-shakacode, but don't use Babel in the main project
requireConfigFile: false
Expand Down Expand Up @@ -89,3 +85,28 @@ overrides:
- error
- patterns:
- "../*"
- files: "k6/**/*.{js,ts}"
globals:
# See https://github.com/grafana/k6-docs/blob/65d9f8a9a53f57a4f416763f8020d0c7f40eb976/docs/sources/.eslintrc.js
__ENV: readonly
__VU: readonly
__ITER: readonly
console: readonly
open: readonly
window: readonly
setInterval: readonly
clearInterval: readonly
setTimeout: readonly
clearTimeout: readonly
rules:
"import/extensions":
- error
- ignorePackages
"import/no-unresolved":
- error
# k6 and k6/... have to be installed globally and can't be resolved by ESLint
- { ignore: ["k6.*"] }
no-use-before-define:
- error
# We want to allow function.name in options, and place them at the top of the file
- { functions: false }
30 changes: 29 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,12 +293,40 @@ If you run `rspec` at the top level, you'll see this message: `require': cannot

After running a test, you can view the coverage results in SimpleCov reports by opening `coverage/index.html`.

### Benchmarking
You'll need to [install `k6`](https://grafana.com/docs/k6/latest/set-up/install-k6/) first and start the dummy app in production mode (using [bin/prod](https://github.com/shakacode/react_on_rails_pro/blob/master/spec/dummy/bin/prod)).
You can remove even more overhead by using
```sh
bin/prod &> /dev/null
```

The benchmarking scripts are in `k6` directory, so you can run, for example:
```sh
k6 run k6/root.js
```
to exercise the main page of the dummy app.

For browser tests like `k6/streaming.js`, it's recommended to include `K6_BROWSER_LOG=fatal` for actual measurement after verifying the tests work.
It avoids unnecessary overhead from outputting console messages.

For significant changes, please make sure to run all benchmarks before and after, and include the results in the PR.
Later they will be added to CI, if we can make sure the results there are stable enough.

If you add significant new functionality and add a page in the dummy app showing it, consider adding the corresponding benchmark as well.

#### Debugging the benchmark scripts
You can add `-e DEBUG_K6=true` to run only a single iteration while showing HTTP requests and responses. See
[How to debug k6 load testing scripts](https://github.com/grafana/k6-learn/blob/main/Modules/III-k6-Intermediate/01-How-to-debug-k6-load-testing-scripts.md) for more suggestions if needed.

### Debugging
Start the sample app like this for some debug printing:

```sh
TRACE_REACT_ON_RAILS=true && foreman start -f Procfile.dev
TRACE_REACT_ON_RAILS=true overmind start -f Procfile.dev
```
Using `overmind` instead of `foreman` lets you restart separate processes, connect with them when stopped with a `debugger` call, and so on.
See https://railsnotes.xyz/blog/overmind-better-bin-dev-for-your-procfile-dev for more details.
If you don't need that, you can use `bin/dev` or `foreman` as well.

# Releasing
Contact Justin Gordon, [email protected]
Expand Down
56 changes: 56 additions & 0 deletions k6/lib/util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
export const url = (path) => `${__ENV.BASE_URL ?? 'http://localhost:3000'}/${path}`;

/** @type {(envVar: string) => boolean} */
const envToBoolean = (envVar) => {
const value = __ENV[envVar];
return !!value && ['true', '1', 'yes'].includes(value.toLowerCase());
};

/**
* @param {boolean} [inScenario=isBrowser] Is this used as `scenarios: { <scenarioName>: defaultOptions(...) }`?
* @param {boolean} [isBrowser=false] Is this a browser test?
* @param {boolean} [isDebug=env.DEBUG_K6] Are we running in debug mode?
* @param {import('k6/options').Options} [otherOptions] Other options to merge in
* @return {import('k6/options').Options}
* */
export const defaultOptions = ({
isBrowser = false,
isDebug = envToBoolean('DEBUG_K6'),
// Browser tests options can only be set inside `scenarios`
// https://grafana.com/docs/k6/latest/using-k6-browser/
inScenario = isBrowser,
...otherOptions
} = {}) => {
const thresholds = {
checks: ['rate>0.90'],
http_req_failed: ['rate<0.05'],
};
const baseOptions = isDebug
? {
vus: 1,
iterations: 1,
httpDebug: inScenario ? undefined : 'full',
thresholds,
...otherOptions,
}
: {
vus: 10,
duration: '30s',
thresholds,
...otherOptions,
};
if (inScenario) {
// See https://github.com/grafana/k6-learn/blob/main/Modules/III-k6-Intermediate/08-Setting-load-profiles-with-executors.md
baseOptions.executor = isDebug ? 'shared-iterations' : 'constant-vus';
}
return isBrowser
? {
...baseOptions,
options: {
browser: {
type: 'chromium',
},
},
}
: baseOptions;
};
24 changes: 24 additions & 0 deletions k6/root.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { check } from 'k6';
import http from 'k6/http';
import { defaultOptions, url } from './lib/util.js';

export const options = defaultOptions();

export default () => {
const rootUrl = url('');
check(http.get(rootUrl), {
'status was 200': (res) => res.status === 200,
'renders components successfully': (res) => {
const body = res.html().text();
return Object.entries({
// This is visible on the page in the browser 4 times, but for some reason, the one under
// "Server Rendered React Component Without Redux" is missing in `body`.
'Hello, Mr. Server Side Rendering!': 3,
'Hello, Mrs. Client Side Rendering!': 2,
'Hello, Mrs. Client Side Hello Again!': 1,
'Hello ES5, Mrs. Client Side Rendering!': 1,
'Hello WORLD! Will this work?? YES! Time to visit Maui': 1,
}).every(([text, count]) => body.split(text).length >= count + 1);
},
});
};
34 changes: 34 additions & 0 deletions k6/streaming.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { check } from 'k6';
import { browser } from 'k6/browser';
import { defaultOptions, url } from './lib/util.js';

const streamingUrl = url('stream_async_components?delay=5');

export const options = {
scenarios: {
browser: defaultOptions({ isBrowser: true }),
},
};

export default async () => {
const page = await browser.newPage();
try {
const response = await page.goto(streamingUrl);
check(response, {
'status was 200': (res) => res.status() === 200,
});
await page.waitForFunction(() => !document.body.textContent.includes('Loading'), {
// in milliseconds
timeout: 5000,
});
check(await page.locator('html').textContent(), {
'has all comments': (text) => {
// can't define commentIds as a constant outside, this runs in browser context
const commentIds = [1, 2, 3, 4];
return commentIds.every((id) => text.includes(`Comment ${id}`));
},
});
} finally {
await page.close();
}
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@tsconfig/node14": "^14.1.2",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/k6": "^0.54.2",
"@types/lockfile": "^1.0.4",
"@types/touch": "^3.1.5",
"babel-jest": "^29.7.0",
Expand Down
7 changes: 6 additions & 1 deletion spec/dummy/app/views/pages/stream_async_components.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
<%= stream_react_component("StreamAsyncComponents", props: @app_props_server_render, prerender: true, trace: true, id: "StreamAsyncComponents-react-component-0") %>
<%=
props = @app_props_server_render
delay = params[:delay]
props = props.merge(baseDelayMs: delay.to_i) unless delay.nil?
stream_react_component("StreamAsyncComponents", props: props, prerender: true, trace: true, id: "StreamAsyncComponents-react-component-0")
%>
<hr/>

<h1>React Rails Server Streaming Server Rendered Async React Components</h1>
3 changes: 3 additions & 0 deletions spec/dummy/bin/prod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash

RAILS_ENV=production NODE_ENV=production overmind start -f Procfile.static
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ const delayPromise = (promise, ms) => new Promise((resolve) => setTimeout(() =>

const cachedFetches = {};

const AsyncPost = async () => {
const AsyncPost = async ({ delayMs }) => {
console.log('Hello from AsyncPost');
const post = (cachedFetches['post'] ??= await delayPromise(
fetch('https://jsonplaceholder.org/posts/1'),
2000,
delayMs,
).then((response) => response.json()));

// Uncomment to test handling of errors occuring outside of the shell
Expand All @@ -26,10 +26,10 @@ const AsyncPost = async () => {
);
};

const AsyncComment = async ({ commentId }) => {
const AsyncComment = async ({ commentId, delayMs }) => {
const comment = (cachedFetches[commentId] ??= await delayPromise(
fetch(`https://jsonplaceholder.org/comments/${commentId}`),
2000 + commentId * 1000,
delayMs,
).then((response) => response.json()));
console.log('Hello from AsyncComment', commentId);
return (
Expand All @@ -40,8 +40,8 @@ const AsyncComment = async ({ commentId }) => {
);
};

function StreamAsyncComponents(props) {
const [name, setName] = useState(props.helloWorldData.name);
function StreamAsyncComponents({ helloWorldData, baseDelayMs = 1000 }) {
const [name, setName] = useState(helloWorldData.name);

// Uncomment to test error handling during rendering the shell
// throw new Error('Hello from StreamAsyncComponents');
Expand All @@ -57,13 +57,13 @@ function StreamAsyncComponents(props) {
<br />
<br />
<Suspense fallback={<div>Loading...</div>}>
<AsyncPost />
<AsyncPost delayMs={baseDelayMs} />
</Suspense>
<br />
<h1 style={{ fontSize: '30px', fontWeight: 'bold' }}>Comments Fetched Asynchronously on Server</h1>
{[1, 2, 3, 4].map((commentId) => (
<Suspense key={commentId} fallback={<div>Loading Comment {commentId}...</div>}>
<AsyncComment commentId={commentId} />
<AsyncComment commentId={commentId} delayMs={(2 + commentId) * baseDelayMs} />
</Suspense>
))}
</div>
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1789,6 +1789,11 @@
dependencies:
"@types/node" "*"

"@types/k6@^0.54.2":
version "0.54.2"
resolved "https://registry.yarnpkg.com/@types/k6/-/k6-0.54.2.tgz#944d6e20881d0fed3123742654ec8a12e175ea49"
integrity sha512-B5LPxeQm97JnUTpoKNE1UX9jFp+JiJCAXgZOa2P7aChxVoPQXKfWMzK+739xHq3lPkKj1aV+HeOxkP56g/oWBg==

"@types/lockfile@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@types/lockfile/-/lockfile-1.0.4.tgz#9d6a6d1b6dbd4853cecc7f334bc53ea0ff363b8e"
Expand Down