diff --git a/.changeset/bright-graphs-wrap.md b/.changeset/bright-graphs-wrap.md new file mode 100644 index 000000000..5cd86020c --- /dev/null +++ b/.changeset/bright-graphs-wrap.md @@ -0,0 +1,5 @@ +--- +"@preact/signals-devtools-ui": patch +--- + +Wrap dense dependency graph layers into columns so large application graphs remain visible when fitted to the viewport. diff --git a/.changeset/model-devtools.md b/.changeset/model-devtools.md new file mode 100644 index 000000000..39dc484a8 --- /dev/null +++ b/.changeset/model-devtools.md @@ -0,0 +1,11 @@ +--- +"@preact/signals-core": minor +"@preact/signals-debug": minor +"@preact/signals-devtools-adapter": minor +"@preact/signals-devtools-ui": minor +"@preact/signals-preact-transform": minor +"@preact/signals-react-transform": minor +"@preact/signals-agent-vite": minor +--- + +Make `createModel` instances first-class in the debug and DevTools experience. Core exposes model names and a lightweight construction hook, while the debug package owns model membership and instance metadata. Model identity and member paths now flow through debug events, update rows show model badges, dependency graphs draw model boundaries, and the Babel transforms infer model names in debug mode. diff --git a/packages/core/README.md b/packages/core/README.md index ee9107482..4f2575466 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -256,7 +256,7 @@ effect(() => { ### `createModel(fn)` -Use `createModel` to define disposable model instances that group signals, computed values, and actions together. +Use `createModel` to define disposable model instances that group signals, computed values, and actions together. The Preact and React Babel transforms automatically provide a debug name; without a transform, pass `{ name: "CounterModel" }` when using Signals DevTools. ```js import { computed, createModel, effect, signal } from "@preact/signals-core"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 25a641ffe..f52c6439e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,10 @@ // created using the same signals library version. const BRAND_SYMBOL = Symbol.for("preact-signals"); +// Debug packages can register model construction hooks without core depending +// on a particular debug package version. +const MODEL_HOOKS_SYMBOL = Symbol.for("preact-signals-model-hooks"); + // Flags for Computed and Effect. const RUNNING = 1 << 0; const NOTIFIED = 1 << 1; @@ -1010,6 +1014,11 @@ type ValidateModel = { export type Model = ValidateModel & DisposableLike; +export interface ModelOptions { + /** A name used to identify model instances in debug tooling. */ + name?: string; +} + export type ModelFactory = ( ...args: TFactoryArgs ) => ValidateModel; @@ -1086,8 +1095,38 @@ const wrapInAction = (value: Record) => { } }; +type ModelHook = ( + model: object, + effects: Effect[] | undefined, + name: string +) => void; + +function notifyModelHooks( + model: object, + effects: Effect[] | undefined, + name: string +) { + const scope = + typeof globalThis !== "undefined" + ? globalThis + : typeof self !== "undefined" + ? self + : undefined; + const hooks = scope && (scope as any)[MODEL_HOOKS_SYMBOL]; + if (!(hooks instanceof Set)) return; + + for (const hook of hooks as Set) { + try { + hook(model, effects, name); + } catch { + // Debug instrumentation must not affect model initialization. + } + } +} + function createModel( - modelFactory: ModelFactory + modelFactory: ModelFactory, + options?: ModelOptions ): ModelConstructor { return function SignalModel(...args: TFactoryArgs): Model { let modelEffects: Effect[] | undefined; @@ -1107,6 +1146,11 @@ function createModel( } wrapInAction(model); + notifyModelHooks( + model, + modelEffects, + options?.name || modelFactory.name || "Model" + ); model[Symbol.dispose] = action(function disposeModel() { if (modelEffects) { diff --git a/packages/core/test/signal.test.tsx b/packages/core/test/signal.test.tsx index 2c10e0c7b..f40de19fd 100644 --- a/packages/core/test/signal.test.tsx +++ b/packages/core/test/signal.test.tsx @@ -2366,6 +2366,49 @@ describe("createModel", () => { expect(counter.quadruple.value).to.equal(4); }); + it("notifies debug hooks when a model is constructed", () => { + const hooksSymbol = Symbol.for("preact-signals-model-hooks"); + const existingHooks = (globalThis as any)[hooksSymbol]; + const hook = vi.fn(); + const hooks = + existingHooks instanceof Set + ? existingHooks + : new Set<(...args: any[]) => void>(); + const installedHooks = hooks !== existingHooks; + if (installedHooks) { + Object.defineProperty(globalThis, hooksSymbol, { + value: hooks, + configurable: true, + }); + } + hooks.add(hook); + + try { + const CounterModel = createModel( + () => { + const count = signal(0); + effect(() => { + count.value; + }); + return { count }; + }, + { name: "CounterModel" } + ); + const counter = new CounterModel(); + + expect(hook).toHaveBeenCalledOnce(); + expect(hook).toHaveBeenCalledWith( + counter, + expect.arrayContaining([expect.anything()]), + "CounterModel" + ); + counter[Symbol.dispose](); + } finally { + hooks.delete(hook); + if (installedHooks) delete (globalThis as any)[hooksSymbol]; + } + }); + it("should accept factory arguments", () => { const CounterModel = createModel((initialCount: number) => { const count = signal(initialCount); diff --git a/packages/debug/README.md b/packages/debug/README.md index 7d74ef248..f05ebd5eb 100644 --- a/packages/debug/README.md +++ b/packages/debug/README.md @@ -20,6 +20,7 @@ pnpm add @preact/signals-debug - Track signal value changes and updates - Monitor effect executions - Debug computed value recalculations +- Identify signals, computed values, and effects owned by `createModel` instances - Get real-time debugging statistics - Configurable debugging options @@ -43,8 +44,9 @@ The package automatically enhances signals with debugging capabilities: 1. **Value Changes**: Tracks and logs all signal value changes 2. **Effect Tracking**: Monitors effect executions and their dependencies 3. **Computed Values**: Tracks computed value recalculations and dependencies -4. **Update Grouping**: Groups related updates for better visualization -5. **Performance Stats**: Provides active trackers and subscriptions count +4. **Model Ownership**: Includes model identity and property paths in console output and DevTools events +5. **Update Grouping**: Groups related updates for better visualization +6. **Performance Stats**: Provides active trackers and subscriptions count ## API Reference diff --git a/packages/debug/src/devtools.ts b/packages/debug/src/devtools.ts index a4a13acd1..2707b92d2 100644 --- a/packages/debug/src/devtools.ts +++ b/packages/debug/src/devtools.ts @@ -1,7 +1,8 @@ -import { UpdateInfo, DependencyInfo } from "./internal"; +import { UpdateInfo, DependencyInfo, ModelInfo } from "./internal"; import { getSignalId, getSignalName, + getModelInfo, isReactOrPreactElement, formatReactElement, } from "./utils"; @@ -17,6 +18,8 @@ export interface FormattedSignalUpdate { timestamp: number; depth: number; subscribedTo?: string; + /** Model instances that contain this reactive value. */ + models?: ModelInfo[]; /** All dependencies this computed/effect currently depends on (with rich info) */ allDependencies?: DependencyInfo[]; } @@ -28,6 +31,8 @@ export interface FormattedSignalDisposed { signalName: string; signalId: string; timestamp: number; + /** Model instances that contain this reactive value. */ + models?: ModelInfo[]; } // Communication layer for Chrome DevTools Extension @@ -73,7 +78,6 @@ class DevToolsCommunicator { public isExtensionConnected = false; public messageQueue: DevToolsMessage[] = []; public readonly maxQueueSize = 100; - public signalOwnership = new WeakMap>(); constructor() { this.setupCommunication(); @@ -154,9 +158,11 @@ class DevToolsCommunicator { } const formattedUpdates = updateInfoList.map(({ signal, ...info }) => { + const models = getModelInfo(signal); if (info.type === "value") { return { ...info, + ...(models ? { models } : {}), type: "update" as const, newValue: deeplyRemoveFunctions(info.newValue), prevValue: deeplyRemoveFunctions(info.prevValue), @@ -169,6 +175,7 @@ class DevToolsCommunicator { } else if (info.type === "component") { return { ...info, + ...(models ? { models } : {}), type: "component" as const, signalType: "component" as const, signalName: this.getSignalName(signal, "component"), @@ -177,6 +184,7 @@ class DevToolsCommunicator { } else { return { ...info, + ...(models ? { models } : {}), type: "effect" as const, signalType: "effect" as const, signalName: this.getSignalName(signal, "effect"), @@ -257,6 +265,7 @@ class DevToolsCommunicator { ), signalId: this.getSignalId(signal), timestamp: Date.now(), + models: getModelInfo(signal), }; // Emit for direct listeners (e.g., DirectAdapter) diff --git a/packages/debug/src/index.ts b/packages/debug/src/index.ts index 30157e4a0..2a49fbc10 100644 --- a/packages/debug/src/index.ts +++ b/packages/debug/src/index.ts @@ -1,7 +1,15 @@ /* eslint-disable no-console */ import { Signal, Effect, Computed, effect } from "@preact/signals-core"; -import { formatValue, getSignalId, getSignalName } from "./utils"; +import { + formatValue, + getModelInfo, + getSignalId, + getSignalLabel, + getSignalName, + getSignalSearchText, +} from "./utils"; import { UpdateInfo, Node, Computed as ComputedType } from "./internal"; + import { getExtensionBridge } from "./extension-bridge"; import "./devtools"; // Initialize DevTools integration @@ -198,10 +206,17 @@ function hasUpdateEntry(signal: Signal) { return false; } +export interface ModelInfo { + id: string; + name: string; + path?: string; +} + export interface DependencyInfo { id: string; name: string; type: "signal" | "computed"; + models?: ModelInfo[]; } /** @@ -230,6 +245,7 @@ function getAllCurrentDependencies( id, name: getSignalName(source, "value"), type: "_fn" in source ? "computed" : "signal", + models: getModelInfo(source), }); } sourceNode = sourceNode._nextSource; @@ -359,10 +375,11 @@ function flushUpdates() { // Send updates to Chrome DevTools extension with filtering and throttling if (typeof window !== "undefined" && !bridge.shouldThrottleUpdate()) { // Filter updates based on signal names - const filteredUpdates = updateInfoList.filter(updateInfo => { - const signalName = getSignalName(updateInfo.signal, updateInfo.type); - return bridge.matchesFilter(signalName); - }); + const filteredUpdates = updateInfoList.filter(updateInfo => + bridge.matchesFilter( + getSignalSearchText(updateInfo.signal, updateInfo.type) + ) + ); if ( filteredUpdates.length > 0 && @@ -399,7 +416,7 @@ function logUpdate( if (!debugEnabled || !consoleLoggingEnabled) return false; const { signal, type, depth } = info; - const name = getSignalName(signal, type); + const name = getSignalLabel(signal, type); // Effects can't have descendants, so we use a normal log instead of a group if (type === "effect" || type === "component") { diff --git a/packages/debug/src/internal.ts b/packages/debug/src/internal.ts index f62474371..ef0e9af99 100644 --- a/packages/debug/src/internal.ts +++ b/packages/debug/src/internal.ts @@ -16,10 +16,17 @@ export type Node = { _rollbackNode?: Node; }; +export interface ModelInfo { + id: string; + name: string; + path?: string; +} + export interface DependencyInfo { id: string; name: string; type: "signal" | "computed"; + models?: ModelInfo[]; } export type UpdateInfo = ValueUpdate | EffectUpdate | ComponentUpdate; diff --git a/packages/debug/src/model.ts b/packages/debug/src/model.ts new file mode 100644 index 000000000..e8e54ac97 --- /dev/null +++ b/packages/debug/src/model.ts @@ -0,0 +1,155 @@ +import { Effect } from "@preact/signals-core"; +import type { ModelInfo } from "./internal"; + +const BRAND_SYMBOL = Symbol.for("preact-signals"); +const MODEL_SYMBOL = Symbol.for("preact-signals-model"); +const MODEL_HOOKS_SYMBOL = Symbol.for("preact-signals-model-hooks"); +const MODEL_OWNER_HOOK_SYMBOL = Symbol.for("preact-signals-debug-model-hook"); + +interface ModelMetadata { + name: string; +} + +interface ModelMembership { + model: ModelMetadata; + path?: string; +} + +type ModelHook = ( + model: object, + effects: Effect[] | undefined, + name: string +) => void; + +function addModelMembership( + value: object, + model: ModelMetadata, + path?: string +) { + let memberships: ModelMembership[]; + try { + const existing = (value as any)[MODEL_SYMBOL]; + if (existing === undefined) { + memberships = []; + Object.defineProperty(value, MODEL_SYMBOL, { value: memberships }); + } else if (Array.isArray(existing)) { + memberships = existing; + } else { + return; + } + } catch { + return; + } + + if ( + !memberships.some( + membership => membership.model === model && membership.path === path + ) + ) { + memberships.push({ model, path }); + } +} + +function reflectModelMembers( + value: object, + model: ModelMetadata, + path?: string, + ancestors = new WeakSet() +) { + if (ancestors.has(value)) return; + + if ((value as any).brand === BRAND_SYMBOL) { + addModelMembership(value, model, path); + return; + } + + ancestors.add(value); + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) continue; + const member = descriptor.value; + if (typeof member === "object" && member !== null) { + reflectModelMembers( + member, + model, + path === undefined ? key : `${path}.${key}`, + ancestors + ); + } + } + ancestors.delete(value); +} + +function attachModelOwnership( + model: object, + effects: Effect[] | undefined, + name: string +) { + const metadata = { name }; + addModelMembership(model, metadata); + reflectModelMembers(model, metadata); + for (const effect of effects || []) { + addModelMembership(effect, metadata); + } +} + +function installModelOwnershipHook() { + try { + const scope = + typeof globalThis !== "undefined" + ? globalThis + : typeof self !== "undefined" + ? self + : undefined; + if (!scope) return; + + let hooks = (scope as any)[MODEL_HOOKS_SYMBOL]; + if (!(hooks instanceof Set)) { + hooks = new Set(); + Object.defineProperty(scope, MODEL_HOOKS_SYMBOL, { value: hooks }); + } + + for (const hook of hooks as Set) { + if ((hook as any)[MODEL_OWNER_HOOK_SYMBOL]) return; + } + + Object.defineProperty(attachModelOwnership, MODEL_OWNER_HOOK_SYMBOL, { + value: true, + }); + hooks.add(attachModelOwnership); + } catch { + // Model ownership is optional instrumentation. + } +} + +installModelOwnershipHook(); + +const modelIds = new WeakMap(); +let nextModelId = 1; + +export function getModelInfo(value: any): ModelInfo[] | undefined { + const memberships = value?.[MODEL_SYMBOL]; + if (!Array.isArray(memberships)) return undefined; + + const models: ModelInfo[] = []; + for (const membership of memberships) { + const model = membership?.model; + if (!model || typeof model !== "object" || typeof model.name !== "string") { + continue; + } + + let id = modelIds.get(model); + if (id === undefined) { + id = `model_${nextModelId++}`; + modelIds.set(model, id); + } + + models.push({ + id, + name: model.name, + ...(typeof membership.path === "string" ? { path: membership.path } : {}), + }); + } + + return models.length > 0 ? models : undefined; +} diff --git a/packages/debug/src/utils.ts b/packages/debug/src/utils.ts index a5569362f..802d75423 100644 --- a/packages/debug/src/utils.ts +++ b/packages/debug/src/utils.ts @@ -1,4 +1,7 @@ import { Effect, Signal } from "@preact/signals-core"; +import { getModelInfo } from "./model"; + +export { getModelInfo }; export function getSignalName( signal: any, @@ -6,11 +9,16 @@ export function getSignalName( ): string { // Try to get a meaningful name for the signal if (signal.displayName) return signal.displayName; - if (signal.name) - return signal.name === "sub" - ? `${(signal as Effect)._sources?._source.name}-subscribe` - : signal.name; + if (signal.name) { + if (signal.name !== "sub") return signal.name; + const subscribedSignal = (signal as Effect)._sources?._source; + return subscribedSignal + ? `${getSignalName(subscribedSignal, "value")}-subscribe` + : "signal-subscribe"; + } if (signal._fn && signal._fn.name) return signal._fn.name; + const modelPath = getModelInfo(signal)?.[0]?.path; + if (modelPath) return modelPath; if (type === "effect" || type === "component") return type; const signalType = "_fn" in signal ? "computed" : "signal"; return `(anonymous ${signalType})`; @@ -182,6 +190,34 @@ function fastStringify( return "{" + parts.join(",") + "}"; } +export function getSignalLabel( + signal: Signal | Effect, + type: "component" | "effect" | "value" +): string { + const signalName = getSignalName(signal, type); + const model = getModelInfo(signal)?.[0]; + if (!model) return signalName; + + return `${model.name}.${model.path || signalName}`; +} + +export function getSignalSearchText( + signal: Signal | Effect, + type: "component" | "effect" | "value" +): string { + const signalName = getSignalName(signal, type); + const candidates = [signalName]; + + for (const model of getModelInfo(signal) || []) { + candidates.push(model.id, model.name); + if (model.path) { + candidates.push(model.path, `${model.name}.${model.path}`); + } + } + + return candidates.join(" "); +} + export function getSignalId(signal: Signal | Effect): string { if (!(signal as any)._debugId) { (signal as any)._debugId = diff --git a/packages/debug/test/browser/signal.test.tsx b/packages/debug/test/browser/signal.test.tsx index 340df472e..a52f9f448 100644 --- a/packages/debug/test/browser/signal.test.tsx +++ b/packages/debug/test/browser/signal.test.tsx @@ -7,6 +7,7 @@ import { effect, batch, ReadonlySignal, + createModel, } from "@preact/signals-core"; import { setDebugOptions } from "@preact/signals-debug"; @@ -70,6 +71,87 @@ describe("Signal Debug", () => { }); }); + describe("Model Updates", () => { + it("includes model identity and member paths in devtools updates", async () => { + const CounterModel = createModel( + () => { + const count = signal(0); + return { count, doubled: computed(() => count.value * 2) }; + }, + { name: "CounterModel" } + ); + const counter = new CounterModel(); + const updates: any[] = []; + const stopListening = window.__PREACT_SIGNALS_DEVTOOLS__.onUpdate(batch => + updates.push(...batch) + ); + const unsubscribe = counter.doubled.subscribe(() => {}); + + counter.count.value = 2; + await new Promise(resolve => setTimeout(resolve, 0)); + + const countUpdate = updates.find(update => update.signalName === "count"); + const doubledUpdate = updates.find( + update => update.signalName === "doubled" + ); + expect(countUpdate.models[0]).toMatchObject({ + name: "CounterModel", + path: "count", + }); + expect(doubledUpdate.models[0]).toMatchObject({ + id: countUpdate.models[0].id, + name: "CounterModel", + path: "doubled", + }); + expect(doubledUpdate.allDependencies[0].models[0]).toMatchObject({ + id: countUpdate.models[0].id, + path: "count", + }); + expect(groupSpy).toHaveBeenCalledWith( + "馃幆 Signal Update: CounterModel.count" + ); + expect(groupCollapsedSpy).toHaveBeenCalledWith( + " 鈫笍 Triggered update: CounterModel.doubled" + ); + + unsubscribe(); + stopListening(); + counter[Symbol.dispose](); + }); + + it("preserves model identity when owned effects are disposed", () => { + const CounterModel = createModel( + () => { + const count = signal(0); + effect(() => { + count.value; + }); + return { count }; + }, + { name: "CounterModel" } + ); + const counter = new CounterModel(); + const disposals: any[] = []; + const stopListening = window.__PREACT_SIGNALS_DEVTOOLS__.onDisposal( + batch => disposals.push(...batch) + ); + + counter[Symbol.dispose](); + + expect( + disposals.find( + disposal => + disposal.signalType === "effect" && disposal.models?.length > 0 + ) + ).toMatchObject({ + type: "disposed", + signalType: "effect", + models: [{ name: "CounterModel" }], + }); + stopListening(); + }); + }); + describe("Computed Signal Updates", () => { it("should show cascading updates from computed signals", async () => { const count = signal(0, { name: "count" }); diff --git a/packages/debug/test/browser/utils.test.tsx b/packages/debug/test/browser/utils.test.tsx index 6373f820b..d320fac34 100644 --- a/packages/debug/test/browser/utils.test.tsx +++ b/packages/debug/test/browser/utils.test.tsx @@ -1,6 +1,60 @@ import { describe, it, expect } from "vitest"; -import { formatValue, getSignalName } from "../../src/utils"; -import { signal, computed } from "@preact/signals-core"; +import { + formatValue, + getModelInfo, + getSignalLabel, + getSignalName, + getSignalSearchText, +} from "../../src/utils"; +import { signal, computed, createModel } from "@preact/signals-core"; + +describe("model reflection", () => { + it("returns model identity and property paths", () => { + const CounterModel = createModel( + () => { + const count = signal(0); + return { count, doubled: computed(() => count.value * 2) }; + }, + { name: "CounterModel" } + ); + const first = new CounterModel(); + const second = new CounterModel(); + + const countModel = getModelInfo(first.count)![0]; + const computedModel = getModelInfo(first.doubled)![0]; + const secondModel = getModelInfo(second.count)![0]; + + expect(countModel).toMatchObject({ name: "CounterModel", path: "count" }); + expect(computedModel).toMatchObject({ + id: countModel.id, + name: "CounterModel", + path: "doubled", + }); + expect(secondModel.id).not.toBe(countModel.id); + expect(getSignalName(first.count, "value")).toBe("count"); + expect(getSignalLabel(first.count, "value")).toBe("CounterModel.count"); + }); + + it("reports every model that shares a reactive value", () => { + const shared = signal(0); + const FirstModel = createModel(() => ({ shared }), { name: "FirstModel" }); + const SecondModel = createModel(() => ({ shared, state: { shared } }), { + name: "SecondModel", + }); + + new FirstModel(); + new SecondModel(); + + expect(getModelInfo(shared)).toMatchObject([ + { name: "FirstModel", path: "shared" }, + { name: "SecondModel", path: "shared" }, + { name: "SecondModel", path: "state.shared" }, + ]); + expect(getSignalSearchText(shared, "value")).toContain( + "SecondModel.state.shared" + ); + }); +}); describe("formatValue", () => { it("should handle null and undefined", () => { diff --git a/packages/devtools-adapter/src/index.ts b/packages/devtools-adapter/src/index.ts index ce246d191..326409333 100644 --- a/packages/devtools-adapter/src/index.ts +++ b/packages/devtools-adapter/src/index.ts @@ -6,6 +6,7 @@ export type { Unsubscribe, SignalUpdate, DependencyInfo, + ModelInfo, Settings, ConnectionStatus, ConnectionStatusType, diff --git a/packages/devtools-adapter/src/types.ts b/packages/devtools-adapter/src/types.ts index 162107ead..5f36e894d 100644 --- a/packages/devtools-adapter/src/types.ts +++ b/packages/devtools-adapter/src/types.ts @@ -1,3 +1,13 @@ +/** + * A model instance that contains a reactive value. + */ +export interface ModelInfo { + id: string; + name: string; + /** Dot-separated property path within the model, when the value is exposed. */ + path?: string; +} + /** * Rich dependency information for a signal/computed */ @@ -5,6 +15,7 @@ export interface DependencyInfo { id: string; name: string; type: "signal" | "computed"; + models?: ModelInfo[]; } /** @@ -21,6 +32,8 @@ export interface SignalUpdate { receivedAt: number; depth?: number; subscribedTo?: string; + /** Model instances that contain this reactive value. */ + models?: ModelInfo[]; /** All dependencies this computed/effect currently depends on (with rich info) */ allDependencies?: DependencyInfo[]; } @@ -34,6 +47,7 @@ export interface SignalDisposed { signalName: string; signalId: string; timestamp: number; + models?: ModelInfo[]; } /** diff --git a/packages/devtools-ui/README.md b/packages/devtools-ui/README.md index d734bbd4d..6d3f9ecae 100644 --- a/packages/devtools-ui/README.md +++ b/packages/devtools-ui/README.md @@ -1,6 +1,6 @@ # @preact/signals-devtools-ui -DevTools UI components for Preact Signals. This package provides a reusable UI that can be embedded in various contexts - browser extensions, iframes, overlays, blog posts, etc. +DevTools UI components for Preact Signals. This package provides a reusable UI that can be embedded in various contexts - browser extensions, iframes, overlays, blog posts, etc. Updates from `createModel` instances include model badges, and the dependency graph draws labeled boundaries around model members. > [!NOTE] > It's important that `@preact/signals-debug` is imported in an entry point to get signals registered into the devtools as soon as possible. diff --git a/packages/devtools-ui/src/components/Graph.tsx b/packages/devtools-ui/src/components/Graph.tsx index 89fdd3b19..bc66dcc10 100644 --- a/packages/devtools-ui/src/components/Graph.tsx +++ b/packages/devtools-ui/src/components/Graph.tsx @@ -4,14 +4,30 @@ import type { GraphData, GraphLink, GraphNode } from "../types"; import type { SignalUpdate } from "../context"; import { getContext } from "../context"; +interface ModelBoundary { + id: string; + name: string; + x: number; + y: number; + width: number; + height: number; +} + interface GraphRenderData extends GraphData { nodeLookup: Map; + modelBoundaries: ModelBoundary[]; } const DEFAULT_VIEWPORT_SIZE = { width: 800, height: 600 }; const FIT_PADDING = 80; const MIN_ZOOM = 0.001; const MAX_ZOOM = 5; +const MIN_NODES_PER_COLUMN = 12; +const TARGET_GRAPH_ASPECT_RATIO = 1.6; +const NODE_COLUMN_SPACING = 160; +const NODE_ROW_SPACING = 120; +const LAYER_SPACING = 250; +const GRAPH_PADDING = 100; interface Point { x: number; @@ -32,6 +48,22 @@ interface GraphBounds { height: number; } +const mergeModels = ( + current: GraphNode["models"], + incoming: GraphNode["models"] +): GraphNode["models"] => { + if (!incoming?.length) return current; + if (!current?.length) return incoming; + + const models = new Map( + current.map(model => [`${model.id}:${model.path || ""}`, model]) + ); + for (const model of incoming) { + models.set(`${model.id}:${model.path || ""}`, model); + } + return Array.from(models.values()); +}; + const getNodeRadius = (node: GraphNode) => { const baseRadius = 30; const charWidth = 6.5; @@ -347,6 +379,7 @@ export function GraphVisualization() { nodes: [], links: [], nodeLookup: new Map(), + modelBoundaries: [], }; } @@ -372,7 +405,11 @@ export function GraphVisualization() { x: 0, y: 0, depth: 0, // Will be recalculated + models: update.models, }); + } else { + const node = nodes.get(update.signalId)!; + node.models = mergeModels(node.models, update.models); } if (update.allDependencies && update.allDependencies.length > 0) { @@ -388,7 +425,11 @@ export function GraphVisualization() { x: 0, y: 0, depth: 0, + models: dep.models, }); + } else { + const node = nodes.get(dep.id)!; + node.models = mergeModels(node.models, dep.models); } const linkKey = `${dep.id}->${update.signalId}`; @@ -436,41 +477,125 @@ export function GraphVisualization() { // Minimize edge crossings minimizeCrossings(nodesByLayer, allLinks); - // Layout nodes with proper spacing. Keep every layer within positive graph - // coordinates so tall layers are pannable instead of being clipped above 0. - const nodeSpacing = 120; - const layerSpacing = 250; - const graphPadding = 100; - const maxLayerNodeCount = Math.max( + // Dense layers used to form a single, extremely tall column. Fitting a large + // application graph then reduced every node to an indistinguishable vertical + // line. Wrap dense layers into columns sized to avoid pathological graph + // aspect ratios while preserving left-to-right depth order. + const nodesPerColumn = Math.max( + MIN_NODES_PER_COLUMN, + Math.ceil( + Math.sqrt( + (nodes.size * NODE_COLUMN_SPACING) / + (TARGET_GRAPH_ASPECT_RATIO * NODE_ROW_SPACING) + ) + ) + ); + const maxColumnNodeCount = Math.max( 1, - ...Array.from(nodesByLayer.values()).map(layerNodes => layerNodes.length) + ...Array.from(nodesByLayer.values()).map(layerNodes => + Math.min(layerNodes.length, nodesPerColumn) + ) ); const graphHeight = - graphPadding * 2 + Math.max(0, maxLayerNodeCount - 1) * nodeSpacing; + GRAPH_PADDING * 2 + + Math.max(0, maxColumnNodeCount - 1) * NODE_ROW_SPACING; + const graphInnerHeight = graphHeight - GRAPH_PADDING * 2; + const orderedLayers = Array.from(nodesByLayer.keys()).sort((a, b) => a - b); + let layerStartX = GRAPH_PADDING; - nodesByLayer.forEach((layerNodes, layer) => { - const layerHeight = (layerNodes.length - 1) * nodeSpacing; - const layerStartY = - graphPadding + (graphHeight - graphPadding * 2 - layerHeight) / 2; + for (const layer of orderedLayers) { + const layerNodes = nodesByLayer.get(layer)!; + const columnCount = Math.ceil(layerNodes.length / nodesPerColumn); layerNodes.forEach((node, index) => { - node.x = graphPadding + layer * layerSpacing; - node.y = layerStartY + index * nodeSpacing; + const column = Math.floor(index / nodesPerColumn); + const row = index % nodesPerColumn; + const columnNodeCount = Math.min( + nodesPerColumn, + layerNodes.length - column * nodesPerColumn + ); + const columnHeight = (columnNodeCount - 1) * NODE_ROW_SPACING; + + node.x = layerStartX + column * NODE_COLUMN_SPACING; + node.y = + GRAPH_PADDING + + (graphInnerHeight - columnHeight) / 2 + + row * NODE_ROW_SPACING; }); - }); + + layerStartX += + Math.max(0, columnCount - 1) * NODE_COLUMN_SPACING + LAYER_SPACING; + } const graphNodes = Array.from(nodes.values()); const nodeLookup = new Map(graphNodes.map(node => [node.id, node])); + const modelNodes = new Map(); + + for (const node of graphNodes) { + for (const model of node.models || []) { + let group = modelNodes.get(model.id); + if (!group) { + group = { name: model.name, nodes: [] }; + modelNodes.set(model.id, group); + } + group.nodes.push(node); + } + } + + const modelBoundaries = Array.from(modelNodes, ([id, group]) => { + const padding = 42; + const minX = Math.min( + ...group.nodes.map(node => node.x - getNodeRadius(node)) + ); + const maxX = Math.max( + ...group.nodes.map(node => node.x + getNodeRadius(node)) + ); + const minY = Math.min( + ...group.nodes.map(node => node.y - getNodeRadius(node)) + ); + const maxY = Math.max( + ...group.nodes.map(node => node.y + getNodeRadius(node)) + ); + + return { + id, + name: group.name, + x: minX - padding, + y: minY - padding, + width: maxX - minX + padding * 2, + height: maxY - minY + padding * 2, + }; + }); + return { nodes: graphNodes, links: allLinks, nodeLookup, + modelBoundaries, }; }); - const graphBounds = useComputed(() => - calculateGraphBounds(graphData.value.nodes) - ); + const graphBounds = useComputed(() => { + const bounds = calculateGraphBounds(graphData.value.nodes); + if (!bounds) return null; + + let { minX, minY, maxX, maxY } = bounds; + for (const boundary of graphData.value.modelBoundaries) { + minX = Math.min(minX, boundary.x); + minY = Math.min(minY, boundary.y); + maxX = Math.max(maxX, boundary.x + boundary.width); + maxY = Math.max(maxY, boundary.y + boundary.height); + } + + return { + minX, + minY, + maxX, + maxY, + width: Math.max(1, maxX - minX), + height: Math.max(1, maxY - minY), + }; + }); const layoutSignature = graphBounds.value ? [ graphData.value.nodes.length, @@ -742,6 +867,29 @@ export function GraphVisualization() { + + {graphData.value.modelBoundaries.map(model => ( + + {`${model.name} 路 ${model.id}`} + + + {model.name} + + + ))} + + {graphData.value.links.map((link, index) => { const sourceNode = graphData.value.nodeLookup.get(link.source); @@ -871,6 +1019,15 @@ export function GraphVisualization() {
{hoveredNode.value.name}
+ {hoveredNode.value.models?.map(model => ( +
+ Model: {model.name} + {model.path ? ` 路 ${model.path}` : ""} 路 {model.id} +
+ ))}
ID: {hoveredNode.value.id}
)} @@ -892,6 +1049,10 @@ export function GraphVisualization() {
Component +
+
+ Model +
diff --git a/packages/devtools-ui/src/components/UpdateItem.tsx b/packages/devtools-ui/src/components/UpdateItem.tsx index c266ac3ea..fc4090ef9 100644 --- a/packages/devtools-ui/src/components/UpdateItem.tsx +++ b/packages/devtools-ui/src/components/UpdateItem.tsx @@ -30,6 +30,15 @@ export function UpdateItem({ update, count, firstUpdate }: UpdateItemProps) { x{count} ); + const modelBadges = update.models?.map(model => ( + + {model.name} + + )); if (update.type === "effect" || update.type === "component") { const icon = update.type === "component" ? "馃攧" : "鈫笍"; @@ -40,6 +49,7 @@ export function UpdateItem({ update, count, firstUpdate }: UpdateItemProps) { {icon} {update.signalName} {countLabel} + {modelBadges} {label} {time} @@ -59,6 +69,7 @@ export function UpdateItem({ update, count, firstUpdate }: UpdateItemProps) { {update.depth === 0 ? "馃幆" : "鈫笍"} {update.signalName} {countLabel} + {modelBadges} {time} diff --git a/packages/devtools-ui/src/index.ts b/packages/devtools-ui/src/index.ts index 811317eb1..5c8d5913d 100644 --- a/packages/devtools-ui/src/index.ts +++ b/packages/devtools-ui/src/index.ts @@ -44,4 +44,5 @@ export type { Settings, ConnectionStatus, ConnectionStatusType, + ModelInfo, } from "@preact/signals-devtools-adapter"; diff --git a/packages/devtools-ui/src/models/UpdatesModel.ts b/packages/devtools-ui/src/models/UpdatesModel.ts index a45a8e75c..4cedd7006 100644 --- a/packages/devtools-ui/src/models/UpdatesModel.ts +++ b/packages/devtools-ui/src/models/UpdatesModel.ts @@ -3,6 +3,7 @@ import type { DevToolsAdapter, SignalDisposed, DependencyInfo, + ModelInfo, } from "@preact/signals-devtools-adapter"; import type { SettingsModel } from "./SettingsModel"; @@ -17,6 +18,7 @@ export interface SignalUpdate { receivedAt: number; depth?: number; subscribedTo?: string; + models?: ModelInfo[]; /** All dependencies this computed/effect currently depends on (with rich info) */ allDependencies?: DependencyInfo[]; } diff --git a/packages/devtools-ui/src/styles.css b/packages/devtools-ui/src/styles.css index 8be304ab8..944b4fddf 100644 --- a/packages/devtools-ui/src/styles.css +++ b/packages/devtools-ui/src/styles.css @@ -47,6 +47,10 @@ --graph-zoom-bg: rgba(255, 255, 255, 0.9); --graph-node-stroke: #fff; --graph-export-bg: white; + --model-boundary: #7c3aed; + --model-boundary-fill: rgba(124, 58, 237, 0.06); + --model-badge-bg: #ede9fe; + --model-badge-text: #5b21b6; /* Tooltip */ --tooltip-bg: #1e293b; @@ -125,6 +129,10 @@ --graph-zoom-bg: rgba(22, 33, 62, 0.9); --graph-node-stroke: #16213e; --graph-export-bg: #16213e; + --model-boundary: #a78bfa; + --model-boundary-fill: rgba(167, 139, 250, 0.08); + --model-badge-bg: #3b2763; + --model-badge-text: #ddd6fe; /* Tooltip */ --tooltip-bg: #0f1629; @@ -204,6 +212,10 @@ --graph-zoom-bg: rgba(22, 33, 62, 0.9); --graph-node-stroke: #16213e; --graph-export-bg: #16213e; + --model-boundary: #a78bfa; + --model-boundary-fill: rgba(167, 139, 250, 0.08); + --model-badge-bg: #3b2763; + --model-badge-text: #ddd6fe; /* Tooltip */ --tooltip-bg: #0f1629; @@ -667,6 +679,18 @@ body { color: var(--brand-purple); } +.model-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + border-radius: 10px; + background: var(--model-badge-bg); + color: var(--model-badge-text); + font-size: 10px; + font-weight: 600; + vertical-align: middle; +} + .component-name { font-weight: 500; color: var(--brand-blue-deep); @@ -867,6 +891,21 @@ body { filter: drop-shadow(0 3px 6px var(--shadow-color)); } +.model-boundary { + fill: var(--model-boundary-fill); + stroke: var(--model-boundary); + stroke-width: 2; + stroke-dasharray: 8 5; + pointer-events: none; +} + +.model-boundary-label { + fill: var(--model-boundary); + font-size: 13px; + font-weight: 700; + pointer-events: none; +} + .graph-node-group.hovered .graph-node { filter: brightness(1.15) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.25)); stroke-width: 3; @@ -979,6 +1018,12 @@ body { word-break: break-word; } +.tooltip-model { + color: var(--tooltip-text); + font-size: 11px; + margin-bottom: 3px; +} + .tooltip-id { color: var(--tooltip-id); font-size: 11px; @@ -1033,6 +1078,14 @@ body { background-color: #9c27b0; } +.legend-model-boundary { + width: 16px; + height: 12px; + border: 2px dashed var(--model-boundary); + border-radius: 3px; + background: var(--model-boundary-fill); +} + .component-boundary { transition: all 0.2s ease; } diff --git a/packages/devtools-ui/src/types.ts b/packages/devtools-ui/src/types.ts index a0859a876..d5183fce7 100644 --- a/packages/devtools-ui/src/types.ts +++ b/packages/devtools-ui/src/types.ts @@ -1,3 +1,5 @@ +import type { ModelInfo } from "@preact/signals-devtools-adapter"; + /** * Re-export types from the adapter package for convenience */ @@ -5,6 +7,7 @@ export type { SignalUpdate, SignalDisposed, DependencyInfo, + ModelInfo, Settings, ConnectionStatus, ConnectionStatusType, @@ -21,6 +24,7 @@ export interface GraphNode { x: number; y: number; depth: number; + models?: ModelInfo[]; } export interface GraphLink { diff --git a/packages/devtools-ui/test/browser/index.test.tsx b/packages/devtools-ui/test/browser/index.test.tsx index ed5118ba9..e8b9e6cb9 100644 --- a/packages/devtools-ui/test/browser/index.test.tsx +++ b/packages/devtools-ui/test/browser/index.test.tsx @@ -315,6 +315,26 @@ describe("@preact/signals-devtools-ui", () => { expect(signalName!.textContent).to.contain("count"); }); + it("should render model membership", () => { + const update = { + type: "update" as const, + signalType: "signal" as const, + signalName: "count", + prevValue: 0, + newValue: 1, + receivedAt: Date.now(), + models: [{ id: "counter-1", name: "CounterModel", path: "count" }], + }; + + render(, scratch); + + const badge = scratch.querySelector(".model-badge"); + expect(badge!.textContent).to.equal("CounterModel"); + expect(badge!.getAttribute("title")).to.equal( + "CounterModel.count 路 counter-1" + ); + }); + it("should render effect signal name with effect type", () => { const update = { type: "effect" as const, @@ -1125,6 +1145,115 @@ describe("@preact/signals-devtools-ui", () => { expect(links.length).to.equal(2); }); + it("should group model members in a labeled boundary", () => { + initDevTools(mockAdapter); + const model = { + id: "counter-model-1", + name: "CounterModel", + }; + mockAdapter._emit("signalUpdate", [ + { + type: "update", + signalType: "computed", + signalName: "doubled", + signalId: "computed-doubled", + prevValue: 0, + newValue: 2, + receivedAt: Date.now(), + models: [{ ...model, path: "doubled" }], + allDependencies: [ + { + id: "signal-count", + name: "count", + type: "signal", + models: [{ ...model, path: "count" }], + }, + ], + }, + ]); + + render(, scratch); + + expect(scratch.querySelectorAll(".graph-node")).to.have.length(2); + expect(scratch.querySelectorAll(".model-boundary")).to.have.length(1); + expect( + scratch.querySelector(".model-boundary-label")!.textContent + ).to.equal("CounterModel"); + expect( + scratch.querySelector(".model-boundary-group title")!.textContent + ).to.equal("CounterModel 路 counter-model-1"); + }); + + it("should wrap dense layers instead of shrinking them into a line", () => { + initDevTools(mockAdapter); + + const denseLayerUpdates = Array.from({ length: 1000 }, (_, index) => ({ + type: "update" as const, + signalType: "signal" as const, + signalName: `signal-${index}`, + signalId: `signal-${index}`, + prevValue: index, + newValue: index + 1, + receivedAt: Date.now(), + })); + + mockAdapter._emit("signalUpdate", denseLayerUpdates); + render(, scratch); + + const nodes = Array.from( + scratch.querySelectorAll(".graph-node") + ); + const xPositions = new Set(nodes.map(node => node.getAttribute("cx"))); + const yPositions = new Set(nodes.map(node => node.getAttribute("cy"))); + const zoomPercent = parseInt( + scratch.querySelector(".graph-zoom-indicator")!.textContent!, + 10 + ); + + expect(nodes).to.have.length(1000); + expect(xPositions.size).to.be.greaterThan(10); + expect(yPositions.size).to.be.lessThan(50); + expect(zoomPercent).to.be.greaterThan(5); + }); + + it("should keep a wrapped dependency layer before its dependent layer", () => { + initDevTools(mockAdapter); + + const dependencies = Array.from({ length: 80 }, (_, index) => ({ + id: `signal-${index}`, + name: `signal-${index}`, + type: "signal" as const, + })); + mockAdapter._emit("signalUpdate", [ + { + type: "update", + signalType: "computed", + signalName: "total", + signalId: "computed-total", + prevValue: 0, + newValue: 1, + receivedAt: Date.now(), + allDependencies: dependencies, + }, + ]); + render(, scratch); + + const signals = Array.from( + scratch.querySelectorAll(".graph-node.signal") + ); + const computed = scratch.querySelector( + ".graph-node.computed" + )!; + const signalXPositions = signals.map(node => + Number(node.getAttribute("cx")) + ); + + expect(new Set(signalXPositions).size).to.be.greaterThan(1); + expect(Math.max(...signalXPositions)).to.be.lessThan( + Number(computed.getAttribute("cx")) + ); + }); + it("should export only graph nodes and links as JSON", async () => { initDevTools(mockAdapter); diff --git a/packages/preact-transform/README.md b/packages/preact-transform/README.md index 0a78e9ea6..62d140f2c 100644 --- a/packages/preact-transform/README.md +++ b/packages/preact-transform/README.md @@ -1,6 +1,6 @@ # Signals Preact Transform -A Babel plugin to provide names to all your `useComputed` and `useSignal` invocations. +A Babel plugin that provides debug names to `signal`, `computed`, `useSignal`, `useComputed`, and `createModel` invocations. ## Installation: diff --git a/packages/preact-transform/src/index.ts b/packages/preact-transform/src/index.ts index dcaa0083c..844143533 100644 --- a/packages/preact-transform/src/index.ts +++ b/packages/preact-transform/src/index.ts @@ -16,17 +16,19 @@ function basename(filename: string | undefined): string | undefined { return filename?.split(/[\\/]/).pop(); } -function isSignalCall(path: NodePath): boolean { +function isDebuggableCall(path: NodePath): boolean { const callee = path.get("callee"); - // Check direct function calls like signal(), computed(), useSignal(), useComputed() + // Check direct function calls like signal(), computed(), useSignal(), + // useComputed(), and createModel(). if (callee.isIdentifier()) { const name = callee.node.name; return ( name === "signal" || name === "computed" || name === "useSignal" || - name === "useComputed" + name === "useComputed" || + name === "createModel" ); } @@ -45,6 +47,8 @@ function getVariableNameFromDeclarator( ) { return currentPath.node.id.name; } + // Do not name a call after a variable outside its factory/callback scope. + if (currentPath !== path && currentPath.isFunction()) return null; currentPath = currentPath.parentPath; } return null; @@ -138,8 +142,8 @@ export default function signalsTransform( name: "@preact/signals-transform", visitor: { CallExpression(path, state) { - // Handle signal naming - if (isEnabled && isSignalCall(path)) { + // Handle signal and model naming + if (isEnabled && isDebuggableCall(path)) { const args = path.get("arguments"); // Only inject name if it doesn't already have one diff --git a/packages/preact-transform/test/node/index.test.tsx b/packages/preact-transform/test/node/index.test.tsx index 564735206..5db7ad228 100644 --- a/packages/preact-transform/test/node/index.test.tsx +++ b/packages/preact-transform/test/node/index.test.tsx @@ -72,6 +72,30 @@ describe("Preact Signals Babel Transform", () => { await runDebugTest(inputCode, expectedOutput, "Component.js"); }); + it("injects names for createModel calls", async () => { + const inputCode = ` + const CounterModel = createModel(() => { + const count = signal(0); + return { count }; + }); + `; + + const expectedOutput = ` + const CounterModel = createModel(() => { + const count = signal(0, { + name: "count (CounterModel.js:3)", + }); + return { + count, + }; + }, { + name: "CounterModel (CounterModel.js:2)", + }); + `; + + await runDebugTest(inputCode, expectedOutput, "CounterModel.js"); + }); + it("injects names for useSignal calls", async () => { const inputCode = ` function MyComponent() { diff --git a/packages/react-transform/README.md b/packages/react-transform/README.md index d1bf98f4c..fa4004abd 100644 --- a/packages/react-transform/README.md +++ b/packages/react-transform/README.md @@ -131,6 +131,19 @@ module.exports = { }; ``` +### `experimental.debug` + +Enable debug naming for `signal`, `computed`, `useSignal`, `useComputed`, and `createModel` calls. Model names let Signals DevTools group reactive values by their owning state container. + +```js +// babel.config.js +module.exports = { + plugins: [ + ["@preact/signals-react-transform", { experimental: { debug: true } }], + ], +}; +``` + ### `detectTransformedJSX` When enabled, alternative methods like `React.createElement` and `jsx-runtime` will also trigger transformation. diff --git a/packages/react-transform/src/index.ts b/packages/react-transform/src/index.ts index bb69d2953..70ee22be4 100644 --- a/packages/react-transform/src/index.ts +++ b/packages/react-transform/src/index.ts @@ -401,17 +401,19 @@ function isJSXAlternativeCall( return false; } -function isSignalCall(path: NodePath): boolean { +function isDebuggableCall(path: NodePath): boolean { const callee = path.get("callee"); - // Check direct function calls like signal(), computed(), useSignal(), useComputed() + // Check direct function calls like signal(), computed(), useSignal(), + // useComputed(), and createModel(). if (callee.isIdentifier()) { const name = callee.node.name; return ( name === "signal" || name === "computed" || name === "useSignal" || - name === "useComputed" + name === "useComputed" || + name === "createModel" ); } @@ -430,6 +432,8 @@ function getVariableNameFromDeclarator( ) { return currentPath.node.id.name; } + // Do not name a call after a variable outside its factory/callback scope. + if (currentPath !== path && currentPath.isFunction()) return null; currentPath = currentPath.parentPath; } return null; @@ -794,6 +798,7 @@ export interface PluginOptions { * * - computed/useComputed * - signal/useSignal + * - createModel * * these names hook into @preact/signals-debug. * @@ -903,8 +908,8 @@ export default function signalsTransform( } } - // Handle signal naming - if (options.experimental?.debug && isSignalCall(path)) { + // Handle signal and model naming + if (options.experimental?.debug && isDebuggableCall(path)) { const args = path.get("arguments"); // Only inject name if it doesn't already have one diff --git a/packages/react-transform/test/node/index.test.tsx b/packages/react-transform/test/node/index.test.tsx index 0221ed9ce..ea1f64c08 100644 --- a/packages/react-transform/test/node/index.test.tsx +++ b/packages/react-transform/test/node/index.test.tsx @@ -972,6 +972,30 @@ describe("React Signals Babel Transform", () => { await runDebugTest(inputCode, expectedOutput, "Component.js"); }); + it("injects names for createModel calls", async () => { + const inputCode = ` + const CounterModel = createModel(() => { + const count = signal(0); + return { count }; + }); + `; + + const expectedOutput = ` + const CounterModel = createModel(() => { + const count = signal(0, { + name: "count (CounterModel.js:3)", + }); + return { + count, + }; + }, { + name: "CounterModel (CounterModel.js:2)", + }); + `; + + await runDebugTest(inputCode, expectedOutput, "CounterModel.js"); + }); + it("injects names for useSignal calls", async () => { const inputCode = ` function MyComponent() { diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index adf4ac8c3..0ee761cbd 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -44,8 +44,8 @@ export default defineConfig({ Transform behavior when `framework` is set: - React projects get `@preact/signals-react-transform` in both dev and build -- React development also enables the transform's debug metadata so component and signal names show up in `@preact/signals-debug` -- Preact projects get `@preact/signals-preact-transform` during development so signal names are injected automatically +- React development also enables the transform's debug metadata so component, signal, and model names show up in `@preact/signals-debug` +- Preact projects get `@preact/signals-preact-transform` during development so signal and model names are injected automatically If you only want the transform wiring and not the local debug API, use: @@ -72,7 +72,7 @@ Query params for event reads: - `after=` - only return events after a known cursor - `limit=` - return only the most recent matching events -- `filterPatterns=AuthForm,login` or repeated `filterPatterns` params - match events by summary, signal name, page info, and related metadata +- `filterPatterns=AuthForm,login` or repeated `filterPatterns` params - match events by summary, signal/model name, page info, and related metadata - `filter=...` - alias for `filterPatterns` - `source=signals|network|page` - restrict results to selected sources diff --git a/packages/vite-plugin/src/client-module.ts b/packages/vite-plugin/src/client-module.ts index 2f0310204..03dcd9087 100644 --- a/packages/vite-plugin/src/client-module.ts +++ b/packages/vite-plugin/src/client-module.ts @@ -239,6 +239,7 @@ export function installSignalsAgentClient() { prevValue: sanitize(update.prevValue), newValue: sanitize(update.newValue), subscribedTo: update.subscribedTo, + models: sanitize(update.models), allDependencies: sanitize(update.allDependencies), timestamp: update.timestamp, }))); @@ -252,6 +253,7 @@ export function installSignalsAgentClient() { type: 'disposed', signalType: disposal.signalType, signalName: disposal.signalName, + models: sanitize(disposal.models), timestamp: disposal.timestamp, }))); }); diff --git a/packages/vite-plugin/src/server.ts b/packages/vite-plugin/src/server.ts index c13f317cc..49155f023 100644 --- a/packages/vite-plugin/src/server.ts +++ b/packages/vite-plugin/src/server.ts @@ -272,6 +272,10 @@ function normalizeIncomingEvent(rawEvent: any): SignalsAgentEvent | null { DEFAULT_REDACTION_PATTERNS ), subscribedTo: toStringValue(rawEvent?.subscribedTo), + models: sanitizeForTransport( + rawEvent?.models, + DEFAULT_REDACTION_PATTERNS + ), allDependencies: sanitizeForTransport( rawEvent?.allDependencies, DEFAULT_REDACTION_PATTERNS diff --git a/packages/vite-plugin/src/shared.ts b/packages/vite-plugin/src/shared.ts index a13ce79e1..0aaeb2124 100644 --- a/packages/vite-plugin/src/shared.ts +++ b/packages/vite-plugin/src/shared.ts @@ -17,10 +17,17 @@ export interface SignalsAgentEventBase { summary?: string; } +export interface SignalsAgentModel { + id: string; + name: string; + path?: string; +} + export interface SignalsAgentDependency { id: string; name: string; type: "signal" | "computed"; + models?: SignalsAgentModel[]; } export interface SignalsAgentSignalEvent extends SignalsAgentEventBase { @@ -31,6 +38,7 @@ export interface SignalsAgentSignalEvent extends SignalsAgentEventBase { prevValue?: any; newValue?: any; subscribedTo?: string; + models?: SignalsAgentModel[]; allDependencies?: SignalsAgentDependency[]; } @@ -185,6 +193,7 @@ export function summarizeEvent(event: SignalsAgentEvent): string { case "signals": return [ event.signalName, + ...(event.models ?? []).map(model => model.name), event.signalType, event.type, event.page.pathname, @@ -262,8 +271,14 @@ function getEventCandidates(event: SignalsAgentEvent): string[] { if (event.source === "signals") { candidates.push(event.signalName, event.signalType); + for (const model of event.models ?? []) { + candidates.push(model.name, model.id, model.path); + } for (const dependency of event.allDependencies ?? []) { candidates.push(dependency.name, dependency.id, dependency.type); + for (const model of dependency.models ?? []) { + candidates.push(model.name, model.id, model.path); + } } } else if (event.source === "network") { candidates.push(event.method, event.requestUrl, String(event.status ?? "")); diff --git a/packages/vite-plugin/test/node/index.test.tsx b/packages/vite-plugin/test/node/index.test.tsx index db2f6ef32..8b6896de3 100644 --- a/packages/vite-plugin/test/node/index.test.tsx +++ b/packages/vite-plugin/test/node/index.test.tsx @@ -266,6 +266,28 @@ describe("@preact/signals-agent-vite", () => { ).to.have.length(1); }); + it("filters signal events by model metadata", () => { + const events: SignalsAgentEvent[] = [ + { + source: "signals", + type: "update", + timestamp: 1, + id: 1, + page, + signalName: "count", + signalType: "signal", + models: [{ id: "model-1", name: "CounterModel", path: "count" }], + }, + ]; + + expect( + queryEvents(events, { + filterPatterns: ["CounterModel"], + sources: ["signals"], + }) + ).to.have.length(1); + }); + it("streams only matching session events", () => { const store = createSignalsAgentStore({ maxEvents: 10 }); const session = store.createSession({ filterPatterns: ["AuthForm"] });