Skip to content

Commit a4c5975

Browse files
authored
feat(client-devtools): close the gap to the panel design (#102)
* feat(client-devtools): close the gap to the panel design The last pass shipped the structural half and I called it finished, which it was not. This is the rest of it, or nearly. The launcher grew the two things it was drawn with. Vitals slide out on hover, so the resting state is still one tile, and `pend` only appears when there is something pending: a permanent `0 pend` teaches you to stop reading it. The ring goes amber while the overlay stack is non-empty. A stuck optimistic write is a bug you want to see and a request in flight is Tuesday, so pending outranks fetching and a real error outranks both. It also gets out of the way now. If a framework's dev badge already owns the bottom-right corner the launcher lifts itself above it, which is the collision that started this whole piece of work. Matched by element name rather than by position, because measuring layout on mount is how a devtools panel starts causing the reflows it exists to help you find. An explicit `offset` beats anything it guessed. The trace flags near misses now, and here is the threshold. Per raised tag, not per cause. A tag that reached no query and closely resembles one somebody carries gets a banner even when the same cause reached something through a different tag, because the tag that missed is still wrong and the one that landed is what hides it. `orderCreate` invalidating `Order:{res.id}` against a list carrying `Order[]` is the case, and it is the defect this package exists to explain. A tag that reached nothing and resembles nothing stays quiet. The vitals strip is back to what StoreSnapshot actually holds. Tombstones and stamped tags are both bounded caches, so a number that climbs is the shape of a leak, and neither was visible anywhere. Network rows draw a bar. Two segments and only two, wire and backoff, because those are the two the transport measures. It does not report when a response started decoding or when the store committed it, and a bar that split those out would be drawing a shape nobody measured. You can act on the overlay stack: roll a layer back, promote one by hand, and read the base record beside what the fold makes of it, changed fields only. Holding a query in loading or error is panel-local and writes nothing. The row says held, the pane says held, the trace says you did it, and release is a map delete. A faked state has to be impossible to mistake for a real one and reversible without the cache unwinding anything. Keyboard: escape, 1 to 9, cmd-shift-F, f for freeze, space, and r and i for the selected row. Nothing fires while you are typing. A panel that eats a 3 out of its own filter box is worse than one with no shortcuts. Every query pane has a why button that opens Explain already pointed at it, instead of asking you to type an exact key from memory into a filter box. The panel opened on the queries tab while the tab list started with the trace. A test caught it. It opens on the trace now. Budgets re-baselined once rather than nudged a fourth time. The reasoning is in the test: the panel is one closure over shared UI state and there is nothing a consumer could decline to import, so splitting would cost more than it saves. The parts that could be declined were split out already and they are ./requests and ./control. Still missing, and deliberately: copy as curl and replay, which need headers and a transport reference the request log does not keep on purpose; per-phase timings, which need more transport events; a command palette, which duplicates the filter box. * feat(client-devtools): finish the panel design The last of it. What is left out at the end is left out for a reason, not because I ran out of road. Editing an entity field pushes a patch onto the overlay stack. This is the argument the whole state-manipulation section rested on and it was the one piece of it I had not built. A devtools edit now rides the same machinery a pending optimistic mutation does, so it never writes the base store, undo is removing the entry rather than applying an inverse, and an evicting stream frame takes it with it exactly as it would take a real optimistic write. A merge over a base record that is gone patches nothing, and that rule was already in OverlayStack. RestTransport closes the credential refresh now. `refresh` had no matching event, so a watcher could say a request hit the refresh but not how long it sat there, which is the only number that tells an auth stall apart from a slow server. The waterfall draws that wait in violet, beside the wire and the backoff. Force stale marks one query behind the server without asking for it again. Invalidate raises its tags and reaches everything sharing them; this reaches one, and leaves the request for whenever something next needs it. That is the state you want when you are trying to see what a stale read looks like. Curl on the request pane. The credential is a placeholder because the log never keeps the real one, which is the reason it is cheap enough to leave running. The shortcuts are finished: slash and cmd-K into the filter box, cmd-Z pops the top overlay, F11 for fullscreen, enter explains the selected row. The mock drew a command palette and it is a filter box instead, because that box already parses status:4xx, >100ms and tag:, and a palette would be a second front door to the same room. Budgets: the inspection entry went to 6.5 kB and the combined one to 10 kB. The action layer grew from six calls to eleven, and the panel needs all five of the new ones. Three things are still not here. Replay needs a transport reference the log does not hold. Mocking a response needs the same. Queued, decode and commit timings need boundaries nobody reports: they happen in the generated client and in the cache, not in the transport, so drawing them would mean inventing a shape nobody measured.
1 parent a84dc2d commit a4c5975

12 files changed

Lines changed: 1908 additions & 20 deletions

File tree

‎packages/client-core/__tests__/observe-requests.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,55 @@ describe('watching what the transport did', () => {
150150
expect(refreshed.filter((event) => !(event as { joined: boolean }).joined)).toHaveLength(1);
151151
});
152152

153+
/**
154+
* The refresh has a duration and nothing reported it. Without a closing
155+
* event the waterfall can say a request hit the credential refresh but not
156+
* how long it sat there, which is the only number that makes an auth stall
157+
* distinguishable from a slow server.
158+
*/
159+
it('closes the refresh so its duration is measurable', async () => {
160+
const seen = recorder();
161+
let token = 't0';
162+
const client = fakeClient((config) => {
163+
if (config.headers?.['Authorization'] === 'Bearer t0') throw new HttpFailure(401);
164+
165+
return { ok: true };
166+
});
167+
const auth: AuthProvider = {
168+
credentials: () => ({ Authorization: `Bearer ${token}` }),
169+
refresh: () => {
170+
token = 't1';
171+
172+
return Promise.resolve();
173+
},
174+
};
175+
const rest = new RestTransport({ client, auth, sleep: () => Promise.resolve(), observer: seen.observe });
176+
177+
await rest.execute({ meta: list, args: {} });
178+
179+
const kinds = seen.events.map((event) => event.type);
180+
181+
expect(kinds).toContain('refresh');
182+
expect(kinds).toContain('refreshed');
183+
expect(kinds.indexOf('refreshed')).toBeGreaterThan(kinds.indexOf('refresh'));
184+
});
185+
186+
it('closes the refresh even when it fails', async () => {
187+
const seen = recorder();
188+
const client = fakeClient(() => {
189+
throw new HttpFailure(401);
190+
});
191+
const auth: AuthProvider = {
192+
credentials: () => ({ Authorization: 'Bearer t0' }),
193+
refresh: () => Promise.reject(new Error('refresh is down')),
194+
};
195+
const rest = new RestTransport({ client, auth, sleep: () => Promise.resolve(), observer: seen.observe });
196+
197+
await expect(rest.execute({ meta: list, args: {} })).rejects.toThrow();
198+
199+
expect(seen.events.map((event) => event.type)).toContain('refreshed');
200+
});
201+
153202
it('costs an unwatched transport nothing but an undefined field', async () => {
154203
const client = fakeClient(() => ({ ok: true }));
155204
const rest = new RestTransport({ client });

‎packages/client-core/src/transport.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,14 @@ export type RequestEvent =
273273
| { readonly type: 'attempt'; readonly attempt: number }
274274
/** A 401 sent this request to the credential refresh. */
275275
| { readonly type: 'refresh'; readonly joined: boolean }
276+
/**
277+
* The refresh this request was waiting on has finished, either way.
278+
*
279+
* Paired with `refresh` so the wait has a duration. Without it a watcher can
280+
* say a request hit the credential refresh but not how long it sat there,
281+
* which is the only number that tells an auth stall apart from a slow server.
282+
*/
283+
| { readonly type: 'refreshed'; readonly ok: boolean }
276284
| {
277285
readonly type: 'retry';
278286
readonly attempt: number;
@@ -484,7 +492,10 @@ export class RestTransport implements Transport {
484492
if (generation === this.generation) {
485493
try {
486494
await this.refresh(watch);
495+
watch?.({ type: 'refreshed', ok: true });
487496
} catch {
497+
watch?.({ type: 'refreshed', ok: false });
498+
488499
// The refresh failed, so the 401 stands. Reporting the refresh's own
489500
// error here would replace "you are not authorized" with whatever
490501
// the token endpoint said, which is not what the caller asked for.

0 commit comments

Comments
 (0)