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

Fix unmount cleanup of Facets #12

Merged
merged 9 commits into from
Nov 5, 2021
Merged
1 change: 1 addition & 0 deletions packages/@react-facet/dom-fiber/src/createFiberRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const createFiberRoot =
(container: HTMLElement): FacetFiberRoot =>
reconciler.createContainer(
{
children: new Set(),
element: container,
styleUnsubscribers: new Map(),
style: container.style,
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-facet/dom-fiber/src/createPortal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export function createPortal(children: ReactNodeList, container: HTMLElement, ke
key,
children,
containerInfo: {
children: new Set(),
element: container,
unsubscribers: new Map(),
styleUnsubscribers: new Map(),
style: container.style,
},
Expand Down
105 changes: 105 additions & 0 deletions packages/@react-facet/dom-fiber/src/setupHostConfig.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -901,10 +901,111 @@ describe('update', () => {
})
})

describe('umnount', () => {
it('unsubscribes from all facets when a element component is unmounted', () => {
const unsubscribe = jest.fn()

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const facet: Facet<any> = {
get: () => 'text',
observe: jest.fn().mockReturnValue(unsubscribe),
}

render(
<fast-div
style={{ background: facet, color: facet }}
className={facet}
data-droppable={facet}
data-testid={facet}
data-x-ray={facet}
src={facet}
href={facet}
target={facet}
autoPlay={facet}
loop={facet}
disabled={facet}
maxLength={facet}
rows={facet}
value={facet}
type={facet}
/>,
)
// on mount, we verify that we have added 16 subscriptions (one for each prop and style above)
expect(facet.observe).toHaveBeenCalledTimes(16)

// on unmount, we check that unsubscribe was called once for each subscription
render(<></>)
expect(unsubscribe).toHaveBeenCalledTimes(16)
})

it('unsubscribes from the text facet when a fast-text component is unmounted', () => {
const unsubscribe = jest.fn()

const facet: Facet<string> = {
get: () => 'text',
observe: jest.fn().mockReturnValue(unsubscribe),
}

render(<fast-text text={facet} />)
expect(facet.observe).toHaveBeenCalledTimes(1)

render(<></>)
expect(unsubscribe).toHaveBeenCalledTimes(1)
})

it('unsubscribe from facets when the parent of a component is unmounted', () => {
const unsubscribe = jest.fn()

const facet: Facet<string> = {
get: () => 'text',
observe: jest.fn().mockReturnValue(unsubscribe),
}

render(
<div>
<fast-text text={facet} />
</div>,
)
expect(facet.observe).toHaveBeenCalledTimes(1)
expect(unsubscribe).toHaveBeenCalledTimes(0)

render(<></>)
expect(unsubscribe).toHaveBeenCalledTimes(1)
})

it('keeps the subscription of facets when moving in a keyed list', () => {
const unsubscribeA = jest.fn()

const facetA: Facet<string> = {
get: () => 'text',
observe: jest.fn().mockReturnValue(unsubscribeA),
}

const unsubscribeB = jest.fn()

const facetB: Facet<string> = {
get: () => 'text',
observe: jest.fn().mockReturnValue(unsubscribeB),
}

render(<div>{[<fast-div key={'A'} className={facetA} />, <fast-div key={'B'} className={facetB} />]}</div>)
expect(facetA.observe).toHaveBeenCalledTimes(1)
expect(facetB.observe).toHaveBeenCalledTimes(1)

render(<div>{[<fast-div key={'B'} className={facetB} />, <fast-div key={'A'} className={facetA} />]}</div>)

expect(facetA.observe).toHaveBeenCalledTimes(1)
expect(facetB.observe).toHaveBeenCalledTimes(1)
expect(unsubscribeA).not.toHaveBeenCalled()
expect(unsubscribeB).not.toHaveBeenCalled()
})
})

describe('commitUpdate style prop', () => {
it('subscribes when updating from null', () => {
const hostConfig = setupHostConfig()
const instance: ElementContainer = {
children: new Set(),
element: document.createElement('div'),
styleUnsubscribers: new Map(),
}
Expand Down Expand Up @@ -944,6 +1045,7 @@ describe('commitUpdate style prop', () => {

const hostConfig = setupHostConfig()
const instance: ElementContainer = {
children: new Set(),
element: document.createElement('div'),
styleUnsubscribers: new Map([['color', colorUnsubscriber]]),
}
Expand Down Expand Up @@ -982,6 +1084,7 @@ describe('commitUpdate style prop', () => {

const hostConfig = setupHostConfig()
const instance: ElementContainer = {
children: new Set(),
element: document.createElement('div'),
styleUnsubscribers: new Map([['color', colorUnsubscriber]]),
}
Expand Down Expand Up @@ -1015,6 +1118,7 @@ describe('commitUpdate style prop', () => {

const hostConfig = setupHostConfig()
const instance: ElementContainer = {
children: new Set(),
element: document.createElement('div'),
styleUnsubscribers: new Map([['color', colorUnsubscriber]]),
}
Expand Down Expand Up @@ -1062,6 +1166,7 @@ describe('commitUpdate style prop', () => {

const hostConfig = setupHostConfig()
const instance: ElementContainer = {
children: new Set(),
element: document.createElement('div'),
styleUnsubscribers: new Map([['color', colorUnsubscriber]]),
}
Expand Down
68 changes: 56 additions & 12 deletions packages/@react-facet/dom-fiber/src/setupHostConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import {
Props,
Type,
Container,
ElementContainer,
Instance,
TextInstance,
HydratableInstance,
PublicInstance,
HostContext,
UpdatePayload,
NoTimeout,
isElementContainer,
} from './types'
import { HostConfig } from 'react-reconciler'
import { FacetProp, isFacet, Unsubscribe } from '@react-facet/core'
Expand Down Expand Up @@ -89,6 +91,7 @@ export const setupHostConfig = (): HostConfig<
const element = document.createTextNode('')

return {
children: new Set(),
element,
text: setupTextUpdate(newProps.text, element),
}
Expand Down Expand Up @@ -188,6 +191,7 @@ export const setupHostConfig = (): HostConfig<
element,
styleUnsubscribers,
style,
children: new Set(),

className: newProps.className != null ? setupClassUpdate(newProps.className, element) : undefined,
autoPlay: newProps.autoPlay != null ? setupAutoPlayUpdate(newProps.autoPlay, element) : undefined,
Expand All @@ -211,12 +215,6 @@ export const setupHostConfig = (): HostConfig<
}
},

appendInitialChild: function (parent, child) {
if (parent.element == null || child.element == null) return

parent.element.appendChild(child.element)
},

finalizeInitialChildren: function () {
return false
},
Expand All @@ -227,12 +225,6 @@ export const setupHostConfig = (): HostConfig<

commitMount: function () {},

appendChildToContainer: function (parent, child) {
if (parent.element == null || child.element == null) return

parent.element.appendChild(child.element)
},

prepareUpdate: function () {
return true
},
Expand Down Expand Up @@ -531,7 +523,27 @@ export const setupHostConfig = (): HostConfig<
textInstance.element.nodeValue = newText
},

appendInitialChild: function (parent, child) {
if (isElementContainer(child)) {
parent.children.add(child)
}

parent.element.appendChild(child.element)
},

appendChildToContainer: function (parent, child) {
if (isElementContainer(child)) {
parent.children.add(child)
}

parent.element.appendChild(child.element)
},

appendChild: function (parentInstance, child) {
if (isElementContainer(child)) {
parentInstance.children.add(child)
}

parentInstance.element.appendChild(child.element)
},

Expand All @@ -540,6 +552,10 @@ export const setupHostConfig = (): HostConfig<
},

removeChild: function (parentInstance, child) {
if (isElementContainer(child)) {
cleanupElementContainer(child)
}

parentInstance.element.removeChild(child.element)
},

Expand All @@ -548,6 +564,10 @@ export const setupHostConfig = (): HostConfig<
},

removeChildFromContainer: function (container, child) {
if (isElementContainer(child)) {
cleanupElementContainer(child)
}

container.element.removeChild(child.element)
},

Expand All @@ -564,6 +584,30 @@ export const setupHostConfig = (): HostConfig<
},
})

const cleanupElementContainer = (instance: ElementContainer) => {
instance.styleUnsubscribers?.forEach((unsubscribe) => unsubscribe())
instance.styleUnsubscribers?.clear()

instance.children.forEach(cleanupElementContainer)
instance.children.clear()

instance.className?.()
instance['data-droppable']?.()
instance['data-testid']?.()
instance['data-x-ray']?.()
instance.src?.()
instance.href?.()
instance.target?.()
instance.autoPlay?.()
instance.loop?.()
instance.disabled?.()
instance.maxLength?.()
instance.rows?.()
instance.value?.()
instance.type?.()
instance.text?.()
}

const setupClassUpdate = (className: FacetProp<string | undefined>, element: HTMLElement) => {
if (isFacet(className)) {
return className.observe((className) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/@react-facet/dom-fiber/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export type Props<T> = ElementProps<T> & TextProps
export type ValidPropsNames = keyof Props<unknown>

export type ElementContainer = {
children: Set<ElementContainer>

element: HTMLElement | Text

style?: CSSStyleDeclaration
Expand All @@ -138,6 +140,10 @@ export type ElementContainer = {
text?: Unsubscribe
}

export const isElementContainer = (value: unknown): value is ElementContainer => {
return value != null && (value as ElementContainer).children != null
}
pirelenito marked this conversation as resolved.
Show resolved Hide resolved

export type TextContainer = {
element: Text
}
Expand Down