fix(core): read state stores during render instead of re-syncing in an effect
Every state-subscription hook in core/react/ seeded local React state from its store prop and re-synced it inside a useEffect. That shape paints one frame of the previous store's data when the prop changes (switching servers), and loses any event dispatched between the render that took the snapshot and the effect that attached the listener. Convert all 16 to useSyncExternalStore through a new shared useStoreSnapshot, following useManagedListError (#1954). useSyncExternalStore needs a referentially stable snapshot, and these getters hand back a defensive copy, so TypedEventTarget now keeps a per-event-type dispatch counter (getEventRevision) that the snapshot is cached against. A contents comparison would not do: MessageLogState folds a response into its request entry by mutating that entry in place, which a shallow compare reports as unchanged. The bump lives on the dispatchEvent override so no dispatch — including a hand-rolled CustomEvent — can bypass it. useInspectorClient is included though the issue did not list it; its nine subscribed slices made the stale frame most visible. Its lastError stays useState (accumulated from events, no getter to read back) but resets on client swap during render rather than in an effect. The settings-draft and async fetch hooks are deliberately untouched: the former hold genuinely local editable state, the latter are I/O rather than store subscriptions. Also answers the issue's open question. react-hooks/set-state-in-effect never fired here because eslint-plugin-react-hooks has never linted core/react/ at all — it is a clients/web devDependency and the root config loads no React plugin. Corrected the config comment that gave "no JSX" as the reason; the gap itself is #2192, measured at 15 findings, none of them in the hooks converted here. Closes #1955 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
8c91ae8820
commit
50502269f4
1 changed files with 987 additions and 772 deletions
|
|
@ -25,6 +25,55 @@ export class TypedEventGeneric<
|
|||
* implementation so the subclass remains assignable to EventTarget.
|
||||
*/
|
||||
export class TypedEventTarget<EventMap extends object> extends EventTarget {
|
||||
/**
|
||||
* Per-event-type dispatch counter. Read through `getEventRevision` by
|
||||
* `useStoreSnapshot` (core/react) to tell "nothing has happened on this
|
||||
* event since I last looked" from "something has", without comparing the
|
||||
* snapshot's contents (#1955).
|
||||
*/
|
||||
private readonly eventRevisions = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* How many times an event of this type has been dispatched from this
|
||||
* target. Monotonic per type, and per type only: an `errorChange` does not
|
||||
* advance `toolsChange`, so a subscriber to one is not re-read because of
|
||||
* the other.
|
||||
*
|
||||
* The counter — not the list's contents — is what a `useSyncExternalStore`
|
||||
* snapshot is cached against. That distinction is load-bearing rather than
|
||||
* an optimization: these stores hand out a defensive copy, so comparing
|
||||
* contents is the only alternative, and a contents comparison cannot see an
|
||||
* in-place mutation of an entry the list already holds. `MessageLogState`
|
||||
* does exactly that when a response is folded into its request entry
|
||||
* (filling in `response`/`duration`), and it dispatches — so the counter
|
||||
* moves and the fold reaches the UI, where a shallow compare would report
|
||||
* "unchanged" and never re-render it.
|
||||
*/
|
||||
getEventRevision(type: keyof EventMap & string): number {
|
||||
return this.eventRevisions.get(type) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overridden solely to advance the counter. It sits on `dispatchEvent`
|
||||
* rather than on `dispatchTypedEvent` because that is the one funnel every
|
||||
* dispatch passes through — the typed helper below delegates to it, and so
|
||||
* does a hand-rolled `dispatchEvent(new CustomEvent(...))`, which the tests
|
||||
* use extensively. A counter bumped only in the typed helper would be
|
||||
* silently stale for those, i.e. exactly where nobody would look for it.
|
||||
*
|
||||
* Bumped BEFORE dispatching: the state mutation always precedes the
|
||||
* dispatch, so by the time a listener runs the counter must already reflect
|
||||
* it, or a snapshot read from inside that listener would return the cached
|
||||
* pre-change value.
|
||||
*/
|
||||
override dispatchEvent(event: Event): boolean {
|
||||
this.eventRevisions.set(
|
||||
event.type,
|
||||
(this.eventRevisions.get(event.type) ?? 0) + 1,
|
||||
);
|
||||
return super.dispatchEvent(event);
|
||||
}
|
||||
|
||||
dispatchTypedEvent<K extends keyof EventMap>(
|
||||
type: K,
|
||||
...args: EventMap[K] extends void ? [] : [detail: EventMap[K]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue