From 76adee41b8a7540620a8215457673fc135c7e5ea Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 28 Feb 2026 19:19:17 -0500 Subject: [PATCH 1/2] feat: Expose __DC_CONTROLLERS__ for MCP integration into devtools --- .changeset/expose-controllers-devtools.md | 9 +++++++ docs/core/api/DevToolsManager.md | 24 +++++++++++++++++++ packages/core/src/manager/DevtoolsManager.ts | 23 +++++++++++++----- .../2026-01-19-v0.16-release-announcement.md | 1 + 4 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 .changeset/expose-controllers-devtools.md diff --git a/.changeset/expose-controllers-devtools.md b/.changeset/expose-controllers-devtools.md new file mode 100644 index 000000000000..f6bb36bb2a27 --- /dev/null +++ b/.changeset/expose-controllers-devtools.md @@ -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. diff --git a/docs/core/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index 2663fcb996eb..921615825e0c 100644 --- a/docs/core/api/DevToolsManager.md +++ b/docs/core/api/DevToolsManager.md @@ -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). diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index 5416a559bbc1..df872c6687ba 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -94,6 +94,7 @@ export default class DevToolsManager implements Manager { protected actions: [ActionTypes, State][] = []; declare protected controller: Controller; declare skipLogging?: (action: ActionTypes) => boolean; + declare protected devtoolsName: string; maxBufferLength = 100; constructor( @@ -101,13 +102,13 @@ export default class DevToolsManager implements Manager { 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; @@ -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 => { @@ -158,6 +159,12 @@ export default class DevToolsManager implements Manager { /** Called when initial state is ready */ init(state: State) { + 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) => { @@ -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); + } + } } diff --git a/website/blog/2026-01-19-v0.16-release-announcement.md b/website/blog/2026-01-19-v0.16-release-announcement.md index a9c96ee6db7e..f831f7df6b20 100644 --- a/website/blog/2026-01-19-v0.16-release-announcement.md +++ b/website/blog/2026-01-19-v0.16-release-announcement.md @@ -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) From db5b610c903d4d3a36be03e500f81623e0f535f3 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 1 Mar 2026 16:47:10 -0500 Subject: [PATCH 2/2] internal(skill): Add devtools debugging reference --- .cursor/skills/data-client-react/SKILL.md | 9 + .../data-client-react/references/Actions.md | 1 + .../references/devtools-debugging.md | 229 ++++++++++++++++++ packages/core/src/manager/DevtoolsManager.ts | 2 +- .../editor-types/@data-client/core.d.ts | 1 + 5 files changed, 241 insertions(+), 1 deletion(-) create mode 120000 .cursor/skills/data-client-react/references/Actions.md create mode 100644 .cursor/skills/data-client-react/references/devtools-debugging.md diff --git a/.cursor/skills/data-client-react/SKILL.md b/.cursor/skills/data-client-react/SKILL.md index aafe575cf45e..45bfdcdc927f 100644 --- a/.cursor/skills/data-client-react/SKILL.md +++ b/.cursor/skills/data-client-react/SKILL.md @@ -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. @@ -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.** diff --git a/.cursor/skills/data-client-react/references/Actions.md b/.cursor/skills/data-client-react/references/Actions.md new file mode 120000 index 000000000000..921a154aab5d --- /dev/null +++ b/.cursor/skills/data-client-react/references/Actions.md @@ -0,0 +1 @@ +../../data-client-manager/references/Actions.md \ No newline at end of file diff --git a/.cursor/skills/data-client-react/references/devtools-debugging.md b/.cursor/skills/data-client-react/references/devtools-debugging.md new file mode 100644 index 000000000000..5c7472f9fb2d --- /dev/null +++ b/.cursor/skills/data-client-react/references/devtools-debugging.md @@ -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'); +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: "`. + +**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 diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index df872c6687ba..4d4f07bd8904 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -94,7 +94,7 @@ export default class DevToolsManager implements Manager { protected actions: [ActionTypes, State][] = []; declare protected controller: Controller; declare skipLogging?: (action: ActionTypes) => boolean; - declare protected devtoolsName: string; + declare devtoolsName: string; maxBufferLength = 100; constructor( diff --git a/website/src/components/Playground/editor-types/@data-client/core.d.ts b/website/src/components/Playground/editor-types/@data-client/core.d.ts index 4edf2d63aa3f..cfb693bc764d 100644 --- a/website/src/components/Playground/editor-types/@data-client/core.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/core.d.ts @@ -1393,6 +1393,7 @@ declare class DevToolsManager implements Manager { protected actions: [ActionTypes, State][]; protected controller: Controller; skipLogging?: (action: ActionTypes) => boolean; + devtoolsName: string; maxBufferLength: number; constructor(config?: DevToolsConfig, skipLogging?: (action: ActionTypes) => boolean); handleAction(action: any, state: any): void;