@uniflowed/query 0.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/react.js ADDED
@@ -0,0 +1,251 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/query/react`: the binding, and why it is this thin.
4
+ //
5
+ // Every hook here is a `useSyncExternalStore` over a store that already knows
6
+ // how to keep itself correct, and almost nothing else. There is no effect that
7
+ // starts a request, no ref holding the latest query function, and no
8
+ // dependency array deciding when to refetch. Subscribing *is* the statement
9
+ // that a value is wanted, so the store starts the request; the component only
10
+ // reads.
11
+ //
12
+ // That is not a stylistic preference. Reading an external store through the
13
+ // API React provides for it is what makes the value React committed with the
14
+ // value the component rendered — which is what stops two components sharing a
15
+ // key from painting different versions of it during a concurrent render — and
16
+ // it is what lets a prerender state the server's answer rather than fall
17
+ // through to it.
18
+ //
19
+ // # What runs during render, and what does not
20
+ //
21
+ // During render: reading the cache, applying `select`, and comparing the
22
+ // result with the previous one. All of it is pure with respect to anything
23
+ // outside this hook — the entry is not created, no request is started, no
24
+ // listener is registered, and running it twice produces the same object. The
25
+ // observer's memo slots are written, and that is a cache in the `useMemo`
26
+ // sense; the module docs in `observer.js` set out why that is the only way
27
+ // `getSnapshot` can meet its contract.
28
+ //
29
+ // Outside render, in the effect React calls `subscribe` from: building the
30
+ // entry, registering the listener, starting the fetch, and setting the stale
31
+ // and interval timers. Every one is undone by the function `subscribe`
32
+ // returns, so a component that mounts and unmounts leaves nothing behind.
33
+ //
34
+ // # Why the query function is not a dependency
35
+ //
36
+ // Callers write `queryFn` inline, so its identity changes on every render, and
37
+ // a subscription keyed on it would be torn down and rebuilt on every
38
+ // keystroke. But it is something to *call*, not something to react to: it is
39
+ // read through a callback whose identity never changes and whose body is
40
+ // always the latest, so a fresh closure changes nothing and the request that
41
+ // is eventually made is the current one.
42
+ //
43
+ // What the subscription *is* keyed on is the plan: the key's hash, whether it
44
+ // is enabled, and the times and intervals. Those are the values that change
45
+ // what is being watched, and there are few enough of them to write down —
46
+ // which is better than an exhaustive dependency array that resubscribes for
47
+ // reasons nobody can name.
48
+ //
49
+ // # Why there is no `Suspense` integration here
50
+ //
51
+ // `useQuery` returning a result rather than suspending is a decision, not an
52
+ // omission. Suspending on read makes waterfalls the default — each component
53
+ // suspends in turn, and each one's request starts only after the one above it
54
+ // resolves — and the fix is to hoist fetching to the route, which is
55
+ // `client.prefetchQuery` and `@uniflowed/router`'s loaders. A `useSuspenseQuery`
56
+ // belongs with that work, where the prefetch can be arranged, not here where
57
+ // it would quietly make every list slower.
58
+
59
+ import * as React from "@uniflowed/react";
60
+ import {
61
+ createContext,
62
+ useCallback,
63
+ useContext,
64
+ useMemo,
65
+ useState,
66
+ useSyncExternalStore,
67
+ } from "@uniflowed/react";
68
+
69
+ import { useStableCallback } from "@uniflowed/hooks";
70
+
71
+ import type { QueryClient } from "./client.js";
72
+ import { InfiniteQueryObserver } from "./infinite.js";
73
+ import type { InfiniteData, InfiniteQueryOptions, InfiniteQueryResult } from "./infinite.js";
74
+ import { hashKey } from "./key.js";
75
+ import { Mutation } from "./mutation.js";
76
+ import type {
77
+ MutationCallbacks,
78
+ MutationOptions,
79
+ MutationResult,
80
+ MutationState,
81
+ } from "./mutation.js";
82
+ import { QueryObserver } from "./observer.js";
83
+ import type { QueryOptions, QueryResult, ResolvedQueryOptions } from "./observer.js";
84
+
85
+ const ClientContext: React.Context<QueryClient | null> = createContext(null);
86
+
87
+ /**
88
+ * Make a client available to the tree.
89
+ *
90
+ * Required rather than falling back to a module-level default. A default is
91
+ * shared with every other tree in the process, which on a server means one
92
+ * reader's data is in the cache the next request renders from, and in a test
93
+ * means the previous test's answer decides this one's result.
94
+ */
95
+ export component QueryClientProvider(client: QueryClient, children: React.Node) {
96
+ return <ClientContext.Provider value={client}>{children}</ClientContext.Provider>;
97
+ }
98
+
99
+ /** The client this subtree uses. */
100
+ export function useQueryClient(): QueryClient {
101
+ const client = useContext(ClientContext);
102
+ if (client == null) {
103
+ throw new Error(
104
+ "useQuery needs a QueryClientProvider above it; render <QueryClientProvider client={new QueryClient()}>",
105
+ );
106
+ }
107
+ return client;
108
+ }
109
+
110
+ /**
111
+ * Read a key, fetching it when it is missing or stale.
112
+ *
113
+ * A cached answer is returned immediately and refreshed behind it, so
114
+ * navigating back to a page shows it at once. `isFetching` says a request is
115
+ * in flight; `isPending` says there is nothing to show yet — conflating those
116
+ * is why applications flash a spinner over data they already have.
117
+ */
118
+ export function useQuery<TData, TSelected = TData>(
119
+ options: QueryOptions<TData, TSelected>,
120
+ ): QueryResult<TSelected> {
121
+ const client = useQueryClient();
122
+ const getOptions = useStableCallback(() => options);
123
+
124
+ // Created once and never during a commit: the constructor touches nothing
125
+ // outside the object, so React discarding one of a double-invoked render's
126
+ // two observers costs an allocation and changes no behaviour.
127
+ const [observer] = useState(() => new QueryObserver<TData, TSelected>(client, getOptions));
128
+
129
+ // Once per render, and used for both what is watched and what is read, so
130
+ // the two cannot disagree about a default.
131
+ const resolved = client.resolveQuery(options);
132
+ const plan = subscriptionPlan(resolved);
133
+ const subscribe = useCallback(
134
+ (listener: () => void) => observer.subscribe(client, listener),
135
+ [observer, client, plan],
136
+ );
137
+
138
+ // Deliberately a fresh closure: it must narrow with *this* render's `select`
139
+ // and read *this* render's client. Its identity is free to change — React
140
+ // calls the latest one — while the value it returns must not, which is the
141
+ // observer's job.
142
+ const read = () => observer.readResult(client, resolved);
143
+ return useSyncExternalStore(subscribe, read, read);
144
+ }
145
+
146
+ /**
147
+ * Read a key that arrives a page at a time.
148
+ *
149
+ * One cache entry holding every page, so the list is invalidated, refetched
150
+ * and collected as the single thing the reader sees. See `infinite.js` for why
151
+ * the alternative — a query per page — cannot stay coherent.
152
+ */
153
+ export function useInfiniteQuery<TPage, TParam, TSelected = InfiniteData<TPage, TParam>>(
154
+ options: InfiniteQueryOptions<TPage, TParam, TSelected>,
155
+ ): InfiniteQueryResult<TSelected> {
156
+ const client = useQueryClient();
157
+ const getOptions = useStableCallback(() => options);
158
+ const [observer] = useState(
159
+ () => new InfiniteQueryObserver<TPage, TParam, TSelected>(client, getOptions),
160
+ );
161
+
162
+ const resolved = client.resolveQuery(options as $FlowFixMe);
163
+ const plan = subscriptionPlan(resolved);
164
+ const subscribe = useCallback(
165
+ (listener: () => void) => observer.subscribe(client, listener),
166
+ [observer, client, plan],
167
+ );
168
+
169
+ // The paged observer's snapshot is the ordinary one with the page controls
170
+ // added; `readResult` is inherited, so the widening is stated here.
171
+ const read = () => observer.readResult(client, resolved) as InfiniteQueryResult<TSelected>;
172
+ return useSyncExternalStore(subscribe, read, read);
173
+ }
174
+
175
+ /**
176
+ * Run something that changes state, with the callbacks a rollback needs.
177
+ *
178
+ * `mutate` is called from an event, so the closure it captures is the one from
179
+ * the render the reader was looking at, and there is nothing to go stale.
180
+ * `mutateAsync` is the same call for a caller who needs to await it — the
181
+ * difference is only whether a failure arrives as a rejected promise or as
182
+ * `error` on the next render.
183
+ */
184
+ export function useMutation<TVariables, TData, TContext = mixed>(
185
+ options: MutationOptions<TVariables, TData, TContext>,
186
+ ): MutationResult<TVariables, TData, TContext> {
187
+ const client = useQueryClient();
188
+ const getOptions = useStableCallback(() => options);
189
+ const [mutation] = useState(() => new Mutation<TVariables, TData, TContext>());
190
+
191
+ const subscribe = useCallback((listener: () => void) => mutation.subscribe(listener), [mutation]);
192
+ const read = () => mutation.state;
193
+ const state: MutationState<TVariables, TData> = useSyncExternalStore(subscribe, read, read);
194
+
195
+ const mutateAsync = useStableCallback(
196
+ (variables: TVariables, callbacks?: MutationCallbacks<TVariables, TData, TContext>) =>
197
+ mutation.execute(variables, client.resolveMutation(getOptions()), callbacks),
198
+ );
199
+ const mutate = useStableCallback(
200
+ (variables: TVariables, callbacks?: MutationCallbacks<TVariables, TData, TContext>) => {
201
+ // Swallowed on purpose: the failure is on the next render as `error`,
202
+ // and an uncaught rejection for a state the UI is already showing is
203
+ // noise in the console and nothing else.
204
+ void mutateAsync(variables, callbacks).catch(ignore);
205
+ },
206
+ );
207
+ const reset = useStableCallback(() => mutation.reset());
208
+
209
+ return useMemo(
210
+ () => ({
211
+ data: state.data,
212
+ error: state.error,
213
+ status: state.status,
214
+ variables: state.variables,
215
+ failureCount: state.failureCount,
216
+ isIdle: state.status === "idle",
217
+ isPending: state.status === "pending",
218
+ isSuccess: state.status === "success",
219
+ isError: state.status === "error",
220
+ mutate,
221
+ mutateAsync,
222
+ reset,
223
+ }),
224
+ [state, mutate, mutateAsync, reset],
225
+ );
226
+ }
227
+
228
+ /**
229
+ * What makes one subscription different from another.
230
+ *
231
+ * A string rather than a dependency array so the reason is readable at the
232
+ * point of failure: these are the values that change *what is being watched*.
233
+ * `queryFn`, `select` and `placeholderData` are deliberately absent — they
234
+ * change what happens when a value arrives, not whether to watch for one, and
235
+ * including them would resubscribe on every render for no effect.
236
+ */
237
+ function subscriptionPlan<TData, TSelected>(
238
+ options: ResolvedQueryOptions<TData, TSelected>,
239
+ ): string {
240
+ return [
241
+ hashKey(options.queryKey),
242
+ String(options.enabled),
243
+ String(options.staleTime),
244
+ String(options.gcTime),
245
+ String(options.refetchInterval),
246
+ String(options.refetchOnWindowFocus),
247
+ String(options.refetchOnReconnect),
248
+ ].join("|");
249
+ }
250
+
251
+ function ignore(): void {}
package/retry.js ADDED
@@ -0,0 +1,192 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/query/retry`: trying again without making the outage worse.
4
+ //
5
+ // A request fails for two unrelated reasons and the difference decides
6
+ // everything: the network dropped a packet, in which case trying again in a
7
+ // moment works; or the server said 404, in which case trying again works
8
+ // exactly as well as the first time and costs the same. So the policy is a
9
+ // function of the failure, not a constant, and [`RetryPolicy`] accepts a
10
+ // predicate for the case where only the caller can tell the two apart.
11
+ //
12
+ // # Why the delay grows
13
+ //
14
+ // The failure mode a fixed delay produces is a thundering herd: a server comes
15
+ // back up, every client that was retrying every second hits it in the same
16
+ // second, and it goes down again. Doubling the wait spreads the recovery out,
17
+ // and the cap keeps a tab left open overnight from drifting into hour-long
18
+ // silences. The default is `1s, 2s, 4s, 8s…` to thirty seconds.
19
+ //
20
+ // Jitter is deliberately absent. It is the right answer for a fleet of servers
21
+ // retrying each other, and close to irrelevant for browser tabs, which are
22
+ // already spread out by the reader's own timing; adding it here would make
23
+ // every test of this module probabilistic in exchange for nothing measurable.
24
+ //
25
+ // # Why this module owns the sleeping too
26
+ //
27
+ // A retry loop that cannot be interrupted is a leak with a delay on it: the
28
+ // reader has navigated away, the query is unobserved, and the loop still wakes
29
+ // up in eight seconds to make a request nobody will read. So [`runWithRetry`]
30
+ // takes the same `AbortSignal` the request does, and the sleep between
31
+ // attempts rejects the moment it fires. Splitting "how long to wait" from
32
+ // "waiting" would put the half that matters in whichever module happened to
33
+ // call this one.
34
+
35
+ /**
36
+ * Whether a failed attempt should be tried again.
37
+ *
38
+ * `false` never retries, `true` retries forever, a number is a count of
39
+ * *retries* — `2` means three attempts in total — and a predicate is given the
40
+ * number of failures so far and the last error, which is the only form that
41
+ * can tell a 500 from a 404.
42
+ */
43
+ export type RetryPolicy = boolean | number | ((failureCount: number, error: Error) => boolean);
44
+
45
+ /** How long to wait before the next attempt, in milliseconds. */
46
+ export type RetryDelay = number | ((failureCount: number, error: Error) => number);
47
+
48
+ /**
49
+ * What a cancelled request rejects with.
50
+ *
51
+ * A real class rather than a string, because the code that swallows a
52
+ * cancellation must not also swallow a genuine failure — telling them apart by
53
+ * message would break the first time someone's API said "aborted".
54
+ */
55
+ export class CancelledError extends Error {
56
+ constructor(message: string = "the request was cancelled") {
57
+ super(message);
58
+ this.name = "CancelledError";
59
+ }
60
+ }
61
+
62
+ /** Doubling backoff from one second, capped at thirty. */
63
+ export function backoffDelay(failureCount: number): number {
64
+ return Math.min(1000 * 2 ** (failureCount - 1), 30_000);
65
+ }
66
+
67
+ /**
68
+ * Run `attempt` until it succeeds, the policy gives up, or the signal fires.
69
+ *
70
+ * `onFailure` is called after every failed attempt with the running count, so
71
+ * the caller can put "retrying, attempt 2" on screen. It is not called for a
72
+ * cancellation: nobody is waiting for that answer, so there is nothing to
73
+ * report.
74
+ *
75
+ * The error a caller finally sees is the *last* one. An early failure that was
76
+ * retried past is not the reason the request failed; the one that exhausted
77
+ * the policy is.
78
+ */
79
+ export async function runWithRetry<T>(options: {|
80
+ readonly attempt: (failureCount: number) => Promise<T>,
81
+ readonly retry: RetryPolicy,
82
+ readonly retryDelay: RetryDelay,
83
+ readonly signal: AbortSignal,
84
+ readonly onFailure?: (failureCount: number, error: Error) => void,
85
+ |}): Promise<T> {
86
+ let failureCount = 0;
87
+ for (;;) {
88
+ if (options.signal.aborted) {
89
+ throw cancellation(options.signal);
90
+ }
91
+ try {
92
+ return await race(options.attempt, failureCount, options.signal);
93
+ } catch (thrown) {
94
+ // A request that failed *because* it was cancelled is not a failure this
95
+ // policy has an opinion about. Retrying it would restart work the caller
96
+ // just asked to stop.
97
+ if (options.signal.aborted) {
98
+ throw cancellation(options.signal);
99
+ }
100
+
101
+ const error = asError(thrown);
102
+ failureCount += 1;
103
+ options.onFailure?.(failureCount, error);
104
+ if (!shouldRetry(options.retry, failureCount, error)) {
105
+ throw error;
106
+ }
107
+ await sleep(delayFor(options.retryDelay, failureCount, error), options.signal);
108
+ }
109
+ }
110
+ }
111
+
112
+ /** Whatever was thrown, as an `Error`, because state has to hold one shape. */
113
+ export function asError(thrown: mixed): Error {
114
+ return thrown instanceof Error ? thrown : new Error(String(thrown));
115
+ }
116
+
117
+ function shouldRetry(policy: RetryPolicy, failureCount: number, error: Error): boolean {
118
+ if (typeof policy === "function") {
119
+ return policy(failureCount, error);
120
+ }
121
+ if (typeof policy === "number") {
122
+ return failureCount <= policy;
123
+ }
124
+ return policy;
125
+ }
126
+
127
+ function delayFor(delay: RetryDelay, failureCount: number, error: Error): number {
128
+ return typeof delay === "function" ? delay(failureCount, error) : delay;
129
+ }
130
+
131
+ /**
132
+ * One attempt, but no longer than the caller wants to wait for it.
133
+ *
134
+ * Aborting a signal does not, on its own, settle anything: `AbortController`
135
+ * is a request, and a function that ignores it goes on running. So the
136
+ * cancellation has to be a promise of its own, raced against the attempt —
137
+ * otherwise cancelling a query whose function does not take a signal hangs
138
+ * forever, and the reader is left watching a spinner for a request nobody is
139
+ * waiting for.
140
+ *
141
+ * The attempt is not stopped by this; nothing can stop it. It is abandoned,
142
+ * and the entry's fetch id makes sure a late answer cannot write.
143
+ */
144
+ async function race<T>(
145
+ attempt: (failureCount: number) => Promise<T>,
146
+ failureCount: number,
147
+ signal: AbortSignal,
148
+ ): Promise<T> {
149
+ let onAbort = () => {};
150
+ const cancelled: Promise<empty> = new Promise((_resolve, reject) => {
151
+ onAbort = () => reject(cancellation(signal));
152
+ signal.addEventListener("abort", onAbort, { once: true });
153
+ });
154
+ try {
155
+ return await Promise.race([attempt(failureCount), cancelled]);
156
+ } finally {
157
+ // So a signal aborted after this attempt succeeded cannot reject a promise
158
+ // nothing is listening to any more.
159
+ signal.removeEventListener("abort", onAbort);
160
+ }
161
+ }
162
+
163
+ function cancellation(signal: AbortSignal): Error {
164
+ const reason = signal.reason;
165
+ return reason instanceof Error ? reason : new CancelledError();
166
+ }
167
+
168
+ /**
169
+ * Wait, unless the signal fires first.
170
+ *
171
+ * The timer is unreferenced where the host supports it: a process whose only
172
+ * remaining work is a backoff nobody is waiting for should exit, and a test
173
+ * that finishes while a retry is pending should not hang for eight seconds.
174
+ */
175
+ function sleep(millis: number, signal: AbortSignal): Promise<void> {
176
+ return new Promise((resolve, reject) => {
177
+ if (signal.aborted) {
178
+ reject(cancellation(signal));
179
+ return;
180
+ }
181
+ const onAbort = () => {
182
+ clearTimeout(timer);
183
+ reject(cancellation(signal));
184
+ };
185
+ const timer = setTimeout(() => {
186
+ signal.removeEventListener("abort", onAbort);
187
+ resolve();
188
+ }, millis);
189
+ (timer as $FlowFixMe)?.unref?.();
190
+ signal.addEventListener("abort", onAbort, { once: true });
191
+ });
192
+ }
package/structural.js ADDED
@@ -0,0 +1,131 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/query/structural`: deciding that nothing changed.
4
+ //
5
+ // A server that is polled every five seconds answers with the same rows and a
6
+ // brand new object graph each time. `JSON.parse` cannot know that, so every
7
+ // response is a fresh identity, every observer re-renders, and every memoised
8
+ // child below it re-renders — twelve times a minute, forever, over data that
9
+ // has not moved. That is the cost this module removes.
10
+ //
11
+ // [`structuralShare`] takes the value the cache holds and the value that just
12
+ // arrived and returns a graph in which every subtree that is deeply equal is
13
+ // the *old* reference. A response with one changed row shares every other row,
14
+ // so a list re-renders one item instead of a thousand, and a response with no
15
+ // changes at all returns the previous object itself — `Object.is` says so,
16
+ // `useSyncExternalStore` bails out of the render, and `React.memo` below it
17
+ // never runs.
18
+ //
19
+ // # What the obvious version gets wrong
20
+ //
21
+ // The comparison people reach for first is `JSON.stringify(a) === JSON.stringify(b)`.
22
+ // It is wrong three ways, and each is a bug someone has shipped:
23
+ //
24
+ // * It answers a different question. It tells you the values are equal, and
25
+ // then you still hand React `next` — a new identity — because you have
26
+ // nothing else to hand it. Equality was never the goal; *sharing* was.
27
+ // * It cannot share subtrees. One changed field makes the whole tree
28
+ // unequal, so the other nine hundred rows get new identities too.
29
+ // * It depends on key order, so two objects that are equal in every way that
30
+ // matters compare unequal because a server reordered its fields.
31
+ //
32
+ // The walk below is `O(size of the response)` once, with no serialisation and
33
+ // no allocation for the parts it shares.
34
+ //
35
+ // # What it deliberately does not do
36
+ //
37
+ // Only arrays and plain objects are walked. A `Date`, a `Map`, a class
38
+ // instance, or anything with a prototype is compared by identity and replaced
39
+ // wholesale — because "deeply equal" for those is a question with no single
40
+ // right answer, and guessing at it silently keeps a stale object alive. A
41
+ // cache that holds such values still works; it just does not get sharing for
42
+ // free inside them.
43
+ //
44
+ // Nothing here freezes. Freezing the graph would make the sharing enforceable,
45
+ // and it would also freeze objects the application owns and did not consent to
46
+ // hand over. `@uniflowed/immer` is where a value becomes structurally
47
+ // immutable; this module only decides which references may be reused.
48
+
49
+ /**
50
+ * Whether `value` is an object literal rather than a class instance.
51
+ *
52
+ * `Object.prototype` or a null prototype, and nothing else: the walk may only
53
+ * rebuild things it can rebuild faithfully, and a class instance rebuilt as an
54
+ * object literal has silently lost its methods.
55
+ */
56
+ export function isPlainObject(value: mixed): boolean {
57
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
58
+ return false;
59
+ }
60
+ const prototype = Object.getPrototypeOf(value);
61
+ return prototype === Object.prototype || prototype === null;
62
+ }
63
+
64
+ /**
65
+ * `next`, with every deeply-equal subtree replaced by `previous`'s reference.
66
+ *
67
+ * Returns `previous` itself when the two are deeply equal, which is the whole
68
+ * point: an unchanged response must not produce a new identity, or nothing
69
+ * downstream can tell that it was unchanged.
70
+ *
71
+ * The shape of the *result* is always `next`'s shape. Sharing never resurrects
72
+ * a key that `next` dropped or a row it removed; it only reuses references for
73
+ * the parts that are still there and still equal.
74
+ */
75
+ export function structuralShare<T>(previous: mixed, next: T): T {
76
+ if (previous === next) {
77
+ return next;
78
+ }
79
+
80
+ const bothArrays = Array.isArray(previous) && Array.isArray(next);
81
+ if (!bothArrays && !(isPlainObject(previous) && isPlainObject(next))) {
82
+ return next;
83
+ }
84
+
85
+ const before = previous as $FlowFixMe;
86
+ const after = next as $FlowFixMe;
87
+ const keys = bothArrays ? null : Object.keys(after);
88
+ const size = bothArrays ? after.length : (keys as $FlowFixMe).length;
89
+ const beforeSize = bothArrays ? before.length : Object.keys(before).length;
90
+ const copy = bothArrays ? new Array(size) : ({}: $FlowFixMe);
91
+
92
+ // Counted rather than tracked with a flag, because "every child was shared"
93
+ // is only half the answer: a child can be shared while the parent has gained
94
+ // or lost a sibling, and then the parent is not the same object.
95
+ let shared = 0;
96
+ for (let index = 0; index < size; index += 1) {
97
+ const key = bothArrays ? index : (keys as $FlowFixMe)[index];
98
+ const child = structuralShare(before[key], after[key]);
99
+ if (child === before[key] && (bothArrays || Object.hasOwn(before, key))) {
100
+ shared += 1;
101
+ }
102
+ copy[key] = child;
103
+ }
104
+
105
+ return beforeSize === size && shared === size ? (before as T) : (copy as T);
106
+ }
107
+
108
+ /**
109
+ * Whether two records have the same keys and `Object.is`-identical values.
110
+ *
111
+ * This is how an observer decides whether the snapshot it just built is the
112
+ * one it already returned. It is a *shallow* comparison on purpose: every
113
+ * field it compares is either a primitive or a value that already went through
114
+ * [`structuralShare`], so identity is the correct question and a deep walk
115
+ * here would be paying twice for an answer we already have.
116
+ */
117
+ export function shallowEqual(left: { +[string]: mixed }, right: { +[string]: mixed }): boolean {
118
+ if (left === right) {
119
+ return true;
120
+ }
121
+ const keys = Object.keys(left);
122
+ if (keys.length !== Object.keys(right).length) {
123
+ return false;
124
+ }
125
+ for (const key of keys) {
126
+ if (!Object.is(left[key], right[key])) {
127
+ return false;
128
+ }
129
+ }
130
+ return true;
131
+ }