Skip to content

Commit 6901964

Browse files
committed
add the code, still missing tests, update README
1 parent b3bff6e commit 6901964

18 files changed

+17727
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules
2+
dist/*
3+
coverage
4+
.rts2*

.npmignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
__tests__
2+
node_modules
3+
example
4+
src
5+
jest.config.js
6+
.travis.yml
7+
tsconfig.json
8+
tslint.json
9+
coverage
10+
.rts2*
11+
!dist/react-use-comlink.d.ts

.travis.yml

Whitespace-only changes.

README.md

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
[![NPM](https://nodei.co/npm/react-use-comlink.svg?downloads=true)](https://nodei.co/npm/react-use-comlink/)
2+
[![TypeScript](https://badges.frapsoft.com/typescript/love/typescript.svg?v=101)](https://github.com/ellerbrock/typescript-badges/)
3+
4+
# react-use-comlink
5+
6+
Three ways to use [Comlink](https://github.com/GoogleChromeLabs/comlink) web workers through React Hooks (and in a typesafe manner).
7+
8+
## Usage
9+
10+
```tsx
11+
// worker.ts
12+
import { expose } from 'comlink'
13+
14+
export class MyClass {
15+
private _counter: number
16+
17+
constructor(init: number) {
18+
this._counter = init
19+
}
20+
21+
get counter() {
22+
return this._counter
23+
}
24+
25+
increment(delta = 1) {
26+
this._counter += delta
27+
}
28+
}
29+
30+
expose(MyClass)
31+
```
32+
33+
```tsx
34+
// index.ts
35+
import React from 'react'
36+
import useComlink from 'react-use-comlink'
37+
import { WorkerClass } from './worker'
38+
39+
const App: React.FC<{startAt: number}> = (props) => {
40+
const [state, setState] = React.useState(0)
41+
42+
const { proxy } = useComlink<typeof WorkerClass>(
43+
() => {
44+
return new Worker('./worker.ts')
45+
},
46+
[ props.someDep ]
47+
)
48+
49+
React.useEffect(() => {
50+
(async () => {
51+
// methods, constructors and setters are async
52+
const classInstance = await new proxy(0)
53+
54+
await classInstance.increment(1)
55+
56+
// even getters are asynchronous, regardless of type
57+
setState(await classInstance.counter)
58+
})()
59+
}, [proxy])
60+
61+
return (
62+
<div>{state}</div>
63+
)
64+
}
65+
66+
ReactDOM.render(
67+
<App />,
68+
document.getElementById('root')
69+
)
70+
```
71+
72+
Also notice that the `worker` property is also exposed, so you may use the library directly with workers without having to use Comlink (kinda defeats the purpose, but oh well):
73+
74+
```tsx
75+
const App = () => {
76+
const { worker } = useComlink('./worker.js')
77+
78+
useEffect(() => {
79+
worker.onmessage = (e) => {
80+
/*do stuff*/
81+
}
82+
83+
worker.onerror = (e) => {
84+
/*do stuff*/
85+
}
86+
}, [worker])
87+
88+
const callback = useCallback(() => {
89+
worker.postMessage('wow')
90+
}, [worker])
91+
92+
return (<button onClick={callback}>Post WOW</button>)
93+
}
94+
```
95+
96+
## API
97+
98+
The api is pretty straightforward, you have the _in loco_ `useComlink`, the factory counter part `createComlink` and the singleton counter part `createComlinkSingleton`.
99+
100+
### `useComlink<T = unknown>(initWorker: Blob | string | () => Worker | string | Blob, deps: any[]): { proxy<T>, worker }`
101+
102+
Use directly inside components. Both object and properties are memoized and can be used as deps.
103+
104+
```tsx
105+
const MyComponent: React.FC = () => {
106+
const { proxy, worker } = useComlink(() => new Worker('./worker.js'), [deps])
107+
}
108+
```
109+
110+
### `createComlink<T = unknown>(initWorker: () => Worker | string | Blob, options = {}): () => { proxy<T>, worker }`
111+
112+
Creates a factory version that can spawn multiple workers with the same settings
113+
114+
```tsx
115+
// outside, just like react-cache, createResource
116+
const useNumber = createComlink<number>(
117+
() => new Worker('worker.js')
118+
)
119+
120+
const MyComponent: React.FC = () => {
121+
const { proxy, worker } = useNumber() // call like a hook
122+
123+
useEffect(() => {
124+
(async () => {
125+
const number = await proxy
126+
// use number
127+
})()
128+
}, [proxy])
129+
130+
return null
131+
}
132+
```
133+
134+
### `createComlinkSingleton<T = unknown>(initWorker: Worker, options: WorkerOptions = {}): () => { proxy<T>, worker }`
135+
136+
If you want to keep the same state between multiple components, be my guest. Not the best choice for modularity, but hey, I just make the tools. Notice that the worker is never terminated, and must be done on demand (on `worker.terminate()`)
137+
138+
```tsx
139+
const useSingleton = createComlinkSingleton<() => Bad>(new Worker('./bad.idea.worker.js'))
140+
141+
const MyComponent: React.FC = () => {
142+
const { proxy } = useSingleton()
143+
144+
useEffect(() => {
145+
(async () => {
146+
const isBad = await proxy()
147+
})()
148+
}, [proxy])
149+
150+
return null
151+
}
152+
```
153+
154+
## Comlink
155+
156+
Make sure the read the Comlink documentation, being the most important part what can be [structure cloned](https://github.com/GoogleChromeLabs/comlink#comlinktransfervalue-transferables-and-comlinkproxyvalue)
157+
158+
## Caveats
159+
160+
Every function with Comlink is async (because you're basically communicating to another thread through web workers), even class instantiation (using new), so local state can't be retrieved automatically from the exposed class, object or function, having to resort to wrapping some code with self invoking `async` functions, or relying on `.then()`. If your render depends on the external worker result, you'll have to think of an intermediate state.
161+
162+
Although the worker will be terminated when the component is unmounted, your code might try to "set" the state of an unmounted component because of how workers work (:P) on their own separate thread in a truly async manner.
163+
164+
In the future, when `react-cache` and Concurrent Mode is upon us, this library will be updated to work nicely with Suspense and the async nature of Comlink
165+
166+
## TODO
167+
168+
Write tests (hardcore web workers tests)
169+
170+
## License
171+
172+
MIT

example/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
dist
3+
.cache

example/1.worker.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { expose } from 'comlink'
2+
3+
export class MyClass {
4+
private _counter: number
5+
6+
constructor(init: number) {
7+
this._counter = init
8+
}
9+
10+
get counter() {
11+
return this._counter
12+
}
13+
14+
increment(delta = 1) {
15+
this._counter += delta
16+
}
17+
}
18+
19+
expose(MyClass)

example/2.worker.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { expose } from 'comlink'
2+
3+
/** even though we have "global state" here, it's per worker, not per file */
4+
let globalState /* bad idea */ = 0;
5+
6+
export const myObj = {
7+
hello: 'world',
8+
inc() {
9+
// bad idea 2
10+
globalState++
11+
return globalState
12+
}
13+
}
14+
15+
expose(myObj)

example/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<title>react-use-comlink</title>
5+
</head>
6+
<body>
7+
<div id="root"></div>
8+
<script src="./index.tsx"></script>
9+
</body>
10+
</html>

example/index.tsx

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import React, { useCallback, useState, useEffect, useMemo } from 'react'
2+
import ReactDOM from 'react-dom'
3+
import { MyClass } from './1.worker'
4+
import { myObj } from './2.worker'
5+
import useComlink, { createComlink, createComlinkSingleton } from '../src/react-use-comlink'
6+
7+
const useMyClass = createComlink<typeof MyClass>(
8+
() => new Worker('./1.worker.ts'),
9+
)
10+
11+
const Main: React.FC<{ startAt: number }> = (props) => {
12+
const [counter, setCounter] = useState(0)
13+
const [unmounted, setUnmounted] = useState(false)
14+
15+
const myclass = useMyClass()
16+
const instance = useMemo(() => {
17+
return new myclass.proxy(props.startAt)
18+
}, [myclass, props.startAt])
19+
20+
const cb = useCallback(async () => {
21+
const use = await instance
22+
await use.increment(1)
23+
setCounter(await use.counter)
24+
}, [instance, setCounter])
25+
26+
const toggleUnmount = useCallback(() => {
27+
setUnmounted((state) => !state)
28+
}, [setUnmounted])
29+
30+
return (
31+
<div>
32+
<p>Counter: {counter}</p>
33+
<button onClick={cb}>Increase from Comlink</button>
34+
<hr />
35+
{unmounted ? null : <Sub />}
36+
<hr />
37+
{/* <Sub /> */}
38+
<hr />
39+
<button onClick={toggleUnmount}>{unmounted ? 'Mount Sub' : 'Unmount Sub'}</button>
40+
</div>
41+
)
42+
}
43+
44+
/**
45+
* initialize a hook from your worker class.
46+
* it doesn't actually import MyClass from worker.js, but only the defitions
47+
* when you're using Typescript! So your code is still strongly typed
48+
*
49+
* This is important for performance so the `new Worker()` isn't eagerly
50+
* evaluated on every render, like it happens with
51+
*
52+
* useComlink(new Worker('./worker.js')) // created every render
53+
*
54+
* best to be
55+
*
56+
* const myWorker = new Worker('./worker') // outside
57+
*
58+
* const App = () => {
59+
* useComlink(myWorker)
60+
* }
61+
*/
62+
const useObj = createComlinkSingleton<typeof myObj>(new Worker('./2.worker.ts'))
63+
64+
const Sub: React.FunctionComponent = () => {
65+
const [state, setState] = useState({ globalcount: 0, localcount: 0 })
66+
const directly = useComlink<typeof myObj>(
67+
() => {
68+
return new Worker('./2.worker.ts')
69+
}
70+
)
71+
72+
const globalObj = useObj()
73+
74+
const incCounts = async () => {
75+
const localcount = await directly.proxy.inc()
76+
77+
setState((prevState) => {
78+
return { ...prevState, localcount }
79+
})
80+
}
81+
82+
useEffect(() => {
83+
incCounts()
84+
}, [directly, setState])
85+
86+
useEffect(() => {
87+
(async () => {
88+
const globalcount = await globalObj.proxy.inc()
89+
90+
setState((prevState) => {
91+
return { ...prevState, globalcount }
92+
})
93+
})()
94+
}, [globalObj, setState])
95+
96+
const cb = useCallback(() => {
97+
incCounts()
98+
}, [directly])
99+
100+
return (
101+
<div>
102+
<button onClick={cb}>Increase local</button>
103+
<p>Global worker instance count: {state.globalcount}</p>
104+
<p>Local worker instance count: {state.localcount}</p>
105+
</div>
106+
)
107+
}
108+
109+
const App: React.FunctionComponent = () => {
110+
return (
111+
<React.StrictMode>
112+
<React.Suspense fallback={<p>Loading</p>}>
113+
<Main startAt={0} />
114+
</React.Suspense>
115+
</React.StrictMode>
116+
)
117+
}
118+
119+
ReactDOM.render(
120+
<App />,
121+
document.getElementById('root'),
122+
)

0 commit comments

Comments
 (0)