Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-graphs-wrap.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions .changeset/model-devtools.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
46 changes: 45 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1010,6 +1014,11 @@ type ValidateModel<TModel> = {

export type Model<TModel> = ValidateModel<TModel> & DisposableLike;

export interface ModelOptions {
/** A name used to identify model instances in debug tooling. */
name?: string;
}

export type ModelFactory<TModel, TFactoryArgs extends any[] = []> = (
...args: TFactoryArgs
) => ValidateModel<TModel>;
Expand Down Expand Up @@ -1086,8 +1095,38 @@ const wrapInAction = (value: Record<string, unknown>) => {
}
};

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<ModelHook>) {
try {
hook(model, effects, name);
} catch {
// Debug instrumentation must not affect model initialization.
}
}
}

function createModel<TModel, TFactoryArgs extends any[] = []>(
modelFactory: ModelFactory<TModel, TFactoryArgs>
modelFactory: ModelFactory<TModel, TFactoryArgs>,
options?: ModelOptions
): ModelConstructor<TModel, TFactoryArgs> {
return function SignalModel(...args: TFactoryArgs): Model<TModel> {
let modelEffects: Effect[] | undefined;
Expand All @@ -1107,6 +1146,11 @@ function createModel<TModel, TFactoryArgs extends any[] = []>(
}

wrapInAction(model);
notifyModelHooks(
model,
modelEffects,
options?.name || modelFactory.name || "Model"
);

model[Symbol.dispose] = action(function disposeModel() {
if (modelEffects) {
Expand Down
43 changes: 43 additions & 0 deletions packages/core/test/signal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions packages/debug/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
13 changes: 11 additions & 2 deletions packages/debug/src/devtools.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { UpdateInfo, DependencyInfo } from "./internal";
import { UpdateInfo, DependencyInfo, ModelInfo } from "./internal";
import {
getSignalId,
getSignalName,
getModelInfo,
isReactOrPreactElement,
formatReactElement,
} from "./utils";
Expand All @@ -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[];
}
Expand All @@ -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
Expand Down Expand Up @@ -73,7 +78,6 @@ class DevToolsCommunicator {
public isExtensionConnected = false;
public messageQueue: DevToolsMessage[] = [];
public readonly maxQueueSize = 100;
public signalOwnership = new WeakMap<any, Set<string>>();

constructor() {
this.setupCommunication();
Expand Down Expand Up @@ -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),
Expand All @@ -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"),
Expand All @@ -177,6 +184,7 @@ class DevToolsCommunicator {
} else {
return {
...info,
...(models ? { models } : {}),
type: "effect" as const,
signalType: "effect" as const,
signalName: this.getSignalName(signal, "effect"),
Expand Down Expand Up @@ -257,6 +265,7 @@ class DevToolsCommunicator {
),
signalId: this.getSignalId(signal),
timestamp: Date.now(),
models: getModelInfo(signal),
};

// Emit for direct listeners (e.g., DirectAdapter)
Expand Down
29 changes: 23 additions & 6 deletions packages/debug/src/index.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -230,6 +245,7 @@ function getAllCurrentDependencies(
id,
name: getSignalName(source, "value"),
type: "_fn" in source ? "computed" : "signal",
models: getModelInfo(source),
});
}
sourceNode = sourceNode._nextSource;
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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") {
Expand Down
7 changes: 7 additions & 0 deletions packages/debug/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading