Skip to content

React forward ref #37

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 13, 2018
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
66 changes: 45 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,68 +6,92 @@ A React HOC for loading 3rd party scripts asynchronously. This HOC allows you to

## Usage

#### HOC api
#### Async Script HOC api

`makeAsyncScriptLoader(getScriptUrl, options)(Component)`

- `Component`: The *Component* to wrap.
- `getScriptUrl`: *string* or *function* that returns the full URL of the script tag.
- `options` *(optional)*:
- `callbackName`: *string* : If the script needs to call a global function when finished loading *(for example: `recaptcha/api.js?onload=callbackName`)*. Please provide the callback name here and it will be autoregistered on `window` for you.
- `globalName`: *string* : If wanted, provide the globalName of the loaded script. It'll be injected on the component with the same name *(ex: "grecaptcha")*
- `removeOnUnmount`: *boolean* **default=false** : If set to `true` removes the script tag on the component unmount
- `globalName`: *string* : Can provide the name of the global that the script attaches to `window`. Async-script will pass this as a prop to the wrapped component. *(`props[globalName] = window[globalName]`)*
- `removeOnUnmount`: *boolean* **default=false** : If set to `true` removes the script tag when component unmounts.

#### HOC Component props
```
const AsyncScriptComponent = makeAsyncScriptLoader(URL)(Component);
---
<AsyncScriptComponent asyncScriptOnLoad={callAfterScriptLoads} />
```
- `asyncScriptOnLoad`: *function* : called after script loads
- `asyncScriptOnLoad`: *function* : called after script finishes loading. *using `script.onload`*


#### HOC Instance methods
#### Ref and forwardRef

- `getComponent()`: Using this method call you can retrieve the child component ref instance (the *Component* that is wrapped)
`react-async-script` uses react's `forwardRef` method to pass along the `ref` applied to the wrapped component.

If you pass a `ref` prop you'll have access to your wrapped components instance. See the tests for detailed example.

Simple Example:
```
const AsyncHoc = makeAsyncScriptLoader(URL)(ComponentNeedsScript);

class DisplayComponent extends React.Component {
constructor(props) {
super(props);
this._internalRef = React.createRef();
}
componentDidMount() {
console.log("ComponentNeedsScript's Instance -", this._internalRef.current);
}
render() { return (<AsyncHoc ref={this._internalRef} />)}
}
```

##### Notes on Requirements

At least `React@16.4.1` is required due to `forwardRef` usage internally.


### Example

See https://github.com/dozoisch/react-google-recaptcha

```js
// recaptcha.js
export class ReCAPTCHA extends React.Component {
componentDidUpdate(prevProps) {
// recaptcha has loaded via async script
if (!prevProps.grecaptcha && this.props.grecaptcha) {
this.props.grecaptcha.render(this._container)
}
}
render() { return (
<div ref={(r) => this._container = r} />)
}
}

// recaptcha-wrapper.js
import React from "react";

import ReCAPTCHA from "./recaptcha";
import makeAsyncScriptLoader from "./react-async-script";
import makeAsyncScriptLoader from "react-async-script";
import { ReCAPTCHA } from "./recaptcha";

const callbackName = "onloadcallback";
const URL = `https://www.google.com/recaptcha/api.js?onload=${callbackName}&render=explicit`;
// the name of the global that recaptcha/api.js sets on window ie: window.grecaptcha
const globalName = "grecaptcha";

export default makeAsyncScriptLoader(URL, {
callbackName: callbackName,
globalName: globalName,
})(ReCAPTCHA);


// main.js
import React from "react";
import ReCAPTCHAWrapper from "./recaptcha-wrapper.js"

function onLoad() {
console.log("script loaded");
}

let reCAPTCHAprops = {
siteKey: "xxxxxxx",
//...
};
const onLoad = () => console.log("script loaded")

React.render(
<ReCAPTCHAWrapper asyncScriptOnLoad={onLoad} {...reCAPTCHAprops} />,
<ReCAPTCHAWrapper asyncScriptOnLoad={onLoad} />,
document.body
);
```
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,14 @@
"phantomjs": "^2.0.0",
"react": "^16.4.2",
"react-dom": "^16.4.2",
"react-is": "^16.4.2",
"webpack": "~1.14.0"
},
"peerDependencies": {
"react": ">=15.5.0"
"react": ">=16.4.1"
},
"dependencies": {
"hoist-non-react-statics": "^3.0.1",
"prop-types": ">=15.5.0"
"prop-types": "^15.5.0"
}
}
30 changes: 14 additions & 16 deletions src/async-script-loader.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, createElement } from "react";
import { Component, createElement, forwardRef } from "react";
import PropTypes from "prop-types";
import hoistStatics from "hoist-non-react-statics";

Expand All @@ -11,14 +11,13 @@ export default function makeAsyncScript(getScriptURL, options) {
options = options || {};
return function wrapWithAsyncScript(WrappedComponent) {
const wrappedComponentName =
WrappedComponent.displayName || WrappedComponent.name || "Component";
WrappedComponent.displayName || WrappedComponent.name || "Component";

class AsyncScriptLoader extends Component {
constructor(props, context) {
super(props, context)
this.state = {};
this.__scriptURL = "";
this.assignChildComponent = this.assignChildComponent.bind(this);
}

asyncScriptLoaderGetScriptLoaderID() {
Expand All @@ -34,14 +33,6 @@ export default function makeAsyncScript(getScriptURL, options) {
return this.__scriptURL;
}

assignChildComponent(ref) {
this.__childComponent = ref;
}

getComponent() {
return this.__childComponent;
}

asyncScriptLoaderHandleLoad(state) {
// use reacts setState callback to fire props.asyncScriptOnLoad with new state/entry
this.setState(state,
Expand Down Expand Up @@ -171,22 +162,29 @@ export default function makeAsyncScript(getScriptURL, options) {
render() {
const globalName = options.globalName;
// remove asyncScriptOnLoad from childProps
let { asyncScriptOnLoad, ...childProps } = this.props; // eslint-disable-line no-unused-vars
let { asyncScriptOnLoad, forwardedRef, ...childProps } = this.props; // eslint-disable-line no-unused-vars
if (globalName && typeof window !== "undefined") {
childProps[globalName] =
typeof window[globalName] !== "undefined"
? window[globalName]
: undefined;
}
childProps.ref = this.assignChildComponent;
childProps.ref = forwardedRef;
return createElement(WrappedComponent, childProps);
}
}
AsyncScriptLoader.displayName = `AsyncScriptLoader(${wrappedComponentName})`;
AsyncScriptLoader.propTypes = {

// Note the second param "ref" provided by React.forwardRef.
// We can pass it along to AsyncScriptLoader as a regular prop, e.g. "forwardedRef"
// And it can then be attached to the Component.
const ForwardedComponent = forwardRef((props, ref) => {
return createElement(AsyncScriptLoader, {...props, forwardedRef: ref });
});
ForwardedComponent.displayName = `AsyncScriptLoader(${wrappedComponentName})`;
ForwardedComponent.propTypes = {
asyncScriptOnLoad: PropTypes.func,
};

return hoistStatics(AsyncScriptLoader, WrappedComponent);
return hoistStatics(ForwardedComponent, WrappedComponent);
}
}
85 changes: 57 additions & 28 deletions test/async-script-loader-spec.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import React from "react";
import ReactDOM from "react-dom";
import ReactTestUtils from "react-dom/test-utils";
import * as ReactIs from "react-is";
import makeAsyncScriptLoader from "../src/async-script-loader";

class MockedComponent extends React.Component {
static callsACallback(fn) {
fn();
}

render() {
return <span/>;
}
Expand Down Expand Up @@ -52,14 +49,13 @@ describe("AsyncScriptLoader", () => {
}
const ComponentWrapper = makeAsyncScriptLoader(URL)(MockedComponent);
assert.equal(ComponentWrapper.displayName, "AsyncScriptLoader(MockedComponent)");
const instance = ReactTestUtils.renderIntoDocument(
ReactTestUtils.renderIntoDocument(
<ComponentWrapper asyncScriptOnLoad={asyncScriptOnLoadSpy} />
);
documentLoadScript(URL);

assert.ok(ReactTestUtils.isCompositeComponent(instance));
assert.ok(ReactTestUtils.isCompositeComponentWithType(instance, ComponentWrapper));
assert.isNotNull(ReactTestUtils.findRenderedComponentWithType(instance, MockedComponent));
assert.equal(ReactIs.isValidElementType(ComponentWrapper), true, "is valid elemnt type");
assert.equal(ReactIs.isForwardRef(<ComponentWrapper />), true, "is valid forwardRef");
assert.equal(hasScript(URL), true, "Url in document");
assert.equal(asyncScriptOnLoadCalled, true, "asyncScriptOnLoad callback called");
assert.equal(scriptLoaded, true, "script loaded state set");
Expand Down Expand Up @@ -97,40 +93,42 @@ describe("AsyncScriptLoader", () => {
asyncScriptOnLoadCalled = true;
}
const ComponentWrapper = makeAsyncScriptLoader(URL, { globalName: globalName })(MockedComponent);
const instance = ReactTestUtils.renderIntoDocument(
ReactTestUtils.renderIntoDocument(
<ComponentWrapper asyncScriptOnLoad={asyncScriptOnLoadSpy} />
);

assert.equal(hasScript(URL), false, "Url not in document");
assert.equal(asyncScriptOnLoadCalled, true, "asyncScriptOnLoad callback called");
ReactDOM.unmountComponentAtNode(ReactDOM.findDOMNode(instance));
instance.componentWillUnmount();
delete window[globalName];
});

it("should accept a function for scriptURL", () => {
const URL = "http://example.com/?url=function";
const ComponentWrapper = makeAsyncScriptLoader(() => URL)(MockedComponent);
const instance = ReactTestUtils.renderIntoDocument(
ReactTestUtils.renderIntoDocument(
<ComponentWrapper />
);

assert.equal(hasScript(URL), true, "Url in document");
ReactDOM.unmountComponentAtNode(ReactDOM.findDOMNode(instance));
instance.componentWillUnmount();
});

it("should expose statics", (done) => {
class MockedComponentWithStatic extends React.Component {
static callsACallback(fn) { fn(); }
render() { return <span/>; }
}
const URL = "http://example.com/?functions=true";
const ComponentWrapper = makeAsyncScriptLoader(URL)(MockedComponent);
const ComponentWrapper = makeAsyncScriptLoader(URL)(MockedComponentWithStatic);
ComponentWrapper.callsACallback(done);
});

it("should not remove tag script on removeOnUnmount option not set", () => {
const URL = "http://example.com/?removeOnUnmount=notset";
const ComponentWrapper = makeAsyncScriptLoader(URL)(MockedComponent);
const instance = ReactTestUtils.renderIntoDocument(
<ComponentWrapper />
<div>
<ComponentWrapper />
</div>
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

i think ReactTestUtils.renderIntoDocument changed a bit with react versions, so we aren't able to just have the Component render then call unmountComponentAtNode, i needed to wrap in a div 🤷‍♂️

);

assert.equal(hasScript(URL), true, "Url in document");
Expand All @@ -143,7 +141,9 @@ describe("AsyncScriptLoader", () => {
const URL = "http://example.com/?removeOnUnmount=true";
const ComponentWrapper = makeAsyncScriptLoader(URL, { removeOnUnmount: true })(MockedComponent);
const instance = ReactTestUtils.renderIntoDocument(
<ComponentWrapper />
<div>
<ComponentWrapper />
</div>
);

assert.equal(hasScript(URL), true, "Url in document");
Expand All @@ -152,22 +152,51 @@ describe("AsyncScriptLoader", () => {
assert.equal(hasScript(URL), false, "Url not in document after unmounting");
});

it("should allow you to access methods on the wrappedComponent via getComponent", (done) => {
class MockedComponentMethod extends React.Component {
callsACallback(fn) {
assert.equal(this.constructor.name, "MockedComponentMethod");
fn();
it("should allow you to access methods on the wrappedComponent via ref callback", (done) => {
// internal component with method we want access to
class InternalComponent extends React.Component {
internalCallsACallback(fn) { fn(); }
render() { return ( <div className='bob' /> )}
}
const URL = "http://example.com/?ref=true";
const ComponentWrapper = makeAsyncScriptLoader(URL)(InternalComponent);

// wrapping component that applies a ref to our AsyncHOC(InternalComponent)
class WrappingComponent extends React.Component {
render() { return (<div><ComponentWrapper ref={(r) => this._internalRef = r} /></div>)}
}
const instance = ReactTestUtils.renderIntoDocument(
<WrappingComponent />
);

assert.equal(hasScript(URL), true, "Url in document");
assert.isOk(instance._internalRef.internalCallsACallback, "internal components method available");
instance._internalRef.internalCallsACallback(done);
});

it("should allow you to access methods on the wrappedComponent via createRef", (done) => {
// internal component with method we want access to
class InternalComponent extends React.Component {
internalCallsACallback(fn) { fn(); }
render() { return ( <div className='bob' /> )}
}
const URL = "http://example.com/?createRef=true";
const ComponentWrapper = makeAsyncScriptLoader(URL)(InternalComponent);

// wrapping component that applies a ref to our AsyncHOC(InternalComponent)
class WrappingComponent extends React.Component {
constructor(props) {
super(props);
this._internalRef = React.createRef();
}
render() { return <span/>; }
render() { return (<div><ComponentWrapper ref={this._internalRef} /></div>)}
}
const URL = "http://example.com/?getComponent=true";
const ComponentWrapper = makeAsyncScriptLoader(URL)(MockedComponentMethod);
const instance = ReactTestUtils.renderIntoDocument(
<ComponentWrapper />
<WrappingComponent />
);
const wrappedComponent = instance.getComponent();

assert.equal(hasScript(URL), true, "Url in document");
wrappedComponent.callsACallback(done);
assert.isOk(instance._internalRef.current.internalCallsACallback, "internal components method available");
instance._internalRef.current.internalCallsACallback(done);
});
});