what-core 0.1.0 → 0.2.0
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/dist/a11y.js +425 -0
- package/dist/animation.js +531 -0
- package/dist/components.js +272 -115
- package/dist/data.js +434 -0
- package/dist/dom.js +635 -424
- package/dist/form.js +441 -0
- package/dist/h.js +191 -138
- package/dist/head.js +59 -42
- package/dist/helpers.js +125 -83
- package/dist/hooks.js +224 -134
- package/dist/index.js +2 -2
- package/dist/reactive.js +150 -107
- package/dist/scheduler.js +241 -0
- package/dist/skeleton.js +363 -0
- package/dist/store.js +113 -55
- package/dist/testing.js +367 -0
- package/dist/what.js +2 -2
- package/index.d.ts +15 -0
- package/package.json +1 -1
- package/src/components.js +93 -0
- package/src/dom.js +47 -12
- package/src/index.js +2 -2
- package/src/store.js +23 -5
package/dist/testing.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// What Framework - Testing Utilities
|
|
2
|
+
// Helpers for testing components, similar to @testing-library/react
|
|
3
|
+
// Works with Node.js test runner or any test framework
|
|
4
|
+
|
|
5
|
+
import { mount, h, signal, batch, effect } from './index.js';
|
|
6
|
+
|
|
7
|
+
// Minimal DOM implementation for Node.js
|
|
8
|
+
let container = null;
|
|
9
|
+
|
|
10
|
+
// --- Setup and Cleanup ---
|
|
11
|
+
|
|
12
|
+
export function setupDOM() {
|
|
13
|
+
if (typeof document !== 'undefined') {
|
|
14
|
+
// Browser environment
|
|
15
|
+
container = document.createElement('div');
|
|
16
|
+
container.id = 'test-root';
|
|
17
|
+
document.body.appendChild(container);
|
|
18
|
+
}
|
|
19
|
+
return container;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function cleanup() {
|
|
23
|
+
if (container) {
|
|
24
|
+
container.innerHTML = '';
|
|
25
|
+
if (container.parentNode) {
|
|
26
|
+
container.parentNode.removeChild(container);
|
|
27
|
+
}
|
|
28
|
+
container = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --- Render ---
|
|
33
|
+
|
|
34
|
+
export function render(vnode, options = {}) {
|
|
35
|
+
const { container: customContainer } = options;
|
|
36
|
+
const target = customContainer || setupDOM();
|
|
37
|
+
|
|
38
|
+
if (!target) {
|
|
39
|
+
throw new Error('No DOM container available. Are you running in Node.js without jsdom?');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const unmount = mount(vnode, target);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
container: target,
|
|
46
|
+
unmount,
|
|
47
|
+
// Query helpers
|
|
48
|
+
getByText: (text) => queryByText(target, text),
|
|
49
|
+
getByTestId: (id) => target.querySelector(`[data-testid="${id}"]`),
|
|
50
|
+
getByRole: (role) => target.querySelector(`[role="${role}"]`),
|
|
51
|
+
getAllByText: (text) => queryAllByText(target, text),
|
|
52
|
+
queryByText: (text) => queryByText(target, text),
|
|
53
|
+
queryByTestId: (id) => target.querySelector(`[data-testid="${id}"]`),
|
|
54
|
+
// Debug
|
|
55
|
+
debug: () => console.log(target.innerHTML),
|
|
56
|
+
// Async utilities
|
|
57
|
+
findByText: (text, timeout) => waitFor(() => queryByText(target, text), { timeout }),
|
|
58
|
+
findByTestId: (id, timeout) => waitFor(() => target.querySelector(`[data-testid="${id}"]`), { timeout }),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Query Helpers ---
|
|
63
|
+
|
|
64
|
+
function queryByText(container, text) {
|
|
65
|
+
const regex = text instanceof RegExp ? text : null;
|
|
66
|
+
const walker = document.createTreeWalker(
|
|
67
|
+
container,
|
|
68
|
+
NodeFilter.SHOW_TEXT,
|
|
69
|
+
null,
|
|
70
|
+
false
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
while (walker.nextNode()) {
|
|
74
|
+
const node = walker.currentNode;
|
|
75
|
+
const matches = regex
|
|
76
|
+
? regex.test(node.textContent)
|
|
77
|
+
: node.textContent.includes(text);
|
|
78
|
+
if (matches) {
|
|
79
|
+
return node.parentElement;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function queryAllByText(container, text) {
|
|
86
|
+
const results = [];
|
|
87
|
+
const regex = text instanceof RegExp ? text : null;
|
|
88
|
+
const walker = document.createTreeWalker(
|
|
89
|
+
container,
|
|
90
|
+
NodeFilter.SHOW_TEXT,
|
|
91
|
+
null,
|
|
92
|
+
false
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
while (walker.nextNode()) {
|
|
96
|
+
const node = walker.currentNode;
|
|
97
|
+
const matches = regex
|
|
98
|
+
? regex.test(node.textContent)
|
|
99
|
+
: node.textContent.includes(text);
|
|
100
|
+
if (matches) {
|
|
101
|
+
results.push(node.parentElement);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return results;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Fire Events ---
|
|
108
|
+
|
|
109
|
+
export const fireEvent = {
|
|
110
|
+
click(element) {
|
|
111
|
+
const event = new MouseEvent('click', {
|
|
112
|
+
bubbles: true,
|
|
113
|
+
cancelable: true,
|
|
114
|
+
view: typeof window !== 'undefined' ? window : undefined,
|
|
115
|
+
});
|
|
116
|
+
element.dispatchEvent(event);
|
|
117
|
+
return event;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
change(element, value) {
|
|
121
|
+
element.value = value;
|
|
122
|
+
const event = new Event('input', { bubbles: true });
|
|
123
|
+
element.dispatchEvent(event);
|
|
124
|
+
const changeEvent = new Event('change', { bubbles: true });
|
|
125
|
+
element.dispatchEvent(changeEvent);
|
|
126
|
+
return changeEvent;
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
input(element, value) {
|
|
130
|
+
element.value = value;
|
|
131
|
+
const event = new Event('input', { bubbles: true });
|
|
132
|
+
element.dispatchEvent(event);
|
|
133
|
+
return event;
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
submit(element) {
|
|
137
|
+
const event = new Event('submit', { bubbles: true, cancelable: true });
|
|
138
|
+
element.dispatchEvent(event);
|
|
139
|
+
return event;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
focus(element) {
|
|
143
|
+
element.focus();
|
|
144
|
+
const event = new FocusEvent('focus', { bubbles: true });
|
|
145
|
+
element.dispatchEvent(event);
|
|
146
|
+
return event;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
blur(element) {
|
|
150
|
+
element.blur();
|
|
151
|
+
const event = new FocusEvent('blur', { bubbles: true });
|
|
152
|
+
element.dispatchEvent(event);
|
|
153
|
+
return event;
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
keyDown(element, key, options = {}) {
|
|
157
|
+
const event = new KeyboardEvent('keydown', {
|
|
158
|
+
bubbles: true,
|
|
159
|
+
cancelable: true,
|
|
160
|
+
key,
|
|
161
|
+
...options,
|
|
162
|
+
});
|
|
163
|
+
element.dispatchEvent(event);
|
|
164
|
+
return event;
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
keyUp(element, key, options = {}) {
|
|
168
|
+
const event = new KeyboardEvent('keyup', {
|
|
169
|
+
bubbles: true,
|
|
170
|
+
cancelable: true,
|
|
171
|
+
key,
|
|
172
|
+
...options,
|
|
173
|
+
});
|
|
174
|
+
element.dispatchEvent(event);
|
|
175
|
+
return event;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
mouseEnter(element) {
|
|
179
|
+
const event = new MouseEvent('mouseenter', { bubbles: true });
|
|
180
|
+
element.dispatchEvent(event);
|
|
181
|
+
return event;
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
mouseLeave(element) {
|
|
185
|
+
const event = new MouseEvent('mouseleave', { bubbles: true });
|
|
186
|
+
element.dispatchEvent(event);
|
|
187
|
+
return event;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// --- Wait Utilities ---
|
|
192
|
+
|
|
193
|
+
export async function waitFor(callback, options = {}) {
|
|
194
|
+
const { timeout = 1000, interval = 50 } = options;
|
|
195
|
+
const startTime = Date.now();
|
|
196
|
+
|
|
197
|
+
while (Date.now() - startTime < timeout) {
|
|
198
|
+
try {
|
|
199
|
+
const result = callback();
|
|
200
|
+
if (result) return result;
|
|
201
|
+
} catch (e) {
|
|
202
|
+
// Keep waiting
|
|
203
|
+
}
|
|
204
|
+
await new Promise(r => setTimeout(r, interval));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
throw new Error(`waitFor timed out after ${timeout}ms`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function waitForElementToBeRemoved(callback, options = {}) {
|
|
211
|
+
const { timeout = 1000, interval = 50 } = options;
|
|
212
|
+
const startTime = Date.now();
|
|
213
|
+
|
|
214
|
+
// First, element should exist
|
|
215
|
+
let element = callback();
|
|
216
|
+
if (!element) {
|
|
217
|
+
throw new Error('Element not found');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Then wait for it to be removed
|
|
221
|
+
while (Date.now() - startTime < timeout) {
|
|
222
|
+
element = callback();
|
|
223
|
+
if (!element) return;
|
|
224
|
+
await new Promise(r => setTimeout(r, interval));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw new Error(`Element still present after ${timeout}ms`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- Act ---
|
|
231
|
+
// Ensure all effects and updates are flushed
|
|
232
|
+
|
|
233
|
+
export async function act(callback) {
|
|
234
|
+
const result = await callback();
|
|
235
|
+
// Wait for microtasks to flush
|
|
236
|
+
await new Promise(r => queueMicrotask(r));
|
|
237
|
+
// Wait for any scheduled effects
|
|
238
|
+
await new Promise(r => setTimeout(r, 0));
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- Signal Testing Helpers ---
|
|
243
|
+
|
|
244
|
+
export function createTestSignal(initial) {
|
|
245
|
+
const s = signal(initial);
|
|
246
|
+
const history = [initial];
|
|
247
|
+
|
|
248
|
+
// Track all changes
|
|
249
|
+
effect(() => {
|
|
250
|
+
history.push(s());
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
signal: s,
|
|
255
|
+
get value() { return s(); },
|
|
256
|
+
set value(v) { s.set(v); },
|
|
257
|
+
history,
|
|
258
|
+
reset() {
|
|
259
|
+
history.length = 0;
|
|
260
|
+
history.push(s());
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- Mocking ---
|
|
266
|
+
|
|
267
|
+
export function mockComponent(name = 'MockComponent') {
|
|
268
|
+
const calls = [];
|
|
269
|
+
|
|
270
|
+
function Mock(props) {
|
|
271
|
+
calls.push({ props, timestamp: Date.now() });
|
|
272
|
+
return h('div', { 'data-testid': `mock-${name}` },
|
|
273
|
+
JSON.stringify(props, null, 2)
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
Mock.displayName = name;
|
|
278
|
+
Mock.calls = calls;
|
|
279
|
+
Mock.lastCall = () => calls[calls.length - 1];
|
|
280
|
+
Mock.reset = () => { calls.length = 0; };
|
|
281
|
+
|
|
282
|
+
return Mock;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- Assertions ---
|
|
286
|
+
|
|
287
|
+
export const expect = {
|
|
288
|
+
toBeInTheDocument(element) {
|
|
289
|
+
if (!element || !element.parentNode) {
|
|
290
|
+
throw new Error('Expected element to be in the document');
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
toHaveTextContent(element, text) {
|
|
295
|
+
if (!element) {
|
|
296
|
+
throw new Error('Element not found');
|
|
297
|
+
}
|
|
298
|
+
const content = element.textContent;
|
|
299
|
+
const matches = text instanceof RegExp ? text.test(content) : content.includes(text);
|
|
300
|
+
if (!matches) {
|
|
301
|
+
throw new Error(`Expected "${content}" to contain "${text}"`);
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
toHaveAttribute(element, attr, value) {
|
|
306
|
+
if (!element) {
|
|
307
|
+
throw new Error('Element not found');
|
|
308
|
+
}
|
|
309
|
+
const attrValue = element.getAttribute(attr);
|
|
310
|
+
if (value !== undefined && attrValue !== value) {
|
|
311
|
+
throw new Error(`Expected attribute "${attr}" to be "${value}", got "${attrValue}"`);
|
|
312
|
+
}
|
|
313
|
+
if (value === undefined && attrValue === null) {
|
|
314
|
+
throw new Error(`Expected element to have attribute "${attr}"`);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
toHaveClass(element, className) {
|
|
319
|
+
if (!element) {
|
|
320
|
+
throw new Error('Element not found');
|
|
321
|
+
}
|
|
322
|
+
if (!element.classList.contains(className)) {
|
|
323
|
+
throw new Error(`Expected element to have class "${className}"`);
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
toBeVisible(element) {
|
|
328
|
+
if (!element) {
|
|
329
|
+
throw new Error('Element not found');
|
|
330
|
+
}
|
|
331
|
+
const style = window.getComputedStyle(element);
|
|
332
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
333
|
+
throw new Error('Expected element to be visible');
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
toBeDisabled(element) {
|
|
338
|
+
if (!element) {
|
|
339
|
+
throw new Error('Element not found');
|
|
340
|
+
}
|
|
341
|
+
if (!element.disabled) {
|
|
342
|
+
throw new Error('Expected element to be disabled');
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
toHaveValue(element, value) {
|
|
347
|
+
if (!element) {
|
|
348
|
+
throw new Error('Element not found');
|
|
349
|
+
}
|
|
350
|
+
if (element.value !== value) {
|
|
351
|
+
throw new Error(`Expected value to be "${value}", got "${element.value}"`);
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// --- Screen ---
|
|
357
|
+
// Global query object for convenience
|
|
358
|
+
|
|
359
|
+
export const screen = {
|
|
360
|
+
getByText: (text) => queryByText(document.body, text),
|
|
361
|
+
getByTestId: (id) => document.querySelector(`[data-testid="${id}"]`),
|
|
362
|
+
getByRole: (role) => document.querySelector(`[role="${role}"]`),
|
|
363
|
+
getAllByText: (text) => queryAllByText(document.body, text),
|
|
364
|
+
queryByText: (text) => queryByText(document.body, text),
|
|
365
|
+
queryByTestId: (id) => document.querySelector(`[data-testid="${id}"]`),
|
|
366
|
+
debug: () => console.log(document.body.innerHTML),
|
|
367
|
+
};
|
package/dist/what.js
CHANGED
|
@@ -16,8 +16,8 @@ onMount,
|
|
|
16
16
|
onCleanup,
|
|
17
17
|
createResource,
|
|
18
18
|
} from './hooks.js';
|
|
19
|
-
export { memo, lazy, Suspense, ErrorBoundary, Show, For, Switch, Match } from './components.js';
|
|
20
|
-
export { createStore, atom } from './store.js';
|
|
19
|
+
export { memo, lazy, Suspense, ErrorBoundary, Show, For, Switch, Match, Island } from './components.js';
|
|
20
|
+
export { createStore, storeComputed, atom } from './store.js';
|
|
21
21
|
export { Head, clearHead } from './head.js';
|
|
22
22
|
export {
|
|
23
23
|
show,
|
package/index.d.ts
CHANGED
|
@@ -147,12 +147,27 @@ export type Store<T extends StoreDefinition> = {
|
|
|
147
147
|
[K in keyof T]: T[K] extends (...args: any[]) => any ? T[K] : T[K];
|
|
148
148
|
};
|
|
149
149
|
|
|
150
|
+
/** Mark a function as a computed property in a store definition */
|
|
151
|
+
export function storeComputed<T>(fn: (state: any) => T): (state: any) => T;
|
|
152
|
+
|
|
150
153
|
/** Create a global reactive store */
|
|
151
154
|
export function createStore<T extends StoreDefinition>(definition: T): () => Store<T>;
|
|
152
155
|
|
|
153
156
|
/** Create a simple global atom */
|
|
154
157
|
export function atom<T>(initial: T): Signal<T>;
|
|
155
158
|
|
|
159
|
+
// --- Island ---
|
|
160
|
+
|
|
161
|
+
export interface IslandProps {
|
|
162
|
+
component: Component<any>;
|
|
163
|
+
mode: 'load' | 'idle' | 'visible' | 'interaction' | 'media';
|
|
164
|
+
mediaQuery?: string;
|
|
165
|
+
[key: string]: any;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Island component for deferred hydration */
|
|
169
|
+
export function Island(props: IslandProps): VNode;
|
|
170
|
+
|
|
156
171
|
// --- Utilities ---
|
|
157
172
|
|
|
158
173
|
/** Conditional rendering helper */
|
package/package.json
CHANGED
package/src/components.js
CHANGED
|
@@ -202,3 +202,96 @@ export function Match({ when, children }) {
|
|
|
202
202
|
// Match is just a marker component, Switch handles the logic
|
|
203
203
|
return { tag: Match, props: { when }, children, _vnode: true };
|
|
204
204
|
}
|
|
205
|
+
|
|
206
|
+
// --- Island ---
|
|
207
|
+
// Deferred hydration component for islands architecture.
|
|
208
|
+
// Usage: h(Island, { component: Counter, mode: 'idle' })
|
|
209
|
+
// The babel plugin compiles <Counter client:idle /> into this.
|
|
210
|
+
|
|
211
|
+
export function Island({ component: Component, mode, mediaQuery, ...props }) {
|
|
212
|
+
const placeholder = h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode });
|
|
213
|
+
|
|
214
|
+
// We need to return a vnode that the reconciler can handle.
|
|
215
|
+
// The actual hydration scheduling happens after mount via an effect.
|
|
216
|
+
const wrapper = signal(null);
|
|
217
|
+
const hydrated = signal(false);
|
|
218
|
+
|
|
219
|
+
function doHydrate() {
|
|
220
|
+
if (hydrated()) return;
|
|
221
|
+
hydrated.set(true);
|
|
222
|
+
// Render the actual component
|
|
223
|
+
wrapper.set(h(Component, props));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Schedule hydration based on mode
|
|
227
|
+
function scheduleHydration(el) {
|
|
228
|
+
switch (mode) {
|
|
229
|
+
case 'load':
|
|
230
|
+
queueMicrotask(doHydrate);
|
|
231
|
+
break;
|
|
232
|
+
|
|
233
|
+
case 'idle':
|
|
234
|
+
if (typeof requestIdleCallback !== 'undefined') {
|
|
235
|
+
requestIdleCallback(doHydrate);
|
|
236
|
+
} else {
|
|
237
|
+
setTimeout(doHydrate, 200);
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
|
|
241
|
+
case 'visible': {
|
|
242
|
+
const observer = new IntersectionObserver((entries) => {
|
|
243
|
+
if (entries[0].isIntersecting) {
|
|
244
|
+
observer.disconnect();
|
|
245
|
+
doHydrate();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
observer.observe(el);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case 'interaction': {
|
|
253
|
+
const hydrate = () => {
|
|
254
|
+
el.removeEventListener('click', hydrate);
|
|
255
|
+
el.removeEventListener('focus', hydrate);
|
|
256
|
+
el.removeEventListener('mouseenter', hydrate);
|
|
257
|
+
doHydrate();
|
|
258
|
+
};
|
|
259
|
+
el.addEventListener('click', hydrate, { once: true });
|
|
260
|
+
el.addEventListener('focus', hydrate, { once: true });
|
|
261
|
+
el.addEventListener('mouseenter', hydrate, { once: true });
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case 'media': {
|
|
266
|
+
if (!mediaQuery) { doHydrate(); break; }
|
|
267
|
+
const mq = window.matchMedia(mediaQuery);
|
|
268
|
+
if (mq.matches) {
|
|
269
|
+
queueMicrotask(doHydrate);
|
|
270
|
+
} else {
|
|
271
|
+
const checkMedia = () => {
|
|
272
|
+
if (mq.matches) {
|
|
273
|
+
mq.removeEventListener('change', checkMedia);
|
|
274
|
+
doHydrate();
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
mq.addEventListener('change', checkMedia);
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
default:
|
|
283
|
+
// Unknown mode, hydrate immediately
|
|
284
|
+
queueMicrotask(doHydrate);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Use ref callback to get the DOM element and schedule hydration
|
|
289
|
+
const refCallback = (el) => {
|
|
290
|
+
if (el) scheduleHydration(el);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Return: show placeholder until hydrated, then show the real component
|
|
294
|
+
return h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode, ref: refCallback },
|
|
295
|
+
hydrated() ? wrapper() : null
|
|
296
|
+
);
|
|
297
|
+
}
|
package/src/dom.js
CHANGED
|
@@ -5,6 +5,19 @@
|
|
|
5
5
|
import { effect, batch, untrack } from './reactive.js';
|
|
6
6
|
import { errorBoundaryStack, reportError } from './components.js';
|
|
7
7
|
|
|
8
|
+
// SVG elements that need namespace
|
|
9
|
+
const SVG_ELEMENTS = new Set([
|
|
10
|
+
'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse',
|
|
11
|
+
'g', 'defs', 'use', 'symbol', 'clipPath', 'mask', 'pattern', 'image',
|
|
12
|
+
'text', 'tspan', 'textPath', 'foreignObject', 'linearGradient', 'radialGradient', 'stop',
|
|
13
|
+
'marker', 'animate', 'animateTransform', 'animateMotion', 'set', 'filter',
|
|
14
|
+
'feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix',
|
|
15
|
+
'feDiffuseLighting', 'feDisplacementMap', 'feFlood', 'feGaussianBlur', 'feImage',
|
|
16
|
+
'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'feSpecularLighting',
|
|
17
|
+
'feTile', 'feTurbulence',
|
|
18
|
+
]);
|
|
19
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
20
|
+
|
|
8
21
|
// Mount a component tree into a DOM container
|
|
9
22
|
export function mount(vnode, container) {
|
|
10
23
|
if (typeof container === 'string') {
|
|
@@ -22,7 +35,7 @@ export function mount(vnode, container) {
|
|
|
22
35
|
|
|
23
36
|
// --- Create DOM from VNode ---
|
|
24
37
|
|
|
25
|
-
function createDOM(vnode, parent) {
|
|
38
|
+
function createDOM(vnode, parent, isSvg) {
|
|
26
39
|
if (vnode == null || vnode === false || vnode === true) return null;
|
|
27
40
|
|
|
28
41
|
// Text
|
|
@@ -34,7 +47,7 @@ function createDOM(vnode, parent) {
|
|
|
34
47
|
if (Array.isArray(vnode)) {
|
|
35
48
|
const frag = document.createDocumentFragment();
|
|
36
49
|
for (const child of vnode) {
|
|
37
|
-
const node = createDOM(child, parent);
|
|
50
|
+
const node = createDOM(child, parent, isSvg);
|
|
38
51
|
if (node) frag.appendChild(node);
|
|
39
52
|
}
|
|
40
53
|
return frag;
|
|
@@ -45,11 +58,17 @@ function createDOM(vnode, parent) {
|
|
|
45
58
|
return createComponent(vnode, parent);
|
|
46
59
|
}
|
|
47
60
|
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
|
|
61
|
+
// Detect SVG context: either we're already in SVG, or this tag is an SVG element
|
|
62
|
+
const svgContext = isSvg || vnode.tag === 'svg' || SVG_ELEMENTS.has(vnode.tag);
|
|
63
|
+
|
|
64
|
+
// HTML or SVG Element
|
|
65
|
+
const el = svgContext
|
|
66
|
+
? document.createElementNS(SVG_NS, vnode.tag)
|
|
67
|
+
: document.createElement(vnode.tag);
|
|
68
|
+
|
|
69
|
+
applyProps(el, vnode.props, {}, svgContext);
|
|
51
70
|
for (const child of vnode.children) {
|
|
52
|
-
const node = createDOM(child, el);
|
|
71
|
+
const node = createDOM(child, el, svgContext && vnode.tag !== 'foreignObject');
|
|
53
72
|
if (node) el.appendChild(node);
|
|
54
73
|
}
|
|
55
74
|
|
|
@@ -506,7 +525,7 @@ function reconcileChildren(parent, newChildVNodes) {
|
|
|
506
525
|
// --- Prop Diffing ---
|
|
507
526
|
// Only touch DOM for props that actually changed.
|
|
508
527
|
|
|
509
|
-
function applyProps(el, newProps, oldProps) {
|
|
528
|
+
function applyProps(el, newProps, oldProps, isSvg) {
|
|
510
529
|
newProps = newProps || {};
|
|
511
530
|
oldProps = oldProps || {};
|
|
512
531
|
|
|
@@ -522,7 +541,7 @@ function applyProps(el, newProps, oldProps) {
|
|
|
522
541
|
for (const key in newProps) {
|
|
523
542
|
if (key === 'key' || key === 'ref' || key === 'children') continue;
|
|
524
543
|
if (newProps[key] !== oldProps[key]) {
|
|
525
|
-
setProp(el, key, newProps[key]);
|
|
544
|
+
setProp(el, key, newProps[key], isSvg);
|
|
526
545
|
}
|
|
527
546
|
}
|
|
528
547
|
|
|
@@ -533,7 +552,7 @@ function applyProps(el, newProps, oldProps) {
|
|
|
533
552
|
}
|
|
534
553
|
}
|
|
535
554
|
|
|
536
|
-
function setProp(el, key, value) {
|
|
555
|
+
function setProp(el, key, value, isSvg) {
|
|
537
556
|
// Event handlers: onClick -> click
|
|
538
557
|
// Wrap in untrack so signal reads in handlers don't create subscriptions
|
|
539
558
|
if (key.startsWith('on') && key.length > 2) {
|
|
@@ -546,13 +565,19 @@ function setProp(el, key, value) {
|
|
|
546
565
|
const wrappedHandler = (e) => untrack(() => value(e));
|
|
547
566
|
wrappedHandler._original = value;
|
|
548
567
|
el._events[event] = wrappedHandler;
|
|
549
|
-
|
|
568
|
+
// Check for _eventOpts (once/capture/passive from compiler)
|
|
569
|
+
const eventOpts = value._eventOpts;
|
|
570
|
+
el.addEventListener(event, wrappedHandler, eventOpts || undefined);
|
|
550
571
|
return;
|
|
551
572
|
}
|
|
552
573
|
|
|
553
|
-
// className
|
|
574
|
+
// className / class
|
|
554
575
|
if (key === 'className' || key === 'class') {
|
|
555
|
-
|
|
576
|
+
if (isSvg) {
|
|
577
|
+
el.setAttribute('class', value || '');
|
|
578
|
+
} else {
|
|
579
|
+
el.className = value || '';
|
|
580
|
+
}
|
|
556
581
|
return;
|
|
557
582
|
}
|
|
558
583
|
|
|
@@ -587,6 +612,16 @@ function setProp(el, key, value) {
|
|
|
587
612
|
return;
|
|
588
613
|
}
|
|
589
614
|
|
|
615
|
+
// SVG: always use setAttribute (SVG properties don't work as DOM properties)
|
|
616
|
+
if (isSvg) {
|
|
617
|
+
if (value === false || value == null) {
|
|
618
|
+
el.removeAttribute(key);
|
|
619
|
+
} else {
|
|
620
|
+
el.setAttribute(key, value === true ? '' : String(value));
|
|
621
|
+
}
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
590
625
|
// Default: set as property if it exists, otherwise attribute
|
|
591
626
|
if (key in el) {
|
|
592
627
|
el[key] = value;
|
package/src/index.js
CHANGED
|
@@ -28,10 +28,10 @@ export {
|
|
|
28
28
|
} from './hooks.js';
|
|
29
29
|
|
|
30
30
|
// Component helpers
|
|
31
|
-
export { memo, lazy, Suspense, ErrorBoundary, Show, For, Switch, Match } from './components.js';
|
|
31
|
+
export { memo, lazy, Suspense, ErrorBoundary, Show, For, Switch, Match, Island } from './components.js';
|
|
32
32
|
|
|
33
33
|
// Store
|
|
34
|
-
export { createStore, atom } from './store.js';
|
|
34
|
+
export { createStore, storeComputed, atom } from './store.js';
|
|
35
35
|
|
|
36
36
|
// Head management
|
|
37
37
|
export { Head, clearHead } from './head.js';
|
package/src/store.js
CHANGED
|
@@ -4,15 +4,32 @@
|
|
|
4
4
|
|
|
5
5
|
import { signal, computed, batch } from './reactive.js';
|
|
6
6
|
|
|
7
|
+
// --- storeComputed ---
|
|
8
|
+
// Marker wrapper to explicitly tag a function as a computed in createStore.
|
|
9
|
+
// Without this, createStore can't distinguish computed(state => ...) from action(item => ...).
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// const useCounter = createStore({
|
|
13
|
+
// count: 0,
|
|
14
|
+
// doubled: storeComputed(state => state.count * 2),
|
|
15
|
+
// addItem(item) { /* this is an action */ },
|
|
16
|
+
// });
|
|
17
|
+
|
|
18
|
+
export function storeComputed(fn) {
|
|
19
|
+
fn._storeComputed = true;
|
|
20
|
+
return fn;
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
// --- createStore ---
|
|
8
24
|
// Creates a reactive store with actions. Each key becomes a signal.
|
|
9
25
|
//
|
|
10
26
|
// Usage:
|
|
11
27
|
// const useCounter = createStore({
|
|
12
28
|
// count: 0,
|
|
13
|
-
// doubled: (state
|
|
14
|
-
// increment() { this.count++; },
|
|
29
|
+
// doubled: storeComputed(state => state.count * 2), // computed
|
|
30
|
+
// increment() { this.count++; }, // action
|
|
15
31
|
// decrement() { this.count--; },
|
|
32
|
+
// addItem(item) { this.items.push(item); }, // action (not confused with computed)
|
|
16
33
|
// });
|
|
17
34
|
//
|
|
18
35
|
// function Counter() {
|
|
@@ -27,12 +44,13 @@ export function createStore(definition) {
|
|
|
27
44
|
const state = {};
|
|
28
45
|
|
|
29
46
|
// Separate state, computeds, and actions
|
|
47
|
+
// Use explicit _storeComputed marker instead of function.length heuristic
|
|
30
48
|
for (const [key, value] of Object.entries(definition)) {
|
|
31
|
-
if (typeof value === 'function' && value.
|
|
32
|
-
// Computed:
|
|
49
|
+
if (typeof value === 'function' && value._storeComputed) {
|
|
50
|
+
// Computed: explicitly marked with storeComputed()
|
|
33
51
|
computeds[key] = value;
|
|
34
52
|
} else if (typeof value === 'function') {
|
|
35
|
-
// Action:
|
|
53
|
+
// Action: any other function
|
|
36
54
|
actions[key] = value;
|
|
37
55
|
} else {
|
|
38
56
|
// State: initial value
|