From 6ebc0200b09fdb6c3d82e03fb72550173beee3dd Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Thu, 21 May 2026 22:39:09 +0200 Subject: [PATCH 1/6] feat: new package: async --- packages/async/LICENSE | 21 +++++ packages/async/README.md | 41 ++++++++++ packages/async/dev/index.tsx | 20 +++++ packages/async/package.json | 59 ++++++++++++++ packages/async/src/index.ts | 123 +++++++++++++++++++++++++++++ packages/async/test/index.test.ts | 103 ++++++++++++++++++++++++ packages/async/test/server.test.ts | 9 +++ packages/async/tsconfig.json | 12 +++ packages/resource/README.md | 4 + 9 files changed, 392 insertions(+) create mode 100644 packages/async/LICENSE create mode 100644 packages/async/README.md create mode 100644 packages/async/dev/index.tsx create mode 100644 packages/async/package.json create mode 100644 packages/async/src/index.ts create mode 100644 packages/async/test/index.test.ts create mode 100644 packages/async/test/server.test.ts create mode 100644 packages/async/tsconfig.json diff --git a/packages/async/LICENSE b/packages/async/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/async/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/async/README.md b/packages/async/README.md new file mode 100644 index 000000000..37ee55d09 --- /dev/null +++ b/packages/async/README.md @@ -0,0 +1,41 @@ +

+ Solid Primitives async +

+ +# @solid-primitives/async + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/async?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/async) +[![version](https://img.shields.io/npm/v/@solid-primitives/async?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/async) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) + +A collection of primitves for handling of asynchronous memos, optimistic signals, stores and actions: + +- [`makeStreamable`](#makeStreamable) - wraps a fetch request to support web streams in memos or optimistic signals +- [`makeAbortable`](#makeabortable) - sets up an AbortSignal with auto-abort on re-fetch or timeout +- [`createAbortable`](#createabortable) - like `makeAbortable`, but with automatic abort on cleanup +- [`makeCache`](#makecache) - wraps the fetcher to cache the responses for a certain amount of time +- [`makeRetrying`](#makeretrying) - wraps the fetcher to retry requests after a delay + +## Installation + +```bash +npm install @solid-primitives/async +# or +yarn add @solid-primitives/async +# or +pnpm add @solid-primitives/async +``` + +## How to use it + +```ts +// TODO +``` + +## Demo + +You can use this template for publishing your demo on CodeSandbox: https://codesandbox.io/s/solid-primitives-demo-template-sz95h + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/async/dev/index.tsx b/packages/async/dev/index.tsx new file mode 100644 index 000000000..cff773f64 --- /dev/null +++ b/packages/async/dev/index.tsx @@ -0,0 +1,20 @@ +import { type Component, createSignal } from "solid-js"; + +const App: Component = () => { + const [count, setCount] = createSignal(0); + const increment = () => setCount(count() + 1); + + return ( +
+
+

Counter component

+

it's very important...

+ +
+
+ ); +}; + +export default App; diff --git a/packages/async/package.json b/packages/async/package.json new file mode 100644 index 000000000..9074ffda8 --- /dev/null +++ b/packages/async/package.json @@ -0,0 +1,59 @@ +{ + "name": "@solid-primitives/async", + "version": "0.0.100", + "description": "A template primitive example.", + "author": "Your Name ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/async", + "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": "async", + "stage": 0, + "list": [ + "createPrimitiveTemplate" + ], + "category": "Display & Media" + }, + "keywords": [ + "solid", + "primitives" + ], + "private": false, + "sideEffects": false, + "files": [ + "dist" + ], + "type": "module", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "import": { + "@solid-primitives/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "typesVersions": {}, + "scripts": { + "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", + "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "vitest2": "vitest -c ../../configs/vitest.config.solid2.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "peerDependencies": { + "solid-js": "2.0.0-beta.10" + }, + "devDependencies": { + "solid-js": "2.0.0-beta.10" + } +} diff --git a/packages/async/src/index.ts b/packages/async/src/index.ts new file mode 100644 index 000000000..f7f30be8f --- /dev/null +++ b/packages/async/src/index.ts @@ -0,0 +1,123 @@ +import { + onCleanup, +} from "solid-js"; + +const chained = new Map<() => AbortSignal, (() => void)[]>(); + +/** + * **aggregates web stream chunks into a memo** + * ```ts + * // from Response: + * const streamed = createMemo(fromStream(() => fetch(url()))); + * + * // from another ReadableStream: + * const streamed = createMemo(fromStream(() => getStream())); + * ``` + */ +export function fromStream(fetcher: (...args: Args) => Promise | Response | ReadableStream) { + return async function*(...args: Args) { + let parts = ''; + const source = await fetcher(...args); + const stream = source instanceof Response ? source.body : source; + const reader = stream?.getReader(); + if (!reader) { + console.warn('No ReadableStream found!') + return; + } + while (true) { + const { done, value } = await reader.read(); + if (done) return; + if (value) { + parts += typeof value === 'string' ? value : String.fromCharCode(value); + } + yield parts; + } + } +} + +export type AbortableReturn = [ + signal: () => AbortSignal, + abort: (reason?: string) => void, + filterAbortError: (err: any) => void, +] + +export type AbortableOptions = { + noAutoAbort?: boolean; + timeout?: number; + chainTo?: () => AbortSignal; +}; + +/** + * **Creates and handles an AbortSignal** + * ```ts + * const [signal, abort, filterAbortError] = + * makeAbortable({ timeout: 10000 }); + * const fetcher = (url) => fetch(url, { signal: signal() }) + * .catch(filterAbortError); // filters abort errors + * ``` + * Returns an accessor for the signal and the abort callback. + * + * Options are optional and include: + * - `timeout`: time in Milliseconds after which the fetcher aborts automatically + * - `noAutoAbort`: can be set to true to make a new source not automatically abort a previous request + * - `chainTo`: listen to another abort signal to abort this signal + */ +export function makeAbortable( + options: AbortableOptions = {}, +): AbortableReturn { + let controller: AbortController; + let timeout: NodeJS.Timeout | number | undefined; + const abort = (reason?: string) => { + timeout && clearTimeout(timeout); + controller?.abort(reason); + }; + if (options.chainTo) { + chained.set(options.chainTo, [...(chained.get(options.chainTo) || []), () => abort("chain abort")]); + } + function signal() { + if (!options.noAutoAbort && controller?.signal.aborted === false) { + abort("retry"); + } + controller = new AbortController(); + if (!controller) { throw new Error('AbortController is not supported!'); } + if (options.timeout) { + timeout = setTimeout(() => abort("timeout"), options.timeout); + } + controller.signal.addEventListener('abort', () => chained.get(signal)?.forEach(a => a())); + return controller.signal; + }; + return [ + signal, + abort, + err => { + if (err.name === "AbortError") { + return undefined; + } + throw err; + }, + ]; +} + +/** + * **Creates and handles an AbortSignal with automated cleanup** + * ```ts + * const [signal, abort, filterAbortError] = + * createAbortable(); + * const fetcher = (url) => fetch(url, { signal: signal() }) + * .catch(filterAbortError); // filters abort errors + * ``` + * Returns an accessor for the signal and the abort callback. + * + * Options are optional and include: + * - `timeout`: time in Milliseconds after which the fetcher aborts automatically + * - `noAutoAbort`: can be set to true to make a new source not automatically abort a previous request + * - `chainTo`: listen to another abort signal to abort this signal + */ + +export function createAbortable( + options?: AbortableOptions, +): [() => AbortSignal, () => void, (err: any) => void] { + const [signal, abort, filterAbortError] = makeAbortable(options); + onCleanup(abort); + return [signal, abort, filterAbortError]; +} \ No newline at end of file diff --git a/packages/async/test/index.test.ts b/packages/async/test/index.test.ts new file mode 100644 index 000000000..1996ff5eb --- /dev/null +++ b/packages/async/test/index.test.ts @@ -0,0 +1,103 @@ +import { describe, test, expect } from "vitest"; +import { createEffect, createMemo, createRoot } from "solid-js"; +import { fromStream, makeAbortable, createAbortable } from "../src/index.js"; + +const delay = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); + +describe("fromStream", () => { + const createStream = (data: string) => new ReadableStream({ + start(controller) { + const chars = data[Symbol.iterator](); + const step = () => { + const { value, done } = chars.next(); + if (done) return; + controller.enqueue(value.codePointAt(0)); + delay(15).then(step); + } + delay().then(step); + }, + pull(_controller) {}, + cancel: () => {}, + }); + + test("streams from response", () => new Promise(resolve => createRoot(dispose => { + const data = "solid is great!"; + const stream = createMemo(fromStream(() => delay().then(() => { + return new Response(createStream(data)); + }))); + + createEffect(stream, (parts) => { + expect(data.slice(0, parts.length)).toBe(parts); + if (parts.length === data.length) { + queueMicrotask(dispose); + resolve(); + } + }) + })), 2000); + + test("streams from web stream", () => new Promise(resolve => createRoot(dispose => { + const data = "solid is great!"; + const stream = createMemo(fromStream(() => delay().then(() => { + return createStream(data); + }))); + + createEffect(stream, (parts) => { + expect(data.slice(0, parts.length)).toBe(parts); + if (parts.length === data.length) { + queueMicrotask(dispose); + resolve(); + } + }) + })), 2000); +}); + +describe("makeAbortable", () => { + test("makes a fetcher abortable", () => { + const [signal, abort] = makeAbortable(); + const signal1 = signal(); + expect(signal1.aborted, "first signal should not be initially aborted").toBeFalsy(); + const signal2 = signal(); + expect(signal1.aborted, "first signal should be aborted after new request").toBeTruthy(); + expect(signal2, "already aborted signal should not be re-used").not.toBe(signal1); + expect(signal2.aborted, "second signal should not be initially aborted").toBeFalsy(); + abort(); + expect(signal2.aborted, "signal should be aborted when calling abort()").toBeTruthy(); + }); + + test("aborts on chained signal abort", () => { + const [sig1, abort] = makeAbortable(); + const [sig2] = makeAbortable({ chainTo: sig1 }); + const signal1 = sig1(), signal2 = sig2(); + expect(signal1.aborted, "first signal should not be initially aborted").toBeFalsy(); + abort(); + expect(signal2.aborted, "chained signal was not aborted by the chained signal abort").toBeTruthy(); + }); + + test("chained signal does not abort its parent", () => { + const [sig1] = makeAbortable(); + const [sig2, abort] = makeAbortable({ chainTo: sig1 }); + const signal1 = sig1(), signal2 = sig2(); + expect(signal2.aborted, "second signal should not be initially aborted").toBeFalsy(); + abort(); + expect(signal1.aborted, "signal chaining works in the wrong direction").toBeFalsy(); + }); + + test("filters (only) abort errors", async () => { + class AbortError extends Error { + constructor(msg: string) { + super(msg); + } + name = "AbortError"; + } + const [_signal, _abort, filterAbortError] = makeAbortable(); + await Promise.reject(new AbortError("test")) + .catch(filterAbortError) + .then(resolution => expect(resolution).toBeUndefined()) + .catch(err => expect.fail(err.message || "failed with error")); + const noAbortError = new Error("not an AbortError"); + await Promise.reject(noAbortError) + .catch(filterAbortError) + .then(() => expect.fail("filtered error that was not an AbortError")) + .catch(err => expect(err).toBe(noAbortError)); + }); +}); diff --git a/packages/async/test/server.test.ts b/packages/async/test/server.test.ts new file mode 100644 index 000000000..d0ba586ee --- /dev/null +++ b/packages/async/test/server.test.ts @@ -0,0 +1,9 @@ +import { describe, test, expect } from "vitest"; +import { createPrimitiveTemplate } from "../src/index.js"; + +describe("createPrimitiveTemplate", () => { + test("doesn't break in SSR", () => { + const [value, setValue] = createPrimitiveTemplate(true); + expect(value(), "initial value should be true").toBe(true); + }); +}); diff --git a/packages/async/tsconfig.json b/packages/async/tsconfig.json new file mode 100644 index 000000000..38c71ce71 --- /dev/null +++ b/packages/async/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [], + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/resource/README.md b/packages/resource/README.md index 1fe53c8cf..5092ed606 100644 --- a/packages/resource/README.md +++ b/packages/resource/README.md @@ -8,6 +8,10 @@ [![version](https://img.shields.io/npm/v/@solid-primitives/resource?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/resource) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) +> [!TIP] +> solid-js@>=2.0.0` no longer uses resources. You can find most of these helpers for the new version in the `@solid-primitives/async` package. + + A collection of composable primitives to augment [`createResource`](https://www.solidjs.com/docs/latest/api#createresource) - [`createAggregated`](#createaggregated) - wraps the resource to aggregate data instead of overwriting it From ddb14a31818d720cc522bbbb3840f1aaf0083eda Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Wed, 27 May 2026 16:59:34 +0200 Subject: [PATCH 2/6] feat: fromStream - use TextEncoder feat: fromJSONStream - new primitive test: improve tests feat: makeRetrying WIP --- packages/async/package.json | 7 +- packages/async/src/index.ts | 180 +++++++++++++++++++++++++++--- packages/async/test/index.test.ts | 65 ++++++++++- pnpm-lock.yaml | 6 + 4 files changed, 241 insertions(+), 17 deletions(-) diff --git a/packages/async/package.json b/packages/async/package.json index 9074ffda8..145997ee1 100644 --- a/packages/async/package.json +++ b/packages/async/package.json @@ -17,7 +17,12 @@ "name": "async", "stage": 0, "list": [ - "createPrimitiveTemplate" + "fromStream", + "fromJSONStream", + "makeAbortable", + "createAbortable", + "makeRetrying", + "createAggregated" ], "category": "Display & Media" }, diff --git a/packages/async/src/index.ts b/packages/async/src/index.ts index f7f30be8f..746a54b61 100644 --- a/packages/async/src/index.ts +++ b/packages/async/src/index.ts @@ -1,6 +1,5 @@ -import { - onCleanup, -} from "solid-js"; +import { onCleanup, createMemo } from "solid-js"; +import type { Accessor, ComputeFunction } from "solid-js"; const chained = new Map<() => AbortSignal, (() => void)[]>(); @@ -16,7 +15,7 @@ const chained = new Map<() => AbortSignal, (() => void)[]>(); */ export function fromStream(fetcher: (...args: Args) => Promise | Response | ReadableStream) { return async function*(...args: Args) { - let parts = ''; + let parts = '', decoder; const source = await fetcher(...args); const stream = source instanceof Response ? source.body : source; const reader = stream?.getReader(); @@ -28,13 +27,73 @@ export function fromStream(fetcher: (...args: const { done, value } = await reader.read(); if (done) return; if (value) { - parts += typeof value === 'string' ? value : String.fromCharCode(value); + if (typeof value !== 'string') { + parts += (decoder ??= new TextDecoder()).decode(value, { stream: true }); + } else { + parts += value; + } } yield parts; } } } +const endMatcher = /(?:\W)(t|tru?|f|fa|fals?|n|nul?)$/; +const endLiterals: Record = { + t: "rue", tr: "ue", tru: "e", + f: "alse", fa: "lse", fal: "se", fals: "e", + n: "ull", nu: "ll", nul: "l" +}; + +const closeJSONPart = (json: string) => + json.replace(/[,:]\s*$/, "") + + (endMatcher.test(json) && endLiterals[RegExp.$1] || "") + + [...json].reduce((stack: string[], char: string) => { + const close = ({ '"': '"', "[": "]", "{": "}" })[char]; + if (char === stack[0]) stack.shift(); + else if (close) stack.unshift(close); + return stack; + }, []).join(""); + +/** + * **aggregates web stream chunks into a memo supporting partial JSON** + * ```ts + * // from Response: + * const streamed = createMemo(fromStream(() => fetch(url()))); + * + * // from another ReadableStream: + * const streamed = createMemo(fromStream(() => getStream())); + * ``` + */ +export function fromJSONStream(fetcher: (...args: Args) => Promise | Response | ReadableStream) { + return async function*(...args: Args) { + let parts = '', decoder; + const source = await fetcher(...args); + const stream = source instanceof Response ? source.body : source; + const reader = stream?.getReader(); + if (!reader) { + console.warn('No ReadableStream found!') + return; + } + while (true) { + const { done, value } = await reader.read(); + if (done) return; + if (value) { + if (typeof value !== 'string') { + parts += (decoder ??= new TextDecoder()).decode(value, { stream: true }); + } else { + parts += value; + } + } + try { + const parsed = JSON.parse(closeJSONPart(parts)) + yield parsed; + } catch (e) { /* ignore erroneous states, recover later */ } + } + } +} + + export type AbortableReturn = [ signal: () => AbortSignal, abort: (reason?: string) => void, @@ -42,8 +101,11 @@ export type AbortableReturn = [ ] export type AbortableOptions = { - noAutoAbort?: boolean; + /** Should abort when a new signal is requested, default is true */ + autoAbort?: boolean; + /** Automatically abort after a timeout in ms if set */ timeout?: number; + /** Aborts if a parent signal is aborted (e.g. first optimistic update after a second write) */ chainTo?: () => AbortSignal; }; @@ -59,14 +121,14 @@ export type AbortableOptions = { * * Options are optional and include: * - `timeout`: time in Milliseconds after which the fetcher aborts automatically - * - `noAutoAbort`: can be set to true to make a new source not automatically abort a previous request + * - `autoAbort`: can be set to true to make a new source not automatically abort a previous request * - `chainTo`: listen to another abort signal to abort this signal */ export function makeAbortable( options: AbortableOptions = {}, ): AbortableReturn { let controller: AbortController; - let timeout: NodeJS.Timeout | number | undefined; + let timeout: ReturnType | undefined; const abort = (reason?: string) => { timeout && clearTimeout(timeout); controller?.abort(reason); @@ -75,11 +137,9 @@ export function makeAbortable( chained.set(options.chainTo, [...(chained.get(options.chainTo) || []), () => abort("chain abort")]); } function signal() { - if (!options.noAutoAbort && controller?.signal.aborted === false) { + if (options.autoAbort !== false && controller?.signal.aborted === false) abort("retry"); - } controller = new AbortController(); - if (!controller) { throw new Error('AbortController is not supported!'); } if (options.timeout) { timeout = setTimeout(() => abort("timeout"), options.timeout); } @@ -113,11 +173,105 @@ export function makeAbortable( * - `noAutoAbort`: can be set to true to make a new source not automatically abort a previous request * - `chainTo`: listen to another abort signal to abort this signal */ - export function createAbortable( options?: AbortableOptions, ): [() => AbortSignal, () => void, (err: any) => void] { const [signal, abort, filterAbortError] = makeAbortable(options); onCleanup(abort); return [signal, abort, filterAbortError]; -} \ No newline at end of file +} + +const isPromiseLike = (obj: unknown): obj is PromiseLike => !!obj && + ['object', 'function'].includes(typeof obj) && typeof (obj as PromiseLike).then === 'function'; + +const isIterable = (obj: unknown): obj is Iterable => !!obj && Object.hasOwn(obj, Symbol.iterator); + +const isAsyncIterable = (obj: unknown): obj is AsyncIterable => !!obj && Object.hasOwn(obj, Symbol.asyncIterator); + +export type RetryOptions = { + delay?: number; + retries?: number; +}; + +/** + * **Creates a fetcher that retries multiple times in case of errors** + * ```ts + * const data = createMemo(makeRetrying(() => fetch(url()), { retries: 5 })); + * ``` + * Receives the fetcher and an optional options object and returns a wrapped fetcher that retries on error after a delay multiple times. + * + * The optional options object contains the following optional properties: + * - `delay` - number of Milliseconds to wait before retrying; default is 5s + * - `retries` - number of times a request should be repeated before giving up throwing the last error; default is 3 times + */ +export function makeRetrying, T>>( + fetcher: C, + options: RetryOptions = {}, +) { + const delay = options.delay ?? 5000; + let retries = options.retries || 3; + + const handleError = (error: unknown) => { + if (retries-- > 0) { + return (delay ? + (new Promise(resolve => setTimeout(resolve, delay))) + : Promise.resolve()).then(() => retrying); + } + retries = options.retries || 3; + throw error; + }; + + async function* retrying(v?: T): AsyncGenerator { + try { + const result = fetcher(v); + if (isPromiseLike(result)) yield result.then(out => out, handleError) as Promise; + else if (isIterable(result)) + for (const item of result) + if (isPromiseLike(item)) yield item.then(out => out, handleError) as Promise; + else yield Promise.resolve(item) as Promise; + else yield Promise.resolve(result) as Promise; + } catch(error) { + handleError(error) + } + }; + return retrying; +} + +function toArray(item: any) { + return Array.isArray(item) ? item : item ? [item] : []; +} + +/** + * **Automatically aggregates resource changes** + * ```ts + * const pages = makeAggregated(currentPage, [], { id: "infinite-scroll" }); + * ``` + * @param res {Accessor} - The accessor that should be aggregated + * @param initialValue {I | undefined} - an optional initial value + * @param memoOptions - optional options for `createMemo` + * + * Depending on the content of the initialValue or the first response, this will aggregate the incoming responses: + * - null will not overwrite undefined + * - if the previous value is an Array, incoming values will be appended + * - if any of the values are Objects, the current one will be shallow-merged into the previous one + * - if the previous value is a string, more string data will be appended + * - otherwise the incoming data will be put into an array + * + * Objects and Arrays are re-created on each operation, but the values will be left untouched, so `` should work fine. + */ +export function createAggregated(res: Accessor, initialValue?: I, memoOptions?: Parameters>[1]) { + return createMemo((previous = initialValue) => { + const current = res(); + return current == null && previous == null + ? previous + : Array.isArray(previous || current) + ? [...toArray(previous), ...toArray(current)] + : typeof (previous || current) === "object" + ? { ...previous, ...current } + : typeof previous === "string" || typeof current === "string" + ? (previous?.toString() || "") + (current || "") + : previous + ? [previous, current] + : [current]; + }, memoOptions); +} diff --git a/packages/async/test/index.test.ts b/packages/async/test/index.test.ts index 1996ff5eb..51087cedc 100644 --- a/packages/async/test/index.test.ts +++ b/packages/async/test/index.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; -import { createEffect, createMemo, createRoot } from "solid-js"; -import { fromStream, makeAbortable, createAbortable } from "../src/index.js"; +import { createEffect, createMemo, createRoot, flush } from "solid-js"; +import { fromStream, fromJSONStream, makeAbortable, createAbortable, makeRetrying } from "../src/index.js"; const delay = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); @@ -8,10 +8,11 @@ describe("fromStream", () => { const createStream = (data: string) => new ReadableStream({ start(controller) { const chars = data[Symbol.iterator](); + const encoder = new TextEncoder(); const step = () => { const { value, done } = chars.next(); if (done) return; - controller.enqueue(value.codePointAt(0)); + controller.enqueue(encoder.encode(value)); delay(15).then(step); } delay().then(step); @@ -51,12 +52,52 @@ describe("fromStream", () => { })), 2000); }); +describe("fromJSONStream", () => { + const createStream = (data: string[]) => new ReadableStream({ + start(controller) { + const parts = data[Symbol.iterator](); + const encoder = new TextEncoder(); + const step = () => { + const { value, done } = parts.next(); + if (done) return; + controller.enqueue(encoder.encode(value)); + delay(15).then(step); + } + delay().then(step); + }, + pull(_controller) {}, + cancel: () => {}, + }); + + test("streams partial JSON from response", () => new Promise(resolve => createRoot(dispose => { + const data = [ + '{"test": tru', + 'e, "data": [1, 2, ', + '3], "solid": "is great!"}' + ]; + const expected = [ + { test: true }, + { test: true, data: [1, 2] }, + { test: true, data: [1, 2, 3], "solid": "is great!" }, + ]; + const stream = createMemo(fromJSONStream(() => createStream(data))); + createEffect(stream, (json) => { + expect(json).toEqual(expected.shift()); + if (!expected.length) { + queueMicrotask(dispose); + resolve(); + } + }) + }))); +}); + describe("makeAbortable", () => { test("makes a fetcher abortable", () => { const [signal, abort] = makeAbortable(); const signal1 = signal(); expect(signal1.aborted, "first signal should not be initially aborted").toBeFalsy(); const signal2 = signal(); + flush(); expect(signal1.aborted, "first signal should be aborted after new request").toBeTruthy(); expect(signal2, "already aborted signal should not be re-used").not.toBe(signal1); expect(signal2.aborted, "second signal should not be initially aborted").toBeFalsy(); @@ -101,3 +142,21 @@ describe("makeAbortable", () => { .catch(err => expect(err).toBe(noAbortError)); }); }); + +describe("createAbortable", () => { + test("aborts on cleanup", () => { + const [dispose, signal] = createRoot((dispose) => [dispose, createAbortable()[0]()]); + expect(signal.aborted).toBeFalsy(); + dispose(); + expect(signal.aborted).toBeTruthy(); + }); +}); + +describe("makeRetrying", () => { + test("makes a fetcher retry in case of error", async () => { + const responses: Promise[] = [Promise.reject(new Error("retry"))]; + const fetcher = (_prev: unknown) => responses.shift() || Promise.resolve(true); + const wrapped = makeRetrying(fetcher, { delay: 15 }); + expect(await wrapped()).toBe(true); + }); +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f21129fcd..c1846f139 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,12 @@ importers: specifier: ^1.9.7 version: 1.9.7 + packages/async: + devDependencies: + solid-js: + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 + packages/audio: dependencies: '@solid-primitives/static-store': From 3daf04d272c34f259103c752e820abc11b40be17 Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Wed, 3 Jun 2026 15:26:54 +0200 Subject: [PATCH 3/6] feat: retrying --- packages/async/src/index.ts | 50 +++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/async/src/index.ts b/packages/async/src/index.ts index 746a54b61..7aa43548f 100644 --- a/packages/async/src/index.ts +++ b/packages/async/src/index.ts @@ -207,35 +207,37 @@ export type RetryOptions = { export function makeRetrying, T>>( fetcher: C, options: RetryOptions = {}, -) { +): () => AsyncGenerator { const delay = options.delay ?? 5000; let retries = options.retries || 3; - const handleError = (error: unknown) => { - if (retries-- > 0) { - return (delay ? - (new Promise(resolve => setTimeout(resolve, delay))) - : Promise.resolve()).then(() => retrying); + return async function* retrying(v?: T): AsyncGenerator { + let result: T | PromiseLike | AsyncIterable | undefined; + while (true) { + try { + result ??= fetcher(v); + if (isPromiseLike(result)) { + yield await result; + result = undefined; + } else if (isIterable(result)) { + for (const item of result) + if (isPromiseLike(item)) yield await item as PromiseLike; + else yield Promise.resolve(item) as Promise; + return; + } else { + yield Promise.resolve(result) as Promise; + result = undefined; + } + } catch(error) { + if (retries-- <= 0) { + retries = options.retries || 3; + throw error; + } + if (delay) await new Promise(resolve => setTimeout(resolve, delay)); + } } - retries = options.retries || 3; - throw error; - }; + };} - async function* retrying(v?: T): AsyncGenerator { - try { - const result = fetcher(v); - if (isPromiseLike(result)) yield result.then(out => out, handleError) as Promise; - else if (isIterable(result)) - for (const item of result) - if (isPromiseLike(item)) yield item.then(out => out, handleError) as Promise; - else yield Promise.resolve(item) as Promise; - else yield Promise.resolve(result) as Promise; - } catch(error) { - handleError(error) - } - }; - return retrying; -} function toArray(item: any) { return Array.isArray(item) ? item : item ? [item] : []; From de77f7a022c139ceacfc343ab8d9a1e9679dbf25 Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Wed, 10 Jun 2026 14:26:10 +0200 Subject: [PATCH 4/6] feat: upate to beta.14 feat: support node-based web streams --- packages/async/package.json | 4 ++-- packages/async/src/index.ts | 5 +++-- packages/async/test/server.test.ts | 19 ++++++++++++++----- pnpm-lock.yaml | 11 ++--------- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/async/package.json b/packages/async/package.json index 145997ee1..5713a0ea1 100644 --- a/packages/async/package.json +++ b/packages/async/package.json @@ -56,9 +56,9 @@ "test:ssr": "pnpm run vitest --mode ssr" }, "peerDependencies": { - "solid-js": "2.0.0-beta.10" + "solid-js": "2.0.0-beta.14" }, "devDependencies": { - "solid-js": "2.0.0-beta.10" + "solid-js": "2.0.0-beta.14" } } diff --git a/packages/async/src/index.ts b/packages/async/src/index.ts index 7aa43548f..bc0d2cee5 100644 --- a/packages/async/src/index.ts +++ b/packages/async/src/index.ts @@ -1,5 +1,6 @@ import { onCleanup, createMemo } from "solid-js"; import type { Accessor, ComputeFunction } from "solid-js"; +import type { ReadableStream as NodeReadableStream } from "stream/web" const chained = new Map<() => AbortSignal, (() => void)[]>(); @@ -13,7 +14,7 @@ const chained = new Map<() => AbortSignal, (() => void)[]>(); * const streamed = createMemo(fromStream(() => getStream())); * ``` */ -export function fromStream(fetcher: (...args: Args) => Promise | Response | ReadableStream) { +export function fromStream(fetcher: (...args: Args) => Promise | Response | ReadableStream | NodeReadableStream) { return async function*(...args: Args) { let parts = '', decoder; const source = await fetcher(...args); @@ -65,7 +66,7 @@ const closeJSONPart = (json: string) => * const streamed = createMemo(fromStream(() => getStream())); * ``` */ -export function fromJSONStream(fetcher: (...args: Args) => Promise | Response | ReadableStream) { +export function fromJSONStream(fetcher: (...args: Args) => Promise | Response | ReadableStream | NodeReadableStream) { return async function*(...args: Args) { let parts = '', decoder; const source = await fetcher(...args); diff --git a/packages/async/test/server.test.ts b/packages/async/test/server.test.ts index d0ba586ee..95e1044a2 100644 --- a/packages/async/test/server.test.ts +++ b/packages/async/test/server.test.ts @@ -1,9 +1,18 @@ import { describe, test, expect } from "vitest"; -import { createPrimitiveTemplate } from "../src/index.js"; +import { createEffect, createMemo, createRoot } from "solid-js"; +import { fromStream } from "../src/index.js"; +import { createReadStream } from "fs"; +import { Readable } from "stream"; -describe("createPrimitiveTemplate", () => { - test("doesn't break in SSR", () => { - const [value, setValue] = createPrimitiveTemplate(true); - expect(value(), "initial value should be true").toBe(true); +describe("fromStream", () => { + test("works on readable streams in SSR", async () => { + const stream = Readable.toWeb(createReadStream('../../README.md')); + const readme = fromStream(() => stream); + let parts = 0; + for await (const data of readme()) { + parts++; + expect(data).toBeDefined(); + } + expect(parts).toBeGreaterThan(0); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aa46f735..3f367cadd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,8 +133,8 @@ importers: packages/async: devDependencies: solid-js: - specifier: 2.0.0-beta.10 - version: 2.0.0-beta.10 + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 packages/audio: dependencies: @@ -3044,10 +3044,6 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@oxc-project/types@0.130.0': - resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} - - '@oxc-resolver/binding-android-arm-eabi@11.20.0': resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} cpu: [arm] @@ -8762,9 +8758,6 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.130.0': {} - - '@oxc-resolver/binding-android-arm-eabi@11.20.0': optional: true From a479a541dc8b829cdba7838bedfdfecaea16a7be Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Thu, 11 Jun 2026 11:30:30 +0200 Subject: [PATCH 5/6] feat: retrying --- packages/async/src/index.ts | 12 ++++++------ packages/async/test/index.test.ts | 11 ++++++++++- packages/async/test/server.test.ts | 4 +++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/async/src/index.ts b/packages/async/src/index.ts index bc0d2cee5..40462aa50 100644 --- a/packages/async/src/index.ts +++ b/packages/async/src/index.ts @@ -216,23 +216,23 @@ export function makeRetrying let result: T | PromiseLike | AsyncIterable | undefined; while (true) { try { - result ??= fetcher(v); + result = fetcher(v); if (isPromiseLike(result)) { yield await result; - result = undefined; } else if (isIterable(result)) { for (const item of result) - if (isPromiseLike(item)) yield await item as PromiseLike; + if (isPromiseLike(item)) yield item as PromiseLike; else yield Promise.resolve(item) as Promise; return; + } else if (isAsyncIterable(result)) { + for await (const item of result) yield Promise.resolve(item) as PromiseLike; } else { - yield Promise.resolve(result) as Promise; - result = undefined; + yield Promise.resolve(result) as PromiseLike; } } catch(error) { if (retries-- <= 0) { retries = options.retries || 3; - throw error; + throw new Error(`retry failed ${options.retries || 3} times`); } if (delay) await new Promise(resolve => setTimeout(resolve, delay)); } diff --git a/packages/async/test/index.test.ts b/packages/async/test/index.test.ts index 51087cedc..90e8eff59 100644 --- a/packages/async/test/index.test.ts +++ b/packages/async/test/index.test.ts @@ -157,6 +157,15 @@ describe("makeRetrying", () => { const responses: Promise[] = [Promise.reject(new Error("retry"))]; const fetcher = (_prev: unknown) => responses.shift() || Promise.resolve(true); const wrapped = makeRetrying(fetcher, { delay: 15 }); - expect(await wrapped()).toBe(true); + expect(await wrapped()[Symbol.asyncIterator]().next()).toEqual({ done: false, value: true }); + }); + + test("throws after the retry limit", async () => { + const responses: Promise[] = Array.from({ length: 4 }, () => Promise.reject(new Error("retry"))); + const fetcher = (_prev: unknown) => responses.shift() || Promise.resolve(true); + const wrapped = makeRetrying(fetcher, { delay: 15 }); + const result = wrapped()[Symbol.asyncIterator]().next(); + console.log(result) + await expect(result).rejects.toThrow(); }); }); \ No newline at end of file diff --git a/packages/async/test/server.test.ts b/packages/async/test/server.test.ts index 95e1044a2..486c356d0 100644 --- a/packages/async/test/server.test.ts +++ b/packages/async/test/server.test.ts @@ -5,7 +5,9 @@ import { createReadStream } from "fs"; import { Readable } from "stream"; describe("fromStream", () => { - test("works on readable streams in SSR", async () => { + // this is only relevant on streaming SSR, which is not supported by our tests, + // so we will test it outside of Solid + test("works on node readable streams", async () => { const stream = Readable.toWeb(createReadStream('../../README.md')); const readme = fromStream(() => stream); let parts = 0; From e34cd0ba742fceac74a3ae16ce392e5b86f9e183 Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Tue, 16 Jun 2026 21:21:09 +0200 Subject: [PATCH 6/6] test: complete tests docs: first storybook stories, README finalized --- packages/async/README.md | 135 ++++++++++++++++- packages/async/dev/index.tsx | 20 --- packages/async/package.json | 2 +- packages/async/src/index.ts | 25 +--- .../async/stories/createAbortable.stories.tsx | 57 +++++++ packages/async/stories/data.json | 1 + .../async/stories/fromJSONStream.stories.tsx | 0 packages/async/stories/fromStream.stories.tsx | 49 ++++++ .../async/stories/makeAbortable.stories.tsx | 57 +++++++ .../async/stories/makeAggregated.stories.tsx | 0 .../async/stories/makeRetrying.stories.tsx | 0 packages/async/stories/tsconfig.json | 4 + packages/async/test/index.test.ts | 139 +++++++++++++++++- 13 files changed, 436 insertions(+), 53 deletions(-) delete mode 100644 packages/async/dev/index.tsx create mode 100644 packages/async/stories/createAbortable.stories.tsx create mode 100644 packages/async/stories/data.json create mode 100644 packages/async/stories/fromJSONStream.stories.tsx create mode 100644 packages/async/stories/fromStream.stories.tsx create mode 100644 packages/async/stories/makeAbortable.stories.tsx create mode 100644 packages/async/stories/makeAggregated.stories.tsx create mode 100644 packages/async/stories/makeRetrying.stories.tsx create mode 100644 packages/async/stories/tsconfig.json diff --git a/packages/async/README.md b/packages/async/README.md index 37ee55d09..81ec2a8d8 100644 --- a/packages/async/README.md +++ b/packages/async/README.md @@ -10,11 +10,12 @@ A collection of primitves for handling of asynchronous memos, optimistic signals, stores and actions: -- [`makeStreamable`](#makeStreamable) - wraps a fetch request to support web streams in memos or optimistic signals +- [`fromStream`](#fromStream) - wraps a fetch request to support web streams in memos or optimistic signals +- [`fromJSONStream`](#fromJSONStream) - wraps a fetch request returning a web stream containing (incomplete) JSON for the use in memos or optimistic signals - [`makeAbortable`](#makeabortable) - sets up an AbortSignal with auto-abort on re-fetch or timeout - [`createAbortable`](#createabortable) - like `makeAbortable`, but with automatic abort on cleanup -- [`makeCache`](#makecache) - wraps the fetcher to cache the responses for a certain amount of time - [`makeRetrying`](#makeretrying) - wraps the fetcher to retry requests after a delay +- [`createAggregated`](#createAggregated) - aggregates the values of an accessor ## Installation @@ -26,16 +27,134 @@ yarn add @solid-primitives/async pnpm add @solid-primitives/async ``` -## How to use it +## `fromStream` + +Turns a function returning a [Web Stream API ReadableStream](https://streams.spec.whatwg.org/#rs-class) or a streaming response directly or in a promise into an async iterator function that buffers the stream and updates with each data package. Node.js Web Streams are also supported, but will only work on streaming SSR. + + +```ts +// definition +fromStream( + webStreamOrResponse: (...args: Args) => ReadableStream | Response +): (...args: Args) => AsyncGenerator; + +// on the client +const plainText = createMemo(fromStream(() => fetch(url()))); + +// on the server +const readme = createMemo(fromStream(Readable.toWeb(createReadStream('README.md')))); +``` + +If the packages were very small and contained only a few words from lorem ipsum, the result would be (one line per update): + +``` +Lorem ipsum +Lorem ipsum dolor sit amet, +Lorem ipsum dolor sit amet, consetetur sadipscing +``` + +and so on. Usual HTTP packets can transmit ~1.4kb including headers, so expect mutliple updates for larger data. + +## `fromJSONStream` + +The same as `fromStream`, but it auto-closes a partial JSON string to allow for successful parsing. + +```ts +// definition +fromStream( + webStreamOrResponse: (...args: Args) => ReadableStream | Response +): (...args: Args) => AsyncGenerator; + +// usage +const answer = createMemo(fromJSONStream(() => fetch(url()))); +``` + +The result looks like this: + +```js +// current data +// parsed JSON + +'[{"id":8429,"name":"fromStrea' +[{ id: 8429, name: "fromStrea" }] + +'[{"id":8429,"name":"fromStream","description":"tu' +[{ id: 8429, name: "fromStream", description: "tu" }] + +'[{"id":8429,"name":"fromStream","description":"turns web streams into' +[{ id: 8429, name: "fromStream", description: "turns web streams into" }] + +'[{"id":8429,"name":"fromStream","description":"turns web streams into async iterator"},{"id":294' +[{ id: 8429, name: "fromStream", description: "turns web streams into async iterator" }, { id: 294 }] + +'[{"id":8429,"name":"fromStream","description":"turns web streams into async iterator"},{"id":2947,"name":"fromJSONStream",' +[{ id: 8429, name: "fromStream", description: "turns web streams into async iterator" }, { id: 2947, name: "fromJSONStream }] + +// and so on +``` + +## `makeAbortable` + +Orchestrates AbortController creation and aborting of abortable fetchers, either on refetch or after a timeout, depending on configuration: + +```ts +// definition +const [ + signal: AbortSignal, + abort: () => void, + filterErrors: (err: E) => E instanceof AbortError ? void : E +] = makeAbortable({ + timeout?: 10000, + noAutoAbort?: true, +}); + +// usage +const [signal, abort, filterErrors] = makeAbortable(); +const data = createMemo(fromStream(() => fetch(url(), { signal: signal() }).catch(filterErrors)); +// use `createAbortable` if you do not want manual cleanup: +onCleanup(abort); +``` + +* The signal function always returns a signal that is not yet aborted; if noAutoAbort is not set to true, calling it will also abort a previous signal, if present +* The abort callback will always abort the current signal +* If timeout is set, the signal will be aborted after that many Milliseconds +* The filterErrors function can be used to filter out abort errors + +## `createAbortable` + +This function does exactly the same as makeAbortable, but also automatically aborts on cleanup. Only use within a reactive scope. + +## `makeRetrying` + +Wraps a fetcher and can catch errors and retry after a delay: ```ts -// TODO +// definition +const fetcher: () => AsyncGenerator = makeRetrying( + () => fetch(url()).then(r => r.body), + { + delay: 1000, // number of Milliseconds to wait before retrying; default is 5s + retries: 1, // number of times a rest should be repeated before throwing the last error; default is 3 times + } +); ``` -## Demo +If you want to retry for an infinite number of times, you can set `options.retries` to `Infinity`. + +## `createAggregated` -You can use this template for publishing your demo on CodeSandbox: https://codesandbox.io/s/solid-primitives-demo-template-sz95h +Aggregates the output of any accessor/memo: -## Changelog +```ts +const aggregated: Accessor = createAggregated( + accessor: Accessor, initialValue?: T | U +); +const pages = createAggregated(currentPage, []); +``` -See [CHANGELOG.md](./CHANGELOG.md) +* `null` will not overwrite `undefined` +* If the previous value is an Array, incoming values will be appended +* If any of the values are Objects, the current one will be shallow-merged into the previous one +* If the previous value is a string, more string data will be appended +* Otherwise the incoming data will be put into an array +* Objects and Arrays are re-created on each operation, but the values will be left untouched, so `` should work fine diff --git a/packages/async/dev/index.tsx b/packages/async/dev/index.tsx deleted file mode 100644 index cff773f64..000000000 --- a/packages/async/dev/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { type Component, createSignal } from "solid-js"; - -const App: Component = () => { - const [count, setCount] = createSignal(0); - const increment = () => setCount(count() + 1); - - return ( -
-
-

Counter component

-

it's very important...

- -
-
- ); -}; - -export default App; diff --git a/packages/async/package.json b/packages/async/package.json index 5713a0ea1..50819ede6 100644 --- a/packages/async/package.json +++ b/packages/async/package.json @@ -24,7 +24,7 @@ "makeRetrying", "createAggregated" ], - "category": "Display & Media" + "category": "Reactivity" }, "keywords": [ "solid", diff --git a/packages/async/src/index.ts b/packages/async/src/index.ts index 40462aa50..017e0d9c8 100644 --- a/packages/async/src/index.ts +++ b/packages/async/src/index.ts @@ -67,34 +67,17 @@ const closeJSONPart = (json: string) => * ``` */ export function fromJSONStream(fetcher: (...args: Args) => Promise | Response | ReadableStream | NodeReadableStream) { + const wrappedFetcher = fromStream(fetcher); return async function*(...args: Args) { - let parts = '', decoder; - const source = await fetcher(...args); - const stream = source instanceof Response ? source.body : source; - const reader = stream?.getReader(); - if (!reader) { - console.warn('No ReadableStream found!') - return; - } - while (true) { - const { done, value } = await reader.read(); - if (done) return; - if (value) { - if (typeof value !== 'string') { - parts += (decoder ??= new TextDecoder()).decode(value, { stream: true }); - } else { - parts += value; - } - } + for await (const data of wrappedFetcher(...args)) { try { - const parsed = JSON.parse(closeJSONPart(parts)) + const parsed = JSON.parse(closeJSONPart(data)); yield parsed; } catch (e) { /* ignore erroneous states, recover later */ } } } } - export type AbortableReturn = [ signal: () => AbortSignal, abort: (reason?: string) => void, @@ -247,7 +230,7 @@ function toArray(item: any) { /** * **Automatically aggregates resource changes** * ```ts - * const pages = makeAggregated(currentPage, [], { id: "infinite-scroll" }); + * const pages = createAggregated(currentPage, [], { id: "infinite-scroll" }); * ``` * @param res {Accessor} - The accessor that should be aggregated * @param initialValue {I | undefined} - an optional initial value diff --git a/packages/async/stories/createAbortable.stories.tsx b/packages/async/stories/createAbortable.stories.tsx new file mode 100644 index 000000000..0487a39d9 --- /dev/null +++ b/packages/async/stories/createAbortable.stories.tsx @@ -0,0 +1,57 @@ +import { createAbortable } from "@solid-primitives/async"; +import preview from "../../../.storybook/preview.js"; +import data from "./data.json"; +import { createMemo, createSignal, For, Loading } from "solid-js"; + +const meta = preview.meta({ + title: "Reactivity", + parameters: { + layout: "centered", + }, +}); + +export default meta; + +declare global { + class AbortError extends Error {} +} + +export const CreateAbortableAutoSuggest = meta.story({ + name: "createAbortable AutoSuggest", + parameters: { + docs: { + description: { + story: + "`createAbortable` automatically aborts subsequent requests and automatically aborts on next signal and cleanup, ideal for patterns like auto-suggest." + } + } + }, + render: () => { + if (!('AbortError' in globalThis)) { + (globalThis as any).AbortError = class AbortError extends Error { + constructor(msg: string) { super(msg); } + } + } + const autoSuggest = async (query: string, signal: AbortSignal) => { + await new Promise(r => setTimeout(r, 500)); + if (signal.aborted) throw new AbortError("aborted"); + const fuzzy = new RegExp(query.replace(/./g, "$1.*?"), "i"); + return data.filter(term => fuzzy.test(term)); + } + const [query, setQuery] = createSignal(""); + const [signal, abort, filterError] = createAbortable(); + const suggest = createMemo(() => autoSuggest(query(), signal())); + + return + { setQuery(ev.currentTarget.value)}} + /> +
    + no suggestions found}> + {(suggestion) =>
  • {suggestion}
  • } +
    +
+
+ }, +}); \ No newline at end of file diff --git a/packages/async/stories/data.json b/packages/async/stories/data.json new file mode 100644 index 000000000..dd78739fd --- /dev/null +++ b/packages/async/stories/data.json @@ -0,0 +1 @@ +["active-element","createActiveElement","analytics","createAnalytics","async","fromStream","fromJSONStream","makeAbortable","createAbortable","makeRetrying","createAggregated","audio","makeAudio","makeAudioPlayer","createAudio","bounds","createElementBounds","broadcast-channel","makeBroadcastChannel","createBroadcastChannel","clipboard","copyClipboard","writeClipboard","createClipboard","connectivity","createConnectivitySignal","context","createContextProvider","createOptionalContextProvider","createLayeredContext","MultiProvider","controlled-props","createControlledProp","controlled-signal","createControllableSignal","createControllableBooleanSignal","createControllableArraySignal","createControllableSetSignal","cookies","createServerCookie","createUserTheme","getCookiesString","cursor","makeBodyCursor","makeElementCursor","createBodyCursor","createElementCursor","createDragCursor","cursorRef","date","createDate","createDateNow","createTimeDifference","createTimeDifferenceFromNow","createTimeAgo","createCountdown","createCountdownFromNow","db-store","createDbStore","supabaseAdapter","deep","trackDeep","trackStore","captureStoreUpdates","destructure","destructure","devices","createDevices","createMicrophones","createSpeakers","createCameras","event-bus","createEventBus","createEmitter","createEventHub","createEventStack","event-dispatcher","createEventDispatcher","event-listener","createEventListener","createEventSignal","createEventListenerMap","WindowEventListener","DocumentEventListener","event-props","createEventProps","fetch","createFetch","filesystem","createFileSystem","createSyncFileSystem","createAsyncFileSystem","makeNoFileSystem","makeNoAsyncFileSystem","makeVirtualFileSystem","makeWebAccessFileSystem","makeNodeFileSystem","makeTauriFileSystem","makeChokidarWatcher","rsync","flux-store","createFluxStore","createFluxStoreFactory","createActions","createAction","focus","autofocus","createAutofocus","createFocusTrap","makeFocusListener","createFocusSignal","fullscreen","makeFullscreen","createFullscreen","fullscreen","geolocation","makeGeolocation","makeGeolocationWatcher","createGeolocation","createGeolocationWatcher","createDistance","createWithinRadius","gestures","graphql","createGraphQLClient","history","createUndoHistory","i18n","flatten","resolveTemplate","translator","scopedTranslator","chainedTranslator","idle","createIdleTimer","immutable","createImmutable","input-mask","createInputMask","createMaskPattern","interaction","makeInteractOutside","interactOutside","createInteractOutside","ariaHideOutside","createHideOutside","intersection-observer","createIntersectionObserver","createViewportObserver","createVisibilityObserver","jsx-tokenizer","createTokenizer","createToken","resolveTokens","isToken","keyboard","useKeyDownEvent","useKeyDownList","useCurrentlyHeldKey","useKeyDownSequence","createKeyHold","createShortcut","keyed","keyArray","Key","Entries","MapEntries","SetValues","lifecycle","createIsMounted","isHydrated","onElementConnect","list","listArray","List","list-state","createListState","createMultiSelectListState","map","ReactiveMap","ReactiveWeakMap","marker","createMarker","masonry","createMasonry","match","MatchTag","MatchValue","media","makeMediaQueryListener","createMediaQuery","createBreakpoints","usePrefersDark","mediastream","createStream","createAmplitudeStream","createAmplitudeFromStream","createMediaPermissionRequest","createScreen","memo","createLatest","createLatestMany","createWritableMemo","createLazyMemo","createPureReaction","createMemoCache","createReducer","mouse","createMousePosition","createPositionToElement","mutable","createMutable","modifyMutable","mutation-observer","createMutationObserver","notification","isNotificationSupported","makeNotification","createNotification","createNotificationPermission","orientation","makeOrientation","createOrientation","page-utilities","createPageVisibility","usePageVisibility","makePageLeave","createPageLeaveBlocker","pagination","createPagination","createSegment","createInfiniteScroll","permission","createPermission","platform","List of variables","pointer","createPointerListeners","createPerPointerListeners","createPointerPosition","createPointerList","presence","createPresence","promise","promiseTimeout","raceTimeout","until","props","combineProps","combineHandlers","filterProps","partitionProps","queue","makeQueue","createQueue","makePriorityQueue","createPriorityQueue","createTaskQueue","createConcurrentTaskQueue","raf","createRAF","createMs","targetFPS","range","createNumericRange","repeat","mapRange","indexRange","Repeat","IndexRange","refs","mergeRefs","resolveElements","resolveFirst","Ref","Refs","resize-observer","createResizeObserver","createWindowSize","createElementSize","resource","createAggregated","createDeepSignal","makeAbortable","createAbortable","makeCache","makeRetrying","rootless","createSubRoot","createCallback","createDisposable","createSharedRoot","createRootPool","scheduled","debounce","throttle","scheduleIdle","leading","createScheduled","leadingAndTrailing","script-loader","createScriptLoader","scroll","createScrollPosition","useWindowScrollPosition","createPreventScroll","selection","createSelection","sensors","makeAccelerometer","createAccelerometer","makeGyroscope","createGyroscope","makeSensor","createSensor","makeCompass","createCompass","makeBattery","createBattery","set","ReactiveSet","ReactiveWeakSet","union","intersection","difference","symmetricDifference","readonlySet","share","createSocialShare","createWebShare","makeWebShare","signal-builders","push","filter","sort","map","get","merge","update","add","clamp","template","spring","createSpring","createDerivedSpring","sse","makeSSE","createSSE","makeSSEWorker","state-machine","createMachine","static-store","createStaticStore","createDerivedStaticStore","storage","makePersisted","cookieStorage","tauriStorage","multiplexStorage","storageSync","messageSync","wsSync","multiplexSync","addClearMethod","addWithOptionsMethod","makeObjectStorage","styles","createRemSize","timer","makeTimer","createTimer","createTimeoutLoop","createPolled","createIntervalCounter","transition-group","createSwitchTransition","createListTransition","trigger","createTrigger","createTriggerCache","tween","createTween","upload","createFilePicker","createFileUploader","fileSender","fileUploader","createDropzone","dropzone","utils","shallowArrayCopy","shallowObjectCopy","shallowCopy","withArrayCopy","withObjectCopy","withCopy","push","drop","dropRight","filterOut","filter","sort","sortBy","map","slice","splice","fill","concat","remove","removeItems","flatten","filterInstance","filterOutInstance","omit","pick","split","merge","get","update","add","substract","multiply","divide","power","clamp","json","ndjson","lines","number","safe","pipe","wrapSetter","vibrate","isVibrationSupported","makeVibrate","createVibrate","frequencyToPattern","makePulse","createPulse","video","makeVideo","makeVideoPlayer","createVideo","createVideoPlayer","virtual","createVirutalList","VirtualList","websocket","makeWS","createWS","createWSState","createWSMessage","makeReconnectingWS","createReconnectingWS","makeHeartbeatWS","wsMessageIterable","createWSData","createWSStore","workers","createWorker","createWorkerPool","createSignaledWorker"] \ No newline at end of file diff --git a/packages/async/stories/fromJSONStream.stories.tsx b/packages/async/stories/fromJSONStream.stories.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/async/stories/fromStream.stories.tsx b/packages/async/stories/fromStream.stories.tsx new file mode 100644 index 000000000..ac54e02af --- /dev/null +++ b/packages/async/stories/fromStream.stories.tsx @@ -0,0 +1,49 @@ +import { fromStream } from "@solid-primitives/async"; +import preview from "../../../.storybook/preview.js"; +import data from "./data.json"; +import { createMemo, Loading } from "solid-js"; + +const meta = preview.meta({ + title: "Reactivity", + parameters: { + layout: "centered", + }, +}); + +export default meta; + +export const FromStream = meta.story({ + name: "makeAbortable AutoSuggest", + parameters: { + docs: { + description: { + story: + "`fromStream` wraps Web Stream ReadableStreams or streaming Responses with aggregation to be used in Solid's reactive system." + } + } + }, + render: () => { + const stream = new ReadableStream({ + async pull(controller) { + const source = JSON.stringify(data); + const packetCount = 16; + const sliceLength = Math.ceil(source.length / 16); + const parts = Array.from( + { length: packetCount }, + (_, idx) => source.slice(idx * sliceLength, (idx + 1) * sliceLength - 1) + ); + const encoder = new TextEncoder(); + for (const part in parts) { + await new Promise(r => setTimeout(r, 200)); + controller.enqueue(encoder.encode(part)); + } + controller.close(); + } + }); + const items = createMemo(fromStream(() => stream)); + + return + {items()} + + }, +}); \ No newline at end of file diff --git a/packages/async/stories/makeAbortable.stories.tsx b/packages/async/stories/makeAbortable.stories.tsx new file mode 100644 index 000000000..f146166c7 --- /dev/null +++ b/packages/async/stories/makeAbortable.stories.tsx @@ -0,0 +1,57 @@ +import { makeAbortable } from "@solid-primitives/async"; +import preview from "../../../.storybook/preview.js"; +import data from "./data.json"; +import { createMemo, createSignal, For, Loading } from "solid-js"; + +const meta = preview.meta({ + title: "Reactivity", + parameters: { + layout: "centered", + }, +}); + +export default meta; + +declare global { + class AbortError extends Error {} +} + +export const MakeAbortableAutoSuggest = meta.story({ + name: "makeAbortable AutoSuggest", + parameters: { + docs: { + description: { + story: + "`makeAbortable` automatically aborts subsequent requests and automatically aborts on next signal, ideal for patterns like auto-suggest." + } + } + }, + render: () => { + if (!('AbortError' in globalThis)) { + (globalThis as any).AbortError = class AbortError extends Error { + constructor(msg: string) { super(msg); } + } + } + const autoSuggest = async (query: string, signal: AbortSignal) => { + await new Promise(r => setTimeout(r, 500)); + if (signal.aborted) throw new AbortError("aborted"); + const fuzzy = new RegExp(query.replace(/./g, "$1.*?"), "i"); + return data.filter(term => fuzzy.test(term)); + } + const [query, setQuery] = createSignal(""); + const [signal, abort, filterError] = makeAbortable(); + const suggest = createMemo(() => autoSuggest(query(), signal())); + + return + { setQuery(ev.currentTarget.value)}} + /> +
    + no suggestions found}> + {(suggestion) =>
  • {suggestion}
  • } +
    +
+
+ }, +}); \ No newline at end of file diff --git a/packages/async/stories/makeAggregated.stories.tsx b/packages/async/stories/makeAggregated.stories.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/async/stories/makeRetrying.stories.tsx b/packages/async/stories/makeRetrying.stories.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/async/stories/tsconfig.json b/packages/async/stories/tsconfig.json new file mode 100644 index 000000000..0cd588e9e --- /dev/null +++ b/packages/async/stories/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../.storybook/tsconfig.json", + "include": ["./**/*"] +} diff --git a/packages/async/test/index.test.ts b/packages/async/test/index.test.ts index 90e8eff59..cead1dcc5 100644 --- a/packages/async/test/index.test.ts +++ b/packages/async/test/index.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; -import { createEffect, createMemo, createRoot, flush } from "solid-js"; -import { fromStream, fromJSONStream, makeAbortable, createAbortable, makeRetrying } from "../src/index.js"; +import { createEffect, createMemo, createRoot, createSignal, flush } from "solid-js"; +import { fromStream, fromJSONStream, makeAbortable, createAbortable, makeRetrying, createAggregated } from "../src/index.js"; const delay = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); @@ -168,4 +168,137 @@ describe("makeRetrying", () => { console.log(result) await expect(result).rejects.toThrow(); }); -}); \ No newline at end of file +}); + +describe("makeAggregated", () => { + test("aggregates arrays", () => + new Promise(resolve => createRoot(dispose => { + const [data, addData] = createSignal(); + const memo = createMemo(() => Promise.resolve(data())); + const aggregated = createAggregated(memo); + let run = 0; + createEffect(aggregated, (aggregate) => { + if (run === 0) { + expect(aggregate, "initially undefined").toBeUndefined(); + addData(["one"]); + } else if (run === 1) { + expect(aggregate, "adding initial data works").toEqual(["one"]); + addData(["two"]); + } else if (run === 2) { + expect(aggregate, "adding another point of data works").toEqual(["one", "two"]); + addData(["three", "four"]); + } else if (run === 3) { + expect(aggregate, "adding multiple data points works").toEqual([ + "one", + "two", + "three", + "four", + ]); + queueMicrotask(dispose); + resolve(); + } + run++; + }); + }))); + test("aggregates objects", () => + new Promise(resolve => createRoot(dispose => { + const [data, addData] = createSignal>(); + const memo = createMemo(() => Promise.resolve(data())); + const aggregated = createAggregated(memo); + let run = 0; + createEffect(aggregated, (aggregate) => { + if (run === 0) { + expect(aggregate, "initially undefined").toBeUndefined(); + addData({ one: "one" }); + } else if (run === 1) { + expect(aggregate, "adding initial data works").toEqual({ one: "one" }); + addData({ two: "two" }); + } else if (run === 2) { + expect(aggregate, "adding another point of data works").toEqual({ + one: "one", + two: "two", + }); + addData({ three: "three", four: "four" }); + } else if (run === 3) { + expect(aggregate, "adding multiple data points works").toEqual({ + one: "one", + two: "two", + three: "three", + four: "four", + }); + queueMicrotask(dispose); + resolve(); + } + run++; + }); + }))); + test("aggregates strings", () => + new Promise(resolve => createRoot(dispose => { + const [data, addData] = createSignal(); + const memo = createMemo(() => Promise.resolve(data())); + const aggregated = createAggregated(memo); + let run = 0; + createEffect(aggregated, (aggregate) => { + if (run === 0) { + expect(aggregate, "initially undefined").toBeUndefined(); + addData("one "); + } else if (run === 1) { + expect(aggregate, "adding initial data works").toBe("one "); + addData("two "); + } else if (run === 2) { + expect(aggregate, "adding another point of data works").toBe("one two "); + addData("three four"); + } else if (run === 3) { + expect(aggregate, "adding multiple data points works").toBe("one two three four"); + queueMicrotask(dispose); + resolve(); + } + run++; + }); + }))); + test("aggregates numbers", () => + new Promise(resolve => createRoot(dispose => { + const [data, addData] = createSignal(); + const memo = createMemo(() => Promise.resolve(data())); + const aggregated = createAggregated(memo); + let run = 0; + createEffect(aggregated, (aggregate) => { + if (run === 0) { + expect(aggregate, "initially undefined").toBeUndefined(); + addData(1); + } else if (run === 1) { + expect(aggregate, "adding initial data works").toEqual([1]); + addData(2); + } else if (run === 2) { + expect(aggregate, "adding another point of data works").toEqual([1, 2]); + queueMicrotask(dispose); + resolve(); + } + run++; + }); + }))); + test("an initial value [] allows to aggregate objects into arrays", () => + new Promise(resolve => createRoot(dispose => { + const [data, addData] = createSignal>(); + const memo = createMemo(() => Promise.resolve(data())); + const aggregated = createAggregated(memo, []); + let run = 0; + createEffect(aggregated, (aggregate) => { + if (run === 0) { + expect(aggregate, "initial value").toEqual([]); + addData({ one: "one" }); + } else if (run === 1) { + expect(aggregate, "adding initial data works").toEqual([{ one: "one" }]); + addData({ two: "two" }); + } else if (run === 2) { + expect(aggregate, "adding another point of data works").toEqual([ + { one: "one" }, + { two: "two" }, + ]); + queueMicrotask(dispose); + resolve(); + } + run++; + }); + }))); +});