what-core 0.10.0 → 0.11.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/render.js CHANGED
@@ -9,15 +9,21 @@ import {
9
9
  isHydrating,
10
10
  mapArray,
11
11
  on,
12
+ setAttr,
13
+ setChecked,
14
+ setClass,
12
15
  setProp,
16
+ setStyle,
17
+ setValue,
13
18
  spread,
14
19
  svgTemplate,
15
20
  template
16
- } from "./chunk-M7UEET5O.js";
21
+ } from "./chunk-H3GA34JK.js";
17
22
  import {
18
23
  effect,
24
+ memo,
19
25
  untrack
20
- } from "./chunk-AW3BAPIK.js";
26
+ } from "./chunk-GZRA4IAJ.js";
21
27
  import "./chunk-AZP2EOGX.js";
22
28
  export {
23
29
  _$createComponent,
@@ -31,8 +37,14 @@ export {
31
37
  insert,
32
38
  isHydrating,
33
39
  mapArray,
40
+ memo,
34
41
  on,
42
+ setAttr,
43
+ setChecked,
44
+ setClass,
35
45
  setProp,
46
+ setStyle,
47
+ setValue,
36
48
  spread,
37
49
  svgTemplate,
38
50
  template,
@@ -1,2 +1,2 @@
1
- import{a as c,b as d,c as e,d as f,e as g,f as h,g as i,h as j,i as k,j as l,k as m,l as n,m as o,n as p}from"./chunk-RN6QIBWL.min.js";import{e as a,i as b}from"./chunk-KBM6CWG4.min.js";import"./chunk-O3SKPRTY.min.js";export{d as _$createComponent,e as _$template,c as _setTextInsertHook,f as _template,n as classList,l as delegateEvents,a as effect,p as hydrate,h as insert,o as isHydrating,i as mapArray,m as on,k as setProp,j as spread,g as svgTemplate,f as template,b as untrack};
1
+ import{a as d,b as e,c as f,d as g,e as h,f as i,g as j,h as k,i as l,j as m,k as n,l as o,m as p,n as q,o as r,p as s,q as t,r as u,s as v}from"./chunk-RI7T5VFD.min.js";import{e as a,g as b,i as c}from"./chunk-MH7L756Y.min.js";import"./chunk-O3SKPRTY.min.js";export{e as _$createComponent,f as _$template,d as _setTextInsertHook,g as _template,t as classList,r as delegateEvents,a as effect,v as hydrate,i as insert,u as isHydrating,j as mapArray,b as memo,s as on,o as setAttr,q as setChecked,m as setClass,l as setProp,n as setStyle,p as setValue,k as spread,h as svgTemplate,g as template,c as untrack};
2
2
  //# sourceMappingURL=render.min.js.map
package/dist/testing.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  flushSync,
5
5
  mount,
6
6
  signal
7
- } from "./chunk-AW3BAPIK.js";
7
+ } from "./chunk-GZRA4IAJ.js";
8
8
  import {
9
9
  h
10
10
  } from "./chunk-AZP2EOGX.js";
@@ -1,2 +1,2 @@
1
- import{J as m,c as f,e as p,h as w,l as b}from"./chunk-KBM6CWG4.min.js";import{a as h}from"./chunk-O3SKPRTY.min.js";var l=null;function E(){return typeof document<"u"&&(l=document.createElement("div"),l.id="test-root",document.body.appendChild(l)),l}function v(){l&&(l.innerHTML="",l.parentNode&&l.parentNode.removeChild(l),l=null)}function N(e,t={}){let{container:n}=t,o=n||E();if(!o)throw new Error("No DOM container available. Are you running in Node.js without jsdom?");let s=m(e,o);return{container:o,unmount:s,getByText:r=>d(o,r),getByTestId:r=>o.querySelector(`[data-testid="${r}"]`),getByRole:r=>o.querySelector(`[role="${r}"]`),getAllByText:r=>g(o,r),queryByText:r=>d(o,r),queryByTestId:r=>o.querySelector(`[data-testid="${r}"]`),debug:()=>console.log(o.innerHTML),findByText:(r,u)=>y(()=>d(o,r),{timeout:u}),findByTestId:(r,u)=>y(()=>o.querySelector(`[data-testid="${r}"]`),{timeout:u})}}function q(e,t){let n=E();if(!n)throw new Error("No DOM container available. Are you running in Node.js without jsdom?");let o={},s=null,r;return b(u=>{s=u;let c=h(e,t||{});r=m(c,n)}),{container:n,signals:new Proxy(o,{get(u,c){if(c in u)return u[c]},set(u,c,i){return u[c]=i,!0}}),update(){w()},unmount(){r&&r(),s&&s(),v()},getByText:u=>d(n,u),getByTestId:u=>n.querySelector(`[data-testid="${u}"]`),queryByText:u=>d(n,u),debug:()=>console.log(n.innerHTML)}}function C(){w()}function M(e){let t=[],n=[],o=f,s=new Map,r=i=>{t.includes(i)||t.push(i)},u=i=>{n.includes(i)||n.push(i)},c;return b(i=>{c=i;let a=p(()=>{e()})}),c&&c(),{accessed:t,written:n}}function D(e,t){let n=[t],o=0,s=f(t,e),r=s.set;s.set=function(i){let a=typeof i=="function"?i(s.peek()):i;return Object.is(s.peek(),a)||(o++,n.push(a)),r(a)};let u=s,c=function(...i){if(i.length===0)return u();let a=typeof i[0]=="function"?i[0](u.peek()):i[0];return Object.is(u.peek(),a)||(o++,n.push(a)),u(a)};return c._signal=!0,c.peek=s.peek,c.set=s.set,c.subscribe=s.subscribe,s._debugName&&(c._debugName=s._debugName),s._subs&&(c._subs=s._subs),Object.defineProperty(c,"history",{get(){return n}}),Object.defineProperty(c,"setCount",{get(){return o}}),c.reset=function(i){let a=i!==void 0?i:t;n.length=0,n.push(a),o=0,u(a)},c}function d(e,t){let n=t instanceof RegExp?t:null,o=document.createTreeWalker(e,NodeFilter.SHOW_TEXT,null,!1);for(;o.nextNode();){let s=o.currentNode;if(n?n.test(s.textContent):s.textContent.includes(t))return s.parentElement}return null}function g(e,t){let n=[],o=t instanceof RegExp?t:null,s=document.createTreeWalker(e,NodeFilter.SHOW_TEXT,null,!1);for(;s.nextNode();){let r=s.currentNode;(o?o.test(r.textContent):r.textContent.includes(t))&&n.push(r.parentElement)}return n}var H={click(e){let t=new MouseEvent("click",{bubbles:!0,cancelable:!0,view:typeof window<"u"?window:void 0});return e.dispatchEvent(t),t},change(e,t){e.value=t;let n=new Event("input",{bubbles:!0});e.dispatchEvent(n);let o=new Event("change",{bubbles:!0});return e.dispatchEvent(o),o},input(e,t){e.value=t;let n=new Event("input",{bubbles:!0});return e.dispatchEvent(n),n},submit(e){let t=new Event("submit",{bubbles:!0,cancelable:!0});return e.dispatchEvent(t),t},focus(e){e.focus();let t=new FocusEvent("focus",{bubbles:!0});return e.dispatchEvent(t),t},blur(e){e.blur();let t=new FocusEvent("blur",{bubbles:!0});return e.dispatchEvent(t),t},keyDown(e,t,n={}){let o=new KeyboardEvent("keydown",{bubbles:!0,cancelable:!0,key:t,...n});return e.dispatchEvent(o),o},keyUp(e,t,n={}){let o=new KeyboardEvent("keyup",{bubbles:!0,cancelable:!0,key:t,...n});return e.dispatchEvent(o),o},mouseEnter(e){let t=new MouseEvent("mouseenter",{bubbles:!0});return e.dispatchEvent(t),t},mouseLeave(e){let t=new MouseEvent("mouseleave",{bubbles:!0});return e.dispatchEvent(t),t}};async function y(e,t={}){let{timeout:n=1e3,interval:o=50}=t,s=Date.now();for(;Date.now()-s<n;){try{let r=e();if(r)return r}catch{}await new Promise(r=>setTimeout(r,o))}throw new Error(`waitFor timed out after ${n}ms`)}async function O(e,t={}){let{timeout:n=1e3,interval:o=50}=t,s=Date.now(),r=e();if(!r)throw new Error("Element not found");for(;Date.now()-s<n;){if(r=e(),!r)return;await new Promise(u=>setTimeout(u,o))}throw new Error(`Element still present after ${n}ms`)}async function _(e){let t=await e();return w(),await new Promise(n=>queueMicrotask(n)),await new Promise(n=>setTimeout(n,0)),t}function F(e){let t=f(e),n=[e];return p(()=>{n.push(t())}),{signal:t,get value(){return t()},set value(o){t.set(o)},history:n,reset(){n.length=0,n.push(t())}}}function R(e="MockComponent"){let t=[];function n(o){return t.push({props:o,timestamp:Date.now()}),h("div",{"data-testid":`mock-${e}`},JSON.stringify(o,null,2))}return n.displayName=e,n.calls=t,n.lastCall=()=>t[t.length-1],n.reset=()=>{t.length=0},n}var j={toBeInTheDocument(e){if(!e||!e.parentNode)throw new Error("Expected element to be in the document")},toHaveTextContent(e,t){if(!e)throw new Error("Element not found");let n=e.textContent;if(!(t instanceof RegExp?t.test(n):n.includes(t)))throw new Error(`Expected "${n}" to contain "${t}"`)},toHaveAttribute(e,t,n){if(!e)throw new Error("Element not found");let o=e.getAttribute(t);if(n!==void 0&&o!==n)throw new Error(`Expected attribute "${t}" to be "${n}", got "${o}"`);if(n===void 0&&o===null)throw new Error(`Expected element to have attribute "${t}"`)},toHaveClass(e,t){if(!e)throw new Error("Element not found");if(!e.classList.contains(t))throw new Error(`Expected element to have class "${t}"`)},toBeVisible(e){if(!e)throw new Error("Element not found");let t=window.getComputedStyle(e);if(t.display==="none"||t.visibility==="hidden"||t.opacity==="0")throw new Error("Expected element to be visible")},toBeDisabled(e){if(!e)throw new Error("Element not found");if(!e.disabled)throw new Error("Expected element to be disabled")},toHaveValue(e,t){if(!e)throw new Error("Element not found");if(e.value!==t)throw new Error(`Expected value to be "${t}", got "${e.value}"`)}},A={getByText:e=>d(document.body,e),getByTestId:e=>document.querySelector(`[data-testid="${e}"]`),getByRole:e=>document.querySelector(`[role="${e}"]`),getAllByText:e=>g(document.body,e),queryByText:e=>d(document.body,e),queryByTestId:e=>document.querySelector(`[data-testid="${e}"]`),debug:()=>console.log(document.body.innerHTML)};export{_ as act,v as cleanup,F as createTestSignal,j as expect,H as fireEvent,C as flushEffects,R as mockComponent,D as mockSignal,N as render,q as renderTest,A as screen,E as setupDOM,M as trackSignals,y as waitFor,O as waitForElementToBeRemoved};
1
+ import{J as m,c as f,e as p,h as w,l as b}from"./chunk-MH7L756Y.min.js";import{a as h}from"./chunk-O3SKPRTY.min.js";var l=null;function E(){return typeof document<"u"&&(l=document.createElement("div"),l.id="test-root",document.body.appendChild(l)),l}function v(){l&&(l.innerHTML="",l.parentNode&&l.parentNode.removeChild(l),l=null)}function N(e,t={}){let{container:n}=t,o=n||E();if(!o)throw new Error("No DOM container available. Are you running in Node.js without jsdom?");let s=m(e,o);return{container:o,unmount:s,getByText:r=>d(o,r),getByTestId:r=>o.querySelector(`[data-testid="${r}"]`),getByRole:r=>o.querySelector(`[role="${r}"]`),getAllByText:r=>g(o,r),queryByText:r=>d(o,r),queryByTestId:r=>o.querySelector(`[data-testid="${r}"]`),debug:()=>console.log(o.innerHTML),findByText:(r,u)=>y(()=>d(o,r),{timeout:u}),findByTestId:(r,u)=>y(()=>o.querySelector(`[data-testid="${r}"]`),{timeout:u})}}function q(e,t){let n=E();if(!n)throw new Error("No DOM container available. Are you running in Node.js without jsdom?");let o={},s=null,r;return b(u=>{s=u;let c=h(e,t||{});r=m(c,n)}),{container:n,signals:new Proxy(o,{get(u,c){if(c in u)return u[c]},set(u,c,i){return u[c]=i,!0}}),update(){w()},unmount(){r&&r(),s&&s(),v()},getByText:u=>d(n,u),getByTestId:u=>n.querySelector(`[data-testid="${u}"]`),queryByText:u=>d(n,u),debug:()=>console.log(n.innerHTML)}}function C(){w()}function M(e){let t=[],n=[],o=f,s=new Map,r=i=>{t.includes(i)||t.push(i)},u=i=>{n.includes(i)||n.push(i)},c;return b(i=>{c=i;let a=p(()=>{e()})}),c&&c(),{accessed:t,written:n}}function D(e,t){let n=[t],o=0,s=f(t,e),r=s.set;s.set=function(i){let a=typeof i=="function"?i(s.peek()):i;return Object.is(s.peek(),a)||(o++,n.push(a)),r(a)};let u=s,c=function(...i){if(i.length===0)return u();let a=typeof i[0]=="function"?i[0](u.peek()):i[0];return Object.is(u.peek(),a)||(o++,n.push(a)),u(a)};return c._signal=!0,c.peek=s.peek,c.set=s.set,c.subscribe=s.subscribe,s._debugName&&(c._debugName=s._debugName),s._subs&&(c._subs=s._subs),Object.defineProperty(c,"history",{get(){return n}}),Object.defineProperty(c,"setCount",{get(){return o}}),c.reset=function(i){let a=i!==void 0?i:t;n.length=0,n.push(a),o=0,u(a)},c}function d(e,t){let n=t instanceof RegExp?t:null,o=document.createTreeWalker(e,NodeFilter.SHOW_TEXT,null,!1);for(;o.nextNode();){let s=o.currentNode;if(n?n.test(s.textContent):s.textContent.includes(t))return s.parentElement}return null}function g(e,t){let n=[],o=t instanceof RegExp?t:null,s=document.createTreeWalker(e,NodeFilter.SHOW_TEXT,null,!1);for(;s.nextNode();){let r=s.currentNode;(o?o.test(r.textContent):r.textContent.includes(t))&&n.push(r.parentElement)}return n}var H={click(e){let t=new MouseEvent("click",{bubbles:!0,cancelable:!0,view:typeof window<"u"?window:void 0});return e.dispatchEvent(t),t},change(e,t){e.value=t;let n=new Event("input",{bubbles:!0});e.dispatchEvent(n);let o=new Event("change",{bubbles:!0});return e.dispatchEvent(o),o},input(e,t){e.value=t;let n=new Event("input",{bubbles:!0});return e.dispatchEvent(n),n},submit(e){let t=new Event("submit",{bubbles:!0,cancelable:!0});return e.dispatchEvent(t),t},focus(e){e.focus();let t=new FocusEvent("focus",{bubbles:!0});return e.dispatchEvent(t),t},blur(e){e.blur();let t=new FocusEvent("blur",{bubbles:!0});return e.dispatchEvent(t),t},keyDown(e,t,n={}){let o=new KeyboardEvent("keydown",{bubbles:!0,cancelable:!0,key:t,...n});return e.dispatchEvent(o),o},keyUp(e,t,n={}){let o=new KeyboardEvent("keyup",{bubbles:!0,cancelable:!0,key:t,...n});return e.dispatchEvent(o),o},mouseEnter(e){let t=new MouseEvent("mouseenter",{bubbles:!0});return e.dispatchEvent(t),t},mouseLeave(e){let t=new MouseEvent("mouseleave",{bubbles:!0});return e.dispatchEvent(t),t}};async function y(e,t={}){let{timeout:n=1e3,interval:o=50}=t,s=Date.now();for(;Date.now()-s<n;){try{let r=e();if(r)return r}catch{}await new Promise(r=>setTimeout(r,o))}throw new Error(`waitFor timed out after ${n}ms`)}async function O(e,t={}){let{timeout:n=1e3,interval:o=50}=t,s=Date.now(),r=e();if(!r)throw new Error("Element not found");for(;Date.now()-s<n;){if(r=e(),!r)return;await new Promise(u=>setTimeout(u,o))}throw new Error(`Element still present after ${n}ms`)}async function _(e){let t=await e();return w(),await new Promise(n=>queueMicrotask(n)),await new Promise(n=>setTimeout(n,0)),t}function F(e){let t=f(e),n=[e];return p(()=>{n.push(t())}),{signal:t,get value(){return t()},set value(o){t.set(o)},history:n,reset(){n.length=0,n.push(t())}}}function R(e="MockComponent"){let t=[];function n(o){return t.push({props:o,timestamp:Date.now()}),h("div",{"data-testid":`mock-${e}`},JSON.stringify(o,null,2))}return n.displayName=e,n.calls=t,n.lastCall=()=>t[t.length-1],n.reset=()=>{t.length=0},n}var j={toBeInTheDocument(e){if(!e||!e.parentNode)throw new Error("Expected element to be in the document")},toHaveTextContent(e,t){if(!e)throw new Error("Element not found");let n=e.textContent;if(!(t instanceof RegExp?t.test(n):n.includes(t)))throw new Error(`Expected "${n}" to contain "${t}"`)},toHaveAttribute(e,t,n){if(!e)throw new Error("Element not found");let o=e.getAttribute(t);if(n!==void 0&&o!==n)throw new Error(`Expected attribute "${t}" to be "${n}", got "${o}"`);if(n===void 0&&o===null)throw new Error(`Expected element to have attribute "${t}"`)},toHaveClass(e,t){if(!e)throw new Error("Element not found");if(!e.classList.contains(t))throw new Error(`Expected element to have class "${t}"`)},toBeVisible(e){if(!e)throw new Error("Element not found");let t=window.getComputedStyle(e);if(t.display==="none"||t.visibility==="hidden"||t.opacity==="0")throw new Error("Expected element to be visible")},toBeDisabled(e){if(!e)throw new Error("Element not found");if(!e.disabled)throw new Error("Expected element to be disabled")},toHaveValue(e,t){if(!e)throw new Error("Element not found");if(e.value!==t)throw new Error(`Expected value to be "${t}", got "${e.value}"`)}},A={getByText:e=>d(document.body,e),getByTestId:e=>document.querySelector(`[data-testid="${e}"]`),getByRole:e=>document.querySelector(`[role="${e}"]`),getAllByText:e=>g(document.body,e),queryByText:e=>d(document.body,e),queryByTestId:e=>document.querySelector(`[data-testid="${e}"]`),debug:()=>console.log(document.body.innerHTML)};export{_ as act,v as cleanup,F as createTestSignal,j as expect,H as fireEvent,C as flushEffects,R as mockComponent,D as mockSignal,N as render,q as renderTest,A as screen,E as setupDOM,M as trackSignals,y as waitFor,O as waitForElementToBeRemoved};
2
2
  //# sourceMappingURL=testing.min.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-core",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "What Framework - Signal-based UI framework built for AI agents",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/render.d.ts CHANGED
@@ -10,3 +10,21 @@ export {
10
10
  effect,
11
11
  untrack,
12
12
  } from './index';
13
+
14
+ // Compiler-internal template alias — identical to template() but never
15
+ // dev-warns. Compiled output imports this (SPRINT v0.11 C5).
16
+ export function _$template(html: string): () => Element;
17
+
18
+ // Specialized attribute setters emitted by the compiler for statically-known
19
+ // attribute names (SPRINT v0.11 C2). Function values are treated as reactive
20
+ // accessors (wrapped in an effect), mirroring setProp.
21
+ export function setClass(el: Element, value: string | null | undefined | (() => any)): void;
22
+ export function setStyle(el: Element, value: string | object | null | undefined | (() => any)): void;
23
+ export function setAttr(el: Element, name: string, value: any): void;
24
+ export function setValue(el: Element, value: any): void;
25
+ export function setChecked(el: Element, value: any): void;
26
+
27
+ // Equality-gated eager memo (the reactive memo, NOT the component-HOC `memo`
28
+ // exported from the package index). Emitted by the compiler for branch
29
+ // memoization of conditional JSX (SPRINT v0.11 C1).
30
+ export function memo<T>(fn: () => T): (() => T) & { peek(): T };
@@ -6,8 +6,9 @@ import { __DEV__ } from './reactive.js';
6
6
  import { getCollectedErrors } from './errors.js';
7
7
 
8
8
  // --- Version ---
9
- // Read from package.json at build time; fallback to runtime constant.
10
- const VERSION = '0.6.0';
9
+ // Keep in sync with packages/core/package.json (checked by
10
+ // core/test/guardrails.test.js so it can't silently go stale again).
11
+ const VERSION = '0.11.0';
11
12
 
12
13
  // --- Component Registry ---
13
14
  // Tracks mounted components for agent inspection.
package/src/dom.js CHANGED
@@ -140,6 +140,22 @@ export function createDOM(vnode, parent, isSvg) {
140
140
  return vnode;
141
141
  }
142
142
 
143
+ // Self-managing list inserter (mapArray) — it owns its own end marker and
144
+ // reconciliation effect and expects to be called as (parent, marker), NOT as
145
+ // a zero-arg reactive accessor. Reached when a keyed `.map()` is the child of
146
+ // a fragment-as-root (e.g. `<>{items().map(...)}</>`), which the compiler
147
+ // lowers to a bare `_$mapArray(...)`. Without this special-case the generic
148
+ // function branch below would call vnode() with no parent and throw.
149
+ if (typeof vnode === 'function' && vnode._mapArray) {
150
+ const frag = document.createDocumentFragment();
151
+ const endMarker = document.createComment('/list-frag');
152
+ frag.appendChild(endMarker);
153
+ // The inserter inserts its content before `endMarker` within `frag`; once
154
+ // `frag` is appended to the real DOM the nodes (and endMarker) carry over.
155
+ vnode(frag, endMarker);
156
+ return frag;
157
+ }
158
+
143
159
  // Reactive function child — use comment markers (no wrapper element)
144
160
  // to avoid polluting the DOM and breaking CSS selectors like :first-child.
145
161
  if (typeof vnode === 'function') {
package/src/guardrails.js CHANGED
@@ -10,7 +10,6 @@ import { createWhatError, collectError } from './errors.js';
10
10
 
11
11
  const guardrails = {
12
12
  signalReadDetection: true,
13
- effectCycleDetection: true,
14
13
  componentNaming: true,
15
14
  importValidation: true,
16
15
  };
@@ -25,16 +24,19 @@ export function getGuardrailConfig() {
25
24
 
26
25
  // --- Guardrail 1: Signal Read Detection ---
27
26
  // Detect when a signal function reference is used where its value was intended.
28
- // This catches the pattern: <span>{count}</span> (should be count())
27
+ // This catches patterns like `Total: ${count}` or `count > 5` (should be count()).
29
28
  //
30
- // At runtime, we can detect this when a signal is coerced to string (via toString/valueOf)
31
- // and warn that it should be called.
29
+ // At runtime, we detect this when a signal is coerced to a string or number
30
+ // (via toString/valueOf) and warn that it should be called.
31
+ //
32
+ // Wiring: what-devtools calls this for every signal it registers (dev only),
33
+ // so any app with devtools installed gets the guardrail automatically.
34
+ // It can also be called manually: installSignalReadGuardrail(sig, 'name').
32
35
 
33
36
  export function installSignalReadGuardrail(signalFn, debugName) {
34
37
  if (!__DEV__ || !guardrails.signalReadDetection) return signalFn;
35
38
 
36
39
  // Override toString to catch template literal coercion
37
- const originalToString = signalFn.toString;
38
40
  signalFn.toString = function () {
39
41
  const err = createWhatError('MISSING_SIGNAL_READ', {
40
42
  signalName: debugName || '(unnamed)',
@@ -58,47 +60,16 @@ export function installSignalReadGuardrail(signalFn, debugName) {
58
60
  return signalFn;
59
61
  }
60
62
 
61
- // --- Guardrail 2: Enhanced Effect Cycle Detection ---
62
- // Track which signals an effect reads AND writes.
63
- // If an effect writes to a signal it reads, warn about the specific cycle.
64
-
65
- const effectWriteTracking = new WeakMap(); // effect -> Set of signal debug names
66
-
67
- export function trackEffectSignalWrite(effectRef, signalDebugName) {
68
- if (!__DEV__ || !guardrails.effectCycleDetection) return;
69
-
70
- if (!effectWriteTracking.has(effectRef)) {
71
- effectWriteTracking.set(effectRef, new Set());
72
- }
73
- effectWriteTracking.get(effectRef).add(signalDebugName);
74
- }
75
-
76
- export function checkEffectCycle(effectRef, readSignals) {
77
- if (!__DEV__ || !guardrails.effectCycleDetection) return null;
78
-
79
- const writes = effectWriteTracking.get(effectRef);
80
- if (!writes || writes.size === 0) return null;
81
-
82
- const overlapping = [];
83
- for (const sigName of readSignals) {
84
- if (writes.has(sigName)) {
85
- overlapping.push(sigName);
86
- }
87
- }
88
-
89
- if (overlapping.length > 0) {
90
- const err = createWhatError('INFINITE_EFFECT', {
91
- effectName: effectRef.fn?.name || '(anonymous)',
92
- signalName: overlapping.join(', '),
93
- });
94
- collectError(err);
95
- return err;
96
- }
97
-
98
- return null;
99
- }
63
+ // NOTE: an "effect cycle detection" guardrail (trackEffectSignalWrite /
64
+ // checkEffectCycle) used to live here, exported but never called by anything
65
+ // it was removed in v0.11 rather than shipped as a dead API. Reviving it
66
+ // requires hooks inside reactive.js: (1) a call on every signal WRITE while
67
+ // an effect is the active listener (to attribute writes to effects), and
68
+ // (2) a post-run callback with the effect's tracked read-set (to intersect
69
+ // reads with writes). reactive.js already detects runaway cascades at flush
70
+ // time (iteration cap), which covers the practical failure mode.
100
71
 
101
- // --- Guardrail 3: Component Naming ---
72
+ // --- Guardrail 2: Component Naming ---
102
73
  // Warn if a component function is not PascalCase.
103
74
 
104
75
  export function checkComponentName(name) {
@@ -119,7 +90,7 @@ function capitalize(str) {
119
90
  return str.charAt(0).toUpperCase() + str.slice(1);
120
91
  }
121
92
 
122
- // --- Guardrail 4: Import Validation ---
93
+ // --- Guardrail 3: Import Validation ---
123
94
  // Verify that all named imports from 'what-framework' are valid exports.
124
95
 
125
96
  const VALID_EXPORTS = new Set([
package/src/reactive.js CHANGED
@@ -287,6 +287,8 @@ function _updateLevel(e) {
287
287
  // Runs a function, auto-tracking signal reads. Re-runs when deps change.
288
288
  // Returns a dispose function.
289
289
 
290
+ const _noopDispose = () => {};
291
+
290
292
  export function effect(fn, opts) {
291
293
  const e = _createEffect(fn);
292
294
  e._level = 1;
@@ -303,6 +305,26 @@ export function effect(fn, opts) {
303
305
  _updateLevel(e);
304
306
  // Mark as stable after first run — subsequent re-runs skip cleanup/re-subscribe
305
307
  if (opts?.stable) e._stable = true;
308
+
309
+ // Zero-dependency release (SPRINT v0.11 C4): an effect that tracked zero
310
+ // signals on its first run can never be notified again — re-tracking only
311
+ // happens during a re-run, and a re-run requires a notification from a
312
+ // subscribed signal. The compiler conservatively wraps destructured props /
313
+ // imported accessors in effects; when those turn out to be plain values the
314
+ // effect is one-shot. If it also registered no cleanup, release it now:
315
+ // no dispose closure, no owner registration, nothing retained.
316
+ // - Effects that returned a cleanup keep full registration so the cleanup
317
+ // still runs on owner disposal.
318
+ // - onCleanup() callbacks register with currentRoot directly (not with the
319
+ // effect), so they are unaffected by this release.
320
+ // - untrack()/peek() reads inside the fn produce zero deps by design — the
321
+ // effect could never re-fire anyway, so releasing is safe.
322
+ if (e.deps.length === 0 && e._cleanup === null) {
323
+ e.disposed = true;
324
+ if (__DEV__ && __devtools) __devtools.onEffectDispose(e);
325
+ return _noopDispose;
326
+ }
327
+
306
328
  const dispose = () => _disposeEffect(e);
307
329
  // Register with current root for automatic cleanup
308
330
  if (currentRoot) {
package/src/render.js CHANGED
@@ -2,9 +2,13 @@
2
2
  // Solid-style rendering: components run once, signals create individual DOM effects.
3
3
  // No VDOM diffing — direct DOM manipulation with surgical signal-driven updates.
4
4
 
5
- import { effect, untrack, createRoot, _createItemScope, signal, __DEV__ } from './reactive.js';
5
+ import { effect, untrack, createRoot, _createItemScope, signal, memo, __DEV__ } from './reactive.js';
6
6
  import { createDOM, disposeTree, getCurrentComponent, getComponentStack, _setSelectValue } from './dom.js';
7
7
  export { effect, untrack };
8
+ // Re-export memo for compiled output (branch memoization: the compiler emits
9
+ // _$memo(() => cond) so conditional branches only re-create DOM when the
10
+ // condition value actually changes, not on every dependency write).
11
+ export { memo };
8
12
 
9
13
  // --- Generic text insertion hook ---
10
14
  // External text engines (e.g., what-text) register a callback here via
@@ -173,41 +177,44 @@ export function insert(parent, child, marker) {
173
177
  }
174
178
 
175
179
  if (typeof child === 'function') {
176
- // Fast path: if the first evaluation returns a string/number, optimistically
177
- // create a text node for direct updates. If the value type changes later
178
- // (e.g., text -> vnode), fall back to full reconcileInsert.
179
- const first = child();
180
- const t = typeof first;
181
- if (t === 'string' || t === 'number') {
182
- const textNode = document.createTextNode(String(first));
183
- const m = marker || null;
184
- if (m) parent.insertBefore(textNode, m);
185
- else parent.appendChild(textNode);
186
- if (_onTextInsert) _onTextInsert(parent, String(first));
187
- let current = textNode;
188
- let isTextFastPath = true;
189
- effect(() => {
190
- const val = child();
191
- const vt = typeof val;
192
- if (isTextFastPath && (vt === 'string' || vt === 'number')) {
193
- // Fast path: still text — update data directly (no allocations)
194
- const str = String(val);
195
- if (textNode.data !== str) textNode.data = str;
196
- if (_onTextInsert) _onTextInsert(parent, str);
197
- } else {
198
- // Type changed — fall back to full reconcile
199
- isTextFastPath = false;
200
- current = reconcileInsert(parent, val, current, m);
201
- }
202
- });
203
- return textNode;
204
- }
205
- // General path for non-text reactive children (first value was null/vnode/array).
206
- // Let the effect handle both the initial insert and subsequent updates to avoid
207
- // double-evaluating child() (which would create components twice on mount).
180
+ // Single-evaluation mount: child() is evaluated exactly ONCE at mount,
181
+ // inside the effect (so signal reads are tracked). The first run decides
182
+ // between the text fast path (direct textNode.data updates, zero
183
+ // allocations) and the general reconcile path. Previously the first
184
+ // evaluation happened outside the effect to pick the path, then the
185
+ // effect's first run re-evaluated child() — creating components twice
186
+ // on mount for non-text children. (SPRINT v0.11 C3)
187
+ const m = marker || null;
208
188
  let current = null;
189
+ let textNode = null; // non-null while on the text fast path
190
+ let mounted = false;
209
191
  effect(() => {
210
- current = reconcileInsert(parent, child(), current, marker || null);
192
+ const val = child();
193
+ const vt = typeof val;
194
+ if (!mounted) {
195
+ // First run — mount
196
+ mounted = true;
197
+ if (vt === 'string' || vt === 'number') {
198
+ textNode = document.createTextNode(String(val));
199
+ if (m) parent.insertBefore(textNode, m);
200
+ else parent.appendChild(textNode);
201
+ if (_onTextInsert) _onTextInsert(parent, String(val));
202
+ current = textNode;
203
+ } else {
204
+ current = reconcileInsert(parent, val, null, m);
205
+ }
206
+ return;
207
+ }
208
+ if (textNode !== null && (vt === 'string' || vt === 'number')) {
209
+ // Fast path: still text — update data directly (no allocations)
210
+ const str = String(val);
211
+ if (textNode.data !== str) textNode.data = str;
212
+ if (_onTextInsert) _onTextInsert(parent, str);
213
+ return;
214
+ }
215
+ // Type changed (or never was text) — full reconcile
216
+ textNode = null;
217
+ current = reconcileInsert(parent, val, current, m);
211
218
  });
212
219
  return current;
213
220
  }
@@ -417,10 +424,17 @@ export function mapArray(source, mapFn, options) {
417
424
 
418
425
  effect(() => {
419
426
  const newItems = source() || [];
427
+ // Resolve the LIVE parent from the end marker each run. When this inserter
428
+ // is mounted at a fragment-as-root (`<>{items().map(...)}</>`), createDOM
429
+ // calls it against a throwaway DocumentFragment which is then appended to
430
+ // the real container — the marker (and existing rows) move with it, so the
431
+ // captured `parent` goes stale. endMarker.parentNode always reflects where
432
+ // the list currently lives. Falls back to the captured parent pre-mount.
433
+ const liveParent = endMarker.parentNode || parent;
420
434
  if (keyFn) {
421
- reconcileKeyed(parent, endMarker, items, newItems, mappedNodes, disposeFns, mapFn, keyFn, keyedState);
435
+ reconcileKeyed(liveParent, endMarker, items, newItems, mappedNodes, disposeFns, mapFn, keyFn, keyedState);
422
436
  } else {
423
- reconcileList(parent, endMarker, items, newItems, mappedNodes, disposeFns, mapFn);
437
+ reconcileList(liveParent, endMarker, items, newItems, mappedNodes, disposeFns, mapFn);
424
438
  }
425
439
  // Save a snapshot of items for next diff. Use slice() to defend against
426
440
  // in-place mutation, but skip for empty arrays (common clear case).
@@ -1244,12 +1258,9 @@ export function spread(el, props) {
1244
1258
  else el.className = cls;
1245
1259
  });
1246
1260
  } else if (key === 'style' && typeof value() === 'object') {
1247
- el._propEffects[key] = effect(() => {
1248
- const styles = value();
1249
- for (const prop in styles) {
1250
- el.style[prop] = styles[prop] ?? '';
1251
- }
1252
- });
1261
+ // Route through setStyle so stale object keys are cleared between
1262
+ // re-evaluations (el._lastStyleObj diffing).
1263
+ el._propEffects[key] = effect(() => { setStyle(el, value()); });
1253
1264
  } else {
1254
1265
  el._propEffects[key] = effect(() => { setProp(el, key, value()); });
1255
1266
  }
@@ -1332,13 +1343,8 @@ export function setProp(el, key, value) {
1332
1343
  }
1333
1344
  }
1334
1345
  } else if (key === 'style') {
1335
- if (typeof value === 'string') {
1336
- el.style.cssText = value;
1337
- } else if (typeof value === 'object') {
1338
- for (const prop in value) {
1339
- el.style[prop] = value[prop] ?? '';
1340
- }
1341
- }
1346
+ // Delegate to setStyle so the object form clears stale keys (el._lastStyleObj).
1347
+ setStyle(el, value);
1342
1348
  } else if (key.startsWith('data-') || key.startsWith('aria-')) {
1343
1349
  el.setAttribute(key, value);
1344
1350
  } else if (typeof value === 'boolean') {
@@ -1355,6 +1361,99 @@ export function setProp(el, key, value) {
1355
1361
  }
1356
1362
  }
1357
1363
 
1364
+ // --- Specialized attribute setters (SPRINT v0.11 C2) ---
1365
+ // The compiler statically knows most attribute names, so it emits direct calls
1366
+ // to these monomorphic helpers instead of routing everything through the
1367
+ // generic setProp() dispatcher (which re-checks ref/key/url/class/style/...
1368
+ // string-compares on every reactive update). setProp() remains the target for
1369
+ // spreads, URL attributes (href/src/action — sanitization lives there) and any
1370
+ // name the compiler can't classify.
1371
+ //
1372
+ // Function values are reactive ACCESSORS (e.g. `value={() => user().name}`),
1373
+ // exactly like setProp treats them: wrap in an effect that re-applies the
1374
+ // resolved value, with the disposer registered on el._propEffects so
1375
+ // disposeTree() tears it down on unmount.
1376
+
1377
+ function _wrapPropAccessor(el, key, accessor, apply) {
1378
+ if (!el._propEffects) el._propEffects = {};
1379
+ if (el._propEffects[key]) {
1380
+ try { el._propEffects[key](); } catch (e) { /* already disposed */ }
1381
+ }
1382
+ el._propEffects[key] = effect(() => apply(el, accessor()));
1383
+ }
1384
+
1385
+ // class / className — hottest dynamic attribute in real apps.
1386
+ export function setClass(el, value) {
1387
+ if (typeof value === 'function') return _wrapPropAccessor(el, 'class', value, setClass);
1388
+ if (_hasSVGElement && el instanceof SVGElement) {
1389
+ el.setAttribute('class', value || '');
1390
+ } else {
1391
+ el.className = value || '';
1392
+ }
1393
+ }
1394
+
1395
+ // style — string (cssText) or object form.
1396
+ export function setStyle(el, value) {
1397
+ if (typeof value === 'function') return _wrapPropAccessor(el, 'style', value, setStyle);
1398
+ if (typeof value === 'string') {
1399
+ el.style.cssText = value;
1400
+ // cssText fully replaces inline styles — drop any tracked object so a later
1401
+ // object form starts clean rather than diffing against stale keys.
1402
+ el._lastStyleObj = null;
1403
+ } else if (value && typeof value === 'object') {
1404
+ const style = el.style;
1405
+ // Clear properties present in the previously-applied object but absent from
1406
+ // the new one. Without this, `style={() => cond() ? {color, fontWeight} :
1407
+ // {color}}` would leave fontWeight set after flipping to the second object.
1408
+ const prev = el._lastStyleObj;
1409
+ if (prev) {
1410
+ for (const prop in prev) {
1411
+ if (!(prop in value)) style[prop] = '';
1412
+ }
1413
+ }
1414
+ for (const prop in value) {
1415
+ style[prop] = value[prop] ?? '';
1416
+ }
1417
+ el._lastStyleObj = value;
1418
+ } else if (value == null) {
1419
+ el.style.cssText = '';
1420
+ el._lastStyleObj = null;
1421
+ }
1422
+ }
1423
+
1424
+ // Plain attribute set — used for data-*/aria-* (statically recognizable).
1425
+ // null/undefined removes the attribute (previously setProp stringified them
1426
+ // to "null"/"undefined" — removal is the correct semantic). Booleans are
1427
+ // stringified ("true"/"false") because aria-* boolean strings are meaningful.
1428
+ export function setAttr(el, name, value) {
1429
+ if (typeof value === 'function') {
1430
+ return _wrapPropAccessor(el, name, value, (e2, v) => setAttr(e2, name, v));
1431
+ }
1432
+ if (value == null) el.removeAttribute(name);
1433
+ else el.setAttribute(name, value);
1434
+ }
1435
+
1436
+ // value — controlled-input property set. <select> keeps multi/deferred-option
1437
+ // handling; other elements get a guarded property write (the !== guard avoids
1438
+ // resetting the caret position in focused inputs on unrelated re-runs).
1439
+ export function setValue(el, value) {
1440
+ if (typeof value === 'function') return _wrapPropAccessor(el, 'value', value, setValue);
1441
+ if (el.tagName === 'SELECT') {
1442
+ _setSelectValue(el, value);
1443
+ return;
1444
+ }
1445
+ const str = value == null ? '' : String(value);
1446
+ if (el.value !== str) el.value = str;
1447
+ }
1448
+
1449
+ // checked — live property write (matches bind:checked). The old generic path
1450
+ // used setAttribute('checked'), which only sets the DEFAULT-checked state and
1451
+ // stops reflecting once the user has toggled the input.
1452
+ export function setChecked(el, value) {
1453
+ if (typeof value === 'function') return _wrapPropAccessor(el, 'checked', value, setChecked);
1454
+ el.checked = !!value;
1455
+ }
1456
+
1358
1457
  // --- delegateEvents(eventNames) ---
1359
1458
  // Event delegation: common events handled at document level.
1360
1459
  // Handlers stored as el.$$click, el.$$input, etc.
@@ -1371,6 +1470,15 @@ export function delegateEvents(eventNames) {
1371
1470
  let node = e.target;
1372
1471
  const key = '$$' + name;
1373
1472
 
1473
+ // Shim e.currentTarget so handlers see the element the (virtual) listener
1474
+ // is attached to — not `document` — during the ancestor walk. Mirrors
1475
+ // Solid's delegation shim. configurable so nested dispatch can redefine.
1476
+ // (SPRINT v0.11 C9)
1477
+ Object.defineProperty(e, 'currentTarget', {
1478
+ configurable: true,
1479
+ get() { return node || document; },
1480
+ });
1481
+
1374
1482
  // Walk up the DOM tree looking for handlers
1375
1483
  while (node) {
1376
1484
  const handler = node[key];
@@ -1659,12 +1767,8 @@ function hydrateElementProps(el, props) {
1659
1767
  if (key === 'class' || key === 'className') {
1660
1768
  effect(() => { el.className = value() || ''; });
1661
1769
  } else if (key === 'style' && typeof value() === 'object') {
1662
- effect(() => {
1663
- const styles = value();
1664
- for (const prop in styles) {
1665
- el.style[prop] = styles[prop] ?? '';
1666
- }
1667
- });
1770
+ // Route through setStyle so stale object keys are cleared (el._lastStyleObj).
1771
+ effect(() => { setStyle(el, value()); });
1668
1772
  } else {
1669
1773
  effect(() => { setProp(el, key, value()); });
1670
1774
  }