From ac69982f389b9bdf991a4297df78b4c166291db3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:44:27 +0000 Subject: [PATCH 1/8] enhance(website): Record which manager sent or handled each playground action The Store inspector's action log now tags each entry with the manager that dispatched it through its controller (origin) and the manager whose middleware handled it without passing it on (consumedBy), and each history records its chain of manager names. No UI yet. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_013oVRifbiMMFhcDHM5wYAkM --- .../components/Playground/preview/Preview.tsx | 2 +- .../__tests__/managerAttribution.test.tsx | 205 ++++++++++++++++++ .../Playground/preview/store/actionLog.ts | 58 ++++- .../Playground/preview/store/attribution.ts | 115 ++++++++++ 4 files changed, 369 insertions(+), 11 deletions(-) create mode 100644 website/src/components/Playground/preview/__tests__/managerAttribution.test.tsx create mode 100644 website/src/components/Playground/preview/store/attribution.ts diff --git a/website/src/components/Playground/preview/Preview.tsx b/website/src/components/Playground/preview/Preview.tsx index 9e4972f5f3a4..02b562c0df73 100644 --- a/website/src/components/Playground/preview/Preview.tsx +++ b/website/src/components/Playground/preview/Preview.tsx @@ -55,7 +55,7 @@ function Preview({ const log = registry.log.connect(history, replacedHistory, action => network.skipLogging(action), ); - return [log.head, registry, ...managers, log.tail]; + return [log.head, registry, ...log.track(managers), log.tail]; }); return ( diff --git a/website/src/components/Playground/preview/__tests__/managerAttribution.test.tsx b/website/src/components/Playground/preview/__tests__/managerAttribution.test.tsx new file mode 100644 index 000000000000..4b3cfc3e6e1f --- /dev/null +++ b/website/src/components/Playground/preview/__tests__/managerAttribution.test.tsx @@ -0,0 +1,205 @@ +/// +import { Endpoint, Entity } from '@data-client/endpoint'; +import { + actionTypes, + DataProvider, + NetworkManager, + PollingSubscription, + SubscriptionManager, + useController, + useSuspense, + type ActionTypes, + type Controller, + type Manager, + type Middleware, +} from '@data-client/react'; +import { act, render, screen } from '@testing-library/react'; +import React, { Suspense } from 'react'; + +import { managerNames } from '../store/attribution'; +import SchemaRegistry from '../store/schemaRegistry'; + +class Ticker extends Entity { + id = ''; + price = 0; +} + +/** Pushes tickers with `controller.set`, and handles subscribes itself */ +class StreamManager implements Manager { + declare controller: Controller; + middleware: Middleware = controller => { + this.controller = controller; + return next => action => + action.type === actionTypes.SUBSCRIBE ? Promise.resolve() : next(action); + }; + + push(price: number) { + return this.controller.set(Ticker, { id: 'BTC' }, { id: 'BTC', price }); + } + + cleanup() {} +} + +/** Passes everything on, after a tick */ +class SlowManager implements Manager { + middleware: Middleware = () => next => async action => { + await Promise.resolve(); + return next(action); + }; + + cleanup() {} +} + +const getTicker = new Endpoint(async () => ({ id: 'BTC', price: 1 }), { + schema: Ticker, + key: () => 'GET /ticker', + name: 'getTicker', +}); + +function mount(own: Manager[], preview?: React.ReactNode) { + const registry = new SchemaRegistry({ trimEvery: 1 }); + const log = registry.log.connect(0); + const managers = [log.head, registry, ...log.track(own), log.tail]; + const ref: { ctrl?: Controller } = {}; + function Grab() { + ref.ctrl = useController(); + return null; + } + render( + + + {preview} + , + ); + return { + ctrl: () => ref.ctrl!, + history: () => registry.log.history(0), + entries: () => + registry.log.history(0).entries.map(e => ({ + type: e.action.type, + origin: e.origin, + consumedBy: e.consumedBy, + })), + }; +} + +describe('manager attribution', () => { + it('names the chain in order, numbering repeats', () => { + const stream = new StreamManager(); + const { history } = mount([ + stream, + new StreamManager(), + new NetworkManager(), + new SubscriptionManager(PollingSubscription), + ]); + act(() => { + stream.push(1); + }); + expect(history().managers).toEqual([ + 'StreamManager', + 'StreamManager #2', + 'NetworkManager', + 'SubscriptionManager', + ]); + }); + + it('knows library managers by class, not by their (minified) name', () => { + class e extends NetworkManager {} + expect( + managerNames([new e(), { middleware: () => (n: unknown) => n } as any]), + ).toEqual(['NetworkManager', 'Object']); + }); + + it("credits what a manager sends through its controller, not the app's", async () => { + const stream = new StreamManager(); + const { ctrl, entries } = mount([stream, new NetworkManager()]); + await act(async () => { + void stream.push(2); + void ctrl().set(Ticker, { id: 'ETH' }, { id: 'ETH', price: 3 }); + }); + expect(entries()).toEqual([ + { type: actionTypes.SET, origin: 'StreamManager', consumedBy: undefined }, + { type: actionTypes.SET, origin: undefined, consumedBy: undefined }, + ]); + }); + + it('marks actions a middleware handled without passing on', async () => { + const { ctrl, entries } = mount([ + new StreamManager(), + new NetworkManager(), + new SubscriptionManager(PollingSubscription), + ]); + await act(async () => { + void ctrl().subscribe(getTicker); + }); + expect(entries()).toEqual([ + { + type: actionTypes.SUBSCRIBE, + origin: undefined, + consumedBy: 'StreamManager', + }, + ]); + }); + + it('credits NetworkManager with the fetches it handles and its response', async () => { + function App() { + const { price } = useSuspense(getTicker); + return
price {price}
; + } + const { entries } = mount( + [new NetworkManager(), new SubscriptionManager(PollingSubscription)], + , + ); + await screen.findByText('price 1'); + // suspense renders twice: the second fetch shares the first's request + const fetched = { + type: actionTypes.FETCH, + origin: undefined, + consumedBy: 'NetworkManager', + }; + expect(entries()).toEqual([ + fetched, + fetched, + { + type: actionTypes.SET_RESPONSE, + origin: 'NetworkManager', + consumedBy: undefined, + }, + ]); + }); + + it('waits for async middleware before calling an action consumed', async () => { + const { ctrl, entries } = mount([new SlowManager(), new NetworkManager()]); + await act(async () => { + void ctrl().set(Ticker, { id: 'BTC' }, { id: 'BTC', price: 4 }); + }); + expect(entries()).toEqual([ + { type: actionTypes.SET, origin: undefined, consumedBy: undefined }, + ]); + expect(screen.queryByText('loading')).toBeNull(); + }); + + it('credits an action sent while another passes to whoever sent it', async () => { + // Echo answers each set with an expire, from inside the set's dispatch + class Echo implements Manager { + middleware: Middleware = controller => next => action => { + if (action.type === actionTypes.SET) + controller.expireAll({ testKey: () => false }); + return next(action); + }; + + cleanup() {} + } + const stream = new StreamManager(); + const { ctrl, entries } = mount([stream, new Echo(), new NetworkManager()]); + await act(async () => { + void stream.push(5); + void ctrl().resetEntireStore(); + }); + expect(entries().map(e => [e.type, e.origin])).toEqual([ + [actionTypes.SET, 'StreamManager'], + [actionTypes.EXPIREALL, 'Echo'], + [actionTypes.RESET, undefined], + ]); + }); +}); diff --git a/website/src/components/Playground/preview/store/actionLog.ts b/website/src/components/Playground/preview/store/actionLog.ts index 1b447c889c17..ac1913bf546b 100644 --- a/website/src/components/Playground/preview/store/actionLog.ts +++ b/website/src/components/Playground/preview/store/actionLog.ts @@ -16,6 +16,7 @@ import { type Change, type RequestGroup, } from './actionGroups'; +import { attributed, managerNames, type Attribution } from './attribution'; import type { Moment } from './nav'; const { createReducer } = __INTERNAL__; @@ -57,6 +58,12 @@ export interface LogEntry { /** A fetch's: whether NetworkManager shared it with the request it already * had for its key, instead of fetching. Missing when the log can't tell */ readonly deduped?: boolean; + /** The manager that dispatched it (through its controller); missing for + * the app's own (a component's fetch, a button's set) */ + readonly origin?: string; + /** The manager whose middleware handled it without passing it on, so the + * managers after it (and the store) never saw it */ + readonly consumedBy?: string; /** The store's own state (pending optimistic updates not yet applied) * right before and after this action. Missing when a manager handled the * action without passing it on (a plain fetch, a subscribe) */ @@ -82,6 +89,8 @@ export interface History { /** By an entry's seq: how many earlier updates of its row `updateLimit` * dropped (see `capUpdates`) */ readonly dropped?: ReadonlyMap; + /** Its current store's managers in chain order, as `track` named them */ + readonly managers?: readonly string[]; } const EMPTY: History = { entries: [], since: 0 }; @@ -208,6 +217,8 @@ export default class ActionLog { ) { /** Each action's entry, for the tail to find what the head recorded */ const recorded = new WeakMap(); + const attribution: Attribution = {}; + let managers: readonly string[] | undefined; let first = true; /** An action reached the tail, which keeps the history's state since */ let reached = false; @@ -220,6 +231,7 @@ export default class ActionLog { if (first) dropOthers(); this.histories.set(id, { ...this.history(id), + managers, state: detach(state), ...(first && { storeFrom: this.nextSeq }), }); @@ -227,6 +239,8 @@ export default class ActionLog { }; const head: Manager = { middleware: () => next => action => { + const origin = attribution.dispatching; + attribution.dispatching = undefined; if (!this.recording) return next(action); // the first action recorded starts the history if (first) dropOthers(); @@ -239,10 +253,11 @@ export default class ActionLog { action.type === actionTypes.FETCH && { deduped: deduped(action), }), + ...(origin && { origin }), }; first = false; recorded.set(action, entry); - this.append(id, entry); + this.append(id, entry, managers); return next(action); }, // a store can start without dispatching (one restored after an error, @@ -273,13 +288,32 @@ export default class ActionLog { action.type === actionTypes.GC ? detach(before) : before, action, ); - this.settle(id, recorded.get(action), { before, after: state }); + // the state it left (one sent before recording started has no + // entry, but its state counts) + this.patch( + id, + recorded.get(action)?.seq, + { store: { before, after: state } }, + { state }, + ); return next(action); }; }, cleanup() {}, }; - return { head, tail }; + /** The store's own managers (between `head` and `tail`), each + * crediting what it dispatches and consumes to its name */ + const track = (chain: readonly Manager[]): Manager[] => { + const names = managerNames(chain); + managers = names; + return chain.map((manager, i) => + attributed(manager, names[i], attribution, action => { + const entry = recorded.get(action); + if (entry) this.patch(id, entry.seq, { consumedBy: names[i] }); + }), + ); + }; + return { head, tail, track }; } history = (id: number): History => this.histories.get(id) ?? EMPTY; @@ -352,7 +386,7 @@ export default class ActionLog { }; }; - private append(id: number, entry: LogEntry) { + private append(id: number, entry: LogEntry, managers?: readonly string[]) { const history = this.histories.get(id); // only a store's first action starts a history; a dropped one stays gone if (!history && !entry.newStore) return; @@ -361,6 +395,7 @@ export default class ActionLog { const due = appended.length - trimmed >= this.trimEvery; this.histories.set(id, { ...history, + ...(!history && { managers }), entries: appended, since: entries.length ? since : entry.at, ...(due && this.compact(appended, history)), @@ -398,18 +433,21 @@ export default class ActionLog { }; } - /** The store state an action left */ - private settle( + /** `fields` added to entry `seq` of history `id` (and `rest` to it) */ + private patch( id: number, - entry: LogEntry | undefined, - store: Required['store'], + seq: number | undefined, + fields: Partial, + rest?: Partial, ) { const history = this.histories.get(id); if (!history) return; this.histories.set(id, { ...history, - entries: history.entries.map(e => (e === entry ? { ...e, store } : e)), - state: store.after, + entries: history.entries.map(e => + e.seq === seq ? { ...e, ...fields } : e, + ), + ...rest, }); this.notify(); } diff --git a/website/src/components/Playground/preview/store/attribution.ts b/website/src/components/Playground/preview/store/attribution.ts new file mode 100644 index 000000000000..d51cb8225556 --- /dev/null +++ b/website/src/components/Playground/preview/store/attribution.ts @@ -0,0 +1,115 @@ +import { + DevToolsManager, + LogoutManager, + NetworkManager, + SubscriptionManager, + type ActionTypes, + type Controller, + type Manager, +} from '@data-client/react'; + +/** The library's managers, by class: a production build minifies their + * `constructor.name` */ +const KNOWN: readonly [abstract new (...args: any) => Manager, string][] = [ + [NetworkManager, 'NetworkManager'], + [SubscriptionManager, 'SubscriptionManager'], + [DevToolsManager, 'DevToolsManager'], + [LogoutManager, 'LogoutManager'], +]; + +/** What the Store inspector calls each manager of a chain: its class, with + * `#2` and on when a chain holds several of one class */ +export function managerNames(managers: readonly Manager[]): string[] { + const seen = new Map(); + return managers.map(manager => { + const base = + KNOWN.find(([cls]) => manager instanceof cls)?.[1] || + manager.constructor?.name || + 'Manager'; + const n = (seen.get(base) ?? 0) + 1; + seen.set(base, n); + return n > 1 ? `${base} #${n}` : base; + }); +} + +export interface Attribution { + /** Who is dispatching right now: the manager whose controller call is on + * the stack. The log's head reads it as each action arrives, and clears + * it, so what else that dispatch sets off isn't credited to it too */ + dispatching?: string; +} + +/** `manager`, telling `attribution` while it calls its controller (what it + * dispatches through `set`, `fetch`, `dispatch` …), and `consumed` with each + * action its middleware returned without passing on (when its promise + * settles, for async middleware). The prototype stays the manager, so + * `instanceof` checks still see its class */ +export function attributed( + manager: Manager, + name: string, + attribution: Attribution, + consumed: (action: ActionTypes) => void, +): Manager { + const tracked: Manager = Object.create(manager); + // with `this` the manager, as DataProvider would call them + tracked.init = manager.init && (state => manager.init?.(state)); + tracked.cleanup = () => manager.cleanup(); + const controllers = new WeakMap(); + tracked.middleware = controller => { + let own = controllers.get(controller); + if (!own) + controllers.set( + controller, + (own = asDispatcher(controller, name, attribution)), + ); + const withController = manager.middleware.call(manager, own); + return next => { + const passed = new WeakSet(); + const dispatch = withController(action => { + passed.add(action); + return next(action); + }); + return action => { + const check = () => { + if (!passed.has(action)) consumed(action); + }; + const result = dispatch(action); + if (result instanceof Promise) result.then(check, check); + else check(); + return result; + }; + }; + }; + return tracked; +} + +type AnyFn = (...args: any[]) => any; + +/** `controller`, whose every call runs as `name` dispatching */ +function asDispatcher( + controller: Controller, + name: string, + attribution: Attribution, +): Controller { + const wrapped = new Map(); + return new Proxy(controller, { + get(target, key) { + const value = Reflect.get(target, key, target); + if (typeof value !== 'function') return value; + // the same function each read, until the controller's changes + const cached = wrapped.get(key); + if (cached && cached[0] === value) return cached[1]; + const fn = function (this: unknown, ...args: unknown[]) { + const outer = attribution.dispatching; + attribution.dispatching = name; + try { + return value.apply(target, args); + } finally { + attribution.dispatching = outer; + } + }; + wrapped.set(key, [value, fn]); + return fn; + }, + }); +} From 8a3c9c2ecd3f300287e406746cdbf4f7f2632fe7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:01:38 +0000 Subject: [PATCH 2/8] enhance(website): Show which manager sent or handled each action in the Store inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Action rows tag the code's own managers (↑ StreamManager, ⊘ handled by StreamManager); an action's detail lists sentBy and handledBy. The State tab gets a Managers section: the chain in order with what each manager sent and handled up to the moment, and a row opens that manager's actions. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_013oVRifbiMMFhcDHM5wYAkM --- .../__tests__/managerAttribution.test.tsx | 151 +++++++++++++++++- .../Playground/preview/store/ActionDetail.tsx | 23 +-- .../Playground/preview/store/ActionList.tsx | 3 + .../Playground/preview/store/RootView.tsx | 96 ++++++++++- .../Playground/preview/store/Sections.tsx | 4 +- .../Playground/preview/store/StorePanel.tsx | 4 + .../Playground/preview/store/Value.tsx | 9 +- .../Playground/preview/store/actionGroups.ts | 8 + .../Playground/preview/store/actionLog.ts | 28 ++-- .../Playground/preview/store/actionParts.tsx | 36 +++++ .../Playground/preview/store/attribution.ts | 140 +++++++++++++--- .../Playground/preview/store/nav.tsx | 4 +- .../Playground/preview/store/store.module.css | 7 + 13 files changed, 455 insertions(+), 58 deletions(-) diff --git a/website/src/components/Playground/preview/__tests__/managerAttribution.test.tsx b/website/src/components/Playground/preview/__tests__/managerAttribution.test.tsx index 4b3cfc3e6e1f..1f585dcb5dc4 100644 --- a/website/src/components/Playground/preview/__tests__/managerAttribution.test.tsx +++ b/website/src/components/Playground/preview/__tests__/managerAttribution.test.tsx @@ -3,6 +3,7 @@ import { Endpoint, Entity } from '@data-client/endpoint'; import { actionTypes, DataProvider, + DevToolsManager, NetworkManager, PollingSubscription, SubscriptionManager, @@ -13,11 +14,16 @@ import { type Manager, type Middleware, } from '@data-client/react'; -import { act, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import React, { Suspense } from 'react'; import { managerNames } from '../store/attribution'; import SchemaRegistry from '../store/schemaRegistry'; +import StorePanel from '../store/StorePanel'; + +jest.mock('../../../../utils/tabStorage', () => ({ + useTabStorage: () => require('react').useState(null), +})); class Ticker extends Entity { id = ''; @@ -56,7 +62,7 @@ const getTicker = new Endpoint(async () => ({ id: 'BTC', price: 1 }), { name: 'getTicker', }); -function mount(own: Manager[], preview?: React.ReactNode) { +function mount(own: Manager[], preview?: React.ReactNode, panel = false) { const registry = new SchemaRegistry({ trimEvery: 1 }); const log = registry.log.connect(0); const managers = [log.head, registry, ...log.track(own), log.tail]; @@ -69,6 +75,7 @@ function mount(own: Manager[], preview?: React.ReactNode) { {preview} + {panel && } , ); return { @@ -202,4 +209,144 @@ describe('manager attribution', () => { [actionTypes.RESET, undefined], ]); }); + + it('counts a copy passed on as the action passed on', async () => { + class Stamping implements Manager { + middleware: Middleware = () => next => action => + next({ ...action }); + + cleanup() {} + } + const { ctrl, entries } = mount([new Stamping(), new NetworkManager()]); + await act(async () => { + void ctrl().set(Ticker, { id: 'BTC' }, { id: 'BTC', price: 6 }); + }); + expect(entries()[0].consumedBy).toBeUndefined(); + }); + + it('takes back "handled" when a held action is passed on later', async () => { + jest.useFakeTimers(); + try { + class Holding implements Manager { + middleware: Middleware = () => next => action => { + setTimeout(() => next(action), 100); + return undefined as any; + }; + + cleanup() {} + } + const { ctrl, entries } = mount([new Holding(), new NetworkManager()]); + act(() => { + void ctrl().set(Ticker, { id: 'BTC' }, { id: 'BTC', price: 7 }); + }); + expect(entries()[0].consumedBy).toBe('Holding'); + act(() => { + jest.advanceTimersByTime(100); + }); + expect(entries()[0].consumedBy).toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); + + it("doesn't credit DevToolsManager with calls through the controller it shares", async () => { + const devtools = new DevToolsManager(); + const { entries } = mount([devtools, new NetworkManager()]); + await act(async () => { + void (devtools as any).controller.set( + Ticker, + { id: 'BTC' }, + { id: 'BTC', price: 8 }, + ); + }); + expect(entries()[0].origin).toBeUndefined(); + }); +}); + +describe('Store panel manager attribution', () => { + /** The shown level */ + const top = () => + [...document.querySelectorAll('[data-level]')].find( + el => !el.closest('[hidden]') && !el.hasAttribute('data-covered'), + )!; + const managersSection = () => + screen.getByRole('button', { name: /^Managers/ }).closest('section')!; + + it('lists the chain with what each manager sent and handled', async () => { + const stream = new StreamManager(); + const { ctrl } = mount( + [ + stream, + new NetworkManager(), + new SubscriptionManager(PollingSubscription), + ], + undefined, + true, + ); + await act(async () => { + void stream.push(1); + void stream.push(2); + void ctrl().subscribe(getTicker); + }); + const rows = [...managersSection().querySelectorAll('tbody tr')].map(tr => + [...tr.querySelectorAll('td')].slice(0, 4).map(td => td.textContent), + ); + expect(rows).toEqual([ + ['1', 'StreamManager', '2', '1'], + ['2', 'NetworkManager', '0', '0'], + ['3', 'SubscriptionManager', '0', '0'], + ]); + }); + + it('starts closed when the chain is only the library defaults', async () => { + const { ctrl } = mount( + [new NetworkManager(), new SubscriptionManager(PollingSubscription)], + undefined, + true, + ); + await act(async () => { + void ctrl().set(Ticker, { id: 'ETH' }, { id: 'ETH', price: 3 }); + }); + expect( + screen + .getByRole('button', { name: /^Managers/ }) + .getAttribute('aria-expanded'), + ).toBe('false'); + }); + + it("opens a manager's actions, tagged with who sent them", async () => { + const stream = new StreamManager(); + const { ctrl } = mount([stream, new NetworkManager()], undefined, true); + await act(async () => { + void stream.push(1); + void ctrl().set(Ticker, { id: 'ETH' }, { id: 'ETH', price: 3 }); + }); + fireEvent.click(within(managersSection()).getByText('StreamManager')); + const level = top(); + expect( + within(level).getByRole('navigation', { name: 'Store location' }) + .textContent, + ).toContain('StreamManager'); + // its one set, without naming it again on each row + expect( + level.querySelectorAll('[role="button"][aria-expanded]'), + ).toHaveLength(1); + expect(within(level).queryByText(/↑ StreamManager/)).toBeNull(); + }); + + it("tags the store's actions with the manager that sent them", async () => { + const stream = new StreamManager(); + const { ctrl } = mount([stream, new NetworkManager()], undefined, true); + await act(async () => { + void stream.push(1); + void ctrl().set(Ticker, { id: 'ETH' }, { id: 'ETH', price: 3 }); + }); + fireEvent.click( + within(screen.getAllByRole('group', { name: /^Scrubber/ })[0]).getByRole( + 'button', + { name: 'Actions' }, + ), + ); + expect(screen.getAllByText('↑ StreamManager')).toHaveLength(1); + }); }); diff --git a/website/src/components/Playground/preview/store/ActionDetail.tsx b/website/src/components/Playground/preview/store/ActionDetail.tsx index 8c6ea88228de..9b1f7a1b05c4 100644 --- a/website/src/components/Playground/preview/store/ActionDetail.tsx +++ b/website/src/components/Playground/preview/store/ActionDetail.tsx @@ -54,6 +54,8 @@ export function ActionDetail({
+ {entry.origin && {entry.origin}} + {entry.consumedBy && {entry.consumedBy}} {actionFields(entry.action).map(([name, value]) => ( ))} @@ -106,7 +108,7 @@ export function SubjectChanges({ )} : - {unappliedNote(entries[entries.length - 1].action)} + {unappliedNote(entries[entries.length - 1])} }
@@ -117,6 +119,7 @@ export function SubjectChanges({ function unchangedNote(subject: View) { switch (subject.kind) { case 'root': + case 'manager': return 'No change to the store'; case 'list': return 'No change to these rows'; @@ -126,16 +129,14 @@ function unchangedNote(subject: View) { } /** Why an action that never reached the store is still in the log */ -function unappliedNote(action: ActionTypes) { - switch (action.type) { - case actionTypes.FETCH: - return 'Started the request; the store changes when its response arrives'; - case actionTypes.SUBSCRIBE: - case actionTypes.UNSUBSCRIBE: - return 'Handled by SubscriptionManager; the store is unchanged'; - default: - return 'A manager handled this without passing it to the store'; - } +function unappliedNote({ action, consumedBy }: LogEntry) { + // NetworkManager sets off a fetch's request (its name may end in `#2`) + const requested = !consumedBy || consumedBy.startsWith('NetworkManager'); + if (action.type === actionTypes.FETCH && requested) + return 'Started the request; the store changes when its response arrives'; + return consumedBy ? + `Handled by ${consumedBy}; the store is unchanged` + : 'Not passed on to the store (yet)'; } /** Rows the `stored` actions changed, and how */ diff --git a/website/src/components/Playground/preview/store/ActionList.tsx b/website/src/components/Playground/preview/store/ActionList.tsx index 7ec6dcefefb9..2e3a3ad62820 100644 --- a/website/src/components/Playground/preview/store/ActionList.tsx +++ b/website/src/components/Playground/preview/store/ActionList.tsx @@ -26,6 +26,7 @@ import { gapText, KeyLabel, Lifecycle, + ManagerTags, spanOf, Status, statusFailed, @@ -395,6 +396,7 @@ const GroupRow = memo(function GroupRow({ {group.kind === 'single' && } +