Skip to content

Commit

Permalink
Add DeferredMountWithCallback and useNotifyMountComplete to allow def…
Browse files Browse the repository at this point in the history
…erred asynchronous renders. (#34)

Co-authored-by: Fernando Via Canel <fernando.via@gmail.com>
  • Loading branch information
marlonicus and xaviervia committed Feb 8, 2022
1 parent f1cbd80 commit 0c5cc8e
Show file tree
Hide file tree
Showing 3 changed files with 225 additions and 11 deletions.
35 changes: 33 additions & 2 deletions packages/@react-facet/deferred-mount/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ React Facet is a state management for performant game UIs. For more information

This package allows you to defer the mounting of a component to the next frame. By wrapping components on a big list you can keep the frame time low while everything is mounted.

When the `DeferredMountProvider` is used, it requires that there is at least one descendent as a `DeferredMount`, otherwise it will wait forever as `deferring`.
When the `DeferredMountProvider` is used, it requires that there is at least one descendent as a `DeferredMount` or `DeferredMountWithCallback`, otherwise it will wait forever as `deferring`.

## Example
### Example

In the example below it will mount:

Expand Down Expand Up @@ -46,3 +46,34 @@ render(
```

The `frameTimeBudget` prop allows the tweaking of how much time the library has available to do work on a given frame (by default it targets 60fps).

## Deferring Asynchronous Renders

Some components may need to wait some time before they can be considered fully rendered (for example if they are fetching data). For these cases you should use `DeferredMountWithCallback` with the `useNotifyMountComplete` hook.

### Example

```tsx
const DelayedComponent = () => {
const notifyMountComplete = useNotifyMountComplete()
const [data, setData] = useState()

useEffect(() => {
fetch('mock-api').then((data) => {
setData(data)
notifyMountComplete()
})
}, [notifyMountComplete])

return <div>{data}</div>
}

render(
<DeferredMountProvider>
<DeferredMountWithCallback>
<DelayedComponent />
</DeferredMountWithCallback>
</DeferredMountProvider>,
document.getElementById('root'),
)
```
117 changes: 115 additions & 2 deletions packages/@react-facet/deferred-mount/src/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import React from 'react'
import { DeferredMountProvider, DeferredMount, useIsDeferring } from '.'
import React, { useEffect } from 'react'
import {
DeferredMountProvider,
DeferredMount,
useIsDeferring,
DeferredMountWithCallback,
useNotifyMountComplete,
} from '.'
import { render, act } from '@react-facet/dom-fiber-testing-library'
import { useFacetEffect, useFacetMap } from '@react-facet/core'

Expand All @@ -21,6 +27,113 @@ describe('DeferredMount', () => {
})
})

describe('DeferredMountWithCallback', () => {
it('renders immediately if we dont have a provider', () => {
const { container } = render(
<DeferredMountWithCallback>
<div>Should be rendered</div>
</DeferredMountWithCallback>,
)
expect(container.firstChild).toContainHTML('<div>Should be rendered</div>')
})

it('waits until previous deferred callback finishes', async () => {
jest.useFakeTimers()

const frames: (() => void)[] = []
const requestSpy = jest.spyOn(window, 'requestAnimationFrame').mockImplementation((frameRequest) => {
const id = idSeed++
const cb = () => {
frameRequest(id)
}
frames.push(cb)
return id
})

const runRaf = () => {
const cb = frames.pop()
if (cb != null) act(() => cb())
}

const MOUNT_COMPLETION_DELAY = 1000

const MockDeferredComponent = ({ index }: { index: number }) => {
const triggerMountComplete = useNotifyMountComplete()

useEffect(() => {
const id = setTimeout(triggerMountComplete, MOUNT_COMPLETION_DELAY)

return () => {
clearTimeout(id)
}
}, [triggerMountComplete, index])

return <div>Callback{index}</div>
}

const SampleComponent = () => {
const isDeferringFacet = useIsDeferring()

return (
<>
<fast-text
text={useFacetMap((isDeferring) => (isDeferring ? 'deferring' : 'done'), [], [isDeferringFacet])}
/>
<DeferredMountWithCallback>
<MockDeferredComponent index={0} />
</DeferredMountWithCallback>
<DeferredMountWithCallback>
<MockDeferredComponent index={1} />
</DeferredMountWithCallback>
<DeferredMountWithCallback>
<MockDeferredComponent index={2} />
</DeferredMountWithCallback>
</>
)
}

const { container } = render(
<DeferredMountProvider>
<SampleComponent />
</DeferredMountProvider>,
)

// Wait a frame for deferred component to render
expect(container).toContainHTML('deferring')
expect(container).not.toContainHTML('Callback0')
expect(container).not.toContainHTML('Callback1')
expect(container).not.toContainHTML('Callback2')

// Initial run, sets renders the first component
runRaf()
expect(container).toContainHTML('Callback0')
expect(container).not.toContainHTML('Callback1')
expect(container).not.toContainHTML('Callback2')

// Wait for component to finish rendering, then advance to the next component render
jest.advanceTimersByTime(MOUNT_COMPLETION_DELAY)
runRaf()
expect(container).toContainHTML('Callback0')
expect(container).toContainHTML('Callback1')
expect(container).not.toContainHTML('Callback2')

// Wait for component to finish rendering, then advance to the next component render
jest.advanceTimersByTime(MOUNT_COMPLETION_DELAY)
runRaf()
expect(container).toContainHTML('Callback0')
expect(container).toContainHTML('Callback1')
expect(container).toContainHTML('Callback2')

// Wait for final deferred render and expect queue to be finished
jest.advanceTimersByTime(MOUNT_COMPLETION_DELAY)
runRaf()
expect(container).toContainHTML('done')

jest.useRealTimers()
requestSpy.mockRestore()
})
})

describe('cost is about half of the budget (should mount two per frame)', () => {
const COST = 5
const BUDGET = 11
Expand Down
84 changes: 77 additions & 7 deletions packages/@react-facet/deferred-mount/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function DeferredMountProvider({
}: DeferredMountProviderProps) {
const [isDeferring, setIsDeferring] = useFacetState(true)
const [requestingToRun, setRequestingToRun] = useFacetState(false)
const waitingForMountCallback = useRef(false)

const deferredMountsRef = useRef<UpdateFn[]>([])

Expand All @@ -49,7 +50,7 @@ export function DeferredMountProvider({
*/
return () => {
// It is most common that we will be cleaning up after all deferred mounting has been run
if (deferredMountsRef.current.length === 0) return
if (deferredMountsRef.current.length === 0 && !waitingForMountCallback.current) return

const index = deferredMountsRef.current.indexOf(updateFn)
if (index !== -1) {
Expand All @@ -64,7 +65,7 @@ export function DeferredMountProvider({
(requestingToRun) => {
// Even if we are not considered to be running, we need to check if there is still
// work pending to be done. If there is... we still need to run this effect.
if (!requestingToRun && deferredMountsRef.current.length === 0) return
if (!requestingToRun && deferredMountsRef.current.length === 0 && !waitingForMountCallback.current) return

const work = (startTimestamp: number) => {
const deferredMounts = deferredMountsRef.current
Expand All @@ -74,26 +75,50 @@ export function DeferredMountProvider({
// before we have a chance to cancel
// Its not possible to detect this with unit testing, so verify on the browser
// after a change here that this function is not executing every frame unnecessarily
if (deferredMounts.length > 0) {
if (deferredMounts.length > 0 || waitingForMountCallback.current) {
frameId = window.requestAnimationFrame(work)
} else {
// Used to check if the requestAnimationFrame has stopped running
frameId = -1
}

let lastUpdateCost = 0
let now = startTimestamp

while (deferredMounts.length > 0 && now - startTimestamp + lastUpdateCost < frameTimeBudget) {
while (
deferredMounts.length > 0 &&
now - startTimestamp + lastUpdateCost < frameTimeBudget &&
!waitingForMountCallback.current
) {
const before = now

const updateFn = deferredMounts.shift() as UpdateFn
updateFn(false)
const result = updateFn(false)

const after = performance.now()

lastUpdateCost = after - before
now = after

// Can be a function that takes a callback if using DeferredMountWithCallback
const resultTakesCallback = typeof result === 'function'

if (resultTakesCallback) {
waitingForMountCallback.current = true

result(() => {
waitingForMountCallback.current = false

// If the requestAnimationFrame stops running while waiting for the
// callback we need to restart it to process the rest of the queue.
if (frameId === -1) {
frameId = window.requestAnimationFrame(work)
}
})
}
}

if (deferredMounts.length === 0) {
if (deferredMounts.length === 0 && !waitingForMountCallback.current) {
setIsDeferring(false)
setRequestingToRun(false)
}
Expand Down Expand Up @@ -136,6 +161,51 @@ export function DeferredMount({ children }: DeferredMountProps) {
return children
}

interface DeferredMountWithCallbackProps {
children: ReactElement
}

const NotifyMountComplete = createContext(() => {})
export const useNotifyMountComplete = () => useContext(NotifyMountComplete)

/**
* Component that should wrap some mounting that must be deferred to a later frame.
* This will wait for a callback from the child component before marking itself as rendered.
* @param children component to be mounted deferred
*/
export function DeferredMountWithCallback({ children }: DeferredMountWithCallbackProps) {
const pushDeferUpdateFunction = useContext(pushDeferUpdateContext)
const [deferred, setDeferred] = useState(pushDeferUpdateFunction != null)
const resolveMountComplete = useRef<(value: void | PromiseLike<void>) => void>()
const mountCompleteBeforeInitialization = useRef(false)

const onMountComplete = useCallback(() => {
if (resolveMountComplete.current != null) {
resolveMountComplete.current()
} else {
mountCompleteBeforeInitialization.current = true
}
}, [])

useEffect(() => {
if (pushDeferUpdateFunction)
pushDeferUpdateFunction((isDeferred) => {
return (resolve) => {
setDeferred(isDeferred)

if (mountCompleteBeforeInitialization.current) {
resolve()
} else {
resolveMountComplete.current = resolve
}
}
})
}, [pushDeferUpdateFunction, onMountComplete])

if (deferred) return null
return <NotifyMountComplete.Provider value={onMountComplete}>{children}</NotifyMountComplete.Provider>
}

interface ImmediateMountProps {
children: ReactElement
}
Expand Down Expand Up @@ -164,7 +234,7 @@ export function ImmediateMount({ children }: ImmediateMountProps) {
}

interface UpdateFn {
(deferred: boolean): void
(deferred: boolean): void | ((onMountComplete: () => void) => void)
}

interface PushDeferUpdateFunction {
Expand Down

0 comments on commit 0c5cc8e

Please sign in to comment.