diff --git a/.storybook/main.ts b/.storybook/main.ts
index de398100d..ed6dd05f1 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -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"],
},
});
},
diff --git a/.storybook/ui/form-control.tsx b/.storybook/ui/form-control.tsx
index 563757e8c..81e639816 100644
--- a/.storybook/ui/form-control.tsx
+++ b/.storybook/ui/form-control.tsx
@@ -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,
diff --git a/.storybook/ui/primitives.tsx b/.storybook/ui/primitives.tsx
index 2f0a433c1..ca365023a 100644
--- a/.storybook/ui/primitives.tsx
+++ b/.storybook/ui/primitives.tsx
@@ -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";
diff --git a/packages/collections/LICENSE b/packages/collections/LICENSE
new file mode 100644
index 000000000..38b41d975
--- /dev/null
+++ b/packages/collections/LICENSE
@@ -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.
\ No newline at end of file
diff --git a/packages/collections/README.md b/packages/collections/README.md
new file mode 100644
index 000000000..511a087c6
--- /dev/null
+++ b/packages/collections/README.md
@@ -0,0 +1,200 @@
+
+
+
+
+# @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();
+const counts = createReactiveMap([["a", 1]]);
+const totals = createReactiveMap(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(async function* (draft) {
+ for await (const user of updates()) {
+ draft.set(user.id, user);
+ yield;
+ }
+});
+```
+
+```ts
+type CreateReactiveMapComputeFunction = (
+ draft: Map,
+) =>
+ | void
+ | ReadonlyMap
+ | PromiseLike>
+ | AsyncIterable>;
+
+type CreateReactiveMapOptions = {
+ name?: string;
+ optimistic?: boolean;
+ loadingValue?: ReadonlyMap;
+ 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>` 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(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 = (
+ draft: WeakMap,
+) =>
+ | void
+ | Iterable
+ | PromiseLike>
+ | AsyncIterable>;
+```
+
+The compute draft recieves a `WeakMap`. 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(() => , { 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();
+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([], { optimistic: true });
+```
+
+```ts
+type CreateReactiveSetComputeFunction = (
+ draft: Set,
+) =>
+ void | ReadonlySet | PromiseLike> | AsyncIterable>;
+
+type CreateReactiveSetOptions = {
+ name?: string;
+ optimistic?: boolean;
+ loadingValue?: ReadonlySet;
+ 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>` 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(async function* (draft) {
+ for await (const item of updates()) {
+ draft.add(item);
+ yield;
+ }
+});
+```
+
+The compute draft recieves a `WeakSet`. 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(() => , { 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).
diff --git a/packages/collections/deno.jsonc b/packages/collections/deno.jsonc
new file mode 100644
index 000000000..8a1461a40
--- /dev/null
+++ b/packages/collections/deno.jsonc
@@ -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"],
+ },
+}
diff --git a/packages/collections/docs/implementation.md b/packages/collections/docs/implementation.md
new file mode 100644
index 000000000..b2786f677
--- /dev/null
+++ b/packages/collections/docs/implementation.md
@@ -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` or `Set`; 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.
diff --git a/packages/collections/docs/weak.md b/packages/collections/docs/weak.md
new file mode 100644
index 000000000..5ade9692c
--- /dev/null
+++ b/packages/collections/docs/weak.md
@@ -0,0 +1,55 @@
+# Reactive weak collection implementation
+
+`createReactiveWeakMap` and `createReactiveWeakSet` support independent per-key reads, computed drafts, promises, async iterables, loading values, refresh, optimistic edits, and SSR/hydration.
+
+The API and examples are in the [package README](../README.md).
+
+## Weak storage and cleanup
+
+A native WeakMap associates each key with an opaque property ID. The shallow store contains an independent value slot and weak key metadata for that ID. Each non-undefined value uses an immutable token containing a native `WeakMap` and `WeakRef(key)`. A token retained by a snapshot or node does not keep its key, or a value-to-key cycle, alive. Replacing tokens preserves snapshot isolation.
+
+Membership and value reads track separate store nodes. Undefined is stored directly so changing between a missing entry and a present-undefined entry does not invalidate `get(key)`. A metadata marker lets hydration adopt decoded key identities when the transported key set changes; ordinary mutations do not write that marker. There is no shared value signal through which all key reads are routed.
+
+An old token can still retain its value if the key remains externally live. Solid must release obsolete companion nodes after reader disposal for overwritten/deleted values to become collectible. The lifetime suite covers both unreachable key cycles and obsolete values with live keys.
+
+FinalizationRegistry callbacks queue only property IDs. Ordinary writes or a fresh compute drain this queue. Stale async drafts cannot drain it, since their guarded writes would do nothing. Ordinary optimistic writes also leave it queued, since a deletion through that path would roll back. Computed optimistic collections prune during the next authoritative computation.
+
+Iterable-initialized optimistic collections have an internal maintenance projection driven by a key-free counter. This supplies an authoritative cleanup channel without sharing key subscriptions. Owner disposal disconnects its finalization callback. User keys/values can be collected before empty metadata is pruned; finalization timing is unspecified.
+
+## API boundaries
+
+- Initial inputs, replacements, and loading values must be iterable. Native WeakMap/WeakSet cannot be enumerated and are rejected as inputs/replacements. Mutating the supplied draft is the primary API.
+- There is no size, enumeration, clear, or set algebra.
+- Keys may be objects, functions, and supported non-registered symbols. Mutators reject primitive and registered-symbol keys. `WeakRef` and `FinalizationRegistry` are required.
+- Values preserve identity. External writes follow Solid batching; draft edits are immediately readable.
+- Independent existing-value edits can settle separately. Structural optimistic edits can settle together through Solid's root key-set node.
+- Native intrinsics bypass the facade's methods. Its native internal storage is empty, and weak collections cannot be materialized by iteration.
+
+## SSR and hydration
+
+The optional serializer plugin is a separate entry so client collection code does not import the codec:
+
+```ts
+import { renderToStream } from "@solidjs/web";
+import { WeakCollectionTokenPlugin } from "@solid-primitives/collections/serialization";
+
+renderToStream(App, { plugins: [WeakCollectionTokenPlugin] });
+```
+
+The renderer's `plugins` option also supports `renderToString`. JSON transports need the plugin on both encoding and decoding sides. Script hydration reconstructs native weak containers directly and needs no client plugin registration. Missing plugin registration fails serialization rather than silently dropping weak state. These transport APIs are integration-facing in Solid and are outside its normal 2.0 stability guarantee.
+
+The codec temporarily snapshots key/value pairs through the normal serializer, preserving cycles and identities shared with separately serialized keys. Already-collected keys become empty tokens. Hydrated lookups must use the transported key identity; an equal-looking object is a different key. Functions, local symbols, and unsupported values retain the transport's normal restrictions.
+
+Static iterable seeds rebuild locally because Solid does not serialize synchronous seeds. Async fixtures and codec tests exercise transported entries, including promise/stream/hybrid/loading/client policies and hydration before or after the remaining stream arrives.
+
+### Transport ownership
+
+Solid/Seroval's script reference table and JSON decoder reference map can retain decoded keys independently of the weak collection. GC tests confirm that releasing those scopes lets the key/value cycle collect while its token remains alive.
+
+The primitive cannot safely clear shared references: later chunks, late hydration, or other consumers may still need them. A renderer-level scope-release contract would be needed to automate that cleanup. JSON transports that discard their decoder after consumption already release that ownership. For strict weak lifetime immediately after hydration, `ssrSource: "client"` with an empty loading seed avoids transporting computed entries; separately serialized keys still have their own transport ownership.
+
+## Validation and future core support
+
+Run `pnpm test` from this package directory for the combined runtime, SSR, codec, and GC checks. The [validation guide](./implementation.md#validation) documents setup and individual commands. Weak coverage includes 21 runtime cases, 41 SSR/hydration/codec cases, and 40 lifetime cases in each shipped development/production profile.
+
+No additional collection-specific core support is currently required. A safe authoritative maintenance operation could simplify cleanup, and an explicit opt-out from key-set observation could allow independent structural optimistic settlement. Exposing leaf signals alone would leave async, held-truth, optimistic, and hydration behavior to reconstruct, so the store backend remains the practical choice.
diff --git a/packages/collections/package.json b/packages/collections/package.json
new file mode 100644
index 000000000..58439c128
--- /dev/null
+++ b/packages/collections/package.json
@@ -0,0 +1,84 @@
+{
+ "name": "@solid-primitives/collections",
+ "version": "0.1.0",
+ "description": "Reactive Map, Set, WeakMap, and WeakSet primitives with async sources, optimistic updates, and SSR.",
+ "license": "MIT",
+ "homepage": "https://primitives.solidjs.community/package/collections",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/solidjs-community/solid-primitives.git"
+ },
+ "bugs": {
+ "url": "https://github.com/solidjs-community/solid-primitives/issues"
+ },
+ "primitive": {
+ "name": "collections",
+ "stage": 0,
+ "list": [
+ "createReactiveMap",
+ "createReactiveSet",
+ "createReactiveWeakMap",
+ "createReactiveWeakSet"
+ ],
+ "category": "Reactivity"
+ },
+ "keywords": [
+ "solid",
+ "primitives",
+ "collections",
+ "map",
+ "set",
+ "weakmap",
+ "weakset",
+ "reactive"
+ ],
+ "private": false,
+ "sideEffects": false,
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": {
+ "@solid-primitives/source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "./serialization": {
+ "import": {
+ "@solid-primitives/source": "./src/serialization.ts",
+ "types": "./dist/serialization.d.ts",
+ "default": "./dist/serialization.js"
+ }
+ }
+ },
+ "typesVersions": {
+ "*": {
+ "serialization": [
+ "./dist/serialization.d.ts"
+ ]
+ }
+ },
+ "scripts": {
+ "build": "tsdown",
+ "test": "node scripts/test.mjs",
+ "test:gc": "node scripts/test-gc.mjs",
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
+ "lint": "oxlint src test --max-warnings 0",
+ "bench": "vitest bench --run --config vitest.config.ts"
+ },
+ "peerDependencies": {
+ "@solidjs/signals": "catalog:peer",
+ "@solidjs/web": "catalog:peer",
+ "solid-js": "catalog:peer"
+ },
+ "devDependencies": {
+ "@solidjs/signals": "catalog:",
+ "@solidjs/web": "catalog:",
+ "solid-js": "catalog:"
+ }
+}
diff --git a/packages/collections/scripts/test-gc.mjs b/packages/collections/scripts/test-gc.mjs
new file mode 100644
index 000000000..8ff2fdd5e
--- /dev/null
+++ b/packages/collections/scripts/test-gc.mjs
@@ -0,0 +1,26 @@
+// Build the weak collection lifetime tests in both shipped Solid profiles.
+import { build } from "esbuild";
+import { mkdir } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { spawnSync } from "node:child_process";
+
+const path = relative => fileURLToPath(new URL(relative, import.meta.url));
+const cache = path("../node_modules/.cache/collections-gc/");
+await mkdir(cache, { recursive: true });
+
+for (const dev of [true, false]) {
+ const outfile = `${cache}/weak-${dev ? "development" : "production"}.mjs`;
+ await build({
+ entryPoints: [path("../test/integration/gc/weak-collections.test.ts")],
+ outfile,
+ bundle: true,
+ platform: "node",
+ format: "esm",
+ // Exercise the shipped client builds, including in this Node GC process.
+ conditions: ["browser", ...(dev ? ["development"] : [])],
+ });
+ process.stdout.write(`Weak collection GC (${dev ? "development" : "production"})\n`);
+ const result = spawnSync(process.execPath, ["--expose-gc", outfile], { stdio: "inherit" });
+ if (result.error) throw result.error;
+ if (result.status !== 0) process.exit(result.status ?? 1);
+}
diff --git a/packages/collections/scripts/test.mjs b/packages/collections/scripts/test.mjs
new file mode 100644
index 000000000..1b789aaa4
--- /dev/null
+++ b/packages/collections/scripts/test.mjs
@@ -0,0 +1,23 @@
+// SSR fixtures must be generated by the server compiler before the browser
+// compiler hydrates them. Never consume stale fixtures after a failed render.
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const root = fileURLToPath(new URL("../", import.meta.url));
+for (const extra of [["--mode", "ssr"], []]) {
+ const result = spawnSync(
+ process.execPath,
+ ["../../node_modules/vitest/vitest.mjs", "run", "--config", "vitest.config.ts", ...extra],
+ { cwd: root, stdio: "inherit" },
+ );
+ if (result.error) throw result.error;
+ if (result.status !== 0) process.exit(result.status ?? 1);
+}
+
+// GC must run with shipped-build defines, without test-only owner registries.
+const gc = spawnSync(process.execPath, ["scripts/test-gc.mjs"], {
+ cwd: root,
+ stdio: "inherit",
+});
+if (gc.error) throw gc.error;
+if (gc.status !== 0) process.exit(gc.status ?? 1);
diff --git a/packages/collections/src/index.ts b/packages/collections/src/index.ts
new file mode 100644
index 000000000..a079274e2
--- /dev/null
+++ b/packages/collections/src/index.ts
@@ -0,0 +1,577 @@
+/**
+ * Shared backend for createReactiveMap and createReactiveSet.
+ *
+ * Each entry is a shallow store slot, so presence/value subscriptions and
+ * committed/pending/optimistic visibility are owned by the existing store
+ * engine. Native keys are encoded independently of enumeration order. No
+ * scheduler hooks, node fields, or existing read/write paths are changed.
+ *
+ * Persistent insertion order and boxed raw leaves are transported by Solid's
+ * shallow-store hydration protocol, including streamed patches.
+ */
+import { $REFRESH, type Refreshable, type StoreSetter } from "@solidjs/signals";
+import { createStore, createOptimisticStore, untrack } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { advance, build, insert, remove, seek, type Order, type OrderNode } from "./order.js";
+export {
+ createReactiveWeakMap,
+ createReactiveWeakSet,
+ type CreateReactiveWeakMapOptions,
+ type CreateReactiveWeakSetOptions,
+ type CreateReactiveWeakMapComputeFunction,
+ type CreateReactiveWeakSetComputeFunction,
+} from "./weak.js";
+
+export interface ReactiveCollectionOptions {
+ /** Label passed to Solid's store diagnostics. */
+ name?: string;
+ /** Make writes tentative during Solid actions, using createOptimisticStore. */
+ optimistic?: boolean;
+}
+
+export interface ReactiveCollectionComputeOptions extends ReactiveCollectionOptions {
+ /** Initial readable contents while the first compute is pending; copied, never mutated. */
+ loadingValue?: C;
+ /** Hold the SSR stream until the first computed value is available. */
+ deferStream?: boolean;
+ /** Solid's hydration policy; defaults to "server". */
+ ssrSource?: "server" | "hybrid" | "client";
+}
+
+export type CreateReactiveMapOptions = ReactiveCollectionComputeOptions>;
+export type CreateReactiveSetOptions = ReactiveCollectionComputeOptions>;
+export type CreateReactiveMapComputeFunction = ReactiveCollectionCompute<
+ Map,
+ ReadonlyMap
+>;
+export type CreateReactiveSetComputeFunction = ReactiveCollectionCompute, ReadonlySet>;
+
+export type ReactiveCollectionCompute = (
+ draft: Draft,
+) => void | Result | PromiseLike | AsyncIterable;
+
+type State = Record & { order: Order; size: number };
+type Entry = readonly [K, V];
+
+const sameKey = (a: unknown, b: unknown) => a === b || (a !== a && b !== b);
+
+// A shallow store permanently raw-marks inserted wrappable objects. Collections
+// must not change a user's object's behavior in OTHER stores. Only our frozen
+// shell reaches that boundary; get() returns the original reference. Primitive
+// values stay unboxed, preserving get(missing) === get(present-undefined) at
+// the subscription-node level. Existing shells are reused for unchanged values.
+function box(value: unknown): unknown {
+ return value !== null && typeof value === "object" ? Object.freeze({ value }) : value;
+}
+function unbox(value: any): V {
+ return value !== null && typeof value === "object" ? value.value : value;
+}
+
+// Registered symbols have a portable identity, but Solid's serializer only
+// handles well-known symbols. Encode registered keys as their registry name.
+// Map values still follow the serializer's ordinary supported-value contract.
+function boxKey(key: unknown): unknown {
+ return typeof key === "symbol" && Symbol.keyFor(key) !== undefined
+ ? Symbol.keyFor(key)
+ : box(key);
+}
+function readKey(state: State, slot: string): K {
+ return slot.startsWith("symbol:") ? (Symbol.for(slot.slice(7)) as K) : unbox(state["k:" + slot]);
+}
+
+/** No strong registry of absent keys: primitives/global symbols encode
+ * directly; objects, functions and local symbols use a WeakMap. Never use a
+ * caller's symbol as a slot: it could be one of Solid's store protocol keys. */
+function keyEncoder(): (key: K, state: State) => string {
+ const objects = new WeakMap();
+ const seen = new WeakSet();
+ const register = (root: Order, state: State): void => {
+ if (!root || seen.has(root)) return;
+ const key = readKey(state, root.slot);
+ if (
+ (typeof key === "object" && key !== null) ||
+ typeof key === "function" ||
+ (typeof key === "symbol" && Symbol.keyFor(key) === undefined)
+ ) {
+ const slots = objects.get(key as WeakKey);
+ if (!slots) objects.set(key as WeakKey, [root.slot]);
+ else if (!slots.includes(root.slot)) slots.push(root.slot);
+ }
+ register(root.left, state);
+ register(root.right, state);
+ seen.add(root);
+ };
+ let next = 0;
+ return (key, state) => {
+ if (key === null) return "l:";
+ switch (typeof key) {
+ case "object":
+ case "function":
+ case "symbol": {
+ if (typeof key === "symbol") {
+ const globalKey = Symbol.keyFor(key);
+ if (globalKey !== undefined) return "symbol:" + globalKey;
+ }
+ // Hydrated snapshots carry the authoritative key identities and slot
+ // names. Visit only new persistent-tree paths, without subscribing the
+ // caller to structure. This also replaces mappings made by trace runs.
+ return untrack(() => {
+ register(state.order, state);
+ const object = key as WeakKey;
+ const slots = objects.get(object);
+ if (slots) {
+ // Loading/held/optimistic readers can simultaneously see different
+ // generations. Retain both trace and wire aliases, choosing the
+ // one actually present in this reader's state.
+ return (
+ slots.find(slot => unbox(state["k:" + slot]) === key) ?? slots[slots.length - 1]!
+ );
+ }
+ const slot = "o:" + (isServer ? "s" : "c") + next++;
+ objects.set(object, [slot]);
+ return slot;
+ });
+ }
+ // Prefixes cannot collide with metadata or pollution-guarded names.
+ default:
+ return typeof key + ":" + String(key);
+ }
+ };
+}
+
+class CollectionData {
+ revision = 0;
+ private readonly keysByNode = new WeakMap>();
+ constructor(
+ readonly store: State,
+ readonly write: StoreSetter,
+ readonly encode: (key: K, state: State) => string,
+ readonly ordinal: (root?: Order) => number,
+ ) {}
+
+ slot(key: K): string {
+ return this.encode(key, this.store);
+ }
+
+ has(key: K): boolean {
+ return this.slot(key) in this.store;
+ }
+
+ get(key: K): V | undefined {
+ return unbox(this.store[this.slot(key)]);
+ }
+
+ set(key: K, value: V): void {
+ // Native Map/Set canonicalize negative zero, including iteration output.
+ if (key === 0) key = 0 as K;
+ this.write(state => {
+ const slot = this.encode(key, state);
+ const present = slot in state;
+ if (!present) {
+ const id = this.ordinal(state.order);
+ state["i:" + slot] = id;
+ state["k:" + slot] = boxKey(key);
+ state.order = insert(state.order, id, slot);
+ state.size++;
+ }
+ if (!present || unbox(state[slot]) !== value) {
+ state[slot] = box(value);
+ this.revision++;
+ }
+ });
+ }
+
+ delete(key: K): boolean {
+ let deleted = false;
+ this.write(state => {
+ const slot = this.encode(key, state);
+ if (!(slot in state)) return;
+ deleted = true;
+ this.revision++;
+ state.order = remove(state.order!, state["i:" + slot]);
+ delete state[slot];
+ delete state["i:" + slot];
+ delete state["k:" + slot];
+ state.size--;
+ });
+ return deleted;
+ }
+
+ clear(): void {
+ this.write(state => {
+ if (!state.size) return;
+ this.revision++;
+ const stack: OrderNode[] = [];
+ seek(state.order, -1, stack);
+ let entry: OrderNode | undefined;
+ while ((entry = advance(stack))) {
+ delete state[entry.slot];
+ delete state["i:" + entry.slot];
+ delete state["k:" + entry.slot];
+ }
+ state.order = undefined;
+ state.size = 0;
+ });
+ }
+
+ replace(entries: Iterable>): void {
+ // Materialize before writing: replacements may read this very collection.
+ const incoming = new Map(entries);
+ this.write(state => {
+ this.revision++;
+ const order = [...incoming.keys()];
+ // Resolve identities before removing metadata from the old index. The
+ // encoder must never cache a half-written historical tree as adopted.
+ const slots = order.map(key => this.encode(key, state));
+ const stack: OrderNode[] = [];
+ seek(state.order, -1, stack);
+ const oldOrder: K[] = [];
+ let entry: OrderNode | undefined;
+ while ((entry = advance(stack))) {
+ const key = readKey(state, entry.slot);
+ oldOrder.push(key);
+ if (!incoming.has(key)) {
+ delete state[entry.slot];
+ delete state["i:" + entry.slot];
+ delete state["k:" + entry.slot];
+ }
+ }
+ let index = 0;
+ for (const value of incoming.values()) {
+ const slot = slots[index++]!;
+ if (!(slot in state) || unbox(state[slot]) !== value) state[slot] = box(value);
+ }
+ if (
+ order.length !== oldOrder.length ||
+ order.some((key, index) => !sameKey(key, oldOrder[index]))
+ ) {
+ // A changed replacement order is a fresh iteration generation, like
+ // clear/reinsert, while unchanged value and presence slots stay intact.
+ const first = order.length ? this.ordinal(state.order) : 0;
+ state.order = build(
+ order.map((key, index) => {
+ const slot = slots[index]!,
+ id = index === 0 ? first : this.ordinal();
+ state["i:" + slot] = id;
+ if (!("k:" + slot in state)) state["k:" + slot] = boxKey(key);
+ return { id, slot };
+ }),
+ );
+ }
+ state.size = order.length;
+ });
+ }
+
+ /** Track on consumption. Seek again only when the visible order changes. */
+ *iterate(select: (key: K, slot: string) => R): Generator {
+ let previous: Order;
+ let cursor = -1;
+ const stack: OrderNode[] = [];
+ while (true) {
+ // Read the store's current view directly. A separate memo can retain a
+ // committed root while an untracked iterator sees optimistic slot data.
+ const order = this.store.order;
+ if (order !== previous) {
+ seek(order, cursor, stack);
+ previous = order;
+ }
+ const entry = advance(stack);
+ if (!entry) return;
+ cursor = entry.id;
+ // The order dependency covers key identity; avoid allocating a value
+ // subscription for every metadata slot just to enumerate keys.
+ let reference = this.keysByNode.get(entry);
+ if (!reference) {
+ const key = untrack(() => readKey(this.store, entry.slot));
+ reference =
+ (typeof key === "object" && key !== null) ||
+ typeof key === "function" ||
+ (typeof key === "symbol" && Symbol.keyFor(key) === undefined)
+ ? new WeakRef(key as WeakKey)
+ : { value: key };
+ this.keysByNode.set(entry, reference);
+ }
+ // The current store keeps live keys strongly reachable. Historical
+ // cursor stacks/cache entries must not retain deleted object keys.
+ const key = reference instanceof WeakRef ? (reference.deref() as K) : reference.value;
+ yield select(key, entry.slot);
+ }
+ }
+
+ keys(): MapIterator {
+ return this.iterate(key => key);
+ }
+ values(): MapIterator {
+ return this.iterate((_, slot) => unbox(this.store[slot]));
+ }
+ entries(): MapIterator<[K, V]> {
+ return this.iterate((key, slot) => [key, unbox(this.store[slot])]);
+ }
+}
+
+class CollectionMap extends Map {
+ declare private readonly data: CollectionData;
+ constructor(data: CollectionData) {
+ super();
+ Object.defineProperty(this, "data", { value: data });
+ }
+ get [$REFRESH]() {
+ return this.data.store[$REFRESH];
+ }
+ override get size() {
+ return this.data.store.size;
+ }
+ override has(key: K) {
+ return this.data.has(key);
+ }
+ override get(key: K) {
+ return this.data.get(key);
+ }
+ override set(key: K, value: V): this {
+ this.data.set(key, value);
+ return this;
+ }
+ override delete(key: K) {
+ return this.data.delete(key);
+ }
+ override clear() {
+ this.data.clear();
+ }
+ override keys(): MapIterator {
+ return this.data.keys();
+ }
+ override values(): MapIterator {
+ return this.data.values();
+ }
+ override entries(): MapIterator<[K, V]> {
+ return this.data.entries();
+ }
+ override [Symbol.iterator](): MapIterator<[K, V]> {
+ return this.entries();
+ }
+ override forEach(callback: (value: V, key: K, map: Map) => void, thisArg?: any) {
+ if (typeof callback !== "function") throw new TypeError("callback must be a function");
+ for (const [key, value] of this.entries()) callback.call(thisArg, value, key, this);
+ }
+}
+
+class CollectionSet extends Set {
+ declare private readonly data: CollectionData;
+ constructor(data: CollectionData) {
+ super();
+ Object.defineProperty(this, "data", { value: data });
+ }
+ get [$REFRESH]() {
+ return this.data.store[$REFRESH];
+ }
+ override get size() {
+ return this.data.store.size;
+ }
+ override has(value: T) {
+ return this.data.has(value);
+ }
+ override add(value: T): this {
+ this.data.set(value, true);
+ return this;
+ }
+ override delete(value: T) {
+ return this.data.delete(value);
+ }
+ override clear() {
+ this.data.clear();
+ }
+ override keys(): SetIterator {
+ return this.values();
+ }
+ override values(): SetIterator {
+ return this.data.keys();
+ }
+ override entries(): SetIterator<[T, T]> {
+ return this.data.iterate(value => [value, value]);
+ }
+ override [Symbol.iterator](): SetIterator {
+ return this.values();
+ }
+ override forEach(callback: (value: T, value2: T, set: Set) => void, thisArg?: any) {
+ if (typeof callback !== "function") throw new TypeError("callback must be a function");
+ for (const value of this.values()) callback.call(thisArg, value, value, this);
+ }
+ // Native set algebra reads the receiver's internal [[SetData]]. Our native
+ // superclass is only a facade, so every algebra method needs a real snapshot.
+ override union(other: ReadonlySetLike): Set {
+ return new Set(this).union(other);
+ }
+ override intersection(other: ReadonlySetLike): Set {
+ return new Set(this).intersection(other);
+ }
+ override difference(other: ReadonlySetLike): Set {
+ return new Set(this).difference(other);
+ }
+ override symmetricDifference(other: ReadonlySetLike): Set {
+ return new Set(this).symmetricDifference(other);
+ }
+ override isSubsetOf(other: ReadonlySetLike): boolean {
+ return new Set(this).isSubsetOf(other);
+ }
+ override isSupersetOf(other: ReadonlySetLike): boolean {
+ return new Set(this).isSupersetOf(other);
+ }
+ override isDisjointFrom(other: ReadonlySetLike): boolean {
+ return new Set(this).isDisjointFrom(other);
+ }
+}
+
+function createCollection(
+ input: Iterable> | ReactiveCollectionCompute,
+ options: ReactiveCollectionComputeOptions | undefined,
+ view: (data: CollectionData) => Draft,
+ entries: (result: Result) => Iterable>,
+): Refreshable {
+ const slot = keyEncoder();
+ let nextOrdinal = 0;
+ const ordinal = (root?: Order) => {
+ if (root) {
+ while (root.right) root = root.right;
+ nextOrdinal = Math.max(nextOrdinal, root.id + 1);
+ }
+ if (nextOrdinal === Number.MAX_SAFE_INTEGER)
+ throw new RangeError("Collection insertion limit exceeded");
+ return nextOrdinal++;
+ };
+ const seed: State = { order: undefined, size: 0 };
+ const factory = options?.optimistic ? createOptimisticStore : createStore;
+ const loading = options?.loadingValue !== undefined;
+ const storeOptions = {
+ name: options?.name,
+ shallow: true,
+ key: null,
+ seedLoadingValue: loading,
+ deferStream: options?.deferStream,
+ ssrSource: options?.ssrSource,
+ };
+ let state: State, write: StoreSetter;
+ if (typeof input === "function") {
+ if (loading)
+ new CollectionData(
+ seed,
+ fn => {
+ fn(seed);
+ },
+ slot,
+ ordinal,
+ ).replace(entries(options!.loadingValue!));
+ [state, write] = factory(
+ draft => {
+ const data = new CollectionData(
+ draft,
+ fn => {
+ fn(draft);
+ },
+ slot,
+ ordinal,
+ );
+ const collection = view(data);
+ const commit = (result: void | Result) => {
+ if (result !== undefined && result !== (collection as unknown))
+ data.replace(entries(result));
+ };
+ const result = input(collection);
+ if (
+ result != null &&
+ typeof (result as AsyncIterable)[Symbol.asyncIterator] === "function"
+ ) {
+ return (async function* () {
+ // The surrounding projection's draft guards each operation against
+ // supersession, including producer mutations after awaits/yields.
+ let yielded = false,
+ revision = data.revision;
+ for await (const value of result as AsyncIterable) {
+ commit(value);
+ yielded = true;
+ revision = data.revision;
+ yield;
+ }
+ // The store's SSR patch stream flushes on yields. Ensure terminal
+ // draft edits (and a producer that yielded nothing) cross the wire.
+ if (!yielded || revision !== data.revision) yield;
+ })();
+ }
+ if (result != null && typeof (result as PromiseLike).then === "function") {
+ return Promise.resolve(result as PromiseLike).then(commit);
+ }
+ commit(result as void | Result);
+ },
+ seed,
+ storeOptions,
+ );
+ } else {
+ new CollectionData(
+ seed,
+ fn => {
+ fn(seed);
+ },
+ slot,
+ ordinal,
+ ).replace(input);
+ [state, write] = factory(seed, storeOptions);
+ }
+ return view(new CollectionData(state, write, slot, ordinal)) as Refreshable;
+}
+
+/**
+ * Create a shallow reactive Map with lazy, independent presence/value tracking.
+ * Compute functions receive a writable draft and may mutate, return a Map,
+ * await a result, or yield replacements. The default initial draft is empty.
+ * External writes follow Solid's batching; draft writes are immediately readable.
+ * Compute-backed maps support Solid's refresh(), loading, and hydration protocols.
+ */
+export function createReactiveMap(
+ value: ReactiveCollectionCompute, ReadonlyMap>,
+ options?: CreateReactiveMapOptions,
+): Refreshable>;
+export function createReactiveMap(
+ value?: Iterable,
+ options?: ReactiveCollectionOptions,
+): Map;
+export function createReactiveMap(
+ value: Iterable | ReactiveCollectionCompute, ReadonlyMap> = [],
+ options?: CreateReactiveMapOptions,
+): Refreshable> {
+ return createCollection(
+ value,
+ options,
+ data => new CollectionMap(data),
+ map => map.entries(),
+ );
+}
+
+/**
+ * Create a shallow reactive Set with a separate lazy membership node per value.
+ * Compute functions receive a writable draft and may mutate, return a Set,
+ * await a result, or yield replacements. The default initial draft is empty.
+ * External writes follow Solid's batching; draft writes are immediately readable.
+ * Compute-backed sets support Solid's refresh(), loading, and hydration protocols.
+ */
+export function createReactiveSet(
+ value: ReactiveCollectionCompute, ReadonlySet>,
+ options?: CreateReactiveSetOptions,
+): Refreshable>;
+export function createReactiveSet(
+ value?: Iterable,
+ options?: ReactiveCollectionOptions,
+): Set;
+export function createReactiveSet(
+ value: Iterable | ReactiveCollectionCompute, ReadonlySet> = [],
+ options?: CreateReactiveSetOptions,
+): Refreshable> {
+ const entries = (set: Iterable): Iterable> =>
+ (function* () {
+ for (const key of set) yield [key, true] as const;
+ })();
+ return createCollection(
+ typeof value === "function" ? value : entries(value),
+ options,
+ data => new CollectionSet(data),
+ entries,
+ );
+}
diff --git a/packages/collections/src/order.ts b/packages/collections/src/order.ts
new file mode 100644
index 000000000..7a0da018a
--- /dev/null
+++ b/packages/collections/src/order.ts
@@ -0,0 +1,88 @@
+/** An immutable AVL index. Historical cursors retain slot names, not user keys. */
+export interface OrderNode {
+ readonly id: number;
+ readonly slot: string;
+ readonly left: Order;
+ readonly right: Order;
+ readonly height: number;
+}
+export type Order = OrderNode | undefined;
+
+const height = (node: Order): number => node?.height ?? 0;
+const node = (id: number, slot: string, left: Order, right: Order): OrderNode => ({
+ id,
+ slot,
+ left,
+ right,
+ height: 1 + Math.max(height(left), height(right)),
+});
+function balance(id: number, slot: string, left: Order, right: Order): OrderNode {
+ if (height(left) > height(right) + 1) {
+ const l = left!;
+ if (height(l.left) >= height(l.right))
+ return node(l.id, l.slot, l.left, node(id, slot, l.right, right));
+ const middle = l.right!;
+ return node(
+ middle.id,
+ middle.slot,
+ node(l.id, l.slot, l.left, middle.left),
+ node(id, slot, middle.right, right),
+ );
+ }
+ if (height(right) > height(left) + 1) {
+ const r = right!;
+ if (height(r.right) >= height(r.left))
+ return node(r.id, r.slot, node(id, slot, left, r.left), r.right);
+ const middle = r.left!;
+ return node(
+ middle.id,
+ middle.slot,
+ node(id, slot, left, middle.left),
+ node(r.id, r.slot, middle.right, r.right),
+ );
+ }
+ return node(id, slot, left, right);
+}
+export function insert(root: Order, id: number, slot: string): OrderNode {
+ if (!root) return node(id, slot, undefined, undefined);
+ // Ordinals always increase, including across rollback: only append is needed.
+ return balance(root.id, root.slot, root.left, insert(root.right, id, slot));
+}
+export function remove(root: OrderNode, id: number): Order {
+ if (id < root.id) return balance(root.id, root.slot, remove(root.left!, id), root.right);
+ if (id > root.id) return balance(root.id, root.slot, root.left, remove(root.right!, id));
+ if (!root.left) return root.right;
+ if (!root.right) return root.left;
+ let next = root.right;
+ while (next.left) next = next.left;
+ return balance(next.id, next.slot, root.left, remove(root.right, next.id));
+}
+export function build(
+ entries: readonly { id: number; slot: string }[],
+ start: number = 0,
+ end: number = entries.length,
+): Order {
+ if (start === end) return undefined;
+ const middle = (start + end) >>> 1;
+ const entry = entries[middle]!;
+ return node(entry.id, entry.slot, build(entries, start, middle), build(entries, middle + 1, end));
+}
+/** Seek strictly after a consumed ordinal; subsequent pops walk in O(1) amortized. */
+export function seek(root: Order, after: number, stack: OrderNode[]): void {
+ stack.length = 0;
+ while (root) {
+ if (root.id > after) {
+ stack.push(root);
+ root = root.left;
+ } else root = root.right;
+ }
+}
+export function advance(stack: OrderNode[]): OrderNode | undefined {
+ const current = stack.pop();
+ let next = current?.right;
+ while (next) {
+ stack.push(next);
+ next = next.left;
+ }
+ return current;
+}
diff --git a/packages/collections/src/serialization.ts b/packages/collections/src/serialization.ts
new file mode 100644
index 000000000..1676a8d9c
--- /dev/null
+++ b/packages/collections/src/serialization.ts
@@ -0,0 +1,35 @@
+// Optional server/transport entry: importing the token runtime does not import the serializer.
+import { createPlugin, type SerovalNode, type SerializerPlugin } from "@solidjs/web/serialization";
+import {
+ isWeakCollectionToken,
+ createWeakCollectionToken,
+ type WeakCollectionToken,
+} from "./weak-token.js";
+
+function snapshot(token: WeakCollectionToken): [] | [WeakKey, unknown] {
+ const key = token.ref?.deref();
+ return key === undefined ? [] : [key, token.values.get(key)];
+}
+
+function restore(entry: [] | [WeakKey, unknown]): WeakCollectionToken {
+ return entry.length
+ ? createWeakCollectionToken(entry[0], entry[1])
+ : Object.freeze({ $weak: true, ref: undefined, values: new WeakMap() });
+}
+
+/** Pass to the renderer's `plugins` option; JSON codecs need it on both peers. */
+export const WeakCollectionTokenPlugin: SerializerPlugin<
+ WeakCollectionToken,
+ { entry: SerovalNode }
+> = createPlugin({
+ tag: "@solid-primitives/weak-collection-token/v1",
+ test: isWeakCollectionToken,
+ parse: {
+ sync: (token, context) => ({ entry: context.parse(snapshot(token)) }),
+ stream: (token, context) => ({ entry: context.parse(snapshot(token)) }),
+ async: async (token, context) => ({ entry: await context.parse(snapshot(token)) }),
+ },
+ serialize: (node, context) =>
+ `(function(e){return Object.freeze({$weak:true,ref:e.length?new WeakRef(e[0]):void 0,values:new WeakMap(e.length?[e]:[])})})(${context.serialize(node.entry)})`,
+ deserialize: (node, context) => restore(context.deserialize(node.entry)),
+});
diff --git a/packages/collections/src/weak-token.ts b/packages/collections/src/weak-token.ts
new file mode 100644
index 000000000..b51d1712a
--- /dev/null
+++ b/packages/collections/src/weak-token.ts
@@ -0,0 +1,37 @@
+/** Experimental ephemeron leaf: holding this token does not retain its key/value. */
+export interface WeakCollectionToken {
+ readonly $weak: true;
+ readonly ref: WeakRef | undefined;
+ readonly values: WeakMap;
+}
+
+export function createWeakCollectionToken(
+ key: K,
+ value: V,
+): WeakCollectionToken {
+ return Object.freeze({
+ $weak: true as const,
+ ref: new WeakRef(key),
+ values: new WeakMap([[key, value]]),
+ });
+}
+
+export function readWeakCollectionToken(
+ token: WeakCollectionToken,
+ key: K,
+): V | undefined {
+ return token.values.get(key);
+}
+
+export function isWeakCollectionToken(
+ value: unknown,
+): value is WeakCollectionToken {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ (value as WeakCollectionToken).$weak === true &&
+ ((value as WeakCollectionToken).ref === undefined ||
+ (value as WeakCollectionToken).ref instanceof WeakRef) &&
+ (value as WeakCollectionToken).values instanceof WeakMap
+ );
+}
diff --git a/packages/collections/src/weak.ts b/packages/collections/src/weak.ts
new file mode 100644
index 000000000..92be40c71
--- /dev/null
+++ b/packages/collections/src/weak.ts
@@ -0,0 +1,390 @@
+/** Weak entries stored as ephemeron tokens in independent shallow store slots. */
+import { $REFRESH, type Refreshable, type StoreSetter } from "@solidjs/signals";
+import {
+ createStore,
+ createOptimisticStore,
+ createSignal,
+ getOwner,
+ onCleanup,
+ untrack,
+} from "solid-js";
+import { isServer } from "@solidjs/web";
+import { createWeakCollectionToken, type WeakCollectionToken } from "./weak-token.js";
+import type { ReactiveCollectionOptions, ReactiveCollectionCompute } from "./index.js";
+
+type Pair = readonly [K, V];
+type State = Record & { keys: object };
+export interface CreateReactiveWeakMapOptions<
+ K extends WeakKey,
+ V,
+> extends ReactiveCollectionOptions {
+ loadingValue?: Iterable>;
+ deferStream?: boolean;
+ ssrSource?: "server" | "hybrid" | "client";
+}
+export interface CreateReactiveWeakSetOptions extends ReactiveCollectionOptions {
+ loadingValue?: Iterable;
+ deferStream?: boolean;
+ ssrSource?: "server" | "hybrid" | "client";
+}
+export type CreateReactiveWeakMapComputeFunction = ReactiveCollectionCompute<
+ WeakMap,
+ Iterable>
+>;
+export type CreateReactiveWeakSetComputeFunction = ReactiveCollectionCompute<
+ WeakSet,
+ Iterable
+>;
+
+const validKey = (key: unknown): key is WeakKey =>
+ (typeof key === "object" && key !== null) ||
+ typeof key === "function" ||
+ (typeof key === "symbol" && Symbol.keyFor(key) === undefined);
+const read = (
+ token: WeakCollectionToken | undefined,
+ key: K,
+): V | undefined => token?.values.get(key);
+
+class KeySlots {
+ private aliases = new WeakMap();
+ private seen = new WeakSet();
+ private next = 0;
+ private dead: string[] = [];
+ maintenance: (() => void) | undefined;
+ private registry = new FinalizationRegistry(slot => {
+ this.dead.push(slot);
+ this.maintenance?.();
+ });
+
+ private register(key: K, slot: string): void {
+ const slots = this.aliases.get(key);
+ if (slots?.includes(slot)) return;
+ if (slots) slots.push(slot);
+ else this.aliases.set(key, [slot]);
+ this.registry.register(key, slot);
+ }
+
+ slot(key: K, state: State): string {
+ return untrack(() => {
+ const marker = state.keys;
+ if (!this.seen.has(marker)) {
+ for (const prop of Object.keys(state))
+ if (prop.startsWith("k:")) {
+ const key = (state[prop] as WeakCollectionToken).ref?.deref();
+ if (key !== undefined) this.register(key, prop.slice(2));
+ }
+ this.seen.add(marker);
+ }
+ const aliases = this.aliases.get(key);
+ if (aliases)
+ return aliases.find(slot => state["k:" + slot]?.ref?.deref() === key) ?? aliases.at(-1)!;
+ const slot = "o:" + (isServer ? "s" : "c") + this.next++;
+ this.register(key, slot);
+ return slot;
+ });
+ }
+
+ drain(state: State): void {
+ // Finalizers never invoke a derived setter: that would cancel a queued
+ // compute. Empty tokens are pruned at the next ordinary write/derive.
+ if (!this.dead.length) return;
+ for (const slot of this.dead) {
+ delete state[slot];
+ delete state["k:" + slot];
+ }
+ this.dead.length = 0;
+ state.keys = Object.freeze({});
+ }
+}
+
+class WeakData {
+ revision = 0;
+ constructor(
+ readonly state: State,
+ readonly write: StoreSetter,
+ readonly slots: KeySlots,
+ readonly publishKeys = false,
+ readonly maintain = true,
+ ) {}
+ has(key: K): boolean {
+ // Read a harmless store property even for invalid keys so pending/error
+ // behavior is consistent with other reads of a computed collection.
+ if (!validKey(key)) {
+ void this.state.keys;
+ return false;
+ }
+ return this.slots.slot(key, this.state) in this.state;
+ }
+ get(key: K): V | undefined {
+ if (!validKey(key)) {
+ void this.state.keys;
+ return undefined;
+ }
+ return read(this.state[this.slots.slot(key, this.state)], key);
+ }
+ set(key: K, value: V): void {
+ if (!validKey(key)) throw new TypeError("Invalid weak collection key");
+ this.write(state => {
+ if (this.maintain) this.slots.drain(state);
+ const slot = this.slots.slot(key, state);
+ const present = slot in state;
+ if (!present) {
+ state["k:" + slot] = createWeakCollectionToken(key, key);
+ if (this.publishKeys) state.keys = Object.freeze({});
+ }
+ if (!present || !Object.is(read(state[slot], key), value)) {
+ state[slot] = value === undefined ? undefined : createWeakCollectionToken(key, value);
+ this.revision++;
+ }
+ });
+ }
+ delete(key: K): boolean {
+ if (!validKey(key)) return false;
+ let deleted = false;
+ this.write(state => {
+ if (this.maintain) this.slots.drain(state);
+ const slot = this.slots.slot(key, state);
+ if (!(slot in state)) return;
+ delete state[slot];
+ delete state["k:" + slot];
+ if (this.publishKeys) state.keys = Object.freeze({});
+ this.revision++;
+ deleted = true;
+ });
+ return deleted;
+ }
+ replace(source: Iterable>): void {
+ if (typeof source?.[Symbol.iterator] !== "function")
+ throw new TypeError(
+ "Weak collection replacements must be iterable; mutate the draft for native WeakMap/WeakSet inputs",
+ );
+ const incoming = new Map(source);
+ for (const key of incoming.keys())
+ if (!validKey(key)) throw new TypeError("Invalid weak collection key");
+ this.write(state => {
+ if (this.maintain) this.slots.drain(state);
+ for (const prop of Object.keys(state))
+ if (prop.startsWith("k:")) {
+ const key = (state[prop] as WeakCollectionToken).ref?.deref();
+ if (key === undefined || !incoming.has(key)) {
+ delete state[prop.slice(2)];
+ delete state[prop];
+ if (this.publishKeys) state.keys = Object.freeze({});
+ this.revision++;
+ }
+ }
+ const draft = new WeakData(
+ state,
+ fn => {
+ fn(state);
+ },
+ this.slots,
+ this.publishKeys,
+ false,
+ );
+ for (const [key, value] of incoming) draft.set(key, value);
+ this.revision += draft.revision;
+ });
+ }
+}
+
+// Facades live outside factory closures so initial iterables are never retained
+// through a shared closure environment. Native intrinsics bypass these methods.
+class WeakMapView extends WeakMap {
+ declare private data: WeakData;
+ constructor(data: WeakData) {
+ super();
+ Object.defineProperty(this, "data", { value: data });
+ Object.defineProperty(this, $REFRESH, { get: () => (data.state as any)[$REFRESH] });
+ }
+ override has(key: K): boolean {
+ return this.data.has(key);
+ }
+ override get(key: K): V | undefined {
+ return this.data.get(key);
+ }
+ override set(key: K, value: V): this {
+ this.data.set(key, value);
+ return this;
+ }
+ override delete(key: K): boolean {
+ return this.data.delete(key);
+ }
+}
+class WeakSetView extends WeakSet {
+ declare private data: WeakData;
+ constructor(data: WeakData) {
+ super();
+ Object.defineProperty(this, "data", { value: data });
+ Object.defineProperty(this, $REFRESH, { get: () => (data.state as any)[$REFRESH] });
+ }
+ override has(key: K): boolean {
+ return this.data.has(key);
+ }
+ override add(key: K): this {
+ this.data.set(key, true);
+ return this;
+ }
+ override delete(key: K): boolean {
+ return this.data.delete(key);
+ }
+}
+function* setEntries(values: Iterable): Generator> {
+ for (const key of values) yield [key, true];
+}
+
+// Static optimism has no user derive in which to prune dead properties. A
+// private maintenance projection provides an authoritative cleanup channel;
+// ordinary optimistic setters would roll that cleanup back. Keep this helper
+// outside the input factory's closure environment so it cannot retain entries.
+function createMaintainedStore(
+ seed: State,
+ slots: KeySlots,
+ name?: string,
+): [State, StoreSetter] {
+ const [version, bump] = createSignal(0);
+ const result = createOptimisticStore(
+ draft => {
+ version();
+ slots.drain(draft);
+ },
+ seed,
+ { shallow: true, key: null, name },
+ );
+ slots.maintenance = () => {
+ bump(value => value + 1);
+ };
+ if (getOwner())
+ onCleanup(() => {
+ slots.maintenance = undefined;
+ });
+ return result;
+}
+
+function createWeakCollection(
+ input: Iterable> | ReactiveCollectionCompute,
+ options: CreateReactiveWeakMapOptions | undefined,
+ view: (data: WeakData) => Draft,
+ entries: (result: Result) => Iterable>,
+): Refreshable {
+ const slots = new KeySlots();
+ const seed: State = { keys: Object.freeze({}) };
+ const factory = options?.optimistic ? createOptimisticStore : createStore;
+ const config = {
+ name: options?.name,
+ shallow: true,
+ key: null,
+ seedLoadingValue: options?.loadingValue !== undefined,
+ deferStream: options?.deferStream,
+ ssrSource: options?.ssrSource,
+ };
+ let state: State, write: StoreSetter;
+ if (typeof input === "function") {
+ if (options?.loadingValue)
+ new WeakData(
+ seed,
+ fn => {
+ fn(seed);
+ },
+ slots,
+ ).replace(options.loadingValue);
+ [state, write] = factory(
+ draft => {
+ slots.drain(draft);
+ const data = new WeakData(
+ draft,
+ fn => {
+ fn(draft);
+ },
+ slots,
+ true,
+ false,
+ );
+ const collection = view(data);
+ const commit = (result: void | Result) => {
+ if (result !== undefined && result !== (collection as unknown))
+ data.replace(entries(result));
+ };
+ const result = input(collection);
+ if (
+ result != null &&
+ typeof (result as AsyncIterable)[Symbol.asyncIterator] === "function"
+ ) {
+ return (async function* () {
+ let yielded = false,
+ revision = data.revision;
+ for await (const value of result as AsyncIterable) {
+ commit(value);
+ yielded = true;
+ revision = data.revision;
+ yield;
+ }
+ if (!yielded || data.revision !== revision) yield;
+ })();
+ }
+ if (result != null && typeof (result as PromiseLike).then === "function")
+ return Promise.resolve(result as PromiseLike).then(commit);
+ commit(result as void | Result);
+ },
+ seed,
+ config,
+ );
+ } else {
+ new WeakData(
+ seed,
+ fn => {
+ fn(seed);
+ },
+ slots,
+ ).replace(input);
+ [state, write] = options?.optimistic
+ ? createMaintainedStore(seed, slots, options.name)
+ : factory(seed, config);
+ }
+ return view(
+ new WeakData(state, write, slots, false, !options?.optimistic),
+ ) as Refreshable;
+}
+
+export function createReactiveWeakMap(
+ input: CreateReactiveWeakMapComputeFunction,
+ options?: CreateReactiveWeakMapOptions,
+): Refreshable>;
+export function createReactiveWeakMap(
+ input?: Iterable>,
+ options?: ReactiveCollectionOptions,
+): WeakMap;
+export function createReactiveWeakMap(
+ input: Iterable> | CreateReactiveWeakMapComputeFunction = [],
+ options?: CreateReactiveWeakMapOptions,
+): Refreshable> {
+ return createWeakCollection(
+ input,
+ options,
+ data => new WeakMapView(data),
+ entries => entries,
+ );
+}
+
+export function createReactiveWeakSet(
+ input: CreateReactiveWeakSetComputeFunction,
+ options?: CreateReactiveWeakSetOptions,
+): Refreshable>;
+export function createReactiveWeakSet(
+ input?: Iterable,
+ options?: ReactiveCollectionOptions,
+): WeakSet;
+export function createReactiveWeakSet(
+ input: Iterable | CreateReactiveWeakSetComputeFunction = [],
+ options?: CreateReactiveWeakSetOptions,
+): Refreshable> {
+ return createWeakCollection(
+ typeof input === "function" ? input : setEntries(input),
+ {
+ ...options,
+ loadingValue: options?.loadingValue && setEntries(options.loadingValue),
+ },
+ data => new WeakSetView(data),
+ setEntries,
+ );
+}
diff --git a/packages/collections/stories/examples/map-async.tsx b/packages/collections/stories/examples/map-async.tsx
new file mode 100644
index 000000000..6f4acf90e
--- /dev/null
+++ b/packages/collections/stories/examples/map-async.tsx
@@ -0,0 +1,49 @@
+import { createSignal, For, isPending, Loading } from "solid-js";
+import { createReactiveMap } from "@solid-primitives/collections";
+import { Button, ButtonRow, Card, Container, StatRow } from "../../../../.storybook/ui/index.js";
+import { wait } from "./shared.ts";
+
+export function AsyncMap() {
+ const [warehouse, setWarehouse] = createSignal("North");
+ const stock = createReactiveMap(async () => {
+ // Read dependencies before awaiting so changing the warehouse recomputes the map.
+ const selected = warehouse();
+ await wait();
+ return new Map(
+ selected === "North"
+ ? [
+ ["Apples", 12],
+ ["Pears", 5],
+ ]
+ : [
+ ["Apples", 3],
+ ["Plums", 8],
+ ],
+ );
+ });
+ return (
+
+
+
+ {name => (
+ setWarehouse(name)}
+ >
+ {name} warehouse
+
+ )}
+
+
+ Fetching inventory…}>
+
+ {isPending(() => stock.size) ? "Loading inventory…" : `${warehouse()} inventory ready`}
+
+
+ {([name, count]) => }
+
+
+
+
+ );
+}
diff --git a/packages/collections/stories/examples/map-basic.tsx b/packages/collections/stories/examples/map-basic.tsx
new file mode 100644
index 000000000..804bdf3ae
--- /dev/null
+++ b/packages/collections/stories/examples/map-basic.tsx
@@ -0,0 +1,31 @@
+import { For } from "solid-js";
+import { createReactiveMap } from "@solid-primitives/collections";
+import { Button, ButtonRow, Card, Container, StatRow } from "../../../../.storybook/ui/index.js";
+
+export function BasicMap() {
+ const players = ["Alice", "Bob", "Carol"];
+ const scores = createReactiveMap(players.map(name => [name, 0] as const));
+ return (
+
+
+ {name => (
+
+
+
+ scores.set(name, (scores.get(name) ?? 0) + 1)}>
+ Add point to {name}
+
+ scores.delete(name)}>
+ Remove {name}
+
+
+
+ )}
+
+
+ scores.clear()}>
+ Clear scores
+
+
+ );
+}
diff --git a/packages/collections/stories/examples/map-optimistic.tsx b/packages/collections/stories/examples/map-optimistic.tsx
new file mode 100644
index 000000000..2a14f978e
--- /dev/null
+++ b/packages/collections/stories/examples/map-optimistic.tsx
@@ -0,0 +1,59 @@
+import { action, createSignal, latest, Loading, refresh } from "solid-js";
+import { createReactiveMap } from "@solid-primitives/collections";
+import { Button, ButtonRow, Card, Container, StatRow } from "../../../../.storybook/ui/index.js";
+import { wait } from "./shared.ts";
+
+export function OptimisticMap() {
+ // This ordinary object stands in for server state; refresh() asks for it again.
+ const server = new Map([["Alice", 0]]);
+ const scores = createReactiveMap(
+ async () => {
+ await wait();
+ return new Map(server);
+ },
+ { optimistic: true },
+ );
+ const [saving, setSaving] = createSignal(false);
+ const [message, setMessage] = createSignal(
+ "Choose whether the simulated server accepts the next point.",
+ );
+ const save = action(function* (accept: boolean) {
+ const next = (scores.get("Alice") ?? 0) + 1;
+ scores.set("Alice", next);
+ yield wait(900);
+ if (accept) server.set("Alice", next);
+ // Keep the optimistic write visible while the authoritative result is fetched.
+ yield refresh(scores);
+ });
+ const submit = async (accept: boolean) => {
+ setSaving(true);
+ setMessage("Saving… the point is visible immediately.");
+ try {
+ await save(accept);
+ setMessage(accept ? "Accepted: point saved." : "Rejected: restored the server score.");
+ } catch (error) {
+ setMessage(`Save failed: ${error instanceof Error ? error.message : String(error)}`);
+ } finally {
+ setSaving(false);
+ }
+ };
+ return (
+
+ Fetching score…}>
+
+
+
+
+ void submit(true)}>
+ Accept +1
+
+ void submit(false)}>
+ Reject +1
+
+
+
+ {/* Pending UI reads the latest state while the action holds committed reads. */}
+ {latest(message)}
+
+ );
+}
diff --git a/packages/collections/stories/examples/map-streaming.tsx b/packages/collections/stories/examples/map-streaming.tsx
new file mode 100644
index 000000000..28c1fd136
--- /dev/null
+++ b/packages/collections/stories/examples/map-streaming.tsx
@@ -0,0 +1,34 @@
+import { createSignal, For, Loading } from "solid-js";
+import { createReactiveMap } from "@solid-primitives/collections";
+import { Button, Card, Container, StatRow } from "../../../../.storybook/ui/index.js";
+import { wait } from "./shared.ts";
+
+export function StreamingMap() {
+ const [round, setRound] = createSignal(1);
+ const scores = createReactiveMap(async function* (draft) {
+ const currentRound = round();
+ draft.clear();
+ yield;
+ for (const [index, name] of ["Alice", "Bob", "Carol"].entries()) {
+ await wait(500);
+ draft.set(name, currentRound * 10 + index);
+ yield;
+ }
+ });
+ return (
+
+
+ setRound(value => value + 1)}>Start next round
+ Opening score stream…}>
+
+ Waiting for the first score…}>
+ {([name, score]) => }
+
+
+
+ {scores.size === 3 ? "All scores received" : `Received ${scores.size} of 3 scores`}
+
+
+
+ );
+}
diff --git a/packages/collections/stories/examples/map-weak.tsx b/packages/collections/stories/examples/map-weak.tsx
new file mode 100644
index 000000000..283c23e89
--- /dev/null
+++ b/packages/collections/stories/examples/map-weak.tsx
@@ -0,0 +1,51 @@
+import { createSignal, For } from "solid-js";
+import { createReactiveWeakMap } from "@solid-primitives/collections";
+import {
+ Button,
+ ButtonRow,
+ Card,
+ Container,
+ Section,
+ StatRow,
+} from "../../../../.storybook/ui/index.js";
+
+export function WeakMapExample() {
+ const [people, setPeople] = createSignal([{ name: "Alice" }, { name: "Bob" }]);
+ const notes = createReactiveWeakMap();
+ return (
+
+ Notes belong to an object, not its name. Replacing a person creates a new key.
+
+ {person => (
+
+
+
+ notes.set(person, "Ready to review")}>
+ Annotate {person.name}
+
+ notes.delete(person)}>
+ Forget {person.name}
+
+
+ setPeople(list =>
+ list.map(item => (item === person ? { name: person.name } : item)),
+ )
+ }
+ >
+ Replace {person.name}
+
+
+
+ )}
+
+
+
+ The separate people list supplies the rows. WeakMap has no size or iterator and does not
+ keep discarded keys alive. Garbage collection timing is not observable here.
+
+
+
+ );
+}
diff --git a/packages/collections/stories/examples/set-async.tsx b/packages/collections/stories/examples/set-async.tsx
new file mode 100644
index 000000000..2a8c56e89
--- /dev/null
+++ b/packages/collections/stories/examples/set-async.tsx
@@ -0,0 +1,46 @@
+import { createSignal, For, isPending, Loading } from "solid-js";
+import { createReactiveSet } from "@solid-primitives/collections";
+import {
+ BoolRow,
+ Button,
+ ButtonRow,
+ Card,
+ Container,
+ StatRow,
+} from "../../../../.storybook/ui/index.js";
+import { wait } from "./shared.ts";
+
+export function AsyncSet() {
+ const [role, setRole] = createSignal("Reader");
+ const permissions = createReactiveSet(async () => {
+ const selected = role();
+ await wait();
+ return new Set(selected === "Reader" ? ["Read"] : ["Read", "Write", "Publish"]);
+ });
+ return (
+
+
+
+ {name => (
+ setRole(name)}>
+ {name}
+
+ )}
+
+
+ Fetching permissions…}>
+
+ {isPending(() => permissions.size)
+ ? "Loading permissions…"
+ : `${role()} permissions ready`}
+
+
+
+ {name => }
+
+
+
+
+
+ );
+}
diff --git a/packages/collections/stories/examples/set-basic.tsx b/packages/collections/stories/examples/set-basic.tsx
new file mode 100644
index 000000000..a3626fd4c
--- /dev/null
+++ b/packages/collections/stories/examples/set-basic.tsx
@@ -0,0 +1,30 @@
+import { For } from "solid-js";
+import { createReactiveSet } from "@solid-primitives/collections";
+import { BoolRow, Button, Card, Container, StatRow } from "../../../../.storybook/ui/index.js";
+
+export function BasicSet() {
+ const fruits = ["Apple", "Pear", "Plum"];
+ const selected = createReactiveSet(["Apple"]);
+ return (
+
+
+ {fruit => (
+
+
+ (selected.has(fruit) ? selected.delete(fruit) : selected.add(fruit))}
+ >
+ Toggle {fruit}
+
+
+ )}
+
+
+
+ selected.clear()}>
+ Clear selection
+
+
+ );
+}
diff --git a/packages/collections/stories/examples/set-optimistic.tsx b/packages/collections/stories/examples/set-optimistic.tsx
new file mode 100644
index 000000000..fb214214c
--- /dev/null
+++ b/packages/collections/stories/examples/set-optimistic.tsx
@@ -0,0 +1,69 @@
+import { action, createSignal, latest, Loading, refresh } from "solid-js";
+import { createReactiveSet } from "@solid-primitives/collections";
+import {
+ BoolRow,
+ Button,
+ ButtonRow,
+ Card,
+ Container,
+ StatRow,
+} from "../../../../.storybook/ui/index.js";
+import { wait } from "./shared.ts";
+
+export function OptimisticSet() {
+ const server = new Set();
+ const favorites = createReactiveSet(
+ async () => {
+ await wait();
+ return new Set(server);
+ },
+ { optimistic: true },
+ );
+ const [saving, setSaving] = createSignal(false);
+ const [message, setMessage] = createSignal(
+ "Choose whether the simulated server accepts the next toggle.",
+ );
+ const save = action(function* (accept: boolean) {
+ const add = !favorites.has("Apple");
+ if (add) favorites.add("Apple");
+ else favorites.delete("Apple");
+ yield wait(900);
+ if (accept) {
+ if (add) server.add("Apple");
+ else server.delete("Apple");
+ }
+ yield refresh(favorites);
+ });
+ const submit = async (accept: boolean) => {
+ setSaving(true);
+ setMessage("Saving… the favorite changes immediately.");
+ try {
+ await save(accept);
+ setMessage(accept ? "Accepted: favorite saved." : "Rejected: restored the server selection.");
+ } catch (error) {
+ setMessage(`Save failed: ${error instanceof Error ? error.message : String(error)}`);
+ } finally {
+ setSaving(false);
+ }
+ };
+ return (
+
+ Fetching favorites…}>
+
+
+
+
+
+ void submit(true)}>
+ Accept toggle
+
+ void submit(false)}>
+ Reject toggle
+
+
+
+ {/* Pending UI reads the latest state while the action holds committed reads. */}
+ {latest(message)}
+
+ );
+}
diff --git a/packages/collections/stories/examples/set-streaming.tsx b/packages/collections/stories/examples/set-streaming.tsx
new file mode 100644
index 000000000..8df758435
--- /dev/null
+++ b/packages/collections/stories/examples/set-streaming.tsx
@@ -0,0 +1,45 @@
+import { createSignal, For, Loading } from "solid-js";
+import { createReactiveSet } from "@solid-primitives/collections";
+import { Button, ButtonRow, Card, Container } from "../../../../.storybook/ui/index.js";
+import { wait } from "./shared.ts";
+
+export function StreamingSet() {
+ const [region, setRegion] = createSignal("Europe");
+ const cities = createReactiveSet(async function* (draft) {
+ const names =
+ region() === "Europe" ? ["Paris", "Berlin", "Oslo"] : ["Tokyo", "Seoul", "Taipei"];
+ draft.clear();
+ yield;
+ for (const name of names) {
+ await wait(500);
+ draft.add(name);
+ yield;
+ }
+ });
+ return (
+
+
+
+ {name => (
+ setRegion(name)}
+ >
+ {name}
+
+ )}
+
+
+ Opening city stream…}>
+
+ Waiting for the first city…}>
+ {city => {city}
}
+
+
+
+ {cities.size === 3 ? "All cities received" : `Received ${cities.size} of 3 cities`}
+
+
+
+ );
+}
diff --git a/packages/collections/stories/examples/set-weak.tsx b/packages/collections/stories/examples/set-weak.tsx
new file mode 100644
index 000000000..3a4e1a8c1
--- /dev/null
+++ b/packages/collections/stories/examples/set-weak.tsx
@@ -0,0 +1,52 @@
+import { createSignal, For } from "solid-js";
+import { createReactiveWeakSet } from "@solid-primitives/collections";
+import {
+ BoolRow,
+ Button,
+ ButtonRow,
+ Card,
+ Container,
+ Section,
+} from "../../../../.storybook/ui/index.js";
+
+export function WeakSetExample() {
+ const [documents, setDocuments] = createSignal([{ name: "Proposal" }, { name: "Notes" }]);
+ const reviewed = createReactiveWeakSet();
+ return (
+
+
+ Review status belongs to each document object. A replacement needs a new review, even with
+ the same name.
+
+
+ {document => (
+
+
+
+ reviewed.add(document)}>Review {document.name}
+ reviewed.delete(document)}>
+ Unreview {document.name}
+
+
+ setDocuments(list =>
+ list.map(item => (item === document ? { name: document.name } : item)),
+ )
+ }
+ >
+ Replace {document.name}
+
+
+
+ )}
+
+
+
+ The document list supplies the rows; WeakSet cannot be enumerated. Discarded document
+ objects can be collected when nothing else retains them.
+
+
+
+ );
+}
diff --git a/packages/collections/stories/examples/shared.ts b/packages/collections/stories/examples/shared.ts
new file mode 100644
index 000000000..f46b0d066
--- /dev/null
+++ b/packages/collections/stories/examples/shared.ts
@@ -0,0 +1,2 @@
+/** Simulated network latency; the examples do not contact a server. */
+export const wait = (ms = 600): Promise => new Promise(resolve => setTimeout(resolve, ms));
diff --git a/packages/collections/stories/map.stories.tsx b/packages/collections/stories/map.stories.tsx
new file mode 100644
index 000000000..8c583950f
--- /dev/null
+++ b/packages/collections/stories/map.stories.tsx
@@ -0,0 +1,92 @@
+import preview from "../../../.storybook/preview.js";
+import readme from "../README.md?raw";
+import { BasicMap } from "./examples/map-basic.js";
+import BasicMapSource from "./examples/map-basic.tsx?raw";
+import { AsyncMap } from "./examples/map-async.js";
+import AsyncMapSource from "./examples/map-async.tsx?raw";
+import { StreamingMap } from "./examples/map-streaming.js";
+import StreamingMapSource from "./examples/map-streaming.tsx?raw";
+import { OptimisticMap } from "./examples/map-optimistic.js";
+import OptimisticMapSource from "./examples/map-optimistic.tsx?raw";
+import { WeakMapExample } from "./examples/map-weak.js";
+import WeakMapExampleSource from "./examples/map-weak.tsx?raw";
+
+const meta = preview.meta({
+ title: "Reactivity/Collections/Map",
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ docs: { description: { component: readme } },
+ },
+});
+
+export default meta;
+
+export const Basic = meta.story({
+ render: BasicMap,
+ parameters: {
+ docs: {
+ source: { code: BasicMapSource, language: "tsx", type: "code" },
+ description: {
+ story:
+ "A mutable map with independent `get(key)` subscriptions. Add points, delete players, or clear the map; `size` tracks membership.",
+ },
+ },
+ },
+});
+
+export const DerivedAsync = meta.story({
+ name: "Derived async",
+ render: AsyncMap,
+ parameters: {
+ docs: {
+ source: { code: AsyncMapSource, language: "tsx", type: "code" },
+ description: {
+ story:
+ "An async compute function reads the selected warehouse before awaiting a simulated response. `Loading` handles the initial request and `isPending` indicates subsequent requests. Switching warehouses supersedes an older request.",
+ },
+ },
+ },
+});
+
+export const DerivedAsyncIterable = meta.story({
+ name: "Derived async iterable",
+ render: StreamingMap,
+ parameters: {
+ docs: {
+ source: { code: StreamingMapSource, language: "tsx", type: "code" },
+ description: {
+ story:
+ "An async generator clears its draft and yields one score every 500 ms. Starting another round supersedes the previous stream, including any late writes to its draft.",
+ },
+ },
+ },
+});
+
+export const DerivedAsyncOptimistic = meta.story({
+ name: "Derived async + optimistic",
+ render: OptimisticMap,
+ parameters: {
+ docs: {
+ source: { code: OptimisticMapSource, language: "tsx", type: "code" },
+ description: {
+ story:
+ "An async computed map with `optimistic: true`. A Solid `action` shows a point immediately, then waits for a simulated save and `refresh(map)`. Accepted writes persist; rejected writes roll back to the authoritative response.",
+ },
+ },
+ },
+});
+
+export const WeakVariant = meta.story({
+ name: "Weak variant",
+ render: WeakMapExample,
+ parameters: {
+ docs: {
+ source: { code: WeakMapExampleSource, language: "tsx", type: "code" },
+ description: {
+ story:
+ "`createReactiveWeakMap` associates notes with object identities. Replace an object with an identically named one to see that its note does not transfer. An explicit object list supplies the UI rows; the weak map does not expose iteration or size.",
+ },
+ },
+ },
+});
diff --git a/packages/collections/stories/set.stories.tsx b/packages/collections/stories/set.stories.tsx
new file mode 100644
index 000000000..fbd0fcd80
--- /dev/null
+++ b/packages/collections/stories/set.stories.tsx
@@ -0,0 +1,92 @@
+import preview from "../../../.storybook/preview.js";
+import readme from "../README.md?raw";
+import { BasicSet } from "./examples/set-basic.js";
+import BasicSetSource from "./examples/set-basic.tsx?raw";
+import { AsyncSet } from "./examples/set-async.js";
+import AsyncSetSource from "./examples/set-async.tsx?raw";
+import { StreamingSet } from "./examples/set-streaming.js";
+import StreamingSetSource from "./examples/set-streaming.tsx?raw";
+import { OptimisticSet } from "./examples/set-optimistic.js";
+import OptimisticSetSource from "./examples/set-optimistic.tsx?raw";
+import { WeakSetExample } from "./examples/set-weak.js";
+import WeakSetExampleSource from "./examples/set-weak.tsx?raw";
+
+const meta = preview.meta({
+ title: "Reactivity/Collections/Set",
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ docs: { description: { component: readme } },
+ },
+});
+
+export default meta;
+
+export const Basic = meta.story({
+ render: BasicSet,
+ parameters: {
+ docs: {
+ source: { code: BasicSetSource, language: "tsx", type: "code" },
+ description: {
+ story:
+ "Each `has(value)` read tracks its own membership. Toggle fruit, clear the set, and compare those reads with `size` and insertion-order iteration.",
+ },
+ },
+ },
+});
+
+export const DerivedAsync = meta.story({
+ name: "Derived async",
+ render: AsyncSet,
+ parameters: {
+ docs: {
+ source: { code: AsyncSetSource, language: "tsx", type: "code" },
+ description: {
+ story:
+ "An async compute function derives permissions from the selected role. Its dependency is read before awaiting. `Loading` handles the initial request, while `isPending` exposes refreshes caused by changing roles.",
+ },
+ },
+ },
+});
+
+export const DerivedAsyncIterable = meta.story({
+ name: "Derived async iterable",
+ render: StreamingSet,
+ parameters: {
+ docs: {
+ source: { code: StreamingSetSource, language: "tsx", type: "code" },
+ description: {
+ story:
+ "An async generator adds one city every 500 ms and yields its draft edits. Switch regions mid-stream to supersede the old producer and observe the replacement set arrive incrementally.",
+ },
+ },
+ },
+});
+
+export const DerivedAsyncOptimistic = meta.story({
+ name: "Derived async + optimistic",
+ render: OptimisticSet,
+ parameters: {
+ docs: {
+ source: { code: OptimisticSetSource, language: "tsx", type: "code" },
+ description: {
+ story:
+ "An async computed set with `optimistic: true`. A Solid `action` immediately toggles membership and holds the tentative result through a simulated save and `refresh(set)`. Accept a toggle to persist it or reject it to see rollback.",
+ },
+ },
+ },
+});
+
+export const WeakVariant = meta.story({
+ name: "Weak variant",
+ render: WeakSetExample,
+ parameters: {
+ docs: {
+ source: { code: WeakSetExampleSource, language: "tsx", type: "code" },
+ description: {
+ story:
+ "`createReactiveWeakSet` tracks whether particular document objects have been reviewed. Replacing a document creates a new identity with no review. The UI enumerates an explicit list, since weak sets have neither iteration nor size.",
+ },
+ },
+ },
+});
diff --git a/packages/collections/test/integration/collections-hydration.test.tsx b/packages/collections/test/integration/collections-hydration.test.tsx
new file mode 100644
index 000000000..df9ce433f
--- /dev/null
+++ b/packages/collections/test/integration/collections-hydration.test.tsx
@@ -0,0 +1,137 @@
+/** @vitest-environment jsdom */
+import { describe, expect, it, vi } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { flush } from "solid-js";
+import { hydrate } from "@solidjs/web";
+import {
+ CollectionFixture,
+ SetCollectionFixture,
+ current,
+ currentSet,
+ kinds,
+} from "./fixtures/collections.js";
+const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
+async function expectView(container: Element, read: () => string, expected: string) {
+ await vi.waitFor(
+ () => {
+ flush();
+ expect(read()).toBe(expected);
+ expect(container.textContent).toBe(expected + "after");
+ },
+ { timeout: 1500, interval: 10 },
+ );
+}
+function apply(container: Element, chunk: string, first: boolean) {
+ const pattern = /