Package Exports
- @dark-engine/core
- @dark-engine/core/jsx-dev-runtime
- @dark-engine/core/jsx-runtime
Readme
@dark-engine/core 🌖
A core package that abstracts away the specific code running platform. The core is based on the Fiber architecture, implements its own call stack, which makes it possible to flexibly manage rendering: schedule, prioritize, interrupt, resume rendering from the same point, or cancel it altogether. Supports asynchronous and concurrent rendering, works synchronously by default. Breaks the rendering work into two phases: the reconciliation phase and the phase of committing changes to the platform.
Installation
npm:
npm install @dark-engine/coreyarn:
yarn add @dark-engine/coreCDN:
<script src="https://unpkg.com/@dark-engine/core/dist/umd/dark-core.production.min.js"></script>Table of contents
- API
- Elements
- JSX
- Components
- Conditional rendering
- List rendering
- Recursive rendering
- Hooks
- Effects
- Memoization
- Refs
- Catching errors
- Context
- Batching
- Code splitting
- Async rendering
- Concurrent rendering
- Hot module replacement
- Others
API
import {
type CommentVirtualNode,
type Component,
type ComponentFactory,
type DarkElement,
type Dispatch,
type ElementKey,
type FunctionRef,
type MutableRef,
type Reducer,
type Ref,
type StandardComponentProps,
type TagVirtualNode,
type TextVirtualNode,
type VirtualNodeFactory,
batch,
Comment,
component,
createContext,
detectIsServer,
ErrorBoundary,
Fragment,
Guard,
h,
hot,
lazy,
memo,
startTransition,
Suspense,
Text,
useCallback,
useContext,
useDeferredValue,
useEffect,
useError,
useEvent,
useId,
useImperativeHandle,
useInsertionEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
useSyncExternalStore,
useTransition,
useUpdate,
View,
VERSION,
} from '@dark-engine/core';Сore concepts...
Elements
Elements are a collection of platform-specific primitives and components. For the browser platform, these are tags, text, and comments.
View, Text, Comment
import { View, Text, Comment } from '@dark-engine/core';
import { createRoot } from '@dark-engine/platform-browser';const h1 = (props = {}) => View({ ...props, as: 'h1' });
const content = [h1({ slot: Text(`I'm the text inside the tag`) }), Comment(`I'm the comment`)];
createRoot(document.getElementById('root')).render(content);JSX
JSX is a syntax extension for JavaScript that lets you write HTML-like markup inside a JavaScript file.
jsx-runtime
In your tsconfig.json, you must add these rows:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@dark-engine/core",
}
}The necessary functions will be automatically imported into your code.
const content = (
<>
<h1>I'm the text inside the tag</h1>
<span>Hello</span>
</>
);
createRoot(document.getElementById('root')).render(content);Components
Components are the fundamental logical units of a modern interface. Components can accept props, have their own internal state, and contain child elements or components.
type SkyProps = {
color: string;
};
const Sky = component<SkyProps>(({ color }) => {
return <div style={`color: ${color}`}>My color is {color}</div>;
});
<Sky color='deepskyblue' />;A component can return an array of elements:
const App = component(props => {
return [
<header>Header</header>,
<div>Content</div>,
<footer>Footer</footer>,
];
});You can also use Fragment as an alias for an array:
return (
<Fragment>
<header>Header</header>
<div>Content</div>
<footer>Footer</footer>
</Fragment>
);or
return (
<>
<header>Header</header>
<div>Content</div>
<footer>Footer</footer>
</>
);If a child element is passed to the component, it will appear in props as slot:
const App = component(({ slot }) => {
return (
<>
<header>Header</header>
<div>{slot}</div>
<footer>Footer</footer>
</>
);
});
<App>Content</App>;Conditional rendering
const App = component(({ isOpen }) => {
return isOpen ? <div>Hello</div> : null
});const App = component(({ isOpen }) => {
return (
<>
<div>Hello</div>
{isOpen && <div>Content</div>}
</>
);
});const App = component(({ isOpen }) => {
return (
<>
<div>Hello</div>
{isOpen ? <ComponentOne /> : <ComponentTwo />}
<div>world</div>
</>
);
});List rendering
const List = component(({ items }) => {
return (
<>
{items.map(x => <div key={item.id}>{item.name}</div>)}
</>
);
});const List = component(({ items }) => {
return items.map(x => <div key={x.id}>{x.title}</div>);
});Recursive rendering
You can put components into themself to get recursion if you want. But every recursion must have return condition for out. In other case we will have infinity loop. Recursive rendering might be useful for tree building or something else.
const Item = component(({ level, current = 0 }) => {
if (current === level) return null;
return (
<div style={`margin-left: ${current === 0 ? '0' : '10px'}`}>
<div>level: {current + 1}</div>
<Item level={level} current={current + 1} />
</div>
);
});
const App = component(() => {
return <Item level={5} />;
});level: 1
level: 2
level: 3
level: 4
level: 5
Hooks
Hooks are needed to bring components to life: give them an internal state, start some actions, and so on. The basic rule for using hooks is to use them at the top level of the component, i.e. do not nest them inside other functions, cycles, conditions. This is a necessary condition, because hooks are not magic, but work based on array indices.
There are three types of main hooks:
- A hook that allows you to store the state of a component between renders.
- A hook that starts the process of rerendering a component.
- A hook that triggers side effects.
All other hooks are somehow derived from these hooks.
useState
The hook to store the state and call to update a piece of the interface.
const App = component(() => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>fired {count} times</button>;
});The setter can take a function as an argument to which the previous state is passed:
const handleClick = () => setCount(x => x + 1);useReducer
It's used when a component has multiple values in the same complex state, or when the state needs to be updated based on its previous value.
type State = { count: number };
type Action = { type: string };
const initialState: State = { count: 0 };
function reducer(state: State, action: Action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
throw new Error();
}
}
const App = component(() => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</>
);
});useUpdate
Simply starts the component rerender.
const update = useUpdate();
console.log('render');
return (
<>
<button onClick={() => update()}>update</button>
</>
);Effects
Side effects are useful actions that take place outside of the interface rendering. For example, side effects can be fetch data from the server, calling timers, subscribing.
useEffect
Executed asynchronously, after rendering.
const [albums, setAlbums] = useState<Array<Album>>([]);
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/albums')
.then(x => x.json())
.then(x => setAlbums(x));
}, []);
if (albums.length === 0) return <div>loading...</div>;
return (
<ul>
{albums.map(x => <li key={x.id}>{x.title}</li>)}
</ul>
);The second argument to this hook is an array of dependencies that tells it when to restart. This parameter is optional, then the effect will be restarted on every render.
Also this hook can return a reset function:
useEffect(() => {
const timerId = setTimeout(() => {
console.log('hey!');
}, 1000);
return () => clearTimeout(timerId);
}, []);useLayoutEffect
This type of effect is similar to useEffect, however, it is executed synchronously right after the commit phase of new changes. Use this to read layout from the DOM and synchronously re-render.
useLayoutEffect(() => {
const height = rootRef.current.clientHeight;
setHeight(height);
}, []);useInsertionEffect
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.
useInsertionEffect(() => {
// add style tags to head
}, []);Memoization
Memoization in Dark is the process of remembering the last value of a function and returning it if the parameters have not changed. Allows you to skip heavy calculations if possible.
useMemo
The hook for memoization of heavy calculations or heavy pieces of the interface:
const memoizedOne = useMemo(() => Math.random(), []);
const memoizedTwo = useMemo(() => <div>{Math.random()}</div>, []);
<>
{memoizedOne}
{memoizedTwo}
</>useCallback
Suitable for memoizing handler functions descending down the component tree:
const handleClick = useCallback(() => setCount(count + 1), [count]);
<button onClick={handleClick}>add</button>useEvent
Similar to useCallback but has no dependencies. Ensures the return of the same function, with the closures always corresponding to the last render. In most cases, it eliminates the need to track dependencies in useCallback.
const handleClick = useEvent(() => setCount(count + 1));
<button onClick={handleClick}>add</button>memo
const Memo = memo(component(() => {
console.log('Memo render!');
return <div>I'm Memo</div>;
}));
const App = component(() => {
console.log('App render!');
useEffect(() => {
setInterval(() => root.render(<App />), 1000);
}, []);
return (
<>
<Memo />
</>
);
});
const root = createRoot(document.getElementById('root'));
root.render(<App />);App render!
Memo render!
App render!
App render!
App render!
...As the second argument, it takes a function that answers the question of when to re-render the component:
const Memo = memo(Component, (prevProps, nextProps) => prevProps.color !== nextProps.color);<Guard />
A component that is intended to mark a certain area of the layout as static, which can be skipped during updates. Based on memo.
<Guard>
<div>I'am always static</div>
</Guard>Refs
To get full control over components or DOM nodes Dark suggests using refs.
useRef
const rootRef = useRef<HTMLInputElement>(null);
useEffect(() => {
rootRef.current.focus();
}, []);
<input ref={rootRef} />;Also there is support function refs
<input ref={ref => console.log(ref)} />;useImperativeHandle
They are needed to create an object inside the reference to the component in order to access the component from outside:
type ChildProps = {
ref?: Ref<ChildRef>;
}
type ChildRef = {
hello: () => void;
};
const Child = component<ChildProps>(({ ref }) => {
useImperativeHandle(
ref,
() => ({
hello: () => console.log('hello!'),
}),
[],
);
return <div>I'm Child</div>;
});
const App = component(() => {
const childRef = useRef<ChildRef>(null);
useEffect(() => {
childRef.current.hello();
}, []);
return <Child ref={childRef} />;
});Catching errors
When you get an error, you can log it and show an alternate user interface.
<ErrorBoundary />
Catching errors is only enabled on the client, and is ignored when rendering on the server. This is done intentionally, because on the server, when rendering to a stream, we cannot take back the HTML that has already been sent.
const Broken = component(() => {
throw new Error();
});
const App = component(() => {
return (
<>
<div>I'm OK 🙃</div>
<ErrorBoundary fallback={<div>Ooops! 😳</div>}>
<Broken />
</ErrorBoundary>
<div>I'm OK too 😘</div>
</>
);
});<ErrorBoundary /> also has a renderFallback and onError prop.
useError
const Counter = component<{ count: number }>(({ count }) => {
if (count === 3) {
throw new Error('oh no!');
}
return <div>{count}</div>;
});
const App = component(() => {
const [count, setCount] = useState(0);
const [error, reset] = useError();
if (error) {
return (
<>
<div>Something went wrong! 🫢</div>
<button onClick={() => (setCount(4), reset())}>try again</button>
</>
)
};
return (
<>
<Counter count={count} />
<button onClick={() => setCount(x => x + 1)}>increment</button>
</>
);
});Context
The context might be useful when you need to synchronize state between deeply nested elements without having to pass props from parent to child.
createContext and useContext
type Theme = 'light' | 'dark';
const ThemeContext = createContext<Theme>('light');
const useTheme = () => useContext(ThemeContext);
const CurrentTheme = component(() => {
const theme = useTheme();
return <div style='font-size: 20vw;'>{theme === 'light' ? '☀️' : '🌙'}</div>;
});
const App = component(() => {
const [theme, setTheme] = useState<Theme>('light');
const handleToggle = () => setTheme(x => (x === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext value={theme}>
<CurrentTheme />
<button onClick={handleToggle}>Toggle: {theme}</button>
</ThemeContext>
);
});If the context consumer is inside a memoized component that will skip the render from above when the context changes, then the consumer will automatically apply its internal render to apply the latest changes.
Batching
The strategy that allows you to merge similar updates into one render to reduce computational work.
batch
useEffect(() => {
const handleEvent = (e: MouseEvent) => {
batch(() => {
setClientX(e.clientX);
setClientY(e.clientY); // render just this time
});
};
document.addEventListener('mousemove', handleEvent);
return () => document.removeEventListener('mousemove', handleEvent);
}, []);Code splitting
lazy and <Suspense />
If your application is structured into separate modules, you may wish to employ lazy loading for efficiency. This can be achieved through code splitting. To implement lazy loading for components, dynamic imports of the component must be wrapped in a specific function - lazy. Additionally, a Suspense component is required to display a loading indicator or skeleton screen until the module has been fully loaded.
const Page = lazy(() => import('./page'));
const App = component(() => {
const [isNewPage, setIsNewPage] = useState(false);
return (
<>
<button onClick={() => setIsNewPage(x => !x)}>toggle</button>
{isNewPage && (
<Suspense fallback={<div>Loading...</div>}>
<Page />
</Suspense>
)}
</>
);
});
// Loading... -> <Page />Async rendering
Dark is designed with support for asynchronous rendering. This implies that following the mounting of each component during the reconciliation phase, the core checks if a preset deadline has been reached. If the deadline is met, control of the event loop is yielded to other code, resuming at the next tick. If the deadline is not yet met, the core continues the rendering process. The deadline is consistently set at 6 milliseconds.
By default, core runs in synchronous mode, however, if Dark understands that it is rendering on the server, it goes into asynchronous mode and can wait for lazy modules to load and asynchronous code to execute if the useQuery hook from @dark-engine/data package is used.
Concurrent rendering
Concurrent rendering is a strategy that enables the assignment of the lowest priority to updates, allowing them to execute in the background without obstructing the main thread. Upon the arrival of higher-priority updates, such as user render events, the low-priority task is halted and retains all essential data for potential reinstatement (or permanent cancellation) by the scheduler, if there won't be further high-priority updates. This technique facilitates the creation of fluid user interfaces.
startTransition
Marks the update as low-priority and renders it in the background. This allows you to switch between tabs quickly, even if they are rendered slowly due to the large number of calculations. When switching tabs, unnecessary work is marked as obsolete and removed from the task list.
const selectTab = (name: string) => startTransition(() => setTab(name));
<>
<TabButton onClick={() => selectTab('about')}>
About
</TabButton>
<TabButton onClick={() => selectTab('posts')}>
Posts
</TabButton>
<TabButton onClick={() => selectTab('contact')}>
Contact
</TabButton>
{tab === 'about' && <AboutTab />}
{tab === 'posts' && <PostsTab />}
{tab === 'contact' && <ContactTab />}
</>useTransition
Allows you to create a version of startTransition and a flag isPending that will show what stage of concurrent rendering we are in.
const [isPending, startTransition] = useTransition();
const handleClick = () => startTransition(() => onClick());
<button style={`color: ${isPending ? 'red' : 'yellow'}`}>{slot}</button>;useDeferredValue
This fixes an issue with an unresponsive interface when user input occurs, based on which heavy calculations or heavy rendering is recalculated. Returns a delayed value that may lag behind the main value. It can be combined with each other and with useMemo and memo for amazing responsiveness results...
const [name, setName] = useState('');
const deferredName = useDeferredValue(name);
const isStale = name !== deferredName;
const handleInput = e => setName(e.target.value);
// <List /> should be a memo component
return (
<div>
<input value={name} onInput={handleInput} />
<List name={deferredName} isStale={isStale} />
</div>
);Hot Module Replacement (HMR)
Allows you to avoid reloading the entire interface when changing code in development mode. Saves the state of other components, because under the hood, instead of reloading the page, it simply performs a new render as if the interface was rebuilt not by new code, but by the user.
hot
// index.tsx
import { hot } from '@dark-engine/core';
import { createRoot } from '@dark-engine/platform-browser';
import { App } from './app';
if (import.meta.webpackHot) {
import.meta.webpackHot.accept('./app', () => {
hot(() => root.render(<App />));
});
}
const root = createRoot(document.getElementById('root'));
root.render(<App />);Others
useId
The hook for generating unique identifiers that stable between renders.
const id = useId();
// generates something like this 'dark:0:lflt'
<>
<label for={id}>Do you like it?</label>
<input id={id} type='checkbox' name='likeit' />
</>useSyncExternalStore
It's useful for synchronizing render states with an external state management library such as Redux.
const state = useSyncExternalStore(store.subscribe, store.getState); // redux storeLICENSE
MIT © Alex Plex