From ac4b16d7ecb684430bf97e01788f6f3a52fb0901 Mon Sep 17 00:00:00 2001 From: Tony Sullivan <tony.f.sullivan@outlook.com> Date: Sun, 29 May 2022 19:08:14 -0500 Subject: [PATCH] WIP: adding an @astrojs/store package and useStore for framework integrations --- packages/astro-store/package.json | 36 +++++ packages/astro-store/src/context.ts | 14 ++ packages/astro-store/src/index.ts | 2 + packages/astro-store/src/store.ts | 158 ++++++++++++++++++++++ packages/astro-store/src/utils.ts | 36 +++++ packages/astro-store/tsconfig.json | 10 ++ packages/integrations/preact/package.json | 1 + packages/integrations/preact/src/store.ts | 13 ++ packages/integrations/react/package.json | 1 + packages/integrations/react/src/store.ts | 13 ++ packages/integrations/solid/package.json | 1 + packages/integrations/solid/src/store.ts | 13 ++ packages/integrations/svelte/package.json | 1 + packages/integrations/svelte/src/store.ts | 5 + packages/integrations/vue/package.json | 1 + packages/integrations/vue/src/store.ts | 12 ++ pnpm-lock.yaml | 29 ++++ 17 files changed, 346 insertions(+) create mode 100644 packages/astro-store/package.json create mode 100644 packages/astro-store/src/context.ts create mode 100644 packages/astro-store/src/index.ts create mode 100644 packages/astro-store/src/store.ts create mode 100644 packages/astro-store/src/utils.ts create mode 100644 packages/astro-store/tsconfig.json create mode 100644 packages/integrations/preact/src/store.ts create mode 100644 packages/integrations/react/src/store.ts create mode 100644 packages/integrations/solid/src/store.ts create mode 100644 packages/integrations/svelte/src/store.ts create mode 100644 packages/integrations/vue/src/store.ts diff --git a/packages/astro-store/package.json b/packages/astro-store/package.json new file mode 100644 index 0000000000..fa299bb259 --- /dev/null +++ b/packages/astro-store/package.json @@ -0,0 +1,36 @@ +{ + "name": "@astrojs/store", + "description": "Add framework-agnostic stores to your Astro projects", + "version": "0.0.1", + "type": "module", + "types": "./dist/index.d.ts", + "author": "withastro", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/withastro/astro.git", + "directory": "packages/astro-store" + }, + "bugs": "https://github.com/withastro/astro/issues", + "homepage": "https://astro.build", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "astro-scripts build \"src/**/*.ts\" && tsc", + "build:ci": "astro-scripts build \"src/**/*.ts\"", + "dev": "astro-scripts dev \"src/**/*.ts\"", + "test": "mocha --exit --timeout 20000" + }, + "devDependencies": { + "@types/chai": "^4.3.1", + "@types/chai-as-promised": "^7.1.5", + "@types/mocha": "^9.1.1", + "astro": "workspace:*", + "astro-scripts": "workspace:*", + "chai": "^4.3.6", + "chai-as-promised": "^7.1.1", + "mocha": "^9.2.2" + } +} diff --git a/packages/astro-store/src/context.ts b/packages/astro-store/src/context.ts new file mode 100644 index 0000000000..8e7e754fe5 --- /dev/null +++ b/packages/astro-store/src/context.ts @@ -0,0 +1,14 @@ +const contexts = new Map(); + +export function getContext<T>(key: any): T { + return contexts.get(key); +} + +export function setContext<T>(key: any, value: T): T { + contexts.set(key, value); + return value; +} + +export function hasContext(key: any): boolean { + return contexts.has(key); +} \ No newline at end of file diff --git a/packages/astro-store/src/index.ts b/packages/astro-store/src/index.ts new file mode 100644 index 0000000000..fd1c30132a --- /dev/null +++ b/packages/astro-store/src/index.ts @@ -0,0 +1,2 @@ +export * from './context'; +export * from './store'; diff --git a/packages/astro-store/src/store.ts b/packages/astro-store/src/store.ts new file mode 100644 index 0000000000..68b0e36000 --- /dev/null +++ b/packages/astro-store/src/store.ts @@ -0,0 +1,158 @@ +import { getStoreValue, isFunction, noop, runAll, safeNotEqual, subscribe } from './utils'; + +export type Subscriber<T> = (value: T) => void; +export type Unsubscriber = () => void; +export type Updater<T> = (value: T) => T; +export type StartStopNotifier<T> = (set: Subscriber<T>) => Unsubscriber | void; + +type Invalidator<T> = (value?: T) => void; + +export interface Readable<T> { + subscribe(this: void, run: Subscriber<T>, invalidate?: Invalidator<T>): Unsubscriber; +} + +export interface Writable<T> extends Readable<T> { + set(this: void, value: T): void; + update(this: void, updater: Updater<T>): void; +} + +type SubscribeInvalidateTuple<T> = [Subscriber<T>, Invalidator<T>]; + +const subscriberQueue: any[] = []; + +export function readable<T>(value?: T, start?: StartStopNotifier<T>): Readable<T> { + return { + subscribe: writable(value, start).subscribe + } +} + +export function writable<T>(value?: T, start: StartStopNotifier<T> = noop): Writable<T> { + let stop: Unsubscriber | null = null; + const subscribers: Set<SubscribeInvalidateTuple<T>> = new Set(); + + function set(newValue: T): void { + if (safeNotEqual(value, newValue)) { + value = newValue; + if (stop !== null) { // store is ready + const runQueue = !subscriberQueue.length; + for (const subscriber of subscribers) { + subscriber[1](); + subscriberQueue.push(subscriber, value); + } + if (runQueue) { + for (let i = 0; i < subscriberQueue.length; i += 2) { + subscriberQueue[i][0](subscriberQueue[i + 1]); + } + subscriberQueue.length = 0; + } + } + } + } + + function update(fn: Updater<T>): void { + set(fn(value!)); + } + + function subscribe(run: Subscriber<T>, invalidate: Invalidator<T> = noop): Unsubscriber { + const subscriber: SubscribeInvalidateTuple<T> = [run, invalidate]; + subscribers.add(subscriber); + if (subscribers.size === 1) { + stop = start(set) || noop; + } + + run(value!); + + return () => { + subscribers.delete(subscriber); + if (subscribers.size === 0) { + stop!(); + stop = null; + } + } + } + + return { + set, + update, + subscribe + }; +} + +type Stores = Readable<any> | [Readable<any>, ...Array<Readable<any>>] | Array<Readable<any>>; + +type StoresValues<T> = T extends Readable<infer U> ? U : + { [K in keyof T]: T[K] extends Readable<infer U> ? U : never }; + +export function derived<S extends Stores, T>( + stores: S, + fn: (values: StoresValues<S>, set: (value: T) => void) => Unsubscriber | void, + initialValue?: T +): Readable<T>; + +export function derived<S extends Stores, T>( + stores: S, + fn: (values: StoresValues<S>) => T, + initialValue?: T +): Readable<T>; + +export function derived<S extends Stores, T>( + stores: S, + fn: (values: StoresValues<S>) => T +): Readable<T>; + +export function derived<T>(stores: Stores, fn: Function, initialValue?: T): Readable<T> { + const isSingle = !Array.isArray(stores); + const storesArray: Array<Readable<any>> = isSingle + ? [stores as Readable<any>] + : stores as Array<Readable<any>>; + + const auto = fn.length < 2; + + return readable(initialValue, (set) => { + let initialized = false; + const values: any[] = []; + + let pending = 0; + let cleanup = noop; + + const sync = () => { + if (pending) { + return; + } + + cleanup(); + + const result = fn(isSingle ? values[0] : values, set); + + if (auto) { + set(result as T); + } else { + cleanup = isFunction(result) ? result as Unsubscriber : noop; + } + }; + + const unsubscribers = storesArray.map((store, i) => subscribe( + store, + (value: any) => { + values[i] = value; + pending &= ~(1 << i); + if (initialized) { + sync(); + } + }, + () => { + pending |= (1 << i); + } + )); + + initialized = true; + sync(); + + return function stop() { + runAll(unsubscribers); + cleanup(); + }; + }); +} + +export const get = getStoreValue; diff --git a/packages/astro-store/src/utils.ts b/packages/astro-store/src/utils.ts new file mode 100644 index 0000000000..440104a79d --- /dev/null +++ b/packages/astro-store/src/utils.ts @@ -0,0 +1,36 @@ +import type { Readable } from "./store"; + +export function getStoreValue<T>(store: Readable<T>): T { + let value: T | undefined = undefined; + subscribe(store, (t: T) => value = t)(); + return value!; +} + +export function isFunction(thing: any): thing is Function { + return typeof thing === 'function'; +} + +export function noop() {} + +export function run(fn: Function) { + fn(); +} + +export function runAll(fns: Function[]) { + fns.forEach(run); +} + +export function safeNotEqual(a: any, b: any) { + return a != a + ? b == b + : a !== b || ((a && typeof a === 'object') || typeof a === 'function'); +} + +export function subscribe<T>(store: Readable<T>, ...callbacks: any[]) { + if (store == null) { + return noop; + } + + const result = (store.subscribe as any)(...callbacks); + return result.unsubscribe ? () => result.unsubscribe() : result; +} diff --git a/packages/astro-store/tsconfig.json b/packages/astro-store/tsconfig.json new file mode 100644 index 0000000000..06900f0f09 --- /dev/null +++ b/packages/astro-store/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "allowJs": true, + "module": "ES2020", + "outDir": "./dist", + "target": "ES2020" + } +} diff --git a/packages/integrations/preact/package.json b/packages/integrations/preact/package.json index 72a6174cc7..d5a9509327 100644 --- a/packages/integrations/preact/package.json +++ b/packages/integrations/preact/package.json @@ -34,6 +34,7 @@ "preact-render-to-string": "^5.2.0" }, "devDependencies": { + "@astrojs/store": "workspace:*", "astro": "workspace:*", "astro-scripts": "workspace:*", "preact": "^10.7.3" diff --git a/packages/integrations/preact/src/store.ts b/packages/integrations/preact/src/store.ts new file mode 100644 index 0000000000..c92fa8b424 --- /dev/null +++ b/packages/integrations/preact/src/store.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'preact/hooks'; +import { get } from '@astrojs/store'; +import type { Readable } from '@astrojs/store'; + +export function useStore<T>(readable: Readable<T>) { + const [state, setState] = useState(get(readable)); + + useEffect(() => { + return readable.subscribe(setState); + }, []); + + return state; +} diff --git a/packages/integrations/react/package.json b/packages/integrations/react/package.json index 96aa67368e..529bd5a44e 100644 --- a/packages/integrations/react/package.json +++ b/packages/integrations/react/package.json @@ -38,6 +38,7 @@ "devDependencies": { "@types/react": "^17.0.45", "@types/react-dom": "^17.0.17", + "@astrojs/store": "workspace:*", "astro": "workspace:*", "astro-scripts": "workspace:*", "react": "^18.1.0", diff --git a/packages/integrations/react/src/store.ts b/packages/integrations/react/src/store.ts new file mode 100644 index 0000000000..4ff30eed1d --- /dev/null +++ b/packages/integrations/react/src/store.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react'; +import { get } from '@astrojs/store'; +import type { Readable } from '@astrojs/store'; + +export function useStore<T>(readable: Readable<T>) { + const [state, setState] = useState(get(readable)); + + useEffect(() => { + return readable.subscribe(setState); + }, []); + + return state; +} diff --git a/packages/integrations/solid/package.json b/packages/integrations/solid/package.json index e60677d3ab..3ab15ea857 100644 --- a/packages/integrations/solid/package.json +++ b/packages/integrations/solid/package.json @@ -34,6 +34,7 @@ "babel-preset-solid": "^1.4.2" }, "devDependencies": { + "@astrojs/store": "workspace:*", "astro": "workspace:*", "astro-scripts": "workspace:*", "solid-js": "^1.4.3" diff --git a/packages/integrations/solid/src/store.ts b/packages/integrations/solid/src/store.ts new file mode 100644 index 0000000000..e85752dbd8 --- /dev/null +++ b/packages/integrations/solid/src/store.ts @@ -0,0 +1,13 @@ +import { createEffect, createSignal, onCleanup } from 'solid-js'; +import { get } from '@astrojs/store'; +import type { Readable } from '@astrojs/store'; + +export function useStore<T>(readable: Readable<T>) { + const [state, setState] = createSignal(get(readable)); + + createEffect(() => { + onCleanup(readable.subscribe(setState as (value: T) => void)); + }); + + return state; +} diff --git a/packages/integrations/svelte/package.json b/packages/integrations/svelte/package.json index ea6a6829c5..dc1306c99f 100644 --- a/packages/integrations/svelte/package.json +++ b/packages/integrations/svelte/package.json @@ -37,6 +37,7 @@ "vite": "^2.9.9" }, "devDependencies": { + "@astrojs/store": "workspace:*", "astro": "workspace:*", "astro-scripts": "workspace:*", "svelte": "^3.48.0" diff --git a/packages/integrations/svelte/src/store.ts b/packages/integrations/svelte/src/store.ts new file mode 100644 index 0000000000..ee960191ea --- /dev/null +++ b/packages/integrations/svelte/src/store.ts @@ -0,0 +1,5 @@ +import type { Readable } from '@astrojs/store'; + +export function useStore<T>(readable: Readable<T>) { + return readable; +} diff --git a/packages/integrations/vue/package.json b/packages/integrations/vue/package.json index e2008fe0b8..4ad61f456c 100644 --- a/packages/integrations/vue/package.json +++ b/packages/integrations/vue/package.json @@ -35,6 +35,7 @@ "vite": "^2.9.9" }, "devDependencies": { + "@astrojs/store": "workspace:*", "astro": "workspace:*", "astro-scripts": "workspace:*", "vue": "^3.2.36" diff --git a/packages/integrations/vue/src/store.ts b/packages/integrations/vue/src/store.ts new file mode 100644 index 0000000000..525ac3cb81 --- /dev/null +++ b/packages/integrations/vue/src/store.ts @@ -0,0 +1,12 @@ +import { getCurrentScope, onScopeDispose, readonly, shallowRef } from 'vue'; +import { get } from '@astrojs/store'; +import type { Readable } from '@astrojs/store'; + +export function useStore<T>(readable: Readable<T>) { + const state = shallowRef(get(readable)); + + const unsubscribe = readable.subscribe(value => state.value = value); + getCurrentScope() && onScopeDispose(unsubscribe); + + return readonly(state); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e862156e4..2c9e5e6e76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -659,6 +659,26 @@ importers: chai-as-promised: 7.1.1_chai@4.3.6 mocha: 9.2.2 + packages/astro-store: + specifiers: + '@types/chai': ^4.3.1 + '@types/chai-as-promised': ^7.1.5 + '@types/mocha': ^9.1.1 + astro: workspace:* + astro-scripts: workspace:* + chai: ^4.3.6 + chai-as-promised: ^7.1.1 + mocha: ^9.2.2 + devDependencies: + '@types/chai': 4.3.1 + '@types/chai-as-promised': 7.1.5 + '@types/mocha': 9.1.1 + astro: link:../astro + astro-scripts: link:../../scripts + chai: 4.3.6 + chai-as-promised: 7.1.1_chai@4.3.6 + mocha: 9.2.2 + packages/astro/e2e/fixtures/astro-component: specifiers: astro: workspace:* @@ -1788,6 +1808,7 @@ importers: packages/integrations/preact: specifiers: + '@astrojs/store': workspace:* '@babel/plugin-transform-react-jsx': ^7.17.12 astro: workspace:* astro-scripts: workspace:* @@ -1797,12 +1818,14 @@ importers: '@babel/plugin-transform-react-jsx': 7.17.12 preact-render-to-string: 5.2.0_preact@10.7.3 devDependencies: + '@astrojs/store': link:../../astro-store astro: link:../../astro astro-scripts: link:../../../scripts preact: 10.7.3 packages/integrations/react: specifiers: + '@astrojs/store': workspace:* '@babel/plugin-transform-react-jsx': ^7.17.12 '@types/react': ^17.0.45 '@types/react-dom': ^17.0.17 @@ -1813,6 +1836,7 @@ importers: dependencies: '@babel/plugin-transform-react-jsx': 7.17.12 devDependencies: + '@astrojs/store': link:../../astro-store '@types/react': 17.0.45 '@types/react-dom': 17.0.17 astro: link:../../astro @@ -1835,6 +1859,7 @@ importers: packages/integrations/solid: specifiers: + '@astrojs/store': workspace:* astro: workspace:* astro-scripts: workspace:* babel-preset-solid: ^1.4.2 @@ -1842,6 +1867,7 @@ importers: dependencies: babel-preset-solid: 1.4.2 devDependencies: + '@astrojs/store': link:../../astro-store astro: link:../../astro astro-scripts: link:../../../scripts solid-js: 1.4.3 @@ -1861,6 +1887,7 @@ importers: svelte-preprocess: 4.10.7_xxnnhi7j46bwl35r5gwl6d4d6q vite: 2.9.10 devDependencies: + '@astrojs/store': link:../../astro-store astro: link:../../astro astro-scripts: link:../../../scripts svelte: 3.48.0 @@ -1910,6 +1937,7 @@ importers: packages/integrations/vue: specifiers: + '@astrojs/store': workspace:* '@vitejs/plugin-vue': ^2.3.3 astro: workspace:* astro-scripts: workspace:* @@ -1919,6 +1947,7 @@ importers: '@vitejs/plugin-vue': 2.3.3_vite@2.9.10+vue@3.2.37 vite: 2.9.10 devDependencies: + '@astrojs/store': link:../../astro-store astro: link:../../astro astro-scripts: link:../../../scripts vue: 3.2.37