Skip to content

Instantly share code, notes, and snippets.

@webstrand
Last active March 29, 2025 22:54
Show Gist options
  • Save webstrand/e6b1fa4a375734dc6e80d290691dda68 to your computer and use it in GitHub Desktop.
Save webstrand/e6b1fa4a375734dc6e80d290691dda68 to your computer and use it in GitHub Desktop.
Remapping utilities for reactive signals
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));
}
/** 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