Skip to content

Commit 8fb5d33

Browse files
committed
Re-render extensible components on change
Allow extensions to be provided after initial render (e.g. via dynamic import after hydration). A single ExtensionsProvider at the root subscribes to extension changes and propagates updates via context, avoiding per-component subscriptions. REDMINE-21261
1 parent 0bae8fc commit 8fb5d33

File tree

4 files changed

+107
-27
lines changed

4 files changed

+107
-27
lines changed

entry_types/scrolled/package/spec/frontend/extensions-spec.js

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import React from 'react';
22

33
import '@testing-library/jest-dom/extend-expect'
4-
import {render} from '@testing-library/react'
4+
import {render, act} from '@testing-library/react'
55
import {
66
extensible,
77
provideExtensions,
8-
clearExtensions
8+
clearExtensions,
9+
ExtensionsProvider
910
} from 'frontend/extensions';
1011
import {StaticPreview} from 'frontend/useScrollPositionLifecycle';
1112

1213
describe('extensions', () => {
1314
afterEach(() => {
14-
clearExtensions();
15+
act(() => clearExtensions());
1516
});
1617

1718
describe('extensible with decorator', () => {
@@ -113,6 +114,50 @@ describe('extensions', () => {
113114
});
114115

115116
describe('provideExtensions', () => {
117+
it('re-renders decorator after mount', () => {
118+
const TestComponent = extensible('TestComponent', function TestComponent() {
119+
return <span>Component</span>;
120+
});
121+
122+
const {container} = render(<ExtensionsProvider><TestComponent /></ExtensionsProvider>);
123+
expect(container).not.toHaveTextContent('Decorator');
124+
125+
act(() => {
126+
provideExtensions({
127+
decorators: {
128+
TestComponent({children}) {
129+
return <div>Decorator{children}</div>;
130+
}
131+
}
132+
});
133+
});
134+
135+
expect(container).toHaveTextContent('DecoratorComponent');
136+
});
137+
138+
it('re-renders alternative after mount', () => {
139+
const TestComponent = extensible('TestComponent', function TestComponent() {
140+
return <span>Original</span>;
141+
});
142+
143+
const {container} = render(<ExtensionsProvider><TestComponent /></ExtensionsProvider>);
144+
expect(container).toHaveTextContent('Original');
145+
146+
act(() => {
147+
provideExtensions({
148+
alternatives: {
149+
TestComponent() {
150+
return <span>Alternative</span>;
151+
}
152+
}
153+
});
154+
});
155+
156+
expect(container).toHaveTextContent('Alternative');
157+
expect(container).not.toHaveTextContent('Original');
158+
});
159+
160+
116161
it('replaces previous extensions', () => {
117162
const TestComponent = extensible('TestComponent', function TestComponent() {
118163
return <span>Original</span>;

entry_types/scrolled/package/spec/frontend/inlineEditingWithLoadedComponents-spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22

33
import '@testing-library/jest-dom/extend-expect'
4-
import {render} from '@testing-library/react'
4+
import {render, act} from '@testing-library/react'
55
import {extensible, clearExtensions} from 'frontend/extensions';
66
import {loadInlineEditingComponents} from 'frontend/inlineEditing';
77
import {StaticPreview} from 'frontend/useScrollPositionLifecycle';
@@ -23,7 +23,7 @@ jest.mock('frontend/inlineEditing/components', () => ({
2323

2424
describe('extensions with loaded inline editing components', () => {
2525
afterAll(() => {
26-
clearExtensions();
26+
act(() => clearExtensions());
2727
});
2828

2929
describe('decorator', () => {

entry_types/scrolled/package/src/frontend/RootProviders.js

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,31 +12,34 @@ import {MediaMutedProvider} from './useMediaMuted';
1212
import {AudioFocusProvider} from './useAudioFocus';
1313
import {ConsentProvider} from './thirdPartyConsent';
1414
import {CurrentSectionProvider} from './useCurrentChapter';
15+
import {ExtensionsProvider} from './extensions';
1516
import {ScrollTargetEmitterProvider} from './useScrollTarget';
1617

1718
export function RootProviders({seed, consent = consentApi, children}) {
1819
return (
1920
<FocusOutlineProvider>
2021
<BrowserFeaturesProvider>
21-
<PhonePlatformProvider>
22-
<PhoneLayoutProvider>
23-
<MediaMutedProvider>
24-
<AudioFocusProvider>
25-
<EntryStateProvider seed={seed}>
26-
<CurrentSectionProvider>
27-
<LocaleProvider>
28-
<ConsentProvider consent={consent}>
29-
<ScrollTargetEmitterProvider>
30-
{children}
31-
</ScrollTargetEmitterProvider>
32-
</ConsentProvider>
33-
</LocaleProvider>
34-
</CurrentSectionProvider>
35-
</EntryStateProvider>
36-
</AudioFocusProvider>
37-
</MediaMutedProvider>
38-
</PhoneLayoutProvider>
39-
</PhonePlatformProvider>
22+
<ExtensionsProvider>
23+
<PhonePlatformProvider>
24+
<PhoneLayoutProvider>
25+
<MediaMutedProvider>
26+
<AudioFocusProvider>
27+
<EntryStateProvider seed={seed}>
28+
<CurrentSectionProvider>
29+
<LocaleProvider>
30+
<ConsentProvider consent={consent}>
31+
<ScrollTargetEmitterProvider>
32+
{children}
33+
</ScrollTargetEmitterProvider>
34+
</ConsentProvider>
35+
</LocaleProvider>
36+
</CurrentSectionProvider>
37+
</EntryStateProvider>
38+
</AudioFocusProvider>
39+
</MediaMutedProvider>
40+
</PhoneLayoutProvider>
41+
</PhonePlatformProvider>
42+
</ExtensionsProvider>
4043
</BrowserFeaturesProvider>
4144
</FocusOutlineProvider>
4245
);

entry_types/scrolled/package/src/frontend/extensions.js

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,67 @@
1-
import React from 'react';
1+
import React, {createContext, useContext, useEffect, useState} from 'react';
22

33
import {useIsStaticPreview} from './useScrollPositionLifecycle';
44

55
let decorators = {};
66
let alternatives = {};
7+
let listeners = [];
8+
9+
function subscribe(listener) {
10+
listeners = [...listeners, listener];
11+
return () => { listeners = listeners.filter(l => l !== listener); };
12+
}
13+
14+
function notifyListeners() {
15+
listeners.forEach(l => l());
16+
}
717

818
export function provideExtensions({decorators: d, alternatives: a} = {}) {
919
decorators = d || {};
1020
alternatives = a || {};
21+
notifyListeners();
1122
}
1223

1324
export function clearExtensions() {
1425
decorators = {};
1526
alternatives = {};
27+
notifyListeners();
28+
}
29+
30+
const ExtensionsContext = createContext(0);
31+
32+
export function ExtensionsProvider({children}) {
33+
const [version, setVersion] = useState(0);
34+
35+
useEffect(() => subscribe(() => setVersion(v => v + 1)), []);
36+
37+
return (
38+
<ExtensionsContext.Provider value={version}>
39+
{children}
40+
</ExtensionsContext.Provider>
41+
);
42+
}
43+
44+
function useExtensions() {
45+
useContext(ExtensionsContext);
46+
return {decorators, alternatives};
1647
}
1748

1849
export function extensible(name, Component) {
1950
return function ExtensibleComponent(props) {
2051
const isStaticPreview = useIsStaticPreview();
52+
const extensions = useExtensions();
2153

2254
if (isStaticPreview) {
2355
return <Component {...props} />;
2456
}
2557

26-
const Alternative = alternatives[name];
58+
const Alternative = extensions.alternatives[name];
2759

2860
if (Alternative) {
2961
return <Alternative {...props} />;
3062
}
3163

32-
const Decorator = decorators[name];
64+
const Decorator = extensions.decorators[name];
3365

3466
if (Decorator) {
3567
return <Decorator {...props}><Component {...props} /></Decorator>;

0 commit comments

Comments
 (0)