@firsthandjs/devtools 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Firsthand contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @firsthandjs/devtools
2
+
3
+ > **Experimental.** This package is new and its shape is still moving. The
4
+ > names, the returned structures and the panel will change without a major
5
+ > version while that is true, and nothing else in the framework depends on it.
6
+
7
+ See which signal updates which DOM node, what depends on what, and why an
8
+ effect ran.
9
+
10
+ ```bash
11
+ npm install --save-dev @firsthandjs/devtools
12
+ ```
13
+
14
+ ```ts
15
+ // main.tsx, above everything else
16
+ import { attach } from '@firsthandjs/devtools';
17
+
18
+ if (import.meta.env.DEV) {
19
+ attach();
20
+ }
21
+ ```
22
+
23
+ Then, in the browser:
24
+
25
+ Press **Ctrl+Shift+F**, or:
26
+
27
+ ```js
28
+ __FIRSTHAND__.panel(); // a panel: pick an element, see what writes it and why
29
+ ```
30
+
31
+ Or from the console, on whatever the Elements panel has selected — no import,
32
+ because a console cannot resolve a bare specifier:
33
+
34
+ ```js
35
+ __FIRSTHAND__.chain($0);
36
+ __FIRSTHAND__.causeOf($0);
37
+ __FIRSTHAND__.queries();
38
+ ```
39
+
40
+ And from a test or a module, where the types apply:
41
+
42
+ ```ts
43
+ import { chain, inspect, causeOf } from '@firsthandjs/devtools';
44
+
45
+ chain(document.querySelector('button'));
46
+ // order.ts:12:19
47
+ // ↓
48
+ // computed(isEditable)
49
+ // ↓
50
+ // button.disabled
51
+
52
+ causeOf(document.querySelector('button'));
53
+ // 'order.ts:12:19' — what changed to make it run
54
+ ```
55
+
56
+ `inspect(node)` returns the same thing as data: each part that writes the node,
57
+ with its dependencies and their dependencies, as far down as you ask.
58
+ `cells()` lists every computed and effect currently alive.
59
+
60
+ ## What it costs
61
+
62
+ The framework's side of it ships nothing: the hooks this reads live in modules
63
+ the production build replaces with empty functions, so a shipped bundle
64
+ contains neither that code nor its strings, whether or not you use devtools.
65
+
66
+ This package is not stripped, though — it is an ordinary module, so an
67
+ unconditional import puts it in your production bundle. Guard it if that
68
+ matters:
69
+
70
+ ```ts
71
+ if (import.meta.env.DEV) {
72
+ const { attach } = await import('@firsthandjs/devtools');
73
+ attach();
74
+ }
75
+ ```
76
+
77
+ An import pulls in 1.96 kB gzip; the panel is a further 4.83 kB, loaded when it
78
+ is opened.
79
+
80
+ In development it records nothing until `attach()` is called, because naming
81
+ every cell costs a `WeakMap` write and a hundred thousand rows would feel it.
82
+
83
+ ## How it works
84
+
85
+ It does not instrument anything. The reactive graph is already there, because
86
+ propagation and disposal need it: every cell carries its dependencies and its
87
+ subscribers, and every owner carries its children. This package attaches names
88
+ to those nodes and reads the structure when asked.
89
+
90
+ **Documentation:** [guide](../../docs/guide/14-devtools.md) ·
91
+ [API reference](../../docs/reference/devtools.md) ·
92
+ [ADR-0020](../../docs/adr/0020-devtools-without-a-runtime-cost.md) for the
93
+ reasoning.
94
+
95
+ ## Licence
96
+
97
+ MIT
@@ -0,0 +1,7 @@
1
+ var m=new WeakMap,L=new WeakMap,s=new WeakMap,v=new WeakMap,w=new WeakMap,u=[],i=[],T=100,l=null,j=200,p=new Set,a=null,b=null,d=null,f=null,c=null,_=1,y=/@firsthandjs|[\\/]node_modules[\\/]|[\\/]packages[\\/](core|dom|deep|devtools|jsx-runtime|query|router|styled|testing)[\\/]src[\\/]/;function D(){let t=(new Error().stack??"").split(`
2
+ `).slice(1),e=[];for(let n of t){if(y.test(n))continue;let o=n.trim().replace(/^at\s+/,"");if(o!==""&&e.push(o),e.length===6)break}return e}function E(){let e=(new Error().stack??"").split(`
3
+ `).slice(1).find(o=>!y.test(o))??"unknown",n=/\(?([^()\s]+:\d+:\d+)\)?$/.exec(e.trim());return n===null?e.trim():n[1]}function U(){if(c!==null)return;let t={attached:!0,label(e,n,o){m.set(e,{kind:n,name:o===""?E():o})},cause(e){f=e,l={update:{at:Math.round(performance.now()),source:h(e),ran:[],stack:D()},effects:[]},i.push(l),i.length>T&&i.shift()},root(e){p.add(new WeakRef(e))},component(e,n){w.set(e,n)},running(e){a=e,e===null&&b?.(),e!==null&&f!==null&&(v.set(e,f),l?.update.ran.push(h(e)),l?.effects.push(e))},query(e,n,o){u.push({event:e,key:n,tags:o}),u.length>j&&u.shift()},part(e,n){if(a===null)return;L.set(a,{node:e,property:n});let o=s.get(e);o===void 0&&(o=new Set,s.set(e,o)),o.add(a)}};globalThis.__FIRSTHAND_DEVTOOLS__=t,c=t,d=e=>{e.ctrlKey&&e.shiftKey&&e.key.toLowerCase()==="f"&&(e.preventDefault(),import("./chunk-7BUP5FXQ.js").then(n=>{n.toggle()}))},globalThis.addEventListener("keydown",d),console.info("[firsthand] devtools attached \u2014 press Ctrl+Shift+F for the panel, or call __FIRSTHAND__.panel()"),globalThis.__FIRSTHAND__={chain:A,inspect:C,causeOf:K,stack:F,timeline:W,cells:I,queries:M,detach:S,panel:e=>{import("./chunk-7BUP5FXQ.js").then(n=>{e===void 0?n.open():n.show(e)})}}}function S(){c!==null&&(c.attached=!1),globalThis.__FIRSTHAND_DEVTOOLS__=void 0,delete globalThis.__FIRSTHAND__,d!==null&&(globalThis.removeEventListener("keydown",d),d=null),c=null,a=null,f=null,l=null,i.length=0,b=null,u.length=0,p.clear()}function h(t){let e=L.get(t);if(e!==void 0){let o=e.node;return`${(o.tagName??o.nodeName??"node").toLowerCase()}.${e.property}`}let n=m.get(t);return n===void 0?(t.flags&_)===0?"signal":"computed":n.name}function G(t){return L.has(t)?"part":m.get(t)?.kind??((t.flags&_)===0?"signal":"computed")}function g(t,e,n){let o={kind:G(t),name:h(t),value:t.v,dependencies:[],dependents:[]};if(e>0)for(let r=t.deps;r!==void 0;r=r.nextDep)o.dependencies.push(g(r.dep,e-1,0));if(n>0)for(let r=t.subs;r!==void 0;r=r.nextSub)o.dependents.push(g(r.sub,0,n-1));return o}function R(){let t=[];for(let e of p){let n=e.deref();n===void 0?p.delete(e):t.push(n)}return t}function x(t,e){t.cells!==null&&e.push(...t.cells);for(let n=t.head;n!==null;n=n.next)x(n,e)}function I(){let t=[];for(let e of R())x(e,t);return t.map(e=>g(e,1,1))}function C(t,e=8){let n=s.get(t);return n===void 0?[]:[...n].map(o=>g(o,e,0))}function A(t){let e=C(t);return e.length===0?"Nothing reactive writes this node.":e.map(n=>H(O(n)).join(`
4
+ \u2193
5
+ `)).join(`
6
+
7
+ `)}function O(t){let e=[];for(let n of t.dependencies){let o=O(n);o.length>e.length&&(e=o)}return[...e,t]}function H(t){return t.map(e=>e.kind==="computed"?`computed(${e.name})`:e.name)}function K(t){let e=s.get(t);if(e===void 0)return null;for(let n of e){let o=v.get(n);if(o!==void 0)return h(o)}return null}function M(){return[...u]}function q(t){b=t}function F(t){let e=s.get(t);if(e===void 0)return[];let n=[];for(let o of e){let r=o.scope??null,k=[];for(;r!==null;){let N=w.get(r);N!==void 0&&k.unshift(N),r=r.parent}k.length>n.length&&(n.length=0,n.push(...k))}return n}function W(t){if(t===void 0)return i.map(n=>n.update);let e=s.get(t);return e===void 0?[]:i.filter(n=>n.effects.some(o=>e.has(o))).map(n=>n.update)}export{U as a,S as b,I as c,C as d,A as e,O as f,K as g,M as h,q as i,F as j,W as k};
@@ -0,0 +1,86 @@
1
+ import{d as $,f as q,g as H,h as C,i as L,j as A,k as T}from"./chunk-5XEHZQPH.js";var J="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";function Q(e){let t=[],r=0,n=0;for(let o of e){let a=J.indexOf(o),i=a&32;if(n+=(a&31)<<r,i===0){let s=n&1;n>>=1,t.push(s===1?-n:n),r=0,n=0}else r+=5}return t}function W(e){let t=[],r=0,n=0,o=0;for(let[a,i]of e.mappings.split(";").entries()){let s=0;for(let u of i.split(",")){if(u==="")continue;let p=Q(u);if(s+=p[0],p.length<4)continue;r+=p[1],n+=p[2],o+=p[3];let m=e.sources[r];m!=null&&t.push({generatedLine:a,generatedColumn:s,source:m,originalLine:n,originalColumn:o})}}return t}var P=new Map,B=/\/\/[#@]\s*sourceMappingURL=data:application\/json;(?:charset=[^;]+;)?base64,([\w+/=]+)/;async function D(e){let t=P.get(e);if(t!==void 0)return t;let r=(async()=>{try{let o=await(await fetch(e)).text(),a=B.exec(o);if(a===null)return null;let i=JSON.parse(atob(a[1]));return W(i)}catch{return null}})();return P.set(e,r),r}function Y(e,t,r){let n=null;for(let o of e){if(o.generatedLine>t||o.generatedLine===t&&o.generatedColumn>r)break;o.generatedLine===t&&(n=o)}return n}async function O(e){let t=/^(.*?)\(?([^\s()]+):(\d+):(\d+)\)?$/.exec(e.trim().replace(/^at\s+/,""));if(t===null)return e;let[,r,n,o,a]=t;if(!/^https?:\/\//.test(n))return e;let i=await D(n);if(i===null)return e;let s=Y(i,Number(o)-1,Number(a)-1);if(s===null)return e;let p=`${s.source.slice(Math.max(s.source.lastIndexOf("/"),s.source.lastIndexOf("\\"))+1)}:${String(s.originalLine+1)}:${String(s.originalColumn+1)}`;return r.trim()===""?p:`${r.trim()} (${p})`}var K=`
2
+ :host { all: initial; }
3
+ .panel {
4
+ position: fixed; right: 16px; bottom: 16px; z-index: 2147483647;
5
+ width: 460px; max-height: 78vh; display: flex; flex-direction: column;
6
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
7
+ color: #e6e6e6; background: #1c1c1f; border: 1px solid #3a3a40;
8
+ border-radius: 10px; box-shadow: 0 10px 40px rgb(0 0 0 / 0.45);
9
+ }
10
+ header { display: flex; align-items: center; gap: 6px; padding: 8px 10px;
11
+ border-bottom: 1px solid #3a3a40; }
12
+ header strong { font-weight: 600; letter-spacing: 0.02em; flex: 1; }
13
+ button { font: inherit; color: inherit; background: #2a2a30; border: 1px solid #45454d;
14
+ border-radius: 5px; padding: 3px 8px; cursor: pointer; }
15
+ button:hover { background: #34343c; }
16
+ button[aria-pressed='true'] { background: #3d5afe; border-color: #3d5afe; color: #fff; }
17
+ .body { display: flex; flex-direction: column; overflow: hidden; padding: 12px; min-height: 0; }
18
+ /* The list scrolls; the detail stays where it can be read. Without this the
19
+ call stack sits below everything and is only reachable by scrolling past
20
+ the whole log \u2014 which is exactly when you least want to. */
21
+ /* Two independent scroll areas: the list above, the detail below. Each gets
22
+ its own, because a call stack that can only be reached by scrolling past
23
+ forty rows of log is out of reach exactly when it is wanted. */
24
+ .scroll { overflow-y: auto; overflow-x: hidden; flex: 1 1 auto; min-height: 60px; }
25
+ .pinned { flex: 0 1 auto; min-height: 140px; max-height: 65%; overflow-y: auto;
26
+ overflow-x: hidden; margin-top: 8px; padding-top: 8px;
27
+ border-top: 1px solid #3a3a40; }
28
+ .detail-head { display: flex; align-items: center; gap: 8px; }
29
+ .detail-head .hint { margin: 0; flex: 1; }
30
+ .detail-head button { padding: 0 6px; line-height: 1.4; }
31
+ .empty { color: #8a8a94; }
32
+ .hint { color: #7c7c88; margin: 14px 0 6px; font-size: 10px; text-transform: uppercase;
33
+ letter-spacing: 0.1em; }
34
+ .hint:first-child { margin-top: 0; }
35
+ .section + .section { margin-top: 2px; }
36
+ .stack { margin: 4px 0 0; }
37
+ .stack div { color: #9a9aa6; padding-left: 10px; border-left: 1px solid #3a3a40; }
38
+ .stack div:first-child { color: #e6e6e6; }
39
+ .filters { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
40
+ .filters .chip { background: #2a2a30; border: 1px solid #45454d; border-radius: 999px;
41
+ padding: 1px 9px; cursor: pointer; color: #9ecbff; }
42
+ .filters .chip[aria-pressed='true'] { background: #3d5afe; border-color: #3d5afe; color: #fff; }
43
+ .count { color: #7c7c88; }
44
+
45
+ /* The path, as boxes and arrows rather than as three lines of text. */
46
+ .flow { display: flex; flex-direction: column; align-items: stretch; gap: 0; }
47
+ .box { border: 1px solid #45454d; border-radius: 7px; padding: 6px 9px; background: #232329;
48
+ display: flex; align-items: baseline; gap: 8px; }
49
+ .box .tag { font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em;
50
+ padding: 1px 5px; border-radius: 4px; background: #34343c; color: #b9b9c4; }
51
+ .box .label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
52
+ .box .val { color: #c3e88d; }
53
+ .box.signal { border-color: #3d5afe; }
54
+ .box.signal .tag { background: #23306b; color: #b6c4ff; }
55
+ .box.computed { border-color: #c792ea; }
56
+ .box.computed .tag { background: #3a2b47; color: #e6c8ff; }
57
+ .box.part { border-color: #ffcb6b; }
58
+ .box.part .tag { background: #4a3a1c; color: #ffdfa1; }
59
+ .box.trigger { box-shadow: 0 0 0 2px #3d5afe66; }
60
+ .arrow { align-self: center; color: #6a6a76; font-size: 14px; line-height: 1; padding: 3px 0; }
61
+
62
+ /* The component stack, as crumbs. */
63
+ .crumbs { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 10px; }
64
+ .crumb { background: #2a2a30; border: 1px solid #45454d; border-radius: 999px;
65
+ padding: 1px 8px; color: #c792ea; }
66
+ .crumb + .crumb::before { content: '\u203A'; color: #8a8a94; margin-right: 6px; margin-left: -4px; }
67
+
68
+ /* The timeline: one row per update, a bar for how much it woke. */
69
+ .track { display: flex; flex-direction: column; gap: 4px; }
70
+ .tick { display: grid; grid-template-columns: 52px minmax(0, 1fr) auto auto; gap: 8px;
71
+ align-items: center; padding: 3px 4px; border-radius: 5px; cursor: pointer; }
72
+ .tick:hover { background: #26262c; }
73
+ .tick[aria-selected='true'] { background: #23306b; }
74
+ .tick .when { color: #8a8a94; text-align: right; }
75
+ .tick .who { color: #9ecbff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
76
+ .tick .where { color: #7c7c88; white-space: nowrap; }
77
+ .tick .bar { height: 8px; border-radius: 4px; background: #3d5afe; min-width: 4px; }
78
+ .tick .bar.none { background: #4a4a54; }
79
+ .detail .ran { color: #ffdfa1; }
80
+ .cause { color: #ffcb6b; margin: 10px 0 0; }
81
+ .event { display: grid; grid-template-columns: 84px 1fr; gap: 8px; padding: 2px 0; }
82
+ .event .created { color: #c3e88d; }
83
+ .event .invalidated { color: #ffcb6b; }
84
+ .event .dropped { color: #f07178; }
85
+ .event .tags { color: #9ecbff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
86
+ `,b="firsthand-devtools-outline",d=null,E=null,h=null,v=!1,g="graph",k=null,c=null,M=new Map,w=null;function y(e,t){let r=document.createElement("button");return r.textContent=e,r.setAttribute("aria-pressed","false"),r.addEventListener("click",t),r}function l(e,t,r){let n=document.createElement(e);return n.className=t,n.textContent=r,n}function V(e,t){let r=document.createElement("div");r.className=`box ${e.kind}${t?" trigger":""}`,r.append(l("span","tag",e.kind)),r.append(x("span","label",e.name));let n=e.value;return n!==void 0&&(typeof n!="object"||n===null)&&r.append(l("span","val",JSON.stringify(n))),r}function X(e,t,r){let n=q(e),o=document.createElement("div");o.className="flow";for(let[a,i]of n.entries())o.append(V(i,i.name===t)),a<n.length-1&&o.append(l("div","arrow","\u2193"));r.append(o)}function Z(e){let t=document.createElement("div");if(t.className="scroll",e.append(t),h===null){t.append(l("p","empty","Nothing selected."),l("p","hint","Press \u201CPick\u201D and click an element."));return}let r=A(h);if(r.length>0){t.append(l("p","hint","Component"));let i=document.createElement("div");i.className="crumbs";for(let s of r)i.append(l("span","crumb",s));t.append(i)}let n=$(h);if(n.length===0){t.append(l("p","empty","Nothing reactive writes this node."));return}let o=H(h);t.append(l("p","hint","Path"));for(let i of n)X(i,o,t);t.append(l("p","cause",o===null?"Has not run since anything changed.":`Triggered by ${o}`));let a=T(h).slice(-6).reverse();a.length>0&&(t.append(l("p","hint",`Last ${String(a.length)} updates`)),t.append(F(a))),c!==null&&e.append(N(S(c)))}function F(e){M.clear();let t=Math.max(1,...e.map(n=>n.ran.length)),r=document.createElement("div");r.className="track";for(let n of e){let o=document.createElement("div");o.className="tick",o.setAttribute("aria-selected",String(n===c)),M.set(o,n),o.append(l("span","when",`${String(n.at)}ms`)),o.append(x("span","who",n.source));let a=document.createElement("span");a.className=n.ran.length===0?"bar none":"bar",a.style.width=`${String(Math.round(n.ran.length/t*60)+6)}px`,a.title=`woke ${String(n.ran.length)} of ${String(t)}`,o.append(a);let i=n.stack[0];i!==void 0&&o.append(x("span","where",i,_)),o.addEventListener("click",()=>{I(n)}),r.append(o)}return r}function U(e){let t=/^(.*?)\(?([^()\s]+):(\d+):(\d+)\)?$/.exec(e);if(t===null)return e;let[,r,n,o,a]=t,i=Math.max(n.lastIndexOf("/"),n.lastIndexOf("\\")),s=n.slice(i+1),u=s.indexOf("?"),m=`${(u===-1?s:s.slice(0,u)).trim()}:${o}:${a}`;return r.trim()===""?m:`${r.trim()} (${m})`}function _(e){let t=/\(([^()]+)\)\s*$/.exec(e);return(t===null?e:t[1]).trim()}function x(e,t,r,n=o=>o){let o=l(e,t,n(U(r)));return O(r).then(a=>{o.textContent=n(U(a))}),o}function N(e){return e.classList.add("pinned"),e}function S(e){let t=document.createElement("div");t.className="detail";let r=document.createElement("div");r.className="detail-head",r.append(x("p","hint",e.source,o=>`${o} woke ${String(e.ran.length)}`));let n=y("\xD7",()=>{I(e)});n.setAttribute("aria-label","Close detail"),r.append(n),t.append(r);for(let o of e.ran)t.append(x("div","ran",o));if(e.stack.length>0){t.append(l("p","hint","Written from"));let o=document.createElement("div");o.className="stack";for(let a of e.stack)o.append(x("div","",a));t.append(o)}return t}function I(e){c=c===e?null:e;for(let[r,n]of M)r.setAttribute("aria-selected",String(n===c));let t=E?.body;t!==void 0&&(t.querySelector(".pinned")?.remove(),c!==null&&t.append(N(S(c))))}function ee(e){let t=T();if(t.length===0){e.append(l("p","empty","Nothing has changed yet."));return}let r=document.createElement("div");r.className="scroll",e.append(r);let n=[...new Set(t.map(a=>a.source))];if(n.length>1){let a=document.createElement("div");a.className="filters";for(let i of n){let s=x("span","chip",i);s.setAttribute("aria-pressed",String(w===i)),s.addEventListener("click",()=>{w=w===i?null:i,c=null,f()}),a.append(s)}r.append(a)}let o=w===null?t:t.filter(a=>a.source===w);r.append(l("p","hint",`${String(o.length)} of ${String(t.length)} updates`)),r.append(F([...o].reverse())),c!==null&&e.append(N(S(c)))}function te(e){let t=document.createElement("div");t.className="scroll",e.append(t);let r=C();if(r.length===0){t.append(l("p","empty","The query cache has done nothing yet."));return}for(let n of[...r].reverse()){let o=document.createElement("div");o.className="event",o.append(l("span",n.event,n.event)),o.append(l("span","tags",n.tags.join(", "))),t.append(o)}}function f(){if(E===null)return;let{body:e,pick:t,graphTab:r,queryTab:n,timelineTab:o}=E,a=e.querySelector(".scroll")?.scrollTop??0;r.setAttribute("aria-pressed",String(g==="graph")),n.setAttribute("aria-pressed",String(g==="queries")),o.setAttribute("aria-pressed",String(g==="timeline")),t.setAttribute("aria-pressed",String(v)),e.textContent="",g==="graph"?Z(e):g==="queries"?te(e):ee(e);let i=e.querySelector(".scroll");i!==null&&(i.scrollTop=a)}function G(e){!v||d===null||e.composedPath().includes(d)||(e.preventDefault(),e.stopPropagation(),h=e.target,v=!1,document.querySelector(`.${b}`)?.classList.remove(b),f())}function z(e){if(!v)return;document.querySelector(`.${b}`)?.classList.remove(b);let t=e.target;t!==null&&t!==d&&t.classList.add(b)}function j(){if(d!==null)return;d=document.createElement("div"),d.setAttribute("data-firsthand-devtools","");let e=d.attachShadow({mode:"open"}),t=document.createElement("style");t.textContent=K;let r=document.createElement("style");r.textContent=`.${b} { outline: 2px solid #3d5afe !important; outline-offset: 1px; }`,document.head.append(r);let n=document.createElement("div");n.className="panel";let o=document.createElement("header");o.append(l("strong","","Firsthand"));let a=y("Pick",()=>{v=!v,f()});a.dataset.pick="";let i=y("Graph",()=>{g="graph",f()});i.dataset.tab="graph";let s=y("Queries",()=>{g="queries",f()});s.dataset.tab="queries";let u=y("Timeline",()=>{g="timeline",f()});u.dataset.tab="timeline";let p=y("\xD7",R);p.setAttribute("aria-label","Close"),o.append(a,i,s,u,p);let m=document.createElement("div");m.className="body",n.append(o,m),e.append(t,n),document.body.append(d),E={body:m,pick:a,graphTab:i,queryTab:s,timelineTab:u},document.addEventListener("click",G,!0),document.addEventListener("mouseover",z,!0),L(()=>{c!==null||k!==null||(k=requestAnimationFrame(()=>{k=null,f()}))}),f()}function R(){L(null),k!==null&&(cancelAnimationFrame(k),k=null),document.removeEventListener("click",G,!0),document.removeEventListener("mouseover",z,!0),document.querySelector(`.${b}`)?.classList.remove(b),d?.remove(),d=null,E=null,c=null,w=null,v=!1,h=null,g="graph"}function ae(){d===null?j():R()}function ie(e){j(),h=e,g="graph",f()}function se(){f()}export{R as close,j as open,se as refresh,ie as show,ae as toggle};
@@ -0,0 +1,163 @@
1
+ /** What a node of the graph is. */
2
+ export type NodeKind = 'signal' | 'computed' | 'effect' | 'part';
3
+ /**
4
+ * One update: a write, and everything that ran because of it.
5
+ *
6
+ * This is the thing the graph cannot answer on its own. The graph says what
7
+ * depends on what; an update says what actually happened, in order, at a time.
8
+ */
9
+ export interface Update {
10
+ /** Milliseconds since the page loaded, so entries can be read as a sequence. */
11
+ at: number;
12
+ /** What was written. */
13
+ source: string;
14
+ /** What ran, in the order it ran. */
15
+ ran: string[];
16
+ /**
17
+ * Where the write came from, application frames only.
18
+ *
19
+ * "Which signal changed" is half an answer; the other half is which of your
20
+ * code changed it, and an event handler three files away is exactly the case
21
+ * where the graph cannot help.
22
+ *
23
+ * Kept exactly as the engine gave them, positions in the *compiled* module
24
+ * and all — browsers do not apply source maps to `error.stack`. The panel
25
+ * resolves them through the module's own map before showing them; anything
26
+ * else reading this should do the same.
27
+ */
28
+ stack: string[];
29
+ }
30
+ /** Something the query cache did. */
31
+ export interface QueryEvent {
32
+ event: 'created' | 'invalidated' | 'dropped';
33
+ /** The cache key: its tags and variables, as the client derived them. */
34
+ key: string;
35
+ /** The tags the entry carries, which is what an invalidation matches on. */
36
+ tags: readonly string[];
37
+ }
38
+ /** One node of the graph, as devtools describe it. */
39
+ export interface GraphNode {
40
+ kind: NodeKind;
41
+ /** `order.status`, `isEditable`, `button.disabled`, or a creation site. */
42
+ name: string;
43
+ /** The value the cell currently holds. */
44
+ value: unknown;
45
+ /** What this node reads. */
46
+ dependencies: GraphNode[];
47
+ /** What reads this node. */
48
+ dependents: GraphNode[];
49
+ }
50
+ /**
51
+ * Starts recording.
52
+ *
53
+ * Call it before the application creates anything — an import at the top of
54
+ * the entry module is the usual place — because a cell created earlier has no
55
+ * name to record. It stays in the graph; it is simply labelled by its
56
+ * creation site rather than by what it is called.
57
+ */
58
+ export declare function attach(): void;
59
+ /** What `attach()` puts on `globalThis` for the browser console to use. */
60
+ export interface Console {
61
+ chain: typeof chain;
62
+ inspect: typeof inspect;
63
+ causeOf: typeof causeOf;
64
+ stack: typeof stack;
65
+ timeline: typeof timeline;
66
+ cells: typeof cells;
67
+ queries: typeof queries;
68
+ detach: typeof detach;
69
+ /** Opens the panel, or shows it for a node you already have. */
70
+ panel: (node?: Node) => void;
71
+ }
72
+ /** Stops recording and forgets everything. Mostly for tests. */
73
+ export declare function detach(): void;
74
+ /** Every computed and effect currently alive, in owner order. */
75
+ export declare function cells(): GraphNode[];
76
+ /**
77
+ * What feeds a DOM node, all the way up to the signals.
78
+ *
79
+ * This is the question the panel exists for: the node is on the screen, the
80
+ * value is wrong, and what you want to know is where it came from.
81
+ */
82
+ export declare function inspect(node: Node, depth?: number): GraphNode[];
83
+ /**
84
+ * The chain from a DOM node up to its sources, as text.
85
+ *
86
+ * ```
87
+ * order.status
88
+ * ↓
89
+ * computed(isEditable)
90
+ * ↓
91
+ * button.disabled
92
+ * ```
93
+ *
94
+ * Only the deepest path is drawn, because a chain is a story and a tree is
95
+ * not. `inspect` returns the whole shape for anything that wants it.
96
+ */
97
+ export declare function chain(node: Node): string;
98
+ /**
99
+ * The longest path through a node's dependencies, sources first.
100
+ *
101
+ * One path rather than the whole tree, because a chain is a story: it is what
102
+ * `chain` prints and what the panel draws as boxes. `inspect` has the shape
103
+ * for anything that wants all of it.
104
+ */
105
+ export declare function path(node: GraphNode): GraphNode[];
106
+ /**
107
+ * Why an effect last ran: the dependency whose change scheduled it.
108
+ *
109
+ * The graph does not keep this — nothing needs it once the flush is over — so
110
+ * it is the one fact devtools record rather than read.
111
+ */
112
+ export declare function causeOf(node: Node): string | null;
113
+ /**
114
+ * What the query cache has done, oldest first.
115
+ *
116
+ * The cache is the one part of the framework whose behaviour is not in the
117
+ * reactive graph: a tag match is a decision rather than an edge, and an
118
+ * invalidation that matched nothing looks exactly like one that was never
119
+ * sent. Bounded to the last 200 events, because a long session should not
120
+ * become a memory leak in a debugging tool.
121
+ *
122
+ * ```ts
123
+ * queries().filter((e) => e.event === 'invalidated');
124
+ * ```
125
+ */
126
+ export declare function queries(): QueryEvent[];
127
+ /**
128
+ * Registers something to be told when the graph has settled.
129
+ *
130
+ * Used by the panel to redraw itself. Exported because the panel is a separate
131
+ * module, not because an application should need it.
132
+ */
133
+ export declare function watch(onSettled: (() => void) | null): void;
134
+ /**
135
+ * The component stack a DOM node's part lives in, outermost first.
136
+ *
137
+ * The owner tree already has the shape — a component's scope is the parent of
138
+ * everything its setup created — so this is a walk, not a recording. What the
139
+ * DOM layer contributes is the name, which only it knows and only at the
140
+ * moment an instance is created.
141
+ *
142
+ * ```ts
143
+ * stack(button); // ['App', 'OrderPage', 'SaveButton']
144
+ * ```
145
+ */
146
+ export declare function stack(node: Node): string[];
147
+ /**
148
+ * Every update, oldest first: what was written, and what ran because of it.
149
+ *
150
+ * The graph answers "what depends on this". This answers "what happened", in
151
+ * order and with a time — which is the question when something updated and
152
+ * nobody expected it to.
153
+ *
154
+ * ```ts
155
+ * timeline(); // everything
156
+ * timeline(button); // only the updates that ran this node's part
157
+ * ```
158
+ *
159
+ * The last 100 updates, so that a page left open overnight is still a
160
+ * debugging tool rather than a leak.
161
+ */
162
+ export declare function timeline(node?: Node): Update[];
163
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAeA,mCAAmC;AACnC,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEjE;;;;;GAKG;AACH,MAAM,WAAW,MAAM;IACrB,gFAAgF;IAChF,EAAE,EAAE,MAAM,CAAC;IACX,wBAAwB;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,qCAAqC;IACrC,GAAG,EAAE,MAAM,EAAE,CAAC;IACd;;;;;;;;;;;OAWG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,qCAAqC;AACrC,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,SAAS,GAAG,aAAa,GAAG,SAAS,CAAC;IAC7C,yEAAyE;IACzE,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CACzB;AAED,sDAAsD;AACtD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,QAAQ,CAAC;IACf,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,KAAK,EAAE,OAAO,CAAC;IACf,4BAA4B;IAC5B,YAAY,EAAE,SAAS,EAAE,CAAC;IAC1B,4BAA4B;IAC5B,UAAU,EAAE,SAAS,EAAE,CAAC;CACzB;AA0ID;;;;;;;GAOG;AACH,wBAAgB,MAAM,IAAI,IAAI,CAuH7B;AAED,2EAA2E;AAC3E,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,OAAO,KAAK,CAAC;IACpB,OAAO,EAAE,OAAO,OAAO,CAAC;IACxB,OAAO,EAAE,OAAO,OAAO,CAAC;IACxB,KAAK,EAAE,OAAO,KAAK,CAAC;IACpB,QAAQ,EAAE,OAAO,QAAQ,CAAC;IAC1B,KAAK,EAAE,OAAO,KAAK,CAAC;IACpB,OAAO,EAAE,OAAO,OAAO,CAAC;IACxB,MAAM,EAAE,OAAO,MAAM,CAAC;IACtB,gEAAgE;IAChE,KAAK,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,KAAK,IAAI,CAAC;CAC9B;AAED,gEAAgE;AAChE,wBAAgB,MAAM,IAAI,IAAI,CAkB7B;AA8ED,iEAAiE;AACjE,wBAAgB,KAAK,IAAI,SAAS,EAAE,CAMnC;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,SAAI,GAAG,SAAS,EAAE,CAM1D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,KAAK,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAMxC;AAED;;;;;;GAMG;AACH,wBAAgB,IAAI,CAAC,IAAI,EAAE,SAAS,GAAG,SAAS,EAAE,CASjD;AAMD;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAYjD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,OAAO,IAAI,UAAU,EAAE,CAEtC;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,SAAS,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,GAAG,IAAI,CAE1D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,KAAK,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,CAsB1C;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,GAAG,MAAM,EAAE,CAW9C"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ import{a,b,c,d,e,f,g,h,i,j,k}from"./chunk-5XEHZQPH.js";export{a as attach,g as causeOf,c as cells,e as chain,b as detach,d as inspect,f as path,h as queries,j as stack,k as timeline,i as watch};
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Opens the panel.
3
+ *
4
+ * In a shadow root with `all: initial`, so the page's stylesheet cannot reach
5
+ * it and its own cannot reach the page. An inspector that changes what it is
6
+ * inspecting is worse than no inspector.
7
+ */
8
+ export declare function open(): void;
9
+ /** Closes the panel and removes everything it added to the page. */
10
+ export declare function close(): void;
11
+ /** Opens the panel, or closes it if it is already open. */
12
+ export declare function toggle(): void;
13
+ /** Shows the panel for a node the caller already has. */
14
+ export declare function show(node: Node): void;
15
+ /** Redraws, for a caller that changed something and wants to see it. */
16
+ export declare function refresh(): void;
17
+ //# sourceMappingURL=panel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"panel.d.ts","sourceRoot":"","sources":["../src/panel.ts"],"names":[],"mappings":"AAgfA;;;;;;GAMG;AACH,wBAAgB,IAAI,IAAI,IAAI,CA2E3B;AAED,oEAAoE;AACpE,wBAAgB,KAAK,IAAI,IAAI,CAiB5B;AAED,2DAA2D;AAC3D,wBAAgB,MAAM,IAAI,IAAI,CAM7B;AAED,yDAAyD;AACzD,wBAAgB,IAAI,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,CAKrC;AAED,wEAAwE;AACxE,wBAAgB,OAAO,IAAI,IAAI,CAE9B"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Turning a generated position back into the one that was written.
3
+ *
4
+ * `new Error().stack` reports where code is, not where it came from: browsers
5
+ * do not apply source maps to `error.stack`, so a frame names a line in the
6
+ * compiled module. In a framework that compiles JSX into templates and thunks,
7
+ * that line is nothing the author recognises — which makes a call stack in a
8
+ * panel worse than none, because it looks authoritative.
9
+ *
10
+ * So the map is read here. It is already in the served module, as an inline
11
+ * comment, which is why this needs no build step and no second request beyond
12
+ * the module itself.
13
+ */
14
+ /**
15
+ * Rewrites a stack frame to the position that was written.
16
+ *
17
+ * `handleSave (http://host/src/order.ts:31:7)` becomes
18
+ * `handleSave (order.ts:12:9)` — the line the author would find by opening the
19
+ * file. A frame that cannot be resolved is handed back unchanged rather than
20
+ * guessed at.
21
+ */
22
+ export declare function original(frame: string): Promise<string>;
23
+ /** Forgets what has been fetched. For tests, and for a page that reloaded. */
24
+ export declare function forget(): void;
25
+ //# sourceMappingURL=source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source.d.ts","sourceRoot":"","sources":["../src/source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA8HH;;;;;;;GAOG;AACH,wBAAsB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA6B7D;AAED,8EAA8E;AAC9E,wBAAgB,MAAM,IAAI,IAAI,CAE7B"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@firsthandjs/devtools",
3
+ "version": "0.4.0",
4
+ "description": "Experimental devtools for Firsthand: see what updates what, why it ran, and when.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "types": "./dist/index.d.ts",
15
+ "main": "./dist/index.js",
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "dependencies": {
22
+ "@firsthandjs/core": "0.4.0"
23
+ },
24
+ "engines": {
25
+ "node": ">=20.11.0"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "provenance": true
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/firsthandjs/firsthand.git",
34
+ "directory": "packages/devtools"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/firsthandjs/firsthand/issues"
38
+ },
39
+ "homepage": "https://github.com/firsthandjs/firsthand#readme",
40
+ "keywords": [
41
+ "firsthand",
42
+ "devtools",
43
+ "experimental",
44
+ "reactive",
45
+ "signals",
46
+ "debugging"
47
+ ]
48
+ }