Last active
March 29, 2025 22:54
-
-
Save webstrand/e6b1fa4a375734dc6e80d290691dda68 to your computer and use it in GitHub Desktop.
Remapping utilities for reactive signals
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {type JSX, createMemo, createRoot, onCleanup} from "solid-js"; | |
import {Public, type RemapOperations, enshroudOperations, remapArray} from "./remap.mts"; | |
export interface RemapOperationsConcrete<key, value, reference> extends RemapOperations<key, value, reference> { | |
create: (ref: reference, key: key) => value; | |
intoKey: (ref: reference) => key; | |
} | |
export function RemapKeyed<key, val, ref>(props: { | |
refs: ArrayLike<ref>; | |
operations: RemapOperationsConcrete<key, val, ref>; | |
children: (val: val, key: key) => JSX.Element; | |
}): JSX.Element { | |
// We perform the user provided operations and construct the children in a single pass | |
type Shadow = {[Public]: JSX.Element; value: val; dispose(): void}; | |
const shadowIndex = new Map<key, Shadow>(); | |
const shadowOperations = enshroudOperations<key, Shadow, ref>(shadowIndex, { | |
create(ref, key) { | |
const value = props.operations.create(ref, key); | |
return createRoot(dispose => ({ | |
dispose, | |
value, | |
[Public]: props.children(value, key), | |
})); | |
}, | |
update: | |
props.operations.update && | |
function (shadow, ref, key) { | |
const value = props.operations.update!(shadow.value, ref, key); | |
if (value === shadow[Public]) return shadow; | |
shadow.dispose(); | |
return createRoot(dispose => ({ | |
dispose, | |
value, | |
[Public]: props.children(value, key), | |
})); | |
}, | |
destroy(shadow, key) { | |
props.operations.destroy!(shadow.value, key); | |
shadow.dispose(); | |
}, | |
}); | |
return createRemapArrayMemo(() => props.refs, props.operations.intoKey, shadowOperations) as never; | |
} | |
export function createRemapArrayMemo<key, val, ref>( | |
references: () => ArrayLike<ref> | undefined, | |
intoKey: (reference: ref, index: number) => key, | |
operations: RemapOperations<key, val, ref>, | |
): () => val[] { | |
const index = new Map<key, val>(); | |
onCleanup(() => remapArray(undefined, intoKey, index, operations)); | |
return createMemo<val[]>(prev => remapArray(references(), intoKey, index, operations, prev)); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** A map that throws when mutated. No useful type modifications */ | |
export class ReadonlyMap<K, V> extends Map<K, V> implements ReadonlyMapLike<K, V> { | |
// eslint-disable-next-line @typescript-eslint/no-unused-vars | |
set(key: K, value: V): this { | |
throw new TypeError("Cannot modify a read-only Map"); | |
} | |
// eslint-disable-next-line @typescript-eslint/no-unused-vars | |
delete(key: K): boolean { | |
throw new TypeError("Cannot modify a read-only Map"); | |
} | |
clear(): void { | |
throw new TypeError("Cannot modify a read-only Map"); | |
} | |
} | |
/** Minimal interface of a ReadonlyMap-like necessary for {@link remap} */ | |
export interface ReadonlyMapLike<K, V> { | |
has(key: K): boolean; | |
[Symbol.iterator](): IterableIterator<[K, V]>; | |
} | |
/** A guaranteed empty map, used to avoid unnecessary calls to {@link indexBy} */ | |
export const EMPTY_MAP: ReadonlyMap<never, never> = new ReadonlyMap<never, never>(); | |
/** | |
* Creates or updates an index that maps derived keys to references. | |
* | |
* The index is created by applying {@link intoKey} to each reference. | |
* If an existing mapping is provided any collisions will be overwritten, | |
* but will not be cleared. | |
* | |
* @param references Collection of items to index | |
* @param intoKey Function that generates a unique key for each reference | |
* @param index Optional existing mapping to overwrite | |
* @returns The populated map with keys mapped to their corresponding items | |
* | |
* @example | |
* // Create an index of users by their ID | |
* const userIndex = indexBy(users, user => user.id); | |
* | |
* // Reuse an existing map | |
* indexBy(updatedUsers, user => user.id, userIndex); | |
*/ | |
export function indexBy<key, ref>( | |
references: ArrayLike<ref>, | |
intoKey: (value: ref) => key, | |
index: Map<key, ref> = new Map(), | |
): typeof index { | |
for (let i = 0; i < references.length; i++) { | |
const value = references[i]!; | |
index.set(intoKey(value), value); | |
} | |
return index; | |
} | |
/** | |
* Creates or updates an index that maps derived keys to references | |
* @see {@link indexBy} | |
*/ | |
export function iterableIndexBy<key, ref>(refs: Iterable<ref>, intoKey: (ref: ref) => key): Map<key, ref> { | |
const projection = new Map<key, ref>(); | |
for (const ref of refs) { | |
projection.set(intoKey(ref), ref); | |
} | |
return projection; | |
} | |
/** | |
* Select values from an index based on a reference list, preserving order. | |
* | |
* The selection is created by applying {@link intoKey} to each reference | |
* and retrieving that key from the index. The values are produced in the | |
* same order as the reference list. | |
* | |
* Providing an existing selection can help to improve the performance by | |
* avoiding the cost of reallocating the selection array multiple times | |
* to expand it. | |
* | |
* @param references Collection of items to hash and retrieve from the index | |
* @param key Function that generates a unique key for each reference | |
* @param index Index from which to retrieve values | |
* @param selection Optional existing selection to clone or return unchanged | |
* @returns Conditionally: | |
* - selection unchanged: {@link selection} is reused with no modification | |
* - selection changed: a new array object (sliced from {@link selection}) | |
* | |
* @example | |
* const productIndex = indexBy(allProducts, product => product.id); | |
* remap(productIndex, computedProductIndex, ...) // transform the index | |
* const computedProducts = selectBy( | |
* allProducts, | |
* product => product.id, | |
* computedProductIndex, | |
* previousComputedProducts | |
* ); | |
*/ | |
export function selectBy<key, val, ref>( | |
references: ArrayLike<ref>, | |
intoKey: (ref: ref) => key, | |
index: ReadonlyMap<key, val>, | |
selection?: val[], | |
): val[]; | |
export function selectBy<key, val, ref>( | |
references: ArrayLike<ref>, | |
intoKey: (ref: ref) => key, | |
index: ReadonlyMap<key, val>, | |
selection?: val[], | |
): val[] { | |
// eslint-disable-next-line no-var | |
var i = 0; | |
// If the array could possibly be unchanged we check every element | |
// for divergence. | |
unchanged: if (selection?.length === references.length) { | |
for (; i < references.length; i++) { | |
const ref = references[i]!; | |
const val = index.get(intoKey(ref))!; | |
if (selection[i] !== val) break unchanged; | |
} | |
// target array is unchanged | |
return selection; | |
} | |
const replacement = selection?.slice(0, references.length) ?? []; | |
// Skipping over the already known unchanged elements, | |
// we rewrite the rest of the array. | |
for (; i < references.length; i++) { | |
const ref = references[i]!; | |
const val = index.get(intoKey(ref))!; | |
// I don't know that its worth reading replacement[i] to check if it needs changed | |
// instead of just changing it directly. | |
replacement[i] = val; | |
} | |
return replacement; | |
} | |
/** | |
* Select values from an index based on a reference Iterable, preserving order. | |
* @see {@link selectBy} | |
*/ | |
export function iterableSelectBy<key, val, ref>( | |
references: Iterable<ref>, | |
intoKey: (ref: ref) => key, | |
index: Map<key, val>, | |
): val[] { | |
const unprojection: val[] = []; | |
for (const ref of references) { | |
unprojection.push(index.get(intoKey(ref))!); | |
} | |
return unprojection; | |
} | |
/** | |
* Operations used by {@link remap} in processing its references, do not modify during remap. | |
* | |
* Undefined operations will not be performed, for instance if | |
* {@link create} is undefined, new references will not be added | |
* to the index during remap. | |
*/ | |
export interface RemapOperations<key, val, ref> { | |
/** Operation called when a new (key, reference) is encountered, constructing a value */ | |
readonly create: ((reference: ref, key: key) => val) | undefined; | |
/** Operation called when a known key changes its reference, either reusing or constructing a new value */ | |
readonly update: ((value: val, reference: ref, key: key) => val) | undefined; | |
/** Operation called when a known key ceases to be */ | |
readonly destroy: ((value: val, key: key) => void) | undefined; | |
} | |
/** | |
* Operations used by {@link remap} in processing its references, do not modify during remap. | |
* | |
* {@link destroy} is always defined, because the index is always updated during remap. | |
* @see {@link RemapOperations} | |
*/ | |
export interface StrictRemapOperations<key, val, ref> extends RemapOperations<key, val, ref> { | |
readonly destroy: ((value: val, key: key) => void) | undefined; | |
} | |
/** | |
* Transform the index by synchronizing its keyset with reference index. | |
* | |
* This is useful for projecting a refreshable data source, i.e. fetch, onto reactive signals | |
* | |
* const index = new Map<key, value>() | |
* createMemo(() => { | |
* const people = fetchDirectory() | |
* remap(people, index, person => ) | |
* | |
* }) | |
* | |
* @param reference Canonical referece index | |
* @param index | |
* @param create | |
* @param update | |
* @param destroy | |
* @returns True if any (key, value) pair changed. Does not return true for updated keys that do not replace the value | |
*/ | |
export function remap<key, val, ref>( | |
reference: ReadonlyMapLike<key, ref> | undefined = EMPTY_MAP, | |
index: Map<key, val>, | |
operations: RemapOperations<key, val, ref>, | |
): boolean { | |
let mappingChanged = false; | |
// First pass: Remove previously known keys that | |
// have been deleted from the reference. | |
for (const [key, value] of index) { | |
// If the key exists, we'll update it later | |
if (reference.has(key)) continue; | |
// Otherwise, if the key isn't present in the reference index, | |
// we need to remove the key and destroy the value. | |
index.delete(key); | |
operations.destroy?.(value, key); | |
mappingChanged = true; | |
} | |
// Second pass: Update existing keys and create the rest. | |
for (const [key, ref] of reference) { | |
if (index.has(key)) { | |
if (operations.update) { | |
const value = index.get(key)!; | |
const replacement = operations.update(value, ref, key); | |
if (value !== replacement) { | |
mappingChanged = true; | |
index.set(key, replacement); | |
} | |
} | |
} else if (operations.create) { | |
index.set(key, operations.create(ref, key)); | |
mappingChanged = true; | |
} | |
} | |
return mappingChanged; | |
} | |
export type Shadow<T = unknown> = {[Public]: T}; | |
export const Public = Symbol(); | |
export function enshroudOperations<key, shadow extends Shadow, ref>( | |
shadowIndex: Map<key, shadow>, | |
operations: RemapOperations<key, shadow, ref>, | |
): StrictRemapOperations<key, shadow[typeof Public], ref> { | |
return { | |
create: | |
operations.create && | |
function (ref, key) { | |
const shadow = operations.create!(ref, key); | |
shadowIndex.set(key, shadow); | |
return shadow[Public]; | |
}, | |
update: | |
operations.update && | |
function (val, ref, key) { | |
const shadow = shadowIndex.get(key)!; | |
const updatedShadow = operations.update!(shadow, ref, key); | |
if (shadow !== updatedShadow) shadowIndex.set(key, updatedShadow); | |
return updatedShadow[Public]; | |
}, | |
destroy: function (val, key) { | |
const shadow = shadowIndex.get(key)!; | |
shadowIndex.delete(key); | |
operations.destroy?.(shadow, key); | |
}, | |
}; | |
} | |
/** | |
* | |
* @param references Collection of items to index and remap | |
* @param intoKey Function that generates a unique key for each reference | |
* @param index Mutable index to update during the remap operation, this needs to be persistent across executions | |
* @param selection Optional existing selection to return or clone | |
* @param operations | |
* @returns | |
*/ | |
export function remapArray<key, val, ref>( | |
references: ArrayLike<ref> | undefined, | |
intoKey: (reference: ref) => key, | |
index: Map<key, val>, | |
operations: RemapOperations<key, val, ref>, | |
selection?: val[], | |
) { | |
if (!references) { | |
remap(undefined, index, operations); | |
return []; | |
} | |
remap(indexBy(references, intoKey), index, operations); | |
return selectBy(references, intoKey, index, selection); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment