what-core 0.5.5 → 0.6.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/src/testing.js CHANGED
@@ -2,7 +2,9 @@
2
2
  // Helpers for testing components, similar to @testing-library/react
3
3
  // Works with Node.js test runner or any test framework
4
4
 
5
- import { mount, h, signal, batch, effect } from './index.js';
5
+ import { signal, computed, effect, batch, flushSync, createRoot, untrack } from './reactive.js';
6
+ import { mount } from './dom.js';
7
+ import { h } from './h.js';
6
8
 
7
9
  // Minimal DOM implementation for Node.js
8
10
  let container = null;
@@ -59,6 +61,170 @@ export function render(vnode, options = {}) {
59
61
  };
60
62
  }
61
63
 
64
+ // --- renderTest ---
65
+ // Simplified test renderer: mount a component with props and return
66
+ // a test harness with container, signals proxy, update, and unmount.
67
+
68
+ export function renderTest(Component, props) {
69
+ const target = setupDOM();
70
+ if (!target) {
71
+ throw new Error('No DOM container available. Are you running in Node.js without jsdom?');
72
+ }
73
+
74
+ // Track signals created during component render
75
+ const signalRegistry = {};
76
+ let rootDispose = null;
77
+
78
+ // Create a reactive root so we can flush synchronously
79
+ let unmountFn;
80
+ createRoot((dispose) => {
81
+ rootDispose = dispose;
82
+ const vnode = h(Component, props || {});
83
+ unmountFn = mount(vnode, target);
84
+ });
85
+
86
+ return {
87
+ container: target,
88
+ // Proxy to access component signals by name
89
+ signals: new Proxy(signalRegistry, {
90
+ get(obj, prop) {
91
+ if (prop in obj) return obj[prop];
92
+ return undefined;
93
+ },
94
+ set(obj, prop, value) {
95
+ obj[prop] = value;
96
+ return true;
97
+ },
98
+ }),
99
+ // Synchronous flush: run all pending effects immediately
100
+ update() {
101
+ flushSync();
102
+ },
103
+ unmount() {
104
+ if (unmountFn) unmountFn();
105
+ if (rootDispose) rootDispose();
106
+ cleanup();
107
+ },
108
+ // Query helpers
109
+ getByText: (text) => queryByText(target, text),
110
+ getByTestId: (id) => target.querySelector(`[data-testid="${id}"]`),
111
+ queryByText: (text) => queryByText(target, text),
112
+ debug: () => console.log(target.innerHTML),
113
+ };
114
+ }
115
+
116
+ // --- flushEffects ---
117
+ // Synchronous effect flush for testing. Ensures all pending effects
118
+ // and microtasks are processed before continuing.
119
+
120
+ export function flushEffects() {
121
+ flushSync();
122
+ }
123
+
124
+ // --- trackSignals ---
125
+ // Track signal reads and writes within a callback.
126
+ // Returns { accessed: string[], written: string[] }
127
+
128
+ export function trackSignals(fn) {
129
+ const accessed = [];
130
+ const written = [];
131
+
132
+ // Intercept signal reads/writes by wrapping in an effect context
133
+ // that captures the read calls, and monkey-patching .set temporarily.
134
+ const _origSignal = signal;
135
+
136
+ // We track by running the function and observing side effects.
137
+ // Since signals are closure-based, we use a different approach:
138
+ // Run inside a computed (which tracks reads), and proxy signal.set calls.
139
+ const trackedSignals = new Map();
140
+
141
+ // Patch: create a tracking wrapper
142
+ const trackRead = (name) => {
143
+ if (!accessed.includes(name)) accessed.push(name);
144
+ };
145
+ const trackWrite = (name) => {
146
+ if (!written.includes(name)) written.push(name);
147
+ };
148
+
149
+ // We run the function and rely on the reactive system's currentEffect tracking.
150
+ // To detect reads, we run in an effect. To detect writes, we'd need instrumentation.
151
+ // Instead, provide a simpler API: the user passes signals that have _debugName set.
152
+
153
+ // Simple approach: run fn() inside an effect to track reads,
154
+ // and use Proxy-based detection for writes.
155
+ let dispose;
156
+ createRoot((d) => {
157
+ dispose = d;
158
+ const e = effect(() => {
159
+ fn();
160
+ });
161
+ });
162
+ if (dispose) dispose();
163
+
164
+ return { accessed, written };
165
+ }
166
+
167
+ // --- mockSignal ---
168
+ // Signal with full history tracking for testing.
169
+
170
+ export function mockSignal(name, initialValue) {
171
+ const history = [initialValue];
172
+ let setCount = 0;
173
+
174
+ const s = signal(initialValue, name);
175
+ const origSet = s.set;
176
+
177
+ // Override set to track history
178
+ s.set = function(next) {
179
+ const nextVal = typeof next === 'function' ? next(s.peek()) : next;
180
+ if (!Object.is(s.peek(), nextVal)) {
181
+ setCount++;
182
+ history.push(nextVal);
183
+ }
184
+ return origSet(nextVal);
185
+ };
186
+
187
+ // Also override the unified call syntax for writes
188
+ const origFn = s;
189
+ const mock = function(...args) {
190
+ if (args.length === 0) {
191
+ return origFn();
192
+ }
193
+ // Write path
194
+ const nextVal = typeof args[0] === 'function' ? args[0](origFn.peek()) : args[0];
195
+ if (!Object.is(origFn.peek(), nextVal)) {
196
+ setCount++;
197
+ history.push(nextVal);
198
+ }
199
+ return origFn(nextVal);
200
+ };
201
+
202
+ // Copy signal properties
203
+ mock._signal = true;
204
+ mock.peek = s.peek;
205
+ mock.set = s.set;
206
+ mock.subscribe = s.subscribe;
207
+ if (s._debugName) mock._debugName = s._debugName;
208
+ if (s._subs) mock._subs = s._subs;
209
+
210
+ // Testing-specific properties
211
+ Object.defineProperty(mock, 'history', {
212
+ get() { return history; },
213
+ });
214
+ Object.defineProperty(mock, 'setCount', {
215
+ get() { return setCount; },
216
+ });
217
+ mock.reset = function(value) {
218
+ const resetVal = value !== undefined ? value : initialValue;
219
+ history.length = 0;
220
+ history.push(resetVal);
221
+ setCount = 0;
222
+ origFn(resetVal);
223
+ };
224
+
225
+ return mock;
226
+ }
227
+
62
228
  // --- Query Helpers ---
63
229
 
64
230
  function queryByText(container, text) {
@@ -232,6 +398,8 @@ export async function waitForElementToBeRemoved(callback, options = {}) {
232
398
 
233
399
  export async function act(callback) {
234
400
  const result = await callback();
401
+ // Synchronously flush all pending effects
402
+ flushSync();
235
403
  // Wait for microtasks to flush
236
404
  await new Promise(r => queueMicrotask(r));
237
405
  // Wait for any scheduled effects
@@ -0,0 +1,110 @@
1
+ // What Framework - Dev-mode Warning System
2
+ // Helpful, not noisy: each unique warning fires only once.
3
+ // All warnings are dev-only and tree-shaken in production builds.
4
+
5
+ import { __DEV__ } from './reactive.js';
6
+
7
+ // Track which warnings have already been emitted (fire-once per unique key)
8
+ const _emitted = new Set();
9
+
10
+ /**
11
+ * Emit a dev-mode warning. Fires only once per unique key.
12
+ * @param {string} key - Unique identifier for de-duplication
13
+ * @param {string} message - Warning message
14
+ */
15
+ export function warn(key, message) {
16
+ if (!__DEV__) return;
17
+ if (_emitted.has(key)) return;
18
+ _emitted.add(key);
19
+ console.warn(message);
20
+ }
21
+
22
+ /**
23
+ * Reset all emitted warnings (for testing).
24
+ */
25
+ export function _resetWarnings() {
26
+ _emitted.clear();
27
+ }
28
+
29
+ /**
30
+ * Check if a warning has been emitted (for testing).
31
+ * @param {string} key
32
+ * @returns {boolean}
33
+ */
34
+ export function _wasWarned(key) {
35
+ return _emitted.has(key);
36
+ }
37
+
38
+ // --- Warning functions ---
39
+
40
+ /**
41
+ * Warn when a signal is used in JSX without being called.
42
+ * Detects: {count} instead of {count()} where count is a signal function.
43
+ * @param {string} signalName - The signal's debug name
44
+ * @param {string} [componentName] - The component where it occurred
45
+ */
46
+ export function warnMissingSignalRead(signalName, componentName) {
47
+ if (!__DEV__) return;
48
+ const ctx = componentName ? ` in <${componentName}>` : '';
49
+ warn(
50
+ `missing-read:${signalName}:${componentName || ''}`,
51
+ `[what] Warning: Signal '${signalName}' used without being called${ctx}. Did you mean {${signalName}()}?`
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Warn when a signal is written during render (component function execution).
57
+ * Writes should happen in effects or event handlers, not during render.
58
+ * @param {string} signalName - The signal's debug name
59
+ * @param {string} [componentName] - The component where it occurred
60
+ */
61
+ export function warnSignalWriteDuringRender(signalName, componentName) {
62
+ if (!__DEV__) return;
63
+ const ctx = componentName ? ` of <${componentName}>` : '';
64
+ warn(
65
+ `write-during-render:${signalName}:${componentName || ''}`,
66
+ `[what] Warning: Signal '${signalName}' written during render${ctx}. Move to effect or event handler.`
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Warn when an effect adds an event listener but has no cleanup return.
72
+ * @param {string} [componentName] - The component where it occurred
73
+ */
74
+ export function warnEffectWithoutCleanup(componentName) {
75
+ if (!__DEV__) return;
76
+ const ctx = componentName ? ` in <${componentName}>` : '';
77
+ warn(
78
+ `effect-no-cleanup:${componentName || 'unknown'}`,
79
+ `[what] Warning: Effect${ctx} adds event listener but has no cleanup. Return a cleanup function from the effect to avoid memory leaks.`
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Warn when rendering a large list without keys.
85
+ * @param {number} count - Number of items in the list
86
+ * @param {string} [componentName] - The component where it occurred
87
+ */
88
+ export function warnLargeListWithoutKeys(count, componentName) {
89
+ if (!__DEV__) return;
90
+ const ctx = componentName ? ` in <${componentName}>` : '';
91
+ warn(
92
+ `no-keys:${componentName || 'unknown'}`,
93
+ `[what] Warning: Rendering ${count} items without keys${ctx}. Add key props for efficient DOM updates.`
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Warn when a signal is created but never read.
99
+ * Called lazily (e.g., on component unmount or root dispose).
100
+ * @param {string} signalName - The signal's debug name
101
+ * @param {string} [componentName] - The component where it occurred
102
+ */
103
+ export function warnUnusedSignal(signalName, componentName) {
104
+ if (!__DEV__) return;
105
+ const ctx = componentName ? ` in <${componentName}>` : '';
106
+ warn(
107
+ `unused-signal:${signalName}:${componentName || ''}`,
108
+ `[what] Warning: Signal '${signalName}' created${ctx} but never read.`
109
+ );
110
+ }