Skip to content

Commit a85b89c

Browse files
authored
Fix plugin idempotency issue with React re-renders (#14)
* Fix plugin idempotency issue with React re-renders - Exclude plugins from deep dependency comparison in useWavesurferInstance - Track plugins array reference separately to avoid re-initialization on mutation - Add comprehensive test for plugin re-render behavior - Update README with detailed plugin memoization examples and requirements Fixes: katspaugh/wavesurfer.js#3731 * Fix lint error: suppress unused variable warning for plugins destructuring * Add checks:write and pull-requests:write permissions to workflows This fixes CI failures caused by missing permissions for GitHub Actions to: - Create check annotations - Post PR comments with test results and coverage reports * Update lint
1 parent b0ae6b3 commit a85b89c

File tree

8 files changed

+185
-12
lines changed

8 files changed

+185
-12
lines changed

.github/workflows/lint.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
name: Lint
22
on: [pull_request]
33

4+
permissions:
5+
checks: write
6+
pull-requests: write
7+
48
jobs:
59
eslint:
610
runs-on: ubuntu-latest
711
steps:
8-
- uses: actions/checkout@v3
12+
- uses: actions/checkout@v4
913

1014
- name: Yarn
1115
shell: bash
1216
run: yarn install --immutable
1317

14-
- uses: Maggi64/eslint-plus-action@master
18+
- name: Run lint
19+
run: npm run lint:report
20+
continue-on-error: true
21+
22+
- name: Annotate code with linting results
23+
uses: ataylorme/eslint-annotate-action@v3
1524
with:
16-
npmInstall: false
25+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26+
report-json: "eslint_report.json"

.github/workflows/tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
name: Tests
22
on: [pull_request]
33

4+
permissions:
5+
checks: write
6+
pull-requests: write
7+
48
jobs:
59
tests:
610
runs-on: ubuntu-latest

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@ docs
44
node_modules
55
yarn-error.log
66
.env
7-
examples/app.js
7+
examples/app.js
8+
coverage
9+
report.json
10+
eslint_report.json

README.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,104 @@ const App = () => {
9191
}
9292
```
9393

94+
## Using plugins
95+
96+
Wavesurfer [plugins](https://wavesurfer.xyz/docs/modules/plugins_index) can be passed in the `plugins` option.
97+
98+
**Important:** The `plugins` array **must be memoized** using `useMemo` or defined outside the component. This is because wavesurfer.js mutates plugin instances during initialization, and passing a new array on every render will cause errors.
99+
100+
### Basic example with a single plugin
101+
102+
```js
103+
import { useMemo } from 'react'
104+
import WavesurferPlayer from '@wavesurfer/react'
105+
import Timeline from 'wavesurfer.js/dist/plugins/timeline.esm.js'
106+
107+
const App = () => {
108+
const plugins = useMemo(() => {
109+
return [
110+
Timeline.create({
111+
container: '#timeline',
112+
}),
113+
]
114+
}, [])
115+
116+
return (
117+
<>
118+
<WavesurferPlayer
119+
height={100}
120+
waveColor="violet"
121+
url="/audio.wav"
122+
plugins={plugins}
123+
/>
124+
<div id="timeline" />
125+
</>
126+
)
127+
}
128+
```
129+
130+
### Example with multiple plugins
131+
132+
```js
133+
import { useMemo } from 'react'
134+
import WavesurferPlayer from '@wavesurfer/react'
135+
import Timeline from 'wavesurfer.js/dist/plugins/timeline.esm.js'
136+
import Regions from 'wavesurfer.js/dist/plugins/regions.esm.js'
137+
138+
const App = () => {
139+
const plugins = useMemo(() => {
140+
return [
141+
Timeline.create({
142+
container: '#timeline',
143+
}),
144+
Regions.create(),
145+
]
146+
}, [])
147+
148+
return (
149+
<>
150+
<WavesurferPlayer
151+
height={100}
152+
waveColor="violet"
153+
url="/audio.wav"
154+
plugins={plugins}
155+
/>
156+
<div id="timeline" />
157+
</>
158+
)
159+
}
160+
```
161+
162+
### Alternative: Define plugins outside the component
163+
164+
If your plugins don't depend on component props or state, you can define them outside:
165+
166+
```js
167+
import WavesurferPlayer from '@wavesurfer/react'
168+
import Timeline from 'wavesurfer.js/dist/plugins/timeline.esm.js'
169+
170+
// Define plugins outside the component
171+
const plugins = [
172+
Timeline.create({
173+
container: '#timeline',
174+
}),
175+
]
176+
177+
const App = () => {
178+
return (
179+
<>
180+
<WavesurferPlayer
181+
height={100}
182+
waveColor="violet"
183+
url="/audio.wav"
184+
plugins={plugins}
185+
/>
186+
<div id="timeline" />
187+
</>
188+
)
189+
}
190+
```
191+
94192
## Docs
95193

96194
https://wavesurfer.xyz

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"build": "rollup -c",
2828
"build:examples": "cd examples && rollup -c",
2929
"lint": "eslint src",
30+
"lint:report": "eslint \"src/**/*.ts*\" --output-file eslint_report.json --format json",
3031
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js tests",
3132
"test:ci": "yarn test --ci --silent --coverage --json --watchAll=false --testLocationInResults --outputFile=report.json",
3233
"serve": "npx live-server --port=3030 --no-browser --ignore='.*,src,tests'",
@@ -62,7 +63,7 @@
6263
"rollup": "^3.26.2",
6364
"rollup-plugin-inject-process-env": "^1.3.1",
6465
"typescript": "^5.0.4",
65-
"wavesurfer.js": "^7.9.4"
66+
"wavesurfer.js": "^7.12.0"
6667
},
6768
"jest": {
6869
"transform": {},

src/index.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,23 @@ function useWavesurferInstance(
3838
options: Partial<WaveSurferOptions>,
3939
): WaveSurfer | null {
4040
const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null)
41+
4142
// Flatten options object to an array of keys and values to compare them deeply in the hook deps
42-
const flatOptions = useMemo(() => Object.entries(options).flat(), [options])
43+
// Exclude plugins from deep comparison since they are mutated during initialization
44+
const optionsWithoutPlugins = useMemo(() => {
45+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
46+
const { plugins, ...rest } = options
47+
return rest
48+
}, [options])
49+
const flatOptions = useMemo(() => Object.entries(optionsWithoutPlugins).flat(), [optionsWithoutPlugins])
4350

4451
// Create a wavesurfer instance
4552
useEffect(() => {
4653
if (!containerRef?.current) return
4754

4855
const ws = WaveSurfer.create({
49-
...options,
56+
...optionsWithoutPlugins,
57+
plugins: options.plugins,
5058
container: containerRef.current,
5159
})
5260

@@ -55,7 +63,9 @@ function useWavesurferInstance(
5563
return () => {
5664
ws.destroy()
5765
}
58-
}, [containerRef, ...flatOptions])
66+
// Only recreate if plugins array reference changes (not on mutation)
67+
// Users should memoize the plugins array to prevent unnecessary re-creation
68+
}, [containerRef, options.plugins, ...flatOptions])
5969

6070
return wavesurfer
6171
}

tests/index.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,51 @@ describe('@wavesurfer/react tests', () => {
6565

6666
expect(WaveSurfer.create).toHaveBeenCalled()
6767
})
68+
69+
it('should handle plugins correctly and not break on re-render', () => {
70+
// Create a mock plugin that simulates being mutated during initialization
71+
const mockPlugin = {
72+
_init: jest.fn(),
73+
_initialized: false,
74+
init: function() {
75+
if (this._initialized) {
76+
throw new Error('Plugin already initialized')
77+
}
78+
this._initialized = true
79+
this._init()
80+
}
81+
}
82+
83+
const props = { waveColor: 'purple', plugins: [mockPlugin] }
84+
85+
// Simulate the mock WaveSurfer.create calling plugin init
86+
const originalMock = WaveSurfer.create.getMockImplementation()
87+
WaveSurfer.create.mockImplementation((...args) => {
88+
const instance = originalMock(...args)
89+
// Simulate plugin initialization (wavesurfer.js would do this)
90+
const options = args[0]
91+
if (options.plugins) {
92+
options.plugins.forEach(plugin => {
93+
if (plugin.init) plugin.init()
94+
})
95+
}
96+
return instance
97+
})
98+
99+
const { rerender } = render(React.createElement(WavesurferPlayer, props))
100+
101+
// First render should initialize the plugin
102+
expect(mockPlugin._init).toHaveBeenCalledTimes(1)
103+
expect(mockPlugin._initialized).toBe(true)
104+
105+
// Re-render with the same props should not cause plugin re-initialization
106+
// because plugins are now handled via ref
107+
rerender(React.createElement(WavesurferPlayer, props))
108+
109+
// Plugin init should still only have been called once
110+
expect(mockPlugin._init).toHaveBeenCalledTimes(1)
111+
112+
// Restore original mock
113+
WaveSurfer.create.mockImplementation(originalMock)
114+
})
68115
})

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3363,10 +3363,10 @@ walker@^1.0.8:
33633363
dependencies:
33643364
makeerror "1.0.12"
33653365

3366-
wavesurfer.js@^7.8.11:
3367-
version "7.9.4"
3368-
resolved "https://registry.yarnpkg.com/wavesurfer.js/-/wavesurfer.js-7.9.4.tgz#fab2e52a4bf8e256f6f13dd85cdfed2bc7487d7d"
3369-
integrity sha512-ahOMvrOKo5jULNnXq8Ske8v/ZStoNNTDjYohvgLNerUFuh+6fJSt7wlxFesEXmnlcTnjMy5/tIzhn9KusjO6bg==
3366+
wavesurfer.js@^7.12.0:
3367+
version "7.12.0"
3368+
resolved "https://registry.yarnpkg.com/wavesurfer.js/-/wavesurfer.js-7.12.0.tgz#1b865af796a1cf7223122ad6d546411558c81ba3"
3369+
integrity sha512-/5PSaKkIC7PblJKCmkSfuFQK/k/VgAflaVIVtu+owj/NqyKn0p4ni6eFwZD6lfm66PdiPZrc5y9OZ/GDzkAS0Q==
33703370

33713371
webidl-conversions@^7.0.0:
33723372
version "7.0.0"

0 commit comments

Comments
 (0)