Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions library/src/__tests__/plugin-strict-mode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* @jest-environment jsdom
*/

import React, { StrictMode } from 'react';
import { render, waitFor } from '@testing-library/react';
import AsyncApiComponent from '..';
import { AsyncApiPlugin, PluginAPI, PluginSlot } from '../types';

jest.mock('use-resize-observer', () => ({
__esModule: true,
useResizeObserver: jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
})),
}));
jest.setTimeout(20000);

const schema = {
asyncapi: '3.0.0',
info: { title: 'Strict Mode API', version: '1.0.0' },
channels: {
userSignedUp: {
address: 'user/signedup',
messages: { UserSignedUp: { payload: { type: 'string' } } },
},
},
operations: {
onUserSignedUp: {
action: 'receive',
channel: { $ref: '#/channels/userSignedUp' },
},
},
};

const PluginPanel = () => <div data-testid="plugin-panel">panel</div>;

test('keeps plugins registered when StrictMode remounts the component', async () => {
const finishUninstalls: (() => void)[] = [];
let resourceActive = false;
let lifecycleOverlapped = false;
const install = jest.fn((api: PluginAPI) => {
if (resourceActive) lifecycleOverlapped = true;
resourceActive = true;
api.registerComponent(PluginSlot.OPERATION, PluginPanel);
});
const uninstall = jest.fn(
() =>
new Promise<void>((resolve) => {
finishUninstalls.push(() => {
resourceActive = false;
resolve();
});
}),
);
const plugin: AsyncApiPlugin = {
name: 'strict-mode-plugin',
version: '1.0.0',
install,
uninstall,
};

const result = render(
<StrictMode>
<AsyncApiComponent schema={schema} plugins={[plugin]} />
</StrictMode>,
);

await waitFor(() => expect(uninstall).toHaveBeenCalledTimes(1));
expect(install).toHaveBeenCalledTimes(1);

finishUninstalls[0]();

await waitFor(
() => {
expect(
result.container.querySelector('[data-testid="plugin-panel"]'),
).toBeTruthy();
},
{ timeout: 15000 },
);
expect(install).toHaveBeenCalledTimes(2);
expect(lifecycleOverlapped).toBe(false);

result.rerender(
<StrictMode>
<AsyncApiComponent schema={schema} plugins={[]} />
</StrictMode>,
);
await waitFor(() => expect(uninstall).toHaveBeenCalledTimes(2));

result.rerender(
<StrictMode>
<AsyncApiComponent schema={schema} plugins={[plugin]} />
</StrictMode>,
);
expect(install).toHaveBeenCalledTimes(2);

finishUninstalls[1]();
await waitFor(() => expect(install).toHaveBeenCalledTimes(3));
expect(lifecycleOverlapped).toBe(false);

result.unmount();
await waitFor(() => expect(uninstall).toHaveBeenCalledTimes(3));
finishUninstalls[2]();
});

test('waits for a same-name plugin to uninstall before installing its replacement', async () => {
let finishUninstall: (() => void) | undefined;
const firstInstall = jest.fn();
const firstUninstall = jest.fn(
() =>
new Promise<void>((resolve) => {
finishUninstall = resolve;
}),
);
const replacementInstall = jest.fn();
const firstPlugin: AsyncApiPlugin = {
name: 'replaceable-plugin',
version: '1.0.0',
install: firstInstall,
uninstall: firstUninstall,
};
const replacementPlugin: AsyncApiPlugin = {
name: 'replaceable-plugin',
version: '2.0.0',
install: replacementInstall,
};

const result = render(
<AsyncApiComponent schema={schema} plugins={[firstPlugin]} />,
);
await waitFor(() => expect(firstInstall).toHaveBeenCalledTimes(1));

result.rerender(
<AsyncApiComponent schema={schema} plugins={[replacementPlugin]} />,
);
await waitFor(() => expect(firstUninstall).toHaveBeenCalledTimes(1));
expect(replacementInstall).not.toHaveBeenCalled();

finishUninstall?.();
await waitFor(() => expect(replacementInstall).toHaveBeenCalledTimes(1));

result.unmount();
});
132 changes: 100 additions & 32 deletions library/src/containers/AsyncApi/Standalone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,18 @@
class AsyncApiComponent extends Component<AsyncApiProps, AsyncAPIState> {
private readonly registeredPlugins = new Set<string>();
private readonly propsPlugins = new Set<string>();
private readonly pendingPropsPlugins = new Set<string>();
/** Stable handler refs so `off()` removes the same listeners registered by `on()`. */
private readonly pluginEventHandlers = new Map<string, EventListener>();
private hasMounted = false;
/** `destroy()` is terminal, so a remount has to build a new manager rather than reuse it. */
private pluginManagerDestroyed = false;
/** Teardown from earlier manager generations that must finish before activating a replacement. */
private pluginManagerTeardown?: Promise<void>;
/** Invalidates deferred mount work when StrictMode unmounts and remounts this instance. */
private mountGeneration = 0;
/** Keeps prop-driven plugin replacements ordered across rapid updates. */
private pluginUpdates = Promise.resolve();

state: AsyncAPIState = {
asyncapi: undefined,
Expand All @@ -54,18 +63,40 @@

componentDidMount() {
this.hasMounted = true;
const mountGeneration = ++this.mountGeneration;

// React StrictMode can remount this component instance in development, so the manager
// destroyed during its simulated unmount must be replaced before registering again.
let pm = this.state.pm;
if (!pm || this.pluginManagerDestroyed) {
pm = new PluginManager({});
this.pluginManagerDestroyed = false;
this.registeredPlugins.clear();
this.propsPlugins.clear();
this.pendingPropsPlugins.clear();
this.setState({ pm });
}

if (!this.state.asyncapi) {
this.updateState(this.props.schema);
this.updateState(this.props.schema, pm);
} else {
this.state.pm?.updateContext({ schema: this.state.asyncapi });
pm.updateContext({ schema: this.state.asyncapi });
}

if (this.props.onPluginManagerReady) {
this.props.onPluginManagerReady(this.state.pm!);
const activatePluginManager = () => {
if (!this.hasMounted || mountGeneration !== this.mountGeneration) return;

this.props.onPluginManagerReady?.(pm);
// setState above may not have applied yet, so pass the manager these need explicitly.
this.setupEventListeners(pm);
void this.registerPlugins(pm);
};

if (this.pluginManagerTeardown) {
void this.pluginManagerTeardown.then(activatePluginManager);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
activatePluginManager();
}
this.setupEventListeners();
void this.registerPlugins();
}

componentDidUpdate(prevProps: AsyncApiProps) {
Expand All @@ -83,15 +114,24 @@
}

if (plugins !== prevProps.plugins) {
void this.updatePlugins(prevProps.plugins, plugins);
this.pluginUpdates = this.pluginUpdates.then(() =>
this.updatePlugins(prevProps.plugins, plugins),
);
}
}

componentWillUnmount() {
this.hasMounted = false;
this.mountGeneration += 1;
this.cleanupEventListeners();
// Let plugins release what they hold (open connections, timers) instead of orphaning it.
void this.state.pm?.destroy();
// Flag it first: destroy() is terminal, so a remount must start from a new manager.
this.pluginManagerDestroyed = true;
const teardown = this.state.pm?.destroy() ?? Promise.resolve();
const previousTeardown = this.pluginManagerTeardown;
this.pluginManagerTeardown = previousTeardown
? Promise.all([previousTeardown, teardown]).then(() => undefined)
: teardown;
}

render() {
Expand Down Expand Up @@ -151,9 +191,8 @@
return this.pluginEventHandlers.get(eventName)!;
}

private setupEventListeners() {
private setupEventListeners(pm: PluginManager | undefined = this.state.pm) {
const { onPluginEvent } = this.props;
const { pm } = this.state;

if (!onPluginEvent) return;

Expand All @@ -162,21 +201,21 @@
});
}

private cleanupEventListeners() {
const { pm } = this.state;
private cleanupEventListeners(pm: PluginManager | undefined = this.state.pm) {
PLUGINEVENTS.forEach((event) => {
pm?.off(event, this.getOrCreateHandler(event));
});
}

private async registerPlugins() {
private async registerPlugins(pm: PluginManager | undefined = this.state.pm) {
const { plugins } = this.props;
const { pm } = this.state;

for (const plugin of plugins ?? []) {
this.pendingPropsPlugins.add(plugin.name);
const registered = await pm?.register(plugin);
this.pendingPropsPlugins.delete(plugin.name);
const stillRequested = (this.props.plugins ?? []).some(

Check warning on line 217 in library/src/containers/AsyncApi/Standalone.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `.includes()` instead of `.some()` when checking value existence.

See more on https://sonarcloud.io/project/issues?id=asyncapi_asyncapi-react&issues=AaCvxNkyCgE7cx2YPFgR&open=AaCvxNkyCgE7cx2YPFgR&pullRequest=1355
(candidate) => candidate.name === plugin.name,
(candidate) => candidate === plugin,
);
if (registered && stillRequested && this.hasMounted) {
this.registeredPlugins.add(plugin.name);
Expand All @@ -197,33 +236,43 @@
newPlugins: AsyncApiPlugin[] | undefined,
) {
const { pm } = this.state;
const mountGeneration = this.mountGeneration;

const prevPluginMap = new Map((prevPlugins ?? []).map((p) => [p.name, p]));
const newPluginMap = new Map((newPlugins ?? []).map((p) => [p.name, p]));

const pluginsToRemove = Array.from(prevPluginMap.keys()).filter(
(name) => !newPluginMap.has(name) && this.propsPlugins.has(name),
const pluginsToRemove = Array.from(prevPluginMap.entries()).filter(
([name, plugin]) =>
newPluginMap.get(name) !== plugin &&
(this.propsPlugins.has(name) || this.pendingPropsPlugins.has(name)),
);
await this.unregisterPlugins(
pm,
pluginsToRemove.map(([name]) => name),
);
for (const name of pluginsToRemove) {
try {
await pm?.unregister(name);
this.registeredPlugins.delete(name);
this.propsPlugins.delete(name);
} catch (error) {
console.error(`Failed to unregister plugin ${name}:`, error);
}
}

const pluginsToAdd = Array.from(newPluginMap.entries()).filter(
([name]) => !prevPluginMap.has(name),
([name, plugin]) => prevPluginMap.get(name) !== plugin,
);

if (this.pluginManagerTeardown) {
await this.pluginManagerTeardown;
}
if (!this.hasMounted || mountGeneration !== this.mountGeneration) return;

for (const [name, plugin] of pluginsToAdd) {
const registered = await pm?.register(plugin);
const stillRequested = (this.props.plugins ?? []).some(

Check warning on line 264 in library/src/containers/AsyncApi/Standalone.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `.includes()` instead of `.some()` when checking value existence.

See more on https://sonarcloud.io/project/issues?id=asyncapi_asyncapi-react&issues=AaCvxNkyCgE7cx2YPFgS&open=AaCvxNkyCgE7cx2YPFgS&pullRequest=1355
(p) => p.name === name,
(candidate) => candidate === plugin,
);
if (registered && stillRequested) {
if (!stillRequested) continue;

this.pendingPropsPlugins.add(name);
const registered = await pm?.register(plugin);
this.pendingPropsPlugins.delete(name);
const remainsRequested = (this.props.plugins ?? []).some(

Check warning on line 272 in library/src/containers/AsyncApi/Standalone.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `.includes()` instead of `.some()` when checking value existence.

See more on https://sonarcloud.io/project/issues?id=asyncapi_asyncapi-react&issues=AaCvxNkyCgE7cx2YPFgT&open=AaCvxNkyCgE7cx2YPFgT&pullRequest=1355
(candidate) => candidate === plugin,
);
if (registered && remainsRequested) {
this.registeredPlugins.add(name);
this.propsPlugins.add(name);
} else if (registered) {
Expand All @@ -237,14 +286,33 @@
}
}

private updateState(schema: PropsSchema) {
private async unregisterPlugins(
pm: PluginManager | undefined,
pluginNames: string[],
) {
for (const name of pluginNames) {
try {
await pm?.unregister(name);
this.registeredPlugins.delete(name);
this.propsPlugins.delete(name);
this.pendingPropsPlugins.delete(name);
} catch (error) {
console.error(`Failed to unregister plugin ${name}:`, error);
}
}
}

private updateState(
schema: PropsSchema,
pm: PluginManager | undefined = this.state.pm,
) {
const parsedSpec = SpecificationHelpers.retrieveParsedSpec(schema);
if (!parsedSpec) {
this.setState({ asyncapi: undefined });
return;
}
this.setState({ asyncapi: parsedSpec });
this.state.pm?.updateContext({ schema: parsedSpec });
pm?.updateContext({ schema: parsedSpec });
}
}

Expand Down
Loading
Loading