Skip to content

Commit c8561b9

Browse files
authored
add lazyCreateConnectedComponent (Like React.lazy but for repluggable) (#304)
* add `lazyCreateConnectedComponent` (Like `React.lazy` but for repluggable) Like `React.lazy` but for components created with repluggable `createConnected` function. * improve tests * Update lazyCreateConnectedComponent.ts with additional jsdoc
1 parent 7751f92 commit c8561b9

File tree

3 files changed

+288
-0
lines changed

3 files changed

+288
-0
lines changed

packages/repluggable/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export { SlotRenderer, ShellRenderer } from './renderSlotComponents'
3636
export { invokeSlotCallbacks } from './invokeSlotCallbacks'
3737

3838
export * from './connectWithShell'
39+
export * from './lazyCreateConnectedComponent'
3940
export { ErrorBoundary } from './errorBoundary'
4041
export { interceptEntryPoints, interceptEntryPointsMap } from './interceptEntryPoints'
4142
export { interceptAnyObject } from './interceptAnyObject'
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import React from 'react'
2+
3+
import { Shell } from './API'
4+
5+
type CreateConnectedComponent<T extends React.ComponentType<any>> = (boundShell: Shell) => T
6+
7+
export interface LazyConnectedComponent<T extends React.ComponentType<any>> extends React.LazyExoticComponent<T> {
8+
/**
9+
* Preloads the connected component to ensure it is ready before rendering.
10+
* Useful for optimizing performance in scenarios where the component will be needed soon.
11+
*
12+
* @example
13+
*
14+
* ```ts
15+
* const Component = lazyCreateConnectedComponent(shell, () => import('./AddPanel'));
16+
*
17+
* // preload the component
18+
* onMouseHover(() => {
19+
* Component.preload();
20+
* });
21+
* ```
22+
*/
23+
preload(): Promise<void>
24+
}
25+
26+
/**
27+
* Like `React.lazy` but for components created with repluggable `createConnected` function.
28+
* @see {@link https://react.dev/reference/react/lazy React Docs}
29+
*
30+
* @example <caption>having `./AddPanel.tsx` - module with **default** export of createConnected function</caption>
31+
* ```ts
32+
* const Component = lazyCreateConnectedComponent(shell,
33+
* () => import('./AddPanel')
34+
* );
35+
*
36+
* // `./AddPanel.tsx`
37+
* export default function createConnectedAddPanel(shell: Shell) { ...}
38+
* ```
39+
* @example <caption>having `./AddPanel.tsx` - module with **named** export of createConnected function</caption>
40+
* ```ts
41+
* const Component = lazyCreateConnectedComponent(shell,
42+
* () => import('./AddPanel').then(module => module.createConnectedAddPanel)
43+
* );
44+
*
45+
* // `./AddPanel.tsx`
46+
* export function createConnectedAddPanel(shell: Shell) { ... }
47+
* ```
48+
*/
49+
export function lazyCreateConnectedComponent<T extends React.ComponentType<any>>(
50+
fromShell: Shell,
51+
loadComponentFactory: () => Promise<{ default: CreateConnectedComponent<T> } | CreateConnectedComponent<T>>
52+
): LazyConnectedComponent<T> {
53+
let loadComponentPromise: Promise<T>
54+
async function loadComponent() {
55+
loadComponentPromise ??= loadComponentFactory().then(componentFactoryModuleOrFn => {
56+
const componentFactory =
57+
typeof componentFactoryModuleOrFn === 'function' ? componentFactoryModuleOrFn : componentFactoryModuleOrFn?.default
58+
59+
if (typeof componentFactory !== 'function') {
60+
throw new Error(
61+
'Expected a createConnected function or a module with a default export of one.\n' +
62+
`Received: ${typeof componentFactory}. Please ensure the module exports the correct function.`
63+
)
64+
}
65+
66+
const Component = fromShell.runLateInitializer(() => componentFactory(fromShell))
67+
68+
return Component
69+
})
70+
71+
return loadComponentPromise
72+
}
73+
74+
/**
75+
* Preloads the connected component to ensure it is ready before rendering.
76+
* Useful for optimizing performance in scenarios where the component will be needed soon.
77+
*
78+
* @example
79+
*
80+
* ```ts
81+
* const Component = lazyCreateConnectedComponent(shell, () => import('./AddPanel'));
82+
*
83+
* // preload the component
84+
* onMouseHover(() => {
85+
* Component.preload();
86+
* });
87+
* ```
88+
*/
89+
async function preload() {
90+
await loadComponent()
91+
}
92+
93+
const LazyComponent = React.lazy<T>(async () => {
94+
const Component = await loadComponent()
95+
96+
// NOTE: satisfy React.lazy expectation for module with default export
97+
return { default: Component }
98+
})
99+
100+
// NOTE: `Object.assign` is OK here,
101+
// so disable "prefer-object-spread"
102+
/* tslint:disable-next-line */
103+
return Object.assign(LazyComponent, { preload })
104+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import React from 'react'
2+
3+
import { lazyCreateConnectedComponent } from '../src'
4+
import { Shell } from '../src/API'
5+
import { addMockShell, collectAllTexts, connectWithShell, createAppHost, renderInHost } from '../testKit'
6+
7+
function createFallbackTrackerComponent(label: string = 'loading...') {
8+
let handleMounted: () => void
9+
let handleUnmounted: () => void
10+
const mountedPromise = new Promise<void>(resolve => {
11+
handleMounted = resolve
12+
})
13+
const unmountedPromise = new Promise<void>(resolve => {
14+
handleUnmounted = resolve
15+
})
16+
17+
const Fallback: React.FC = () => {
18+
React.useEffect(() => {
19+
handleMounted()
20+
return handleUnmounted
21+
}, [])
22+
return <div>{label}</div>
23+
}
24+
25+
// NOTE: `Object.assign` is OK here,
26+
// so disable "prefer-object-spread"
27+
/* tslint:disable-next-line */
28+
return Object.assign(Fallback, {
29+
LABEL: label,
30+
waitMounted: () => mountedPromise,
31+
waitUnmounted: () => unmountedPromise
32+
})
33+
}
34+
35+
const TEXT_IN_LAZY_COMPONENT = 'HELLO FROM LAZY COMPONENT'
36+
37+
const lazyComponentFactoryMock = (() => {
38+
interface MyComponentStateProps {
39+
foo: string
40+
}
41+
42+
interface MyComponentOwnProps {
43+
bar?: string
44+
}
45+
46+
const MyComponent: React.FC<MyComponentStateProps & MyComponentOwnProps> = props => (
47+
<div className="my-component">
48+
{props.foo}
49+
<span>{props.bar}</span>
50+
</div>
51+
)
52+
53+
const createMyComponent = (boundShell: Shell) =>
54+
connectWithShell<{}, MyComponentOwnProps, MyComponentStateProps>(
55+
() => ({
56+
foo: TEXT_IN_LAZY_COMPONENT
57+
}),
58+
undefined,
59+
boundShell
60+
)(MyComponent)
61+
62+
return {
63+
/**
64+
* represents a module with a named export
65+
* @example
66+
* ```
67+
* export function createMyComponent(shell: Shell) {}
68+
* ```
69+
*/
70+
loadWithNamedExport: async () => ({
71+
createMyComponent
72+
}),
73+
/**
74+
* represents a module with a default export
75+
* @example
76+
* ```
77+
* export default function createMyComponent(shell: Shell) {}
78+
* ```
79+
*/
80+
loadWithDefaultExport: async () => ({
81+
default: createMyComponent
82+
})
83+
}
84+
})()
85+
86+
describe.each([
87+
["with 'default export' module", () => lazyComponentFactoryMock.loadWithDefaultExport()],
88+
["with 'named export' module", () => lazyComponentFactoryMock.loadWithNamedExport().then(module => module.createMyComponent)]
89+
])('lazyCreateConnectedComponent (loadComponentFactory %s)', (_, loadComponentFactory) => {
90+
it('should create and render connected component from lazy factory', async () => {
91+
const host = createAppHost([])
92+
const boundShell = addMockShell(host)
93+
94+
const MyComponent = lazyCreateConnectedComponent(boundShell, loadComponentFactory)
95+
96+
const FallbackTracker = createFallbackTrackerComponent()
97+
98+
const { parentWrapper } = renderInHost(
99+
<React.Suspense fallback={<FallbackTracker />}>
100+
<MyComponent />
101+
</React.Suspense>,
102+
host
103+
)
104+
105+
// Check that the component shows the fallback, while the lazy component is loading
106+
expect(collectAllTexts(parentWrapper)).toContain(FallbackTracker.LABEL)
107+
expect(collectAllTexts(parentWrapper)).not.toContain(TEXT_IN_LAZY_COMPONENT)
108+
109+
await FallbackTracker.waitUnmounted()
110+
111+
// Check that the component shows the loaded content
112+
expect(collectAllTexts(parentWrapper)).not.toContain(FallbackTracker.LABEL)
113+
expect(collectAllTexts(parentWrapper)).toContain(TEXT_IN_LAZY_COMPONENT)
114+
})
115+
116+
it('should create and render connected componet with forwarding own props', async () => {
117+
const host = createAppHost([])
118+
const boundShell = addMockShell(host)
119+
120+
const MyComponent = lazyCreateConnectedComponent(boundShell, loadComponentFactory)
121+
122+
const FallbackTracker = createFallbackTrackerComponent()
123+
124+
const TEST_PROP = 'HELLO_FROM_PROPS'
125+
126+
const { parentWrapper } = renderInHost(
127+
<React.Suspense fallback={<FallbackTracker />}>
128+
<MyComponent bar={TEST_PROP} />
129+
</React.Suspense>,
130+
host
131+
)
132+
133+
expect(collectAllTexts(parentWrapper)).not.toContain(TEST_PROP)
134+
135+
await FallbackTracker.waitUnmounted()
136+
137+
expect(collectAllTexts(parentWrapper)).toContain(TEST_PROP)
138+
})
139+
140+
it('should call `loadComponentFactory` only after first render', async () => {
141+
const host = createAppHost([])
142+
const boundShell = addMockShell(host)
143+
144+
const loadComponentFactorySpy = jest.fn(() => loadComponentFactory())
145+
146+
const MyComponent = lazyCreateConnectedComponent(boundShell, loadComponentFactorySpy)
147+
148+
expect(loadComponentFactorySpy).not.toHaveBeenCalled()
149+
150+
renderInHost(
151+
<React.Suspense fallback={null}>
152+
<MyComponent />
153+
</React.Suspense>,
154+
host
155+
)
156+
157+
expect(loadComponentFactorySpy).toHaveBeenCalled()
158+
})
159+
160+
it('should preload component with `loadComponentFactory` on `Component.preload` call', async () => {
161+
const host = createAppHost([])
162+
const boundShell = addMockShell(host)
163+
164+
const loadComponentFactorySpy = jest.fn(() => loadComponentFactory())
165+
166+
const MyComponent = lazyCreateConnectedComponent(boundShell, loadComponentFactorySpy)
167+
168+
expect(loadComponentFactorySpy).not.toHaveBeenCalled()
169+
170+
MyComponent.preload()
171+
172+
expect(loadComponentFactorySpy).toHaveBeenCalled()
173+
174+
renderInHost(
175+
<React.Suspense fallback={null}>
176+
<MyComponent />
177+
</React.Suspense>,
178+
host
179+
)
180+
181+
expect(loadComponentFactorySpy).toHaveBeenCalledTimes(1)
182+
})
183+
})

0 commit comments

Comments
 (0)