Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/expose-controllers-devtools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@data-client/core': patch
'@data-client/react': patch
'@data-client/vue': patch
---

Add `globalThis.__DC_CONTROLLERS__` Map in dev mode for programmatic store access from browser DevTools MCP, React Native debuggers, and other development tooling.

Each [DataProvider](/docs/api/DataProvider) registers its [Controller](/docs/api/Controller) keyed by the devtools connection name, supporting multiple providers on the same page.
9 changes: 9 additions & 0 deletions .cursor/skills/data-client-react/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ const todosByUser = useQuery(groupTodoByUser);

---

## Browser Debugging (Chrome DevTools MCP)

To inspect store state, track dispatched [actions](references/Actions.md), or invoke
[Controller](references/Controller.md) methods from a browser MCP (`user-chrome-devtools`),
see [devtools-debugging](references/devtools-debugging.md). Uses `globalThis.__DC_CONTROLLERS__`
available in dev mode.

## Managers

Custom [Managers](https://dataclient.io/docs/api/Manager) allow for global side effect handling.
Expand Down Expand Up @@ -138,5 +145,7 @@ For detailed API documentation, see the [references](references/) directory:
- [DataProvider](references/DataProvider.md) - Root provider
- [data-dependency](references/data-dependency.md) - Rendering guide
- [mutations](references/mutations.md);[_VoteDemo.mdx](references/_VoteDemo.mdx) - Mutations guide
- [Actions](references/Actions.md) - Store action types (FETCH, SET, etc.)
- [devtools-debugging](references/devtools-debugging.md) - Debug with Chrome DevTools MCP

**ALWAYS follow these patterns and refer to the official docs for edge cases. Prioritize code generation that is idiomatic, type-safe, and leverages automatic normalization/caching via skill "data-client-schema" definitions.**
1 change: 1 addition & 0 deletions .cursor/skills/data-client-react/references/Actions.md
229 changes: 229 additions & 0 deletions .cursor/skills/data-client-react/references/devtools-debugging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
# Debugging @data-client/react with Chrome DevTools MCP

Use `user-chrome-devtools` MCP tools to programmatically inspect and interact with the
data-client store in a running browser. Requires dev mode (`NODE_ENV !== 'production'`).

For full [Controller](./Controller.md) API (fetch, set, invalidate, etc.) and
[Action](./Actions.md) types (FETCH, SET, INVALIDATE, etc.), see those references.

## Setup: Inject Action Logger

On first navigation (or reload), inject a logging shim via `initScript` to capture all
dispatched actions before the app boots. This gives you a persistent action log to query.

```js
navigate_page → initScript:

window.__DC_ACTION_LOG__ = [];
const _origDispatch = Object.getOwnPropertyDescriptor(Object.getPrototypeOf({}), 'dispatch');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code in skill documentation example

Low Severity

The _origDispatch variable is assigned the result of Object.getOwnPropertyDescriptor(Object.getPrototypeOf({}), 'dispatch'), which always returns undefined since Object.prototype has no dispatch property. The variable is never referenced again in the code block. This appears to be leftover from an earlier approach and is dead code in a skill file that AI coding assistants will follow as a template.

Fix in Cursor Fix in Web

Object.defineProperty(globalThis, '__DC_INTERCEPT_DISPATCH__', {
value(action) {
const entry = {
type: action.type,
key: action.meta?.key,
schema: action.endpoint?.name ?? action.schema?.constructor?.name,
timestamp: Date.now(),
};
if (action.error) entry.error = true;
globalThis.__DC_ACTION_LOG__.push(entry);
if (globalThis.__DC_ACTION_LOG__.length > 500)
globalThis.__DC_ACTION_LOG__ = globalThis.__DC_ACTION_LOG__.slice(-250);
},
writable: true, configurable: true,
});
```

Then after the app loads, hook into the controller's dispatch. First discover
the `devtoolsName` key (see "Accessing the Controller"), then use it:

```js
evaluate_script:

() => {
const ctrl = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App');
if (!ctrl) return 'no controller yet';
const orig = ctrl._dispatch.bind(ctrl);
ctrl._dispatch = (action) => {
globalThis.__DC_INTERCEPT_DISPATCH__?.(action);
return orig(action);
};
return 'dispatch intercepted';
}
```

## Accessing the Controller

`DevToolsManager` registers controllers in `globalThis.__DC_CONTROLLERS__` (a `Map`) keyed by
`devtoolsName` — defaults to `"Data Client: <page title>"`.

**Step 1: Discover available controllers and their keys.**

```js
() => {
const m = globalThis.__DC_CONTROLLERS__;
if (!m || m.size === 0) return 'no controllers registered';
return [...m.keys()];
}
```

**Step 2: Get a controller by its `devtoolsName` key.** Use the key from Step 1.

```js
() => {
const ctrl = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App');
return ctrl ? 'controller found' : 'not found';
}
```

Always use `.get(devtoolsName)` with the actual key from Step 1 — not `.values().next().value`
— so you target the correct store when multiple `DataProvider`s exist.

## Reading State

The store state shape is `{ entities, endpoints, indexes, meta, entitiesMeta, optimistic, lastReset }`.

### Full state overview

```js
() => {
const state = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App')?.getState();
if (!state) return 'no state';
return {
entityTypes: Object.keys(state.entities),
endpointCount: Object.keys(state.endpoints).length,
optimisticCount: state.optimistic.length,
lastReset: state.lastReset,
};
}
```

### List all entities of a type

```js
() => {
const state = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App')?.getState();
const entities = state?.entities?.['Todo'];
if (!entities) return 'no Todo entities';
return Object.values(entities);
}
```

### Inspect a specific entity by pk

```js
() => {
const state = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App')?.getState();
return state?.entities?.['Todo']?.['5'];
}
```

### List cached endpoint keys

```js
() => {
const state = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App')?.getState();
return Object.keys(state?.endpoints ?? {});
}
```

### Get endpoint response (raw, before denormalization)

```js
() => {
const state = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App')?.getState();
const key = Object.keys(state?.endpoints ?? {}).find(k => k.includes('GET /todos'));
return key ? { key, data: state.endpoints[key] } : 'not found';
}
```

### Check endpoint metadata (expiry, errors, invalidation)

```js
() => {
const state = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App')?.getState();
const key = Object.keys(state?.meta ?? {}).find(k => k.includes('/todos'));
return key ? { key, ...state.meta[key] } : 'no meta found';
}
```

## Dispatching Actions

Use the [Controller](./Controller.md) to mutate the store. Always look up by `devtoolsName`.
See [Actions](./Actions.md) for the full list of action types dispatched.

### Reset the entire store

```js
() => {
const ctrl = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App');
ctrl?.resetEntireStore();
return 'store reset';
}
```

### Invalidate all endpoints (force refetch)

```js
() => {
const ctrl = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App');
ctrl?.invalidateAll({ testKey: () => true });
return 'all invalidated';
}
```

### Invalidate endpoints matching a pattern

```js
() => {
const ctrl = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App');
ctrl?.invalidateAll({ testKey: key => key.includes('/todos') });
return 'todo endpoints invalidated';
}
```

### Expire endpoints (mark stale, refetch on next use)

```js
() => {
const ctrl = globalThis.__DC_CONTROLLERS__?.get('Data Client: My App');
ctrl?.expireAll({ testKey: key => key.includes('/todos') });
return 'todo endpoints expired';
}
```

## Reading the Action Log

After injecting the dispatch interceptor (see Setup):

### Recent actions

```js
() => globalThis.__DC_ACTION_LOG__?.slice(-20) ?? 'no log'
```

### Filter by type

```js
() => globalThis.__DC_ACTION_LOG__?.filter(a => a.type === 'rdc/set') ?? []
```

### Filter errors

```js
() => globalThis.__DC_ACTION_LOG__?.filter(a => a.error) ?? []
```

## Correlating with Network Requests

Use `list_network_requests` with `resourceTypes: ["fetch", "xhr"]` to see API calls,
then cross-reference with endpoint keys in state.

## Debugging Checklist

1. **Verify controller exists**: Check `__DC_CONTROLLERS__` map size
2. **Inspect state shape**: Get entity types and endpoint count
3. **Check specific data**: Look up entities by type and pk
4. **Review endpoint metadata**: Check expiry, errors, invalidation status
5. **Track actions**: Read the action log for recent dispatches
6. **Correlate network**: Compare `list_network_requests` with endpoint keys
7. **Force refresh**: Use `invalidateAll` or `expireAll` to trigger refetches
24 changes: 24 additions & 0 deletions docs/core/api/DevToolsManager.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ const managers = getDefaultManagers({
});
```

## Programmatic store access {#controllers}

In development mode, `DevToolsManager` registers each [Controller](/docs/api/Controller) on
`globalThis.__DC_CONTROLLERS__` — a `Map` keyed by the devtools connection name. This works
in browsers, React Native, and Node.

```js title="Browser DevTools console"
// List all registered providers
__DC_CONTROLLERS__.keys();

// Get state from the first provider
__DC_CONTROLLERS__.values().next().value.getState();

// Get state by name
__DC_CONTROLLERS__.get('Data Client: My App').getState();
```

This is useful for AI coding assistants using the [Chrome DevTools MCP](https://developer.chrome.com/blog/chrome-devtools-mcp)
or [Expo MCP](https://docs.expo.dev/eas/ai/mcp/) to programmatically inspect and interact
with the store. Each [DataProvider](/docs/api/DataProvider) registers independently, so
multiple providers on the same page are fully supported.

Controllers are removed from the map when `cleanup()` is called.

## More info

Using this Manager allows in browser [debugging and store inspection](../getting-started/debugging.md).
23 changes: 17 additions & 6 deletions packages/core/src/manager/DevtoolsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,20 +94,21 @@ export default class DevToolsManager implements Manager {
protected actions: [ActionTypes, State<unknown>][] = [];
declare protected controller: Controller;
declare skipLogging?: (action: ActionTypes) => boolean;
declare devtoolsName: string;
maxBufferLength = 100;

constructor(
config?: DevToolsConfig,
skipLogging?: (action: ActionTypes) => boolean,
) {
/* istanbul ignore next */
const options = { ...DEFAULT_CONFIG, ...config };
this.devtoolsName =
options.name ?? `Data Client: ${globalThis.document?.title}`;
this.devTools =
typeof window !== 'undefined' &&
(window as any).__REDUX_DEVTOOLS_EXTENSION__ &&
(window as any).__REDUX_DEVTOOLS_EXTENSION__.connect({
...DEFAULT_CONFIG,
...config,
});
(window as any).__REDUX_DEVTOOLS_EXTENSION__.connect(options);
// we cut it in half so we should double so we don't lose
if (config?.maxAge) this.maxBufferLength = config.maxAge * 2;
if (skipLogging) this.skipLogging = skipLogging;
Expand All @@ -118,8 +119,8 @@ export default class DevToolsManager implements Manager {
/* istanbul ignore next */
if (process.env.NODE_ENV !== 'production') {
this.prototype.middleware = function (controller) {
if (!this.devTools) return next => action => next(action);
this.controller = controller;
if (!this.devTools) return next => action => next(action);
const reducer = createReducer(controller as any);
let state = controller.getState();
return next => action => {
Expand Down Expand Up @@ -158,6 +159,12 @@ export default class DevToolsManager implements Manager {

/** Called when initial state is ready */
init(state: State<any>) {
if (process.env.NODE_ENV !== 'production') {
((globalThis as any).__DC_CONTROLLERS__ ??= new Map()).set(
this.devtoolsName,
this.controller,
);
}
if (process.env.NODE_ENV !== 'production' && this.devTools) {
this.devTools.init(state);
this.devTools.subscribe((msg: any) => {
Expand Down Expand Up @@ -186,5 +193,9 @@ export default class DevToolsManager implements Manager {
}

/** Ensures all subscriptions are cleaned up. */
cleanup() {}
cleanup() {
if (process.env.NODE_ENV !== 'production') {
(globalThis as any).__DC_CONTROLLERS__?.delete(this.devtoolsName);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup removes another provider's active controller entry

Low Severity

When multiple DataProviders use the default DevToolsManager (no custom name config), they share the same devtoolsName from DEFAULT_CONFIG.name. The cleanup() unconditionally deletes the map entry by name without checking if the entry still belongs to this instance. If Provider A unmounts, it removes Provider B's still-active controller from __DC_CONTROLLERS__, even though Provider B registered after A. The delete call needs an identity check like map.get(name) === this.controller before removing. The PR docs claim multiple providers are "fully supported," but the default naming makes this collision the common case.

Additional Locations (1)

Fix in Cursor Fix in Web

}
1 change: 1 addition & 0 deletions website/blog/2026-01-19-v0.16-release-announcement.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { parallelFetchFixtures } from '@site/src/fixtures/post-comments';
**Performance:**

**Other Improvements:**
- [DevToolsManager](/docs/api/DevToolsManager#controllers) exposes `globalThis.__DC_CONTROLLERS__` in dev mode for programmatic store access from [Chrome DevTools MCP](https://developer.chrome.com/blog/chrome-devtools-mcp) and [Expo MCP](https://docs.expo.dev/eas/ai/mcp/)
- Remove misleading 'Uncaught Suspense' warning during Next.js SSR
- Fix `sideEffect: false` type being lost with `method: 'POST'` in [RestEndpoint](/rest/api/RestEndpoint)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,7 @@ declare class DevToolsManager implements Manager {
protected actions: [ActionTypes, State<unknown>][];
protected controller: Controller;
skipLogging?: (action: ActionTypes) => boolean;
devtoolsName: string;
maxBufferLength: number;
constructor(config?: DevToolsConfig, skipLogging?: (action: ActionTypes) => boolean);
handleAction(action: any, state: any): void;
Expand Down