@ipxjs/refract 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/assets/lens-syntax-refract.svg +32 -0
- package/package.json +32 -0
- package/src/refract/context.ts +2 -0
- package/src/refract/core.ts +3 -0
- package/src/refract/coreRenderer.ts +291 -0
- package/src/refract/createElement.ts +40 -0
- package/src/refract/devtools.ts +254 -0
- package/src/refract/dom.ts +122 -0
- package/src/refract/features/context.ts +40 -0
- package/src/refract/features/hooks.ts +145 -0
- package/src/refract/features/memoRuntime.ts +33 -0
- package/src/refract/features/security.ts +61 -0
- package/src/refract/fiber.ts +10 -0
- package/src/refract/full.ts +14 -0
- package/src/refract/hooks.ts +11 -0
- package/src/refract/hooksRuntime.ts +63 -0
- package/src/refract/index.ts +1 -0
- package/src/refract/memo.ts +27 -0
- package/src/refract/memoMarker.ts +14 -0
- package/src/refract/reconcile.ts +185 -0
- package/src/refract/render.ts +9 -0
- package/src/refract/renderCore.ts +7 -0
- package/src/refract/runtimeExtensions.ts +80 -0
- package/src/refract/types.ts +48 -0
- package/tests/context.test.ts +90 -0
- package/tests/createElement.test.ts +71 -0
- package/tests/devtools.test.ts +90 -0
- package/tests/entrypoints.test.ts +63 -0
- package/tests/fragments.test.ts +92 -0
- package/tests/hooks.test.ts +255 -0
- package/tests/innerhtml-errors.test.ts +139 -0
- package/tests/keyed.test.ts +172 -0
- package/tests/memo.test.ts +132 -0
- package/tests/reconcile.test.ts +106 -0
- package/tests/render.test.ts +82 -0
- package/tsconfig.json +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Polera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# Refract
|
|
4
|
+
|
|
5
|
+
A minimal React-like virtual DOM library, written in TypeScript with split entrypoints
|
|
6
|
+
so you can keep bundles small and targetetd.
|
|
7
|
+
|
|
8
|
+
Refract implements the core ideas behind React in TypeScript
|
|
9
|
+
- a virtual DOM
|
|
10
|
+
- createElement
|
|
11
|
+
- render
|
|
12
|
+
- reconciliation
|
|
13
|
+
- hooks
|
|
14
|
+
- context
|
|
15
|
+
- memo
|
|
16
|
+
|
|
17
|
+
## LLM Disclosure
|
|
18
|
+
This project is an experiment and uses code generated with both Claude Opus 4.6 and gpt-5.3-codex.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- **createElement / JSX** -- builds virtual DOM nodes from tags, props, and children
|
|
23
|
+
- **Fragments** -- group children without extra DOM nodes
|
|
24
|
+
- **render** -- mounts a VNode tree into a real DOM container
|
|
25
|
+
- **Fiber-based reconciliation** -- keyed and positional diffing with minimal DOM patches
|
|
26
|
+
- **Hooks** -- useState, useEffect, useRef, useMemo, useCallback, useReducer, useErrorBoundary
|
|
27
|
+
- **Context API** -- createContext / Provider for dependency injection
|
|
28
|
+
- **memo** -- skip re-renders when props are unchanged
|
|
29
|
+
- **Refs** -- createRef and callback refs via the `ref` prop
|
|
30
|
+
- **Error boundaries** -- catch and recover from render errors
|
|
31
|
+
- **SVG support** -- automatic SVG namespace handling
|
|
32
|
+
- **dangerouslySetInnerHTML** -- raw HTML injection with sanitizer defaults in `refract/full` and configurable `setHtmlSanitizer` override
|
|
33
|
+
- **Automatic batching** -- state updates are batched via microtask queue
|
|
34
|
+
- **DevTools hook support** -- emits commit/unmount snapshots to a global hook or explicit hook instance
|
|
35
|
+
|
|
36
|
+
No JSX transform is required, but the library works with one. The tsconfig maps
|
|
37
|
+
`jsxFactory` to `createElement` so JSX can be used if desired.
|
|
38
|
+
|
|
39
|
+
## Project Structure
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
refract/
|
|
43
|
+
src/refract/
|
|
44
|
+
createElement.ts -- VNode factory + Fragment symbol
|
|
45
|
+
coreRenderer.ts -- render work loop + commit + batched updates
|
|
46
|
+
reconcile.ts -- keyed + positional child diffing
|
|
47
|
+
dom.ts -- DOM creation/prop patching + sanitizer hooks
|
|
48
|
+
renderCore.ts -- minimal render() entrypoint used by `refract/core`
|
|
49
|
+
render.ts -- full render() entrypoint (auto-enables security defaults)
|
|
50
|
+
hooksRuntime.ts -- effect scheduling + cleanup lifecycle wiring
|
|
51
|
+
runtimeExtensions.ts -- runtime plugin hooks (memo/devtools/effects/errors)
|
|
52
|
+
devtools.ts -- optional devtools bridge + snapshot serialization
|
|
53
|
+
full.ts -- full public API exports
|
|
54
|
+
core.ts -- minimal public API exports
|
|
55
|
+
features/ -- feature modules (`hooks`, `context`, `memoRuntime`, `security`)
|
|
56
|
+
demo/ -- image gallery demo app
|
|
57
|
+
tests/ -- Vitest unit tests
|
|
58
|
+
benchmark/ -- Puppeteer benchmark: Refract entrypoint matrix vs React & Preact
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Getting Started
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
yarn install
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Run the demo dev server:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
yarn dev
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Run the tests:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
yarn test
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Entrypoints
|
|
80
|
+
|
|
81
|
+
- `refract/core` -- minimal runtime surface (`createElement`, `Fragment`, `render`) with no default HTML sanitizer
|
|
82
|
+
- `refract/full` -- complete API including hooks, context, memo, sanitizer defaults, and devtools integration
|
|
83
|
+
- `refract` -- alias of `refract/full` for backward compatibility
|
|
84
|
+
- Feature entrypoints for custom bundles: `refract/hooks`, `refract/context`, `refract/memo`, `refract/security`, `refract/devtools`
|
|
85
|
+
|
|
86
|
+
## API
|
|
87
|
+
|
|
88
|
+
### createElement(type, props, ...children)
|
|
89
|
+
|
|
90
|
+
Creates a virtual DOM node. If `type` is a function, it is treated as a
|
|
91
|
+
functional component and invoked during render/reconciliation.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { createElement } from "refract";
|
|
95
|
+
|
|
96
|
+
const vnode = createElement("div", { className: "card" },
|
|
97
|
+
createElement("img", { src: "photo.jpg", alt: "A photo" }),
|
|
98
|
+
createElement("span", null, "Caption text"),
|
|
99
|
+
);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### render(vnode, container)
|
|
103
|
+
|
|
104
|
+
Mounts a VNode tree into a DOM element. On subsequent calls with the same
|
|
105
|
+
container, it reconciles against the previous tree instead of re-mounting.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { render } from "refract";
|
|
109
|
+
|
|
110
|
+
render(vnode, document.getElementById("app")!);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Reconciliation is internal and is triggered automatically by `render` on
|
|
114
|
+
subsequent renders to the same container.
|
|
115
|
+
|
|
116
|
+
### DevTools hook integration
|
|
117
|
+
|
|
118
|
+
Refract emits commit and unmount events when a hook is present at
|
|
119
|
+
`window.__REFRACT_DEVTOOLS_GLOBAL_HOOK__` (or `globalThis` in non-browser
|
|
120
|
+
environments). You can also set the hook directly with `setDevtoolsHook`.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { setDevtoolsHook } from "refract";
|
|
124
|
+
|
|
125
|
+
setDevtoolsHook({
|
|
126
|
+
inject(renderer) {
|
|
127
|
+
console.log(renderer.name); // "refract"
|
|
128
|
+
return 1;
|
|
129
|
+
},
|
|
130
|
+
onCommitFiberRoot(rendererId, root) {
|
|
131
|
+
console.log(rendererId, root.current?.type);
|
|
132
|
+
},
|
|
133
|
+
onCommitFiberUnmount(rendererId, fiber) {
|
|
134
|
+
console.log(rendererId, fiber.type);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## How It Works
|
|
140
|
+
|
|
141
|
+
1. `createElement` normalizes children (flattening arrays, converting strings
|
|
142
|
+
and numbers to text VNodes, filtering out nulls and booleans). Component
|
|
143
|
+
functions are stored as VNode types and called later during reconciliation.
|
|
144
|
+
|
|
145
|
+
2. `render` builds a fiber tree from the VNode tree. Each fiber holds a
|
|
146
|
+
reference to its DOM node, hooks, and alternate (previous render). Props are
|
|
147
|
+
applied as attributes, with special handling for `className`, `style`
|
|
148
|
+
objects, `ref`, and `on*` event listeners.
|
|
149
|
+
|
|
150
|
+
3. Reconciliation diffs old and new children using keyed matching (when keys
|
|
151
|
+
are present) or positional matching. Fibers are flagged for placement,
|
|
152
|
+
update, or deletion. After the work phase, a commit phase applies all DOM
|
|
153
|
+
mutations in a single pass, followed by an effects phase that runs
|
|
154
|
+
useEffect callbacks.
|
|
155
|
+
|
|
156
|
+
4. State updates from hooks are batched via `queueMicrotask` -- multiple
|
|
157
|
+
`setState` calls within the same synchronous block result in a single
|
|
158
|
+
re-render.
|
|
159
|
+
|
|
160
|
+
## Benchmark
|
|
161
|
+
|
|
162
|
+
The benchmark compares Refract entrypoint combinations against React 19 and
|
|
163
|
+
Preact 10, all rendering the same image gallery app (6 cards + shuffle).
|
|
164
|
+
Refract variants benchmarked:
|
|
165
|
+
|
|
166
|
+
- `refract/core`
|
|
167
|
+
- `refract/core` + `refract/hooks`
|
|
168
|
+
- `refract/core` + `refract/context`
|
|
169
|
+
- `refract/core` + `refract/memo`
|
|
170
|
+
- `refract/core` + `refract/security`
|
|
171
|
+
- `refract` (full entrypoint)
|
|
172
|
+
|
|
173
|
+
All benchmark apps are built with Vite and served as static production bundles.
|
|
174
|
+
Measurements are taken with Puppeteer (15 measured + 3 warmup runs per
|
|
175
|
+
framework by default), with round-robin ordering, cache disabled, and external
|
|
176
|
+
image requests blocked.
|
|
177
|
+
|
|
178
|
+
### Bundle Size Snapshot
|
|
179
|
+
|
|
180
|
+
The values below are from a local run on February 15, 2026.
|
|
181
|
+
|
|
182
|
+
| Framework | JS bundle (raw) | JS bundle (gzip) |
|
|
183
|
+
|---------------------------|----------------:|-----------------:|
|
|
184
|
+
| Refract (`core`) | 7.46 kB | 2.93 kB |
|
|
185
|
+
| Refract (`core+hooks`) | 8.75 kB | 3.38 kB |
|
|
186
|
+
| Refract (`core+context`) | 7.94 kB | 3.15 kB |
|
|
187
|
+
| Refract (`core+memo`) | 8.09 kB | 3.15 kB |
|
|
188
|
+
| Refract (`core+security`) | 8.51 kB | 3.29 kB |
|
|
189
|
+
| Refract (`refract`) | 13.55 kB | 5.04 kB |
|
|
190
|
+
| React | 189.74 kB | 59.52 kB |
|
|
191
|
+
| Preact | 14.46 kB | 5.95 kB |
|
|
192
|
+
|
|
193
|
+
Load-time metrics are machine-dependent, so the benchmark script prints a fresh
|
|
194
|
+
per-run timing table (median, p95, min/max, sd) for every framework.
|
|
195
|
+
|
|
196
|
+
From this snapshot, Refract `core` gzip JS is about 20.3x smaller than React,
|
|
197
|
+
and the full `refract` entrypoint is about 11.8x smaller.
|
|
198
|
+
|
|
199
|
+
### Component Combination Benchmarks (Vitest)
|
|
200
|
+
|
|
201
|
+
`benchmark/components.bench.ts` runs 16 component combinations (`memo`,
|
|
202
|
+
`context`, `fragment`, `keyed`) across two phases each (mount + reconcile).
|
|
203
|
+
Higher `hz` is better.
|
|
204
|
+
|
|
205
|
+
| Component usage profile | Mount (hz) | Mount vs base | Reconcile (hz) | Reconcile vs base |
|
|
206
|
+
|-------------------------|------------|---------------|----------------|-------------------|
|
|
207
|
+
| `base` | 5209.15 | baseline | 4432.98 | baseline |
|
|
208
|
+
| `memo` | 5924.46 | +13.7% | 5367.20 | +21.1% |
|
|
209
|
+
| `context` | 3457.71 | -33.6% | 5243.29 | +18.3% |
|
|
210
|
+
| `fragment` | 5189.17 | -0.4% | 3964.90 | -10.6% |
|
|
211
|
+
| `keyed` | 6084.45 | +16.8% | 5037.30 | +13.6% |
|
|
212
|
+
| `memo+context` | 6113.94 | +17.4% | 5347.56 | +20.6% |
|
|
213
|
+
| `memo+context+keyed` | 6040.74 | +16.0% | 5088.81 | +14.8% |
|
|
214
|
+
|
|
215
|
+
In this run, `memo+context` was the fastest mount profile, while
|
|
216
|
+
`memo` was the fastest reconcile profile.
|
|
217
|
+
|
|
218
|
+
### Running the Benchmark
|
|
219
|
+
|
|
220
|
+
Recommended:
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
# Standard benchmark (default: 15 measured + 3 warmup)
|
|
224
|
+
make benchmark
|
|
225
|
+
|
|
226
|
+
# Stress benchmark (default: 50 measured + 5 warmup)
|
|
227
|
+
make bench-stress
|
|
228
|
+
|
|
229
|
+
# CI benchmark preset (CI-oriented run counts + benchmark flags)
|
|
230
|
+
make bench-ci
|
|
231
|
+
|
|
232
|
+
# Component-combination microbenchmarks (32 cases)
|
|
233
|
+
yarn bench:components
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Custom run counts:
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
# Example: deeper stress run
|
|
240
|
+
make bench-stress STRESS_RUNS=100 STRESS_WARMUP=10
|
|
241
|
+
|
|
242
|
+
# Example: deeper CI run
|
|
243
|
+
make bench-ci CI_RUNS=50 CI_WARMUP=5
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Feature Matrix
|
|
247
|
+
|
|
248
|
+
How Refract compares to React and Preact:
|
|
249
|
+
|
|
250
|
+
| Feature | Refract | React | Preact |
|
|
251
|
+
|--------------------------------|---------|-------|--------|
|
|
252
|
+
| **Core** | | | |
|
|
253
|
+
| Virtual DOM | Yes | Yes | Yes |
|
|
254
|
+
| createElement | Yes | Yes | Yes |
|
|
255
|
+
| Reconciliation / diffing | Yes | Yes | Yes |
|
|
256
|
+
| Keyed reconciliation | Yes | Yes | Yes |
|
|
257
|
+
| Fragments | Yes | Yes | Yes |
|
|
258
|
+
| JSX support | Yes | Yes | Yes |
|
|
259
|
+
| SVG support | Yes | Yes | Yes |
|
|
260
|
+
| **Components** | | | |
|
|
261
|
+
| Functional components | Yes | Yes | Yes |
|
|
262
|
+
| Class components | No | Yes | Yes |
|
|
263
|
+
| **Hooks** | | | |
|
|
264
|
+
| useState | Yes | Yes | Yes |
|
|
265
|
+
| useEffect | Yes | Yes | Yes |
|
|
266
|
+
| useLayoutEffect | No | Yes | Yes |
|
|
267
|
+
| useRef | Yes | Yes | Yes |
|
|
268
|
+
| useMemo / useCallback | Yes | Yes | Yes |
|
|
269
|
+
| useReducer | Yes | Yes | Yes |
|
|
270
|
+
| useContext | Yes | Yes | Yes |
|
|
271
|
+
| useId | No | Yes | Yes |
|
|
272
|
+
| useTransition / useDeferredValue | No | Yes | No |
|
|
273
|
+
| **State & Data Flow** | | | |
|
|
274
|
+
| Built-in state management | Yes | Yes | Yes |
|
|
275
|
+
| Context API | Yes | Yes | Yes |
|
|
276
|
+
| Refs (createRef / ref prop) | Yes | Yes | Yes |
|
|
277
|
+
| forwardRef | No | Yes | Yes |
|
|
278
|
+
| **Rendering** | | | |
|
|
279
|
+
| Event handling | Yes | Yes | Yes |
|
|
280
|
+
| Style objects | Yes | Yes | Yes |
|
|
281
|
+
| className prop | Yes | Yes | Yes¹ |
|
|
282
|
+
| dangerouslySetInnerHTML | Yes | Yes | Yes |
|
|
283
|
+
| Portals | No | Yes | Yes |
|
|
284
|
+
| Suspense / lazy | No | Yes | Yes² |
|
|
285
|
+
| Error boundaries | Yes³ | Yes | Yes |
|
|
286
|
+
| Server-side rendering | No | Yes | Yes |
|
|
287
|
+
| Hydration | No | Yes | Yes |
|
|
288
|
+
| **Security** | | | |
|
|
289
|
+
| Default HTML sanitizer for `dangerouslySetInnerHTML` | Yes | No | No |
|
|
290
|
+
| Configurable HTML sanitizer hook (`setHtmlSanitizer`) | Yes | No | No |
|
|
291
|
+
| **Performance** | | | |
|
|
292
|
+
| Fiber architecture | Yes | Yes | No |
|
|
293
|
+
| Concurrent rendering | No | Yes | No |
|
|
294
|
+
| Automatic batching | Yes | Yes | Yes |
|
|
295
|
+
| memo / PureComponent | Yes | Yes | Yes |
|
|
296
|
+
| **Ecosystem** | | | |
|
|
297
|
+
| DevTools | Basic (hook API) | Yes | Yes |
|
|
298
|
+
| React compatibility layer | N/A | N/A | Yes |
|
|
299
|
+
| **Bundle Size (gzip, JS)** | ~2.9-5.0 kB⁴ | ~59.5 kB | ~6.0 kB |
|
|
300
|
+
|
|
301
|
+
¹ Preact supports both `class` and `className`.
|
|
302
|
+
² Preact has partial Suspense support via `preact/compat`.
|
|
303
|
+
³ Refract uses the `useErrorBoundary` hook rather than class-based error boundaries.
|
|
304
|
+
⁴ Refract size depends on entrypoint (`refract/core` vs `refract` full).
|
|
305
|
+
|
|
306
|
+
## License
|
|
307
|
+
|
|
308
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="textGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
4
|
+
<stop offset="0%" stop-color="#00d2ff" />
|
|
5
|
+
<stop offset="100%" stop-color="#00ff99" />
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
|
|
9
|
+
<!-- Large Outer Brackets (Pulled in) -->
|
|
10
|
+
<path d="M25 15L15 25V75L25 85" stroke="#444" stroke-width="2" fill="none"/>
|
|
11
|
+
<path d="M75 15L85 25V75L75 85" stroke="#444" stroke-width="2" fill="none"/>
|
|
12
|
+
|
|
13
|
+
<!-- Connection Lines (Adjusted for narrower width) -->
|
|
14
|
+
<path d="M28 45H35" stroke="#333" stroke-width="1.5"/>
|
|
15
|
+
<path d="M72 45H65" stroke="#333" stroke-width="1.5"/>
|
|
16
|
+
|
|
17
|
+
<!-- Lens/Focus Circle -->
|
|
18
|
+
<circle cx="50" cy="45" r="18" stroke="#333" stroke-width="1"/>
|
|
19
|
+
|
|
20
|
+
<!-- Small Inner Brackets (The Core) -->
|
|
21
|
+
<path d="M45 37L42 40V50L45 53" stroke="#00d2ff" stroke-width="2" fill="none"/>
|
|
22
|
+
<path d="M55 37L58 40V50L55 53" stroke="#00ff99" stroke-width="2" fill="none"/>
|
|
23
|
+
|
|
24
|
+
<!-- Text (Smaller and tighter) -->
|
|
25
|
+
<text x="50" y="78"
|
|
26
|
+
font-family="system-ui, -apple-system, sans-serif"
|
|
27
|
+
font-size="8"
|
|
28
|
+
font-weight="700"
|
|
29
|
+
letter-spacing="2"
|
|
30
|
+
fill="url(#textGradient)"
|
|
31
|
+
text-anchor="middle">REFRACT</text>
|
|
32
|
+
</svg>
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ipxjs/refract",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "A minimal React-like virtual DOM library focused on image rendering",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/refract/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/refract/index.ts",
|
|
9
|
+
"./full": "./src/refract/full.ts",
|
|
10
|
+
"./core": "./src/refract/core.ts",
|
|
11
|
+
"./hooks": "./src/refract/features/hooks.ts",
|
|
12
|
+
"./context": "./src/refract/features/context.ts",
|
|
13
|
+
"./memo": "./src/refract/memo.ts",
|
|
14
|
+
"./security": "./src/refract/features/security.ts",
|
|
15
|
+
"./devtools": "./src/refract/devtools.ts"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev": "vite demo",
|
|
19
|
+
"build": "vite build",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"bench:components": "vitest bench --run benchmark/components.bench.ts"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/jsdom": "^27.0.0",
|
|
27
|
+
"jsdom": "^28.0.0",
|
|
28
|
+
"typescript": "^5.9.3",
|
|
29
|
+
"vite": "^7.3.1",
|
|
30
|
+
"vitest": "^4.0.18"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import type { VNode, Fiber, Props } from "./types.js";
|
|
2
|
+
import { PLACEMENT, UPDATE } from "./types.js";
|
|
3
|
+
import { reconcileChildren } from "./reconcile.js";
|
|
4
|
+
import { Fragment } from "./createElement.js";
|
|
5
|
+
import { createDom, applyProps } from "./dom.js";
|
|
6
|
+
import {
|
|
7
|
+
runAfterCommitHandlers,
|
|
8
|
+
runCommitHandlers,
|
|
9
|
+
runFiberCleanupHandlers,
|
|
10
|
+
shouldBailoutComponent,
|
|
11
|
+
tryHandleRenderError,
|
|
12
|
+
} from "./runtimeExtensions.js";
|
|
13
|
+
|
|
14
|
+
/** Module globals for hook system */
|
|
15
|
+
export let currentFiber: Fiber | null = null;
|
|
16
|
+
|
|
17
|
+
/** Store root fiber per container */
|
|
18
|
+
const roots = new WeakMap<Node, Fiber>();
|
|
19
|
+
let deletions: Fiber[] = [];
|
|
20
|
+
|
|
21
|
+
export function pushDeletion(fiber: Fiber): void {
|
|
22
|
+
deletions.push(fiber);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Render a VNode tree into a container (entry point) */
|
|
26
|
+
export function renderFiber(vnode: VNode, container: Node): void {
|
|
27
|
+
const oldRoot = roots.get(container) ?? null;
|
|
28
|
+
|
|
29
|
+
const rootFiber: Fiber = {
|
|
30
|
+
type: "div",
|
|
31
|
+
props: { children: [vnode] },
|
|
32
|
+
key: null,
|
|
33
|
+
dom: container,
|
|
34
|
+
parentDom: container,
|
|
35
|
+
parent: null,
|
|
36
|
+
child: null,
|
|
37
|
+
sibling: null,
|
|
38
|
+
hooks: null,
|
|
39
|
+
alternate: oldRoot,
|
|
40
|
+
flags: UPDATE,
|
|
41
|
+
};
|
|
42
|
+
deletions = [];
|
|
43
|
+
performWork(rootFiber);
|
|
44
|
+
const committedDeletions = deletions.slice();
|
|
45
|
+
commitRoot(rootFiber);
|
|
46
|
+
runAfterCommitHandlers();
|
|
47
|
+
roots.set(container, rootFiber);
|
|
48
|
+
runCommitHandlers(rootFiber, committedDeletions);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Depth-first work loop: call components, diff children */
|
|
52
|
+
function performWork(fiber: Fiber): void {
|
|
53
|
+
const isComponent = typeof fiber.type === "function";
|
|
54
|
+
const isFragment = fiber.type === Fragment;
|
|
55
|
+
|
|
56
|
+
if (isComponent) {
|
|
57
|
+
if (fiber.alternate && fiber.flags === UPDATE && shouldBailoutComponent(fiber)) {
|
|
58
|
+
return advanceWork(fiber);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
currentFiber = fiber;
|
|
62
|
+
fiber._hookIndex = 0;
|
|
63
|
+
if (!fiber.hooks) fiber.hooks = [];
|
|
64
|
+
|
|
65
|
+
const comp = fiber.type as (props: Props) => VNode;
|
|
66
|
+
try {
|
|
67
|
+
const children = [comp(fiber.props)];
|
|
68
|
+
reconcileChildren(fiber, children);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!tryHandleRenderError(fiber, error)) throw error;
|
|
71
|
+
}
|
|
72
|
+
} else if (isFragment) {
|
|
73
|
+
reconcileChildren(fiber, fiber.props.children ?? []);
|
|
74
|
+
} else {
|
|
75
|
+
if (!fiber.dom) {
|
|
76
|
+
fiber.dom = createDom(fiber);
|
|
77
|
+
}
|
|
78
|
+
// Skip children when dangerouslySetInnerHTML is used
|
|
79
|
+
if (!fiber.props.dangerouslySetInnerHTML) {
|
|
80
|
+
reconcileChildren(fiber, fiber.props.children ?? []);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Traverse: child first, then sibling, then uncle
|
|
85
|
+
if (fiber.child) {
|
|
86
|
+
performWork(fiber.child);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
advanceWork(fiber);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function advanceWork(fiber: Fiber): void {
|
|
94
|
+
let next: Fiber | null = fiber;
|
|
95
|
+
while (next) {
|
|
96
|
+
if (next.sibling) {
|
|
97
|
+
performWork(next.sibling);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
next = next.parent;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Find the next DOM sibling for insertion (skips siblings being placed/moved) */
|
|
105
|
+
function getNextDomSibling(fiber: Fiber): Node | null {
|
|
106
|
+
let sib: Fiber | null = fiber.sibling;
|
|
107
|
+
while (sib) {
|
|
108
|
+
// Skip any sibling that is itself being placed/moved
|
|
109
|
+
if (sib.flags & PLACEMENT) {
|
|
110
|
+
sib = sib.sibling;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (sib.dom) return sib.dom;
|
|
114
|
+
if (sib.child) {
|
|
115
|
+
const childDom = getFirstCommittedDom(sib);
|
|
116
|
+
if (childDom) return childDom;
|
|
117
|
+
}
|
|
118
|
+
sib = sib.sibling;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Collect all DOM nodes from a component/fragment fiber's subtree */
|
|
124
|
+
function collectChildDomNodes(fiber: Fiber): Node[] {
|
|
125
|
+
const nodes: Node[] = [];
|
|
126
|
+
function walk(f: Fiber | null): void {
|
|
127
|
+
while (f) {
|
|
128
|
+
if (f.dom) {
|
|
129
|
+
nodes.push(f.dom);
|
|
130
|
+
} else {
|
|
131
|
+
walk(f.child);
|
|
132
|
+
}
|
|
133
|
+
f = f.sibling;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
walk(fiber.child);
|
|
137
|
+
return nodes;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Get the first committed DOM node in a fiber subtree */
|
|
141
|
+
function getFirstCommittedDom(fiber: Fiber): Node | null {
|
|
142
|
+
if (fiber.dom && !(fiber.flags & PLACEMENT)) return fiber.dom;
|
|
143
|
+
let child = fiber.child;
|
|
144
|
+
while (child) {
|
|
145
|
+
const dom = getFirstCommittedDom(child);
|
|
146
|
+
if (dom) return dom;
|
|
147
|
+
child = child.sibling;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Commit all DOM mutations */
|
|
153
|
+
function commitRoot(rootFiber: Fiber): void {
|
|
154
|
+
for (const fiber of deletions) {
|
|
155
|
+
commitDeletion(fiber);
|
|
156
|
+
}
|
|
157
|
+
if (rootFiber.child) {
|
|
158
|
+
commitWork(rootFiber.child);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function commitWork(fiber: Fiber): void {
|
|
163
|
+
let parentFiber = fiber.parent;
|
|
164
|
+
while (parentFiber && !parentFiber.dom) {
|
|
165
|
+
parentFiber = parentFiber.parent;
|
|
166
|
+
}
|
|
167
|
+
const parentDom = parentFiber!.dom!;
|
|
168
|
+
|
|
169
|
+
if (fiber.flags & PLACEMENT) {
|
|
170
|
+
if (fiber.dom) {
|
|
171
|
+
const before = getNextDomSibling(fiber);
|
|
172
|
+
if (before) {
|
|
173
|
+
parentDom.insertBefore(fiber.dom, before);
|
|
174
|
+
} else {
|
|
175
|
+
parentDom.appendChild(fiber.dom);
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
// Component/fragment: move all child DOM nodes
|
|
179
|
+
const domNodes = collectChildDomNodes(fiber);
|
|
180
|
+
const before = getNextDomSibling(fiber);
|
|
181
|
+
for (const dom of domNodes) {
|
|
182
|
+
if (before) {
|
|
183
|
+
parentDom.insertBefore(dom, before);
|
|
184
|
+
} else {
|
|
185
|
+
parentDom.appendChild(dom);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} else if (fiber.flags & UPDATE && fiber.dom) {
|
|
190
|
+
if (fiber.type === "TEXT") {
|
|
191
|
+
const oldValue = fiber.alternate?.props.nodeValue;
|
|
192
|
+
if (oldValue !== fiber.props.nodeValue) {
|
|
193
|
+
fiber.dom.textContent = fiber.props.nodeValue as string;
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
applyProps(
|
|
197
|
+
fiber.dom as HTMLElement,
|
|
198
|
+
fiber.alternate?.props ?? {},
|
|
199
|
+
fiber.props,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Handle ref prop
|
|
205
|
+
if (fiber.dom && fiber.props.ref) {
|
|
206
|
+
setRef(fiber.props.ref, fiber.dom);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
fiber.flags = 0;
|
|
210
|
+
|
|
211
|
+
if (fiber.child) commitWork(fiber.child);
|
|
212
|
+
if (fiber.sibling) commitWork(fiber.sibling);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function setRef(ref: unknown, value: Node | null): void {
|
|
216
|
+
if (typeof ref === "function") {
|
|
217
|
+
ref(value);
|
|
218
|
+
} else if (ref && typeof ref === "object" && "current" in ref) {
|
|
219
|
+
(ref as { current: unknown }).current = value;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function commitDeletion(fiber: Fiber): void {
|
|
224
|
+
runCleanups(fiber);
|
|
225
|
+
// Clear ref on unmount
|
|
226
|
+
if (fiber.dom && fiber.props.ref) {
|
|
227
|
+
setRef(fiber.props.ref, null);
|
|
228
|
+
}
|
|
229
|
+
if (fiber.dom) {
|
|
230
|
+
fiber.dom.parentNode?.removeChild(fiber.dom);
|
|
231
|
+
} else if (fiber.child) {
|
|
232
|
+
// Fragment/component — delete children
|
|
233
|
+
let child: Fiber | null = fiber.child;
|
|
234
|
+
while (child) {
|
|
235
|
+
commitDeletion(child);
|
|
236
|
+
child = child.sibling;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function runCleanups(fiber: Fiber): void {
|
|
242
|
+
runFiberCleanupHandlers(fiber);
|
|
243
|
+
if (fiber.child) runCleanups(fiber.child);
|
|
244
|
+
if (fiber.sibling) runCleanups(fiber.sibling);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const pendingContainers = new Set<Node>();
|
|
248
|
+
let flushScheduled = false;
|
|
249
|
+
|
|
250
|
+
export function scheduleRender(fiber: Fiber): void {
|
|
251
|
+
let root = fiber;
|
|
252
|
+
while (root.parent) {
|
|
253
|
+
root = root.parent;
|
|
254
|
+
}
|
|
255
|
+
pendingContainers.add(root.dom!);
|
|
256
|
+
|
|
257
|
+
if (!flushScheduled) {
|
|
258
|
+
flushScheduled = true;
|
|
259
|
+
queueMicrotask(flushRenders);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function flushRenders(): void {
|
|
264
|
+
flushScheduled = false;
|
|
265
|
+
for (const container of pendingContainers) {
|
|
266
|
+
const currentRoot = roots.get(container);
|
|
267
|
+
if (!currentRoot) continue;
|
|
268
|
+
|
|
269
|
+
const newRoot: Fiber = {
|
|
270
|
+
type: currentRoot.type,
|
|
271
|
+
props: currentRoot.props,
|
|
272
|
+
key: currentRoot.key,
|
|
273
|
+
dom: currentRoot.dom,
|
|
274
|
+
parentDom: currentRoot.parentDom,
|
|
275
|
+
parent: null,
|
|
276
|
+
child: null,
|
|
277
|
+
sibling: null,
|
|
278
|
+
hooks: null,
|
|
279
|
+
alternate: currentRoot,
|
|
280
|
+
flags: UPDATE,
|
|
281
|
+
};
|
|
282
|
+
deletions = [];
|
|
283
|
+
performWork(newRoot);
|
|
284
|
+
const committedDeletions = deletions.slice();
|
|
285
|
+
commitRoot(newRoot);
|
|
286
|
+
runAfterCommitHandlers();
|
|
287
|
+
roots.set(container, newRoot);
|
|
288
|
+
runCommitHandlers(newRoot, committedDeletions);
|
|
289
|
+
}
|
|
290
|
+
pendingContainers.clear();
|
|
291
|
+
}
|