From bae9490c19bacde222003a5dcefcba5d41918758 Mon Sep 17 00:00:00 2001 From: atellmer Date: Tue, 29 Nov 2022 03:04:45 -0700 Subject: [PATCH 1/2] added useInsertionEffect --- README.md | 15 + packages/core/src/fiber/fiber.ts | 44 +- packages/core/src/index.ts | 3 +- packages/core/src/scope/scope.ts | 15 + packages/core/src/unmount/unmount.spec.tsx | 15 +- packages/core/src/unmount/unmount.ts | 5 +- .../core/src/use-effect/use-effect.spec.tsx | 47 +++ .../core/src/use-insertion-effect/index.ts | 1 + .../use-insertion-effect.spec.tsx | 384 ++++++++++++++++++ .../use-insertion-effect.ts | 12 + .../use-layout-effect.spec.tsx | 45 ++ packages/core/src/use-update/use-update.ts | 3 +- .../src/create-root/create-root.spec.tsx | 75 ++++ .../platform-browser/src/render/render.ts | 11 +- 14 files changed, 656 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/use-insertion-effect/index.ts create mode 100644 packages/core/src/use-insertion-effect/use-insertion-effect.spec.tsx create mode 100644 packages/core/src/use-insertion-effect/use-insertion-effect.ts create mode 100644 packages/platform-browser/src/create-root/create-root.spec.tsx diff --git a/README.md b/README.md index f7bab0a2..bd9a29fe 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ import { useContext, useEffect, useLayoutEffect, + useInsertionEffect, useError, useRef, useId, @@ -584,6 +585,20 @@ useLayoutEffect(() => { }, []); ``` +#### useInsertionEffect + +```tsx +import { useInsertionEffect } from '@dark-engine/core'; +``` + +The signature is identical to useEffect, but it fires synchronously before all DOM mutations. Use this to inject styles into the DOM before reading layout in useLayoutEffect. This hook does not have access to refs and cannot call render. Useful for css-in-js libraries. + +```tsx +useInsertionEffect(() => { + // add style tags to head +}, []); +``` + ## Performance optimization diff --git a/packages/core/src/fiber/fiber.ts b/packages/core/src/fiber/fiber.ts index 21aeedac..a9a13fd7 100644 --- a/packages/core/src/fiber/fiber.ts +++ b/packages/core/src/fiber/fiber.ts @@ -21,7 +21,9 @@ import { rootStore, effectsStore, layoutEffectsStore, + insertionEffectsStore, isLayoutEffectsZone, + isInsertionEffectsZone, } from '../scope'; import { type ComponentFactory, detectIsComponentFactory, getComponentFactoryKey } from '../component'; import { @@ -39,6 +41,7 @@ import { PARTIAL_UPDATE } from '../constants'; import { type NativeElement, type Hook, EffectTag, cloneTagMap } from './types'; import { hasEffects } from '../use-effect'; import { hasLayoutEffects } from '../use-layout-effect'; +import { hasInsertionEffects } from '../use-insertion-effect'; import { walkFiber } from '../walk'; import { unmountFiber } from '../unmount'; import { Text } from '../view'; @@ -56,9 +59,10 @@ class Fiber { public provider: Map; public transposition: boolean; public mountedToHost: boolean; - public portalHost: boolean; public effectHost: boolean; public layoutEffectHost: boolean; + public insertionEffectHost: boolean; + public portalHost: boolean; public childrenCount: number; public marker: string; public isUsed: boolean; @@ -79,9 +83,10 @@ class Fiber { this.provider = options.provider || null; this.transposition = !detectIsUndefined(options.transposition) ? options.transposition : false; this.mountedToHost = !detectIsUndefined(options.mountedToHost) || false; - this.portalHost = !detectIsUndefined(options.portalHost) ? options.portalHost : false; this.effectHost = !detectIsUndefined(options.effectHost) ? options.effectHost : false; this.layoutEffectHost = !detectIsUndefined(options.layoutEffectHost) ? options.layoutEffectHost : false; + this.insertionEffectHost = !detectIsUndefined(options.insertionEffectHost) ? options.insertionEffectHost : false; + this.portalHost = !detectIsUndefined(options.portalHost) ? options.portalHost : false; this.childrenCount = options.childrenCount || 0; this.marker = options.marker || ''; this.idx = options.idx || 0; @@ -89,9 +94,9 @@ class Fiber { this.batched = options.batched || null; } - public markPortalHost() { - this.portalHost = true; - this.parent && !this.parent.portalHost && this.parent.markPortalHost(); + public markMountedToHost() { + this.mountedToHost = true; + this.parent && !this.parent.mountedToHost && this.parent.markMountedToHost(); } public markEffectHost() { @@ -104,9 +109,14 @@ class Fiber { this.parent && !this.parent.layoutEffectHost && this.parent.markLayoutEffectHost(); } - public markMountedToHost() { - this.mountedToHost = true; - this.parent && !this.parent.mountedToHost && this.parent.markMountedToHost(); + public markInsertionEffectHost() { + this.insertionEffectHost = true; + this.parent && !this.parent.insertionEffectHost && this.parent.markInsertionEffectHost(); + } + + public markPortalHost() { + this.portalHost = true; + this.parent && !this.parent.portalHost && this.parent.markPortalHost(); } public setError(error: Error) { @@ -549,6 +559,10 @@ function performMemo(options: PerformMemoOptions) { nextFiber = nextFiber.nextSibling; } + if (memoFiber.mountedToHost) { + fiber.markMountedToHost(); + } + if (memoFiber.effectHost) { fiber.markEffectHost(); } @@ -557,8 +571,8 @@ function performMemo(options: PerformMemoOptions) { fiber.markLayoutEffectHost(); } - if (memoFiber.mountedToHost) { - fiber.markMountedToHost(); + if (memoFiber.insertionEffectHost) { + fiber.markInsertionEffectHost(); } if (memoFiber.portalHost) { @@ -610,6 +624,10 @@ function pertformInstance(options: PerformInstanceOptions) { fiber.markLayoutEffectHost(); } + if (hasInsertionEffects(fiber)) { + fiber.markInsertionEffectHost(); + } + if (platform.detectIsPortal(performedInstance)) { fiber.markPortalHost(); } @@ -845,6 +863,11 @@ function hasChildrenProp(element: DarkElementInstance): element is TagVirtualNod function commitChanges() { const wipFiber = wipRootStore.get(); + const insertionEffects = insertionEffectsStore.get(); + + isInsertionEffectsZone.set(true); + insertionEffects.forEach(fn => fn()); + isInsertionEffectsZone.set(false); commitWork(wipFiber.child, () => { const layoutEffects = layoutEffectsStore.get(); @@ -859,6 +882,7 @@ function commitChanges() { }); wipRootStore.set(null); // important order + insertionEffectsStore.reset(); layoutEffectsStore.reset(); effectsStore.reset(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d0a8fefc..3dd8e9e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,10 +16,11 @@ export * from './use-callback'; export * from './use-context'; export * from './use-deferred-value'; export { useEffect } from './use-effect'; +export { useLayoutEffect } from './use-layout-effect'; +export { useInsertionEffect } from './use-insertion-effect'; export * from './use-error'; export * from './use-event'; export * from './use-imperative-handle'; -export { useLayoutEffect } from './use-layout-effect'; export * from './use-memo'; export * from './use-reducer'; export * from './use-ref'; diff --git a/packages/core/src/scope/scope.ts b/packages/core/src/scope/scope.ts index 239b63b5..7a0fd363 100644 --- a/packages/core/src/scope/scope.ts +++ b/packages/core/src/scope/scope.ts @@ -19,7 +19,9 @@ class Store { public componentFiber: Fiber = null; public effects: Array<() => void> = []; public layoutEffects: Array<() => void> = []; + public insertionEffects: Array<() => void> = []; public isLayoutEffectsZone = false; + public isInserionEffectsZone = false; public isUpdateHookZone = false; public isBatchZone = false; public trackUpdate: (nativeElement: N) => void | undefined; @@ -126,11 +128,22 @@ const layoutEffectsStore = { add: (effect: () => void) => store.get().layoutEffects.push(effect), }; +const insertionEffectsStore = { + get: () => store.get().insertionEffects, + reset: () => (store.get().insertionEffects = []), + add: (effect: () => void) => store.get().insertionEffects.push(effect), +}; + const isLayoutEffectsZone = { get: () => store.get()?.isLayoutEffectsZone || false, set: (value: boolean) => (store.get().isLayoutEffectsZone = value), }; +const isInsertionEffectsZone = { + get: (id?: number) => store.get(id)?.isInserionEffectsZone || false, + set: (value: boolean) => (store.get().isInserionEffectsZone = value), +}; + const isUpdateHookZone = { get: () => store.get()?.isUpdateHookZone || false, set: (value: boolean) => (store.get().isUpdateHookZone = value), @@ -153,7 +166,9 @@ export { fiberMountStore, effectsStore, layoutEffectsStore, + insertionEffectsStore, isLayoutEffectsZone, + isInsertionEffectsZone, isUpdateHookZone, isBatchZone, }; diff --git a/packages/core/src/unmount/unmount.spec.tsx b/packages/core/src/unmount/unmount.spec.tsx index b7b42d62..e025783e 100644 --- a/packages/core/src/unmount/unmount.spec.tsx +++ b/packages/core/src/unmount/unmount.spec.tsx @@ -2,6 +2,7 @@ import { createRoot } from '@dark-engine/platform-browser'; import { h } from '../element'; import { createComponent } from '../component'; +import { useInsertionEffect } from '../use-insertion-effect'; import { useLayoutEffect } from '../use-layout-effect'; import { useEffect } from '../use-effect'; @@ -14,10 +15,14 @@ beforeEach(() => { }); describe('[unmount]', () => { - test('clears all effects and unmounts root node correctly', () => { + test('clears all effects correctly', () => { const dropFn = jest.fn(); const Child = createComponent(() => { + useInsertionEffect(() => { + return () => dropFn(); + }, []); + useLayoutEffect(() => { return () => dropFn(); }, []); @@ -30,6 +35,10 @@ describe('[unmount]', () => { }); const App = createComponent(() => { + useInsertionEffect(() => { + return () => dropFn(); + }, []); + useLayoutEffect(() => { return () => dropFn(); }, []); @@ -52,8 +61,6 @@ describe('[unmount]', () => { root.render(App()); jest.runAllTimers(); root.unmount(); - expect(dropFn).toBeCalledTimes(8); - expect(host.innerHTML).toBe(''); - expect(root.unmount).not.toThrowError(); + expect(dropFn).toBeCalledTimes(12); }); }); diff --git a/packages/core/src/unmount/unmount.ts b/packages/core/src/unmount/unmount.ts index b6073e6b..b361e2a3 100644 --- a/packages/core/src/unmount/unmount.ts +++ b/packages/core/src/unmount/unmount.ts @@ -3,17 +3,20 @@ import { platform } from '../platform'; import { detectIsComponentFactory } from '../component'; import { dropEffects } from '../use-effect'; import { dropLayoutEffects } from '../use-layout-effect'; +import { dropInsertionEffects } from '../use-insertion-effect'; import { walkFiber } from '../walk'; import { detectIsUndefined } from '../helpers'; import { currentRootStore, eventsStore, rootStore } from '../scope'; function unmountFiber(fiber: Fiber) { - if (!fiber.effectHost && !fiber.layoutEffectHost && !fiber.portalHost) return; + if (!fiber.insertionEffectHost && !fiber.layoutEffectHost && !fiber.effectHost && !fiber.portalHost) return; walkFiber(fiber, ({ nextFiber, isReturn, stop }) => { if (nextFiber === fiber.nextSibling || fiber.transposition) return stop(); if (!isReturn && detectIsComponentFactory(nextFiber.instance)) { + // important order + nextFiber.insertionEffectHost && dropInsertionEffects(nextFiber.hook); nextFiber.layoutEffectHost && dropLayoutEffects(nextFiber.hook); nextFiber.effectHost && dropEffects(nextFiber.hook); nextFiber.portalHost && platform.unmountPortal(nextFiber); diff --git a/packages/core/src/use-effect/use-effect.spec.tsx b/packages/core/src/use-effect/use-effect.spec.tsx index 0fad9ce0..66ef4e12 100644 --- a/packages/core/src/use-effect/use-effect.spec.tsx +++ b/packages/core/src/use-effect/use-effect.spec.tsx @@ -2,6 +2,7 @@ import { render } from '@dark-engine/platform-browser'; import { h } from '../element'; import { createComponent } from '../component'; +import { useUpdate } from '../use-update'; import { useEffect } from './use-effect'; let host: HTMLElement = null; @@ -251,4 +252,50 @@ describe('[use-effect]', () => { expect(effectFn1.mock.invocationCallOrder[2]).toBeLessThan(effectFn2.mock.invocationCallOrder[2]); expect(dropFn1.mock.invocationCallOrder[1]).toBeLessThan(dropFn2.mock.invocationCallOrder[1]); }); + + test('can call render #1', () => { + const mockFn = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const App = createComponent(() => { + useEffect(() => { + render$(); + }, []); + + mockFn(); + + return null; + }); + + render$(); + jest.runAllTimers(); + expect(mockFn).toBeCalledTimes(2); + }); + + test('can call render #2', () => { + const mockFn = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const App = createComponent(() => { + const update = useUpdate(); + + useEffect(() => { + update(); + }, []); + + mockFn(); + + return null; + }); + + render$(); + jest.runAllTimers(); + expect(mockFn).toBeCalledTimes(2); + }); }); diff --git a/packages/core/src/use-insertion-effect/index.ts b/packages/core/src/use-insertion-effect/index.ts new file mode 100644 index 00000000..026926b7 --- /dev/null +++ b/packages/core/src/use-insertion-effect/index.ts @@ -0,0 +1 @@ +export * from './use-insertion-effect'; diff --git a/packages/core/src/use-insertion-effect/use-insertion-effect.spec.tsx b/packages/core/src/use-insertion-effect/use-insertion-effect.spec.tsx new file mode 100644 index 00000000..08b1442a --- /dev/null +++ b/packages/core/src/use-insertion-effect/use-insertion-effect.spec.tsx @@ -0,0 +1,384 @@ +/** @jsx h */ +import { render } from '@dark-engine/platform-browser'; +import { h } from '../element'; +import { createComponent } from '../component'; +import { useUpdate } from '../use-update'; +import { useLayoutEffect } from '../use-layout-effect'; +import { useEffect } from '../use-effect'; +import { useInsertionEffect } from './use-insertion-effect'; + +let host: HTMLElement = null; + +jest.useFakeTimers(); + +beforeEach(() => { + host = document.createElement('div'); +}); + +describe('[use-insertion-effect]', () => { + test('runs sync', () => { + const effectFn = jest.fn(); + const App = createComponent(() => { + useInsertionEffect(() => effectFn(), []); + + return null; + }); + + render(App(), host); + expect(effectFn).toBeCalledTimes(1); + }); + + test('fires on mount event', () => { + const mockFn = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const App = createComponent(() => { + useInsertionEffect(() => mockFn(), []); + + return null; + }); + + render$(); + expect(mockFn).toBeCalledTimes(1); + + render$(); + expect(mockFn).toBeCalledTimes(1); + }); + + test('works correctly with deps', () => { + type AppProps = { + x: number; + }; + const effectFn = jest.fn(); + const dropFn = jest.fn(); + + const render$ = (props: AppProps) => { + render(App(props), host); + }; + + const App = createComponent(({ x }) => { + useInsertionEffect(() => { + effectFn(); + return () => dropFn(); + }, [x]); + + return null; + }); + + render$({ x: 1 }); + expect(effectFn).toBeCalledTimes(1); + expect(dropFn).toBeCalledTimes(0); + + render$({ x: 1 }); + expect(effectFn).toBeCalledTimes(1); + expect(dropFn).toBeCalledTimes(0); + + render$({ x: 2 }); + expect(effectFn).toBeCalledTimes(2); + expect(dropFn).toBeCalledTimes(1); + + render$({ x: 3 }); + expect(effectFn).toBeCalledTimes(3); + expect(dropFn).toBeCalledTimes(2); + }); + + test('fires on every render without deps', () => { + const effectFn = jest.fn(); + const dropFn = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const App = createComponent(() => { + useInsertionEffect(() => { + effectFn(); + return () => dropFn(); + }); + + return null; + }); + + render$(); + expect(effectFn).toBeCalledTimes(1); + expect(dropFn).toBeCalledTimes(0); + + render$(); + expect(effectFn).toBeCalledTimes(2); + expect(dropFn).toBeCalledTimes(1); + + render$(); + expect(effectFn).toBeCalledTimes(3); + expect(dropFn).toBeCalledTimes(2); + }); + + test('drops effect on unmount event', () => { + const effectFn = jest.fn(); + const dropFn = jest.fn(); + const App = createComponent(() => { + useInsertionEffect(() => { + effectFn(); + return () => dropFn(); + }, []); + + return null; + }); + + render(App(), host); + expect(effectFn).toBeCalledTimes(1); + expect(dropFn).toBeCalledTimes(0); + + render(null, host); + expect(effectFn).toBeCalledTimes(1); + expect(dropFn).toBeCalledTimes(1); + + render(App(), host); + expect(effectFn).toBeCalledTimes(2); + expect(dropFn).toBeCalledTimes(1); + }); + + test('can trigger many effects', () => { + const effectFn1 = jest.fn(); + const dropFn1 = jest.fn(); + const effectFn2 = jest.fn(); + const dropFn2 = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const App = createComponent(() => { + useInsertionEffect(() => { + effectFn1(); + return () => dropFn1(); + }); + useInsertionEffect(() => { + effectFn2(); + return () => dropFn2(); + }); + + return null; + }); + + render$(); + expect(effectFn1).toBeCalledTimes(1); + expect(effectFn2).toBeCalledTimes(1); + expect(dropFn1).toBeCalledTimes(0); + expect(dropFn2).toBeCalledTimes(0); + + render$(); + expect(effectFn1).toBeCalledTimes(2); + expect(effectFn2).toBeCalledTimes(2); + expect(dropFn1).toBeCalledTimes(1); + expect(dropFn2).toBeCalledTimes(1); + + render$(); + expect(effectFn1).toBeCalledTimes(3); + expect(effectFn2).toBeCalledTimes(3); + expect(dropFn1).toBeCalledTimes(2); + expect(dropFn2).toBeCalledTimes(2); + }); + + test('can work with nested components correctly', () => { + const effectFn1 = jest.fn(); + const dropFn1 = jest.fn(); + const effectFn2 = jest.fn(); + const dropFn2 = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const Child = createComponent(() => { + useInsertionEffect(() => { + effectFn2(); + return () => dropFn2(); + }); + + return null; + }); + + const App = createComponent(() => { + useInsertionEffect(() => { + effectFn1(); + return () => dropFn1(); + }); + + return ; + }); + + render$(); + expect(effectFn1).toBeCalledTimes(1); + expect(effectFn2).toBeCalledTimes(1); + expect(dropFn1).toBeCalledTimes(0); + expect(dropFn2).toBeCalledTimes(0); + expect(effectFn1.mock.invocationCallOrder[0]).toBeLessThan(effectFn2.mock.invocationCallOrder[0]); + + render$(); + expect(effectFn1).toBeCalledTimes(2); + expect(effectFn2).toBeCalledTimes(2); + expect(dropFn1).toBeCalledTimes(1); + expect(dropFn2).toBeCalledTimes(1); + expect(effectFn1.mock.invocationCallOrder[1]).toBeLessThan(effectFn2.mock.invocationCallOrder[1]); + expect(dropFn1.mock.invocationCallOrder[0]).toBeLessThan(dropFn2.mock.invocationCallOrder[0]); + + render$(); + expect(effectFn1).toBeCalledTimes(3); + expect(effectFn2).toBeCalledTimes(3); + expect(dropFn1).toBeCalledTimes(2); + expect(dropFn2).toBeCalledTimes(2); + expect(effectFn1.mock.invocationCallOrder[2]).toBeLessThan(effectFn2.mock.invocationCallOrder[2]); + expect(dropFn1.mock.invocationCallOrder[1]).toBeLessThan(dropFn2.mock.invocationCallOrder[1]); + }); + + test('runs before useLayoutEffect and useEffect', () => { + const effectFn1 = jest.fn(); + const effectFn2 = jest.fn(); + const effectFn3 = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const App = createComponent(() => { + useEffect(() => { + effectFn3(); + }); + + useLayoutEffect(() => { + effectFn2(); + }); + + useInsertionEffect(() => { + effectFn1(); + }); + + return null; + }); + + render$(); + jest.runAllTimers(); + expect(effectFn1.mock.invocationCallOrder[0]).toBeLessThan(effectFn2.mock.invocationCallOrder[0]); + expect(effectFn2.mock.invocationCallOrder[0]).toBeLessThan(effectFn3.mock.invocationCallOrder[0]); + + render$(); + jest.runAllTimers(); + expect(effectFn1.mock.invocationCallOrder[1]).toBeLessThan(effectFn2.mock.invocationCallOrder[1]); + expect(effectFn2.mock.invocationCallOrder[1]).toBeLessThan(effectFn3.mock.invocationCallOrder[1]); + }); + + test('drop effects call in order of placement when render regardless of type', () => { + const dropFn1 = jest.fn(); + const dropFn2 = jest.fn(); + const dropFn3 = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const App = createComponent(() => { + useEffect(() => { + return () => dropFn3(); + }); + + useLayoutEffect(() => { + return () => dropFn2(); + }); + + useInsertionEffect(() => { + return () => dropFn1(); + }); + + return null; + }); + + render$(); + jest.runAllTimers(); + render$(); + jest.runAllTimers(); + expect(dropFn1.mock.invocationCallOrder[0]).toBeGreaterThan(dropFn2.mock.invocationCallOrder[0]); + expect(dropFn2.mock.invocationCallOrder[0]).toBeGreaterThan(dropFn3.mock.invocationCallOrder[0]); + + render$(); + jest.runAllTimers(); + expect(dropFn1.mock.invocationCallOrder[1]).toBeGreaterThan(dropFn2.mock.invocationCallOrder[1]); + expect(dropFn2.mock.invocationCallOrder[1]).toBeGreaterThan(dropFn3.mock.invocationCallOrder[1]); + }); + + test('drop effects call in order of type when unmount', () => { + const dropFn1 = jest.fn(); + const dropFn2 = jest.fn(); + const dropFn3 = jest.fn(); + + const App = createComponent(() => { + useEffect(() => { + return () => dropFn3(); + }, []); + + useLayoutEffect(() => { + return () => dropFn2(); + }, []); + + useInsertionEffect(() => { + return () => dropFn1(); + }, []); + + return null; + }); + + render(App(), host); + jest.runAllTimers(); + + render(null, host); + jest.runAllTimers(); + expect(dropFn1.mock.invocationCallOrder[0]).toBeLessThan(dropFn2.mock.invocationCallOrder[0]); + expect(dropFn2.mock.invocationCallOrder[0]).toBeLessThan(dropFn3.mock.invocationCallOrder[0]); + }); + + test('can not call render #1', () => { + const mockFn = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const App = createComponent(() => { + useInsertionEffect(() => { + render$(); + }, []); + + mockFn(); + + return null; + }); + + render$(); + expect(mockFn).toBeCalledTimes(1); + }); + + test('can not call render #2', () => { + const mockFn = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const App = createComponent(() => { + const update = useUpdate(); + + useInsertionEffect(() => { + update(); + }, []); + + mockFn(); + + return null; + }); + + render$(); + expect(mockFn).toBeCalledTimes(1); + }); +}); diff --git a/packages/core/src/use-insertion-effect/use-insertion-effect.ts b/packages/core/src/use-insertion-effect/use-insertion-effect.ts new file mode 100644 index 00000000..6db968dc --- /dev/null +++ b/packages/core/src/use-insertion-effect/use-insertion-effect.ts @@ -0,0 +1,12 @@ +import { insertionEffectsStore } from '../scope'; +import { createEffect } from '../use-effect'; + +const $$useInsertionEffect = Symbol('use-insertion-effect'); + +const { + useEffect: useInsertionEffect, + hasEffects: hasInsertionEffects, + dropEffects: dropInsertionEffects, +} = createEffect($$useInsertionEffect, insertionEffectsStore); + +export { useInsertionEffect, hasInsertionEffects, dropInsertionEffects }; diff --git a/packages/core/src/use-layout-effect/use-layout-effect.spec.tsx b/packages/core/src/use-layout-effect/use-layout-effect.spec.tsx index 2c1d2c7f..2561bb26 100644 --- a/packages/core/src/use-layout-effect/use-layout-effect.spec.tsx +++ b/packages/core/src/use-layout-effect/use-layout-effect.spec.tsx @@ -2,6 +2,7 @@ import { render } from '@dark-engine/platform-browser'; import { h } from '../element'; import { createComponent } from '../component'; +import { useUpdate } from '../use-update'; import { useLayoutEffect } from './use-layout-effect'; let host: HTMLElement = null; @@ -228,4 +229,48 @@ describe('[use-layout-effect]', () => { expect(effectFn1.mock.invocationCallOrder[2]).toBeLessThan(effectFn2.mock.invocationCallOrder[2]); expect(dropFn1.mock.invocationCallOrder[1]).toBeLessThan(dropFn2.mock.invocationCallOrder[1]); }); + + test('can call render #1', () => { + const mockFn = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const App = createComponent(() => { + useLayoutEffect(() => { + render$(); + }, []); + + mockFn(); + + return null; + }); + + render$(); + expect(mockFn).toBeCalledTimes(2); + }); + + test('can call render #2', () => { + const mockFn = jest.fn(); + + const render$ = (props = {}) => { + render(App(props), host); + }; + + const App = createComponent(() => { + const update = useUpdate(); + + useLayoutEffect(() => { + update(); + }, []); + + mockFn(); + + return null; + }); + + render$(); + expect(mockFn).toBeCalledTimes(2); + }); }); diff --git a/packages/core/src/use-update/use-update.ts b/packages/core/src/use-update/use-update.ts index f8005e87..0dc781da 100644 --- a/packages/core/src/use-update/use-update.ts +++ b/packages/core/src/use-update/use-update.ts @@ -1,5 +1,5 @@ import { platform, type ScheduleCallbackOptions } from '../platform'; -import { getRootId, currentFiberStore, isLayoutEffectsZone, isBatchZone } from '../scope'; +import { getRootId, currentFiberStore, isInsertionEffectsZone, isLayoutEffectsZone, isBatchZone } from '../scope'; import { createUpdateCallback } from '../fiber'; import { useMemo } from '../use-memo'; import { dummyFn } from '../helpers'; @@ -13,6 +13,7 @@ function useUpdate(options?: ScheduleCallbackOptions) { scope.fiber = fiber; const update = (onStart?: () => void) => { + if (isInsertionEffectsZone.get()) return; const callback = createUpdateCallback({ rootId, fiber: scope.fiber, diff --git a/packages/platform-browser/src/create-root/create-root.spec.tsx b/packages/platform-browser/src/create-root/create-root.spec.tsx new file mode 100644 index 00000000..2049e958 --- /dev/null +++ b/packages/platform-browser/src/create-root/create-root.spec.tsx @@ -0,0 +1,75 @@ +/** @jsx h */ +import { h, createComponent, useInsertionEffect, useLayoutEffect, useEffect } from '@dark-engine/core'; +import { createRoot } from './create-root'; + +let host: HTMLElement = null; + +jest.useFakeTimers(); + +beforeEach(() => { + host = document.createElement('div'); +}); + +describe('[create-root]', () => { + test('has render and unmount methods', () => { + const App = createComponent(() => { + return null; + }); + const root = createRoot(host); + + root.render(App()); + expect(root.render).toBeInstanceOf(Function); + expect(root.unmount).toBeInstanceOf(Function); + }); + + test('unmount clears all effects and unmounts root node correctly', () => { + const dropFn = jest.fn(); + + const Child = createComponent(() => { + useInsertionEffect(() => { + return () => dropFn(); + }, []); + + useLayoutEffect(() => { + return () => dropFn(); + }, []); + + useEffect(() => { + return () => dropFn(); + }, []); + + return
child
; + }); + + const App = createComponent(() => { + useInsertionEffect(() => { + return () => dropFn(); + }, []); + + useLayoutEffect(() => { + return () => dropFn(); + }, []); + + useEffect(() => { + return () => dropFn(); + }, []); + + return ( +
+ + + +
+ ); + }); + + const root = createRoot(host); + + root.render(App()); + jest.runAllTimers(); + root.unmount(); + expect(dropFn).toBeCalledTimes(12); + expect(host.innerHTML).toBe(''); + expect(root.unmount).not.toThrowError(); + }); +}); diff --git a/packages/platform-browser/src/render/render.ts b/packages/platform-browser/src/render/render.ts index 6323e80d..48d939aa 100644 --- a/packages/platform-browser/src/render/render.ts +++ b/packages/platform-browser/src/render/render.ts @@ -16,6 +16,7 @@ import { fiberMountStore, TaskPriority, createEmptyVirtualNode, + isInsertionEffectsZone, isLayoutEffectsZone, } from '@dark-engine/core'; import { createNativeElement, applyCommit, finishCommitWork } from '../dom'; @@ -39,7 +40,7 @@ function render(element: DarkElement, container: Element) { } const isMounted = !detectIsUndefined(roots.get(container)); - let rootId = null; + let rootId: number = null; if (!isMounted) { rootId = roots.size; @@ -50,6 +51,9 @@ function render(element: DarkElement, container: Element) { rootId = roots.get(container); } + // insertion effect can't schedule renders + if (isInsertionEffectsZone.get(rootId)) return; + const callback = () => { rootStore.set(rootId); // important order! const currentRoot = currentRootStore.get(); @@ -69,7 +73,10 @@ function render(element: DarkElement, container: Element) { nextUnitOfWorkStore.set(fiber); }; - platform.scheduleCallback(callback, { priority: TaskPriority.NORMAL, forceSync: isLayoutEffectsZone.get() }); + platform.scheduleCallback(callback, { + priority: TaskPriority.NORMAL, + forceSync: isLayoutEffectsZone.get(), + }); } export { render, roots }; From fdf02d3010f3add04cf175efe71c4225edcf8370 Mon Sep 17 00:00:00 2001 From: atellmer Date: Tue, 29 Nov 2022 04:10:32 -0700 Subject: [PATCH 2/2] added use-sync-external-store --- README.md | 17 ++++++ packages/core/src/index.ts | 31 +++++----- .../core/src/use-sync-external-store/index.ts | 1 + .../use-sync-external-store.spec.tsx | 60 +++++++++++++++++++ .../use-sync-external-store.ts | 21 +++++++ 5 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/use-sync-external-store/index.ts create mode 100644 packages/core/src/use-sync-external-store/use-sync-external-store.spec.tsx create mode 100644 packages/core/src/use-sync-external-store/use-sync-external-store.ts diff --git a/README.md b/README.md index bd9a29fe..30b26bac 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ import { useReducer, useReactiveState, useDeferredValue, + useSyncExternalStore, } from '@dark-engine/core'; import { render, createRoot, createPortal, useStyle } from '@dark-engine/platform-browser'; ``` @@ -1024,6 +1025,22 @@ const Checkbox = createComponent(() => { }); ``` +#### useSyncExternalStore + +The hook is useful for synchronizing render states with an external state management library such as Redux. + +```tsx +import { useSyncExternalStore } from '@dark-engine/core'; +``` + +```tsx +const App = createComponent(() => { + const state = useSyncExternalStore(store.subscribe, store.getState); // redux store + + return
{state.isFetching ? 'loading...' : 'ola! 🤪'}
; +}); +``` + Thanks everyone! # LICENSE diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3dd8e9e9..305aab37 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,27 +12,28 @@ export * from './ref'; export * from './scope'; export * from './shared'; export * from './suspense'; -export * from './use-callback'; -export * from './use-context'; -export * from './use-deferred-value'; +export { useCallback } from './use-callback'; +export { useContext } from './use-context'; +export { useDeferredValue } from './use-deferred-value'; export { useEffect } from './use-effect'; export { useLayoutEffect } from './use-layout-effect'; export { useInsertionEffect } from './use-insertion-effect'; -export * from './use-error'; -export * from './use-event'; -export * from './use-imperative-handle'; -export * from './use-memo'; -export * from './use-reducer'; -export * from './use-ref'; -export * from './use-update'; -export * from './use-state'; -export * from './use-reactive-state'; -export * from './use-id'; -export * from './view'; -export * from './constants'; +export { useError } from './use-error'; +export { useEvent } from './use-event'; +export { useImperativeHandle } from './use-imperative-handle'; +export { useMemo } from './use-memo'; +export { useReducer } from './use-reducer'; +export { useRef } from './use-ref'; +export { useUpdate } from './use-update'; +export { useState } from './use-state'; +export { useReactiveState } from './use-reactive-state'; +export { useId } from './use-id'; +export { useSyncExternalStore } from './use-sync-external-store'; export { walkFiber } from './walk'; export { unmountRoot } from './unmount'; export { batch } from './batch'; +export * from './view'; +export * from './constants'; export const version = process.env.VERSION; diff --git a/packages/core/src/use-sync-external-store/index.ts b/packages/core/src/use-sync-external-store/index.ts new file mode 100644 index 00000000..c928c939 --- /dev/null +++ b/packages/core/src/use-sync-external-store/index.ts @@ -0,0 +1 @@ +export * from './use-sync-external-store'; diff --git a/packages/core/src/use-sync-external-store/use-sync-external-store.spec.tsx b/packages/core/src/use-sync-external-store/use-sync-external-store.spec.tsx new file mode 100644 index 00000000..670e72f4 --- /dev/null +++ b/packages/core/src/use-sync-external-store/use-sync-external-store.spec.tsx @@ -0,0 +1,60 @@ +/** @jsx h */ +import { render } from '@dark-engine/platform-browser'; +import { createComponent } from '../component'; +import { useSyncExternalStore } from './use-sync-external-store'; + +let host: HTMLElement = null; + +jest.useFakeTimers(); + +beforeEach(() => { + host = document.createElement('div'); +}); + +function createStore(initialState: T) { + let state = initialState; + const listeners = new Set<() => void>(); + + const getState = () => state; + + const setState = (fn: (prevState: T) => T) => { + state = fn(state); + listeners.forEach(fn => fn()); + }; + + const subscribe = (listener: () => void) => { + listeners.add(listener); + + return () => listeners.delete(listener); + }; + + return { getState, setState, subscribe }; +} + +describe('[use-sync-external-store]', () => { + test('works correctly', () => { + const store = createStore(0); + let state: number; + + const App = createComponent(() => { + state = useSyncExternalStore(store.subscribe, store.getState); + + return null; + }); + + render(App(), host); + jest.runAllTimers(); + expect(state).toBe(store.getState()); + expect(state).toBe(0); + + store.setState(x => x + 1); + jest.runAllTimers(); + expect(state).toBe(store.getState()); + expect(state).toBe(1); + + store.setState(x => x + 1); + jest.runAllTimers(); + expect(state).toBe(store.getState()); + expect(state).toBe(2); + }); +}); diff --git a/packages/core/src/use-sync-external-store/use-sync-external-store.ts b/packages/core/src/use-sync-external-store/use-sync-external-store.ts new file mode 100644 index 00000000..7028358c --- /dev/null +++ b/packages/core/src/use-sync-external-store/use-sync-external-store.ts @@ -0,0 +1,21 @@ +import { useState } from '../use-state'; +import { useEffect } from '../use-effect'; + +type Sunscribe = (cb: () => void) => Unsubscribe; +type Unsubscribe = () => void; + +function useSyncExternalStore(subscribe: Sunscribe, getSnapshot: () => T) { + const [state, setState] = useState(getSnapshot()); + + useEffect(() => { + const unsubscribe = subscribe(() => { + setState(getSnapshot()); + }); + + return () => unsubscribe(); + }, [getSnapshot]); + + return state; +} + +export { useSyncExternalStore };