a small incremental UI library for the web
javascript
web
ui
1import { queueFiberTree } from './render.ts';
2import { getCurrentFiber, setCurrentScope } from './current.ts';
3import { pushSideEffect } from './effects.ts';
4import { parseCSS, scopeCSS, generateCSS } from './css.ts';
5import { hashString } from './hash.ts';
6
7export const Hook = {
8 State: 'state',
9 Effect: 'effect',
10 Ref: 'ref',
11 Style: 'style',
12};
13
14const {
15 State,
16 Effect,
17 Ref,
18 Style,
19} = Hook;
20
21export function useFiber() {
22 const currentFiber = getCurrentFiber();
23 if (!currentFiber) throw new Error('Hooks can only be called from within a function component.');
24 return currentFiber;
25}
26
27function pushHook(fiber, hookType) {
28 if (!fiber.hooks) fiber.hooks = [];
29 const {hooks, pointer} = fiber;
30 fiber.pointer += 3;
31
32 const existing = hooks[pointer];
33
34 if (!existing) hooks[pointer] = hookType;
35 else if (existing !== hookType) throw new Error('Hooks must be called in the same order between renders.');
36
37 return pointer;
38}
39
40export function useState(initialValue) {
41 const fiber = useFiber();
42 const pointer = pushHook(fiber, State);
43
44 let value = fiber.hooks[pointer + 1];
45 const existing = fiber.hooks[pointer + 2];
46
47 if (!existing) {
48 fiber.hooks[pointer + 1] = value = initialValue;
49 fiber.hooks[pointer + 2] = true;
50 }
51
52 const setValue = (val) => {
53 if (typeof val === 'function') {
54 const currentVal = fiber.hooks[pointer + 1];
55 fiber.hooks[pointer + 1] = val(currentVal);
56 }
57 else{
58 fiber.hooks[pointer + 1] = val;
59 }
60
61 queueFiberTree(fiber);
62 }
63
64 return [value, setValue];
65}
66
67export function useEffect(callback, dependencies) {
68 const fiber = useFiber();
69 const pointer = pushHook(fiber, Effect);
70
71 const oldDeps = fiber.hooks[pointer + 2];
72 if (!areDependenciesEqual(oldDeps, dependencies)) {
73 fiber.hooks[pointer + 2] = dependencies;
74 const oldCleanup = fiber.hooks[pointer + 1];
75
76 pushSideEffect(() => {
77 if (oldCleanup !== undefined) oldCleanup();
78 const newCleanup = callback();
79 fiber.hooks[pointer + 1] = newCleanup;
80 });
81 }
82}
83
84export function useRef(initialValue) {
85 const fiber = useFiber();
86 const pointer = pushHook(fiber, Ref);
87
88 let ref = fiber.hooks[pointer + 1];
89 if(!ref) {
90 fiber.hooks[pointer + 1] = ref = {current: initialValue};
91 }
92
93 return ref;
94}
95
96export function useStyle(text) {
97 const fiber = useFiber();
98 const pointer = pushHook(fiber, Style);
99
100 const existingText = fiber.hooks[pointer + 1];
101 let scope = fiber.hooks[pointer + 2];
102
103 if (existingText !== text) {
104 scope = insertScopedStyles(text);
105 fiber.hooks[pointer + 1] = text;
106 fiber.hooks[pointer + 2] = scope;
107 }
108
109 setCurrentScope(scope);
110 return scope;
111}
112
113const KNOWN_STYLES = new Map();
114function insertScopedStyles(text) {
115 let scope = KNOWN_STYLES.get(text);
116 if (scope !== undefined) return scope;
117
118 const hash = hashString(text);
119 // TODO: abs prevents a negative number (s--123) but is not ideal
120 scope = 's-' + Math.abs(hash).toString(36);
121 KNOWN_STYLES.set(text, scope);
122
123 const ast = parseCSS(text);
124 scopeCSS(ast, scope);
125 const rules = generateCSS(ast);
126
127 injectStyles(rules);
128
129 return scope;
130}
131
132function injectStyles(rules) {
133 const sheet = getStylesheet();
134 for (const rule of rules) {
135 try {
136 sheet.insertRule(rule);
137 }
138 catch(error) {
139 console.error('An error occurred while inserting the following scoped rule:\n\n' + rule);
140 throw error;
141 }
142 }
143}
144
145let STYLESHEET;
146function getStylesheet() {
147 if (!STYLESHEET) {
148 STYLESHEET = new CSSStyleSheet();
149 document.adoptedStyleSheets.push(STYLESHEET);
150 }
151 return STYLESHEET;
152}
153
154export function cleanUpEffects(fiber) {
155 const hooks = fiber.hooks;
156 if (!hooks) return;
157
158 for (let i = 0; i < hooks.length; i += 3) {
159 if (hooks[i] === Effect) {
160 const cleanup = hooks[i + 1];
161 cleanup();
162 }
163 }
164}
165
166function areDependenciesEqual(oldDeps, newDeps) {
167 if (oldDeps === undefined) return false;
168 if (newDeps === oldDeps) return true;
169
170 const length = oldDeps.length;
171 if (length !== newDeps.length) return false;
172 for (let i = 0; i < length; i++) {
173 if (newDeps[i] !== oldDeps[i]) return false;
174 }
175 return true;
176}