round-core 0.1.2 → 0.1.4

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.
@@ -0,0 +1,70 @@
1
+ import { pushContext, popContext, contextStack, generateContextId, readContext, SuspenseContext, runInContext } from './context-shared.js';
2
+ import { createElement, Fragment } from './dom.js';
3
+
4
+ export { pushContext, popContext, contextStack, generateContextId, readContext, SuspenseContext, runInContext };
5
+
6
+ /**
7
+ * Internal logic to create a Provider component for a context.
8
+ */
9
+ function createProvider(ctx) {
10
+ const Provider = function Provider(props = {}) {
11
+ const children = props.children;
12
+ const value = props.value;
13
+
14
+ // Push context now so that any createElement/appendChild called
15
+ // during the instantiation of this Provider branch picks it up immediately.
16
+ pushContext({ [ctx.id]: value });
17
+ try {
18
+ // We use a span to handle reactive value updates and dynamic children.
19
+ return createElement('span', { style: { display: 'contents' } }, () => {
20
+ // Read current value (reactive if it's a signal)
21
+ const val = (typeof value === 'function' && value.peek) ? value() : value;
22
+
23
+ // Push it during the effect run too!
24
+ pushContext({ [ctx.id]: val });
25
+ try {
26
+ return typeof children === 'function' ? children() : children;
27
+ } finally {
28
+ popContext();
29
+ }
30
+ });
31
+ } finally {
32
+ popContext();
33
+ }
34
+ };
35
+ return Provider;
36
+ }
37
+
38
+ /**
39
+ * Create a new Context object for sharing state between components.
40
+ */
41
+ export function createContext(defaultValue) {
42
+ const ctx = {
43
+ id: generateContextId(),
44
+ defaultValue,
45
+ Provider: null
46
+ };
47
+ ctx.Provider = createProvider(ctx);
48
+ return ctx;
49
+ }
50
+
51
+ // Attach providers to built-in shared contexts
52
+ SuspenseContext.Provider = createProvider(SuspenseContext);
53
+
54
+ export function bindContext(ctx) {
55
+ return () => {
56
+ const provided = readContext(ctx);
57
+ if (typeof provided === 'function') {
58
+ try {
59
+ return provided();
60
+ } catch {
61
+ return provided;
62
+ }
63
+ }
64
+ return provided;
65
+ };
66
+ }
67
+
68
+ export function captureContext() {
69
+ return contextStack.slice();
70
+ }
@@ -0,0 +1,402 @@
1
+ import { effect, untrack } from './signals.js';
2
+ import { runInLifecycle, createComponentInstance, mountComponent, initLifecycleRoot } from './lifecycle.js';
3
+ import { reportErrorSafe } from './error-reporter.js';
4
+ import { captureContext, runInContext, readContext, SuspenseContext } from './context-shared.js';
5
+
6
+
7
+ const warnedSignals = new Set();
8
+
9
+ function isPromiseLike(v) {
10
+ return v && (typeof v === 'object' || typeof v === 'function') && typeof v.then === 'function';
11
+ }
12
+
13
+ function warnSignalDirectUsage(fn, kind) {
14
+ try {
15
+ if (typeof fn !== 'function') return;
16
+ if (typeof fn.peek !== 'function') return;
17
+ if (!('value' in fn)) return;
18
+ // Using signals as dynamic children/props is a supported pattern.
19
+ if (kind === 'child') return;
20
+ if (typeof kind === 'string' && kind.startsWith('prop:')) return;
21
+ const key = `${kind}:${fn.name ?? 'signal'}`;
22
+ if (warnedSignals.has(key)) return;
23
+ warnedSignals.add(key);
24
+ console.warn(`[round] Prefer {signal()} (reactive) or {signal.value} (static). Direct {signal} usage is allowed but discouraged.`);
25
+ } catch {
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Create a DOM element or instance a component.
31
+ * @param {string | Function} tag HTML tag name or Component function.
32
+ * @param {object} [props] Element attributes or component props.
33
+ * @param {...any} children Child nodes.
34
+ * @returns {Node} The resulting DOM node.
35
+ */
36
+ export function createElement(tag, props = {}, ...children) {
37
+ if (typeof tag === 'function') {
38
+ const componentInstance = createComponentInstance();
39
+ const componentName = tag?.name ?? 'Anonymous';
40
+ componentInstance.name = componentName;
41
+
42
+ let node = runInLifecycle(componentInstance, () => {
43
+ const componentProps = { ...props, children };
44
+ try {
45
+ const res = untrack(() => tag(componentProps));
46
+ if (isPromiseLike(res)) throw res;
47
+ return res;
48
+ } catch (e) {
49
+ if (isPromiseLike(e)) {
50
+ const suspense = readContext(SuspenseContext);
51
+ if (!suspense) {
52
+ throw new Error("cannot instance a lazy component outside a suspense");
53
+ }
54
+ throw e;
55
+ }
56
+
57
+ reportErrorSafe(e, { phase: 'component.render', component: componentName });
58
+ throw e;
59
+ }
60
+ });
61
+
62
+ if (Array.isArray(node)) {
63
+ const wrapper = document.createElement('span');
64
+ wrapper.style.display = 'contents';
65
+ node.forEach(n => appendChild(wrapper, n));
66
+ node = wrapper;
67
+ }
68
+
69
+ if (node instanceof Node) {
70
+ node._componentInstance = componentInstance;
71
+ componentInstance.nodes.push(node);
72
+
73
+ componentInstance.mountTimerId = setTimeout(() => {
74
+ componentInstance.mountTimerId = null;
75
+ mountComponent(componentInstance);
76
+ }, 0);
77
+ }
78
+
79
+ return node;
80
+ }
81
+
82
+ if (typeof tag === 'string') {
83
+ const isCustomElement = tag.includes('-');
84
+
85
+ const isStandard = /^(a|abbr|address|area|article|aside|audio|b|base|bdi|bdo|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|data|datalist|dd|del|details|dfn|dialog|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|label|legend|li|link|main|map|mark|meta|meter|nav|noscript|object|ol|optgroup|option|output|p|param|picture|pre|progress|q|rp|rt|ruby|s|samp|script|search|section|select|slot|small|source|span|strong|style|sub|summary|sup|svg|table|tbody|td|template|textarea|tfoot|th|thead|time|title|tr|track|u|ul|var|video|wbr|menu|animate|animateMotion|animateTransform|circle|clipPath|defs|desc|ellipse|feBlend|feColorMatrix|feComponentTransfer|feComposite|feConvolveMatrix|feDiffuseLighting|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feImage|feMerge|feMergeNode|feMorphology|feOffset|fePointLight|feSpecularLighting|feSpotLight|feTile|feTurbulence|filter|foreignObject|g|image|line|linearGradient|marker|mask|metadata|mpath|path|pattern|polygon|polyline|radialGradient|rect|set|stop|switch|symbol|text|textPath|tspan|use|view)$/.test(tag);
86
+
87
+ // __ROUND_CUSTOM_TAGS__ is injected by the vite plugin from round.config.json
88
+ const isCustomConfigured = typeof __ROUND_CUSTOM_TAGS__ !== 'undefined' && __ROUND_CUSTOM_TAGS__.includes(tag);
89
+
90
+ if (!isCustomElement && !isStandard && !isCustomConfigured && /^[a-z]/.test(tag)) {
91
+ throw new Error(`Component names must start with an uppercase letter: <${tag} />`);
92
+ }
93
+ }
94
+
95
+ const element = document.createElement(tag);
96
+
97
+ if (props) {
98
+ Object.entries(props).forEach(([key, value]) => {
99
+ if (key === 'bind:value' || key === 'bind:checked') {
100
+ const isSignalLike = typeof value === 'function' && typeof value.peek === 'function' && ('value' in value);
101
+ const isBindable = isSignalLike && value.bind === true;
102
+
103
+ if (!isSignalLike) {
104
+ try {
105
+ console.warn('[round] bind:* expects a signal/bindable. Example: const name = bindable(\'\'); <input bind:value={name} />');
106
+ } catch {
107
+ }
108
+ return;
109
+ }
110
+
111
+ if (!isBindable) {
112
+ try {
113
+ console.warn('[round] bind:* is intended to be used with bindable(). Plain signal() is accepted but discouraged.');
114
+ } catch {
115
+ }
116
+ }
117
+
118
+ const isValueBinding = key === 'bind:value';
119
+ const isCheckedBinding = key === 'bind:checked';
120
+ const el = element;
121
+ const tagName = String(el.tagName ?? '').toLowerCase();
122
+ const type = String(el.getAttribute?.('type') ?? '').toLowerCase();
123
+
124
+ const isInput = tagName === 'input';
125
+ const isTextarea = tagName === 'textarea';
126
+ const isSelect = tagName === 'select';
127
+
128
+ if (isCheckedBinding && !(isInput && (type === 'checkbox' || type === 'radio'))) {
129
+ try {
130
+ console.warn(`[round] bind:checked is only supported on <input type="checkbox|radio">. Got <${tagName}${type ? ` type=\"${type}\"` : ''}>.`);
131
+ } catch {
132
+ }
133
+ return;
134
+ }
135
+
136
+ if (isValueBinding && !(isInput || isTextarea || isSelect)) {
137
+ try {
138
+ console.warn(`[round] bind:value is only supported on <input>, <textarea>, and <select>. Got <${tagName}>.`);
139
+ } catch {
140
+ }
141
+ return;
142
+ }
143
+
144
+ const coerceFromDom = () => {
145
+ if (isCheckedBinding) {
146
+ if (type === 'radio') {
147
+ return Boolean(el.checked);
148
+ }
149
+ return Boolean(el.checked);
150
+ }
151
+
152
+ if (isInput && type === 'number') {
153
+ const raw = el.value;
154
+ if (raw === '') return '';
155
+ const n = Number(raw);
156
+ return Number.isFinite(n) ? n : raw;
157
+ }
158
+
159
+ if (isSelect && el.multiple) {
160
+ try {
161
+ return Array.from(el.selectedOptions ?? []).map(o => o.value);
162
+ } catch {
163
+ return [];
164
+ }
165
+ }
166
+
167
+ return el.value;
168
+ };
169
+
170
+ const writeToDom = (v) => {
171
+ if (isCheckedBinding) {
172
+ const b = Boolean(v);
173
+ if (type === 'radio') {
174
+ el.checked = b;
175
+ } else {
176
+ el.checked = b;
177
+ }
178
+ return;
179
+ }
180
+
181
+ if (isSelect && el.multiple) {
182
+ const arr = Array.isArray(v) ? v.map(x => String(x)) : [];
183
+ try {
184
+ Array.from(el.options ?? []).forEach(opt => {
185
+ opt.selected = arr.includes(opt.value);
186
+ });
187
+ } catch {
188
+ }
189
+ return;
190
+ }
191
+
192
+ el.value = v ?? '';
193
+ };
194
+
195
+ const warnTypeMismatch = (next) => {
196
+ try {
197
+ if (isCheckedBinding && typeof next !== 'boolean') {
198
+ console.warn('[round] bind:checked expects a boolean signal value.');
199
+ }
200
+ if (isValueBinding && isSelect && el.multiple && !Array.isArray(next)) {
201
+ console.warn('[round] bind:value on <select multiple> expects an array signal value.');
202
+ }
203
+ if (isValueBinding && isInput && type === 'number' && !(typeof next === 'number' || typeof next === 'string')) {
204
+ console.warn('[round] bind:value on <input type="number"> expects number|string (empty string allowed).');
205
+ }
206
+ } catch {
207
+ }
208
+ };
209
+
210
+ effect(() => {
211
+ const v = value();
212
+ warnTypeMismatch(v);
213
+ writeToDom(v);
214
+ }, { onLoad: false });
215
+
216
+ const validateOn = isValueBinding && value && typeof value === 'function'
217
+ ? value.__round_validateOn
218
+ : null;
219
+ const valueEvent = (validateOn === 'blur') ? 'blur' : (isSelect ? 'change' : 'input');
220
+ const eventName = isCheckedBinding ? 'change' : valueEvent;
221
+ el.addEventListener(eventName, (e) => {
222
+ try {
223
+ const target = e.currentTarget;
224
+ if (!target) return;
225
+ const next = coerceFromDom();
226
+ value(next);
227
+ } catch {
228
+ }
229
+ });
230
+ return;
231
+ }
232
+
233
+ if (key.startsWith('on') && typeof value === 'function') {
234
+ element.addEventListener(key.toLowerCase().substring(2), value);
235
+ return;
236
+ }
237
+
238
+ if (key === 'dangerouslySetInnerHTML') {
239
+ if (typeof value === 'function') {
240
+ effect(() => {
241
+ const v = value();
242
+ if (v && typeof v === 'object' && '__html' in v) {
243
+ element.innerHTML = v.__html ?? '';
244
+ }
245
+ }, { onLoad: false });
246
+ } else if (value && typeof value === 'object' && '__html' in value) {
247
+ element.innerHTML = value.__html ?? '';
248
+ }
249
+ return;
250
+ }
251
+
252
+ if (key === 'style') {
253
+ if (typeof value === 'function') {
254
+ effect(() => {
255
+ const v = value();
256
+ if (v && typeof v === 'object') {
257
+ Object.assign(element.style, v);
258
+ }
259
+ }, { onLoad: false });
260
+ return;
261
+ }
262
+ if (value && typeof value === 'object') {
263
+ Object.assign(element.style, value);
264
+ return;
265
+ }
266
+ }
267
+
268
+ if (typeof value === 'function') {
269
+ warnSignalDirectUsage(value, `prop:${key}`);
270
+ effect(() => {
271
+ const val = value();
272
+ if (key === 'className') element.className = val;
273
+ else if (key === 'value') element.value = val;
274
+ else if (key === 'checked') element.checked = Boolean(val);
275
+ else element.setAttribute(key, val);
276
+ }, { onLoad: false });
277
+ return;
278
+ }
279
+
280
+ if (key === 'classList') {
281
+ if (value && typeof value === 'object') {
282
+ Object.entries(value).forEach(([className, condition]) => {
283
+ if (typeof condition === 'function') {
284
+ effect(() => {
285
+ element.classList.toggle(className, !!condition());
286
+ }, { onLoad: false });
287
+ } else {
288
+ element.classList.toggle(className, !!condition);
289
+ }
290
+ });
291
+ }
292
+ return;
293
+ }
294
+
295
+ if (key === 'className') element.className = value;
296
+ else if (key === 'value') element.value = value;
297
+ else if (key === 'checked') element.checked = Boolean(value);
298
+ else element.setAttribute(key, value);
299
+ });
300
+ }
301
+
302
+ children.forEach(child => appendChild(element, child));
303
+
304
+ return element;
305
+ }
306
+
307
+ function appendChild(parent, child) {
308
+ if (child === null || child === undefined) return;
309
+
310
+ if (Array.isArray(child)) {
311
+ child.forEach(c => appendChild(parent, c));
312
+ return;
313
+ }
314
+
315
+ if (typeof child === 'string' || typeof child === 'number') {
316
+ parent.appendChild(document.createTextNode(child));
317
+ return;
318
+ }
319
+
320
+ if (typeof child === 'function') {
321
+ warnSignalDirectUsage(child, 'child');
322
+ const placeholder = document.createTextNode('');
323
+ parent.appendChild(placeholder);
324
+
325
+ let currentNode = placeholder;
326
+
327
+ const ctxSnapshot = captureContext();
328
+
329
+ effect(() => {
330
+ runInContext(ctxSnapshot, () => {
331
+ let val;
332
+ try {
333
+ val = child();
334
+ if (isPromiseLike(val)) throw val;
335
+ } catch (e) {
336
+ if (isPromiseLike(e)) {
337
+ const suspense = readContext(SuspenseContext);
338
+ if (suspense && typeof suspense.register === 'function') {
339
+ suspense.register(e);
340
+ return;
341
+ }
342
+ throw new Error("cannot instance a lazy component outside a suspense");
343
+ }
344
+
345
+ reportErrorSafe(e, { phase: 'child.dynamic' });
346
+ throw e;
347
+ }
348
+
349
+ if (Array.isArray(val)) {
350
+ if (!(currentNode instanceof Element) || !currentNode._roundArrayWrapper) {
351
+ const wrapper = document.createElement('span');
352
+ wrapper.style.display = 'contents';
353
+ wrapper._roundArrayWrapper = true;
354
+ if (currentNode.parentNode) {
355
+ currentNode.parentNode.replaceChild(wrapper, currentNode);
356
+ currentNode = wrapper;
357
+ }
358
+ }
359
+
360
+ while (currentNode.firstChild) currentNode.removeChild(currentNode.firstChild);
361
+ val.forEach(v => appendChild(currentNode, v));
362
+ return;
363
+ }
364
+
365
+ if (val instanceof Node) {
366
+ if (currentNode !== val) {
367
+ if (currentNode.parentNode) {
368
+ currentNode.parentNode.replaceChild(val, currentNode);
369
+ currentNode = val;
370
+ }
371
+ }
372
+ }
373
+ else {
374
+ const textContent = (val === null || val === undefined) ? '' : val;
375
+
376
+ if (currentNode instanceof Element) {
377
+ const newText = document.createTextNode(textContent);
378
+ if (currentNode.parentNode) {
379
+ currentNode.parentNode.replaceChild(newText, currentNode);
380
+ currentNode = newText;
381
+ }
382
+ } else {
383
+ currentNode.textContent = textContent;
384
+ }
385
+ }
386
+ });
387
+ }, { onLoad: false });
388
+ return;
389
+ }
390
+
391
+ if (child instanceof Node) {
392
+ parent.appendChild(child);
393
+ return;
394
+ }
395
+ }
396
+
397
+ /**
398
+ * A grouping component that returns its children without a wrapper element.
399
+ */
400
+ export function Fragment(props) {
401
+ return props.children;
402
+ }
@@ -0,0 +1,48 @@
1
+ import { signal } from './signals.js';
2
+ import { createElement } from './dom.js';
3
+ import { reportError } from './error-store.js';
4
+
5
+ export function ErrorBoundary(props = {}) {
6
+ const error = signal(null);
7
+
8
+ const name = props.name ?? 'ErrorBoundary';
9
+ const fallback = props.fallback;
10
+ const resetKey = props.resetKey;
11
+
12
+ let lastResetKey = resetKey;
13
+
14
+ return createElement('span', { style: { display: 'contents' } }, () => {
15
+ if (resetKey !== undefined && resetKey !== lastResetKey) {
16
+ lastResetKey = resetKey;
17
+ if (error()) error(null);
18
+ }
19
+
20
+ const err = error();
21
+ if (err) {
22
+ if (typeof fallback === 'function') {
23
+ try {
24
+ return fallback({ error: err });
25
+ } catch (e) {
26
+ reportError(e, { phase: 'ErrorBoundary.fallback', component: name });
27
+ return createElement('div', { style: { padding: '16px' } }, 'ErrorBoundary fallback crashed');
28
+ }
29
+ }
30
+ if (fallback !== undefined) return fallback;
31
+ return createElement('div', { style: { padding: '16px' } }, 'Something went wrong.');
32
+ }
33
+
34
+ const renderFn = (typeof props.render === 'function')
35
+ ? props.render
36
+ : (typeof props.children === 'function' ? props.children : null);
37
+
38
+ if (typeof renderFn !== 'function') return props.children ?? null;
39
+
40
+ try {
41
+ return renderFn();
42
+ } catch (e) {
43
+ if (!error() || error() !== e) error(e);
44
+ reportError(e, { phase: 'ErrorBoundary.render', component: name });
45
+ return null;
46
+ }
47
+ });
48
+ }
@@ -0,0 +1,21 @@
1
+
2
+ let reporter = null;
3
+
4
+ export function setErrorReporter(fn) {
5
+ reporter = typeof fn === 'function' ? fn : null;
6
+ }
7
+
8
+ export function reportErrorSafe(error, info) {
9
+ if (reporter) {
10
+ try {
11
+ reporter(error, info);
12
+ return;
13
+ } catch {
14
+ }
15
+ }
16
+
17
+ // Default: Descriptive console logging
18
+ const phase = info?.phase ? ` in phase "${info.phase}"` : "";
19
+ const component = info?.component ? ` of component <${info.component} />` : "";
20
+ console.error(`[round] Error${phase}${component}:`, error);
21
+ }
@@ -0,0 +1,85 @@
1
+ import { signal } from './signals.js';
2
+ import { setErrorReporter } from './error-reporter.js';
3
+
4
+ const errors = signal([]);
5
+
6
+ let lastSentKey = null;
7
+ let lastSentAt = 0;
8
+
9
+ let lastStoredKey = null;
10
+ let lastStoredAt = 0;
11
+
12
+ export function reportError(error, info = {}) {
13
+ const err = error instanceof Error ? error : new Error(String(error));
14
+ const stack = err.stack ? String(err.stack) : '';
15
+ const message = err.message;
16
+ const phase = info.phase ?? null;
17
+ const component = info.component ?? null;
18
+ const key = `${message}|${component ?? ''}|${phase ?? ''}|${stack}`;
19
+ const now = Date.now();
20
+
21
+ if (lastStoredKey === key && (now - lastStoredAt) < 1500) {
22
+ return;
23
+ }
24
+ lastStoredKey = key;
25
+ lastStoredAt = now;
26
+
27
+ const entry = {
28
+ error: err,
29
+ message,
30
+ stack,
31
+ phase,
32
+ component,
33
+ time: now
34
+ };
35
+
36
+ const current = typeof errors.peek === 'function' ? errors.peek() : errors();
37
+ errors([entry, ...(Array.isArray(current) ? current : [])]);
38
+
39
+ try {
40
+ const where = entry.component ? ` in ${entry.component}` : '';
41
+ const phase = entry.phase ? ` (${entry.phase})` : '';
42
+ const label = `[round] Runtime error${where}${phase}`;
43
+
44
+ if (typeof console.groupCollapsed === 'function') {
45
+ console.groupCollapsed(label);
46
+ console.error(entry.error);
47
+ if (entry.stack) console.log(entry.stack);
48
+ if (info && Object.keys(info).length) console.log('info:', info);
49
+ console.groupEnd();
50
+ } else {
51
+ console.error(label);
52
+ console.error(entry.error);
53
+ if (entry.stack) console.log(entry.stack);
54
+ if (info && Object.keys(info).length) console.log('info:', info);
55
+ }
56
+ } catch {
57
+ }
58
+
59
+ try {
60
+ if (import.meta?.hot && typeof import.meta.hot.send === 'function') {
61
+ if (lastSentKey !== key || (now - lastSentAt) > 1500) {
62
+ lastSentKey = key;
63
+ lastSentAt = now;
64
+ import.meta.hot.send('round:runtime-error', {
65
+ message: entry.message,
66
+ stack: entry.stack ? String(entry.stack) : '',
67
+ phase: entry.phase,
68
+ component: entry.component,
69
+ time: entry.time
70
+ });
71
+ }
72
+ }
73
+ } catch {
74
+ }
75
+ }
76
+
77
+ export function clearErrors() {
78
+ errors([]);
79
+ }
80
+
81
+ export function useErrors() {
82
+ return errors;
83
+ }
84
+
85
+ setErrorReporter(reportError);