summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/utils/resource
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/src/utils/resource')
-rw-r--r--devtools/client/debugger/src/utils/resource/base-query.js174
-rw-r--r--devtools/client/debugger/src/utils/resource/compare.js44
-rw-r--r--devtools/client/debugger/src/utils/resource/core.js180
-rw-r--r--devtools/client/debugger/src/utils/resource/index.js74
-rw-r--r--devtools/client/debugger/src/utils/resource/memoize.js54
-rw-r--r--devtools/client/debugger/src/utils/resource/moz.build17
-rw-r--r--devtools/client/debugger/src/utils/resource/query-cache.js148
-rw-r--r--devtools/client/debugger/src/utils/resource/query.js245
-rw-r--r--devtools/client/debugger/src/utils/resource/selector.js56
-rw-r--r--devtools/client/debugger/src/utils/resource/tests/crud.spec.js266
-rw-r--r--devtools/client/debugger/src/utils/resource/tests/query.spec.js1079
11 files changed, 2337 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/utils/resource/base-query.js b/devtools/client/debugger/src/utils/resource/base-query.js
new file mode 100644
index 0000000000..fda49b0901
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/base-query.js
@@ -0,0 +1,174 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import {
+ getResourceValues,
+ getValidatedResource,
+ makeIdentity,
+ type ResourceBound,
+ type Id,
+ type ResourceState,
+ type ResourceValues,
+ type ResourceIdentity,
+} from "./core";
+import { arrayShallowEqual } from "./compare";
+
+export type ResourceQuery<R: ResourceBound, Args, Reduced> = (
+ ResourceState<R>,
+ Args
+) => Reduced;
+
+export type QueryFilter<R: ResourceBound, Args> = (
+ ResourceValues<R>,
+ Args
+) => Array<Id<R>>;
+
+export type QueryMap<R: ResourceBound, Args, Mapped> =
+ | QueryMapNoArgs<R, Mapped>
+ | QueryMapWithArgs<R, Args, Mapped>;
+export type QueryMapNoArgs<R: ResourceBound, Mapped> = {
+ +needsArgs?: false,
+ (R, ResourceIdentity): Mapped,
+};
+export type QueryMapWithArgs<R: ResourceBound, Args, Mapped> = {
+ +needsArgs: true,
+ (R, ResourceIdentity, Args): Mapped,
+};
+
+export type QueryReduce<R: ResourceBound, Args, Mapped, Reduced> = (
+ $ReadOnlyArray<Mapped>,
+ $ReadOnlyArray<Id<R>>,
+ Args
+) => Reduced;
+
+export type QueryContext<Args> = {
+ args: Args,
+ identMap: WeakMap<ResourceIdentity, ResourceIdentity>,
+};
+export type QueryResult<Mapped, Reduced> = {
+ mapped: Array<Mapped>,
+ reduced: Reduced,
+};
+export type QueryResultCompare<Reduced> = (Reduced, Reduced) => boolean;
+
+export type QueryCacheHandler<R: ResourceBound, Args, Mapped, Reduced> = (
+ ResourceState<R>,
+ QueryContext<Args>,
+ QueryResult<Mapped, Reduced> | null
+) => QueryResult<Mapped, Reduced>;
+
+export type QueryCache<R: ResourceBound, Args, Mapped, Reduced> = (
+ handler: QueryCacheHandler<R, Args, Mapped, Reduced>
+) => ResourceQuery<R, Args, Reduced>;
+
+export function makeMapWithArgs<R: ResourceBound, Args, Mapped>(
+ map: (R, ResourceIdentity, Args) => Mapped
+): QueryMapWithArgs<R, Args, Mapped> {
+ const wrapper = (resource, identity, args) => map(resource, identity, args);
+ wrapper.needsArgs = true;
+ return wrapper;
+}
+
+export function makeResourceQuery<R: ResourceBound, Args, Mapped, Reduced>({
+ cache,
+ filter,
+ map,
+ reduce,
+ resultCompare,
+}: {|
+ cache: QueryCache<R, Args, Mapped, Reduced>,
+ filter: QueryFilter<R, Args>,
+ map: QueryMap<R, Args, Mapped>,
+ reduce: QueryReduce<R, Args, Mapped, Reduced>,
+ resultCompare: QueryResultCompare<Reduced>,
+|}): ResourceQuery<R, Args, Reduced> {
+ const loadResource = makeResourceMapper(map);
+
+ return cache((state, context, existing) => {
+ const ids = filter(getResourceValues(state), context.args);
+ const mapped = ids.map(id => loadResource(state, id, context));
+
+ if (existing && arrayShallowEqual(existing.mapped, mapped)) {
+ // If the items are exactly the same as the existing ones, we return
+ // early to reuse the existing result.
+ return existing;
+ }
+
+ const reduced = reduce(mapped, ids, context.args);
+
+ if (existing && resultCompare(existing.reduced, reduced)) {
+ return existing;
+ }
+
+ return { mapped, reduced };
+ });
+}
+
+type ResourceLoader<R: ResourceBound, Args, Mapped> = (
+ ResourceState<R>,
+ Id<R>,
+ QueryContext<Args>
+) => Mapped;
+
+function makeResourceMapper<R: ResourceBound, Args, Mapped>(
+ map: QueryMap<R, Args, Mapped>
+): ResourceLoader<R, Args, Mapped> {
+ return map.needsArgs
+ ? makeResourceArgsMapper(map)
+ : makeResourceNoArgsMapper(map);
+}
+
+/**
+ * Resources loaded when things care about arguments need to be given a
+ * special ResourceIdentity object that correlates with both the resource
+ * _and_ the arguments being passed to the query. That means they need extra
+ * logic when loading those resources.
+ */
+function makeResourceArgsMapper<R: ResourceBound, Args, Mapped>(
+ map: QueryMapWithArgs<R, Args, Mapped>
+): ResourceLoader<R, Args, Mapped> {
+ const mapper = (value, identity, context) =>
+ map(value, getIdentity(context.identMap, identity), context.args);
+ return (state, id, context) => getCachedResource(state, id, context, mapper);
+}
+
+function makeResourceNoArgsMapper<R: ResourceBound, Args, Mapped>(
+ map: QueryMapNoArgs<R, Mapped>
+): ResourceLoader<R, Args, Mapped> {
+ const mapper = (value, identity, context) => map(value, identity);
+ return (state, id, context) => getCachedResource(state, id, context, mapper);
+}
+
+function getCachedResource<R: ResourceBound, Args, Mapped>(
+ state: ResourceState<R>,
+ id: Id<R>,
+ context: QueryContext<Args>,
+ map: (
+ value: R,
+ identity: ResourceIdentity,
+ context: QueryContext<Args>
+ ) => Mapped
+): Mapped {
+ const validatedState = getValidatedResource(state, id);
+ if (!validatedState) {
+ throw new Error(`Resource ${id} does not exist`);
+ }
+
+ return map(validatedState.values[id], validatedState.identity[id], context);
+}
+
+function getIdentity(
+ identMap: WeakMap<ResourceIdentity, ResourceIdentity>,
+ identity: ResourceIdentity
+) {
+ let ident = identMap.get(identity);
+ if (!ident) {
+ ident = makeIdentity();
+ identMap.set(identity, ident);
+ }
+
+ return ident;
+}
diff --git a/devtools/client/debugger/src/utils/resource/compare.js b/devtools/client/debugger/src/utils/resource/compare.js
new file mode 100644
index 0000000000..5320e1f067
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/compare.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+export function strictEqual(value: mixed, other: mixed): boolean {
+ return value === other;
+}
+
+export function shallowEqual(value: mixed, other: mixed): boolean {
+ return (
+ value === other ||
+ (Array.isArray(value) &&
+ Array.isArray(other) &&
+ arrayShallowEqual(value, other)) ||
+ (isObject(value) && isObject(other) && objectShallowEqual(value, other))
+ );
+}
+
+export function arrayShallowEqual(
+ value: $ReadOnlyArray<mixed>,
+ other: $ReadOnlyArray<mixed>
+): boolean {
+ return value.length === other.length && value.every((k, i) => k === other[i]);
+}
+
+function objectShallowEqual(
+ value: { [string]: mixed },
+ other: { [string]: mixed }
+): boolean {
+ const existingKeys = Object.keys(other);
+ const keys = Object.keys(value);
+
+ return (
+ keys.length === existingKeys.length &&
+ keys.every((k, i) => k === existingKeys[i]) &&
+ keys.every(k => value[k] === other[k])
+ );
+}
+
+function isObject(value: mixed): boolean %checks {
+ return typeof value === "object" && !!value;
+}
diff --git a/devtools/client/debugger/src/utils/resource/core.js b/devtools/client/debugger/src/utils/resource/core.js
new file mode 100644
index 0000000000..dad5337447
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/core.js
@@ -0,0 +1,180 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+export type Resource<R: ResourceBound> = $ReadOnly<$Exact<R>>;
+
+export type ResourceBound = {
+ +id: string,
+};
+export type Id<R: ResourceBound> = $ElementType<R, "id">;
+
+type ResourceSubset<R: ResourceBound> = $ReadOnly<{
+ +id: Id<R>,
+ ...$Shape<$Rest<R, { +id: Id<R> }>>,
+}>;
+
+export opaque type ResourceIdentity: { [string]: mixed } = {||};
+export type ResourceValues<R: ResourceBound> = { [Id<R>]: R };
+
+export opaque type ResourceState<R: ResourceBound> = {
+ identity: { [Id<R>]: ResourceIdentity },
+ values: ResourceValues<R>,
+};
+
+export function createInitial<R: ResourceBound>(): ResourceState<R> {
+ return {
+ identity: {},
+ values: {},
+ };
+}
+
+export function insertResources<R: ResourceBound>(
+ state: ResourceState<R>,
+ resources: $ReadOnlyArray<R>
+): ResourceState<R> {
+ if (resources.length === 0) {
+ return state;
+ }
+
+ state = {
+ identity: { ...state.identity },
+ values: { ...state.values },
+ };
+
+ for (const resource of resources) {
+ const { id } = resource;
+ if (state.identity[id]) {
+ throw new Error(
+ `Resource "${id}" already exists, cannot insert ${JSON.stringify(
+ resource
+ )}`
+ );
+ }
+ if (state.values[id]) {
+ throw new Error(
+ `Resource state corrupt: ${id} has value but no identity`
+ );
+ }
+
+ state.identity[resource.id] = makeIdentity();
+ state.values[resource.id] = resource;
+ }
+ return state;
+}
+
+export function removeResources<R: ResourceBound>(
+ state: ResourceState<R>,
+ resources: $ReadOnlyArray<ResourceSubset<R> | Id<R>>
+): ResourceState<R> {
+ if (resources.length === 0) {
+ return state;
+ }
+
+ state = {
+ identity: { ...state.identity },
+ values: { ...state.values },
+ };
+
+ for (let id of resources) {
+ if (typeof id !== "string") {
+ id = id.id;
+ }
+
+ if (!state.identity[id]) {
+ throw new Error(`Resource "${id}" does not exists, cannot remove`);
+ }
+ if (!state.values[id]) {
+ throw new Error(
+ `Resource state corrupt: ${id} has identity but no value`
+ );
+ }
+
+ delete state.identity[id];
+ delete state.values[id];
+ }
+ return state;
+}
+
+export function updateResources<R: ResourceBound>(
+ state: ResourceState<R>,
+ resources: $ReadOnlyArray<ResourceSubset<R>>
+): ResourceState<R> {
+ if (resources.length === 0) {
+ return state;
+ }
+
+ let didCopyValues = false;
+
+ for (const subset of resources) {
+ const { id } = subset;
+
+ if (!state.identity[id]) {
+ throw new Error(`Resource "${id}" does not exists, cannot update`);
+ }
+ if (!state.values[id]) {
+ throw new Error(
+ `Resource state corrupt: ${id} has identity but no value`
+ );
+ }
+
+ const existing = state.values[id];
+ const updated = {};
+
+ for (const field of Object.keys(subset)) {
+ if (field === "id") {
+ continue;
+ }
+
+ if (subset[field] !== existing[field]) {
+ updated[field] = subset[field];
+ }
+ }
+
+ if (Object.keys(updated).length > 0) {
+ if (!didCopyValues) {
+ didCopyValues = true;
+ state = {
+ identity: state.identity,
+ values: { ...state.values },
+ };
+ }
+
+ state.values[id] = { ...existing, ...updated };
+ }
+ }
+
+ return state;
+}
+
+export function makeIdentity(): ResourceIdentity {
+ return ({}: any);
+}
+
+export function getValidatedResource<R: ResourceBound>(
+ state: ResourceState<R>,
+ id: Id<R>
+):
+ | (ResourceState<R> & {
+ values: Array<R>,
+ identity: Array<string>,
+ })
+ | null {
+ const value = state.values[id];
+ const identity = state.identity[id];
+ if ((value && !identity) || (!value && identity)) {
+ throw new Error(
+ `Resource state corrupt: ${id} has mismatched value and identity`
+ );
+ }
+
+ return value ? (state: any) : null;
+}
+
+export function getResourceValues<R: ResourceBound>(
+ state: ResourceState<R>
+): ResourceValues<R> {
+ return state.values;
+}
diff --git a/devtools/client/debugger/src/utils/resource/index.js b/devtools/client/debugger/src/utils/resource/index.js
new file mode 100644
index 0000000000..fb8b917d2d
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/index.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+export {
+ createInitial,
+ insertResources,
+ removeResources,
+ updateResources,
+} from "./core";
+export type {
+ Id,
+ Resource,
+ ResourceBound,
+ // Disabled pending eslint-plugin-import bug #1345
+ // eslint-disable-next-line import/named
+ ResourceState,
+ // Disabled pending eslint-plugin-import bug #1345
+ // eslint-disable-next-line import/named
+ ResourceIdentity,
+ ResourceValues,
+} from "./core";
+
+export {
+ hasResource,
+ getResourceIds,
+ getResource,
+ getMappedResource,
+} from "./selector";
+export type { ResourceMap } from "./selector";
+
+export { makeResourceQuery, makeMapWithArgs } from "./base-query";
+export type {
+ ResourceQuery,
+ QueryMap,
+ QueryMapNoArgs,
+ QueryMapWithArgs,
+ QueryFilter,
+ QueryReduce,
+ QueryResultCompare,
+} from "./base-query";
+
+export {
+ filterAllIds,
+ makeWeakQuery,
+ makeShallowQuery,
+ makeStrictQuery,
+ makeIdQuery,
+ makeLoadQuery,
+ makeFilterQuery,
+ makeReduceQuery,
+ makeReduceAllQuery,
+} from "./query";
+export type {
+ WeakQuery,
+ ShallowQuery,
+ StrictQuery,
+ IdQuery,
+ LoadQuery,
+ FilterQuery,
+ ReduceQuery,
+ ReduceAllQuery,
+} from "./query";
+
+export {
+ queryCacheWeak,
+ queryCacheShallow,
+ queryCacheStrict,
+} from "./query-cache";
+export type { WeakArgsBound, ShallowArgsBound } from "./query-cache";
+
+export { memoizeResourceShallow } from "./memoize";
diff --git a/devtools/client/debugger/src/utils/resource/memoize.js b/devtools/client/debugger/src/utils/resource/memoize.js
new file mode 100644
index 0000000000..b00501b4df
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/memoize.js
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { ResourceBound } from "./core";
+import type { QueryMap } from "./base-query";
+import { shallowEqual } from "./compare";
+
+/**
+ * Wraps a 'mapper' function to create a shallow-equality memoized version
+ * of the mapped result. The returned function will return the same value
+ * even if the input object is different, as long as the identity is the same
+ * and the mapped result is shallow-equal to the most recent mapped value.
+ */
+export function memoizeResourceShallow<
+ R: ResourceBound,
+ Args,
+ Mapped,
+ T: QueryMap<R, Args, Mapped>
+>(map: T): T {
+ const cache = new WeakMap();
+
+ const fn = (input, identity, args) => {
+ let existingEntry = cache.get(identity);
+
+ if (!existingEntry || existingEntry.input !== input) {
+ const mapper = (map: any);
+ const output = mapper(input, identity, args);
+
+ if (existingEntry) {
+ // If the new output is shallow-equal to the old output, we reuse
+ // the previous object instead to preserve object equality.
+ const newOutput = shallowEqual(output, existingEntry.output)
+ ? existingEntry.output
+ : output;
+
+ existingEntry.output = newOutput;
+ existingEntry.input = input;
+ } else {
+ existingEntry = {
+ input,
+ output,
+ };
+ cache.set(identity, existingEntry);
+ }
+ }
+
+ return existingEntry.output;
+ };
+ fn.needsArgs = map.needsArgs;
+ return (fn: any);
+}
diff --git a/devtools/client/debugger/src/utils/resource/moz.build b/devtools/client/debugger/src/utils/resource/moz.build
new file mode 100644
index 0000000000..7fa8b2a810
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/moz.build
@@ -0,0 +1,17 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "base-query.js",
+ "compare.js",
+ "core.js",
+ "index.js",
+ "memoize.js",
+ "query-cache.js",
+ "query.js",
+ "selector.js",
+)
diff --git a/devtools/client/debugger/src/utils/resource/query-cache.js b/devtools/client/debugger/src/utils/resource/query-cache.js
new file mode 100644
index 0000000000..6fa8c922ad
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/query-cache.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { ResourceBound, ResourceState } from "./core";
+import type {
+ ResourceQuery,
+ QueryCacheHandler,
+ QueryContext,
+ QueryResult,
+} from "./base-query";
+import { strictEqual, shallowEqual } from "./compare";
+
+export type WeakArgsBound =
+ | $ReadOnly<{ [string]: mixed }>
+ | $ReadOnlyArray<mixed>;
+
+export type ShallowArgsBound =
+ | $ReadOnly<{ [string]: mixed }>
+ | $ReadOnlyArray<mixed>;
+
+/**
+ * A query 'cache' function that uses the identity of the arguments object to
+ * cache data for the query itself.
+ */
+export function queryCacheWeak<
+ R: ResourceBound,
+ Args: WeakArgsBound,
+ Mapped,
+ Reduced
+>(
+ handler: QueryCacheHandler<R, Args, Mapped, Reduced>
+): ResourceQuery<R, Args, Reduced> {
+ const cache = new WeakMap();
+ return makeCacheFunction({
+ handler,
+ // The WeakMap will only return entries for the exact object,
+ // so there is no need to compare at all.
+ compareArgs: () => true,
+ getEntry: args => cache.get(args) || null,
+ setEntry: (args, entry) => {
+ cache.set(args, entry);
+ },
+ });
+}
+
+/**
+ * A query 'cache' function that uses shallow comparison to cache the most
+ * recent calculated result based on the value of the argument.
+ */
+export function queryCacheShallow<
+ R: ResourceBound,
+ // We require args to be an object here because if you're using a primitive
+ // then you should be using queryCacheStrict instead.
+ Args: ShallowArgsBound,
+ Mapped,
+ Reduced
+>(
+ handler: QueryCacheHandler<R, Args, Mapped, Reduced>
+): ResourceQuery<R, Args, Reduced> {
+ let latestEntry = null;
+ return makeCacheFunction({
+ handler,
+ compareArgs: shallowEqual,
+ getEntry: () => latestEntry,
+ setEntry: (args, entry) => {
+ latestEntry = entry;
+ },
+ });
+}
+
+/**
+ * A query 'cache' function that uses strict comparison to cache the most
+ * recent calculated result based on the value of the argument.
+ */
+export function queryCacheStrict<R: ResourceBound, Args, Mapped, Reduced>(
+ handler: QueryCacheHandler<R, Args, Mapped, Reduced>
+): ResourceQuery<R, Args, Reduced> {
+ let latestEntry = null;
+ return makeCacheFunction({
+ handler,
+ compareArgs: strictEqual,
+ getEntry: () => latestEntry,
+ setEntry: (args, entry) => {
+ latestEntry = entry;
+ },
+ });
+}
+
+type CacheEntry<R: ResourceBound, Args, Mapped, Reduced> = {
+ context: QueryContext<Args>,
+ state: ResourceState<R>,
+ result: QueryResult<Mapped, Reduced>,
+};
+
+type CacheFunctionInfo<R: ResourceBound, Args, Mapped, Reduced> = {|
+ // The handler to call when the args or the state are different from
+ // those in the entry for the arguments.
+ handler: QueryCacheHandler<R, Args, Mapped, Reduced>,
+
+ // Compare two sets of arguments to decide whether or not they should be
+ // treated as the same set of arguments from the standpoint of caching.
+ compareArgs: (a: Args, b: Args) => boolean,
+
+ getEntry: (args: Args) => CacheEntry<R, Args, Mapped, Reduced> | null,
+ setEntry: (args: Args, entry: CacheEntry<R, Args, Mapped, Reduced>) => void,
+|};
+function makeCacheFunction<R: ResourceBound, Args, Mapped, Reduced>(
+ info: CacheFunctionInfo<R, Args, Mapped, Reduced>
+): ResourceQuery<R, Args, Reduced> {
+ const { handler, compareArgs, getEntry, setEntry } = info;
+
+ return (state, args: Args) => {
+ let entry = getEntry(args);
+
+ const sameArgs = !!entry && compareArgs(entry.context.args, args);
+ const sameState = !!entry && entry.state === state;
+
+ if (!entry || !sameArgs || !sameState) {
+ const context =
+ !entry || !sameArgs
+ ? {
+ args,
+ identMap: new WeakMap(),
+ }
+ : entry.context;
+
+ const result = handler(state, context, entry ? entry.result : null);
+
+ if (entry) {
+ entry.context = context;
+ entry.state = state;
+ entry.result = result;
+ } else {
+ entry = {
+ context,
+ state,
+ result,
+ };
+ setEntry(args, entry);
+ }
+ }
+
+ return entry.result.reduced;
+ };
+}
diff --git a/devtools/client/debugger/src/utils/resource/query.js b/devtools/client/debugger/src/utils/resource/query.js
new file mode 100644
index 0000000000..b2edd0f5e5
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/query.js
@@ -0,0 +1,245 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import type { ResourceBound, Id, ResourceValues } from "./core";
+
+import {
+ makeResourceQuery,
+ type ResourceQuery,
+ type QueryFilter,
+ type QueryMap,
+ type QueryReduce,
+} from "./base-query";
+
+import {
+ queryCacheWeak,
+ queryCacheShallow,
+ queryCacheStrict,
+ type WeakArgsBound,
+ type ShallowArgsBound,
+} from "./query-cache";
+
+import { memoizeResourceShallow } from "./memoize";
+import { shallowEqual } from "./compare";
+
+export function filterAllIds<R: ResourceBound>(
+ values: ResourceValues<R>
+): Array<Id<R>> {
+ return Object.keys(values);
+}
+
+/**
+ * Create a query function to take a list of IDs and map each Reduceding
+ * resource object into a mapped form.
+ */
+export type WeakQuery<
+ R: ResourceBound,
+ Args: WeakArgsBound,
+ Reduced
+> = ResourceQuery<R, Args, Reduced>;
+export function makeWeakQuery<
+ R: ResourceBound,
+ Args: WeakArgsBound,
+ Mapped,
+ Reduced
+>({
+ filter,
+ map,
+ reduce,
+}: {|
+ filter: QueryFilter<R, Args>,
+ map: QueryMap<R, Args, Mapped>,
+ reduce: QueryReduce<R, Args, Mapped, Reduced>,
+|}): WeakQuery<R, Args, Reduced> {
+ return makeResourceQuery({
+ cache: queryCacheWeak,
+ filter,
+ map: memoizeResourceShallow(map),
+ reduce,
+ resultCompare: shallowEqual,
+ });
+}
+
+/**
+ * Create a query function to take a list of IDs and map each Reduceding
+ * resource object into a mapped form.
+ */
+export type ShallowQuery<R: ResourceBound, Args, Reduced> = ResourceQuery<
+ R,
+ Args,
+ Reduced
+>;
+export function makeShallowQuery<
+ R: ResourceBound,
+ Args: ShallowArgsBound,
+ Mapped,
+ Reduced
+>({
+ filter,
+ map,
+ reduce,
+}: {|
+ filter: QueryFilter<R, Args>,
+ map: QueryMap<R, Args, Mapped>,
+ reduce: QueryReduce<R, Args, Mapped, Reduced>,
+|}): ShallowQuery<R, Args, Reduced> {
+ return makeResourceQuery({
+ cache: queryCacheShallow,
+ filter,
+ map: memoizeResourceShallow(map),
+ reduce,
+ resultCompare: shallowEqual,
+ });
+}
+
+/**
+ * Create a query function to take a list of IDs and map each Reduceding
+ * resource object into a mapped form.
+ */
+export type StrictQuery<R: ResourceBound, Args, Reduced> = ResourceQuery<
+ R,
+ Args,
+ Reduced
+>;
+export function makeStrictQuery<R: ResourceBound, Args, Mapped, Reduced>({
+ filter,
+ map,
+ reduce,
+}: {|
+ filter: QueryFilter<R, Args>,
+ map: QueryMap<R, Args, Mapped>,
+ reduce: QueryReduce<R, Args, Mapped, Reduced>,
+|}): StrictQuery<R, Args, Reduced> {
+ return makeResourceQuery({
+ cache: queryCacheStrict,
+ filter,
+ map: memoizeResourceShallow(map),
+ reduce,
+ resultCompare: shallowEqual,
+ });
+}
+
+/**
+ * Create a query function to take a list of IDs and map each Reduceding
+ * resource object into a mapped form.
+ */
+export type IdQuery<R: ResourceBound, Mapped> = WeakQuery<
+ R,
+ Array<Id<R>>,
+ Array<Mapped>
+>;
+export function makeIdQuery<R: ResourceBound, Mapped>(
+ map: QueryMap<R, void, Mapped>
+): IdQuery<R, Mapped> {
+ return makeWeakQuery({
+ filter: (state, ids) => ids,
+ map: (r, identity) => map(r, identity),
+ reduce: items => items.slice(),
+ });
+}
+
+/**
+ * Create a query function to take a list of IDs and map each Reduceding
+ * resource object into a mapped form.
+ */
+export type LoadQuery<R: ResourceBound, Mapped> = WeakQuery<
+ R,
+ Array<Id<R>>,
+ $ReadOnly<{ [Id<R>]: Mapped }>
+>;
+export function makeLoadQuery<R: ResourceBound, Mapped>(
+ map: QueryMap<R, void, Mapped>
+): LoadQuery<R, Mapped> {
+ return makeWeakQuery({
+ filter: (state, ids) => ids,
+ map: (r, identity) => map(r, identity),
+ reduce: reduceMappedArrayToObject,
+ });
+}
+
+/**
+ * Create a query function that accepts an argument and can filter the
+ * resource items to a subset before mapping each reduced resource.
+ */
+export type FilterQuery<
+ R: ResourceBound,
+ Args: WeakArgsBound,
+ Mapped
+> = WeakQuery<R, Args, $ReadOnly<{ [Id<R>]: Mapped }>>;
+export function makeFilterQuery<R: ResourceBound, Args: WeakArgsBound, Mapped>(
+ filter: (R, Args) => boolean,
+ map: QueryMap<R, Args, Mapped>
+): FilterQuery<R, Args, Mapped> {
+ return makeWeakQuery({
+ filter: (values, args) => {
+ const ids = [];
+ for (const id of Object.keys(values)) {
+ if (filter(values[id], args)) {
+ ids.push(id);
+ }
+ }
+ return ids;
+ },
+ map,
+ reduce: reduceMappedArrayToObject,
+ });
+}
+
+/**
+ * Create a query function that accepts an argument and can filter the
+ * resource items to a subset before mapping each resulting resource.
+ */
+export type ReduceQuery<
+ R: ResourceBound,
+ Args: ShallowArgsBound,
+ Reduced
+> = ShallowQuery<R, Args, Reduced>;
+export function makeReduceQuery<
+ R: ResourceBound,
+ Args: ShallowArgsBound,
+ Mapped,
+ Reduced
+>(
+ map: QueryMap<R, Args, Mapped>,
+ reduce: QueryReduce<R, Args, Mapped, Reduced>
+): ReduceQuery<R, Args, Reduced> {
+ return makeShallowQuery({
+ filter: filterAllIds,
+ map,
+ reduce,
+ });
+}
+
+/**
+ * Create a query function that accepts an argument and can filter the
+ * resource items to a subset before mapping each resulting resource.
+ */
+export type ReduceAllQuery<R: ResourceBound, Reduced> = ShallowQuery<
+ R,
+ void,
+ Reduced
+>;
+export function makeReduceAllQuery<R: ResourceBound, Mapped, Reduced>(
+ map: QueryMap<R, void, Mapped>,
+ reduce: QueryReduce<R, void, Mapped, Reduced>
+): ReduceAllQuery<R, Reduced> {
+ return makeStrictQuery({
+ filter: filterAllIds,
+ map,
+ reduce,
+ });
+}
+
+function reduceMappedArrayToObject<Args, ID, Mapped>(
+ items: $ReadOnlyArray<Mapped>,
+ ids: $ReadOnlyArray<ID>,
+ args: Args
+): { [ID]: Mapped } {
+ return items.reduce((acc: { [ID]: Mapped }, item, i) => {
+ acc[ids[i]] = item;
+ return acc;
+ }, {});
+}
diff --git a/devtools/client/debugger/src/utils/resource/selector.js b/devtools/client/debugger/src/utils/resource/selector.js
new file mode 100644
index 0000000000..df5fae4422
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/selector.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import {
+ getValidatedResource,
+ getResourceValues,
+ type ResourceState,
+ type Id,
+ type ResourceBound,
+ type ResourceIdentity,
+} from "./core";
+
+export type ResourceMap<R: ResourceBound, Mapped> = (
+ R,
+ ResourceIdentity
+) => Mapped;
+
+export function hasResource<R: ResourceBound>(
+ state: ResourceState<R>,
+ id: Id<R>
+): boolean %checks {
+ return !!getValidatedResource(state, id);
+}
+
+export function getResourceIds<R: ResourceBound>(
+ state: ResourceState<R>
+): Array<Id<R>> {
+ return Object.keys(getResourceValues(state));
+}
+
+export function getResource<R: ResourceBound>(
+ state: ResourceState<R>,
+ id: Id<R>
+): R {
+ const validatedState = getValidatedResource(state, id);
+ if (!validatedState) {
+ throw new Error(`Resource ${id} does not exist`);
+ }
+ return validatedState.values[id];
+}
+
+export function getMappedResource<R: ResourceBound, Mapped>(
+ state: ResourceState<R>,
+ id: Id<R>,
+ map: ResourceMap<R, Mapped>
+): Mapped {
+ const validatedState = getValidatedResource(state, id);
+ if (!validatedState) {
+ throw new Error(`Resource ${id} does not exist`);
+ }
+
+ return map(validatedState.values[id], validatedState.identity[id]);
+}
diff --git a/devtools/client/debugger/src/utils/resource/tests/crud.spec.js b/devtools/client/debugger/src/utils/resource/tests/crud.spec.js
new file mode 100644
index 0000000000..474323e6e5
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/tests/crud.spec.js
@@ -0,0 +1,266 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+declare var describe: (name: string, func: () => void) => void;
+declare var it: (desc: string, func: () => void) => void;
+declare var expect: (value: any) => any;
+
+import {
+ createInitial,
+ insertResources,
+ removeResources,
+ updateResources,
+ hasResource,
+ getResourceIds,
+ getResource,
+ getMappedResource,
+ type Resource,
+ type ResourceIdentity,
+} from "..";
+
+type TestResource = Resource<{
+ id: string,
+ name: string,
+ data: number,
+ obj: {},
+}>;
+
+const makeResource = (id: string): TestResource => ({
+ id,
+ name: `name-${id}`,
+ data: 42,
+ obj: {},
+});
+
+const mapName = (resource: TestResource) => resource.name;
+const mapWithIdent = (resource: TestResource, identity: ResourceIdentity) => ({
+ resource,
+ identity,
+ obj: {},
+});
+
+const clone = <T>(v: T): T => (JSON.parse((JSON.stringify(v): any)): any);
+
+describe("resource CRUD operations", () => {
+ let r1, r2, r3;
+ let originalInitial;
+ let initialState;
+ beforeEach(() => {
+ r1 = makeResource("id-1");
+ r2 = makeResource("id-2");
+ r3 = makeResource("id-3");
+
+ initialState = createInitial();
+ originalInitial = clone(initialState);
+ });
+
+ describe("insert", () => {
+ it("should work", () => {
+ const state = insertResources(initialState, [r1, r2, r3]);
+
+ expect(initialState).toEqual(originalInitial);
+ expect(getResource(state, r1.id)).toBe(r1);
+ expect(getResource(state, r2.id)).toBe(r2);
+ expect(getResource(state, r3.id)).toBe(r3);
+ });
+
+ it("should throw on duplicate", () => {
+ const state = insertResources(initialState, [r1]);
+ expect(() => {
+ insertResources(state, [r1]);
+ }).toThrow(/already exists/);
+
+ expect(() => {
+ insertResources(state, [r2, r2]);
+ }).toThrow(/already exists/);
+ });
+
+ it("should be a no-op when given no resources", () => {
+ const state = insertResources(initialState, []);
+
+ expect(state).toBe(initialState);
+ });
+ });
+
+ describe("read", () => {
+ beforeEach(() => {
+ initialState = insertResources(initialState, [r1, r2, r3]);
+ });
+
+ it("should allow reading all IDs", () => {
+ expect(getResourceIds(initialState)).toEqual([r1.id, r2.id, r3.id]);
+ });
+
+ it("should allow checking for existing of an ID", () => {
+ expect(hasResource(initialState, r1.id)).toBe(true);
+ expect(hasResource(initialState, r2.id)).toBe(true);
+ expect(hasResource(initialState, r3.id)).toBe(true);
+ expect(hasResource(initialState, "unknownId")).toBe(false);
+ });
+
+ it("should allow reading an item", () => {
+ expect(getResource(initialState, r1.id)).toBe(r1);
+ expect(getResource(initialState, r2.id)).toBe(r2);
+ expect(getResource(initialState, r3.id)).toBe(r3);
+
+ expect(() => {
+ getResource(initialState, "unknownId");
+ }).toThrow(/does not exist/);
+ });
+
+ it("should allow reading and mapping an item", () => {
+ expect(getMappedResource(initialState, r1.id, mapName)).toBe(r1.name);
+ expect(getMappedResource(initialState, r2.id, mapName)).toBe(r2.name);
+ expect(getMappedResource(initialState, r3.id, mapName)).toBe(r3.name);
+
+ expect(() => {
+ getMappedResource(initialState, "unknownId", mapName);
+ }).toThrow(/does not exist/);
+ });
+
+ it("should allow reading and mapping an item with identity", () => {
+ const r1Ident = getMappedResource(initialState, r1.id, mapWithIdent);
+ const r2Ident = getMappedResource(initialState, r2.id, mapWithIdent);
+
+ const state = updateResources(initialState, [{ ...r1, obj: {} }]);
+
+ const r1NewIdent = getMappedResource(state, r1.id, mapWithIdent);
+ const r2NewIdent = getMappedResource(state, r2.id, mapWithIdent);
+
+ // The update changed the resource object, but not the identity.
+ expect(r1NewIdent.resource).not.toBe(r1Ident.resource);
+ expect(r1NewIdent.resource).toEqual(r1Ident.resource);
+ expect(r1NewIdent.identity).toBe(r1Ident.identity);
+
+ // The update did not change the r2 resource.
+ expect(r2NewIdent.resource).toBe(r2Ident.resource);
+ expect(r2NewIdent.identity).toBe(r2Ident.identity);
+ });
+ });
+
+ describe("update", () => {
+ beforeEach(() => {
+ initialState = insertResources(initialState, [r1, r2, r3]);
+ originalInitial = clone(initialState);
+ });
+
+ it("should work", () => {
+ const r1Ident = getMappedResource(initialState, r1.id, mapWithIdent);
+ const r2Ident = getMappedResource(initialState, r2.id, mapWithIdent);
+ const r3Ident = getMappedResource(initialState, r3.id, mapWithIdent);
+
+ const state = updateResources(initialState, [
+ {
+ id: r1.id,
+ data: 21,
+ },
+ {
+ id: r2.id,
+ name: "newName",
+ },
+ ]);
+
+ expect(initialState).toEqual(originalInitial);
+ expect(getResource(state, r1.id)).toEqual({ ...r1, data: 21 });
+ expect(getResource(state, r2.id)).toEqual({ ...r2, name: "newName" });
+ expect(getResource(state, r3.id)).toBe(r3);
+
+ const r1NewIdent = getMappedResource(state, r1.id, mapWithIdent);
+ const r2NewIdent = getMappedResource(state, r2.id, mapWithIdent);
+ const r3NewIdent = getMappedResource(state, r3.id, mapWithIdent);
+
+ // The update changed the resource object, but not the identity.
+ expect(r1NewIdent.resource).not.toBe(r1Ident.resource);
+ expect(r1NewIdent.resource).toEqual({
+ ...r1Ident.resource,
+ data: 21,
+ });
+ expect(r1NewIdent.identity).toBe(r1Ident.identity);
+
+ // The update changed the resource object, but not the identity.
+ expect(r2NewIdent.resource).toEqual({
+ ...r2Ident.resource,
+ name: "newName",
+ });
+ expect(r2NewIdent.identity).toBe(r2Ident.identity);
+
+ // The update did not change the r3 resource.
+ expect(r3NewIdent.resource).toBe(r3Ident.resource);
+ expect(r3NewIdent.identity).toBe(r3Ident.identity);
+ });
+
+ it("should throw if not found", () => {
+ expect(() => {
+ updateResources(initialState, [
+ {
+ ...r1,
+ id: "unknownId",
+ },
+ ]);
+ }).toThrow(/does not exists/);
+ });
+
+ it("should be a no-op when new fields are strict-equal", () => {
+ const state = updateResources(initialState, [r1]);
+ expect(state).toBe(initialState);
+ });
+
+ it("should be a no-op when given no resources", () => {
+ const state = updateResources(initialState, []);
+ expect(state).toBe(initialState);
+ });
+ });
+
+ describe("delete", () => {
+ beforeEach(() => {
+ initialState = insertResources(initialState, [r1, r2, r3]);
+ originalInitial = clone(initialState);
+ });
+
+ it("should work with objects", () => {
+ const state = removeResources(initialState, [r1]);
+
+ expect(initialState).toEqual(originalInitial);
+ expect(hasResource(state, r1.id)).toBe(false);
+ expect(hasResource(state, r2.id)).toBe(true);
+ expect(hasResource(state, r3.id)).toBe(true);
+ });
+
+ it("should work with object subsets", () => {
+ const state = removeResources(initialState, [{ id: r1.id }]);
+
+ expect(initialState).toEqual(originalInitial);
+ expect(hasResource(state, r1.id)).toBe(false);
+ expect(hasResource(state, r2.id)).toBe(true);
+ expect(hasResource(state, r3.id)).toBe(true);
+ });
+
+ it("should work with ids", () => {
+ const state = removeResources(initialState, [r1.id]);
+
+ expect(initialState).toEqual(originalInitial);
+ expect(hasResource(state, r1.id)).toBe(false);
+ expect(hasResource(state, r2.id)).toBe(true);
+ expect(hasResource(state, r3.id)).toBe(true);
+ });
+
+ it("should throw if not found", () => {
+ expect(() => {
+ removeResources(initialState, [makeResource("unknownId")]);
+ }).toThrow(/does not exist/);
+ expect(() => {
+ removeResources(initialState, [{ id: "unknownId" }]);
+ }).toThrow(/does not exist/);
+ expect(() => {
+ removeResources(initialState, ["unknownId"]);
+ }).toThrow(/does not exist/);
+ });
+
+ it("should be a no-op when given no resources", () => {
+ const state = removeResources(initialState, []);
+ expect(state).toBe(initialState);
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/utils/resource/tests/query.spec.js b/devtools/client/debugger/src/utils/resource/tests/query.spec.js
new file mode 100644
index 0000000000..adca73f750
--- /dev/null
+++ b/devtools/client/debugger/src/utils/resource/tests/query.spec.js
@@ -0,0 +1,1079 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @flow
+
+import {
+ createInitial,
+ insertResources,
+ updateResources,
+ makeMapWithArgs,
+ makeWeakQuery,
+ makeShallowQuery,
+ makeStrictQuery,
+ type Id,
+ type Resource,
+ type ResourceValues,
+ type ResourceIdentity,
+ type QueryMapNoArgs,
+ type QueryMapWithArgs,
+ type QueryFilter,
+ type QueryReduce,
+} from "..";
+
+type TestResource = Resource<{
+ id: string,
+ name: string,
+ data: number,
+ obj: {},
+}>;
+
+const makeResource = (id: string): TestResource => ({
+ id,
+ name: `name-${id}`,
+ data: 42,
+ obj: {},
+});
+
+// Jest's mock type just wouldn't cooperate below, so this is a custom version
+// that does what I need.
+type MockedFn<InputFn, OutputFn> = OutputFn & {
+ mock: {
+ calls: Array<any>,
+ },
+ mockImplementation(fn: InputFn): void,
+};
+
+// We need to pass the 'needsArgs' prop through to the query fn so we use
+// this utility to do that and at the same time preserve typechecking.
+const mockFn = (f: any) => Object.assign((jest.fn(f): any), f);
+
+const mockFilter = <Args, F: QueryFilter<TestResource, Args>>(
+ callback: F
+): MockedFn<F, F> => mockFn(callback);
+const mockMapNoArgs = <Mapped, F: (TestResource, ResourceIdentity) => Mapped>(
+ callback: F
+): MockedFn<F, QueryMapNoArgs<TestResource, Mapped>> => mockFn(callback);
+const mockMapWithArgs = <
+ Args,
+ Mapped,
+ F: (TestResource, ResourceIdentity, Args) => Mapped
+>(
+ callback: F
+): MockedFn<F, QueryMapWithArgs<TestResource, Args, Mapped>> =>
+ mockFn(makeMapWithArgs(callback));
+const mockReduce = <
+ Args,
+ Mapped,
+ Reduced,
+ F: QueryReduce<TestResource, Args, Mapped, Reduced>
+>(
+ callback: F
+): MockedFn<F, F> => mockFn(callback);
+
+type TestArgs = Array<Id<TestResource>>;
+type TestReduced = { [Id<TestResource>]: TestResource };
+
+describe("resource query operations", () => {
+ let r1, r2, r3;
+ let initialState;
+ let mapNoArgs, mapWithArgs, reduce;
+
+ beforeEach(() => {
+ r1 = makeResource("id-1");
+ r2 = makeResource("id-2");
+ r3 = makeResource("id-3");
+
+ initialState = createInitial();
+
+ initialState = insertResources(initialState, [r1, r2, r3]);
+
+ mapNoArgs = mockMapNoArgs(
+ (resource: TestResource, ident: ResourceIdentity): TestResource =>
+ resource
+ );
+ mapWithArgs = mockMapWithArgs(
+ (
+ resource: TestResource,
+ ident: ResourceIdentity,
+ args: mixed
+ ): TestResource => resource
+ );
+ reduce = mockReduce(
+ (
+ mapped: $ReadOnlyArray<TestResource>,
+ ids: $ReadOnlyArray<string>,
+ args: mixed
+ ): TestReduced => {
+ return mapped.reduce((acc, item, i) => {
+ acc[ids[i]] = item;
+ return acc;
+ }, {});
+ }
+ );
+ });
+
+ describe("weak cache", () => {
+ let filter;
+
+ beforeEach(() => {
+ filter = mockFilter(
+ // eslint-disable-next-line max-nested-callbacks
+ (values: ResourceValues<TestResource>, args: TestArgs): TestArgs => args
+ );
+ });
+
+ describe("no args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeWeakQuery({ filter, map: mapNoArgs, reduce });
+ });
+
+ it("should return same with same state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 1", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return diff with updated id state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the mapper returns a value with name, changing a name will
+ // invalidate the cache.
+ const state = updateResources(initialState, [
+ {
+ id: r1.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "newName" },
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const firstArgs = [r1.id, r2.id];
+ const secondArgs = [r1.id, r2.id];
+ const result1 = query(initialState, firstArgs);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, secondArgs);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(2);
+
+ // Same result from first query still available.
+ const result3 = query(initialState, firstArgs);
+ expect(result3).not.toBe(result2);
+ expect(result3).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(2);
+
+ // Same result from second query still available.
+ const result4 = query(initialState, secondArgs);
+ expect(result4).toBe(result2);
+ expect(result4).not.toBe(result3);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+ });
+
+ describe("with args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeWeakQuery({ filter, map: mapWithArgs, reduce });
+ });
+
+ it("should return same with same state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 1", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return diff with updated id state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the mapper returns a value with name, changing a name will
+ // invalidate the cache.
+ const state = updateResources(initialState, [
+ {
+ id: r1.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "newName" },
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const firstArgs = [r1.id, r2.id];
+ const secondArgs = [r1.id, r2.id];
+
+ const result1 = query(initialState, firstArgs);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, secondArgs);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(4);
+ expect(reduce.mock.calls).toHaveLength(2);
+
+ // Same result from first query still available.
+ const result3 = query(initialState, firstArgs);
+ expect(result3).not.toBe(result2);
+ expect(result3).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(4);
+ expect(reduce.mock.calls).toHaveLength(2);
+
+ // Same result from second query still available.
+ const result4 = query(initialState, secondArgs);
+ expect(result4).toBe(result2);
+ expect(result4).not.toBe(result3);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(4);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+ });
+ });
+
+ describe("shallow cache", () => {
+ let filter;
+
+ beforeEach(() => {
+ filter = mockFilter(
+ // eslint-disable-next-line max-nested-callbacks
+ (
+ values: ResourceValues<TestResource>,
+ { ids }: { ids: TestArgs }
+ ): TestArgs => ids
+ );
+ });
+
+ describe("no args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeShallowQuery({ filter, map: mapNoArgs, reduce });
+ });
+
+ it("should return last with same state and same args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return last with updated other state and same args 1", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return last with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return new with updated id state and same args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r2.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: { ...r2, name: "newName" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids, flag: true });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, { ids, flag: false });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result3 = query(initialState, { ids, flag: true });
+ expect(result3).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(3);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result4 = query(initialState, { ids, flag: false });
+ expect(result4).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(4);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+ });
+
+ describe("with args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeShallowQuery({ filter, map: mapWithArgs, reduce });
+ });
+
+ it("should return last with same state and same args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return last with updated other state and same args 1", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return last with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return new with updated id state and same args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r2.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, { ids });
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: { ...r2, name: "newName" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const ids = [r1.id, r2.id];
+ const result1 = query(initialState, { ids, flag: true });
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, { ids, flag: false });
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(4);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result3 = query(initialState, { ids, flag: true });
+ expect(result3).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(3);
+ expect(mapWithArgs.mock.calls).toHaveLength(6);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result4 = query(initialState, { ids, flag: false });
+ expect(result4).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(4);
+ expect(mapWithArgs.mock.calls).toHaveLength(8);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+ });
+ });
+
+ describe("strict cache", () => {
+ let filter;
+
+ beforeEach(() => {
+ filter = mockFilter(
+ // eslint-disable-next-line max-nested-callbacks
+ (values: ResourceValues<TestResource>, ids: Array<string>): TestArgs =>
+ ids
+ );
+ });
+
+ describe("no args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeStrictQuery({ filter, map: mapNoArgs, reduce });
+ });
+
+ it("should return same with same state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 1", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return diff with updated id state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the mapper returns a value with name, changing a name will
+ // invalidate the cache.
+ const state = updateResources(initialState, [
+ {
+ id: r1.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "newName" },
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const firstArgs = [r1.id, r2.id];
+ const secondArgs = [r1.id, r2.id];
+ const result1 = query(initialState, firstArgs);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, secondArgs);
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result3 = query(initialState, firstArgs);
+ expect(result3).toBe(result2);
+ expect(filter.mock.calls).toHaveLength(3);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result4 = query(initialState, secondArgs);
+ expect(result4).toBe(result3);
+ expect(filter.mock.calls).toHaveLength(4);
+ expect(mapNoArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+ });
+
+ describe("with args", () => {
+ let query;
+
+ beforeEach(() => {
+ query = makeStrictQuery({ filter, map: mapWithArgs, reduce });
+ });
+
+ it("should return same with same state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 1", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Updating r2 does not affect cached result that only cares about r2.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ obj: {},
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return same with updated other state and same args 2", () => {
+ // eslint-disable-next-line max-nested-callbacks
+ mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" }));
+
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the map function ignores the name value, updating it should
+ // not reset the cached for this query.
+ const state = updateResources(initialState, [
+ {
+ id: r3.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "" },
+ [r2.id]: { ...r2, name: "" },
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+
+ it("should return diff with updated id state and same args", () => {
+ const args = [r1.id, r2.id];
+ const result1 = query(initialState, args);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ // Since the mapper returns a value with name, changing a name will
+ // invalidate the cache.
+ const state = updateResources(initialState, [
+ {
+ id: r1.id,
+ name: "newName",
+ },
+ ]);
+
+ const result2 = query(state, args);
+ expect(result2).not.toBe(result1);
+ expect(result2).toEqual({
+ [r1.id]: { ...r1, name: "newName" },
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(3);
+ expect(reduce.mock.calls).toHaveLength(2);
+ });
+
+ it("should return diff with same state and diff args", () => {
+ const firstArgs = [r1.id, r2.id];
+ const secondArgs = [r1.id, r2.id];
+
+ const result1 = query(initialState, firstArgs);
+ expect(result1).toEqual({
+ [r1.id]: r1,
+ [r2.id]: r2,
+ });
+ expect(filter.mock.calls).toHaveLength(1);
+ expect(mapWithArgs.mock.calls).toHaveLength(2);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result2 = query(initialState, secondArgs);
+ expect(result2).toBe(result1);
+ expect(filter.mock.calls).toHaveLength(2);
+ expect(mapWithArgs.mock.calls).toHaveLength(4);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result3 = query(initialState, firstArgs);
+ expect(result3).toBe(result2);
+ expect(filter.mock.calls).toHaveLength(3);
+ expect(mapWithArgs.mock.calls).toHaveLength(6);
+ expect(reduce.mock.calls).toHaveLength(1);
+
+ const result4 = query(initialState, secondArgs);
+ expect(result4).toBe(result3);
+ expect(filter.mock.calls).toHaveLength(4);
+ expect(mapWithArgs.mock.calls).toHaveLength(8);
+ expect(reduce.mock.calls).toHaveLength(1);
+ });
+ });
+ });
+});