From 57877693650fc54fd1c304b4a9c76fe6f225bf81 Mon Sep 17 00:00:00 2001 From: jwbrandon Date: Mon, 10 Aug 2026 16:10:46 -0400 Subject: [PATCH 1/3] fix: apply content prop changes to the running player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup config was built once in the constructor and never reapplied, so changing playlist or file left the old media playing. Consumers had to force a remount with a changing key. Push content changes through the player's load() method from componentDidUpdate, comparing playlist by value so a fresh but equal array does not reload. Build the setup config from the props current when setup runs, so props that change while the library loads are no longer lost. Event listeners were re-subscribed inside shouldComponentUpdate, which then returned false. That dropped any content change arriving in the same update — the common case, since inline handlers get a new identity on every parent render. Move that work to componentDidUpdate. The test asserting shouldComponentUpdate returns false for event-prop changes now expects true, matching the corrected behavior. Other config options are still setup-time only; document that and the key-remount workaround. Closes #20 --- README.md | 25 ++++++++ src/jwplayer-react.d.ts | 4 ++ src/jwplayer.jsx | 55 ++++++++++++++--- src/util.js | 23 +++++++ test/jwplayer-react.test.js | 118 +++++++++++++++++++++++++++++++++++- test/util.js | 3 +- 6 files changed, 215 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d10da68..6b926e5 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ * [Props](#props) * [Required Props](#required-props) * [Optional Props](#optional-props) + * [Changing Props After Mount](#changing-props-after-mount) * [API Functionality](#api-functionality) * [Advanced Implementation Examples](#advanced-implementation-examples) * [Development](#development) @@ -121,6 +122,30 @@ If you are not using a cloud hosted player you will need to provide a license ke * Type: `({ player: PlayerAPI, id: string }) => void` * Example: See [advanced implementation example](#advanced-implementation-examples) +### Changing Props After Mount + +Content props update the existing player. Change `playlist` or `file` and the component calls the player's [`load`](https://developer.jwplayer.com/jwplayer/docs/jw8-javascript-api-reference) method, so the new media plays: + +```jsx +// Switching playlist swaps the content in the player already on the page. + +``` + +`playlist` is compared by value, so passing an equal array built fresh on every render does not reload the player. + +Every other config option is read once, when the player is set up. Changing one after mount has no effect, because the player cannot reconfigure itself in place. To apply a new config, force a remount by giving the component a `key` that changes with it: + +```jsx + +``` + +`on` and `once` handler props are always kept current, and changing one never reloads the player. + ### API Functionality For advanced usage,`jwplayer-react` creates an instance of the player API when mounted, and sets it to `this.player`, exposing all api functionality listed [here](https://developer.jwplayer.com/jwplayer/docs/jw8-javascript-api-reference). diff --git a/src/jwplayer-react.d.ts b/src/jwplayer-react.d.ts index 651fd1e..c0cdbad 100644 --- a/src/jwplayer-react.d.ts +++ b/src/jwplayer-react.d.ts @@ -46,6 +46,10 @@ export interface UnmountCallbackArguments { * * Exception: React reserves the `key` prop and never forwards it, so a player * license key only works via `config={{ key: ... }}`. + * + * After mount, only the content options (`playlist`, `file`) update the running + * player, via its load() method. Every other option is read once at setup, so + * changing it requires a remount — see "Changing Props After Mount" in the README. */ export interface JWPlayerProps extends JWPlayerConfig { didMountCallback?: (args: MountCallbackArguments) => void; diff --git a/src/jwplayer.jsx b/src/jwplayer.jsx index 2a199c4..102b66f 100644 --- a/src/jwplayer.jsx +++ b/src/jwplayer.jsx @@ -3,7 +3,7 @@ import { ALL, ON_REGEX, ONCE_REGEX, } from './const'; import { - generateConfig, generateUniqueId, loadPlayer, getHandlerName, + generateConfig, generateUniqueId, loadPlayer, getHandlerName, getContent, deepEqual, } from './util'; function createOnEventHandler(props) { @@ -60,17 +60,23 @@ class JWPlayer extends React.Component { } } - shouldComponentUpdate(nextProps) { + // The rendered output is a static container div, so an update is only ever + // worth running to sync props onto the player in componentDidUpdate. Without + // a player there is nothing to sync. + shouldComponentUpdate() { + return Boolean(this.player); + } + + componentDidUpdate(prevProps) { if (!this.player) { - return false; + return; } - if (this.didOnEventsChange(nextProps)) { - this.updateOnEventListener(nextProps); - return false; + if (this.didOnEventsChange(prevProps)) { + this.updateOnEventListener(this.props); } - return true; + this.syncContent(); } componentWillUnmount() { @@ -87,13 +93,42 @@ class JWPlayer extends React.Component { } createPlayer() { - const { config, ref } = this; - const setupConfig = { ...window.jwDefaults, ...config }; - const view = ref.current; + // Props can change while the library loads, so set up from the current + // props rather than the constructor-time snapshot. + this.config = generateConfig(this.props); + const setupConfig = { ...window.jwDefaults, ...this.config }; + const view = this.ref.current; return window.jwplayer(view.id).setup(setupConfig); } + // Content is the only setup option the player can swap in place. Every other + // config prop is read once at setup, so changing it needs a remount — give + // the component a new `key`. + syncContent() { + const nextConfig = generateConfig(this.props); + const prevContent = getContent(this.config); + const nextContent = getContent(nextConfig); + + this.config = nextConfig; + + if (deepEqual(prevContent, nextContent)) { + return; + } + + const { playlist, file } = nextContent; + + if (playlist) { + this.player.load(playlist); + return; + } + + // load() takes playlist items, so a bare media file has to be wrapped. + if (file) { + this.player.load([{ file }]); + } + } + didOnEventsChange(nextProps) { const onEventFilter = (prop) => prop.match(ON_REGEX); const currEvents = Object.keys(this.props).filter(onEventFilter).sort(); diff --git a/src/util.js b/src/util.js index f552a5d..cf54016 100644 --- a/src/util.js +++ b/src/util.js @@ -76,6 +76,29 @@ export function generateConfig(props) { return { ...props.config, ...config, isReactComponent: true }; } +// Structural comparison of player config values — strings, numbers, arrays and +// plain objects. Not a general-purpose deep equal: functions, dates and class +// instances compare by identity, which is all the config surface needs. +export function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + + return keys.every((key) => ( + Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]) + )); +} + +// The content the player is asked to play. This is the only part of the setup +// config the player can swap in place, via load(). +export function getContent(config) { + const { playlist, file } = config; + return { playlist, file }; +} + export function getHandlerName(prop, regex) { const match = prop.match(regex) || ['', '']; diff --git a/test/jwplayer-react.test.js b/test/jwplayer-react.test.js index a78c314..5715749 100644 --- a/test/jwplayer-react.test.js +++ b/test/jwplayer-react.test.js @@ -282,11 +282,14 @@ describe('methods', () => { expect(shouldUpdate).toBe(true); }); - it('should not update component if on event props change', async () => { + // Event listeners used to be re-subscribed from shouldComponentUpdate, + // which then returned false. That dropped any config change landing in + // the same update, so the work moved to componentDidUpdate. + it('should update component if on event props change', async () => { const { instance } = await createMountedComponent(); const nextProps = { onPlay: noop }; const shouldUpdate = instance.shouldComponentUpdate(nextProps); - expect(shouldUpdate).toBe(false); + expect(shouldUpdate).toBe(true); }); it('should not update component if player does not exist', async () => { @@ -412,3 +415,114 @@ describe('methods', () => { }); }); }); + +// Content props must reach an already-created player. +// See https://github.com/jwplayer/jwplayer-react/issues/20 +describe('content updates', () => { + const otherPlaylist = 'https://cdn.jwplayer.com/v2/media/abcd1234'; + + const renderPlayer = async (props) => { + const ref = React.createRef(); + let result; + await act(async () => { + result = render(); + }); + const rerenderPlayer = async (nextProps) => { + await act(async () => { + result.rerender(); + }); + }; + return { instance: ref.current, rerenderPlayer }; + }; + + it('loads the new playlist when the playlist prop changes', async () => { + const { instance, rerenderPlayer } = await renderPlayer({ playlist }); + + await rerenderPlayer({ playlist: otherPlaylist }); + + expect(instance.player.load).toHaveBeenCalledWith(otherPlaylist); + }); + + it('loads the new playlist when an inline playlist array changes', async () => { + const { instance, rerenderPlayer } = await renderPlayer({ playlist: [{ file: 'a.mp4' }] }); + + await rerenderPlayer({ playlist: [{ file: 'b.mp4' }] }); + + expect(instance.player.load).toHaveBeenCalledWith([{ file: 'b.mp4' }]); + }); + + it('wraps a bare file in a playlist item, since load takes playlist items', async () => { + const { instance, rerenderPlayer } = await renderPlayer({ file: 'a.mp4' }); + + await rerenderPlayer({ file: 'b.mp4' }); + + expect(instance.player.load).toHaveBeenCalledWith([{ file: 'b.mp4' }]); + }); + + it('reads content out of the config prop too', async () => { + const { instance, rerenderPlayer } = await renderPlayer({ config: { playlist } }); + + await rerenderPlayer({ config: { playlist: otherPlaylist } }); + + expect(instance.player.load).toHaveBeenCalledWith(otherPlaylist); + }); + + it('does not reload when a deep-equal playlist is passed as a new array', async () => { + const { instance, rerenderPlayer } = await renderPlayer({ playlist: [{ file: 'a.mp4' }] }); + + await rerenderPlayer({ playlist: [{ file: 'a.mp4' }] }); + + expect(instance.player.load).not.toHaveBeenCalled(); + }); + + it('does not reload when an unrelated prop changes', async () => { + const { instance, rerenderPlayer } = await renderPlayer({ playlist, width: 500 }); + + await rerenderPlayer({ playlist, width: 640 }); + + expect(instance.player.load).not.toHaveBeenCalled(); + }); + + // Inline arrow handlers get a new identity on every parent render, so this + // is the common case, not an edge case. + it('loads new content even when an on event handler changed in the same update', async () => { + const { instance, rerenderPlayer } = await renderPlayer({ playlist, onPlay: () => {} }); + + await rerenderPlayer({ playlist: otherPlaylist, onPlay: () => {} }); + + expect(instance.player.load).toHaveBeenCalledWith(otherPlaylist); + }); + + it('keeps the current content when the new config has none', async () => { + const { instance, rerenderPlayer } = await renderPlayer({ playlist }); + + await rerenderPlayer({ advertising: { outstream: true } }); + + expect(instance.player.load).not.toHaveBeenCalled(); + }); + + it('sets up with the props current at setup time, not at construction', async () => { + window.jwplayer = null; + const ref = React.createRef(); + let result; + + await act(async () => { + result = render(); + }); + const [script] = Array.from(document.getElementsByTagName('script')) + .filter((tag) => tag.src === library); + + // The playlist changes while the library is still loading. + await act(async () => { + result.rerender(); + }); + + window.jwplayer = mockLibrary; + await act(async () => { + script.onload(); + }); + + const setupConfig = window.jwplayer(ref.current.id).setup.mock.calls[0][0]; + expect(setupConfig.playlist).toBe(otherPlaylist); + }); +}); diff --git a/test/util.js b/test/util.js index ad09d33..2d797e4 100644 --- a/test/util.js +++ b/test/util.js @@ -7,8 +7,9 @@ const createMockAPI = (id) => { const off = jest.fn(() => api); const remove = jest.fn(() => api); const setup = jest.fn(() => api); + const load = jest.fn(() => api); - Object.assign(api, { on, once, off, remove, setup }); + Object.assign(api, { on, once, off, remove, setup, load }); players[id] = api; return api; From 201ab0a0367f5a5a3fcc77dcb1913a3a0c0c7b87 Mon Sep 17 00:00:00 2001 From: jwbrandon Date: Mon, 10 Aug 2026 16:18:19 -0400 Subject: [PATCH 2/3] fix: limit live content updates to the playlist prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reloading a changed `file` meant rebuilding a playlist item from the top level config, which silently drops the sibling item options this package forwards without knowing their schema — image, tracks, title. Drop `file` from the live path and document it as setup-time only, alongside every other config option. Also ignore an empty or absent playlist, so content that arrives later cannot blank out a player that is already playing. --- README.md | 6 +++--- src/jwplayer-react.d.ts | 6 +++--- src/jwplayer.jsx | 31 ++++++++++++++++--------------- src/util.js | 7 ------- test/jwplayer-react.test.js | 18 ++++++++++++++---- 5 files changed, 36 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 6b926e5..2de1e78 100644 --- a/README.md +++ b/README.md @@ -124,16 +124,16 @@ If you are not using a cloud hosted player you will need to provide a license ke ### Changing Props After Mount -Content props update the existing player. Change `playlist` or `file` and the component calls the player's [`load`](https://developer.jwplayer.com/jwplayer/docs/jw8-javascript-api-reference) method, so the new media plays: +The `playlist` prop updates the existing player. Change it and the component calls the player's [`load`](https://developer.jwplayer.com/jwplayer/docs/jw8-javascript-api-reference) method, so the new media plays: ```jsx // Switching playlist swaps the content in the player already on the page. ``` -`playlist` is compared by value, so passing an equal array built fresh on every render does not reload the player. +`playlist` is compared by value, so passing an equal array built fresh on every render does not reload the player. An empty or absent playlist is ignored, so it never blanks out a player that is already playing. -Every other config option is read once, when the player is set up. Changing one after mount has no effect, because the player cannot reconfigure itself in place. To apply a new config, force a remount by giving the component a `key` that changes with it: +Every other config option, including `file`, is read once when the player is set up. Changing one after mount has no effect, because the player cannot reconfigure itself in place. To apply a new config, force a remount by giving the component a `key` that changes with it: ```jsx void; diff --git a/src/jwplayer.jsx b/src/jwplayer.jsx index 102b66f..bec6917 100644 --- a/src/jwplayer.jsx +++ b/src/jwplayer.jsx @@ -3,7 +3,7 @@ import { ALL, ON_REGEX, ONCE_REGEX, } from './const'; import { - generateConfig, generateUniqueId, loadPlayer, getHandlerName, getContent, deepEqual, + generateConfig, generateUniqueId, loadPlayer, getHandlerName, deepEqual, } from './util'; function createOnEventHandler(props) { @@ -102,31 +102,32 @@ class JWPlayer extends React.Component { return window.jwplayer(view.id).setup(setupConfig); } - // Content is the only setup option the player can swap in place. Every other - // config prop is read once at setup, so changing it needs a remount — give - // the component a new `key`. + // The playlist is the only setup option the player can swap in place. Every + // other config prop is read once at setup, so changing it needs a remount — + // give the component a new `key`. + // + // `file` is deliberately excluded. Reloading it would mean rebuilding a + // playlist item, which silently drops the sibling top-level item options + // (image, tracks, title, ...) that this package forwards without knowing their + // schema. syncContent() { const nextConfig = generateConfig(this.props); - const prevContent = getContent(this.config); - const nextContent = getContent(nextConfig); + const prevPlaylist = this.config.playlist; + const { playlist } = nextConfig; this.config = nextConfig; - if (deepEqual(prevContent, nextContent)) { + if (deepEqual(prevPlaylist, playlist)) { return; } - const { playlist, file } = nextContent; - - if (playlist) { - this.player.load(playlist); + // An empty or absent playlist must not blank out a playing player; content + // that arrives later still triggers a load. + if (!playlist || playlist.length === 0) { return; } - // load() takes playlist items, so a bare media file has to be wrapped. - if (file) { - this.player.load([{ file }]); - } + this.player.load(playlist); } didOnEventsChange(nextProps) { diff --git a/src/util.js b/src/util.js index cf54016..24fabec 100644 --- a/src/util.js +++ b/src/util.js @@ -92,13 +92,6 @@ export function deepEqual(a, b) { )); } -// The content the player is asked to play. This is the only part of the setup -// config the player can swap in place, via load(). -export function getContent(config) { - const { playlist, file } = config; - return { playlist, file }; -} - export function getHandlerName(prop, regex) { const match = prop.match(regex) || ['', '']; diff --git a/test/jwplayer-react.test.js b/test/jwplayer-react.test.js index 5715749..0b62749 100644 --- a/test/jwplayer-react.test.js +++ b/test/jwplayer-react.test.js @@ -451,12 +451,14 @@ describe('content updates', () => { expect(instance.player.load).toHaveBeenCalledWith([{ file: 'b.mp4' }]); }); - it('wraps a bare file in a playlist item, since load takes playlist items', async () => { - const { instance, rerenderPlayer } = await renderPlayer({ file: 'a.mp4' }); + // Reloading a bare file would mean rebuilding a playlist item and dropping + // its sibling top-level options, so file stays setup-only. + it('does not reload when only the file prop changes', async () => { + const { instance, rerenderPlayer } = await renderPlayer({ file: 'a.mp4', image: 'a.jpg' }); - await rerenderPlayer({ file: 'b.mp4' }); + await rerenderPlayer({ file: 'b.mp4', image: 'a.jpg' }); - expect(instance.player.load).toHaveBeenCalledWith([{ file: 'b.mp4' }]); + expect(instance.player.load).not.toHaveBeenCalled(); }); it('reads content out of the config prop too', async () => { @@ -501,6 +503,14 @@ describe('content updates', () => { expect(instance.player.load).not.toHaveBeenCalled(); }); + it('keeps the current content when the new playlist is empty', async () => { + const { instance, rerenderPlayer } = await renderPlayer({ playlist: [{ file: 'a.mp4' }] }); + + await rerenderPlayer({ playlist: [] }); + + expect(instance.player.load).not.toHaveBeenCalled(); + }); + it('sets up with the props current at setup time, not at construction', async () => { window.jwplayer = null; const ref = React.createRef(); From 045f53c070666ff16a1d255e130996d8e708aea0 Mon Sep 17 00:00:00 2001 From: jwbrandon Date: Mon, 10 Aug 2026 16:23:00 -0400 Subject: [PATCH 3/3] test: strengthen playlist update coverage Carries over the stronger checks from the parallel PR on this issue: unit tests for deepEqual, exact assertions on load's call list rather than "was called with", a structural-equality case with nested tracks, and a table over every empty playlist shape. Also declares load() on the player API type, and documents that once handlers are subscribed at mount only. --- README.md | 2 +- src/jwplayer-react.d.ts | 2 ++ test/jwplayer-react.test.js | 43 ++++++++++++++++++++++++++++--------- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2de1e78..d6bff9f 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Every other config option, including `file`, is read once when the player is set /> ``` -`on` and `once` handler props are always kept current, and changing one never reloads the player. +`on` handler props are re-subscribed on every change, so the player always calls the latest one, and changing one never reloads the player. `once` handlers are subscribed at mount only. ### API Functionality For advanced usage,`jwplayer-react` creates an instance of the player API when mounted, and sets it to `this.player`, exposing all api functionality listed [here](https://developer.jwplayer.com/jwplayer/docs/jw8-javascript-api-reference). diff --git a/src/jwplayer-react.d.ts b/src/jwplayer-react.d.ts index 0e588e6..edb85f2 100644 --- a/src/jwplayer-react.d.ts +++ b/src/jwplayer-react.d.ts @@ -25,6 +25,8 @@ export interface JWPlayerApi { off(event?: string, callback?: EventCallback): JWPlayerApi; remove(): void; setup(config: JWPlayerConfig | object): JWPlayerApi; + /** A playlist feed url, or an array of playlist items */ + load(playlist: string | object[]): JWPlayerApi; [member: string]: unknown; } diff --git a/test/jwplayer-react.test.js b/test/jwplayer-react.test.js index 0b62749..aa891f7 100644 --- a/test/jwplayer-react.test.js +++ b/test/jwplayer-react.test.js @@ -5,7 +5,7 @@ import React from 'react'; import { render, act } from '@testing-library/react'; import JWPlayer from '../src/jwplayer'; -import { loadPlayer } from '../src/util'; +import { loadPlayer, deepEqual } from '../src/util'; import { mockLibrary, players } from './util'; const noop = () => {}; @@ -155,6 +155,22 @@ describe('methods', () => { }); }); + describe('deepEqual', () => { + it.each([ + ['identical primitives', 'a.mp4', 'a.mp4', true], + ['different primitives', 'a.mp4', 'b.mp4', false], + ['a primitive and an object', 'a.mp4', { file: 'a.mp4' }, false], + ['null and an object', null, {}, false], + ['an array and an object', [], {}, false], + ['objects with different key counts', { a: 1 }, { a: 1, b: 2 }, false], + ['objects with different keys', { a: undefined }, { b: undefined }, false], + ['nested equal values', { a: [{ b: 1 }] }, { a: [{ b: 1 }] }, true], + ['nested different values', { a: [{ b: 1 }] }, { a: [{ b: 2 }] }, false], + ])('compares %s', (_name, a, b, expected) => { + expect(deepEqual(a, b)).toBe(expected); + }); + }); + it('createEventListeners', async () => { const { instance } = await createMountedComponent({ onReady: noop, onPlay: noop, oncePause: noop }); const id = instance.id; @@ -440,7 +456,7 @@ describe('content updates', () => { await rerenderPlayer({ playlist: otherPlaylist }); - expect(instance.player.load).toHaveBeenCalledWith(otherPlaylist); + expect(instance.player.load.mock.calls).toEqual([[otherPlaylist]]); }); it('loads the new playlist when an inline playlist array changes', async () => { @@ -448,7 +464,7 @@ describe('content updates', () => { await rerenderPlayer({ playlist: [{ file: 'b.mp4' }] }); - expect(instance.player.load).toHaveBeenCalledWith([{ file: 'b.mp4' }]); + expect(instance.player.load.mock.calls).toEqual([[[{ file: 'b.mp4' }]]]); }); // Reloading a bare file would mean rebuilding a playlist item and dropping @@ -466,13 +482,14 @@ describe('content updates', () => { await rerenderPlayer({ config: { playlist: otherPlaylist } }); - expect(instance.player.load).toHaveBeenCalledWith(otherPlaylist); + expect(instance.player.load.mock.calls).toEqual([[otherPlaylist]]); }); it('does not reload when a deep-equal playlist is passed as a new array', async () => { - const { instance, rerenderPlayer } = await renderPlayer({ playlist: [{ file: 'a.mp4' }] }); + const item = { file: 'a.mp4', tracks: [{ label: 'English', kind: 'captions' }] }; + const { instance, rerenderPlayer } = await renderPlayer({ playlist: [{ ...item }] }); - await rerenderPlayer({ playlist: [{ file: 'a.mp4' }] }); + await rerenderPlayer({ playlist: [{ ...item }] }); expect(instance.player.load).not.toHaveBeenCalled(); }); @@ -492,7 +509,7 @@ describe('content updates', () => { await rerenderPlayer({ playlist: otherPlaylist, onPlay: () => {} }); - expect(instance.player.load).toHaveBeenCalledWith(otherPlaylist); + expect(instance.player.load.mock.calls).toEqual([[otherPlaylist]]); }); it('keeps the current content when the new config has none', async () => { @@ -503,10 +520,16 @@ describe('content updates', () => { expect(instance.player.load).not.toHaveBeenCalled(); }); - it('keeps the current content when the new playlist is empty', async () => { - const { instance, rerenderPlayer } = await renderPlayer({ playlist: [{ file: 'a.mp4' }] }); + // Content that has not resolved yet must not blank out a playing player. + it.each([ + ['removed', undefined], + ['null', null], + ['an empty string', ''], + ['an empty array', []], + ])('keeps the current content when the playlist becomes %s', async (_name, nextValue) => { + const { instance, rerenderPlayer } = await renderPlayer({ playlist }); - await rerenderPlayer({ playlist: [] }); + await rerenderPlayer({ playlist: nextValue }); expect(instance.player.load).not.toHaveBeenCalled(); });