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