Skip to content
This repository has been archived by the owner on Oct 1, 2024. It is now read-only.

Better patterns for sharing headers/data #1411

Merged
merged 2 commits into from
May 4, 2020
Merged
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
2 changes: 2 additions & 0 deletions gems/quilt_rails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

- Add: Expose a `data` arugment on `render_react` to share data to the React server using the `X-Quilt-Data` header ([#1411](https://github.com/Shopify/quilt/pull/1411))

## [1.11.1] - 2020-03-24

- add `allowed_push_host` in gemspec that is required to publish
Expand Down
57 changes: 19 additions & 38 deletions gems/quilt_rails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ yarn add react react-dom
# Add Typescript
yarn add typescript @types/react @types/react-dom
```

##### Define typescript config

```json
// tsconfig.json
{
Expand All @@ -122,6 +124,7 @@ yarn add typescript @types/react @types/react-dom
"include": ["app/ui"]
}
```

```sh
yarn
dev up
Expand Down Expand Up @@ -326,67 +329,45 @@ function App() {
export default App;
```

##### Example: sending headers from Rails controller
##### Example: sending custom data from Rails controller

In some cases you may want to send custom headers or basic data from Rails to your React server. Quilt facilitates this case by providing consumers with a `headers` and `data` argument on the `render_react` call.

**Note:** The data passed should be data that is unlikely or will never change over the course of the session before they render any React components.

```ruby
class ReactController < ApplicationController
include Quilt::ReactRenderable

def index
render_react(headers: { 'x-custom-header': 'header-value-a' })
render_react(headers: {'x-custom-header': 'header-value-a'}, data: {'some_id': 123})
end
end
```

You will need to serialize the result of the useRequestHeader hook in order for it to persist to the client

```tsx
// app/ui/foundation/CustomUniversalProvider.tsx
import {createContext} from 'react';
import {createUniversalProvider} from '@shopify/react-universal-provider';

export const CustomContext = createContext<string | null>(null);
export const CustomUniversalProvider = createUniversalProvider('custom-key', CustomContext);
```
The React server will serialize the provided quilt data using `x-quilt-data` as the ID. You can then get this serialized data on the client with `getSerialized` from `@shopify/react-html`.

```tsx
// app/ui/index.tsx

import React from 'react';
import {useRequestHeader} from '@shopify/react-network';
import {CustomUniversalProvider} from './foundation/CustomUniversalProvider';
import {ComponentWithCustomHeader} from './components/ComponentWithCustomHeader';
import {getSerialized} from '@shopify/react-html';

const IS_CLIENT = typeof window !== 'undefined';

function App() {
// get `x-custom-header` from the request that was sent through Rails ReactController
const customHeader = useRequestHeader('x-custom-header');
// get `x-quilt-data` from the request that was sent through Rails ReactController
const quiltData = IS_CLIENT ? getSerialized<{[key: string]: any}>('x-quilt-data') : null;

return (
<CustomUniversalProvider value={customHeader}>
<h1>My application ❤️</h1>
<ComponentWithCustomHeader />
</CustomUniversalProvider>
);
// Logs {"x-custom-header":"header-value-a","some_id":123}
console.log(quiltData);

return <h1>Data: {quiltData}</h1>;
}

export default App;
```

```tsx
// app/ui/components/ComponentWithCustomHeader.tsx

import React, {useContext} from 'react';
import {CustomContext} from '../foundation/CustomUniversalProvider';

export function ComponentWithCustomHeader() {
// get `x-custom-header` from serialized context
// will be 'header-value-a' in this example
const customHeader = useContext(CustomContext);

return <span>{customHeader}</span>;
}
```

##### Example: redirecting

```tsx
Expand Down
18 changes: 11 additions & 7 deletions gems/quilt_rails/lib/quilt_rails/react_renderable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,29 @@ module Quilt
module ReactRenderable
include ReverseProxy::Controller

def render_react(headers: {})
def render_react(headers: {}, data: {})
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the example above should be using headers as well as data

raise DoNotIntegrationTestError if Rails.env.test?

# Allow concurrent loading to prevent this thread from blocking class
# loading in controllers called by the Node server.
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
call_proxy(headers)
call_proxy(headers, data)
end
end

private

def call_proxy(headers)
def call_proxy(headers, data)
if defined? ShopifySecurityBase
ShopifySecurityBase::HTTPHostRestriction.whitelist([Quilt.configuration.react_server_host]) do
proxy(headers)
proxy(headers, data)
end
else
proxy(headers)
proxy(headers, data)
end
end

def proxy(headers)
def proxy(headers, data)
url = "#{Quilt.configuration.react_server_protocol}://#{Quilt.configuration.react_server_host}"
Quilt::Logger.log("[ReactRenderable] proxying to React server at #{url}")

Expand All @@ -37,7 +37,11 @@ def proxy(headers)
end

begin
reverse_proxy(url, headers: headers.merge('X-CSRF-Token': form_authenticity_token)) do |callbacks|

reverse_proxy(
url,
headers: headers.merge('X-CSRF-Token': form_authenticity_token, 'X-Quilt-Data': headers.merge(data).to_json)
) do |callbacks|
callbacks.on_response do |status_code, _response|
Quilt::Logger.log("[ReactRenderable] #{url} returned #{status_code}")
end
Expand Down
26 changes: 24 additions & 2 deletions gems/quilt_rails/test/quilt_rails/react_renderable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,41 @@ def test_render_react_calls_reverse_proxy_with_server_uri_and_csrf
Rails.env.stubs(:test?).returns(false)
url = "#{Quilt.configuration.react_server_protocol}://#{Quilt.configuration.react_server_host}"

assert_equal(render_react, reverse_proxy(url, headers: { 'X-CSRF-Token': form_authenticity_token }))
assert_equal(
render_react,
reverse_proxy(
url,
headers: { 'X-CSRF-Token': form_authenticity_token, 'X-Quilt-Data': {}.to_json }
)
)
end

def test_render_react_calls_with_custom_headers
Rails.env.stubs(:test?).returns(false)
url = "#{Quilt.configuration.react_server_protocol}://#{Quilt.configuration.react_server_host}"

render_result = render_react(headers: { 'x-custom-header': 'test' })
proxy_result = reverse_proxy(url, headers: { 'x-custom-header': 'test', 'X-CSRF-Token': form_authenticity_token })
headers = {
'x-custom-header': 'test',
'X-CSRF-Token': form_authenticity_token,
'X-Quilt-Data': { 'x-custom-header': 'test' }.to_json,
}
proxy_result = reverse_proxy(url, headers: headers)

assert_equal(render_result, proxy_result)
end

def test_render_react_calls_reverse_proxy_with_header_data
Rails.env.stubs(:test?).returns(false)
url = "#{Quilt.configuration.react_server_protocol}://#{Quilt.configuration.react_server_host}"

headers = { 'X-CSRF-Token': form_authenticity_token, 'X-Quilt-Data': { 'X-Foo': 'bar' }.to_json }
assert_equal(
render_react(data: { 'X-Foo': 'bar' }),
reverse_proxy(url, headers: headers)
)
end

def test_render_react_errors_in_tests
Rails.env.stubs(:test?).returns(true)
assert_raises Quilt::ReactRenderable::DoNotIntegrationTestError do
Expand Down
4 changes: 4 additions & 0 deletions packages/react-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]

- Add: Serialize `x-quilt-data` received from the Rails server for use on the client ([#1411](https://github.com/Shopify/quilt/pull/1411))

## [0.10.0] - 2020-03-23

- Allow `assetName` to take a function for apps which need to serve multiple sub-apps based on path [[#1332]](https://github.com/Shopify/quilt/pull/1332)
Expand Down
8 changes: 8 additions & 0 deletions packages/react-server/src/render/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
HtmlContext,
stream,
} from '@shopify/react-html/server';
import {useSerialized} from '@shopify/react-html';
import {
applyToContext,
NetworkContext,
Expand Down Expand Up @@ -37,6 +38,10 @@ export interface RenderFunction {
(ctx: Context): React.ReactElement<any>;
}

interface Data {
value: {[key: string]: any} | undefined;
}

type Options = Pick<
NonNullable<ArgumentAtIndex<typeof extract, 1>>,
'afterEachPass' | 'betweenEachPass'
Expand Down Expand Up @@ -72,11 +77,14 @@ export function createRender(render: RenderFunction, options: Options = {}) {
const hydrationManager = new HydrationManager();

function Providers({children}: {children: React.ReactElement<any>}) {
const [, Serialize] = useSerialized<Data>('x-quilt-data');

return (
<AsyncAssetContext.Provider value={asyncAssetManager}>
<HydrationContext.Provider value={hydrationManager}>
<NetworkContext.Provider value={networkManager}>
{children}
<Serialize data={() => ctx.headers['x-quilt-data']} />
</NetworkContext.Provider>
</HydrationContext.Provider>
</AsyncAssetContext.Provider>
Expand Down
15 changes: 15 additions & 0 deletions packages/react-server/src/render/test/render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ describe('createRender', () => {
expect(await readStream(ctx.body)).toContain(myCoolApp);
});

it('response contains x-quilt-data from headers', async () => {
const myCoolApp = 'My cool app';
const customHeader = {'X-foo': 'bar'};
const ctx = createMockContext({
headers: {'x-quilt-data': JSON.stringify(customHeader)},
});

const renderFunction = createRender(() => <>{myCoolApp}</>);
await renderFunction(ctx, noop);

const response = await readStream(ctx.body);
expect(response).toContain('x-quilt-data');
expect(response).toContain('X-foo');
});

it('does not clobber proxies in the context object', async () => {
const headerValue = 'some-value';
const ctx = createMockContext({headers: {'some-header': headerValue}});
Expand Down
2 changes: 1 addition & 1 deletion packages/react-server/src/server/test/server.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('createServer()', () => {
const response = await wrapper.request();

expect(await response.text()).toStrictEqual(
`<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta http-equiv="X-UA-Compatible" content="IE=edge"/><meta name="referrer" content="never"/></head><body><div id="app"><div>markup</div></div></body></html>`,
`<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta http-equiv="X-UA-Compatible" content="IE=edge"/><meta name="referrer" content="never"/></head><body><div id="app"><div>markup</div></div><script type="text/json" data-serialized-id="x-quilt-data">undefined</script></body></html>`,
);
});

Expand Down