diff --git a/README.md b/README.md
index f7bab0a2..30b26bac 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,7 @@ import {
useContext,
useEffect,
useLayoutEffect,
+ useInsertionEffect,
useError,
useRef,
useId,
@@ -99,6 +100,7 @@ import {
useReducer,
useReactiveState,
useDeferredValue,
+ useSyncExternalStore,
} from '@dark-engine/core';
import { render, createRoot, createPortal, useStyle } from '@dark-engine/platform-browser';
```
@@ -584,6 +586,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
@@ -1009,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/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..305aab37 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -12,26 +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 * 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';
-export * from './use-update';
-export * from './use-state';
-export * from './use-reactive-state';
-export * from './use-id';
-export * from './view';
-export * from './constants';
+export { useInsertionEffect } from './use-insertion-effect';
+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/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-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 };
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 };