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 dede392e3b1f..82592a909367 100644 --- a/.agents/skills/data-client-react/references/devtools-debugging.md +++ b/.agents/skills/data-client-react/references/devtools-debugging.md @@ -10,17 +10,30 @@ 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 | 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 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. +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'`), +which only sees actions from then on. ```js // evaluate_script @@ -30,44 +43,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. @@ -186,13 +196,23 @@ 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 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 ```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, + fetchedAt: a.meta?.fetchedAt, + error: a.error ? (a.response?.message || true) : undefined, +})) ``` ### Filter by action type @@ -202,20 +222,31 @@ 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, fetchedAt: a.meta?.fetchedAt })) ``` ### 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) +() => ({ + 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 ```js // evaluate_script -() => { globalThis.__DC_ACTION_LOG__ = []; return { cleared: true }; } +() => { __DC_ACTION_LOG__.length = 0; return { cleared: true }; } ``` ## Step 4: Mutate State via Controller @@ -278,11 +309,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 | @@ -330,24 +356,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/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 15e8201281b3..07c6c19ed695 100644 --- a/.agents/skills/data-client-vue/references/devtools-debugging.md +++ b/.agents/skills/data-client-vue/references/devtools-debugging.md @@ -10,17 +10,30 @@ 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 | 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 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. +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'`), +which only sees actions from then on. ```js // evaluate_script @@ -30,44 +43,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. @@ -186,13 +196,23 @@ 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 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 ```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, + fetchedAt: a.meta?.fetchedAt, + error: a.error ? (a.response?.message || true) : undefined, +})) ``` ### Filter by action type @@ -202,20 +222,31 @@ 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, fetchedAt: a.meta?.fetchedAt })) ``` ### 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) +() => ({ + 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 ```js // evaluate_script -() => { globalThis.__DC_ACTION_LOG__ = []; return { cleared: true }; } +() => { __DC_ACTION_LOG__.length = 0; return { cleared: true }; } ``` ## Step 4: Mutate State via Controller @@ -278,11 +309,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 | @@ -330,24 +356,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/.changeset/devtools-action-log.md b/.changeset/devtools-action-log.md new file mode 100644 index 000000000000..fc20d7689625 --- /dev/null +++ b/.changeset/devtools-action-log.md @@ -0,0 +1,18 @@ +--- +'@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. + +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/.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..9e5bc692aabf 100644 --- a/docs/core/_agents/devtools-debugging.md +++ b/docs/core/_agents/devtools-debugging.md @@ -10,17 +10,30 @@ 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 | 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 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. +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'`), +which only sees actions from then on. ```js // evaluate_script @@ -30,44 +43,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.] @@ -186,13 +196,23 @@ 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 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 ```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, + fetchedAt: a.meta?.fetchedAt, + error: a.error ? (a.response?.message || true) : undefined, +})) ``` ### Filter by action type @@ -202,20 +222,31 @@ 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, fetchedAt: a.meta?.fetchedAt })) ``` ### 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) +() => ({ + 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 ```js // evaluate_script -() => { globalThis.__DC_ACTION_LOG__ = []; return { cleared: true }; } +() => { __DC_ACTION_LOG__.length = 0; return { cleared: true }; } ``` ## Step 4: Mutate State via Controller @@ -278,11 +309,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 | @@ -330,24 +356,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/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index 4804a60d26a9..05b72643b04d 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,36 @@ __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. 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. 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 for each key (endpoint and args) (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 are removed from the map when `cleanup()` is called. +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..dd9910c7540e 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'; @@ -84,6 +85,44 @@ 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); +} + +/** 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 @@ -95,6 +134,14 @@ 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 */ + 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 */ + declare protected pollingSubscribers: Map; + /** Actions kept in the log for each key while it polls (development only) */ + declare maxPollingActions: number; declare protected controller: Controller; declare skipLogging?: (action: ActionTypes) => boolean; declare devtoolsName: string; @@ -115,6 +162,11 @@ 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 (process.env.NODE_ENV !== 'production') { + this.actionLog = []; + this.pollingSubscribers = new Map(); + this.maxPollingActions = config?.maxPollingActions || 4; + } } static { @@ -122,13 +174,50 @@ 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; - 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(); + /** 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); + trackPolling(action); + if (!shouldSkip) logAction(action); 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 +240,41 @@ 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()); + // 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; + // 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; + 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 +307,14 @@ 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__; - 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.node.ts b/packages/core/src/manager/__tests__/devtoolsManager.node.ts new file mode 100644 index 000000000000..c843a7122c42 --- /dev/null +++ b/packages/core/src/manager/__tests__/devtoolsManager.node.ts @@ -0,0 +1,48 @@ +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 server rendering', () => { + expect(typeof window).toBe('undefined'); + 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); +}); + +// 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; + } +}); diff --git a/packages/core/src/manager/__tests__/devtoolsManager.ts b/packages/core/src/manager/__tests__/devtoolsManager.ts new file mode 100644 index 000000000000..d9f9dcbd6bad --- /dev/null +++ b/packages/core/src/manager/__tests__/devtoolsManager.ts @@ -0,0 +1,342 @@ +import { Controller, initialState } from '../..'; +import type { ActionTypes } from '../..'; +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; + +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 }); +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__; + 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('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); + 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('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 pollingSetup(skipLogging?: (action: ActionTypes) => boolean) { + const manager = new DevToolsManager( + { name: 'App', maxPollingActions: 2 }, + skipLogging, + ); + const { dispatch } = setup(manager); + manager.init(initialState); + return { manager, dispatch }; + } + + it('keeps only the latest maxPollingActions for a polling key', () => { + const { dispatch } = pollingSetup(); + 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('trims actions logged before polling started', () => { + const { dispatch } = pollingSetup(); + 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('holds the cap across unsubscribe and resubscribe', () => { + const { dispatch } = pollingSetup(); + 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('tracks polling even when skipLogging hides subscribes', () => { + const { dispatch } = pollingSetup(action => action.type === SUBSCRIBE); + 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 } = pollingSetup(); + 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('forgets polling keys on cleanup', () => { + const { manager, dispatch } = pollingSetup(); + 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 } = pollingSetup(); + 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' }); + 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', 'App (3)']); + expect([...actionLogs().keys()]).toEqual(['App', 'App (3)']); + a.cleanup(); + c.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('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); + // cleanup before init has nothing to remove + manager.cleanup(); + manager.init(initialState); + const other = new Controller(); + controllers().set('App', other); + manager.cleanup(); + expect(controllers().get('App')).toBe(other); + expect(actionLogs().has('App')).toBe(true); + }); + }); +}); diff --git a/packages/core/src/manager/devtoolsTypes.ts b/packages/core/src/manager/devtoolsTypes.ts index 49e3d76b9e3f..b1cf350c7fa2 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 key (endpoint and args) while it polls + * + * @default 4 (the last two polls) + */ + maxPollingActions?: number; } 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 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..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 @@ -1496,6 +1496,11 @@ interface EnhancerOptions { } interface DevToolsConfig extends EnhancerOptions { type?: string; + /** Actions kept in `__DC_ACTIONS__` for each key (endpoint and args) while it polls + * + * @default 4 (the last two polls) + */ + maxPollingActions?: number; } /** Integrates with https://github.com/reduxjs/redux-devtools @@ -1509,6 +1514,14 @@ 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 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 (development only) */ + maxPollingActions: number; protected controller: Controller; skipLogging?: (action: ActionTypes) => boolean; devtoolsName: string;