Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ const config: StorybookConfig = {
},
},
],
// Keep direct signals imports in the same prebundle as Solid's runtime.
optimizeDeps: { include: ["@solidjs/signals"] },
resolve: {
conditions: ["@solid-primitives/source"],
alias: [{ find: "solid-js/web", replacement: "@solidjs/web" }],
dedupe: ["react", "react-dom"],
},
});
},
Expand Down
3 changes: 2 additions & 1 deletion .storybook/ui/form-control.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type JSX, Show, onCleanup } from "solid-js";
import { Show, onCleanup } from "solid-js";
import type { JSX } from "@solidjs/web";
import {
type FormControlContextValue,
createFormControl,
Expand Down
2 changes: 1 addition & 1 deletion .storybook/ui/primitives.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { JSX } from "solid-js";
import type { JSX } from "@solidjs/web";
import { For } from "solid-js";
import { colors, font, radii } from "./tokens.js";

Expand Down
21 changes: 21 additions & 0 deletions packages/collections/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2021 Solid Primitives Working Group

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
200 changes: 200 additions & 0 deletions packages/collections/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<p>
<img width="100%" src="https://assets.solidjs.com/banner?type=Primitives&background=tiles&project=Collections" alt="Solid Primitives Collections">
</p>

# @solid-primitives/collections

Reactive Map, Set, WeakMap, and WeakSet factories with per-key tracking, computed drafts, async sources, optimistic updates, and SSR.

- [`createReactiveMap`](#createreactivemap)
- [`createReactiveSet`](#createreactiveset)
- [`createReactiveWeakMap`](#createreactiveweakmap)
- [`createReactiveWeakSet`](#createreactiveweakset)

> For synchronous reactive classes, see [`@solid-primitives/map`](../map/README.md) and [`@solid-primitives/set`](../set/README.md).

## Installation

```sh
npm install @solid-primitives/collections
# or
yarn add @solid-primitives/collections
# or
pnpm add @solid-primitives/collections
```

## `createReactiveMap`

Creates a shallow reactive `Map` from entries or a compute function. Each observed key gets its own lazily created presence and value nodes, so reading `map.get(a)` does not subscribe to changes to `b`. Object keys and values retain their references.

```ts
import { createReactiveMap } from "@solid-primitives/collections";

// Create inside a component or reactive owner.
const counts = createReactiveMap<string, number>();
const counts = createReactiveMap<string, number>([["a", 1]]);
const totals = createReactiveMap<string, number>(draft => {
draft.set("total", items().length);
});
const users = createReactiveMap(
async () => {
const group = groupId();
const result: User[] = await fetchUsers(group);
return new Map(result.map(user => [user.id, user]));
},
{ optimistic: true },
);
const users = createReactiveMap<string, User>(async function* (draft) {
for await (const user of updates()) {
draft.set(user.id, user);
yield;
}
});
```

```ts
type CreateReactiveMapComputeFunction<K, V> = (
draft: Map<K, V>,
) =>
| void
| ReadonlyMap<K, V>
| PromiseLike<void | ReadonlyMap<K, V>>
| AsyncIterable<void | ReadonlyMap<K, V>>;

type CreateReactiveMapOptions<K, V> = {
name?: string;
optimistic?: boolean;
loadingValue?: ReadonlyMap<K, V>;
deferStream?: boolean;
ssrSource?: "server" | "hybrid" | "client";
};
```

Reactive semantics follow Solid stores: batched reads & writes, errors, pending status, supersession and disposal. Likewise, `optimistic` follows optimistic stores: independent value edits can settle independently, while overlapping structural actions share order/size state and can settle together. The returned map is shallow: nested object changes are not tracked.

The compute function has similar semantics to `createStore`: Mutate the draft and return `void`, or return a `Map`/`ReadonlyMap` to replace the contents. Async functions and generators are also supported. An AsyncIterable can yield replacement maps or `void` after editing the draft. Final draft edits before the iterable completes are also applied, but the terminal `return` value is ignored. Inputs and returned maps are copied, never mutated. When using a compute function, the primitive returns a `Refreshable<Map<K, V>>` so it can be used with Solid's `refresh`.

`deferStream` delays SSR flushing for the first answer, and `ssrSource` selects Solid's normal server/hybrid/client hydration policy. Client-only sources need `Loading` or initial `loadingValue` on the server. Computes may run during hydration to discover dependencies and should avoid unrelated side effects. SSR transports the backing store, preserving shared serializable object identities between sources and collection keys. Dates, `NaN`, `undefined`, BigInts, cycles, and registered/well-known symbol keys are supported. Functions, local symbol keys, and other unsupported values still require a client-only source or an application serialization strategy.

Iterators are live and track when consumed, including iterators created outside a tracking scope. They skip removed entries, see appended entries and updated values, and remain finished once exhausted. An order-changing replacement starts a fresh insertion generation, as with clear/reinsert. Optimistic rollback restores the collection but does not rewind an existing iterator past entries it has already consumed. Consume a fresh iterator inside each reactive computation.

The result implements the Map methods through a subclass facade. Native intrinsics such as `Map.prototype.get.call(map, key)`, `structuredClone(map)`, and serializers that inspect native Map storage bypass it. Use `new Map(map)` when passing contents to those APIs. Full live traversal is slower than native Map traversal; this helper is intended primarily for tracked reads and computed/async collections.

weak symbol bookkeeping requires a runtime supporting symbols in `WeakMap`/`WeakRef`.

## `createReactiveWeakMap`

Like `createReactiveMap`, but creates a _weak_ collection with separate lazy membership and value tracking for every key.

```ts
import { createReactiveWeakMap } from "@solid-primitives/collections";

const metadata = createReactiveWeakMap<object, string>(async draft => {
const item = selectedItem();
const label = await fetchLabel(item);
draft.set(item, label);
});

// Iterable replacements are supported, including arrays and ordinary Maps.
const visible = createReactiveWeakMap(() => items().map(item => [item, true] as const));
```

```ts
type CreateReactiveWeakMapComputeFunction<K extends WeakKey, V> = (
draft: WeakMap<K, V>,
) =>
| void
| Iterable<readonly [K, V]>
| PromiseLike<void | Iterable<readonly [K, V]>>
| AsyncIterable<void | Iterable<readonly [K, V]>>;
```

The compute draft recieves a `WeakMap<K, V>`. It can mutate the draft and return/yield `void`, or return/yield iterable replacement entries. Unlike regular Maps, native WeakMaps cannot be enumerated, so they are not accepted as replacements or initial inputs. For the same reason, there is no `size`, iteration, or `clear()` method. Keys must be `WeakKey`s, just like native `WeakMap`.

To support SSR, install the optional codec in the renderer:

```ts
import { WeakCollectionTokenPlugin } from "@solid-primitives/collections/serialization";

renderToStream(() => <App />, { plugins: [WeakCollectionTokenPlugin] });
```

This supports shared serializable key identities, promises, streamed drafts, loading values, and server/hybrid/client hydration policies. Script hydration reconstructs the weak containers without a client codec import; JSON codecs need the plugin on both peers. Functions and local symbols are client-only unless an application supplies a suitable transport strategy. Solid/Seroval's decoding reference tables can retain transported keys after hydration; the collection cannot safely release those shared tables. A client-only source with an empty loading seed avoids transporting its entries.

## `createReactiveSet`

Creates a shallow reactive `Set` from an iterable or a compute function. Each tracked `set.has(value)` gets a separate lazily created membership node, and adding an unrelated value does not trigger the dependencies of others. Objects keep their identity.

```ts
import { createReactiveSet } from "@solid-primitives/collections";

// Create inside a component or reactive owner.
const selected = createReactiveSet<number>();
const active = createReactiveSet(() => new Set(items().filter(item => item.active)));
const allowed = createReactiveSet(async () => {
const id = userId();
return new Set(await fetchAllowedIds(id));
});

// Use `optimistic: true` create an optimistic set
const optimistic = createReactiveSet<number>([], { optimistic: true });
```

```ts
type CreateReactiveSetComputeFunction<T> = (
draft: Set<T>,
) =>
void | ReadonlySet<T> | PromiseLike<void | ReadonlySet<T>> | AsyncIterable<void | ReadonlySet<T>>;

type CreateReactiveSetOptions<T> = {
name?: string;
optimistic?: boolean;
loadingValue?: ReadonlySet<T>;
deferStream?: boolean;
ssrSource?: "server" | "hybrid" | "client";
};
```

Reactive semantics follow Solid stores: batched reads & writes, errors, pending status, supersession and disposal. Likewise, `optimistic` follows optimistic stores: independent value edits can settle independently, while overlapping structural actions share order/size state and can settle together.

The compute function has similar semantics to `createStore`: Mutate the draft and return `void`, or return a `Set`/`ReadonlySet` to replace the contents. Async functions and generators are also supported. An AsyncIterable can yield replacement sets or `void` after editing the draft. Final draft edits before the iterable completes are also applied, but the terminal `return` value is ignored. Inputs and returned sets are copied, never mutated. When using a compute function, the primitive returns a `Refreshable<Set<T>>` so it can be used with Solid's `refresh`.

`deferStream` delays SSR flushing for the first answer, and `ssrSource` selects Solid's normal server/hybrid/client hydration policy. Client-only sources need `Loading` or initial `loadingValue` on the server. Computes may run during hydration to discover dependencies and should avoid unrelated side effects. SSR preserves serializable object identities and supports registered/well-known symbol values. However, functions and local symbols are not automatically serialized and need a client-only source or an application serialization strategy.

Iterators are live and track when consumed, including iterators created outside a tracking scope. They skip removed entries, see appended entries and updated values, and remain finished once exhausted. An order-changing replacement starts a fresh insertion generation, as with clear/reinsert. Optimistic rollback restores the collection but does not rewind an existing iterator past entries it has already consumed. Consume a fresh iterator inside each reactive computation.

The returned Set is a subclass facade. Native intrinsics, `structuredClone`, and serializers inspecting native Set storage bypass its methods; pass `new Set(set)` to such APIs. Full live traversal is slower than native Set traversal.

The Set algebra methods return ordinary snapshot Sets.

## `createReactiveWeakSet`

Like `createReactiveSet`, but creates a _weak_ collection with lazy, independent `has(value)` tracking.

```ts
import { createReactiveWeakSet } from "@solid-primitives/collections";

const active = createReactiveWeakSet(() => items().filter(item => item.active));
const seen = createReactiveWeakSet<object>(async function* (draft) {
for await (const item of updates()) {
draft.add(item);
yield;
}
});
```

The compute draft recieves a `WeakSet<T>`. It can mutate the draft and return/yield `void`, or return/yield an iterable replacement. Unlike regular Sets, WeakSets cannot be enumerated so they are not valid replacements or initial inputs. For the same reason, there is no iteration, size, or set algebra methods. Values must be `WeakKey`s, just like native `WeakSet`.

To support SSR, install the optional codec in the renderer:

```ts
import { WeakCollectionTokenPlugin } from "@solid-primitives/collections/serialization";

renderToStream(() => <App />, { plugins: [WeakCollectionTokenPlugin] });
```

Hydration preserves shared serializable key identities. Shared decoding reference tables may retain transported keys independently of the weak collection. See the [WeakMap factory documentation](#createreactiveweakmap) for renderer setup, garbage collection, metadata cleanup, and native-intrinsic limitations.

## Development

Tests, build configuration, and implementation notes live in this package. See the [validation guide](./docs/implementation.md#validation) and [weak storage/SSR notes](./docs/weak.md).
14 changes: 14 additions & 0 deletions packages/collections/deno.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@solid-primitives/collections",
"version": "0.0.100",
"description": "Reactive Map, Set, WeakMap, and WeakSet factories with computed drafts, async sources, optimistic updates, and SSR.",
"license": "MIT",
"exports": {
".": "./src/index.ts",
"./serialization": "./src/serialization.ts",
},
"publish": {
"include": ["README.md", "LICENSE", "src/**/*.ts", "package.json"],
"exclude": ["dist", "docs", "scripts", "test", "node_modules", "*.config.ts", "tsconfig*.json"],
},
}
68 changes: 68 additions & 0 deletions packages/collections/docs/implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Reactive collection implementation

`createReactiveMap` and `createReactiveSet` use shallow Solid stores to provide independent per-key tracking, computed drafts, promises, async iterables, optimistic edits, and SSR/hydration. The existing `ReactiveMap` and `ReactiveSet` classes remain available. Weak variants share the computation model with a different storage representation; see [weak collections](./weak.md).

The public contracts and examples are in the [package README](../README.md).

## API and storage

- Iterable input returns `Map<K, V>` or `Set<T>`; compute input returns the corresponding `Refreshable` type.
- A compute receives an initially empty mutable draft. It can mutate the draft or return a readonly/native replacement, a PromiseLike of either result, or an AsyncIterable yielding either result.
- Compute options include `loadingValue`, `deferStream`, and `ssrSource`. Both input forms accept `name` and `optimistic`. An `ownedWrite` override is not exposed.
- Values are shallow and preserve identity. External mutations follow Solid batching; draft writes are immediately readable. Solid guards superseded and disposed async drafts.

Each entry occupies independently addressable store slots. Membership and value reads allocate separate lazy store nodes; a present-undefined value does not invalidate a `get` reader whose result remains undefined. Primitive keys encode directly. Objects, functions, and local symbols use weak identity registration, including aliases adopted during hydration.

Insertion order uses an immutable AVL tree of ordinals and slot names. Structural edits copy a logarithmic path rather than an entire ordered-key array. This does not guarantee logarithmic optimistic writes: Solid can still copy/diff its backing state. Replacements preserve unchanged value/presence slots and start a fresh insertion generation when order changes.

Iterators read the current store order when consumed. They advance with a stack until the visible root changes, then seek after the last ordinal. Weak key caches prevent historical iterator trees from retaining deleted object keys. Values use frozen wrapper objects so shallow ingestion does not alter user objects. The serializer handles shared identities and cycles through the backing store.

The implementation uses public Solid APIs. Private node inspections and projection-trace inspection through `solid-js/internal` occur only in tests.

## Behavioral boundaries

- Optimistic value edits to independent Map keys settle independently. Structural edits can settle together through Solid's shared key-set node.
- Iterators do not rewind on optimistic rollback. Order-changing replacements act as a new insertion generation.
- The facade has empty native internal storage. Use `new Map(map)` / `new Set(set)` with native intrinsics, structured cloning, or serializers that bypass instance methods.
- Functions and local symbols are supported on the client but are not generally wire-serializable. Local-symbol bookkeeping requires runtime support for weak symbol keys.
- Correct live traversal costs more than native traversal; keep this in mind for iteration-heavy workloads.

## Validation

Install the workspace dependencies before running the package checks. Runtime tests, type checking, and Storybook use the installed Solid packages.

From this package directory:

```sh
pnpm test
pnpm typecheck
pnpm lint
pnpm build
```

`test` runs server rendering before client hydration, then the weak lifetime suite in separate development/production processes with real GC. A failed server run prevents consumption of stale fixtures. The shared test runner discovers `test/*.test.*`; keeping this suite under `test/integration` lets the package runner select the right compiler/runtime conditions, generate fresh SSR fixtures before hydration, and enable GC for lifetime checks. Generated bundles and hydration fixtures stay under the ignored `node_modules/.cache` directory.

Coverage includes lazy node allocation/release, raw identity, native collection behavior and live iteration, a deterministic 2,000-operation comparison with native Map, replacement order, errors, refresh, async supersession/disposal, held truth, optimistic rollback, shared serialized identities, delayed hydration, and weak-key/value lifetime.

To run lifetime checks alone or measure the current collection implementation:

```sh
pnpm test:gc
pnpm bench
```

The benchmarks cover structural edits, a single edit among 1,000 observed values, and full traversal. Native Set provides a reference for structural edits and traversal; it does not perform reactive notification.

## Storybook

Run from the repository root:

```sh
pnpm storybook
# or build a static preview:
pnpm build-storybook
```

Stories live under `stories/` and are discovered by the shared Storybook configuration, using the workspace dependencies.

Both Map and Set have basic, derived async, derived async iterable, derived async + optimistic, and weak-variant examples. Requests are simulated locally. The optimistic examples offer accepted and rejected saves; the streaming examples can be restarted while a previous stream is still running.
Loading
Loading