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 +

+ +# @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 => ( + + )} + + + 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 => ( + + + + + + + + )} + + + + + ); +} 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…}> + + + + + + + + + {/* 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 ( + + + + 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 => ( + + + + + + + + + )} + +
+

+ 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 => ( + + )} + + + 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 => ( + + + + + )} + + + + + + ); +} 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…}> + + + + + + + + + + {/* 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 => ( + + )} + + + 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 => ( + + + + + + + + + )} + +
+

+ 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 = /]*)>([\s\S]*?)<\/script>/g; + const scripts = [...chunk.matchAll(pattern)].map(m => m[1]!); + const markup = chunk.replace(pattern, ""); + if (first) container.innerHTML = markup; + else container.insertAdjacentHTML("beforeend", markup); + for (const script of scripts) (0, eval)(script); +} +describe("real SSR to collection hydration", () => { + for (const kind of kinds) + for (const optimistic of [false, true]) + for (const mode of ["loaded", "streamed"] as const) { + it(`${kind}, optimistic=${optimistic}, ${mode}`, async () => { + const artifact = JSON.parse( + readFileSync( + resolve(`node_modules/.cache/collections-hydration/${kind}-${optimistic}.json`), + "utf8", + ), + ); + const container = document.createElement("div"); + document.body.appendChild(container); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let dispose: (() => void) | undefined; + try { + apply(container, artifact.shell, true); + if (mode === "loaded" && artifact.rest) apply(container, artifact.rest, false); + const section = container.querySelector("section"), + sibling = container.querySelector("b"); + dispose = hydrate( + () => , + container, + ); + flush(); + await sleep(kind.includes("hybrid") ? 50 : 10); + flush(); + if (mode === "streamed" && artifact.rest) apply(container, artifact.rest, false); + const streamed = kind === "stream" || kind.includes("hybrid"); + await expectView( + container, + current.read, + streamed ? "true:10:true:true:2:true" : "true:0:true:true:1:false", + ); + expect(container.querySelector("section")).toBe(section); + expect(container.querySelector("b")).toBe(sibling); + current.update(); + flush(); + const updated = streamed ? "true:11:true:true:2:true" : "true:1:true:true:1:false"; + await expectView( + container, + current.read, + streamed ? "true:11:true:true:2:true" : "true:1:true:true:1:false", + ); + await current.refresh(); + await expectView(container, current.read, updated); + expect(warn).not.toHaveBeenCalled(); + } finally { + dispose?.(); + await sleep(0); + warn.mockRestore(); + container.remove(); + } + }); + } +}); + +describe("real Set hydration", () => { + for (const kind of ["stream", "loading", "client", "hybrid"] as const) + for (const optimistic of [false, true]) + for (const mode of ["loaded", "streamed"]) { + it(`${kind}, optimistic=${optimistic}, ${mode}`, async () => { + const artifact = JSON.parse( + readFileSync( + resolve(`node_modules/.cache/collections-hydration/set-${kind}-${optimistic}.json`), + "utf8", + ), + ); + const container = document.createElement("div"); + document.body.appendChild(container); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let dispose: (() => void) | undefined; + try { + apply(container, artifact.shell, true); + if (mode === "loaded" && artifact.rest) apply(container, artifact.rest, false); + const section = container.querySelector("section"), + sibling = container.querySelector("b"); + dispose = hydrate( + () => , + container, + ); + flush(); + await sleep(10); + flush(); + if (mode === "streamed" && artifact.rest) apply(container, artifact.rest, false); + await expectView(container, currentSet.read, "true:true:true:5:true:true"); + expect(container.querySelector("section")).toBe(section); + expect(container.querySelector("b")).toBe(sibling); + currentSet.update(); + flush(); + await expectView(container, currentSet.read, "true:true:true:5:true:true"); + await currentSet.refresh(); + await expectView(container, currentSet.read, "true:true:true:5:true:true"); + expect(warn).not.toHaveBeenCalled(); + } finally { + dispose?.(); + await sleep(0); + warn.mockRestore(); + container.remove(); + } + }); + } +}); diff --git a/packages/collections/test/integration/collections-order.test.ts b/packages/collections/test/integration/collections-order.test.ts new file mode 100644 index 000000000..175bd44a4 --- /dev/null +++ b/packages/collections/test/integration/collections-order.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { action, createEffect, createRoot, createSignal, flush } from "solid-js"; +import { createReactiveMap, createReactiveSet } from "../../src/index.js"; +import { advance, insert, remove, seek, type Order } from "../../src/order.js"; +const disposers: (() => void)[] = []; +function root(fn: () => T): T { + return createRoot(d => { + disposers.push(d); + return fn(); + }); +} +afterEach(() => { + while (disposers.length) disposers.pop()!(); + flush(); +}); + +describe("ordered collections", () => { + for (const kind of ["keys", "values", "entries"] as const) { + it(`Map.${kind} observes delete, overwrite, append and reinsertion`, () => { + const map = root(() => + createReactiveMap([ + ["a", 1], + ["b", 2], + ["c", 3], + ]), + ); + const native = new Map(map); + const actual = map[kind](), + expected = native[kind](); + expect(actual.next()).toEqual(expected.next()); + for (const m of [map, native]) { + m.delete("b"); + m.set("c", 30); + m.set("d", 4); + m.delete("a"); + m.set("a", 5); + } + flush(); + expect([...actual]).toEqual([...expected]); + }); + } + it("Set iterators observe clear/reinsert and remain done after exhaustion", () => { + const set = root(() => createReactiveSet([1, 2])); + const iterator = set.values(); + expect(iterator.next().value).toBe(1); + set.clear(); + set.add(3); + flush(); + expect([...iterator]).toEqual([3]); + set.add(4); + flush(); + expect(iterator.next().done).toBe(true); + }); + it("tracks consumption, including iterators created outside the observer", () => { + const map = root(() => createReactiveMap([["a", 1]])); + const iterator = map.values(); + let creationRuns = 0, + readRuns = 0; + root(() => + createEffect( + () => { + creationRuns++; + return map.values(); + }, + () => {}, + ), + ); + root(() => + createEffect( + () => { + readRuns++; + return iterator.next(); + }, + () => {}, + ), + ); + flush(); + map.set("a", 2); + flush(); + expect(creationRuns).toBe(1); + expect(readRuns).toBe(2); + }); + it("forEach is live inside a writable draft and validates an empty callback", () => { + const visited: string[] = []; + const map = root(() => + createReactiveMap(draft => { + draft.set("a", 1); + draft.set("b", 2); + draft.forEach((_, key) => { + visited.push(key); + if (key === "a") { + draft.delete("b"); + draft.set("c", 3); + } + }); + }), + ); + expect(visited).toEqual(["a", "c"]); + expect([...map.keys()]).toEqual(visited); + const empty = root(() => createReactiveSet()); + expect(() => empty.forEach(null as any)).toThrow(TypeError); + }); + it("object-key reads do not subscribe to the order index", () => { + const key = {}, + other = {}; + const map = root(() => createReactiveMap([[key, 1]])); + let runs = 0; + root(() => + createEffect( + () => { + runs++; + return map.has(key); + }, + () => {}, + ), + ); + flush(); + map.set(other, 2); + flush(); + map.delete(other); + flush(); + expect(runs).toBe(1); + }); + it("order-changing replacement starts a fresh cursor generation without changing membership", () => { + const [source, update] = createSignal(new Set([1, 2])); + const set = root(() => createReactiveSet(source)); + const iterator = set.values(); + expect(iterator.next().value).toBe(1); + update(new Set([2, 1])); + flush(); + expect([...iterator]).toEqual([2, 1]); + }); + it("keeps a live cursor's progress when an optimistic order rolls back", async () => { + const set = root(() => createReactiveSet([1, 2], { optimistic: true })); + const iterator = set.values(); + expect(iterator.next().value).toBe(1); + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const pending = action(function* () { + set.delete(2); + set.add(3); + yield gate; + })(); + flush(); + expect([...set]).toEqual([1, 3]); + expect(iterator.next().value).toBe(3); + release(); + await pending; + flush(); + expect([...set]).toEqual([1, 2]); + // Restored entries behind the consumed insertion ordinal are not replayed. + expect(iterator.next().done).toBe(true); + }); + it("matches native Maps under deterministic interleaved mutations and cursor reads", () => { + const actual = root(() => createReactiveMap()); + const expected = new Map(); + let a = actual.entries(), + b = expected.entries(), + seed = 0xabcd; + const random = () => (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0); + for (let step = 0; step < 2000; step++) { + const key = random() % 50; + switch (random() % 7) { + case 0: + actual.delete(key); + expected.delete(key); + break; + case 1: + actual.clear(); + expected.clear(); + break; + case 2: + expect(a.next()).toEqual(b.next()); + break; + case 3: + a = actual.entries(); + b = expected.entries(); + break; + default: + actual.set(key, step); + expected.set(key, step); + } + flush(); + expect([...actual]).toEqual([...expected]); + expect(actual.size).toBe(expected.size); + } + }); + it("balances persistent order paths and keeps old roots valid", () => { + let tree: Order; + const roots: Order[] = []; + const validate = (root: Order, min = -Infinity, max = Infinity): number => { + if (!root) return 0; + expect(root.id > min && root.id < max).toBe(true); + const l = validate(root.left, min, root.id), + r = validate(root.right, root.id, max); + expect(Math.abs(l - r)).toBeLessThanOrEqual(1); + expect(root.height).toBe(1 + Math.max(l, r)); + return root.height; + }; + for (let i = 0; i < 512; i++) { + tree = insert(tree, i, String(i)); + if (i % 64 === 63) roots.push(tree); + } + for (let i = 0; i < 512; i += 2) tree = remove(tree!, i); + validate(tree); + for (let i = 0; i < roots.length; i++) { + validate(roots[i]); + const stack: any[] = []; + seek(roots[i], -1, stack); + const values: number[] = []; + let item; + while ((item = advance(stack))) values.push(item.id); + expect(values).toEqual(Array.from({ length: (i + 1) * 64 }, (_, n) => n)); + } + }); +}); diff --git a/packages/collections/test/integration/collections-render.test.tsx b/packages/collections/test/integration/collections-render.test.tsx new file mode 100644 index 000000000..12d5eef3a --- /dev/null +++ b/packages/collections/test/integration/collections-render.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { renderToStream } from "@solidjs/web"; +import { CollectionFixture, SetCollectionFixture, kinds } from "./fixtures/collections.js"; +const directory = resolve("node_modules/.cache/collections-hydration"); +mkdirSync(directory, { recursive: true }); +describe("real collection SSR fixtures", () => { + for (const kind of kinds) + for (const optimistic of [false, true]) { + it(`${kind}, optimistic=${optimistic}`, async () => { + const chunks: string[] = []; + let shell = "", + shellDone = false; + await new Promise((resolve, reject) => { + renderToStream(() => , { + onCompleteShell() { + shellDone = true; + }, + onError(error) { + reject(error); + }, + }).pipe({ + write(chunk: string) { + chunks.push(chunk); + if (shellDone && !shell) shell = chunks.join(""); + }, + end() { + resolve(); + }, + }); + }); + const full = chunks.join(""); + if (!shell) shell = full; + const visible = full.replace(//g, "").replace(/<[^>]*>/g, ""); + expect(visible).toContain( + kind === "loading" || kind === "client" ? "placeholder" : "true:0:true:true:1:false", + ); + expect(visible).toContain("after"); + writeFileSync( + resolve(directory, `${kind}-${optimistic}.json`), + JSON.stringify({ shell, rest: full.slice(shell.length) }), + ); + }); + } +}); + +describe("real Set SSR fixtures", () => { + for (const kind of ["stream", "loading", "client", "hybrid"] as const) + for (const optimistic of [false, true]) { + it(`${kind}, optimistic=${optimistic}`, async () => { + const chunks: string[] = []; + let shell = "", + shellDone = false; + await new Promise((resolve, reject) => { + renderToStream(() => , { + onCompleteShell() { + shellDone = true; + }, + onError(error) { + reject(error); + }, + }).pipe({ + write(chunk: string) { + chunks.push(chunk); + if (shellDone && !shell) shell = chunks.join(""); + }, + end() { + resolve(); + }, + }); + }); + const full = chunks.join(""); + if (!shell) shell = full; + const visible = full.replace(//g, "").replace(/<[^>]*>/g, ""); + expect(visible).toContain( + kind === "stream" ? "true:true:true:4:false:true" : "placeholder", + ); + writeFileSync( + resolve(directory, `set-${kind}-${optimistic}.json`), + JSON.stringify({ shell, rest: full.slice(shell.length) }), + ); + }); + } +}); diff --git a/packages/collections/test/integration/collections-server.test.ts b/packages/collections/test/integration/collections-server.test.ts new file mode 100644 index 000000000..b15d08009 --- /dev/null +++ b/packages/collections/test/integration/collections-server.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createRoot, NotReadyError } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { getProjectionTrace } from "solid-js/internal"; +import { createReactiveMap, createReactiveSet } from "../../src/index.js"; +const disposers: (() => void)[] = []; +function root(fn: () => T): T { + return createRoot(d => { + disposers.push(d); + return fn(); + }); +} +afterEach(() => { + while (disposers.length) disposers.pop()!(); +}); +const tick = async () => { + for (let i = 0; i < 40; i++) await Promise.resolve(); +}; +const trace = (collection: any) => + getProjectionTrace(collection.data.store)!.subscribe()[Symbol.asyncIterator](); + +describe("server collections", () => { + it("uses the server runtime and preserves synchronous raw key/value identity", () => { + expect(isServer).toBe(true); + const key = {}, + value = new Date(0); + const map = root(() => + createReactiveMap(draft => { + draft.set(key, value); + }), + ); + expect(map.get(key)).toBe(value); + expect([...map]).toEqual([[key, value]]); + expect([...map.keys()][0]).toBe(key); + const set = root(() => createReactiveSet(() => new Set([key]))); + expect(set.has(key)).toBe(true); + }); + it("suspends initial promise reads then preserves identity", async () => { + const key = {}, + value = Object.freeze({ key }); + const map = root(() => createReactiveMap(async () => new Map([[key, value]]))); + expect(() => map.has(key)).toThrow(NotReadyError); + await tick(); + expect(map.get(key)).toBe(value); + const first = await trace(map).next(); + expect(Object.values(first.value).some((v: any) => v?.value === value)).toBe(true); + }); + it("locks rendered state at the first yield and transports terminal mutations", async () => { + const key = {}, + terminalKey = {}; + const map = root(() => + createReactiveMap(async function* (draft) { + draft.set(key, 1); + yield; + draft.set(terminalKey, 2); + }), + ); + await tick(); + expect([...map]).toEqual([[key, 1]]); + const iterator = trace(map); + const first = await iterator.next(); + expect(first.value.size).toBe(1); + const finalPatch = await iterator.next(); + expect(finalPatch.done).toBe(false); + expect(finalPatch.value.some((patch: any) => patch[0][0] === "size" && patch[1] === 2)).toBe( + true, + ); + expect((await iterator.next()).done).toBe(true); + expect([...map]).toEqual([[key, 1]]); + }); + it("settles an iterable with no explicit yields, including its draft edits", async () => { + const set = root(() => + createReactiveSet(async function* (draft) { + draft.add(1); + }), + ); + await tick(); + expect([...set]).toEqual([1]); + const first = await trace(set).next(); + expect(first.value.size).toBe(1); + }); + it("keeps loadingValue visible while serializing the actual answer", async () => { + const key = {}, + final = {}; + const loading = new Set([key]); + const set = root(() => + createReactiveSet( + async draft => { + draft.clear(); + draft.add(final); + }, + { loadingValue: loading }, + ), + ); + expect([...set]).toEqual([key]); + await tick(); + expect([...set]).toEqual([key]); + const first = await trace(set).next(); + expect(Object.values(first.value).some((v: any) => v?.value === final)).toBe(true); + expect([...loading]).toEqual([key]); + }); + it("does not run a client source on the server", () => { + let calls = 0; + const set = root(() => + createReactiveSet( + () => { + calls++; + return new Set([1]); + }, + { + ssrSource: "client", + loadingValue: new Set(), + }, + ), + ); + expect([...set]).toEqual([]); + expect(calls).toBe(0); + }); +}); diff --git a/packages/collections/test/integration/collections-shallow-server.test.ts b/packages/collections/test/integration/collections-shallow-server.test.ts new file mode 100644 index 000000000..82b676e38 --- /dev/null +++ b/packages/collections/test/integration/collections-shallow-server.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { createRoot, createStore } from "solid-js"; +import { getProjectionTrace } from "solid-js/internal"; + +const tick = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); +}; + +describe("server shallow store integration", () => { + it("SSR shallow draft reads return raw leaves instead of deep proxies", () => { + const value = Object.freeze({ key: {} }); + createRoot(() => { + const [state] = createStore( + (draft: { value: typeof value }) => { + expect(draft.value).toBe(value); + expect(draft.value.key).toBe(value.key); + }, + { value }, + { shallow: true }, + ); + expect(state.value).toBe(value); + }); + }); + + it("SSR stream snapshots preserve raw identity and the first visible answer", async () => { + const key = {}, + value = Object.freeze({ key, time: new Date(0), n: NaN }); + const replacement = Object.freeze({ key, time: new Date(1), n: NaN }); + let dispose!: () => void; + const [state] = createRoot((d: () => void) => { + dispose = d; + return createStore( + async function* (draft: { value: typeof value | undefined }) { + draft.value = value; + yield; + draft.value = replacement; + yield; + }, + { value: undefined }, + { shallow: true }, + ); + }); + try { + await tick(); + expect(state.value).toBe(value); + const iterator = getProjectionTrace(state)!.subscribe()[Symbol.asyncIterator](); + const first = await iterator.next(); + expect(first.value.value).toBe(value); + const next = await iterator.next(); + expect(next.value).toEqual([[["value"], replacement]]); + expect(state.value).toBe(value); + await iterator.return?.(); + } finally { + dispose(); + } + }); +}); diff --git a/packages/collections/test/integration/collections-shallow.test.ts b/packages/collections/test/integration/collections-shallow.test.ts new file mode 100644 index 000000000..e19e1b771 --- /dev/null +++ b/packages/collections/test/integration/collections-shallow.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { createRoot, createStore, createOptimisticStore, flush } from "solid-js"; + +const tick = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); + flush(); +}; +describe("shallow store integration", () => { + for (const optimistic of [false, true]) { + it(`loading drafts retain raw leaf identity, optimistic=${optimistic}`, async () => { + const original = Object.freeze({ id: 1 }); + const replacement = Object.freeze({ id: 2 }); + let release!: () => void; + const gate = new Promise(r => { + release = r; + }); + let seen: unknown; + let dispose!: () => void; + const [state] = createRoot(d => { + dispose = d; + return (optimistic ? createOptimisticStore : createStore)( + async draft => { + seen = draft.value; + draft.value = replacement; + await gate; + }, + { value: original }, + { shallow: true, seedLoadingValue: true }, + ); + }); + try { + expect(seen).toBe(original); + expect(state.value).toBe(original); + release(); + await tick(); + expect(state.value).toBe(replacement); + } finally { + release(); + dispose(); + } + }); + } +}); diff --git a/packages/collections/test/integration/collections.bench.ts b/packages/collections/test/integration/collections.bench.ts new file mode 100644 index 000000000..610ac345a --- /dev/null +++ b/packages/collections/test/integration/collections.bench.ts @@ -0,0 +1,67 @@ +import { afterAll, bench, describe } from "vitest"; +import { createRoot, createEffect, flush } from "solid-js"; +import { createReactiveMap, createReactiveSet } from "../../src/index.js"; + +const options = { time: 250, warmupTime: 100 }; +for (const size of [10, 1000, 10000]) { + describe(`${size} entries: delete/reinsert`, () => { + const keys = Array.from({ length: size }, (_, i) => i); + for (const [name, set] of [ + ["native Set", new Set(keys)], + ["reactive Set + flush", createReactiveSet(keys)], + ] as const) { + bench( + name, + () => { + set.delete(5); + set.add(5); + if (name === "reactive Set + flush") flush(); + }, + options, + ); + } + }); +} + +describe("1000 observed values: one value write + flush", () => { + const map = createReactiveMap( + Array.from({ length: 1000 }, (_, i) => [i, 0] as const), + ); + const dispose = createRoot(dispose => { + for (let i = 0; i < 1000; i++) + createEffect( + () => map.get(i), + () => {}, + ); + return dispose; + }); + afterAll(dispose); + flush(); + let n = 0; + bench( + "reactive Map", + () => { + map.set(500, ++n); + flush(); + }, + options, + ); +}); + +describe("1000 keys: full iteration", () => { + const keys = Array.from({ length: 1000 }, (_, i) => i); + for (const [name, set] of [ + ["native Set", new Set(keys)], + ["reactive Set", createReactiveSet(keys)], + ] as const) { + bench( + name, + () => { + let sum = 0; + for (const key of set) sum += key; + return sum; + }, + options, + ); + } +}); diff --git a/packages/collections/test/integration/collections.test.ts b/packages/collections/test/integration/collections.test.ts new file mode 100644 index 000000000..ee4ca91e1 --- /dev/null +++ b/packages/collections/test/integration/collections.test.ts @@ -0,0 +1,636 @@ +import { afterEach, describe, expect, expectTypeOf, it } from "vitest"; +import { + $TARGET, + action, + createEffect, + createRoot, + createSignal, + createStore, + flush, + isPending, + latest, + NotReadyError, + refresh, +} from "@solidjs/signals"; +import { createReactiveMap, createReactiveSet } from "@solid-primitives/collections"; + +const disposers: (() => void)[] = []; +function root(fn: () => T): T { + return createRoot(dispose => { + disposers.push(dispose); + return fn(); + }); +} +afterEach(() => { + while (disposers.length) disposers.pop()!(); + flush(); +}); +const tick = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); + flush(); +}; +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +} +function observe(read: () => T) { + let runs = 0; + const values: T[] = []; + createEffect( + () => { + runs++; + return read(); + }, + value => { + values.push(value); + }, + ); + return { + values, + get runs() { + return runs; + }, + }; +} +// Deliberate private inspection: prove nodes themselves are lazy/independent, +// not just filtered callbacks. This adds no debug hook to production code. +const target = (collection: any) => collection.data.store[$TARGET]; +const count = (record: object | null) => (record ? Reflect.ownKeys(record).length : 0); + +describe("reactive collection factories", () => { + it("keeps backend fields out of ordinary enumeration and JSON", () => { + const map = root(() => createReactiveMap([["a", 1]])); + const set = root(() => createReactiveSet([1])); + for (const collection of [map, set]) { + expect(Object.keys(collection)).toEqual([]); + expect(JSON.stringify(collection)).toBe("{}"); + } + expect([...new Map(map)]).toEqual([["a", 1]]); + expect([...new Set(set)]).toEqual([1]); + }); + it("infers iterable, compute, and async result types", () => { + root(() => { + const entries = [["a", 1]] as const; + expectTypeOf(createReactiveMap(entries).get("a")).toEqualTypeOf<1 | undefined>(); + expectTypeOf(createReactiveMap(() => new Map()).get("a")).toEqualTypeOf< + number | undefined + >(); + const asyncMap = createReactiveMap(async () => new Map()); + expectTypeOf(asyncMap).toMatchTypeOf>(); + expectTypeOf(createReactiveSet(() => new Set())).toMatchTypeOf>(); + }); + }); + + it("allocates independent membership nodes only on tracked reads and releases them", () => { + const set = root(() => createReactiveSet([0])); + const backing = target(set); + expect(set.has(0)).toBe(true); + expect(count(backing.h)).toBe(0); + let disposeReaders!: () => void; + const [zero, one] = createRoot(dispose => { + disposeReaders = dispose; + return [observe(() => set.has(0)), observe(() => set.has(1))]; + }); + flush(); + expect(count(backing.h)).toBe(2); + expect(backing.h["number:0"]).not.toBe(backing.h["number:1"]); + set.add(1); + flush(); + expect([zero.runs, one.runs]).toEqual([1, 2]); + set.add(2); + flush(); + expect([zero.runs, one.runs]).toEqual([1, 2]); + disposeReaders(); + flush(); + expect(count(backing.h)).toBe(0); + }); + + it("separates value, presence, size, and key-order subscriptions", () => { + const map = root(() => createReactiveMap([["a", 1]])); + const readers = root(() => ({ + value: observe(() => map.get("a")), + has: observe(() => map.has("a")), + missing: observe(() => map.get("b")), + size: observe(() => map.size), + keys: observe(() => [...map.keys()]), + values: observe(() => [...map.values()]), + })); + flush(); + map.set("a", 2); + flush(); + expect([readers.value.runs, readers.has.runs, readers.size.runs, readers.keys.runs]).toEqual([ + 2, 1, 1, 1, + ]); + map.set("b", undefined); + flush(); + expect(readers.missing.runs).toBe(1); + expect(readers.values.values.at(-1)).toEqual([2, undefined]); + expect(readers.keys.values.at(-1)).toEqual(["a", "b"]); + expect(readers.size.values).toEqual([1, 2]); + map.delete("b"); + flush(); + expect(readers.missing.runs).toBe(1); + map.set("a", 2); + flush(); + expect(readers.value.runs).toBe(2); + expect(readers.values.values.at(-1)).toEqual([2]); + }); + + it("retains native key identity, SameValueZero, and shallow values", () => { + const object = {}, + other = {}, + fn = () => {}, + symbol = Symbol("key"), + value = {}; + const keys = [ + object, + other, + fn, + symbol, + NaN, + -0, + 1, + "1", + 1n, + null, + undefined, + $TARGET, + Symbol.for("order"), + Symbol("key"), + "order", + "size", + "__proto__", + "constructor", + ]; + const map = root(() => + createReactiveMap(keys.map(key => [key, value] as const)), + ); + expect(map.size).toBe(keys.length); + for (const key of keys) expect(map.get(key)).toBe(value); + expect(map.has({})).toBe(false); + expect(Object.is([...map.keys()][5], 0)).toBe(true); + map.delete(NaN); + map.delete(0); + flush(); + expect(map.has(NaN)).toBe(false); + expect(map.has(-0)).toBe(false); + expect(map.get(symbol)).toBe(value); + }); + + it("batches writes, composes mutators, and does not mutate input collections", () => { + const original = new Map([["a", 1]]); + const map = root(() => createReactiveMap(original)); + expect(map.set("b", 2)).toBe(map); + expect(map.get("b")).toBeUndefined(); + expect(map.delete("b")).toBe(true); + expect(map.delete("b")).toBe(false); + map.set("c", 3); + flush(); + expect([...map]).toEqual([ + ["a", 1], + ["c", 3], + ]); + expect([...original]).toEqual([["a", 1]]); + map.clear(); + map.set("d", 4); + flush(); + expect([...map]).toEqual([["d", 4]]); + }); + + it("does not raw-mark user objects or interfere with deep stores", () => { + const before = { n: 1 }, + after = { n: 2 }; + const [existing] = createStore({ before }); + expect(existing.before).not.toBe(before); + const map = root(() => + createReactiveMap([ + ["before", before], + ["after", after], + ]), + ); + const [subsequent] = createStore({ after }); + expect(subsequent.after).not.toBe(after); + expect(map.get("before")).toBe(before); + expect(map.get("after")).toBe(after); + const observer = root(() => observe(() => map.get("before"))); + flush(); + map.set("before", before); + flush(); + expect(observer.runs).toBe(1); + const replacement = { n: 3 }; + map.set("before", replacement); + flush(); + expect(observer.values).toEqual([before, replacement]); + expect(map.get("before")).toBe(replacement); + }); + + it("preserves raw identity in compute drafts before and after suspension", async () => { + const key = Object.freeze({ id: 1 }), + value = Object.freeze({ time: new Date(0) }); + const gate = deferred(); + let seen: unknown, seenKey: unknown; + const map = root(() => + createReactiveMap(async draft => { + draft.set(key, value); + seen = draft.get(key); + seenKey = [...draft.keys()][0]; + await gate.promise; + expect(draft.get(key)).toBe(value); + expect(draft.has([...draft.keys()][0]!)).toBe(true); + draft.delete(key); + draft.set(key, value); + }), + ); + expect(seen).toBe(value); + expect(seenKey).toBe(key); + gate.resolve(); + await tick(); + expect([...map.keys()]).toEqual([key]); + expect(map.get(key)).toBe(value); + }); + + it("tracks order-only replacement without invalidating membership or size", () => { + const [source, setSource] = createSignal(new Set([0, 1])); + const set = root(() => createReactiveSet(source)); + const [members, size, order] = root(() => [ + observe(() => set.has(0)), + observe(() => set.size), + observe(() => [...set]), + ]); + flush(); + setSource(new Set([1, 0])); + flush(); + expect(order.values).toEqual([ + [0, 1], + [1, 0], + ]); + expect([members.runs, size.runs]).toEqual([1, 1]); + set.delete(1); + set.add(1); + flush(); + expect([...set]).toEqual([0, 1]); + expect(set.size).toBe(2); + }); + + it("forEach receives the facade and iterators read the current visible state", () => { + const map = root(() => + createReactiveMap([ + ["a", 1], + ["b", 2], + ]), + ); + const iterator = map.entries(); + map.clear(); + flush(); + expect([...iterator]).toEqual([]); + map.set("a", 3); + flush(); + const context = {}; + map.forEach(function (this: object, value, key, self) { + expect(this).toBe(context); + expect(self).toBe(map); + expect([key, value]).toEqual(["a", 3]); + }, context); + const set = root(() => createReactiveSet([1])); + set.forEach((value, key, self) => { + expect(value).toBe(key); + expect(self).toBe(set); + }); + }); + + it("overrides native set algebra instead of reading the facade's empty native storage", () => { + const set = root(() => createReactiveSet([1, 2])); + expect([...set.union(new Set([2, 3]))]).toEqual([1, 2, 3]); + expect([...set.intersection(new Set([2, 3]))]).toEqual([2]); + expect([...set.difference(new Set([2, 3]))]).toEqual([1]); + expect([...set.symmetricDifference(new Set([2, 3]))]).toEqual([1, 3]); + expect(set.isSubsetOf(new Set([1, 2, 3]))).toBe(true); + expect(set.isSupersetOf(new Set([1]))).toBe(true); + expect(set.isDisjointFrom(new Set([3]))).toBe(true); + }); + + it("supports synchronous draft mutation and manual-write precedence", () => { + const [value, setValue] = createSignal(1); + const map = root(() => + createReactiveMap(draft => { + draft.set("a", value()); + }), + ); + expect(map.get("a")).toBe(1); + setValue(2); + map.set("a", 10); + flush(); + expect(map.get("a")).toBe(10); + setValue(3); + flush(); + expect(map.get("a")).toBe(3); + }); + + it("propagates computation errors through collection reads and recovers", () => { + const [fail, setFail] = createSignal(false); + const error = new Error("collection source failed"); + const map = root(() => + createReactiveMap(() => { + if (fail()) throw error; + return new Map([["a", 1]]); + }), + ); + setFail(true); + flush(); + for (const read of [() => map.get("a"), () => map.has("a"), () => map.size, () => [...map]]) + expect(read).toThrow(error.message); + setFail(false); + flush(); + expect([...map]).toEqual([["a", 1]]); + }); + + it("suspends all initial read surfaces and settles an unchanged absent entry", async () => { + const gate = deferred(); + const set = root(() => + createReactiveSet(async () => { + await gate.promise; + }), + ); + const reader = root(() => observe(() => set.has(0))); + flush(); + for (const read of [ + () => set.size, + () => set.has(0), + () => [...set], + () => set.entries().next(), + ]) + expect(read).toThrow(NotReadyError); + expect(reader.values).toEqual([]); + gate.resolve(); + await tick(); + expect(reader.values).toEqual([false]); + expect(set.size).toBe(0); + }); + + it("ignores superseded promise draft writes and replacements", async () => { + const gates = [deferred(), deferred()]; + const [run, next] = createSignal(0); + const map = root(() => + createReactiveMap(async draft => { + const i = run(); + await gates[i].promise; + draft.set("draft", i); + return new Map([["result", i]]); + }), + ); + next(1); + flush(); + gates[1].resolve(); + await tick(); + expect([...map]).toEqual([["result", 1]]); + gates[0].resolve(); + await tick(); + expect([...map]).toEqual([["result", 1]]); + }); + + it("accepts thenables and preserves returned object references", async () => { + const key = {}, + value = {}; + const map = root(() => + createReactiveMap(() => ({ + then(resolve: any) { + return Promise.resolve(resolve(new Map([[key, value]]))); + }, + })), + ); + await tick(); + expect(map.get(key)).toBe(value); + }); + + it("supports replacement and void yields plus terminal draft mutations", async () => { + const a = deferred(), + b = deferred(); + const set = root(() => + createReactiveSet(async function* (draft) { + draft.add(1); + yield; + await a.promise; + yield new Set([2]); + await b.promise; + draft.add(3); + }), + ); + await tick(); + expect([...set]).toEqual([1]); + a.resolve(); + await tick(); + expect([...set]).toEqual([2]); + b.resolve(); + await tick(); + expect([...set]).toEqual([2, 3]); + }); + + it("forwards generator cleanup and guards a disposed draft", async () => { + const gate = deferred(); + let closed = false; + let dispose!: () => void; + const set = createRoot(d => { + dispose = d; + return createReactiveSet(async function* (draft) { + try { + draft.add(1); + yield; + await gate.promise; + draft.add(2); + yield; + } finally { + closed = true; + } + }); + }); + await tick(); + dispose(); + gate.resolve(); + await tick(); + expect(closed).toBe(true); + expect([...set]).toEqual([1]); + }); + + it("exposes refresh and pending status through its projection", async () => { + const gates = [deferred(), deferred(), deferred()]; + let runs = 0; + const [revision, update] = createSignal(0); + const set = root(() => + createReactiveSet(async () => { + revision(); + const run = runs++; + await gates[run].promise; + return new Set([run]); + }), + ); + root(() => observe(() => [...set])); + const pendingState = root(() => observe(() => isPending(() => set.has(0)))); + gates[0].resolve(); + await tick(); + const pending = refresh(set); + flush(); + // Explicit refresh is a quiet re-ask in Solid 2; a new source question + // below is transition-pending instead. Preserve both core semantics. + expect(isPending(() => set.has(0))).toBe(false); + gates[1].resolve(); + await pending; + await tick(); + expect([...set]).toEqual([1]); + expect(isPending(() => set.has(1))).toBe(false); + update(1); + flush(); + expect(pendingState.values.at(-1)).toBe(true); + gates[2].resolve(); + await tick(); + expect([...set]).toEqual([2]); + expect(pendingState.values.at(-1)).toBe(false); + }); + + it("keeps held truth isolated, including first reads of previously unread entries", async () => { + const gate = deferred(); + const [value, change] = createSignal(0); + const map = root(() => + createReactiveMap( + () => + new Map([ + ["a", value()], + ["unread", value()], + ]), + ), + ); + const reader = root(() => observe(() => map.get("a"))); + flush(); + const held = action(function* () { + yield gate.promise; + })(); + change(1); + flush(); + expect(map.get("a")).toBe(0); + expect(map.get("unread")).toBe(0); + expect(reader.values).toEqual([0]); + expect(latest(() => map.get("a"))).toBe(1); + gate.resolve(); + await held; + await tick(); + expect(map.get("unread")).toBe(1); + expect(reader.values).toEqual([0, 1]); + }); + + it("rolls back disjoint optimistic value edits independently", async () => { + const map = root(() => + createReactiveMap( + [ + ["a", 1], + ["b", 2], + ], + { optimistic: true }, + ), + ); + const gates = [deferred(), deferred()]; + const edit = (key: string, value: number, gate: Promise) => + action(function* () { + map.set(key, value); + yield gate; + })(); + const a = edit("a", 10, gates[0].promise); + flush(); + const b = edit("b", 20, gates[1].promise); + flush(); + expect([...map]).toEqual([ + ["a", 10], + ["b", 20], + ]); + gates[1].resolve(); + await b; + await tick(); + expect([...map]).toEqual([ + ["a", 10], + ["b", 2], + ]); + gates[0].resolve(); + await a; + await tick(); + expect([...map]).toEqual([ + ["a", 1], + ["b", 2], + ]); + }); + + it("keeps optimistic structural reads coherent without prior subscribers", async () => { + const set = root(() => createReactiveSet([1, 2], { optimistic: true })); + const gate = deferred(); + const pending = action(function* () { + set.clear(); + set.add(3); + yield gate.promise; + })(); + flush(); + expect([...set]).toEqual([3]); + expect(set.size).toBe(1); + expect(set.has(1)).toBe(false); + expect(set.has(3)).toBe(true); + gate.resolve(); + await pending; + await tick(); + expect([...set]).toEqual([1, 2]); + expect(set.size).toBe(2); + expect(set.has(1)).toBe(true); + expect(set.has(3)).toBe(false); + }); + + it("coordinates overlapping structural actions through shared order and size slots", async () => { + const set = root(() => createReactiveSet([], { optimistic: true })); + const gates = [deferred(), deferred()]; + const add = (key: number, gate: Promise) => + action(function* () { + set.add(key); + yield gate; + })(); + const a = add(1, gates[0].promise); + flush(); + const b = add(2, gates[1].promise); + flush(); + gates[0].resolve(); + await tick(); + // Collections inherit store overlap semantics: structural + // actions share order/size slots and settle together, unlike value-only + // edits to independent Map entries. Pin the limitation and view coherence. + expect([...set]).toEqual([1, 2]); + expect(set.size).toBe(2); + expect(set.has(1) && set.has(2)).toBe(true); + gates[1].resolve(); + await Promise.all([a, b]); + await tick(); + expect([...set]).toEqual([]); + expect(set.size).toBe(0); + expect(set.has(1) || set.has(2)).toBe(false); + }); + + it("keeps optimistic values until an authoritative refetch lands", async () => { + const gates = [deferred(), deferred()]; + const [run, next] = createSignal(0); + const map = root(() => + createReactiveMap( + async () => { + const i = run(); + await gates[i].promise; + return new Map([["a", i]]); + }, + { optimistic: true }, + ), + ); + root(() => observe(() => map.get("a"))); + gates[0].resolve(); + await tick(); + next(1); + map.set("a", 10); + flush(); + expect(map.get("a")).toBe(10); + gates[1].resolve(); + await tick(); + expect(map.get("a")).toBe(1); + }); +}); diff --git a/packages/collections/test/integration/collections.type-tests.ts b/packages/collections/test/integration/collections.type-tests.ts new file mode 100644 index 000000000..b50dd5657 --- /dev/null +++ b/packages/collections/test/integration/collections.type-tests.ts @@ -0,0 +1,93 @@ +import { expectTypeOf } from "vitest"; +import { refresh, type Refreshable } from "solid-js"; +import { + createReactiveMap, + type CreateReactiveMapComputeFunction, + createReactiveWeakMap, + createReactiveSet, + type CreateReactiveSetComputeFunction, + createReactiveWeakSet, +} from "@solid-primitives/collections"; + +// Compiled by tsconfig.test.json; never execute async work just to test types. +function types() { + const entries = [["a", 1]] as const; + expectTypeOf(createReactiveMap(entries)).toEqualTypeOf>(); + expectTypeOf(createReactiveSet([1, 2] as const)).toEqualTypeOf>(); + expectTypeOf(createReactiveMap(() => new Map())).toEqualTypeOf< + Refreshable> + >(); + expectTypeOf(createReactiveMap(async () => new Map())).toEqualTypeOf< + Refreshable> + >(); + expectTypeOf(createReactiveSet(() => new Set())).toEqualTypeOf< + Refreshable> + >(); + expectTypeOf( + createReactiveSet(async function* () { + yield new Set(); + }), + ).toEqualTypeOf>>(); + const mapCompute: CreateReactiveMapComputeFunction = draft => { + draft.set("a", 1); + }; + const setCompute: CreateReactiveSetComputeFunction = async draft => { + draft.add(1); + }; + refresh(createReactiveMap(mapCompute)); + refresh( + createReactiveSet(setCompute, { + loadingValue: new Set([0]), + ssrSource: "hybrid", + deferStream: true, + optimistic: true, + name: "numbers", + }), + ); + const readonlyMap: ReadonlyMap = new Map(); + const readonlySet: ReadonlySet = new Set(); + createReactiveMap(() => readonlyMap, { loadingValue: readonlyMap }); + createReactiveSet(() => readonlySet, { loadingValue: readonlySet }); + // @ts-expect-error compute-only options require a compute function + createReactiveSet([1], { loadingValue: new Set([0]) }); + // @ts-expect-error wrong map value type + createReactiveMap(() => new Map([["a", "wrong"]])); + // @ts-expect-error no refresh contract on an iterable-only collection + refresh(createReactiveSet([1])); + + const weakKey = { id: 1 }; + expectTypeOf(createReactiveWeakMap([[weakKey, 1]] as const)).toEqualTypeOf< + WeakMap<{ id: number }, 1> + >(); + expectTypeOf(createReactiveWeakSet([weakKey])).toEqualTypeOf>(); + expectTypeOf(createReactiveWeakMap(async () => [[weakKey, 1]] as const)).toEqualTypeOf< + Refreshable> + >(); + expectTypeOf( + createReactiveWeakSet(async function* () { + yield [weakKey]; + }), + ).toEqualTypeOf>>(); + refresh( + createReactiveWeakMap( + async draft => { + draft.set(weakKey, 1); + }, + { loadingValue: [[weakKey, 0]], optimistic: true, ssrSource: "hybrid" }, + ), + ); + createReactiveWeakSet(draft => { + draft.add(Symbol("weak")); + }); + // @ts-expect-error primitive keys cannot be weak + createReactiveWeakMap([[1, "no"]]); + // @ts-expect-error native weak replacements are not enumerable + createReactiveWeakMap(() => new WeakMap()); + // @ts-expect-error native weak loading values are not enumerable + createReactiveWeakSet(() => {}, { loadingValue: new WeakSet() }); + // @ts-expect-error compute-only options require a compute function + createReactiveWeakSet([weakKey], { ssrSource: "client" }); + // @ts-expect-error iterable-only collections do not implement refresh + refresh(createReactiveWeakMap([[weakKey, 1]])); +} +void types; diff --git a/packages/collections/test/integration/fixtures/collections.tsx b/packages/collections/test/integration/fixtures/collections.tsx new file mode 100644 index 000000000..921f5f6e3 --- /dev/null +++ b/packages/collections/test/integration/fixtures/collections.tsx @@ -0,0 +1,157 @@ +import { + createMemo, + createSignal, + createStore, + createOptimisticStore, + Loading, + refresh, +} from "solid-js"; +import { createReactiveMap } from "../../../src/index.js"; +import { createReactiveSet } from "../../../src/index.js"; +export const kinds = ["promise", "stream", "loading", "client", "hybrid", "store-hybrid"] as const; +export type Kind = (typeof kinds)[number]; +type Key = { tag: string } | string; +type Value = { n: number; date: Date; missing: undefined; nan: number; big: bigint; self?: Value }; +export let current: { + read: () => string; + update: () => void; + refresh: () => Promise; +}; +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); +const value = (n: number): Value => { + const result: Value = { n, date: new Date(0), missing: undefined, nan: NaN, big: 1n }; + result.self = result; + return result; +}; +export function CollectionFixture(props: { kind: Kind; optimistic: boolean }) { + if (props.kind === "store-hybrid") return StoreHybridFixture(props); + const [version, update] = createSignal(0); + // The same serialized object must be shared between this source and the map. + // A deep JSON snapshot of the store would silently break that identity. + const key = createMemo(async () => ({ tag: "shared" })); + const compute = + props.kind === "stream" || props.kind === "hybrid" + ? async function* (draft: Map) { + const n = version(), + k = key(); + await sleep(2); + draft.set(k, value(n)); + yield; + await sleep(5); + draft.set(k, value(n + 10)); + yield; + draft.set("tail", value(99)); + } + : async (draft: Map) => { + const n = version(), + k = key(); + await sleep(2); + draft.clear(); + draft.set(k, value(n)); + }; + const map = createReactiveMap(compute, { + optimistic: props.optimistic, + ...(props.kind === "loading" || props.kind === "client" + ? { loadingValue: new Map([["placeholder", value(-1)]]) } + : {}), + ...(props.kind === "client" || props.kind === "hybrid" ? { ssrSource: props.kind } : {}), + deferStream: props.kind === "promise", + }); + const read = () => { + if (map.has("placeholder")) return "placeholder"; + const k = key(), + entry = map.get(k); + return `${map.has(k)}:${entry?.n}:${entry?.date instanceof Date && entry.big === 1n && entry.self === entry}:${Number.isNaN(entry?.nan)}:${map.size}:${map.has("tail")}`; + }; + current = { read, update: () => update(n => n + 1), refresh: () => refresh(map) }; + return ( +
+ loading}> + {read()} + + after +
+ ); +} + +// Control case: exercise the same delayed hybrid handoff without collections. +function StoreHybridFixture(props: { optimistic: boolean }) { + const [version, update] = createSignal(0); + const key = createMemo(async () => ({ tag: "shared" })); + const [state] = (props.optimistic ? createOptimisticStore : createStore)( + async function* (draft) { + const n = version(), + k = key(); + await sleep(2); + draft.key = k; + draft.value = value(n); + yield; + await sleep(5); + draft.value = value(n + 10); + yield; + draft.tail = true; + }, + { key: undefined as Key | undefined, value: undefined as Value | undefined, tail: false }, + { shallow: true, ssrSource: "hybrid" }, + ); + const read = () => { + const entry = state.value; + return `${state.key === key()}:${entry?.n}:${entry?.date instanceof Date && entry.big === 1n && entry.self === entry}:${Number.isNaN(entry?.nan)}:${state.tail ? 2 : 1}:${state.tail}`; + }; + current = { read, update: () => update(n => n + 1), refresh: () => refresh(state) }; + return ( +
+ loading}> + {read()} + + after +
+ ); +} + +export let currentSet: { + set: Set; + read: () => string; + update: () => void; + refresh: () => Promise; +}; +export function SetCollectionFixture(props: { + kind: "stream" | "loading" | "client" | "hybrid"; + optimistic: boolean; +}) { + const [version, update] = createSignal(0); + const key = createMemo(async () => ({ tag: "set-key" })); + const set = createReactiveSet( + async function* (draft) { + const revision = version(); + const k = key(); + await sleep(2); + draft.clear(); + draft.add(k); + draft.add(NaN); + draft.add(Symbol.for("set-symbol")); + draft.add(revision); + yield; + await sleep(5); + draft.add("tail"); + }, + { + optimistic: props.optimistic, + ...(props.kind !== "stream" ? { loadingValue: new Set(["placeholder"]) } : {}), + ...(props.kind === "client" || props.kind === "hybrid" ? { ssrSource: props.kind } : {}), + }, + ); + const read = () => + set.has("placeholder") + ? "placeholder" + : `${set.has(key())}:${set.has(NaN)}:${set.has(Symbol.for("set-symbol"))}:${set.size}:${set.has("tail")}:${set.has(version())}`; + currentSet = { set, read, update: () => update(n => n + 1), refresh: () => refresh(set) }; + return ( +
+ loading}> + {read()} + + after +
+ ); +} diff --git a/packages/collections/test/integration/fixtures/weak-collections.tsx b/packages/collections/test/integration/fixtures/weak-collections.tsx new file mode 100644 index 000000000..3e0f13e85 --- /dev/null +++ b/packages/collections/test/integration/fixtures/weak-collections.tsx @@ -0,0 +1,109 @@ +import { createMemo, createSignal, Loading, refresh, untrack } from "solid-js"; +import { createReactiveWeakMap, createReactiveWeakSet } from "../../../src/weak.js"; + +export let current: { read(): string; update(): void; refresh(): Promise }; +export let calls = 0; +export const kinds = ["promise", "stream", "hybrid", "loading", "client", "static"] as const; +export type Kind = (typeof kinds)[number]; +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +type Key = { name: string }; +type Value = { key: Key; count: number; self?: Value }; + +/** A real weak map/set sharing a separately serialized object key. */ +export function WeakTokenFixture(props: { kind: Kind; optimistic: boolean }) { + if (props.kind === "static") return StaticWeakFixture(props); + const key = createMemo(async () => ({ name: "shared" })); + const placeholder: Key = { name: "placeholder" }; + const [version, update] = createSignal(0); + const change = (draft: WeakMap, key: Key, count: number) => { + draft.delete(placeholder); + const value: Value = { key, count }; + value.self = value; + draft.set(key, value); + }; + const options = { + optimistic: props.optimistic, + ...(props.kind === "hybrid" || props.kind === "client" ? { ssrSource: props.kind } : {}), + }; + const map = createReactiveWeakMap( + props.kind !== "stream" && props.kind !== "hybrid" + ? async draft => { + calls++; + const k = key(), + n = version(); + await sleep(2); + change(draft, k, n); + } + : async function* (draft) { + calls++; + const k = key(), + n = version(); + await sleep(2); + change(draft, k, n); + yield; + await sleep(5); + change(draft, k, n + 10); + }, + { + ...options, + ...(props.kind === "loading" || props.kind === "client" + ? { + loadingValue: [[placeholder, { key: placeholder, count: -1 }]] as const, + } + : {}), + deferStream: props.kind === "promise", + }, + ); + const set = createReactiveWeakSet(draft => { + draft.add(key()); + }, options); + const read = () => { + if (map.has(placeholder)) return "placeholder"; + const k = key(), + value = map.get(k); + return `${map.has(k) && set.has(k)}:${value?.key === k}:${value?.self === value}:${value?.count}`; + }; + current = { + read, + update: () => update(n => n + 1), + refresh: () => Promise.all([refresh(map), refresh(set)]), + }; + return ( +
+ loading}> + {read()} + + after +
+ ); +} + +/** Iterable initialization does not route through a user computation. */ +function StaticWeakFixture(props: { optimistic: boolean }) { + const key = untrack(createMemo(() => ({ name: "static-shared" }))); + const value: Value = { key, count: 0 }; + value.self = value; + const map = createReactiveWeakMap([[key, value]], { optimistic: props.optimistic }); + const set = createReactiveWeakSet([key], { optimistic: props.optimistic }); + const read = () => { + const entry = map.get(key); + return `${map.has(key) && set.has(key)}:${entry?.key === key}:${entry?.self === entry}:${entry?.count}`; + }; + current = { + read, + update() { + const entry: Value = { key, count: 1 }; + entry.self = entry; + map.set(key, entry); + set.delete(key); + set.add(key); + }, + refresh: () => Promise.resolve(), + }; + return ( +
+ {read()} + after +
+ ); +} diff --git a/packages/collections/test/integration/gc/weak-collections.test.ts b/packages/collections/test/integration/gc/weak-collections.test.ts new file mode 100644 index 000000000..a1f439ed5 --- /dev/null +++ b/packages/collections/test/integration/gc/weak-collections.test.ts @@ -0,0 +1,524 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { + action, + createEffect, + createRoot, + createSignal, + flush, + isPending, + latest, +} from "@solidjs/signals"; +import { createReactiveWeakMap, createReactiveWeakSet } from "../../../src/weak.js"; + +const disposers: (() => void)[] = []; +function root(fn: () => T): T { + return createRoot(dispose => { + disposers.push(dispose); + return fn(); + }); +} +afterEach(() => { + while (disposers.length) disposers.pop()!(); + flush(); +}); +function observe(read: () => unknown) { + createEffect(read, () => {}); +} +function deferred() { + let resolve!: () => void; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +} +async function tick() { + for (let i = 0; i < 30; i++) await Promise.resolve(); + flush(); +} +async function collect() { + assert.equal(typeof global.gc, "function", "GC tests require --expose-gc"); + for (let i = 0; i < 12; i++) { + await new Promise(resolve => + setTimeout(() => { + flush(); + global.gc!(); + resolve(); + }, 0), + ); + } +} +function inspect(map: object): Record { + // Test-only inspection of representation; all runtime imports are public. + return (map as { data: { state: Record } }).data.state; +} +function emptyCompute(): void {} +function installValue(write: (value: object) => void): WeakRef { + const value = { old: true }; + write(value); + flush(); + return new WeakRef(value); +} + +for (const computed of [false, true]) { + for (const companion of ["ordinary", "latest", "isPending"] as const) { + for (const operation of ["overwrite", "delete"] as const) { + test(`${computed ? "computed" : "static"} disposed ${companion} reader releases the old value after ${operation} for a live key`, async () => { + const key = {}; + const map = root(() => + computed + ? createReactiveWeakMap(emptyCompute) + : createReactiveWeakMap(), + ); + const old = installValue(value => { + map.set(key, value); + }); + const disposeReader = createRoot(dispose => { + const read = () => Boolean(map.get(key)); + observe(() => + companion === "latest" + ? latest(read) + : companion === "isPending" + ? isPending(read) + : read(), + ); + return dispose; + }); + flush(); + disposeReader(); + flush(); + if (operation === "overwrite") map.set(key, { next: true }); + else map.delete(key); + flush(); + await collect(); + assert.equal(old.deref(), undefined); + assert.equal(map.has(key), operation === "overwrite"); + }); + } + } +} + +function ephemeral(map: WeakMap, cyclic: boolean) { + const key = {}; + const value = cyclic ? { key } : 123; + map.set(key, value); + flush(); + return { key: new WeakRef(key), value: cyclic ? new WeakRef(value as object) : undefined }; +} + +for (const optimistic of [false, true]) { + for (const cyclic of [false, true]) { + test(`${optimistic ? "optimistic" : "plain"} static map releases ${cyclic ? "value-key cycles" : "primitive-value keys"}`, async () => { + const fixture = root(() => { + const key = {}; + const value = cyclic ? { key } : 123; + return { + map: createReactiveWeakMap([[key, value]], { optimistic }), + key: new WeakRef(key), + value: cyclic ? new WeakRef(value as object) : undefined, + }; + }); + await collect(); + assert.equal(fixture.key.deref(), undefined); + assert.equal(fixture.value?.deref(), undefined); + assert.equal(fixture.map.has({}), false); + }); + } + + test(`${optimistic ? "optimistic" : "plain"} computed map releases internally generated key while producer lives`, async () => { + let refs!: { key: WeakRef; value: WeakRef }; + const map = root(() => + createReactiveWeakMap( + draft => { + const key = {}; + const value = { key }; + draft.set(key, value); + refs = { key: new WeakRef(key), value: new WeakRef(value) }; + }, + { optimistic }, + ), + ); + assert.equal(map.has({}), false); + flush(); + await collect(); + assert.equal(refs.key.deref(), undefined); + assert.equal(refs.value.deref(), undefined); + assert.equal(map.has({}), false); + }); + + for (const companion of ["ordinary", "isPending", "latest"] as const) { + test(`${optimistic ? "optimistic" : "plain"} disposed ${companion} readers release keys and values`, async () => { + const fixture = root(() => { + const key = {}; + const value = { key }; + const map = createReactiveWeakMap([[key, value]], { optimistic }); + const disposeReader = createRoot(dispose => { + const read = () => map.get(key); + observe(() => map.has(key)); + observe(() => + companion === "isPending" + ? isPending(read) + : companion === "latest" + ? latest(read) + : read(), + ); + return dispose; + }); + flush(); + disposeReader(); + flush(); + return { map, key: new WeakRef(key), value: new WeakRef(value) }; + }); + await collect(); + assert.equal(fixture.key.deref(), undefined); + assert.equal(fixture.value.deref(), undefined); + assert.equal(fixture.map.has({}), false); + }); + } + + test(`${optimistic ? "optimistic" : "plain"} WeakSet does not retain initializer or keys`, async () => { + const fixture = root(() => { + const key = {}; + const input = [key]; + return { + set: createReactiveWeakSet(input, { optimistic }), + key: new WeakRef(key), + input: new WeakRef(input), + }; + }); + await collect(); + assert.equal(fixture.key.deref(), undefined); + assert.equal(fixture.input.deref(), undefined); + assert.equal(fixture.set.has({}), false); + }); +} + +test("computed reader companions do not retain unreachable keys and values", async () => { + const fixture = root(() => { + const key = {}; + const value = { key }; + const map = createReactiveWeakMap(emptyCompute); + map.set(key, value); + const disposeReader = createRoot(dispose => { + observe(() => isPending(() => map.get(key))); + observe(() => latest(() => map.get(key))); + return dispose; + }); + flush(); + disposeReader(); + flush(); + return { map, key: new WeakRef(key), value: new WeakRef(value) }; + }); + await collect(); + assert.equal(fixture.key.deref(), undefined); + assert.equal(fixture.value.deref(), undefined); + assert.equal(fixture.map.has({}), false); +}); + +test("values are collectible before metadata cleanup; next ordinary write prunes finalized slots", async () => { + const map = root(() => createReactiveWeakMap()); + const refs = ephemeral(map, true); + const count = () => Object.keys(inspect(map)).filter(key => key.startsWith("k:")).length; + assert.equal(count(), 1); + await collect(); + assert.equal(refs.key.deref(), undefined); + assert.equal(refs.value!.deref(), undefined); + assert.equal(count(), 1, "a finalizer must only queue maintenance, never invoke the setter"); + const survivor = {}; + map.set(survivor, 1); + flush(); + assert.equal(count(), 1, "ordinary writes prune finalized metadata before inserting"); + assert.equal(map.get(survivor), 1); +}); + +test("optimistic action releases its captured key and value after settlement", async () => { + const map = root(() => createReactiveWeakMap(undefined, { optimistic: true })); + const gate = deferred(); + const fixture = (() => { + const key = {}; + const value = { key }; + return { + key: new WeakRef(key), + value: new WeakRef(value), + pending: action(function* () { + map.set(key, value); + yield gate.promise; + })(), + }; + })(); + flush(); + gate.resolve(); + await fixture.pending; + await tick(); + await collect(); + assert.equal(fixture.key.deref(), undefined); + assert.equal(fixture.value.deref(), undefined); + assert.equal(map.has({}), false); +}); + +test("settled async draft releases ephemeral keys while the computed collection remains live", async () => { + let refs!: { key: WeakRef; value: WeakRef }; + const gate = deferred(); + const map = root(() => + createReactiveWeakMap( + async draft => { + await gate.promise; + const key = {}; + const value = { key }; + draft.set(key, value); + refs = { key: new WeakRef(key), value: new WeakRef(value) }; + }, + { loadingValue: [] }, + ), + ); + assert.equal(map.has({}), false); + gate.resolve(); + await tick(); + await collect(); + assert.equal(refs.key.deref(), undefined); + assert.equal(refs.value.deref(), undefined); + assert.equal(map.has({}), false); +}); + +test("pending loading seed releases its initializer and key without waiting for a flight", async () => { + const gate = deferred(); + // Construct the async callback outside the key's lexical environment. + const compute = async () => { + await gate.promise; + }; + const fixture = root(() => { + const key = {}; + const value = { key }; + const seed: [object, object][] = [[key, value]]; + return { + map: createReactiveWeakMap(compute, { loadingValue: seed }), + key: new WeakRef(key), + value: new WeakRef(value), + seed: new WeakRef(seed), + }; + }); + assert.equal(fixture.map.has({}), false); + await collect(); + assert.equal(fixture.key.deref(), undefined); + assert.equal(fixture.value.deref(), undefined); + assert.equal(fixture.seed.deref(), undefined); + gate.resolve(); + await tick(); +}); + +for (const operation of ["overwrite", "delete"] as const) { + test(`${operation} releases the obsolete value while the key remains live`, async () => { + const key = {}; + const map = root(() => createReactiveWeakMap()); + const old = (() => { + const value = { key }; + map.set(key, value); + flush(); + return new WeakRef(value); + })(); + if (operation === "overwrite") map.set(key, { next: true }); + else map.delete(key); + flush(); + await collect(); + assert.equal(old.deref(), undefined); + assert.equal(map.has(key), operation === "overwrite"); + }); +} + +test("computed WeakSet releases internally created members after an async commit", async () => { + let ref!: WeakRef; + const gate = deferred(); + const set = root(() => + createReactiveWeakSet( + async draft => { + await gate.promise; + const key = {}; + draft.add(key); + ref = new WeakRef(key); + }, + { loadingValue: [] }, + ), + ); + assert.equal(set.has({}), false); + gate.resolve(); + await tick(); + await collect(); + assert.equal(ref.deref(), undefined); + assert.equal(set.has({}), false); +}); + +test("async iterable replacement result does not retain its returned entries", async () => { + let refs!: { key: WeakRef; value: WeakRef; entries: WeakRef }; + const gate = deferred(); + const map = root(() => + createReactiveWeakMap( + async () => { + await gate.promise; + const key = {}; + const value = { key }; + const entries: [object, object][] = [[key, value]]; + refs = { key: new WeakRef(key), value: new WeakRef(value), entries: new WeakRef(entries) }; + return entries; + }, + { loadingValue: [] }, + ), + ); + assert.equal(map.has({}), false); + gate.resolve(); + await tick(); + await collect(); + assert.equal(refs.key.deref(), undefined); + assert.equal(refs.value.deref(), undefined); + assert.equal(refs.entries.deref(), undefined); + assert.equal(map.has({}), false); +}); + +test("dependency reruns do not retain internally generated keys from prior synchronous drafts", async () => { + const refs: WeakRef[] = []; + const fixture = root(() => { + const [version, next] = createSignal(0); + const map = createReactiveWeakMap(draft => { + version(); + const key = {}; + draft.set(key, { key }); + refs.push(new WeakRef(key)); + }); + return { map, next }; + }); + assert.equal(fixture.map.has({}), false); + flush(); + fixture.next(1); + flush(); + assert.equal(fixture.map.has({}), false); + assert.equal(refs.length, 2); + await collect(); + assert.ok(refs.every(ref => ref.deref() === undefined)); + assert.equal(fixture.map.has({}), false); +}); + +test("static optimistic maintenance remains committed across later action rollbacks", async () => { + const fixture = root(() => { + const key = {}; + return { + map: createReactiveWeakMap([[key, { key }]], { optimistic: true }), + key: new WeakRef(key), + }; + }); + await collect(); + assert.equal(fixture.key.deref(), undefined); + const count = () => Object.keys(inspect(fixture.map)).filter(key => key.startsWith("k:")).length; + assert.equal(count(), 0, "maintenance projection authoritatively prunes finalized seed metadata"); + const gate = deferred(); + const survivor = {}; + const pending = action(function* () { + fixture.map.set(survivor, 1); + yield gate.promise; + })(); + flush(); + assert.equal(count(), 1, "only the optimistic insertion remains visible"); + gate.resolve(); + await pending; + await tick(); + assert.equal(count(), 0, "rollback cannot restore the pruned metadata"); + const secondGate = deferred(); + const second = action(function* () { + fixture.map.set(survivor, 2); + yield secondGate.promise; + })(); + flush(); + assert.equal(count(), 1); + secondGate.resolve(); + await second; + await tick(); + assert.equal(count(), 0); +}); + +test("static optimistic maintenance during an unrelated action preserves its override", async () => { + const survivor = {}; + const fixture = root(() => { + const key = {}; + return { + map: createReactiveWeakMap( + [ + [key, { key }], + [survivor, 0], + ], + { optimistic: true }, + ), + key: new WeakRef(key), + }; + }); + const count = () => Object.keys(inspect(fixture.map)).filter(key => key.startsWith("k:")).length; + const gate = deferred(); + const pending = action(function* () { + fixture.map.set(survivor, 1); + yield gate.promise; + })(); + flush(); + await collect(); + assert.equal(fixture.key.deref(), undefined); + assert.equal( + fixture.map.get(survivor), + 1, + "maintenance must preserve the live action's visible edit", + ); + gate.resolve(); + await pending; + await tick(); + assert.equal(fixture.map.get(survivor), 0); + assert.equal(count(), 1, "after settlement only the live key's metadata remains"); +}); + +test("computed optimistic ordinary writes preserve maintenance IDs for the next authoritative derive", async () => { + let dead!: WeakRef; + const survivor = {}; + const fixture = root(() => { + const [version, next] = createSignal(0); + const map = createReactiveWeakMap( + draft => { + if (version() === 0) { + const key = {}; + draft.set(key, { key }); + dead = new WeakRef(key); + } + }, + { optimistic: true }, + ); + return { map, next }; + }); + assert.equal(fixture.map.has({}), false); + flush(); + await collect(); + assert.equal(dead.deref(), undefined); + const count = () => Object.keys(inspect(fixture.map)).filter(key => key.startsWith("k:")).length; + assert.equal(count(), 1); + const gate = deferred(); + const pending = action(function* () { + fixture.map.set(survivor, 1); + yield gate.promise; + })(); + flush(); + assert.equal(count(), 2, "ordinary optimistic edits defer authoritative cleanup"); + gate.resolve(); + await pending; + await tick(); + assert.equal(count(), 1); + fixture.next(1); + flush(); + assert.equal(fixture.map.has({}), false); + assert.equal(count(), 0, "next derive still receives the queued dead-slot ID"); +}); + +test("a key explicitly captured by a live compute remains reachable by design", async () => { + const fixture = root(() => { + const key = {}; + const map = createReactiveWeakMap(draft => { + draft.set(key, { key }); + }); + assert.equal(map.get(key)?.key, key); + return { map, key: new WeakRef(key) }; + }); + await collect(); + assert.ok(fixture.key.deref()); + assert.equal(fixture.map.has(fixture.key.deref()!), true); +}); diff --git a/packages/collections/test/integration/weak-codec.test.ts b/packages/collections/test/integration/weak-codec.test.ts new file mode 100644 index 000000000..f2e3f8d55 --- /dev/null +++ b/packages/collections/test/integration/weak-codec.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { runInNewContext } from "node:vm"; +import { + createSerializer, + createJSONDeserializer, + serializeJSON, + getLocalHeaderScript, +} from "@solidjs/web/serialization"; +import { WeakCollectionTokenPlugin } from "@solid-primitives/collections/serialization"; +import { createWeakCollectionToken, isWeakCollectionToken } from "../../src/weak-token.js"; + +function source() { + const key = { tag: "shared" }; + const value = { key, date: new Date(1234), self: undefined as unknown }; + value.self = value; + return { + key, + value, + metadata: createWeakCollectionToken(key, key), + token: createWeakCollectionToken(key, value), + missing: Object.freeze({ $weak: true, ref: undefined, values: new WeakMap() }), + undefined: createWeakCollectionToken(key, undefined), + }; +} + +function verify(value: ReturnType) { + expect(value.metadata.ref?.deref()).toBe(value.key); + expect(value.metadata.values.get(value.key)).toBe(value.key); + expect(value.token.ref?.deref()).toBe(value.key); + expect(value.token.values.get(value.key)).toBe(value.value); + expect(value.value.key).toBe(value.key); + expect(value.value.self).toBe(value.value); + expect(value.value.date.getTime()).toBe(1234); + expect(value.missing.ref).toBeUndefined(); + expect(value.undefined.values.has(value.key)).toBe(true); + expect(value.undefined.values.get(value.key)).toBeUndefined(); + expect(Object.isFrozen(value.token)).toBe(true); +} + +async function collect() { + expect(typeof globalThis.gc).toBe("function"); + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + globalThis.gc!(); + } +} + +function decodeWeakOnlyJSON() { + const decode = createJSONDeserializer({ plugins: [WeakCollectionTokenPlugin] }); + let token: ReturnType["token"]; + serializeJSON(source().token, { + plugins: [WeakCollectionTokenPlugin], + onParse: node => { + token = decode(JSON.parse(JSON.stringify(node))); + }, + onError: error => { + throw error; + }, + }); + return { token: token!, owner: { decode: decode as typeof decode | undefined } }; +} + +function decodeWeakOnlyScript() { + const scripts: string[] = []; + const serializer = createSerializer({ + globalIdentifier: "self.data", + plugins: [WeakCollectionTokenPlugin], + onData: script => scripts.push(script), + onError: error => { + throw error; + }, + }); + serializer.write("entry", source().token); + serializer.close(); + const context = { + data: {} as Record["token"]>, + self: undefined as unknown, + $R: undefined as unknown, + }; + context.self = context; + runInNewContext(getLocalHeaderScript() + scripts.join(";"), context); + return { token: context.data.entry!, context }; +} + +describe("weak leaf transport", () => { + it("reconstructs script values without a client runtime or custom globals", () => { + const scripts: string[] = []; + const serializer = createSerializer({ + globalIdentifier: "self.data", + plugins: [WeakCollectionTokenPlugin], + onData: script => scripts.push(script), + onError: error => { + throw error; + }, + }); + serializer.write("entry", source()); + serializer.close(); + const context = { + data: {} as Record>, + self: undefined as unknown, + }; + context.self = context; + runInNewContext(getLocalHeaderScript() + scripts.join(";"), context); + verify(context.data.entry!); + }); + + it("preserves shared key/value identity in JSON codec", async () => { + const decode = createJSONDeserializer({ plugins: [WeakCollectionTokenPlugin] }); + const result = await new Promise>((resolve, reject) => { + let value: ReturnType; + serializeJSON(source(), { + plugins: [WeakCollectionTokenPlugin], + onParse(node, initial) { + const decoded = decode(JSON.parse(JSON.stringify(node))); + if (initial) value = decoded as ReturnType; + }, + onDone: () => resolve(value), + onError: reject, + }); + }); + verify(result); + expect(isWeakCollectionToken(result.token)).toBe(true); + }); + + it("requires codec registration rather than silently dropping weak state", () => { + const serializer = createSerializer({ + globalIdentifier: "self.data", + onData() {}, + onError: error => { + throw error; + }, + }); + expect(() => serializer.write("entry", source())).toThrow(); + }); + + it("records JSON reference-table retention and releases keys when the decoder is released", async () => { + const decoded = decodeWeakOnlyJSON(); + await collect(); + expect(decoded.token.ref?.deref()).toBeDefined(); + decoded.owner.decode = undefined; + await collect(); + expect(decoded.token.ref?.deref()).toBeUndefined(); + }); + + it("records script reference-table retention and proves tokens become weak after scope release", async () => { + const decoded = decodeWeakOnlyScript(); + await collect(); + expect(decoded.token.ref?.deref()).toBeDefined(); + // Characterization only: a primitive must not clear this shared table; + // other hydration consumers or later stream patches may still need it. + (decoded.context.$R as unknown[]).length = 0; + decoded.context.$R = undefined; + await collect(); + expect(decoded.token.ref?.deref()).toBeUndefined(); + }); +}); diff --git a/packages/collections/test/integration/weak-collections.test.ts b/packages/collections/test/integration/weak-collections.test.ts new file mode 100644 index 000000000..02b17d27e --- /dev/null +++ b/packages/collections/test/integration/weak-collections.test.ts @@ -0,0 +1,459 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + $TARGET, + action, + createEffect, + createRoot, + createSignal, + flush, + isPending, + latest, + NotReadyError, + refresh, +} from "@solidjs/signals"; +import { createReactiveWeakMap, createReactiveWeakSet } from "@solid-primitives/collections"; + +const disposers: (() => void)[] = []; +function root(fn: () => T): T { + return createRoot(dispose => { + disposers.push(dispose); + return fn(); + }); +} +afterEach(() => { + while (disposers.length) disposers.pop()!(); + flush(); +}); +const tick = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); + flush(); +}; +function gate() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function observe(fn: () => T) { + let runs = 0; + const values: T[] = []; + createEffect( + () => { + runs++; + return fn(); + }, + value => { + values.push(value); + }, + ); + return { + values, + get runs() { + return runs; + }, + }; +} + +describe("weak collection store slots", () => { + it("allocates independent lazy nodes for membership and value", () => { + const a = {}, + b = {}; + const map = root(() => createReactiveWeakMap([[a, 1]])); + const state = (map as any).data.state[$TARGET]; + expect(state.n).toBeNull(); + expect(state.h).toBeNull(); + expect(map.has(a)).toBe(true); + expect(map.get(a)).toBe(1); + expect(state.n).toBeNull(); + expect(state.h).toBeNull(); + const [ha, va, hb, vb] = root(() => [ + observe(() => map.has(a)), + observe(() => map.get(a)), + observe(() => map.has(b)), + observe(() => map.get(b)), + ]); + flush(); + expect(Object.keys(state.n)).toHaveLength(2); + expect(Object.keys(state.h)).toHaveLength(2); + map.set(a, 2); + flush(); + expect([ha.runs, va.runs, hb.runs, vb.runs]).toEqual([1, 2, 1, 1]); + map.set(b, 3); + flush(); + expect([ha.runs, va.runs, hb.runs, vb.runs]).toEqual([1, 2, 2, 2]); + }); + + it("preserves undefined membership, identity and unchanged-value equality", () => { + const key = {}, + value = { key }, + fn = () => key; + const map = root(() => createReactiveWeakMap()); + const reader = root(() => observe(() => map.get(key))); + flush(); + map.set(key, undefined); + flush(); + expect(map.has(key)).toBe(true); + expect(reader.runs).toBe(1); + map.delete(key); + flush(); + expect(reader.runs).toBe(1); + for (const item of [value, fn, NaN, Symbol("value")]) { + map.set(key, item); + flush(); + expect(map.get(key)).toBe(item); + const before = reader.runs; + map.set(key, item); + flush(); + expect(reader.runs).toBe(before); + } + expect(Object.keys(map)).toEqual([]); + }); + + it("supports native weak key types and rejects invalid writes", () => { + const local = Symbol("local"), + fn = () => {}; + const map = root(() => + createReactiveWeakMap([ + [local, 1], + [fn, 2], + ]), + ); + expect(map.get(local)).toBe(1); + expect(map.get(fn)).toBe(2); + for (const key of [null, 1, "x", Symbol.for("registered")]) { + expect(() => map.set(key as never, 3)).toThrow(TypeError); + expect(map.has(key as never)).toBe(false); + expect(map.get(key as never)).toBeUndefined(); + expect(map.delete(key as never)).toBe(false); + } + }); + + it("supports reactive WeakSet membership and draft mutations", () => { + const a = {}, + b = {}; + const [which, update] = createSignal(false); + const set = root(() => + createReactiveWeakSet(draft => { + draft.delete(which() ? a : b); + draft.add(which() ? b : a); + }), + ); + const [ra, rb] = root(() => [observe(() => set.has(a)), observe(() => set.has(b))]); + flush(); + expect([set.has(a), set.has(b)]).toEqual([true, false]); + update(true); + flush(); + expect([ra.values, rb.values]).toEqual([ + [true, false], + [false, true], + ]); + expect(set.add(a)).toBe(set); + flush(); + expect(set.has(a)).toBe(true); + }); + + it("replaces from an iterable and tracks only entries whose value changes", () => { + const a = {}, + b = {}; + const [value, update] = createSignal(0); + const map = root(() => + createReactiveWeakMap( + () => + [ + [a, value()], + [b, 2], + ] as const, + ), + ); + const [ra, rb] = root(() => [observe(() => map.get(a)), observe(() => map.get(b))]); + flush(); + update(1); + flush(); + expect(ra.values).toEqual([0, 1]); + expect(rb.runs).toBe(1); + }); + + for (const optimistic of [false, true]) { + it(`reads raw keys/values through async drafts, optimistic=${optimistic}`, async () => { + const key = {}, + value = Object.freeze({ key }), + pending = gate(); + const map = root(() => + createReactiveWeakMap( + async draft => { + draft.set(key, value); + expect(draft.get(key)).toBe(value); + await pending.promise; + expect(draft.get(key)).toBe(value); + }, + { optimistic }, + ), + ); + expect(() => map.has(key)).toThrow(NotReadyError); + pending.resolve(); + await tick(); + expect(map.get(key)).toBe(value); + }); + + it(`isolates loading seeds from partial drafts, optimistic=${optimistic}`, async () => { + const key = {}, + pending = gate(); + const map = root(() => + createReactiveWeakMap( + async draft => { + expect(draft.get(key)).toBe(0); + draft.set(key, 1); + await pending.promise; + draft.set(key, 2); + }, + { optimistic, loadingValue: [[key, 0]] }, + ), + ); + const reader = root(() => observe(() => map.get(key))); + flush(); + expect(map.get(key)).toBe(0); + pending.resolve(); + await tick(); + expect(map.get(key)).toBe(2); + expect(reader.values).toEqual([0, 2]); + }); + + it(`propagates pending and rejects superseded draft writes, optimistic=${optimistic}`, async () => { + const key = {}, + gates = [gate(), gate()]; + const [phase, update] = createSignal(0); + const map = root(() => + createReactiveWeakMap( + draft => { + const current = phase(); + if (current === 0) { + draft.set(key, 0); + return; + } + return gates[current - 1]!.promise.then(() => { + draft.set(key, current); + }); + }, + { optimistic }, + ), + ); + const reader = root(() => observe(() => map.get(key))); + flush(); + update(1); + flush(); + expect(isPending(() => map.get(key))).toBe(true); + update(2); + flush(); + gates[1]!.resolve(); + await tick(); + expect(map.get(key)).toBe(2); + expect(isPending(() => map.get(key))).toBe(false); + gates[0]!.resolve(); + await tick(); + expect(map.get(key)).toBe(2); + expect(reader.values).toEqual([0, 2]); + }); + + it(`ignores draft mutations after owner disposal, optimistic=${optimistic}`, async () => { + const key = {}, + pending = gate(); + let map!: WeakMap; + const dispose = createRoot(dispose => { + map = createReactiveWeakMap( + async draft => { + await pending.promise; + draft.set(key, 2); + }, + { optimistic, loadingValue: [[key, 0]] }, + ); + return dispose; + }); + dispose(); + pending.resolve(); + await tick(); + expect(map.get(key)).toBe(0); + }); + + it(`supports streaming drafts and terminal edits, optimistic=${optimistic}`, async () => { + const a = {}, + b = {}, + pending = gate(); + const set = root(() => + createReactiveWeakSet( + async function* (draft) { + draft.add(a); + yield; + await pending.promise; + draft.delete(a); + draft.add(b); + }, + { optimistic }, + ), + ); + const reader = root(() => observe(() => [set.has(a), set.has(b)])); + await tick(); + expect([set.has(a), set.has(b)]).toEqual([true, false]); + pending.resolve(); + await tick(); + expect([set.has(a), set.has(b)]).toEqual([false, true]); + expect(reader.values.at(-1)).toEqual([false, true]); + }); + } + + it("keeps held truth isolated for observed and previously unread keys", async () => { + const a = {}, + b = {}, + pending = gate(); + const [value, update] = createSignal(0); + const map = root(() => + createReactiveWeakMap( + () => + [ + [a, value()], + [b, value()], + ] as const, + ), + ); + const reader = root(() => observe(() => map.get(a))); + flush(); + const held = action(function* () { + yield pending.promise; + })(); + update(1); + flush(); + expect(map.get(a)).toBe(0); + expect(map.get(b)).toBe(0); + expect(latest(() => map.get(b))).toBe(1); + expect(reader.values).toEqual([0]); + pending.resolve(); + await held; + await tick(); + expect(map.get(b)).toBe(1); + }); + + it("keeps overlapping structural actions coherent through shared store settlement", async () => { + const a = {}, + b = {}, + gates = [gate(), gate()]; + const map = root(() => createReactiveWeakMap([[a, 1]], { optimistic: true })); + const first = action(function* () { + map.delete(a); + yield gates[0]!.promise; + })(); + flush(); + const second = action(function* () { + map.set(b, 2); + yield gates[1]!.promise; + })(); + flush(); + expect(map.has(a)).toBe(false); + expect(map.get(b)).toBe(2); + gates[1]!.resolve(); + await tick(); + // Optimistic stores share an internal key-set slot even with no iterator. + // Distinct membership edits therefore settle together, like strong maps. + expect(map.has(a)).toBe(false); + expect(map.has(b)).toBe(true); + gates[0]!.resolve(); + await Promise.all([first, second]); + await tick(); + expect(map.get(a)).toBe(1); + expect(map.has(b)).toBe(false); + }); + + it("settles optimistic value edits to existing keys independently", async () => { + const a = {}, + b = {}, + gates = [gate(), gate()]; + const map = root(() => + createReactiveWeakMap( + [ + [a, 1], + [b, 2], + ], + { optimistic: true }, + ), + ); + const first = action(function* () { + map.set(a, 10); + yield gates[0]!.promise; + })(); + flush(); + const second = action(function* () { + map.set(b, 20); + yield gates[1]!.promise; + })(); + flush(); + gates[1]!.resolve(); + await second; + await tick(); + expect(map.get(a)).toBe(10); + expect(map.get(b)).toBe(2); + gates[0]!.resolve(); + await first; + await tick(); + expect(map.get(a)).toBe(1); + }); + + it("retains optimism until authoritative refetch lands", async () => { + const key = {}, + gates = [gate(), gate()]; + const [phase, update] = createSignal(0); + const map = root(() => + createReactiveWeakMap( + async draft => { + const current = phase(); + await gates[current]!.promise; + draft.set(key, current); + }, + { optimistic: true }, + ), + ); + root(() => observe(() => map.get(key))); + gates[0]!.resolve(); + await tick(); + update(1); + map.set(key, 10); + flush(); + expect(map.get(key)).toBe(10); + gates[1]!.resolve(); + await tick(); + expect(map.get(key)).toBe(1); + }); + + it("refreshes computed entries", async () => { + const key = {}; + let version = 0; + const map = root(() => + createReactiveWeakMap(draft => { + draft.set(key, version); + }), + ); + root(() => observe(() => map.get(key))); + flush(); + version = 1; + await refresh(map); + await tick(); + expect(map.get(key)).toBe(1); + }); + + it("surfaces rejected computes and recovers on refresh", async () => { + const key = {}; + let fail = true; + const map = root(() => + createReactiveWeakMap(async draft => { + if (fail) throw new Error("weak failure"); + draft.set(key, 1); + }), + ); + await tick(); + expect(() => map.get(key)).toThrow("weak failure"); + fail = false; + await refresh(map); + await tick(); + expect(map.get(key)).toBe(1); + }); +}); diff --git a/packages/collections/test/integration/weak-hydration.test.tsx b/packages/collections/test/integration/weak-hydration.test.tsx new file mode 100644 index 000000000..77f959d15 --- /dev/null +++ b/packages/collections/test/integration/weak-hydration.test.tsx @@ -0,0 +1,85 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { action, flush } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { WeakTokenFixture, kinds, current } from "./fixtures/weak-collections.js"; +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +function apply(container: Element, chunk: string, first: boolean) { + const pattern = /]*)>([\s\S]*?)<\/script>/g; + const scripts = [...chunk.matchAll(pattern)].map(m => m[1]!); + const markup = chunk.replace(pattern, ""); + if (first) container.innerHTML = markup; + else container.insertAdjacentHTML("beforeend", markup); + for (const script of scripts) (0, eval)(script); +} +describe("real weak token projection hydration", () => { + for (const kind of kinds) + for (const optimistic of [false, true]) + for (const mode of ["loaded", "streamed"]) { + it(`${kind}, optimistic=${optimistic}, ${mode}`, async () => { + const artifact = JSON.parse( + readFileSync( + resolve(`node_modules/.cache/weak-ssr/${kind}-${optimistic}.json`), + "utf8", + ), + ); + const container = document.createElement("div"); + document.body.appendChild(container); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let dispose: (() => void) | undefined; + try { + apply(container, artifact.shell, true); + if (mode === "loaded" && artifact.rest) apply(container, artifact.rest, false); + const section = container.querySelector("section"); + dispose = hydrate( + () => , + container, + ); + flush(); + await sleep(kind === "hybrid" ? 50 : 10); + flush(); + if (mode === "streamed" && artifact.rest) apply(container, artifact.rest, false); + const n = kind === "stream" || kind === "hybrid" ? 10 : 0; + const expectValue = async (count: number) => + vi.waitFor( + () => { + flush(); + expect(current.read()).toBe(`true:true:true:${count}`); + expect(container.textContent).toBe(`true:true:true:${count}after`); + }, + { timeout: 1500, interval: 10 }, + ); + await expectValue(n); + expect(container.querySelector("section")).toBe(section); + if (kind === "static" && optimistic) { + let settle!: () => void; + const gate = new Promise(resolve => { + settle = resolve; + }); + const pending = action(function* () { + current.update(); + yield gate; + })(); + await expectValue(1); + settle(); + await pending; + await expectValue(0); + } else { + current.update(); + await expectValue(n + 1); + await current.refresh(); + await expectValue(n + 1); + } + expect(warn).not.toHaveBeenCalled(); + } finally { + dispose?.(); + await sleep(0); + warn.mockRestore(); + container.remove(); + } + }); + } +}); diff --git a/packages/collections/test/integration/weak-render.test.tsx b/packages/collections/test/integration/weak-render.test.tsx new file mode 100644 index 000000000..89db1f257 --- /dev/null +++ b/packages/collections/test/integration/weak-render.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { renderToStream } from "@solidjs/web"; +import { WeakCollectionTokenPlugin } from "@solid-primitives/collections/serialization"; +import { WeakTokenFixture, kinds } from "./fixtures/weak-collections.js"; +const directory = resolve("node_modules/.cache/weak-ssr"); +mkdirSync(directory, { recursive: true }); +describe("SSR weak token projections", () => { + for (const kind of kinds) + for (const optimistic of [false, true]) { + it(`${kind}, optimistic=${optimistic}`, async () => { + const chunks: string[] = []; + let shell = "", + shellDone = false; + await new Promise((resolve, reject) => { + renderToStream(() => , { + plugins: [WeakCollectionTokenPlugin], + onCompleteShell() { + shellDone = true; + }, + onError: reject, + }).pipe({ + write(chunk: string) { + chunks.push(chunk); + if (shellDone && !shell) shell = chunks.join(""); + }, + end: resolve, + }); + }); + const full = chunks.join(""); + if (!shell) shell = full; + expect(full.replace(//g, "").replace(/<[^>]*>/g, "")).toContain( + kind === "loading" || kind === "client" ? "placeholder" : "true:true:true:0", + ); + writeFileSync( + resolve(directory, `${kind}-${optimistic}.json`), + JSON.stringify({ shell, rest: full.slice(shell.length) }), + ); + }); + } +}); diff --git a/packages/collections/tsconfig.json b/packages/collections/tsconfig.json new file mode 100644 index 000000000..a8def1699 --- /dev/null +++ b/packages/collections/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": false + }, + "include": ["src"] +} diff --git a/packages/collections/tsconfig.test.json b/packages/collections/tsconfig.test.json new file mode 100644 index 000000000..299f7bf9d --- /dev/null +++ b/packages/collections/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "isolatedDeclarations": false, "noEmit": true, "types": ["node"] }, + "include": [ + "test/integration/collections.type-tests.ts", + "test/integration/gc/weak-collections.test.ts" + ] +} diff --git a/packages/collections/tsdown.config.ts b/packages/collections/tsdown.config.ts new file mode 100644 index 000000000..fde2e2108 --- /dev/null +++ b/packages/collections/tsdown.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsdown"; +import shared from "../../tsdown.config.ts"; + +export default defineConfig({ + ...shared, + name: "@solid-primitives/collections", + workspace: false, + entry: ["src/index.ts", "src/serialization.ts"], + tsconfig: "tsconfig.json", +}); diff --git a/packages/collections/vitest.config.ts b/packages/collections/vitest.config.ts new file mode 100644 index 000000000..0c9d968f4 --- /dev/null +++ b/packages/collections/vitest.config.ts @@ -0,0 +1,70 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { transformAsync } from "@babel/core"; +const require = createRequire(import.meta.url); +const babelSolid = require("babel-preset-solid"); +export default defineConfig(({ mode }) => ({ + root: fileURLToPath(new URL("./", import.meta.url)), + plugins: [ + { + name: "collection-test-jsx", + enforce: "pre", + async transform(code, id) { + if (!id.endsWith(".tsx") || id.includes("node_modules")) return; + const result = await transformAsync(code, { + filename: id, + configFile: false, + babelrc: false, + presets: [ + [ + babelSolid, + { + generate: mode === "ssr" ? "ssr" : "dom", + moduleName: "@solidjs/web", + hydratable: true, + }, + ], + ], + parserOpts: { plugins: ["jsx", "typescript"] }, + }); + return { code: result!.code!, map: result!.map }; + }, + }, + ], + resolve: { + conditions: [ + "@solid-primitives/source", + mode === "ssr" ? "node" : "browser", + ...(mode === "benchmark" ? [] : ["development"]), + ], + }, + test: { + include: + mode === "ssr" + ? [ + "test/integration/collections-server.test.ts", + "test/integration/collections-shallow-server.test.ts", + "test/integration/collections-render.test.tsx", + "test/integration/weak-render.test.tsx", + ] + : ["test/integration/*.test.{ts,tsx}"], + exclude: + mode === "ssr" + ? [] + : [ + "test/integration/collections-server.test.ts", + "test/integration/collections-shallow-server.test.ts", + "test/integration/collections-render.test.tsx", + "test/integration/weak-render.test.tsx", + ], + // Resolve runtime exports with this suite's client/server conditions. + server: { deps: { inline: ["solid-js", "@solidjs/signals", "@solidjs/web"] } }, + // Codec lifetime checks require real GC in an isolated process. + pool: "forks", + poolOptions: { forks: { execArgv: ["--expose-gc"] } }, + maxWorkers: 1, + minWorkers: 1, + benchmark: { include: ["test/integration/collections.bench.ts"] }, + }, +})); diff --git a/packages/map/README.md b/packages/map/README.md index 48577c739..9f1b731fe 100644 --- a/packages/map/README.md +++ b/packages/map/README.md @@ -14,6 +14,8 @@ The reactive versions of `Map` & `WeakMap` built-in data structures. - **[`ReactiveMap`](#reactivemap)** - A reactive `Map`. - **[`ReactiveWeakMap`](#reactiveweakmap)** - A reactive `WeakMap`. +> For computed, async, and optimistic collections, see [`@solid-primitives/collections`](../collections/README.md). + ## Installation ```bash diff --git a/packages/set/README.md b/packages/set/README.md index 2357367ea..9ab57b1c2 100644 --- a/packages/set/README.md +++ b/packages/set/README.md @@ -21,6 +21,8 @@ Reactive `Set` and `WeakSet` primitives, plus a suite of derived set-algebra ope | [`symmetricDifference`](#symmetricdifference) | `function` | Elements in `a` or `b`, but not both | | [`readonlySet`](#readonlyset) | `function` | Cast a `ReactiveSet` to `ReadonlySet` | +> For computed, async, and optimistic collections, see [`@solid-primitives/collections`](../collections/README.md). + ## Installation ```bash diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e975d8ec..a4a9b19fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -275,6 +275,18 @@ importers: specifier: 'catalog:' version: 2.0.0-rc.0 + packages/collections: + devDependencies: + '@solidjs/signals': + specifier: 'catalog:' + version: 2.0.0-rc.0 + '@solidjs/web': + specifier: 'catalog:' + version: 2.0.0-rc.0(solid-js@2.0.0-rc.0) + solid-js: + specifier: 'catalog:' + version: 2.0.0-rc.0 + packages/connectivity: dependencies: '@solid-primitives/event-listener': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 612b5c5fd..b9c421c13 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -49,6 +49,7 @@ catalogs: peer: solid-js: ^2.0.0-rc.0 '@solidjs/web': ^2.0.0-rc.0 + '@solidjs/signals': 2.0.0-rc.0 patchedDependencies: storybook-solidjs-vite@10.5.2: patches/storybook-solidjs-vite@10.5.2.patch