Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add unmountEventing through the NovaEventingProvider #85

Merged
merged 7 commits into from
Oct 10, 2023
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
7 changes: 7 additions & 0 deletions change/@nova-react-2a92f71d-e096-4da8-afb5-d02dd26f4bc9.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "add useNovaUnmountEventing",
"packageName": "@nova/react",
"email": "[email protected]",
"dependentChangeType": "patch"
}
124 changes: 122 additions & 2 deletions packages/nova-react/src/eventing/nova-eventing-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import type {
import {
NovaEventingProvider,
useNovaEventing,
useNovaUnmountEventing,
} from "./nova-eventing-provider";
import type { EventWrapper, NovaEventing } from "@nova/types";
import { InputType } from "@nova/types";

import * as ReactEventSourceMapper from "./react-event-source-mapper";

const { useEffect } = React;

jest.mock("./react-event-source-mapper");

describe("useNovaEventing", () => {
Expand Down Expand Up @@ -61,15 +64,32 @@ describe("useNovaEventing", () => {
};
});

it("throws without a provider", () => {
it("useNovaEventing throws without a provider", () => {
expect.assertions(1);

const TestUndefinedContextComponent: React.FC = () => {
try {
useNovaEventing();
} catch (e) {
expect((e as Error).message).toMatch(
"Nova Eventing provider must be initialized prior to consumption!",
"Nova Eventing provider must be initialized prior to consumption of eventing!",
);
}
return null;
};

render(<TestUndefinedContextComponent />);
});

it("useNovaUnmountEventing throws without a provider", () => {
expect.assertions(1);

const TestUndefinedContextComponent: React.FC = () => {
try {
useNovaUnmountEventing();
} catch (e) {
expect((e as Error).message).toMatch(
"Nova Eventing provider must be initialized prior to consumption of unmountEventing!",
);
}
return null;
Expand Down Expand Up @@ -260,3 +280,103 @@ describe("NovaReactEventing exposes 'generateEvent'", () => {
);
});
});

describe("useUnmountEventing", () => {
it("falls back to eventing instance when unmountEventing is not provided", () => {
const eventing = {
bubble: jest.fn(),
} as unknown as NovaEventing;

const mapper = jest.fn();

const TestPassedContextComponent: React.FC =
(): React.ReactElement | null => {
const unmountEventing = useNovaUnmountEventing();
expect(unmountEventing).toBeDefined();

unmountEventing.bubble({} as ReactEventWrapper);

expect(eventing.bubble).toHaveBeenCalled();
return null;
};

render(
<NovaEventingProvider eventing={eventing} reactEventMapper={mapper}>
<TestPassedContextComponent />
</NovaEventingProvider>,
);
});

it('calls "bubble" on the unmountEventing instance when provided', () => {
const eventing = {
bubble: jest.fn(),
} as unknown as NovaEventing;

const unmountEventingMock = {
bubble: jest.fn(),
} as unknown as NovaEventing;

const mapper = jest.fn();

const TestPassedContextComponent: React.FC =
(): React.ReactElement | null => {
const unmountEventing = useNovaUnmountEventing();
expect(unmountEventing).toBeDefined();

unmountEventing.bubble({} as ReactEventWrapper);

expect(unmountEventingMock.bubble).toHaveBeenCalled();
return null;
};

render(
<NovaEventingProvider
eventing={eventing}
unmountEventing={unmountEventingMock}
reactEventMapper={mapper}
>
<TestPassedContextComponent />
</NovaEventingProvider>,
);
});
drewatk marked this conversation as resolved.
Show resolved Hide resolved

it("calls the unmounting eventing instance when the component calls unmounting through a useEffect cleanup event", () => {
const eventing = {
bubble: jest.fn(),
} as unknown as NovaEventing;
const unmountEventingMock = {
bubble: jest.fn(),
} as unknown as NovaEventing;

const mapper = jest.fn();

const TestPassedContextComponent: React.FC = () => {
const unmountEventing = useNovaUnmountEventing();
expect(unmountEventing).toBeDefined();

useEffect(
() => () => {
unmountEventing.bubble({} as ReactEventWrapper);
},
[unmountEventing],
);

return null;
};

const { unmount } = render(
<NovaEventingProvider
eventing={eventing}
unmountEventing={unmountEventingMock}
reactEventMapper={mapper}
>
<TestPassedContextComponent />
</NovaEventingProvider>,
);

unmount();

expect(unmountEventingMock.bubble).toHaveBeenCalled();
expect(eventing.bubble).not.toHaveBeenCalled();
});
});
144 changes: 96 additions & 48 deletions packages/nova-react/src/eventing/nova-eventing-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,21 @@ import type { NovaEvent, NovaEventing, EventWrapper } from "@nova/types";
import { InputType } from "@nova/types";
import invariant from "invariant";

// Initializing default with null to make sure providers are correctly placed in the tree
const NovaEventingContext = React.createContext<NovaReactEventing | null>(null);
// Context is initialized with an empty object and this is null-checked within the hooks
const NovaEventingContext = React.createContext<INovaEventingContext>({});

// Both properties are optional in the context for initialization state only, but eventing must be supplied in the props
interface INovaEventingContext {
eventing?: NovaReactEventing;
unmountEventing?: NovaReactEventing;
}

interface NovaEventingProviderProps {
eventing: NovaEventing;
/**
* Optional version of eventing to use when unmounting, defaults to eventing if not supplied via props
*/
unmountEventing?: NovaEventing;
/**
* Mapping logic to transform a React SyntheticEvent into a Nova
* Source object. Supply the Nova default implemenation using the import
Expand Down Expand Up @@ -47,57 +57,95 @@ export interface NovaReactEventing {
generateEvent(eventWrapper: GeneratedEventWrapper): Promise<void>;
}

export const NovaEventingProvider: React.FunctionComponent<NovaEventingProviderProps> =
({ children, eventing, reactEventMapper }) => {
// Nova contexts provide a facade over framework functions
// We don't need to trigger rerender in children when we are rerendered
// or when the input functions change, we just need to make sure callbacks
// use the right functions
const eventingRef = React.useRef(eventing);
if (eventingRef.current !== eventing) {
eventingRef.current = eventing;
}

const mapperRef = React.useRef(reactEventMapper);
if (mapperRef.current !== reactEventMapper) {
mapperRef.current = reactEventMapper;
}

const reactEventing: NovaReactEventing = React.useMemo(
() => ({
bubble: (eventWrapper: ReactEventWrapper) => {
const mappedEvent: EventWrapper = mapperRef.current(eventWrapper);
return eventingRef.current.bubble(mappedEvent);
},
generateEvent: (eventWrapper: GeneratedEventWrapper) => {
const mappedEvent = {
event: eventWrapper.event,
source: {
inputType: InputType.programmatic,
timeStamp: eventWrapper.timeStampOverride ?? Date.now(),
},
};
return eventingRef.current.bubble(mappedEvent);
},
}),
[],
);

return (
<NovaEventingContext.Provider value={reactEventing}>
{children}
</NovaEventingContext.Provider>
);
};
export const NovaEventingProvider: React.FunctionComponent<
NovaEventingProviderProps
> = ({ children, eventing, unmountEventing, reactEventMapper }) => {
// Nova contexts provide a facade over framework functions
// We don't need to trigger rerender in children when we are rerendered
// or when the input functions change, we just need to make sure callbacks
// use the right functions
const eventingRef = React.useRef(eventing);
if (eventingRef.current !== eventing) {
eventingRef.current = eventing;
}

const unmountEventingRef = React.useRef(unmountEventing || eventing);
if (unmountEventingRef.current !== unmountEventing) {
unmountEventingRef.current = unmountEventing || eventing; // default to eventing if unmountEventing not supplied
}

const mapperRef = React.useRef(reactEventMapper);
if (mapperRef.current !== reactEventMapper) {
mapperRef.current = reactEventMapper;
}

const reactEventing = React.useMemo(
generateEventing(eventingRef, mapperRef),
[],
);

const reactUnmountEventing = React.useMemo(
generateEventing(unmountEventingRef, mapperRef),
[],
);

const contextValue = React.useMemo(
() => ({ eventing: reactEventing, unmountEventing: reactUnmountEventing }),
[reactEventing, reactUnmountEventing],
);

return (
<NovaEventingContext.Provider value={contextValue}>
{children}
</NovaEventingContext.Provider>
);
};
NovaEventingProvider.displayName = "NovaEventingProvider";

export const useNovaEventing = (): NovaReactEventing => {
const eventing = React.useContext<NovaReactEventing | null>(
NovaEventingContext,
);
const { eventing } = React.useContext(NovaEventingContext);
invariant(
eventing,
"Nova Eventing provider must be initialized prior to consumption!",
"Nova Eventing provider must be initialized prior to consumption of eventing!",
);
return eventing;
};

/**
* Used for eventing that should be triggered when the component is unmounted, such as within a useEffect cleanup function
* If unmountEventing has not been supplied to the NovaEventingProvider, this will fallback to use the defualt eventing instance
*
* @returns NovaReactEventing
*/
export const useNovaUnmountEventing = (): NovaReactEventing => {
const { unmountEventing } = React.useContext(NovaEventingContext);
invariant(
unmountEventing,
"Nova Eventing provider must be initialized prior to consumption of unmountEventing!",
);
return unmountEventing;
};

const generateEventing =
(
eventingRef: React.MutableRefObject<NovaEventing>,
mapperRef: React.MutableRefObject<
(reactEventWrapper: ReactEventWrapper) => EventWrapper
>,
) =>
(): NovaReactEventing => ({
bubble: (eventWrapper: ReactEventWrapper) => {
const mappedEvent: EventWrapper = mapperRef.current(eventWrapper);
return eventingRef.current.bubble(mappedEvent);
},
generateEvent: (eventWrapper: GeneratedEventWrapper) => {
const mappedEvent = {
event: eventWrapper.event,
source: {
inputType: InputType.programmatic,
timeStamp: eventWrapper.timeStampOverride ?? Date.now(),
},
};
return eventingRef.current.bubble(mappedEvent);
},
});
Loading