From 8dce3ea6a135f58038c6b71cd4378e4cf6055544 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:10:21 +0000 Subject: [PATCH 01/17] feat(core): Log recent actions on __DC_ACTIONS__ and give same-named stores unique keys DevToolsManager now records each store's recent actions in development, from startup and with or without the Redux DevTools extension, on globalThis.__DC_ACTIONS__ (keyed like __DC_CONTROLLERS__). A store whose devtools name is taken registers as 'name (2)' instead of replacing the first. The debugging skill uses the built-in log and falls back to wrapping dispatch on older versions. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- .../references/devtools-debugging.md | 59 ++++++---- .../references/devtools-debugging.md | 59 ++++++---- .changeset/devtools-action-log.md | 15 +++ .changeset/devtools-unique-controller-keys.md | 11 ++ docs/core/_agents/devtools-debugging.md | 59 ++++++---- docs/core/api/DevToolsManager.md | 15 ++- packages/core/src/manager/DevtoolsManager.ts | 51 ++++++-- .../src/manager/__tests__/devtoolsManager.ts | 110 ++++++++++++++++++ website/blog/2026-10-06-coding-agents.md | 5 +- 9 files changed, 292 insertions(+), 92 deletions(-) create mode 100644 .changeset/devtools-action-log.md create mode 100644 .changeset/devtools-unique-controller-keys.md create mode 100644 packages/core/src/manager/__tests__/devtoolsManager.ts diff --git a/.agents/skills/data-client-react/references/devtools-debugging.md b/.agents/skills/data-client-react/references/devtools-debugging.md index dede392e3b1f..e97ca0156be3 100644 --- a/.agents/skills/data-client-react/references/devtools-debugging.md +++ b/.agents/skills/data-client-react/references/devtools-debugging.md @@ -13,14 +13,16 @@ Debug `@data-client/react` state and actions programmatically via [Chrome DevToo ## Step 1: Install the Debug Shim Run this after each page load (a reload clears it). It picks a controller, exposes it as -`__DC_CTRL__` for every later snippet, and wraps dispatch to record actions in `__DC_ACTION_LOG__`. -Rerunning it is safe. A `ReferenceError` for either global means the shim is not installed. +`__DC_CTRL__` for every later snippet, and points `__DC_ACTION_LOG__` at its recent actions. Rerunning it +is safe. A `ReferenceError` for either global means the shim is not installed. `DevToolsManager` registers controllers in `globalThis.__DC_CONTROLLERS__` keyed by `devtoolsName`, which defaults to `"Data Client: "`. With a single controller, leave `NAME` unset. With several, the shim returns their keys; rerun it with `NAME` set to the one to target. -`DataProvider`s sharing the default name overwrite each other, so give each a distinct -[`name`](https://dataclient.io/docs/api/DevToolsManager) to inspect them separately. + +Newer versions record actions from startup in `globalThis.__DC_ACTIONS__` (same keys), and the shim uses +that log (`actionLog: 'built-in'`). On older versions it wraps dispatch instead (`actionLog: 'shim'`), +which only sees actions from then on. ```js // evaluate_script @@ -30,44 +32,41 @@ unset. With several, the shim returns their keys; rerun it with `NAME` set to th if (!controllers?.size) return { error: 'No controllers registered' }; if (!NAME && controllers.size > 1) return { error: 'Set NAME to one of', keys: [...controllers.keys()] }; - const ctrl = NAME ? controllers.get(NAME) : controllers.values().next().value; + const key = NAME ?? controllers.keys().next().value; + const ctrl = controllers.get(key); if (!ctrl) return { error: 'Controller not found', keys: [...controllers.keys()] }; + const stateKeys = Object.keys(ctrl.getState()); - if (globalThis.__DC_CTRL__ !== ctrl) globalThis.__DC_ACTION_LOG__ = []; + const builtIn = globalThis.__DC_ACTIONS__?.get(key); + if (builtIn || globalThis.__DC_CTRL__ !== ctrl) globalThis.__DC_ACTION_LOG__ = builtIn ?? []; globalThis.__DC_CTRL__ = ctrl; + if (builtIn) return { ok: true, actionLog: 'built-in', stateKeys }; // dispatch is rebound if the middleware chain is rebuilt, so check the wrapper, not the alias - if (ctrl._dispatch.__dcDebugShim) return { ok: true, alreadyInstalled: true }; + if (ctrl._dispatch.__dcDebugShim) return { ok: true, actionLog: 'shim', stateKeys }; const MAX_LOG = 200; const origDispatch = ctrl._dispatch; const shim = (action) => { if (globalThis.__DC_CTRL__ === ctrl) { - const entry = { type: action.type, key: action.key, ts: Date.now() }; - if (action.endpoint) entry.endpoint = action.endpoint.name || action.endpoint.key; - if (action.args) entry.args = action.args; - if (action.meta?.date) entry.date = action.meta.date; - if (action.error) entry.error = action.response?.message || true; - const log = globalThis.__DC_ACTION_LOG__; - log.push(entry); - if (log.length > MAX_LOG) globalThis.__DC_ACTION_LOG__ = log.slice(-MAX_LOG / 2); + log.push(action); + if (log.length > MAX_LOG) log.splice(0, MAX_LOG / 2); } return origDispatch(action); }; shim.__dcDebugShim = true; ctrl._dispatch = shim; - return { ok: true, stateKeys: Object.keys(ctrl.getState()) }; + return { ok: true, actionLog: 'shim', stateKeys }; })() ``` ### Gotchas -- **Actions from before the shim are not logged**, including the fetches made while the page - loaded. For a load-time problem, read the outcome from state (`meta` has each key's `date`, - `expiresAt` and `error`; see Step 2) and the requests from `list_network_requests`. To watch the - fetches happen, install the shim, then repeat them with `invalidateAll` (Step 4) or an in-app - navigation; a reload clears the shim. +- **With `actionLog: 'shim'`, actions from before the shim are not logged**, including the fetches + made while the page loaded. For a load-time problem, read the outcome from state (`meta` has each key's + `date`, `expiresAt` and `error`; see Step 2) and the requests from `list_network_requests`. To watch the + fetches happen, repeat them with `invalidateAll` (Step 4) or an in-app navigation; a reload clears the shim. - **Rerun Step 1 when the log stops growing or the data looks stale.** Nothing errors when a remount of `DataProvider` creates a new controller, or rebuilding its middleware drops the wrapper. @@ -191,8 +190,15 @@ After installing the debug shim (Step 1): ### Read recent actions ```js -// evaluate_script -() => __DC_ACTION_LOG__.slice(-20) +// evaluate_script — actions hold endpoints and promises, so return just these fields +() => __DC_ACTION_LOG__.slice(-20).map(a => ({ + type: a.type, + key: a.key, + endpoint: a.endpoint?.name, + args: a.args, + date: a.meta?.date, + error: a.error ? (a.response?.message || true) : undefined, +})) ``` ### Filter by action type @@ -202,20 +208,23 @@ After installing the debug shim (Step 1): () => __DC_ACTION_LOG__ .filter(a => a.type === 'rdc/fetch' || a.type === 'rdc/setresponse') .slice(-20) + .map(a => ({ type: a.type, key: a.key, date: a.meta?.date })) ``` ### Filter errors ```js // evaluate_script -() => __DC_ACTION_LOG__.filter(a => a.error) +() => __DC_ACTION_LOG__ + .filter(a => a.error) + .map(a => ({ type: a.type, key: a.key, error: a.response?.message || true })) ``` ### Clear action log ```js // evaluate_script -() => { globalThis.__DC_ACTION_LOG__ = []; return { cleared: true }; } +() => { __DC_ACTION_LOG__.length = 0; return { cleared: true }; } ``` ## Step 4: Mutate State via Controller diff --git a/.agents/skills/data-client-vue/references/devtools-debugging.md b/.agents/skills/data-client-vue/references/devtools-debugging.md index 15e8201281b3..d8d05fc7726e 100644 --- a/.agents/skills/data-client-vue/references/devtools-debugging.md +++ b/.agents/skills/data-client-vue/references/devtools-debugging.md @@ -13,14 +13,16 @@ Debug `@data-client/vue` state and actions programmatically via [Chrome DevTools ## Step 1: Install the Debug Shim Run this after each page load (a reload clears it). It picks a controller, exposes it as -`__DC_CTRL__` for every later snippet, and wraps dispatch to record actions in `__DC_ACTION_LOG__`. -Rerunning it is safe. A `ReferenceError` for either global means the shim is not installed. +`__DC_CTRL__` for every later snippet, and points `__DC_ACTION_LOG__` at its recent actions. Rerunning it +is safe. A `ReferenceError` for either global means the shim is not installed. `DevToolsManager` registers controllers in `globalThis.__DC_CONTROLLERS__` keyed by `devtoolsName`, which defaults to `"Data Client: "`. With a single controller, leave `NAME` unset. With several, the shim returns their keys; rerun it with `NAME` set to the one to target. -Apps sharing the default name overwrite each other, so give each a distinct -[`name`](https://dataclient.io/vue/api/DevToolsManager) to inspect them separately. + +Newer versions record actions from startup in `globalThis.__DC_ACTIONS__` (same keys), and the shim uses +that log (`actionLog: 'built-in'`). On older versions it wraps dispatch instead (`actionLog: 'shim'`), +which only sees actions from then on. ```js // evaluate_script @@ -30,44 +32,41 @@ Apps sharing the default name overwrite each other, so give each a distinct if (!controllers?.size) return { error: 'No controllers registered' }; if (!NAME && controllers.size > 1) return { error: 'Set NAME to one of', keys: [...controllers.keys()] }; - const ctrl = NAME ? controllers.get(NAME) : controllers.values().next().value; + const key = NAME ?? controllers.keys().next().value; + const ctrl = controllers.get(key); if (!ctrl) return { error: 'Controller not found', keys: [...controllers.keys()] }; + const stateKeys = Object.keys(ctrl.getState()); - if (globalThis.__DC_CTRL__ !== ctrl) globalThis.__DC_ACTION_LOG__ = []; + const builtIn = globalThis.__DC_ACTIONS__?.get(key); + if (builtIn || globalThis.__DC_CTRL__ !== ctrl) globalThis.__DC_ACTION_LOG__ = builtIn ?? []; globalThis.__DC_CTRL__ = ctrl; + if (builtIn) return { ok: true, actionLog: 'built-in', stateKeys }; // dispatch is rebound if the middleware chain is rebuilt, so check the wrapper, not the alias - if (ctrl._dispatch.__dcDebugShim) return { ok: true, alreadyInstalled: true }; + if (ctrl._dispatch.__dcDebugShim) return { ok: true, actionLog: 'shim', stateKeys }; const MAX_LOG = 200; const origDispatch = ctrl._dispatch; const shim = (action) => { if (globalThis.__DC_CTRL__ === ctrl) { - const entry = { type: action.type, key: action.key, ts: Date.now() }; - if (action.endpoint) entry.endpoint = action.endpoint.name || action.endpoint.key; - if (action.args) entry.args = action.args; - if (action.meta?.date) entry.date = action.meta.date; - if (action.error) entry.error = action.response?.message || true; - const log = globalThis.__DC_ACTION_LOG__; - log.push(entry); - if (log.length > MAX_LOG) globalThis.__DC_ACTION_LOG__ = log.slice(-MAX_LOG / 2); + log.push(action); + if (log.length > MAX_LOG) log.splice(0, MAX_LOG / 2); } return origDispatch(action); }; shim.__dcDebugShim = true; ctrl._dispatch = shim; - return { ok: true, stateKeys: Object.keys(ctrl.getState()) }; + return { ok: true, actionLog: 'shim', stateKeys }; })() ``` ### Gotchas -- **Actions from before the shim are not logged**, including the fetches made while the page - loaded. For a load-time problem, read the outcome from state (`meta` has each key's `date`, - `expiresAt` and `error`; see Step 2) and the requests from `list_network_requests`. To watch the - fetches happen, install the shim, then repeat them with `invalidateAll` (Step 4) or an in-app - navigation; a reload clears the shim. +- **With `actionLog: 'shim'`, actions from before the shim are not logged**, including the fetches + made while the page loaded. For a load-time problem, read the outcome from state (`meta` has each key's + `date`, `expiresAt` and `error`; see Step 2) and the requests from `list_network_requests`. To watch the + fetches happen, repeat them with `invalidateAll` (Step 4) or an in-app navigation; a reload clears the shim. - **Rerun Step 1 when the log stops growing or the data looks stale.** Nothing errors when the app is created again (for example on a hot reload of the module that calls `createApp`), which makes a new controller. @@ -191,8 +190,15 @@ After installing the debug shim (Step 1): ### Read recent actions ```js -// evaluate_script -() => __DC_ACTION_LOG__.slice(-20) +// evaluate_script — actions hold endpoints and promises, so return just these fields +() => __DC_ACTION_LOG__.slice(-20).map(a => ({ + type: a.type, + key: a.key, + endpoint: a.endpoint?.name, + args: a.args, + date: a.meta?.date, + error: a.error ? (a.response?.message || true) : undefined, +})) ``` ### Filter by action type @@ -202,20 +208,23 @@ After installing the debug shim (Step 1): () => __DC_ACTION_LOG__ .filter(a => a.type === 'rdc/fetch' || a.type === 'rdc/setresponse') .slice(-20) + .map(a => ({ type: a.type, key: a.key, date: a.meta?.date })) ``` ### Filter errors ```js // evaluate_script -() => __DC_ACTION_LOG__.filter(a => a.error) +() => __DC_ACTION_LOG__ + .filter(a => a.error) + .map(a => ({ type: a.type, key: a.key, error: a.response?.message || true })) ``` ### Clear action log ```js // evaluate_script -() => { globalThis.__DC_ACTION_LOG__ = []; return { cleared: true }; } +() => { __DC_ACTION_LOG__.length = 0; return { cleared: true }; } ``` ## Step 4: Mutate State via Controller diff --git a/.changeset/devtools-action-log.md b/.changeset/devtools-action-log.md new file mode 100644 index 000000000000..4a5631c4722f --- /dev/null +++ b/.changeset/devtools-action-log.md @@ -0,0 +1,15 @@ +--- +'@data-client/core': patch +'@data-client/react': patch +'@data-client/vue': patch +--- + +Add recent actions to `globalThis.__DC_ACTIONS__` in development + +Coding agents and console debugging can now see what each store dispatched, including the fetches made while the +page loaded, without the Redux DevTools extension. [DevToolsManager](https://dataclient.io/docs/api/DevToolsManager#controllers) +keeps each store's recent actions in a `Map` keyed like `__DC_CONTROLLERS__`. Production builds don't include it. + +```js title="Browser DevTools console" +__DC_ACTIONS__.get('Data Client: My App').slice(-10); +``` diff --git a/.changeset/devtools-unique-controller-keys.md b/.changeset/devtools-unique-controller-keys.md new file mode 100644 index 000000000000..00b36258239a --- /dev/null +++ b/.changeset/devtools-unique-controller-keys.md @@ -0,0 +1,11 @@ +--- +'@data-client/core': patch +'@data-client/react': patch +'@data-client/vue': patch +--- + +Fix stores with the same devtools name hiding each other in `__DC_CONTROLLERS__` + +With two `DataProvider`s, or two Vue apps using `DataClientPlugin`, on one page and the default devtools name, the +second store replaced the first in `globalThis.__DC_CONTROLLERS__`, so debugging tools and coding agents could only find one. The second now +registers as `'Data Client: My App (2)'`, the next as `(3)`, and so on. diff --git a/docs/core/_agents/devtools-debugging.md b/docs/core/_agents/devtools-debugging.md index 156f7c60305c..a0e5e36f1318 100644 --- a/docs/core/_agents/devtools-debugging.md +++ b/docs/core/_agents/devtools-debugging.md @@ -13,14 +13,16 @@ Debug :react[`@data-client/react`]:vue[`@data-client/vue`] state and actions pro ## Step 1: Install the Debug Shim Run this after each page load (a reload clears it). It picks a controller, exposes it as -`__DC_CTRL__` for every later snippet, and wraps dispatch to record actions in `__DC_ACTION_LOG__`. -Rerunning it is safe. A `ReferenceError` for either global means the shim is not installed. +`__DC_CTRL__` for every later snippet, and points `__DC_ACTION_LOG__` at its recent actions. Rerunning it +is safe. A `ReferenceError` for either global means the shim is not installed. `DevToolsManager` registers controllers in `globalThis.__DC_CONTROLLERS__` keyed by `devtoolsName`, which defaults to `"Data Client: "`. With a single controller, leave `NAME` unset. With several, the shim returns their keys; rerun it with `NAME` set to the one to target. -:react[`DataProvider`s]:vue[Apps] sharing the default name overwrite each other, so give each a distinct -[`name`](../api/DevToolsManager.md) to inspect them separately. + +Newer versions record actions from startup in `globalThis.__DC_ACTIONS__` (same keys), and the shim uses +that log (`actionLog: 'built-in'`). On older versions it wraps dispatch instead (`actionLog: 'shim'`), +which only sees actions from then on. ```js // evaluate_script @@ -30,44 +32,41 @@ unset. With several, the shim returns their keys; rerun it with `NAME` set to th if (!controllers?.size) return { error: 'No controllers registered' }; if (!NAME && controllers.size > 1) return { error: 'Set NAME to one of', keys: [...controllers.keys()] }; - const ctrl = NAME ? controllers.get(NAME) : controllers.values().next().value; + const key = NAME ?? controllers.keys().next().value; + const ctrl = controllers.get(key); if (!ctrl) return { error: 'Controller not found', keys: [...controllers.keys()] }; + const stateKeys = Object.keys(ctrl.getState()); - if (globalThis.__DC_CTRL__ !== ctrl) globalThis.__DC_ACTION_LOG__ = []; + const builtIn = globalThis.__DC_ACTIONS__?.get(key); + if (builtIn || globalThis.__DC_CTRL__ !== ctrl) globalThis.__DC_ACTION_LOG__ = builtIn ?? []; globalThis.__DC_CTRL__ = ctrl; + if (builtIn) return { ok: true, actionLog: 'built-in', stateKeys }; // dispatch is rebound if the middleware chain is rebuilt, so check the wrapper, not the alias - if (ctrl._dispatch.__dcDebugShim) return { ok: true, alreadyInstalled: true }; + if (ctrl._dispatch.__dcDebugShim) return { ok: true, actionLog: 'shim', stateKeys }; const MAX_LOG = 200; const origDispatch = ctrl._dispatch; const shim = (action) => { if (globalThis.__DC_CTRL__ === ctrl) { - const entry = { type: action.type, key: action.key, ts: Date.now() }; - if (action.endpoint) entry.endpoint = action.endpoint.name || action.endpoint.key; - if (action.args) entry.args = action.args; - if (action.meta?.date) entry.date = action.meta.date; - if (action.error) entry.error = action.response?.message || true; - const log = globalThis.__DC_ACTION_LOG__; - log.push(entry); - if (log.length > MAX_LOG) globalThis.__DC_ACTION_LOG__ = log.slice(-MAX_LOG / 2); + log.push(action); + if (log.length > MAX_LOG) log.splice(0, MAX_LOG / 2); } return origDispatch(action); }; shim.__dcDebugShim = true; ctrl._dispatch = shim; - return { ok: true, stateKeys: Object.keys(ctrl.getState()) }; + return { ok: true, actionLog: 'shim', stateKeys }; })() ``` ### Gotchas -- **Actions from before the shim are not logged**, including the fetches made while the page - loaded. For a load-time problem, read the outcome from state (`meta` has each key's `date`, - `expiresAt` and `error`; see Step 2) and the requests from `list_network_requests`. To watch the - fetches happen, install the shim, then repeat them with `invalidateAll` (Step 4) or an in-app - navigation; a reload clears the shim. +- **With `actionLog: 'shim'`, actions from before the shim are not logged**, including the fetches + made while the page loaded. For a load-time problem, read the outcome from state (`meta` has each key's + `date`, `expiresAt` and `error`; see Step 2) and the requests from `list_network_requests`. To watch the + fetches happen, repeat them with `invalidateAll` (Step 4) or an in-app navigation; a reload clears the shim. - **Rerun Step 1 when the log stops growing or the data looks stale.** Nothing errors when :react[a remount of `DataProvider` creates a new controller, or rebuilding its middleware drops the wrapper.]:vue[the app is created again (for example on a hot reload of the module that calls `createApp`), which makes a new controller.] @@ -191,8 +190,15 @@ After installing the debug shim (Step 1): ### Read recent actions ```js -// evaluate_script -() => __DC_ACTION_LOG__.slice(-20) +// evaluate_script — actions hold endpoints and promises, so return just these fields +() => __DC_ACTION_LOG__.slice(-20).map(a => ({ + type: a.type, + key: a.key, + endpoint: a.endpoint?.name, + args: a.args, + date: a.meta?.date, + error: a.error ? (a.response?.message || true) : undefined, +})) ``` ### Filter by action type @@ -202,20 +208,23 @@ After installing the debug shim (Step 1): () => __DC_ACTION_LOG__ .filter(a => a.type === 'rdc/fetch' || a.type === 'rdc/setresponse') .slice(-20) + .map(a => ({ type: a.type, key: a.key, date: a.meta?.date })) ``` ### Filter errors ```js // evaluate_script -() => __DC_ACTION_LOG__.filter(a => a.error) +() => __DC_ACTION_LOG__ + .filter(a => a.error) + .map(a => ({ type: a.type, key: a.key, error: a.response?.message || true })) ``` ### Clear action log ```js // evaluate_script -() => { globalThis.__DC_ACTION_LOG__ = []; return { cleared: true }; } +() => { __DC_ACTION_LOG__.length = 0; return { cleared: true }; } ``` ## Step 4: Mutate State via Controller diff --git a/docs/core/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index 4804a60d26a9..5ac9bfbc755b 100644 --- a/docs/core/api/DevToolsManager.md +++ b/docs/core/api/DevToolsManager.md @@ -90,8 +90,9 @@ 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. +`globalThis.__DC_CONTROLLERS__`, a `Map` keyed by the devtools connection name. It also records the +most recent dispatched actions for each one on `globalThis.__DC_ACTIONS__`, under the same key. This works +in browsers, React Native, and Node, with or without the Redux DevTools extension. ```js title="Browser DevTools console" // List all registered providers @@ -102,14 +103,20 @@ __DC_CONTROLLERS__.values().next().value.getState(); // Get state by name __DC_CONTROLLERS__.get('Data Client: My App').getState(); + +// Last 10 actions, including the ones dispatched while the page loaded +__DC_ACTIONS__.get('Data Client: My App').slice(-10); ``` 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 :react[[DataProvider](/docs/api/DataProvider)]:vue[installed [DataClientPlugin](./DataClientPlugin.md)] registers independently, so -multiple :react[providers]:vue[apps] on the same page are fully supported. +multiple :react[providers]:vue[apps] on the same page are supported. When a name is already taken, the next one +registers as `'Data Client: My App (2)'`, and so on; set `name` to give each a recognizable key. -Controllers are removed from the map when `cleanup()` is called. +The action log keeps up to twice [`maxAge`](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#maxage) +actions (100 by default) and skips the same duplicate fetches the extension does. Controllers and their logs are +removed when `cleanup()` is called. None of this exists in production builds. ## More info diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index 153dbb575931..ea639b45db6c 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -84,6 +84,16 @@ if (process.env.NODE_ENV !== 'production') { }; } +/** Appends, dropping the oldest half once over `max`. + * + * Halving means we aren't reallocating on every push; trimming in place keeps + * arrays shared on globals (like `__DC_ACTIONS__`) current. + */ +function pushCapped(buffer: T[], item: T, max: number) { + buffer.push(item); + if (buffer.length > max) buffer.splice(0, max / 2); +} + /** Integrates with https://github.com/reduxjs/redux-devtools * * Options: https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md @@ -95,6 +105,10 @@ export default class DevToolsManager implements Manager { declare protected devTools: undefined | any; protected started = false; protected actions: [ActionTypes, State][] = []; + /** Recent actions, kept in development whether or not the extension is connected */ + protected actionLog: ActionTypes[] = []; + /** Key this controller is registered under in `__DC_CONTROLLERS__` and `__DC_ACTIONS__` */ + declare protected registryKey?: string; declare protected controller: Controller; declare skipLogging?: (action: ActionTypes) => boolean; declare devtoolsName: string; @@ -123,12 +137,14 @@ export default class DevToolsManager implements Manager { if (process.env.NODE_ENV !== 'production') { this.prototype.middleware = function (controller) { this.controller = controller; - if (!this.devTools) return next => action => next(action); - const reducer = createReducer(controller as any); + const reducer = this.devTools && createReducer(controller as any); let state = controller.getState(); return next => action => { const shouldSkip = this.skipLogging?.(action); + if (!shouldSkip) + pushCapped(this.actionLog, action, this.maxBufferLength); const ret = next(action); + if (!reducer) return ret; if (this.started) { // we track state changes here since getState() will only update after a batch commit state = reducer(state, action); @@ -151,21 +167,30 @@ export default class DevToolsManager implements Manager { if (this.started) { this.devTools.send(action, state); } else { - // avoid this getting too big in case this is long running - // we cut in half so we aren't constantly reallocating - if (this.actions.length > this.maxBufferLength) - this.actions = this.actions.slice(this.maxBufferLength / 2); // queue actions - this.actions.push([action, state]); + pushCapped(this.actions, [action, state], this.maxBufferLength); } } /** 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, + const controllers: Map = (( + globalThis as any + ).__DC_CONTROLLERS__ ??= new Map()); + // another provider already has this name: register under 'name (2)', 'name (3)', ... + let key = this.devtoolsName; + for ( + let i = 2; + controllers.has(key) && controllers.get(key) !== this.controller; + i++ + ) + key = `${this.devtoolsName} (${i})`; + this.registryKey = key; + controllers.set(key, this.controller); + ((globalThis as any).__DC_ACTIONS__ ??= new Map()).set( + key, + this.actionLog, ); } if (process.env.NODE_ENV !== 'production' && this.devTools) { @@ -198,10 +223,12 @@ export default class DevToolsManager implements Manager { /** Ensures all subscriptions are cleaned up. */ cleanup() { if (process.env.NODE_ENV !== 'production') { + const key = this.registryKey; const map: Map | undefined = (globalThis as any) .__DC_CONTROLLERS__; - if (map?.get(this.devtoolsName) === this.controller) { - map.delete(this.devtoolsName); + if (key !== undefined && map?.get(key) === this.controller) { + map.delete(key); + (globalThis as any).__DC_ACTIONS__?.delete(key); } } } diff --git a/packages/core/src/manager/__tests__/devtoolsManager.ts b/packages/core/src/manager/__tests__/devtoolsManager.ts new file mode 100644 index 000000000000..9f01d2cf0e38 --- /dev/null +++ b/packages/core/src/manager/__tests__/devtoolsManager.ts @@ -0,0 +1,110 @@ +import { Controller, initialState } from '../..'; +import { RESET, SET } from '../../actionTypes'; +import DevToolsManager from '../DevtoolsManager.js'; + +const controllers = () => + (globalThis as any).__DC_CONTROLLERS__ as Map; +const actionLogs = () => + (globalThis as any).__DC_ACTIONS__ as Map; + +function setup(manager: DevToolsManager, controller = new Controller()) { + const next = jest.fn(() => Promise.resolve()); + const dispatch = manager.middleware(controller as any)(next); + return { controller, dispatch, next }; +} + +const setAction = (i: number): any => ({ type: SET, value: i }); + +afterEach(() => { + delete (globalThis as any).__DC_CONTROLLERS__; + delete (globalThis as any).__DC_ACTIONS__; +}); + +describe('DevToolsManager', () => { + describe('action log', () => { + it('logs actions from startup without the extension', () => { + const manager = new DevToolsManager({ name: 'App' }); + const { dispatch, next } = setup(manager); + // dispatched before init, like the first fetches of a page load + dispatch(setAction(1)); + manager.init(initialState); + dispatch(setAction(2)); + + expect(next).toHaveBeenCalledTimes(2); + expect(actionLogs().get('App')).toEqual([setAction(1), setAction(2)]); + }); + + it('trims in place to maxBufferLength', () => { + const manager = new DevToolsManager({ name: 'App', maxAge: 5 }); + const { dispatch } = setup(manager); + manager.init(initialState); + const log = actionLogs().get('App'); + for (let i = 0; i < 12; i++) dispatch(setAction(i)); + + expect(actionLogs().get('App')).toBe(log); + expect(log!.length).toBeLessThanOrEqual(10); + expect(log!.at(-1)).toEqual(setAction(11)); + }); + + it('skips actions skipLogging rejects', () => { + const manager = new DevToolsManager( + { name: 'App' }, + action => action.type === SET, + ); + const { dispatch } = setup(manager); + manager.init(initialState); + dispatch(setAction(1)); + dispatch({ type: RESET, date: 0 } as any); + + expect(actionLogs().get('App')).toEqual([{ type: RESET, date: 0 }]); + }); + }); + + describe('registry', () => { + it('registers controllers that share a name under distinct keys', () => { + const a = new DevToolsManager({ name: 'App' }); + const b = new DevToolsManager({ name: 'App' }); + const { controller: ctrlA } = setup(a); + const { controller: ctrlB, dispatch: dispatchB } = setup(b); + a.init(initialState); + b.init(initialState); + dispatchB(setAction(1)); + + expect([...controllers().entries()]).toEqual([ + ['App', ctrlA], + ['App (2)', ctrlB], + ]); + expect(actionLogs().get('App')).toEqual([]); + expect(actionLogs().get('App (2)')).toEqual([setAction(1)]); + + b.cleanup(); + expect([...controllers().keys()]).toEqual(['App']); + expect([...actionLogs().keys()]).toEqual(['App']); + a.cleanup(); + expect(controllers().size).toBe(0); + expect(actionLogs().size).toBe(0); + }); + + it('keeps its key when init runs again (StrictMode remount)', () => { + const manager = new DevToolsManager({ name: 'App' }); + const { controller } = setup(manager); + manager.init(initialState); + manager.init(initialState); + expect([...controllers().entries()]).toEqual([['App', controller]]); + + manager.cleanup(); + manager.init(initialState); + expect([...controllers().entries()]).toEqual([['App', controller]]); + }); + + it('cleanup leaves a controller registered by another manager', () => { + const manager = new DevToolsManager({ name: 'App' }); + setup(manager); + manager.cleanup(); + const other = new Controller(); + (globalThis as any).__DC_CONTROLLERS__ = new Map([['App', other]]); + manager.cleanup(); + expect(controllers().get('App')).toBe(other); + }); + }); +}); diff --git a/website/blog/2026-10-06-coding-agents.md b/website/blog/2026-10-06-coding-agents.md index 8c4463b3528d..b0f30de12625 100644 --- a/website/blog/2026-10-06-coding-agents.md +++ b/website/blog/2026-10-06-coding-agents.md @@ -104,10 +104,13 @@ of guessing from code. In dev mode, [DevToolsManager](/docs/api/DevToolsManager# [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp), or [Expo MCP](https://docs.expo.dev/eas/ai/mcp/) for React Native, can read the store, follow dispatched actions, and match them to network requests. The map is there in browsers, React Native and Node -([#3753](https://github.com/reactive/data-client/pull/3753)). +([#3753](https://github.com/reactive/data-client/pull/3753)). Each store's recent actions are on +`globalThis.__DC_ACTIONS__` from the moment the app starts, so the agent also sees the fetches made while the page +loaded, with or without the Redux DevTools extension. ```js title="Browser DevTools console" __DC_CONTROLLERS__.get('Data Client: My App').getState(); +__DC_ACTIONS__.get('Data Client: My App').slice(-10); ``` The React and Vue skills drive this through Chrome DevTools MCP; see From 9c3abad1512c42f515cefdf9fbad3ab378be6458 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:16:16 +0000 Subject: [PATCH 02/17] fix(core): Keep replacing same-named stores when server rendering Per-request SSR stores (e.g. Vue's DataClientPlugin with default managers) never run cleanup, so suffixing their keys would keep every request's store alive in development. Unique keys now apply only on a page. Also covers the third-collision key and the cleanup guard in tests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- docs/core/api/DevToolsManager.md | 2 +- packages/core/src/manager/DevtoolsManager.ts | 17 +++++++----- .../manager/__tests__/devtoolsManager.node.ts | 26 +++++++++++++++++++ .../src/manager/__tests__/devtoolsManager.ts | 14 +++++++--- 4 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/manager/__tests__/devtoolsManager.node.ts diff --git a/docs/core/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index 5ac9bfbc755b..a3d53fbc7c39 100644 --- a/docs/core/api/DevToolsManager.md +++ b/docs/core/api/DevToolsManager.md @@ -111,7 +111,7 @@ __DC_ACTIONS__.get('Data Client: My App').slice(-10); 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 :react[[DataProvider](/docs/api/DataProvider)]:vue[installed [DataClientPlugin](./DataClientPlugin.md)] registers independently, so -multiple :react[providers]:vue[apps] on the same page are supported. When a name is already taken, the next one +multiple :react[providers]:vue[apps] on the same page are supported. When a name is already taken on the page, the next one registers as `'Data Client: My App (2)'`, and so on; set `name` to give each a recognizable key. The action log keeps up to twice [`maxAge`](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#maxage) diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index ea639b45db6c..15081161a7f6 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -178,14 +178,17 @@ export default class DevToolsManager implements Manager { const controllers: Map = (( globalThis as any ).__DC_CONTROLLERS__ ??= new Map()); - // another provider already has this name: register under 'name (2)', 'name (3)', ... + // another provider on this page has the name: register under 'name (2)', 'name (3)', ... + // Without a page (server rendering) stores are made per request and never cleaned up, + // so replace the old one rather than keep every request's store alive. let key = this.devtoolsName; - for ( - let i = 2; - controllers.has(key) && controllers.get(key) !== this.controller; - i++ - ) - key = `${this.devtoolsName} (${i})`; + if (globalThis.document) + for ( + let i = 2; + controllers.has(key) && controllers.get(key) !== this.controller; + i++ + ) + key = `${this.devtoolsName} (${i})`; this.registryKey = key; controllers.set(key, this.controller); ((globalThis as any).__DC_ACTIONS__ ??= new Map()).set( diff --git a/packages/core/src/manager/__tests__/devtoolsManager.node.ts b/packages/core/src/manager/__tests__/devtoolsManager.node.ts new file mode 100644 index 000000000000..74d9dbb97595 --- /dev/null +++ b/packages/core/src/manager/__tests__/devtoolsManager.node.ts @@ -0,0 +1,26 @@ +import { Controller, initialState } from '../..'; +import DevToolsManager from '../DevtoolsManager.js'; + +const controllers = () => + (globalThis as any).__DC_CONTROLLERS__ as Map; + +afterEach(() => { + delete (globalThis as any).__DC_CONTROLLERS__; + delete (globalThis as any).__DC_ACTIONS__; +}); + +// server rendering makes a store per request and never cleans it up +it('replaces a same-named controller when there is no page', () => { + expect(globalThis.document).toBeUndefined(); + const a = new DevToolsManager({ name: 'App' }); + const b = new DevToolsManager({ name: 'App' }); + const ctrlB = new Controller(); + a.middleware(new Controller() as any); + b.middleware(ctrlB as any); + a.init(initialState); + b.init(initialState); + + expect([...controllers().entries()]).toEqual([['App', ctrlB]]); + a.cleanup(); + expect(controllers().get('App')).toBe(ctrlB); +}); diff --git a/packages/core/src/manager/__tests__/devtoolsManager.ts b/packages/core/src/manager/__tests__/devtoolsManager.ts index 9f01d2cf0e38..b6353f91f587 100644 --- a/packages/core/src/manager/__tests__/devtoolsManager.ts +++ b/packages/core/src/manager/__tests__/devtoolsManager.ts @@ -64,23 +64,28 @@ describe('DevToolsManager', () => { it('registers controllers that share a name under distinct keys', () => { const a = new DevToolsManager({ name: 'App' }); const b = new DevToolsManager({ name: 'App' }); + const c = new DevToolsManager({ name: 'App' }); const { controller: ctrlA } = setup(a); const { controller: ctrlB, dispatch: dispatchB } = setup(b); + const { controller: ctrlC } = setup(c); a.init(initialState); b.init(initialState); + c.init(initialState); dispatchB(setAction(1)); expect([...controllers().entries()]).toEqual([ ['App', ctrlA], ['App (2)', ctrlB], + ['App (3)', ctrlC], ]); expect(actionLogs().get('App')).toEqual([]); expect(actionLogs().get('App (2)')).toEqual([setAction(1)]); b.cleanup(); - expect([...controllers().keys()]).toEqual(['App']); - expect([...actionLogs().keys()]).toEqual(['App']); + expect([...controllers().keys()]).toEqual(['App', 'App (3)']); + expect([...actionLogs().keys()]).toEqual(['App', 'App (3)']); a.cleanup(); + c.cleanup(); expect(controllers().size).toBe(0); expect(actionLogs().size).toBe(0); }); @@ -100,11 +105,14 @@ describe('DevToolsManager', () => { it('cleanup leaves a controller registered by another manager', () => { const manager = new DevToolsManager({ name: 'App' }); setup(manager); + // cleanup before init has nothing to remove manager.cleanup(); + manager.init(initialState); const other = new Controller(); - (globalThis as any).__DC_CONTROLLERS__ = new Map([['App', other]]); + controllers().set('App', other); manager.cleanup(); expect(controllers().get('App')).toBe(other); + expect(actionLogs().has('App')).toBe(true); }); }); }); From 6e13e8b6d9d15552a380f845239547819cb40207 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:18:24 +0000 Subject: [PATCH 03/17] fix(core): Detect server rendering with typeof window, keeping unique keys on React Native React Native has no document but does define window, so the document check made same-named React Native stores replace each other. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- docs/core/api/DevToolsManager.md | 5 +++-- packages/core/src/manager/DevtoolsManager.ts | 8 ++++---- .../core/src/manager/__tests__/devtoolsManager.node.ts | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/core/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index a3d53fbc7c39..1051f52c65a0 100644 --- a/docs/core/api/DevToolsManager.md +++ b/docs/core/api/DevToolsManager.md @@ -111,8 +111,9 @@ __DC_ACTIONS__.get('Data Client: My App').slice(-10); 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 :react[[DataProvider](/docs/api/DataProvider)]:vue[installed [DataClientPlugin](./DataClientPlugin.md)] registers independently, so -multiple :react[providers]:vue[apps] on the same page are supported. When a name is already taken on the page, the next one -registers as `'Data Client: My App (2)'`, and so on; set `name` to give each a recognizable key. +multiple :react[providers]:vue[apps] on the same page are supported. When a name is already taken, the next one +registers as `'Data Client: My App (2)'`, and so on; set `name` to give each a recognizable key. (When server rendering, a +store replaces the previous one with its name instead, since per-request stores are never cleaned up.) The action log keeps up to twice [`maxAge`](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#maxage) actions (100 by default) and skips the same duplicate fetches the extension does. Controllers and their logs are diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index 15081161a7f6..3baecaf19bda 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -178,11 +178,11 @@ export default class DevToolsManager implements Manager { const controllers: Map = (( globalThis as any ).__DC_CONTROLLERS__ ??= new Map()); - // another provider on this page has the name: register under 'name (2)', 'name (3)', ... - // Without a page (server rendering) stores are made per request and never cleaned up, - // so replace the old one rather than keep every request's store alive. + // another provider has the name: register under 'name (2)', 'name (3)', ... + // Server rendering makes a store per request and never cleans it up, + // so there replace the old one rather than keep every request's store alive. let key = this.devtoolsName; - if (globalThis.document) + if (typeof window !== 'undefined') for ( let i = 2; controllers.has(key) && controllers.get(key) !== this.controller; diff --git a/packages/core/src/manager/__tests__/devtoolsManager.node.ts b/packages/core/src/manager/__tests__/devtoolsManager.node.ts index 74d9dbb97595..33fb4ca890c1 100644 --- a/packages/core/src/manager/__tests__/devtoolsManager.node.ts +++ b/packages/core/src/manager/__tests__/devtoolsManager.node.ts @@ -10,8 +10,8 @@ afterEach(() => { }); // server rendering makes a store per request and never cleans it up -it('replaces a same-named controller when there is no page', () => { - expect(globalThis.document).toBeUndefined(); +it('replaces a same-named controller when server rendering', () => { + expect(typeof window).toBe('undefined'); const a = new DevToolsManager({ name: 'App' }); const b = new DevToolsManager({ name: 'App' }); const ctrlB = new Controller(); From b697e6f1aded4a5fa7b77febf48c476fbe247745 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:20:12 +0000 Subject: [PATCH 04/17] test(core): Cover unique keys where window exists without document Pins the React Native case, and words the docs and comment as the condition the code checks. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- docs/core/api/DevToolsManager.md | 4 ++-- packages/core/src/manager/DevtoolsManager.ts | 5 +++-- .../manager/__tests__/devtoolsManager.node.ts | 22 +++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/core/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index 1051f52c65a0..57a8c75a00cd 100644 --- a/docs/core/api/DevToolsManager.md +++ b/docs/core/api/DevToolsManager.md @@ -112,8 +112,8 @@ This is useful for AI coding assistants using the [Chrome DevTools MCP](https:// or [Expo MCP](https://docs.expo.dev/eas/ai/mcp/) to programmatically inspect and interact with the store. Each :react[[DataProvider](/docs/api/DataProvider)]:vue[installed [DataClientPlugin](./DataClientPlugin.md)] registers independently, so multiple :react[providers]:vue[apps] on the same page are supported. When a name is already taken, the next one -registers as `'Data Client: My App (2)'`, and so on; set `name` to give each a recognizable key. (When server rendering, a -store replaces the previous one with its name instead, since per-request stores are never cleaned up.) +registers as `'Data Client: My App (2)'`, and so on; set `name` to give each a recognizable key. Where there is no `window`, as when +server rendering, a store replaces the previous one with its name instead, since per-request stores are never cleaned up. The action log keeps up to twice [`maxAge`](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#maxage) actions (100 by default) and skips the same duplicate fetches the extension does. Controllers and their logs are diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index 3baecaf19bda..513f756ff57f 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -179,8 +179,9 @@ export default class DevToolsManager implements Manager { globalThis as any ).__DC_CONTROLLERS__ ??= new Map()); // another provider has the name: register under 'name (2)', 'name (3)', ... - // Server rendering makes a store per request and never cleans it up, - // so there replace the old one rather than keep every request's store alive. + // Without a window (server rendering), stores are made per request and never cleaned up, + // so replace the old one instead of keeping every request's store alive. + // Checks window, not document: React Native has a window but no document. let key = this.devtoolsName; if (typeof window !== 'undefined') for ( diff --git a/packages/core/src/manager/__tests__/devtoolsManager.node.ts b/packages/core/src/manager/__tests__/devtoolsManager.node.ts index 33fb4ca890c1..c843a7122c42 100644 --- a/packages/core/src/manager/__tests__/devtoolsManager.node.ts +++ b/packages/core/src/manager/__tests__/devtoolsManager.node.ts @@ -24,3 +24,25 @@ it('replaces a same-named controller when server rendering', () => { a.cleanup(); expect(controllers().get('App')).toBe(ctrlB); }); + +// React Native defines window but not document +it('registers same-named controllers under distinct keys with a window', () => { + (globalThis as any).window = globalThis; + try { + const a = new DevToolsManager({ name: 'App' }); + const b = new DevToolsManager({ name: 'App' }); + const ctrlA = new Controller(); + const ctrlB = new Controller(); + a.middleware(ctrlA as any); + b.middleware(ctrlB as any); + a.init(initialState); + b.init(initialState); + + expect([...controllers().entries()]).toEqual([ + ['App', ctrlA], + ['App (2)', ctrlB], + ]); + } finally { + delete (globalThis as any).window; + } +}); From b522ddacb54241ab50b7d4e00c06b4e210440d7e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:57:27 +0000 Subject: [PATCH 05/17] feat(core): Keep __DC_ACTIONS__ small: summarize old actions, cap polling keys Only the newest 20 actions keep args and response; older ones keep type, key, endpoint, meta and error responses. While an endpoint polls, the log keeps only its last maxPollingActions (default 4) actions. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- .../references/devtools-debugging.md | 8 +- .../references/devtools-debugging.md | 8 +- .changeset/devtools-action-log.md | 3 + docs/core/_agents/devtools-debugging.md | 8 +- docs/core/api/DevToolsManager.md | 19 ++- packages/core/src/manager/DevtoolsManager.ts | 67 ++++++++- .../src/manager/__tests__/devtoolsManager.ts | 140 +++++++++++++++++- packages/core/src/manager/devtoolsTypes.ts | 5 + .../editor-types/@data-client/core.d.ts | 16 ++ 9 files changed, 259 insertions(+), 15 deletions(-) diff --git a/.agents/skills/data-client-react/references/devtools-debugging.md b/.agents/skills/data-client-react/references/devtools-debugging.md index e97ca0156be3..1b01782992e1 100644 --- a/.agents/skills/data-client-react/references/devtools-debugging.md +++ b/.agents/skills/data-client-react/references/devtools-debugging.md @@ -185,7 +185,9 @@ Use these when you need to see the raw cache structure without denormalization. ## Step 3: Track Actions -After installing the debug shim (Step 1): +After installing the debug shim (Step 1). With the built-in log, only the newest 20 actions keep `args` and +`response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling endpoint +keeps only its last few actions. To see a payload, repeat the action and read it right away. ### Read recent actions @@ -196,7 +198,7 @@ After installing the debug shim (Step 1): key: a.key, endpoint: a.endpoint?.name, args: a.args, - date: a.meta?.date, + fetchedAt: a.meta?.fetchedAt, error: a.error ? (a.response?.message || true) : undefined, })) ``` @@ -208,7 +210,7 @@ After installing the debug shim (Step 1): () => __DC_ACTION_LOG__ .filter(a => a.type === 'rdc/fetch' || a.type === 'rdc/setresponse') .slice(-20) - .map(a => ({ type: a.type, key: a.key, date: a.meta?.date })) + .map(a => ({ type: a.type, key: a.key, fetchedAt: a.meta?.fetchedAt })) ``` ### Filter errors diff --git a/.agents/skills/data-client-vue/references/devtools-debugging.md b/.agents/skills/data-client-vue/references/devtools-debugging.md index d8d05fc7726e..a1965a6211c9 100644 --- a/.agents/skills/data-client-vue/references/devtools-debugging.md +++ b/.agents/skills/data-client-vue/references/devtools-debugging.md @@ -185,7 +185,9 @@ Use these when you need to see the raw cache structure without denormalization. ## Step 3: Track Actions -After installing the debug shim (Step 1): +After installing the debug shim (Step 1). With the built-in log, only the newest 20 actions keep `args` and +`response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling endpoint +keeps only its last few actions. To see a payload, repeat the action and read it right away. ### Read recent actions @@ -196,7 +198,7 @@ After installing the debug shim (Step 1): key: a.key, endpoint: a.endpoint?.name, args: a.args, - date: a.meta?.date, + fetchedAt: a.meta?.fetchedAt, error: a.error ? (a.response?.message || true) : undefined, })) ``` @@ -208,7 +210,7 @@ After installing the debug shim (Step 1): () => __DC_ACTION_LOG__ .filter(a => a.type === 'rdc/fetch' || a.type === 'rdc/setresponse') .slice(-20) - .map(a => ({ type: a.type, key: a.key, date: a.meta?.date })) + .map(a => ({ type: a.type, key: a.key, fetchedAt: a.meta?.fetchedAt })) ``` ### Filter errors diff --git a/.changeset/devtools-action-log.md b/.changeset/devtools-action-log.md index 4a5631c4722f..807dd2297357 100644 --- a/.changeset/devtools-action-log.md +++ b/.changeset/devtools-action-log.md @@ -10,6 +10,9 @@ Coding agents and console debugging can now see what each store dispatched, incl page loaded, without the Redux DevTools extension. [DevToolsManager](https://dataclient.io/docs/api/DevToolsManager#controllers) keeps each store's recent actions in a `Map` keyed like `__DC_CONTROLLERS__`. Production builds don't include it. +To keep memory small, only the newest 20 actions keep their payloads, and each polling endpoint keeps only its +last two polls (`maxPollingActions`, default 4 actions). + ```js title="Browser DevTools console" __DC_ACTIONS__.get('Data Client: My App').slice(-10); ``` diff --git a/docs/core/_agents/devtools-debugging.md b/docs/core/_agents/devtools-debugging.md index a0e5e36f1318..166d0d386aaa 100644 --- a/docs/core/_agents/devtools-debugging.md +++ b/docs/core/_agents/devtools-debugging.md @@ -185,7 +185,9 @@ Use these when you need to see the raw cache structure without denormalization. ## Step 3: Track Actions -After installing the debug shim (Step 1): +After installing the debug shim (Step 1). With the built-in log, only the newest 20 actions keep `args` and +`response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling endpoint +keeps only its last few actions. To see a payload, repeat the action and read it right away. ### Read recent actions @@ -196,7 +198,7 @@ After installing the debug shim (Step 1): key: a.key, endpoint: a.endpoint?.name, args: a.args, - date: a.meta?.date, + fetchedAt: a.meta?.fetchedAt, error: a.error ? (a.response?.message || true) : undefined, })) ``` @@ -208,7 +210,7 @@ After installing the debug shim (Step 1): () => __DC_ACTION_LOG__ .filter(a => a.type === 'rdc/fetch' || a.type === 'rdc/setresponse') .slice(-20) - .map(a => ({ type: a.type, key: a.key, date: a.meta?.date })) + .map(a => ({ type: a.type, key: a.key, fetchedAt: a.meta?.fetchedAt })) ``` ### Filter errors diff --git a/docs/core/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index 57a8c75a00cd..cf1897509a3b 100644 --- a/docs/core/api/DevToolsManager.md +++ b/docs/core/api/DevToolsManager.md @@ -116,8 +116,23 @@ registers as `'Data Client: My App (2)'`, and so on; set `name` to give each a r server rendering, a store replaces the previous one with its name instead, since per-request stores are never cleaned up. The action log keeps up to twice [`maxAge`](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#maxage) -actions (100 by default) and skips the same duplicate fetches the extension does. Controllers and their logs are -removed when `cleanup()` is called. None of this exists in production builds. +actions (100 by default) and skips the same duplicate fetches the extension does. To keep its memory small, only the +newest 20 actions keep their `args` and `response`; older ones keep `type`, `key`, `endpoint`, `meta` and error +responses. While an endpoint [polls](/rest/api/RestEndpoint#pollfrequency), the log keeps only its last +`maxPollingActions` actions (4 by default, the last two polls), so polling doesn't push everything else out: + + + +```ts +const managers = getDefaultManagers({ + // highlight-next-line + devToolsManager: { maxPollingActions: 10 }, +}); +``` + + + +Controllers and their logs are removed when `cleanup()` is called. None of this exists in production builds. ## More info diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index 513f756ff57f..6041a4cc1729 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -1,3 +1,4 @@ +import { FETCH, SUBSCRIBE, UNSUBSCRIBE } from '../actionTypes.js'; import type { DevToolsConfig } from './devtoolsTypes.js'; import type { Controller, EndpointInterface } from '../index.js'; import type { Middleware } from '../middlewareTypes.js'; @@ -94,6 +95,34 @@ function pushCapped(buffer: T[], item: T, max: number) { if (buffer.length > max) buffer.splice(0, max / 2); } +/** Newest actions kept whole in the action log; older ones are summarized. + * + * Matches the 20 the debugging skill reads. + */ +const FULL_ACTIONS = 20; + +/** Keeps what shows what happened and when, dropping what retains memory + * (response bodies, args, set values, fetch promises). + * + * Builds the object explicitly: a rest spread here was 4x slower. + */ +function summarize(action: any) { + const summary: any = { + type: action.type, + key: action.key, + endpoint: action.endpoint, + meta: + action.type === FETCH ? + { fetchedAt: action.meta.fetchedAt } + : action.meta, + }; + if (action.error) { + summary.error = true; + summary.response = action.response; + } + return summary; +} + /** Integrates with https://github.com/reduxjs/redux-devtools * * Options: https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md @@ -109,6 +138,9 @@ export default class DevToolsManager implements Manager { protected actionLog: ActionTypes[] = []; /** Key this controller is registered under in `__DC_CONTROLLERS__` and `__DC_ACTIONS__` */ declare protected registryKey?: string; + /** Subscriber count for each polling key */ + protected polling = new Map(); + maxPollingActions = 4; declare protected controller: Controller; declare skipLogging?: (action: ActionTypes) => boolean; declare devtoolsName: string; @@ -129,6 +161,8 @@ export default class DevToolsManager implements Manager { // 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; + if (config?.maxPollingActions) + this.maxPollingActions = config.maxPollingActions; } static { @@ -141,8 +175,7 @@ export default class DevToolsManager implements Manager { let state = controller.getState(); return next => action => { const shouldSkip = this.skipLogging?.(action); - if (!shouldSkip) - pushCapped(this.actionLog, action, this.maxBufferLength); + if (!shouldSkip) this.logAction(action); const ret = next(action); if (!reducer) return ret; if (this.started) { @@ -163,6 +196,36 @@ export default class DevToolsManager implements Manager { } } + /** Keeps `__DC_ACTIONS__` small: polling keys keep only their latest actions, + * and all but the newest actions drop their payloads. + */ + protected logAction(action: ActionTypes) { + const log: { key?: string }[] = this.actionLog; + const key = (action as { key?: string }).key; + if ( + (action.type === SUBSCRIBE || action.type === UNSUBSCRIBE) && + action.endpoint.pollFrequency + ) { + const count = + (this.polling.get(action.key) ?? 0) + + (action.type === SUBSCRIBE ? 1 : -1); + if (count > 0) this.polling.set(action.key, count); + else this.polling.delete(action.key); + } + if (key !== undefined && this.polling.has(key)) { + // the log already holds at most maxPollingActions for this key, so this removes its oldest + for (let i = log.length - 1, seen = 0; i >= 0; i--) { + if (log[i].key === key && ++seen >= this.maxPollingActions) { + log.splice(i, 1); + break; + } + } + } + pushCapped(log, action, this.maxBufferLength); + const old = log.length - 1 - FULL_ACTIONS; + if (old >= 0) log[old] = summarize(log[old]); + } + handleAction(action: any, state: any) { if (this.started) { this.devTools.send(action, state); diff --git a/packages/core/src/manager/__tests__/devtoolsManager.ts b/packages/core/src/manager/__tests__/devtoolsManager.ts index b6353f91f587..8d80716771ea 100644 --- a/packages/core/src/manager/__tests__/devtoolsManager.ts +++ b/packages/core/src/manager/__tests__/devtoolsManager.ts @@ -1,11 +1,18 @@ import { Controller, initialState } from '../..'; -import { RESET, SET } from '../../actionTypes'; +import { + FETCH, + RESET, + SET, + SET_RESPONSE, + SUBSCRIBE, + UNSUBSCRIBE, +} from '../../actionTypes'; import DevToolsManager from '../DevtoolsManager.js'; const controllers = () => (globalThis as any).__DC_CONTROLLERS__ as Map; const actionLogs = () => - (globalThis as any).__DC_ACTIONS__ as Map; + (globalThis as any).__DC_ACTIONS__ as Map; function setup(manager: DevToolsManager, controller = new Controller()) { const next = jest.fn(() => Promise.resolve()); @@ -14,6 +21,36 @@ function setup(manager: DevToolsManager, controller = new Controller()) { } const setAction = (i: number): any => ({ type: SET, value: i }); +const typesAndKeys = () => + actionLogs() + .get('App')! + .map(a => [a.type, a.key]); +const pollEndpoint = { name: 'poll', pollFrequency: 1000 }; +const subscribe = (key: string): any => ({ + type: SUBSCRIBE, + key, + endpoint: pollEndpoint, + args: [], +}); +const unsubscribe = (key: string): any => ({ + ...subscribe(key), + type: UNSUBSCRIBE, +}); +const fetchAction = (key: string, i: number): any => ({ + type: FETCH, + key, + endpoint: pollEndpoint, + args: [i], + meta: { fetchedAt: i, promise: Promise.resolve(), resolve() {}, reject() {} }, +}); +const responseAction = (key: string, i: number): any => ({ + type: SET_RESPONSE, + key, + endpoint: pollEndpoint, + args: [i], + response: { i }, + meta: { fetchedAt: i, date: i, expiresAt: i }, +}); afterEach(() => { delete (globalThis as any).__DC_CONTROLLERS__; @@ -60,6 +97,105 @@ describe('DevToolsManager', () => { }); }); + describe('summaries', () => { + it('drops payloads from all but the newest 20 actions', () => { + const manager = new DevToolsManager({ name: 'App' }); + const { dispatch } = setup(manager); + manager.init(initialState); + dispatch(fetchAction('a', 1)); + dispatch({ + ...responseAction('a', 2), + error: true, + response: { message: 'boom' }, + }); + dispatch(responseAction('a', 3)); + for (let i = 0; i < 20; i++) dispatch(setAction(i)); + const log = actionLogs().get('App')!; + + expect(log.slice(0, 3)).toEqual([ + { + type: FETCH, + key: 'a', + endpoint: pollEndpoint, + meta: { fetchedAt: 1 }, + }, + { + type: SET_RESPONSE, + key: 'a', + endpoint: pollEndpoint, + error: true, + response: { message: 'boom' }, + meta: { fetchedAt: 2, date: 2, expiresAt: 2 }, + }, + { + type: SET_RESPONSE, + key: 'a', + endpoint: pollEndpoint, + meta: { fetchedAt: 3, date: 3, expiresAt: 3 }, + }, + ]); + expect(log.slice(3)).toEqual( + Array.from({ length: 20 }, (_, i) => setAction(i)), + ); + }); + }); + + describe('polling', () => { + function pollingDispatch() { + const manager = new DevToolsManager({ + name: 'App', + maxPollingActions: 2, + }); + const { dispatch } = setup(manager); + manager.init(initialState); + return dispatch; + } + + it('keeps only the latest maxPollingActions for a polling key', () => { + const dispatch = pollingDispatch(); + dispatch(subscribe('a')); + dispatch(subscribe('a')); + dispatch(setAction(0)); + for (let i = 1; i <= 3; i++) { + dispatch(fetchAction('a', i)); + dispatch(responseAction('a', i)); + } + dispatch(fetchAction('b', 9)); + + expect(typesAndKeys()).toEqual([ + [SET, undefined], + [FETCH, 'a'], + [SET_RESPONSE, 'a'], + [FETCH, 'b'], + ]); + expect(actionLogs().get('App')![1].args).toEqual([3]); + }); + + it('stops capping once every subscriber leaves', () => { + const dispatch = pollingDispatch(); + dispatch(subscribe('a')); + dispatch(subscribe('a')); + dispatch(unsubscribe('a')); + dispatch(fetchAction('a', 1)); + dispatch(responseAction('a', 1)); + expect(typesAndKeys()).toEqual([ + [FETCH, 'a'], + [SET_RESPONSE, 'a'], + ]); + + dispatch(unsubscribe('a')); + dispatch(fetchAction('a', 2)); + expect(actionLogs().get('App')).toHaveLength(4); + }); + + it('does not cap subscriptions without pollFrequency', () => { + const dispatch = pollingDispatch(); + dispatch({ ...subscribe('a'), endpoint: { name: 'live' } }); + for (let i = 1; i <= 3; i++) dispatch(fetchAction('a', i)); + expect(actionLogs().get('App')).toHaveLength(4); + }); + }); + describe('registry', () => { it('registers controllers that share a name under distinct keys', () => { const a = new DevToolsManager({ name: 'App' }); diff --git a/packages/core/src/manager/devtoolsTypes.ts b/packages/core/src/manager/devtoolsTypes.ts index 49e3d76b9e3f..4518f23a7c24 100644 --- a/packages/core/src/manager/devtoolsTypes.ts +++ b/packages/core/src/manager/devtoolsTypes.ts @@ -213,4 +213,9 @@ export interface EnhancerOptions { export interface DevToolsConfig extends EnhancerOptions { type?: string; + /** Actions kept in `__DC_ACTIONS__` for each endpoint while it polls + * + * @default 4 (the last two polls) + */ + maxPollingActions?: number; } 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 c91b940b2eac..540756af26b1 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 @@ -1496,6 +1496,11 @@ interface EnhancerOptions { } interface DevToolsConfig extends EnhancerOptions { type?: string; + /** Actions kept in `__DC_ACTIONS__` for each endpoint while it polls + * + * @default 4 (the last two polls) + */ + maxPollingActions?: number; } /** Integrates with https://github.com/reduxjs/redux-devtools @@ -1509,11 +1514,22 @@ declare class DevToolsManager implements Manager { protected devTools: undefined | any; protected started: boolean; protected actions: [ActionTypes, State][]; + /** Recent actions, kept in development whether or not the extension is connected */ + protected actionLog: ActionTypes[]; + /** Key this controller is registered under in `__DC_CONTROLLERS__` and `__DC_ACTIONS__` */ + protected registryKey?: string; + /** Subscriber count for each polling key */ + protected polling: Map; + maxPollingActions: number; protected controller: Controller; skipLogging?: (action: ActionTypes) => boolean; devtoolsName: string; maxBufferLength: number; constructor(config?: DevToolsConfig, skipLogging?: (action: ActionTypes) => boolean); + /** Keeps `__DC_ACTIONS__` small: polling keys keep only their latest actions, + * and all but the newest actions drop their payloads. + */ + protected logAction(action: ActionTypes): void; handleAction(action: any, state: any): void; /** Called when initial state is ready */ init(state: State): void; From 189eefe0b7f6438822d5722d79bb2a67a747eb39 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:02:05 +0000 Subject: [PATCH 06/17] fix(core): Type the polling scan without narrowing the log Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- packages/core/src/manager/DevtoolsManager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index 6041a4cc1729..16bf6589c037 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -200,7 +200,7 @@ export default class DevToolsManager implements Manager { * and all but the newest actions drop their payloads. */ protected logAction(action: ActionTypes) { - const log: { key?: string }[] = this.actionLog; + const log = this.actionLog; const key = (action as { key?: string }).key; if ( (action.type === SUBSCRIBE || action.type === UNSUBSCRIBE) && @@ -215,7 +215,10 @@ export default class DevToolsManager implements Manager { if (key !== undefined && this.polling.has(key)) { // the log already holds at most maxPollingActions for this key, so this removes its oldest for (let i = log.length - 1, seen = 0; i >= 0; i--) { - if (log[i].key === key && ++seen >= this.maxPollingActions) { + if ( + (log[i] as { key?: string }).key === key && + ++seen >= this.maxPollingActions + ) { log.splice(i, 1); break; } From d9430a68847735a770456972220e4cd3ab282bbf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:06:24 +0000 Subject: [PATCH 07/17] fix(core): Trim a key's older actions when it starts polling Also say the polling cap is per key (endpoint and args), and rename the subscriber count map. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- .../references/devtools-debugging.md | 4 ++-- .../references/devtools-debugging.md | 4 ++-- .changeset/devtools-action-log.md | 4 ++-- docs/core/_agents/devtools-debugging.md | 4 ++-- docs/core/api/DevToolsManager.md | 2 +- packages/core/src/manager/DevtoolsManager.ts | 19 +++++++++---------- .../src/manager/__tests__/devtoolsManager.ts | 15 +++++++++++++++ packages/core/src/manager/devtoolsTypes.ts | 2 +- .../editor-types/@data-client/core.d.ts | 7 ++++--- 9 files changed, 38 insertions(+), 23 deletions(-) diff --git a/.agents/skills/data-client-react/references/devtools-debugging.md b/.agents/skills/data-client-react/references/devtools-debugging.md index 1b01782992e1..8e0c60b80d91 100644 --- a/.agents/skills/data-client-react/references/devtools-debugging.md +++ b/.agents/skills/data-client-react/references/devtools-debugging.md @@ -186,8 +186,8 @@ Use these when you need to see the raw cache structure without denormalization. ## Step 3: Track Actions After installing the debug shim (Step 1). With the built-in log, only the newest 20 actions keep `args` and -`response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling endpoint -keeps only its last few actions. To see a payload, repeat the action and read it right away. +`response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling key +(endpoint and args) keeps only its last few actions. To see a payload, repeat the action and read it right away. ### Read recent actions diff --git a/.agents/skills/data-client-vue/references/devtools-debugging.md b/.agents/skills/data-client-vue/references/devtools-debugging.md index a1965a6211c9..947ab28f5428 100644 --- a/.agents/skills/data-client-vue/references/devtools-debugging.md +++ b/.agents/skills/data-client-vue/references/devtools-debugging.md @@ -186,8 +186,8 @@ Use these when you need to see the raw cache structure without denormalization. ## Step 3: Track Actions After installing the debug shim (Step 1). With the built-in log, only the newest 20 actions keep `args` and -`response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling endpoint -keeps only its last few actions. To see a payload, repeat the action and read it right away. +`response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling key +(endpoint and args) keeps only its last few actions. To see a payload, repeat the action and read it right away. ### Read recent actions diff --git a/.changeset/devtools-action-log.md b/.changeset/devtools-action-log.md index 807dd2297357..fc20d7689625 100644 --- a/.changeset/devtools-action-log.md +++ b/.changeset/devtools-action-log.md @@ -10,8 +10,8 @@ Coding agents and console debugging can now see what each store dispatched, incl page loaded, without the Redux DevTools extension. [DevToolsManager](https://dataclient.io/docs/api/DevToolsManager#controllers) keeps each store's recent actions in a `Map` keyed like `__DC_CONTROLLERS__`. Production builds don't include it. -To keep memory small, only the newest 20 actions keep their payloads, and each polling endpoint keeps only its -last two polls (`maxPollingActions`, default 4 actions). +To keep memory small, only the newest 20 actions keep their payloads, and each polling key (endpoint and args) keeps +only its last two polls (`maxPollingActions`, default 4 actions). ```js title="Browser DevTools console" __DC_ACTIONS__.get('Data Client: My App').slice(-10); diff --git a/docs/core/_agents/devtools-debugging.md b/docs/core/_agents/devtools-debugging.md index 166d0d386aaa..c872d107087d 100644 --- a/docs/core/_agents/devtools-debugging.md +++ b/docs/core/_agents/devtools-debugging.md @@ -186,8 +186,8 @@ Use these when you need to see the raw cache structure without denormalization. ## Step 3: Track Actions After installing the debug shim (Step 1). With the built-in log, only the newest 20 actions keep `args` and -`response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling endpoint -keeps only its last few actions. To see a payload, repeat the action and read it right away. +`response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling key +(endpoint and args) keeps only its last few actions. To see a payload, repeat the action and read it right away. ### Read recent actions diff --git a/docs/core/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index cf1897509a3b..05b72643b04d 100644 --- a/docs/core/api/DevToolsManager.md +++ b/docs/core/api/DevToolsManager.md @@ -119,7 +119,7 @@ The action log keeps up to twice [`maxAge`](https://github.com/reduxjs/redux-dev actions (100 by default) and skips the same duplicate fetches the extension does. To keep its memory small, only the newest 20 actions keep their `args` and `response`; older ones keep `type`, `key`, `endpoint`, `meta` and error responses. While an endpoint [polls](/rest/api/RestEndpoint#pollfrequency), the log keeps only its last -`maxPollingActions` actions (4 by default, the last two polls), so polling doesn't push everything else out: +`maxPollingActions` actions for each key (endpoint and args) (4 by default, the last two polls), so polling doesn't push everything else out: diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index 16bf6589c037..f89c7f4f2d90 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -138,8 +138,9 @@ export default class DevToolsManager implements Manager { protected actionLog: ActionTypes[] = []; /** Key this controller is registered under in `__DC_CONTROLLERS__` and `__DC_ACTIONS__` */ declare protected registryKey?: string; - /** Subscriber count for each polling key */ - protected polling = new Map(); + /** Subscriber count for each key that is polling; a key is removed when its count reaches 0 */ + protected pollingSubscribers = new Map(); + /** Actions kept in the log for each key while it polls */ maxPollingActions = 4; declare protected controller: Controller; declare skipLogging?: (action: ActionTypes) => boolean; @@ -207,21 +208,19 @@ export default class DevToolsManager implements Manager { action.endpoint.pollFrequency ) { const count = - (this.polling.get(action.key) ?? 0) + + (this.pollingSubscribers.get(action.key) ?? 0) + (action.type === SUBSCRIBE ? 1 : -1); - if (count > 0) this.polling.set(action.key, count); - else this.polling.delete(action.key); + if (count > 0) this.pollingSubscribers.set(action.key, count); + else this.pollingSubscribers.delete(action.key); } - if (key !== undefined && this.polling.has(key)) { - // the log already holds at most maxPollingActions for this key, so this removes its oldest + if (key !== undefined && this.pollingSubscribers.has(key)) { + // drop all but the newest maxPollingActions - 1, making room for this one for (let i = log.length - 1, seen = 0; i >= 0; i--) { if ( (log[i] as { key?: string }).key === key && ++seen >= this.maxPollingActions - ) { + ) log.splice(i, 1); - break; - } } } pushCapped(log, action, this.maxBufferLength); diff --git a/packages/core/src/manager/__tests__/devtoolsManager.ts b/packages/core/src/manager/__tests__/devtoolsManager.ts index 8d80716771ea..74b1d001a139 100644 --- a/packages/core/src/manager/__tests__/devtoolsManager.ts +++ b/packages/core/src/manager/__tests__/devtoolsManager.ts @@ -171,6 +171,21 @@ describe('DevToolsManager', () => { expect(actionLogs().get('App')![1].args).toEqual([3]); }); + it('trims actions logged before polling started', () => { + const dispatch = pollingDispatch(); + for (let i = 1; i <= 3; i++) { + dispatch(fetchAction('a', i)); + dispatch(responseAction('a', i)); + } + dispatch(subscribe('a')); + dispatch(fetchAction('a', 4)); + + expect(typesAndKeys()).toEqual([ + [SUBSCRIBE, 'a'], + [FETCH, 'a'], + ]); + }); + it('stops capping once every subscriber leaves', () => { const dispatch = pollingDispatch(); dispatch(subscribe('a')); diff --git a/packages/core/src/manager/devtoolsTypes.ts b/packages/core/src/manager/devtoolsTypes.ts index 4518f23a7c24..b1cf350c7fa2 100644 --- a/packages/core/src/manager/devtoolsTypes.ts +++ b/packages/core/src/manager/devtoolsTypes.ts @@ -213,7 +213,7 @@ export interface EnhancerOptions { export interface DevToolsConfig extends EnhancerOptions { type?: string; - /** Actions kept in `__DC_ACTIONS__` for each endpoint while it polls + /** Actions kept in `__DC_ACTIONS__` for each key (endpoint and args) while it polls * * @default 4 (the last two polls) */ 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 540756af26b1..ef32016faa01 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 @@ -1496,7 +1496,7 @@ interface EnhancerOptions { } interface DevToolsConfig extends EnhancerOptions { type?: string; - /** Actions kept in `__DC_ACTIONS__` for each endpoint while it polls + /** Actions kept in `__DC_ACTIONS__` for each key (endpoint and args) while it polls * * @default 4 (the last two polls) */ @@ -1518,8 +1518,9 @@ declare class DevToolsManager implements Manager { protected actionLog: ActionTypes[]; /** Key this controller is registered under in `__DC_CONTROLLERS__` and `__DC_ACTIONS__` */ protected registryKey?: string; - /** Subscriber count for each polling key */ - protected polling: Map; + /** Subscriber count for each key that is polling; a key is removed when its count reaches 0 */ + protected pollingSubscribers: Map; + /** Actions kept in the log for each key while it polls */ maxPollingActions: number; protected controller: Controller; skipLogging?: (action: ActionTypes) => boolean; From f05aa0ecb8c373699c4a97f2597b26502b74112e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:06:49 +0000 Subject: [PATCH 08/17] test(core): Polling cap holds across unsubscribe and resubscribe Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- .../src/manager/__tests__/devtoolsManager.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/core/src/manager/__tests__/devtoolsManager.ts b/packages/core/src/manager/__tests__/devtoolsManager.ts index 74b1d001a139..bf5b38c03db2 100644 --- a/packages/core/src/manager/__tests__/devtoolsManager.ts +++ b/packages/core/src/manager/__tests__/devtoolsManager.ts @@ -186,6 +186,28 @@ describe('DevToolsManager', () => { ]); }); + it('holds the cap across unsubscribe and resubscribe', () => { + const dispatch = pollingDispatch(); + for (let cycle = 0; cycle < 3; cycle++) { + dispatch(subscribe('a')); + for (let i = 1; i <= 3; i++) { + dispatch(fetchAction('a', i)); + dispatch(responseAction('a', i)); + } + dispatch(unsubscribe('a')); + dispatch(fetchAction('a', 9)); + dispatch(responseAction('a', 9)); + } + dispatch(subscribe('a')); + dispatch(fetchAction('a', 10)); + dispatch(responseAction('a', 10)); + + expect(typesAndKeys()).toEqual([ + [FETCH, 'a'], + [SET_RESPONSE, 'a'], + ]); + }); + it('stops capping once every subscriber leaves', () => { const dispatch = pollingDispatch(); dispatch(subscribe('a')); From 802e9168f2cf0b1646518879202df27a17a47240 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:14:23 +0000 Subject: [PATCH 09/17] fix(core): Track polling for skipped subscribes; keep a held registry key on re-init Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- packages/core/src/manager/DevtoolsManager.ts | 26 ++++++++++---- .../src/manager/__tests__/devtoolsManager.ts | 34 +++++++++++++++++++ .../editor-types/@data-client/core.d.ts | 2 ++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index f89c7f4f2d90..4bf4808765b9 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -176,6 +176,7 @@ export default class DevToolsManager implements Manager { let state = controller.getState(); return next => action => { const shouldSkip = this.skipLogging?.(action); + this.trackPolling(action); if (!shouldSkip) this.logAction(action); const ret = next(action); if (!reducer) return ret; @@ -197,12 +198,8 @@ export default class DevToolsManager implements Manager { } } - /** Keeps `__DC_ACTIONS__` small: polling keys keep only their latest actions, - * and all but the newest actions drop their payloads. - */ - protected logAction(action: ActionTypes) { - const log = this.actionLog; - const key = (action as { key?: string }).key; + /** Counts polling subscribers per key, even for actions skipLogging hides */ + protected trackPolling(action: ActionTypes) { if ( (action.type === SUBSCRIBE || action.type === UNSUBSCRIBE) && action.endpoint.pollFrequency @@ -213,6 +210,14 @@ export default class DevToolsManager implements Manager { if (count > 0) this.pollingSubscribers.set(action.key, count); else this.pollingSubscribers.delete(action.key); } + } + + /** Keeps `__DC_ACTIONS__` small: polling keys keep only their latest actions, + * and all but the newest actions drop their payloads. + */ + protected logAction(action: ActionTypes) { + const log = this.actionLog; + const key = (action as { key?: string }).key; if (key !== undefined && this.pollingSubscribers.has(key)) { // drop all but the newest maxPollingActions - 1, making room for this one for (let i = log.length - 1, seen = 0; i >= 0; i--) { @@ -247,7 +252,14 @@ export default class DevToolsManager implements Manager { // Without a window (server rendering), stores are made per request and never cleaned up, // so replace the old one instead of keeping every request's store alive. // Checks window, not document: React Native has a window but no document. - let key = this.devtoolsName; + // keep the key this controller already holds (init can run again without cleanup) + let key = + ( + this.registryKey !== undefined && + controllers.get(this.registryKey) === this.controller + ) ? + this.registryKey + : this.devtoolsName; if (typeof window !== 'undefined') for ( let i = 2; diff --git a/packages/core/src/manager/__tests__/devtoolsManager.ts b/packages/core/src/manager/__tests__/devtoolsManager.ts index bf5b38c03db2..5a307a2453b0 100644 --- a/packages/core/src/manager/__tests__/devtoolsManager.ts +++ b/packages/core/src/manager/__tests__/devtoolsManager.ts @@ -208,6 +208,21 @@ describe('DevToolsManager', () => { ]); }); + it('tracks polling even when skipLogging hides subscribes', () => { + const manager = new DevToolsManager( + { name: 'App', maxPollingActions: 2 }, + action => action.type === SUBSCRIBE, + ); + const { dispatch } = setup(manager); + manager.init(initialState); + dispatch(subscribe('a')); + for (let i = 1; i <= 3; i++) dispatch(fetchAction('a', i)); + expect(typesAndKeys()).toEqual([ + [FETCH, 'a'], + [FETCH, 'a'], + ]); + }); + it('stops capping once every subscriber leaves', () => { const dispatch = pollingDispatch(); dispatch(subscribe('a')); @@ -275,6 +290,25 @@ describe('DevToolsManager', () => { expect([...controllers().entries()]).toEqual([['App', controller]]); }); + it('keeps a suffixed key when init runs again after a lower one frees', () => { + const a = new DevToolsManager({ name: 'App' }); + const b = new DevToolsManager({ name: 'App' }); + const c = new DevToolsManager({ name: 'App' }); + setup(a); + setup(b); + const { controller: ctrlC } = setup(c); + a.init(initialState); + b.init(initialState); + c.init(initialState); + b.cleanup(); + c.init(initialState); + + expect(controllers().get('App (3)')).toBe(ctrlC); + expect(controllers().has('App (2)')).toBe(false); + c.cleanup(); + expect(controllers().has('App (3)')).toBe(false); + }); + it('cleanup leaves a controller registered by another manager', () => { const manager = new DevToolsManager({ name: 'App' }); setup(manager); 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 ef32016faa01..ae0de3680cc2 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 @@ -1527,6 +1527,8 @@ declare class DevToolsManager implements Manager { devtoolsName: string; maxBufferLength: number; constructor(config?: DevToolsConfig, skipLogging?: (action: ActionTypes) => boolean); + /** Counts polling subscribers per key, even for actions skipLogging hides */ + protected trackPolling(action: ActionTypes): void; /** Keeps `__DC_ACTIONS__` small: polling keys keep only their latest actions, * and all but the newest actions drop their payloads. */ From c10d7295df429823957d8c3104c0f3d4b8e8e4e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:19:18 +0000 Subject: [PATCH 10/17] fix(core): Forget polling keys on cleanup Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- packages/core/src/manager/DevtoolsManager.ts | 2 ++ .../core/src/manager/__tests__/devtoolsManager.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index 4bf4808765b9..e66b7a20f6c1 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -304,6 +304,8 @@ export default class DevToolsManager implements Manager { /** Ensures all subscriptions are cleaned up. */ cleanup() { if (process.env.NODE_ENV !== 'production') { + // SubscriptionManager.cleanup() stops polling without dispatching unsubscribes + this.pollingSubscribers.clear(); const key = this.registryKey; const map: Map | undefined = (globalThis as any) .__DC_CONTROLLERS__; diff --git a/packages/core/src/manager/__tests__/devtoolsManager.ts b/packages/core/src/manager/__tests__/devtoolsManager.ts index 5a307a2453b0..79aaa288bdc8 100644 --- a/packages/core/src/manager/__tests__/devtoolsManager.ts +++ b/packages/core/src/manager/__tests__/devtoolsManager.ts @@ -240,6 +240,20 @@ describe('DevToolsManager', () => { expect(actionLogs().get('App')).toHaveLength(4); }); + it('forgets polling keys on cleanup', () => { + const manager = new DevToolsManager({ + name: 'App', + maxPollingActions: 2, + }); + const { dispatch } = setup(manager); + manager.init(initialState); + dispatch(subscribe('a')); + manager.cleanup(); + manager.init(initialState); + for (let i = 1; i <= 3; i++) dispatch(fetchAction('a', i)); + expect(typesAndKeys().filter(([type]) => type === FETCH)).toHaveLength(3); + }); + it('does not cap subscriptions without pollFrequency', () => { const dispatch = pollingDispatch(); dispatch({ ...subscribe('a'), endpoint: { name: 'live' } }); From 0b828d7ecd20d0a5ed391ca2496639927157eb9a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:24:37 +0000 Subject: [PATCH 11/17] fix(core): Fresh action log for a reused manager; treat pollFrequency 0 as polling Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- packages/core/src/manager/DevtoolsManager.ts | 4 +++- .../core/src/manager/__tests__/devtoolsManager.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index e66b7a20f6c1..bd811985b2a8 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -171,6 +171,8 @@ export default class DevToolsManager implements Manager { /* istanbul ignore next */ if (process.env.NODE_ENV !== 'production') { this.prototype.middleware = function (controller) { + // a reused manager starts a fresh log for a new store + if (controller !== this.controller) this.actionLog = []; this.controller = controller; const reducer = this.devTools && createReducer(controller as any); let state = controller.getState(); @@ -202,7 +204,7 @@ export default class DevToolsManager implements Manager { protected trackPolling(action: ActionTypes) { if ( (action.type === SUBSCRIBE || action.type === UNSUBSCRIBE) && - action.endpoint.pollFrequency + action.endpoint.pollFrequency !== undefined ) { const count = (this.pollingSubscribers.get(action.key) ?? 0) + diff --git a/packages/core/src/manager/__tests__/devtoolsManager.ts b/packages/core/src/manager/__tests__/devtoolsManager.ts index 79aaa288bdc8..915fa8293e9c 100644 --- a/packages/core/src/manager/__tests__/devtoolsManager.ts +++ b/packages/core/src/manager/__tests__/devtoolsManager.ts @@ -71,6 +71,18 @@ describe('DevToolsManager', () => { expect(actionLogs().get('App')).toEqual([setAction(1), setAction(2)]); }); + it('starts a fresh log when reused for another store', () => { + const manager = new DevToolsManager({ name: 'App' }); + const { dispatch } = setup(manager); + manager.init(initialState); + dispatch(setAction(1)); + manager.cleanup(); + const { dispatch: dispatch2 } = setup(manager); + manager.init(initialState); + dispatch2(setAction(2)); + expect(actionLogs().get('App')).toEqual([setAction(2)]); + }); + it('trims in place to maxBufferLength', () => { const manager = new DevToolsManager({ name: 'App', maxAge: 5 }); const { dispatch } = setup(manager); From 2b0e420675c549b353035801b9054696645b8a16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:58:46 +0000 Subject: [PATCH 12/17] docs(skills): Lead devtools debugging with a symptom table, drop unused sections Skill evals (with and without the skill on three bugs) showed agents never used the polling pattern, checklist or network-correlation sections, and asked for a symptom-to-first-check hint. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- .../references/devtools-debugging.md | 37 +++++-------------- .../references/devtools-debugging.md | 37 +++++-------------- docs/core/_agents/devtools-debugging.md | 37 +++++-------------- 3 files changed, 30 insertions(+), 81 deletions(-) diff --git a/.agents/skills/data-client-react/references/devtools-debugging.md b/.agents/skills/data-client-react/references/devtools-debugging.md index 8e0c60b80d91..c55db6653e3b 100644 --- a/.agents/skills/data-client-react/references/devtools-debugging.md +++ b/.agents/skills/data-client-react/references/devtools-debugging.md @@ -10,6 +10,14 @@ Debug `@data-client/react` state and actions programmatically via [Chrome DevToo 2. Chrome DevTools MCP connected and page loaded 3. `DevToolsManager` included in `DataProvider` managers (default in dev mode) +## Where to Start + +| Symptom | First check | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Data never appears or stays loading | The key's `meta` `error` and `getError` (Step 2), and its status in `list_network_requests` | +| A mutation succeeded but the UI shows old data | The entity in `state.entities` (Step 2). If it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema`, so nothing was normalized into the entity | +| Requests fire too often or not at all | Timestamps in `list_network_requests`, then `fetchedAt` per key in the action log (Step 3) of the controller that owns the endpoint. `pollFrequency` is in milliseconds | + ## Step 1: Install the Debug Shim Run this after each page load (a reload clears it). It picks a controller, exposes it as @@ -18,7 +26,8 @@ is safe. A `ReferenceError` for either global means the shim is not installed. `DevToolsManager` registers controllers in `globalThis.__DC_CONTROLLERS__` keyed by `devtoolsName`, which defaults to `"Data Client: "`. With a single controller, leave `NAME` -unset. With several, the shim returns their keys; rerun it with `NAME` set to the one to target. +unset. With several, the shim returns their keys; rerun it with `NAME` set to the one whose +`getState().endpoints` holds the keys you are debugging. Newer versions record actions from startup in `globalThis.__DC_ACTIONS__` (same keys), and the shim uses that log (`actionLog: 'built-in'`). On older versions it wraps dispatch instead (`actionLog: 'shim'`), @@ -289,11 +298,6 @@ async () => { } ``` -## Correlating with Network Requests - -Use `list_network_requests` with `resourceTypes: ["fetch", "xhr"]` to see API calls, -then cross-reference with endpoint keys in state. - ## Action Types Reference | Type | Controller Method | Description | @@ -341,24 +345,3 @@ State = { lastReset: number, } ``` - -## Polling Pattern - -For monitoring ongoing activity, poll with short intervals: - -1. Install shim (Step 1) -2. Trigger the user action or navigation -3. Wait 2–3 seconds -4. Read actions (Step 3) — check for `rdc/fetch` then `rdc/setresponse` pairs -5. If needed, read entity state (Step 2) to verify cache contents -6. Repeat if watching for subscription updates - -## Debugging Checklist - -1. **Install the shim**: Confirms a controller exists and exposes it as `__DC_CTRL__` -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/.agents/skills/data-client-vue/references/devtools-debugging.md b/.agents/skills/data-client-vue/references/devtools-debugging.md index 947ab28f5428..af294c280c72 100644 --- a/.agents/skills/data-client-vue/references/devtools-debugging.md +++ b/.agents/skills/data-client-vue/references/devtools-debugging.md @@ -10,6 +10,14 @@ Debug `@data-client/vue` state and actions programmatically via [Chrome DevTools 2. Chrome DevTools MCP connected and page loaded 3. `DevToolsManager` included in `DataClientPlugin` managers (default in dev mode) +## Where to Start + +| Symptom | First check | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Data never appears or stays loading | The key's `meta` `error` and `getError` (Step 2), and its status in `list_network_requests` | +| A mutation succeeded but the UI shows old data | The entity in `state.entities` (Step 2). If it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema`, so nothing was normalized into the entity | +| Requests fire too often or not at all | Timestamps in `list_network_requests`, then `fetchedAt` per key in the action log (Step 3) of the controller that owns the endpoint. `pollFrequency` is in milliseconds | + ## Step 1: Install the Debug Shim Run this after each page load (a reload clears it). It picks a controller, exposes it as @@ -18,7 +26,8 @@ is safe. A `ReferenceError` for either global means the shim is not installed. `DevToolsManager` registers controllers in `globalThis.__DC_CONTROLLERS__` keyed by `devtoolsName`, which defaults to `"Data Client: "`. With a single controller, leave `NAME` -unset. With several, the shim returns their keys; rerun it with `NAME` set to the one to target. +unset. With several, the shim returns their keys; rerun it with `NAME` set to the one whose +`getState().endpoints` holds the keys you are debugging. Newer versions record actions from startup in `globalThis.__DC_ACTIONS__` (same keys), and the shim uses that log (`actionLog: 'built-in'`). On older versions it wraps dispatch instead (`actionLog: 'shim'`), @@ -289,11 +298,6 @@ async () => { } ``` -## Correlating with Network Requests - -Use `list_network_requests` with `resourceTypes: ["fetch", "xhr"]` to see API calls, -then cross-reference with endpoint keys in state. - ## Action Types Reference | Type | Controller Method | Description | @@ -341,24 +345,3 @@ State = { lastReset: number, } ``` - -## Polling Pattern - -For monitoring ongoing activity, poll with short intervals: - -1. Install shim (Step 1) -2. Trigger the user action or navigation -3. Wait 2–3 seconds -4. Read actions (Step 3) — check for `rdc/fetch` then `rdc/setresponse` pairs -5. If needed, read entity state (Step 2) to verify cache contents -6. Repeat if watching for subscription updates - -## Debugging Checklist - -1. **Install the shim**: Confirms a controller exists and exposes it as `__DC_CTRL__` -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/docs/core/_agents/devtools-debugging.md b/docs/core/_agents/devtools-debugging.md index c872d107087d..3fac79e65ff3 100644 --- a/docs/core/_agents/devtools-debugging.md +++ b/docs/core/_agents/devtools-debugging.md @@ -10,6 +10,14 @@ Debug :react[`@data-client/react`]:vue[`@data-client/vue`] state and actions pro 2. Chrome DevTools MCP connected and page loaded 3. `DevToolsManager` included in :react[`DataProvider`]:vue[`DataClientPlugin`] managers (default in dev mode) +## Where to Start + +| Symptom | First check | +|---|---| +| Data never appears or stays loading | The key's `meta` `error` and `getError` (Step 2), and its status in `list_network_requests` | +| A mutation succeeded but the UI shows old data | The entity in `state.entities` (Step 2). If it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema`, so nothing was normalized into the entity | +| Requests fire too often or not at all | Timestamps in `list_network_requests`, then `fetchedAt` per key in the action log (Step 3) of the controller that owns the endpoint. `pollFrequency` is in milliseconds | + ## Step 1: Install the Debug Shim Run this after each page load (a reload clears it). It picks a controller, exposes it as @@ -18,7 +26,8 @@ is safe. A `ReferenceError` for either global means the shim is not installed. `DevToolsManager` registers controllers in `globalThis.__DC_CONTROLLERS__` keyed by `devtoolsName`, which defaults to `"Data Client: "`. With a single controller, leave `NAME` -unset. With several, the shim returns their keys; rerun it with `NAME` set to the one to target. +unset. With several, the shim returns their keys; rerun it with `NAME` set to the one whose +`getState().endpoints` holds the keys you are debugging. Newer versions record actions from startup in `globalThis.__DC_ACTIONS__` (same keys), and the shim uses that log (`actionLog: 'built-in'`). On older versions it wraps dispatch instead (`actionLog: 'shim'`), @@ -289,11 +298,6 @@ async () => { } ``` -## Correlating with Network Requests - -Use `list_network_requests` with `resourceTypes: ["fetch", "xhr"]` to see API calls, -then cross-reference with endpoint keys in state. - ## Action Types Reference | Type | Controller Method | Description | @@ -341,24 +345,3 @@ State = { lastReset: number, } ``` - -## Polling Pattern - -For monitoring ongoing activity, poll with short intervals: - -1. Install shim (Step 1) -2. Trigger the user action or navigation -3. Wait 2–3 seconds -4. Read actions (Step 3) — check for `rdc/fetch` then `rdc/setresponse` pairs -5. If needed, read entity state (Step 2) to verify cache contents -6. Repeat if watching for subscription updates - -## Debugging Checklist - -1. **Install the shim**: Confirms a controller exists and exposes it as `__DC_CTRL__` -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 From 13c3de3b275caab0bb507a91e0fd29cc716e4c0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:48:33 +0000 Subject: [PATCH 13/17] docs(skills): Cover reverting changes, stored-only errors and invalidation loops in devtools debugging Harder evals (refetch loop, wrapped mutation response, stale list cache) on Sonnet and Haiku showed: normalization errors live only in meta, so the errors snippet missed them; agents sliced the trimmed log by index; and one run never opened the reference. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- .agents/skills/data-client-react/SKILL.md | 9 ++++--- .../references/devtools-debugging.md | 26 +++++++++++++------ .agents/skills/data-client-vue/SKILL.md | 9 ++++--- .../references/devtools-debugging.md | 26 +++++++++++++------ docs/core/_agents/devtools-debugging.md | 20 ++++++++++---- 5 files changed, 61 insertions(+), 29 deletions(-) diff --git a/.agents/skills/data-client-react/SKILL.md b/.agents/skills/data-client-react/SKILL.md index 63454c83240a..7cc8712a4487 100644 --- a/.agents/skills/data-client-react/SKILL.md +++ b/.agents/skills/data-client-react/SKILL.md @@ -118,10 +118,11 @@ 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). Dev mode only: start with its debug shim, -which every other snippet there depends on. +When debugging a running app with a browser MCP (`user-chrome-devtools`), such as data that never +appears, reverts, or refetches too often, read [devtools-debugging](references/devtools-debugging.md) +before probing: its "Where to Start" table maps symptoms to the state and +[actions](references/Actions.md) to check. Dev mode only: start with its debug shim, which every other +snippet there depends on. ## Managers diff --git a/.agents/skills/data-client-react/references/devtools-debugging.md b/.agents/skills/data-client-react/references/devtools-debugging.md index c55db6653e3b..cbe518b65e62 100644 --- a/.agents/skills/data-client-react/references/devtools-debugging.md +++ b/.agents/skills/data-client-react/references/devtools-debugging.md @@ -12,11 +12,12 @@ Debug `@data-client/react` state and actions programmatically via [Chrome DevToo ## Where to Start -| Symptom | First check | -| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Data never appears or stays loading | The key's `meta` `error` and `getError` (Step 2), and its status in `list_network_requests` | -| A mutation succeeded but the UI shows old data | The entity in `state.entities` (Step 2). If it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema`, so nothing was normalized into the entity | -| Requests fire too often or not at all | Timestamps in `list_network_requests`, then `fetchedAt` per key in the action log (Step 3) of the controller that owns the endpoint. `pollFrequency` is in milliseconds | +| Symptom | First check | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Data never appears or stays loading | The key's `meta` `error` and `getError` (Step 2), and its status in `list_network_requests` | +| A mutation succeeded but the UI shows old data | The mutation key's `meta` `error` (Step 2): a response that fails to normalize, such as one wrapped in `{ data }`, is stored there with no error on the action. Then the entity in `state.entities`: if it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema` | +| A change shows, then reverts on its own | The mutation key's `meta` `error` first: an optimistic change reverts when its response fails. If there is none, repeat the change and read the action log by time (Step 3): a later `rdc/setresponse` with a newer `meta.fetchedAt` (often a poll) carried the old value | +| Requests fire too often or not at all | Timestamps in `list_network_requests`, then the action log (Step 3) of the controller that owns the endpoint: `fetchedAt` per key, and whether an `rdc/invalidate` or `rdc/invalidateall` precedes each fetch (something, often a Manager, keeps invalidating it). `pollFrequency` is in milliseconds | ## Step 1: Install the Debug Shim @@ -197,6 +198,7 @@ Use these when you need to see the raw cache structure without denormalization. After installing the debug shim (Step 1). With the built-in log, only the newest 20 actions keep `args` and `response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling key (endpoint and args) keeps only its last few actions. To see a payload, repeat the action and read it right away. +Entries are dropped from the front, so select by time (`meta.fetchedAt >= start`), not by index. ### Read recent actions @@ -224,11 +226,19 @@ After installing the debug shim (Step 1). With the built-in log, only the newest ### Filter errors +Errors thrown while storing a response (for example a missing primary key) land only in `meta`, with no +`error` on the action, so check both. + ```js // evaluate_script -() => __DC_ACTION_LOG__ - .filter(a => a.error) - .map(a => ({ type: a.type, key: a.key, error: a.response?.message || true })) +() => ({ + actions: __DC_ACTION_LOG__ + .filter(a => a.error) + .map(a => ({ type: a.type, key: a.key, error: a.response?.message || true })), + meta: Object.entries(__DC_CTRL__.getState().meta) + .filter(([, m]) => m.error) + .map(([key, m]) => ({ key, error: m.error.message, status: m.error.status })), +}) ``` ### Clear action log diff --git a/.agents/skills/data-client-vue/SKILL.md b/.agents/skills/data-client-vue/SKILL.md index 462a4c2dad1e..ee145df21a67 100644 --- a/.agents/skills/data-client-vue/SKILL.md +++ b/.agents/skills/data-client-vue/SKILL.md @@ -143,10 +143,11 @@ 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). Dev mode only: start with its debug shim, -which every other snippet there depends on. +When debugging a running app with a browser MCP (`user-chrome-devtools`), such as data that never +appears, reverts, or refetches too often, read [devtools-debugging](references/devtools-debugging.md) +before probing: its "Where to Start" table maps symptoms to the state and +[actions](references/Actions.md) to check. Dev mode only: start with its debug shim, which every other +snippet there depends on. ## Managers diff --git a/.agents/skills/data-client-vue/references/devtools-debugging.md b/.agents/skills/data-client-vue/references/devtools-debugging.md index af294c280c72..bfb0d2210ba0 100644 --- a/.agents/skills/data-client-vue/references/devtools-debugging.md +++ b/.agents/skills/data-client-vue/references/devtools-debugging.md @@ -12,11 +12,12 @@ Debug `@data-client/vue` state and actions programmatically via [Chrome DevTools ## Where to Start -| Symptom | First check | -| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Data never appears or stays loading | The key's `meta` `error` and `getError` (Step 2), and its status in `list_network_requests` | -| A mutation succeeded but the UI shows old data | The entity in `state.entities` (Step 2). If it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema`, so nothing was normalized into the entity | -| Requests fire too often or not at all | Timestamps in `list_network_requests`, then `fetchedAt` per key in the action log (Step 3) of the controller that owns the endpoint. `pollFrequency` is in milliseconds | +| Symptom | First check | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Data never appears or stays loading | The key's `meta` `error` and `getError` (Step 2), and its status in `list_network_requests` | +| A mutation succeeded but the UI shows old data | The mutation key's `meta` `error` (Step 2): a response that fails to normalize, such as one wrapped in `{ data }`, is stored there with no error on the action. Then the entity in `state.entities`: if it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema` | +| A change shows, then reverts on its own | The mutation key's `meta` `error` first: an optimistic change reverts when its response fails. If there is none, repeat the change and read the action log by time (Step 3): a later `rdc/setresponse` with a newer `meta.fetchedAt` (often a poll) carried the old value | +| Requests fire too often or not at all | Timestamps in `list_network_requests`, then the action log (Step 3) of the controller that owns the endpoint: `fetchedAt` per key, and whether an `rdc/invalidate` or `rdc/invalidateall` precedes each fetch (something, often a Manager, keeps invalidating it). `pollFrequency` is in milliseconds | ## Step 1: Install the Debug Shim @@ -197,6 +198,7 @@ Use these when you need to see the raw cache structure without denormalization. After installing the debug shim (Step 1). With the built-in log, only the newest 20 actions keep `args` and `response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling key (endpoint and args) keeps only its last few actions. To see a payload, repeat the action and read it right away. +Entries are dropped from the front, so select by time (`meta.fetchedAt >= start`), not by index. ### Read recent actions @@ -224,11 +226,19 @@ After installing the debug shim (Step 1). With the built-in log, only the newest ### Filter errors +Errors thrown while storing a response (for example a missing primary key) land only in `meta`, with no +`error` on the action, so check both. + ```js // evaluate_script -() => __DC_ACTION_LOG__ - .filter(a => a.error) - .map(a => ({ type: a.type, key: a.key, error: a.response?.message || true })) +() => ({ + actions: __DC_ACTION_LOG__ + .filter(a => a.error) + .map(a => ({ type: a.type, key: a.key, error: a.response?.message || true })), + meta: Object.entries(__DC_CTRL__.getState().meta) + .filter(([, m]) => m.error) + .map(([key, m]) => ({ key, error: m.error.message, status: m.error.status })), +}) ``` ### Clear action log diff --git a/docs/core/_agents/devtools-debugging.md b/docs/core/_agents/devtools-debugging.md index 3fac79e65ff3..5a02bf3124e8 100644 --- a/docs/core/_agents/devtools-debugging.md +++ b/docs/core/_agents/devtools-debugging.md @@ -15,8 +15,9 @@ Debug :react[`@data-client/react`]:vue[`@data-client/vue`] state and actions pro | Symptom | First check | |---|---| | Data never appears or stays loading | The key's `meta` `error` and `getError` (Step 2), and its status in `list_network_requests` | -| A mutation succeeded but the UI shows old data | The entity in `state.entities` (Step 2). If it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema`, so nothing was normalized into the entity | -| Requests fire too often or not at all | Timestamps in `list_network_requests`, then `fetchedAt` per key in the action log (Step 3) of the controller that owns the endpoint. `pollFrequency` is in milliseconds | +| A mutation succeeded but the UI shows old data | The mutation key's `meta` `error` (Step 2): a response that fails to normalize, such as one wrapped in `{ data }`, is stored there with no error on the action. Then the entity in `state.entities`: if it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema` | +| A change shows, then reverts on its own | The mutation key's `meta` `error` first: an optimistic change reverts when its response fails. If there is none, repeat the change and read the action log by time (Step 3): a later `rdc/setresponse` with a newer `meta.fetchedAt` (often a poll) carried the old value | +| Requests fire too often or not at all | Timestamps in `list_network_requests`, then the action log (Step 3) of the controller that owns the endpoint: `fetchedAt` per key, and whether an `rdc/invalidate` or `rdc/invalidateall` precedes each fetch (something, often a Manager, keeps invalidating it). `pollFrequency` is in milliseconds | ## Step 1: Install the Debug Shim @@ -197,6 +198,7 @@ Use these when you need to see the raw cache structure without denormalization. After installing the debug shim (Step 1). With the built-in log, only the newest 20 actions keep `args` and `response` (older ones keep `type`, `key`, `endpoint`, `meta` and error responses), and each polling key (endpoint and args) keeps only its last few actions. To see a payload, repeat the action and read it right away. +Entries are dropped from the front, so select by time (`meta.fetchedAt >= start`), not by index. ### Read recent actions @@ -224,11 +226,19 @@ After installing the debug shim (Step 1). With the built-in log, only the newest ### Filter errors +Errors thrown while storing a response (for example a missing primary key) land only in `meta`, with no +`error` on the action, so check both. + ```js // evaluate_script -() => __DC_ACTION_LOG__ - .filter(a => a.error) - .map(a => ({ type: a.type, key: a.key, error: a.response?.message || true })) +() => ({ + actions: __DC_ACTION_LOG__ + .filter(a => a.error) + .map(a => ({ type: a.type, key: a.key, error: a.response?.message || true })), + meta: Object.entries(__DC_CTRL__.getState().meta) + .filter(([, m]) => m.error) + .map(([key, m]) => ({ key, error: m.error.message, status: m.error.status })), +}) ``` ### Clear action log From 4fbcbba1519d1537bcdb35f05f858e52467fca06 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:38:24 +0000 Subject: [PATCH 14/17] refactor(core): Keep the DevToolsManager action log out of production builds Move trackPolling and logAction into the development-only prototype block and create their state in a development-only constructor branch, so production bundles drop them (DevToolsManager's minified gzip size goes from +381 B over master to +35 B). Share one polling test setup. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- packages/core/src/manager/DevtoolsManager.ts | 85 ++++++++++--------- .../src/manager/__tests__/devtoolsManager.ts | 37 +++----- 2 files changed, 58 insertions(+), 64 deletions(-) diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index bd811985b2a8..651b19968734 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -135,13 +135,15 @@ export default class DevToolsManager implements Manager { protected started = false; protected actions: [ActionTypes, State][] = []; /** Recent actions, kept in development whether or not the extension is connected */ - protected actionLog: ActionTypes[] = []; + declare protected actionLog: ActionTypes[]; /** Key this controller is registered under in `__DC_CONTROLLERS__` and `__DC_ACTIONS__` */ declare protected registryKey?: string; /** Subscriber count for each key that is polling; a key is removed when its count reaches 0 */ - protected pollingSubscribers = new Map(); - /** Actions kept in the log for each key while it polls */ - maxPollingActions = 4; + declare protected pollingSubscribers: Map; + /** Actions kept in the log for each key while it polls (development only) */ + declare maxPollingActions: number; + declare protected trackPolling: (action: ActionTypes) => void; + declare protected logAction: (action: ActionTypes) => void; declare protected controller: Controller; declare skipLogging?: (action: ActionTypes) => boolean; declare devtoolsName: string; @@ -162,14 +164,50 @@ export default class DevToolsManager implements Manager { // 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; - if (config?.maxPollingActions) - this.maxPollingActions = config.maxPollingActions; + if (process.env.NODE_ENV !== 'production') { + this.actionLog = []; + this.pollingSubscribers = new Map(); + this.maxPollingActions = config?.maxPollingActions || 4; + } } static { /* istanbul ignore if */ /* istanbul ignore next */ if (process.env.NODE_ENV !== 'production') { + /** Counts polling subscribers per key, even for actions skipLogging hides */ + this.prototype.trackPolling = function (action) { + if ( + (action.type === SUBSCRIBE || action.type === UNSUBSCRIBE) && + action.endpoint.pollFrequency !== undefined + ) { + const count = + (this.pollingSubscribers.get(action.key) ?? 0) + + (action.type === SUBSCRIBE ? 1 : -1); + if (count > 0) this.pollingSubscribers.set(action.key, count); + else this.pollingSubscribers.delete(action.key); + } + }; + /** Keeps `__DC_ACTIONS__` small: polling keys keep only their latest actions, + * and all but the newest actions drop their payloads. + */ + this.prototype.logAction = function (action) { + const log = this.actionLog; + const key = (action as { key?: string }).key; + if (key !== undefined && this.pollingSubscribers.has(key)) { + // drop all but the newest maxPollingActions - 1, making room for this one + for (let i = log.length - 1, seen = 0; i >= 0; i--) { + if ( + (log[i] as { key?: string }).key === key && + ++seen >= this.maxPollingActions + ) + log.splice(i, 1); + } + } + pushCapped(log, action, this.maxBufferLength); + const old = log.length - 1 - FULL_ACTIONS; + if (old >= 0) log[old] = summarize(log[old]); + }; this.prototype.middleware = function (controller) { // a reused manager starts a fresh log for a new store if (controller !== this.controller) this.actionLog = []; @@ -200,41 +238,6 @@ export default class DevToolsManager implements Manager { } } - /** Counts polling subscribers per key, even for actions skipLogging hides */ - protected trackPolling(action: ActionTypes) { - if ( - (action.type === SUBSCRIBE || action.type === UNSUBSCRIBE) && - action.endpoint.pollFrequency !== undefined - ) { - const count = - (this.pollingSubscribers.get(action.key) ?? 0) + - (action.type === SUBSCRIBE ? 1 : -1); - if (count > 0) this.pollingSubscribers.set(action.key, count); - else this.pollingSubscribers.delete(action.key); - } - } - - /** Keeps `__DC_ACTIONS__` small: polling keys keep only their latest actions, - * and all but the newest actions drop their payloads. - */ - protected logAction(action: ActionTypes) { - const log = this.actionLog; - const key = (action as { key?: string }).key; - if (key !== undefined && this.pollingSubscribers.has(key)) { - // drop all but the newest maxPollingActions - 1, making room for this one - for (let i = log.length - 1, seen = 0; i >= 0; i--) { - if ( - (log[i] as { key?: string }).key === key && - ++seen >= this.maxPollingActions - ) - log.splice(i, 1); - } - } - pushCapped(log, action, this.maxBufferLength); - const old = log.length - 1 - FULL_ACTIONS; - if (old >= 0) log[old] = summarize(log[old]); - } - handleAction(action: any, state: any) { if (this.started) { this.devTools.send(action, state); diff --git a/packages/core/src/manager/__tests__/devtoolsManager.ts b/packages/core/src/manager/__tests__/devtoolsManager.ts index 915fa8293e9c..d9f9dcbd6bad 100644 --- a/packages/core/src/manager/__tests__/devtoolsManager.ts +++ b/packages/core/src/manager/__tests__/devtoolsManager.ts @@ -1,4 +1,5 @@ import { Controller, initialState } from '../..'; +import type { ActionTypes } from '../..'; import { FETCH, RESET, @@ -153,18 +154,18 @@ describe('DevToolsManager', () => { }); describe('polling', () => { - function pollingDispatch() { - const manager = new DevToolsManager({ - name: 'App', - maxPollingActions: 2, - }); + function pollingSetup(skipLogging?: (action: ActionTypes) => boolean) { + const manager = new DevToolsManager( + { name: 'App', maxPollingActions: 2 }, + skipLogging, + ); const { dispatch } = setup(manager); manager.init(initialState); - return dispatch; + return { manager, dispatch }; } it('keeps only the latest maxPollingActions for a polling key', () => { - const dispatch = pollingDispatch(); + const { dispatch } = pollingSetup(); dispatch(subscribe('a')); dispatch(subscribe('a')); dispatch(setAction(0)); @@ -184,7 +185,7 @@ describe('DevToolsManager', () => { }); it('trims actions logged before polling started', () => { - const dispatch = pollingDispatch(); + const { dispatch } = pollingSetup(); for (let i = 1; i <= 3; i++) { dispatch(fetchAction('a', i)); dispatch(responseAction('a', i)); @@ -199,7 +200,7 @@ describe('DevToolsManager', () => { }); it('holds the cap across unsubscribe and resubscribe', () => { - const dispatch = pollingDispatch(); + const { dispatch } = pollingSetup(); for (let cycle = 0; cycle < 3; cycle++) { dispatch(subscribe('a')); for (let i = 1; i <= 3; i++) { @@ -221,12 +222,7 @@ describe('DevToolsManager', () => { }); it('tracks polling even when skipLogging hides subscribes', () => { - const manager = new DevToolsManager( - { name: 'App', maxPollingActions: 2 }, - action => action.type === SUBSCRIBE, - ); - const { dispatch } = setup(manager); - manager.init(initialState); + const { dispatch } = pollingSetup(action => action.type === SUBSCRIBE); dispatch(subscribe('a')); for (let i = 1; i <= 3; i++) dispatch(fetchAction('a', i)); expect(typesAndKeys()).toEqual([ @@ -236,7 +232,7 @@ describe('DevToolsManager', () => { }); it('stops capping once every subscriber leaves', () => { - const dispatch = pollingDispatch(); + const { dispatch } = pollingSetup(); dispatch(subscribe('a')); dispatch(subscribe('a')); dispatch(unsubscribe('a')); @@ -253,12 +249,7 @@ describe('DevToolsManager', () => { }); it('forgets polling keys on cleanup', () => { - const manager = new DevToolsManager({ - name: 'App', - maxPollingActions: 2, - }); - const { dispatch } = setup(manager); - manager.init(initialState); + const { manager, dispatch } = pollingSetup(); dispatch(subscribe('a')); manager.cleanup(); manager.init(initialState); @@ -267,7 +258,7 @@ describe('DevToolsManager', () => { }); it('does not cap subscriptions without pollFrequency', () => { - const dispatch = pollingDispatch(); + const { dispatch } = pollingSetup(); dispatch({ ...subscribe('a'), endpoint: { name: 'live' } }); for (let i = 1; i <= 3; i++) dispatch(fetchAction('a', i)); expect(actionLogs().get('App')).toHaveLength(4); From ad1d9e2e96b6a15d3a4abfe69fd551946e2e6240 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:41:23 +0000 Subject: [PATCH 15/17] chore: Regenerate website editor types Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- .../Playground/editor-types/@data-client/core.d.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 ae0de3680cc2..1301ff458222 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 @@ -1520,19 +1520,15 @@ declare class DevToolsManager implements Manager { protected registryKey?: string; /** Subscriber count for each key that is polling; a key is removed when its count reaches 0 */ protected pollingSubscribers: Map; - /** Actions kept in the log for each key while it polls */ + /** Actions kept in the log for each key while it polls (development only) */ maxPollingActions: number; + protected trackPolling: (action: ActionTypes) => void; + protected logAction: (action: ActionTypes) => void; protected controller: Controller; skipLogging?: (action: ActionTypes) => boolean; devtoolsName: string; maxBufferLength: number; constructor(config?: DevToolsConfig, skipLogging?: (action: ActionTypes) => boolean); - /** Counts polling subscribers per key, even for actions skipLogging hides */ - protected trackPolling(action: ActionTypes): void; - /** Keeps `__DC_ACTIONS__` small: polling keys keep only their latest actions, - * and all but the newest actions drop their payloads. - */ - protected logAction(action: ActionTypes): void; handleAction(action: any, state: any): void; /** Called when initial state is ready */ init(state: State): void; From 012088d5e84334e9af5e42f3b961a66115df3ca3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:08:45 +0000 Subject: [PATCH 16/17] refactor(core): Keep DevToolsManager log helpers private to the middleware trackPolling and logAction are only called from the middleware, so they become local functions there instead of protected prototype members, dropping them from the published types. Comments in init() now sit by the statements they explain. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- packages/core/src/manager/DevtoolsManager.ts | 80 +++++++++---------- .../editor-types/@data-client/core.d.ts | 2 - 2 files changed, 39 insertions(+), 43 deletions(-) diff --git a/packages/core/src/manager/DevtoolsManager.ts b/packages/core/src/manager/DevtoolsManager.ts index 651b19968734..dd9910c7540e 100644 --- a/packages/core/src/manager/DevtoolsManager.ts +++ b/packages/core/src/manager/DevtoolsManager.ts @@ -142,8 +142,6 @@ export default class DevToolsManager implements Manager { declare protected pollingSubscribers: Map; /** Actions kept in the log for each key while it polls (development only) */ declare maxPollingActions: number; - declare protected trackPolling: (action: ActionTypes) => void; - declare protected logAction: (action: ActionTypes) => void; declare protected controller: Controller; declare skipLogging?: (action: ActionTypes) => boolean; declare devtoolsName: string; @@ -175,49 +173,49 @@ export default class DevToolsManager implements Manager { /* istanbul ignore if */ /* istanbul ignore next */ if (process.env.NODE_ENV !== 'production') { - /** Counts polling subscribers per key, even for actions skipLogging hides */ - this.prototype.trackPolling = function (action) { - if ( - (action.type === SUBSCRIBE || action.type === UNSUBSCRIBE) && - action.endpoint.pollFrequency !== undefined - ) { - const count = - (this.pollingSubscribers.get(action.key) ?? 0) + - (action.type === SUBSCRIBE ? 1 : -1); - if (count > 0) this.pollingSubscribers.set(action.key, count); - else this.pollingSubscribers.delete(action.key); - } - }; - /** Keeps `__DC_ACTIONS__` small: polling keys keep only their latest actions, - * and all but the newest actions drop their payloads. - */ - this.prototype.logAction = function (action) { - const log = this.actionLog; - const key = (action as { key?: string }).key; - if (key !== undefined && this.pollingSubscribers.has(key)) { - // drop all but the newest maxPollingActions - 1, making room for this one - for (let i = log.length - 1, seen = 0; i >= 0; i--) { - if ( - (log[i] as { key?: string }).key === key && - ++seen >= this.maxPollingActions - ) - log.splice(i, 1); - } - } - pushCapped(log, action, this.maxBufferLength); - const old = log.length - 1 - FULL_ACTIONS; - if (old >= 0) log[old] = summarize(log[old]); - }; this.prototype.middleware = function (controller) { // a reused manager starts a fresh log for a new store if (controller !== this.controller) this.actionLog = []; this.controller = controller; const reducer = this.devTools && createReducer(controller as any); let state = controller.getState(); + /** Counts polling subscribers per key, even for actions skipLogging hides */ + const trackPolling = (action: ActionTypes) => { + if ( + (action.type === SUBSCRIBE || action.type === UNSUBSCRIBE) && + action.endpoint.pollFrequency !== undefined + ) { + const count = + (this.pollingSubscribers.get(action.key) ?? 0) + + (action.type === SUBSCRIBE ? 1 : -1); + if (count > 0) this.pollingSubscribers.set(action.key, count); + else this.pollingSubscribers.delete(action.key); + } + }; + /** Keeps `__DC_ACTIONS__` small: polling keys keep only their latest actions, + * and all but the newest actions drop their payloads. + */ + const logAction = (action: ActionTypes) => { + const log = this.actionLog; + const key = (action as { key?: string }).key; + if (key !== undefined && this.pollingSubscribers.has(key)) { + // drop all but the newest maxPollingActions - 1, making room for this one + for (let i = log.length - 1, seen = 0; i >= 0; i--) { + if ( + (log[i] as { key?: string }).key === key && + ++seen >= this.maxPollingActions + ) + log.splice(i, 1); + } + } + pushCapped(log, action, this.maxBufferLength); + const old = log.length - 1 - FULL_ACTIONS; + if (old >= 0) log[old] = summarize(log[old]); + }; return next => action => { const shouldSkip = this.skipLogging?.(action); - this.trackPolling(action); - if (!shouldSkip) this.logAction(action); + trackPolling(action); + if (!shouldSkip) logAction(action); const ret = next(action); if (!reducer) return ret; if (this.started) { @@ -253,10 +251,6 @@ export default class DevToolsManager implements Manager { const controllers: Map = (( globalThis as any ).__DC_CONTROLLERS__ ??= new Map()); - // another provider has the name: register under 'name (2)', 'name (3)', ... - // Without a window (server rendering), stores are made per request and never cleaned up, - // so replace the old one instead of keeping every request's store alive. - // Checks window, not document: React Native has a window but no document. // keep the key this controller already holds (init can run again without cleanup) let key = ( @@ -265,6 +259,10 @@ export default class DevToolsManager implements Manager { ) ? this.registryKey : this.devtoolsName; + // another provider has the name: register under 'name (2)', 'name (3)', ... + // Without a window (server rendering), stores are made per request and never cleaned up, + // so replace the old one instead of keeping every request's store alive. + // Checks window, not document: React Native has a window but no document. if (typeof window !== 'undefined') for ( let i = 2; 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 1301ff458222..a21c6d45e2e1 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 @@ -1522,8 +1522,6 @@ declare class DevToolsManager implements Manager { protected pollingSubscribers: Map; /** Actions kept in the log for each key while it polls (development only) */ maxPollingActions: number; - protected trackPolling: (action: ActionTypes) => void; - protected logAction: (action: ActionTypes) => void; protected controller: Controller; skipLogging?: (action: ActionTypes) => boolean; devtoolsName: string; From af22339680698758236e05cfe3aaa9f8e0f5d3a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:22:55 +0000 Subject: [PATCH 17/17] docs(skills): Tighten the devtools-debugging Where to Start table Split the request-rate row so a request that never fires points at meta.expiresAt and a missing rdc/subscribe, point meta errors at the snippet that reads them, and cover a created item missing from a list. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01U7fXTuhewgyp2SGAqY6LgJ --- .../references/devtools-debugging.md | 13 +++++++------ .../references/devtools-debugging.md | 13 +++++++------ docs/core/_agents/devtools-debugging.md | 11 ++++++----- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.agents/skills/data-client-react/references/devtools-debugging.md b/.agents/skills/data-client-react/references/devtools-debugging.md index cbe518b65e62..82592a909367 100644 --- a/.agents/skills/data-client-react/references/devtools-debugging.md +++ b/.agents/skills/data-client-react/references/devtools-debugging.md @@ -12,12 +12,13 @@ Debug `@data-client/react` state and actions programmatically via [Chrome DevToo ## Where to Start -| Symptom | First check | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Data never appears or stays loading | The key's `meta` `error` and `getError` (Step 2), and its status in `list_network_requests` | -| A mutation succeeded but the UI shows old data | The mutation key's `meta` `error` (Step 2): a response that fails to normalize, such as one wrapped in `{ data }`, is stored there with no error on the action. Then the entity in `state.entities`: if it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema` | -| A change shows, then reverts on its own | The mutation key's `meta` `error` first: an optimistic change reverts when its response fails. If there is none, repeat the change and read the action log by time (Step 3): a later `rdc/setresponse` with a newer `meta.fetchedAt` (often a poll) carried the old value | -| Requests fire too often or not at all | Timestamps in `list_network_requests`, then the action log (Step 3) of the controller that owns the endpoint: `fetchedAt` per key, and whether an `rdc/invalidate` or `rdc/invalidateall` precedes each fetch (something, often a Manager, keeps invalidating it). `pollFrequency` is in milliseconds | +| Symptom | Check first | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Data never appears or stays loading | The key's `meta` error (Filter errors, Step 3) and its request in `list_network_requests` | +| A mutation succeeded but the UI shows old data | The mutation key's `meta` error: a response that fails to normalize, such as one wrapped in `{ data }`, is stored there, not on the action. Then `state.entities`: if the entity is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema`. A created item missing from a list needs the list's Collection, such as `getList.push` | +| A change shows, then reverts on its own | The mutation key's `meta` error: an optimistic change reverts when its request fails. If there is none, repeat the change and read the log by time (Step 3) for a later `rdc/setresponse` with a newer `meta.fetchedAt` (often a poll) that carried the old value | +| Requests fire too often | Request times in `list_network_requests`, then the log (Step 3) for that key: whether an `rdc/invalidate` or `rdc/invalidateall` comes before each fetch (something, often a Manager, keeps invalidating it). `pollFrequency` is in milliseconds | +| Requests never fire | The key's `meta.expiresAt`: data stays fresh until then. A polled key also needs an `rdc/subscribe` in the log | ## Step 1: Install the Debug Shim diff --git a/.agents/skills/data-client-vue/references/devtools-debugging.md b/.agents/skills/data-client-vue/references/devtools-debugging.md index bfb0d2210ba0..07c6c19ed695 100644 --- a/.agents/skills/data-client-vue/references/devtools-debugging.md +++ b/.agents/skills/data-client-vue/references/devtools-debugging.md @@ -12,12 +12,13 @@ Debug `@data-client/vue` state and actions programmatically via [Chrome DevTools ## Where to Start -| Symptom | First check | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Data never appears or stays loading | The key's `meta` `error` and `getError` (Step 2), and its status in `list_network_requests` | -| A mutation succeeded but the UI shows old data | The mutation key's `meta` `error` (Step 2): a response that fails to normalize, such as one wrapped in `{ data }`, is stored there with no error on the action. Then the entity in `state.entities`: if it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema` | -| A change shows, then reverts on its own | The mutation key's `meta` `error` first: an optimistic change reverts when its response fails. If there is none, repeat the change and read the action log by time (Step 3): a later `rdc/setresponse` with a newer `meta.fetchedAt` (often a poll) carried the old value | -| Requests fire too often or not at all | Timestamps in `list_network_requests`, then the action log (Step 3) of the controller that owns the endpoint: `fetchedAt` per key, and whether an `rdc/invalidate` or `rdc/invalidateall` precedes each fetch (something, often a Manager, keeps invalidating it). `pollFrequency` is in milliseconds | +| Symptom | Check first | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Data never appears or stays loading | The key's `meta` error (Filter errors, Step 3) and its request in `list_network_requests` | +| A mutation succeeded but the UI shows old data | The mutation key's `meta` error: a response that fails to normalize, such as one wrapped in `{ data }`, is stored there, not on the action. Then `state.entities`: if the entity is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema`. A created item missing from a list needs the list's Collection, such as `getList.push` | +| A change shows, then reverts on its own | The mutation key's `meta` error: an optimistic change reverts when its request fails. If there is none, repeat the change and read the log by time (Step 3) for a later `rdc/setresponse` with a newer `meta.fetchedAt` (often a poll) that carried the old value | +| Requests fire too often | Request times in `list_network_requests`, then the log (Step 3) for that key: whether an `rdc/invalidate` or `rdc/invalidateall` comes before each fetch (something, often a Manager, keeps invalidating it). `pollFrequency` is in milliseconds | +| Requests never fire | The key's `meta.expiresAt`: data stays fresh until then. A polled key also needs an `rdc/subscribe` in the log | ## Step 1: Install the Debug Shim diff --git a/docs/core/_agents/devtools-debugging.md b/docs/core/_agents/devtools-debugging.md index 5a02bf3124e8..9e5bc692aabf 100644 --- a/docs/core/_agents/devtools-debugging.md +++ b/docs/core/_agents/devtools-debugging.md @@ -12,12 +12,13 @@ Debug :react[`@data-client/react`]:vue[`@data-client/vue`] state and actions pro ## Where to Start -| Symptom | First check | +| Symptom | Check first | |---|---| -| Data never appears or stays loading | The key's `meta` `error` and `getError` (Step 2), and its status in `list_network_requests` | -| A mutation succeeded but the UI shows old data | The mutation key's `meta` `error` (Step 2): a response that fails to normalize, such as one wrapped in `{ data }`, is stored there with no error on the action. Then the entity in `state.entities`: if it is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema` | -| A change shows, then reverts on its own | The mutation key's `meta` `error` first: an optimistic change reverts when its response fails. If there is none, repeat the change and read the action log by time (Step 3): a later `rdc/setresponse` with a newer `meta.fetchedAt` (often a poll) carried the old value | -| Requests fire too often or not at all | Timestamps in `list_network_requests`, then the action log (Step 3) of the controller that owns the endpoint: `fetchedAt` per key, and whether an `rdc/invalidate` or `rdc/invalidateall` precedes each fetch (something, often a Manager, keeps invalidating it). `pollFrequency` is in milliseconds | +| Data never appears or stays loading | The key's `meta` error (Filter errors, Step 3) and its request in `list_network_requests` | +| A mutation succeeded but the UI shows old data | The mutation key's `meta` error: a response that fails to normalize, such as one wrapped in `{ data }`, is stored there, not on the action. Then `state.entities`: if the entity is unchanged and the response sits only under the mutation's own `endpoints` key, the endpoint has no `schema`. A created item missing from a list needs the list's Collection, such as `getList.push` | +| A change shows, then reverts on its own | The mutation key's `meta` error: an optimistic change reverts when its request fails. If there is none, repeat the change and read the log by time (Step 3) for a later `rdc/setresponse` with a newer `meta.fetchedAt` (often a poll) that carried the old value | +| Requests fire too often | Request times in `list_network_requests`, then the log (Step 3) for that key: whether an `rdc/invalidate` or `rdc/invalidateall` comes before each fetch (something, often a Manager, keeps invalidating it). `pollFrequency` is in milliseconds | +| Requests never fire | The key's `meta.expiresAt`: data stays fresh until then. A polled key also needs an `rdc/subscribe` in the log | ## Step 1: Install the Debug Shim