@playfast/reform-query-browser 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @playfast/reform-query-browser
2
+
3
+ Browser host layers for Reform's `AsyncCalc`. Reform core stays renderer-neutral —
4
+ it declares the optional `QueryEvents` (lifecycle signals) and `QueryStore`
5
+ (persistence) seams but contains no DOM. This package implements them over the
6
+ real browser, plus the focus/reconnect auto-invalidation managers built on top.
7
+
8
+ ## What it gives you
9
+
10
+ - **`QueryEventsBrowser`** — implements `QueryEvents` over `window` focus /
11
+ `document` visibility / `online`. Falls back to a no-op off the main thread
12
+ (SSR, workers, tests), so the same app layer is safe everywhere.
13
+ - **`localStorageQueryStore`** — implements `QueryStore` over `localStorage`
14
+ (JSON, namespaced under `reform-query:`). Provide it to enable `persist` on a
15
+ calc.
16
+ - **`RefetchOnFocus` / `RefetchOnReconnect`** — managers that invalidate every
17
+ live query on the matching signal (React-Query's `refetchOnWindowFocus` /
18
+ `refetchOnReconnect`). Invalidation only marks queries stale; a stale query
19
+ that's actively read refetches.
20
+ - **`BrowserQueryDefaults`** — focus + reconnect managers pre-wired over
21
+ `QueryEventsBrowser`, so you only need `Queries` (part of `Engine`) in context.
22
+
23
+ ## Usage
24
+
25
+ ```ts
26
+ import { Engine, AsyncCalc } from '@playfast/reform'
27
+ import { BrowserQueryDefaults, localStorageQueryStore } from '@playfast/reform-query-browser'
28
+
29
+ // In your app layer, alongside Engine:
30
+ const base = Layer.mergeAll(
31
+ Engine,
32
+ BrowserQueryDefaults, // refetch on focus + reconnect
33
+ localStorageQueryStore, // enables `persist`
34
+ /* your state + calc layers */
35
+ )
36
+
37
+ // Opt a query into persistence:
38
+ AsyncCalc.live(Todos, { query, persist: { key: 'todos' } })
39
+
40
+ // Imperative control anywhere you can run an Effect:
41
+ runtime.runFork(AsyncCalc.invalidate(Todos)) // mark stale (refetches if active)
42
+ runtime.runFork(AsyncCalc.refetch(Todos)) // force a run now
43
+ ```
44
+
45
+ All of `QueryEvents`/`QueryStore` are optional: a calc that uses `persist` with no
46
+ `QueryStore` in context (or runs where there's no `window`) simply behaves as if
47
+ the feature were off — no hard requirement is added to your layer.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@playfast/reform-query-browser",
3
+ "playbook": "./playbook",
4
+ "version": "0.0.2",
5
+ "type": "module",
6
+ "description": "Browser host layers for Reform's AsyncCalc — window focus/online auto-invalidation and localStorage persistence. Keeps reform core DOM-free: implements the optional QueryEvents and QueryStore seams.",
7
+ "keywords": [
8
+ "reform",
9
+ "effect",
10
+ "react-query",
11
+ "persistence",
12
+ "refetch-on-focus",
13
+ "layer"
14
+ ],
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/playfast/reform.git",
19
+ "directory": "packages/reform-query-browser"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/playfast/reform/issues"
23
+ },
24
+ "sideEffects": false,
25
+ "exports": {
26
+ "./package.json": "./package.json",
27
+ ".": "./src/index.ts",
28
+ "./*": "./src/*.ts"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "README.md"
33
+ ],
34
+ "scripts": {
35
+ "clean": "rm -rf dist .tsbuildinfo",
36
+ "check": "tsc --noEmit",
37
+ "build": "tsc -p tsconfig.build.json",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "coverage": "vitest run --coverage",
41
+ "lint": "oxlint src",
42
+ "lint:fix": "oxlint --fix src"
43
+ },
44
+ "peerDependencies": {
45
+ "effect": "*",
46
+ "@playfast/reform": "*"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // `@playfast/reform-query-browser` — browser host layers for AsyncCalc's optional
2
+ // seams. Reform core stays DOM-free; these layers implement `QueryEvents`
3
+ // (window focus/online) and `QueryStore` (localStorage), plus the focus/reconnect
4
+ // auto-invalidation managers built on them. Provide them alongside `Engine`.
5
+
6
+ export { QueryEventsBrowser } from './queryEvents.browser'
7
+ export { localStorageQueryStore } from './localStorage.store'
8
+ export { BrowserQueryDefaults, RefetchOnFocus, RefetchOnReconnect } from './refetchManagers'
@@ -0,0 +1,40 @@
1
+ import { QueryStore, type QueryStoreApi, noopQueryStore } from '@playfast/reform'
2
+ import { Effect, Layer, Option } from 'effect'
3
+
4
+ // `QueryStore` implemented over `localStorage` (JSON values under a namespace
5
+ // prefix). The driver hands already-`output`-encoded values across this seam, so
6
+ // JSON round-trips faithfully; a corrupt/absent entry reads as `None` and the
7
+ // query just fetches fresh. Off the main thread it falls back to the no-op store.
8
+
9
+ const PREFIX = 'reform-query:'
10
+
11
+ const parse = (raw: string): Option.Option<unknown> => {
12
+ try {
13
+ return Option.some(JSON.parse(raw))
14
+ } catch {
15
+ // A hand-edited or version-skewed entry: treat as a cache miss, fetch fresh.
16
+ return Option.none()
17
+ }
18
+ }
19
+
20
+ const makeApi = (): QueryStoreApi => {
21
+ if (typeof window === 'undefined' || typeof localStorage === 'undefined') return noopQueryStore
22
+ return {
23
+ get: (key) =>
24
+ Effect.sync(() => {
25
+ const raw = localStorage.getItem(PREFIX + key)
26
+ return raw === null ? Option.none() : parse(raw)
27
+ }),
28
+ set: (key, value) =>
29
+ Effect.sync(() => {
30
+ localStorage.setItem(PREFIX + key, JSON.stringify(value))
31
+ }),
32
+ remove: (key) =>
33
+ Effect.sync(() => {
34
+ localStorage.removeItem(PREFIX + key)
35
+ }),
36
+ }
37
+ }
38
+
39
+ /** A `QueryStore` backed by `window.localStorage`. Provide it to enable `persist`. */
40
+ export const localStorageQueryStore: Layer.Layer<QueryStore> = Layer.succeed(QueryStore, makeApi())
@@ -0,0 +1,54 @@
1
+ import { QueryEvents, type QueryEventsApi, noopQueryEvents } from '@playfast/reform'
2
+ import { Effect, Layer } from 'effect'
3
+
4
+ // `QueryEvents` implemented over the browser: window focus/visibility drives the
5
+ // focus signal, `online` drives the reconnect signal. This is the only place the
6
+ // DOM is touched — reform core stays renderer-neutral. Off the main thread (SSR,
7
+ // a worker, a test with no `window`), it falls back to the inert no-op source, so
8
+ // the same app layer is safe everywhere.
9
+
10
+ /** A browser-backed `QueryEvents`, scoped so the window listeners are removed with the layer. */
11
+ export const QueryEventsBrowser: Layer.Layer<QueryEvents> = Layer.scoped(
12
+ QueryEvents,
13
+ Effect.gen(function* () {
14
+ if (typeof window === 'undefined') return noopQueryEvents
15
+
16
+ const focusListeners = new Set<() => void>()
17
+ const onlineListeners = new Set<() => void>()
18
+ const fire = (listeners: ReadonlySet<() => void>) => {
19
+ for (const listener of listeners) listener()
20
+ }
21
+ // Refocus and tab-becomes-visible both count as "focus"; ignore the
22
+ // visibility event that fires on hide.
23
+ const onFocus = () => {
24
+ if (document.visibilityState === 'visible') fire(focusListeners)
25
+ }
26
+ const onOnline = () => fire(onlineListeners)
27
+
28
+ // `visibilitychange` targets `document`; `focus`/`online` target `window`.
29
+ document.addEventListener('visibilitychange', onFocus)
30
+ window.addEventListener('focus', onFocus)
31
+ window.addEventListener('online', onOnline)
32
+ yield* Effect.addFinalizer(() =>
33
+ Effect.sync(() => {
34
+ document.removeEventListener('visibilitychange', onFocus)
35
+ window.removeEventListener('focus', onFocus)
36
+ window.removeEventListener('online', onOnline)
37
+ }),
38
+ )
39
+
40
+ const subscribeTo =
41
+ (listeners: Set<() => void>) =>
42
+ (listener: () => void): (() => void) => {
43
+ listeners.add(listener)
44
+ return () => {
45
+ listeners.delete(listener)
46
+ }
47
+ }
48
+ const api: QueryEventsApi = {
49
+ subscribeFocus: subscribeTo(focusListeners),
50
+ subscribeOnline: subscribeTo(onlineListeners),
51
+ }
52
+ return api
53
+ }),
54
+ )
@@ -0,0 +1,44 @@
1
+ import { QueryEvents, type QueryEventsApi, Queries } from '@playfast/reform'
2
+ import { Effect, Layer } from 'effect'
3
+ import { QueryEventsBrowser } from './queryEvents.browser'
4
+
5
+ // Auto-invalidation managers: a window signal (focus / reconnect) invalidates
6
+ // every live query, so stale + actively-read queries refetch (React-Query's
7
+ // `refetchOnWindowFocus` / `refetchOnReconnect`). They are pure routers — the
8
+ // signal comes from `QueryEvents`, the act is `handle.invalidate()` on the
9
+ // `Queries` registry — so nothing here touches the DOM directly.
10
+
11
+ const onSignal = (
12
+ pick: (events: QueryEventsApi) => (listener: () => void) => () => void,
13
+ ): Layer.Layer<never, never, QueryEvents | Queries> =>
14
+ Layer.scopedDiscard(
15
+ Effect.gen(function* () {
16
+ const events = yield* QueryEvents
17
+ const queries = yield* Queries
18
+ const invalidateAll = () => {
19
+ for (const handle of queries.byName.values()) handle.invalidate()
20
+ }
21
+ const unsubscribe = pick(events)(invalidateAll)
22
+ yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
23
+ }),
24
+ )
25
+
26
+ /** Invalidate every live query when the window regains focus. Requires `QueryEvents`. */
27
+ export const RefetchOnFocus: Layer.Layer<never, never, QueryEvents | Queries> = onSignal(
28
+ (events) => events.subscribeFocus,
29
+ )
30
+
31
+ /** Invalidate every live query when the network reconnects. Requires `QueryEvents`. */
32
+ export const RefetchOnReconnect: Layer.Layer<never, never, QueryEvents | Queries> = onSignal(
33
+ (events) => events.subscribeOnline,
34
+ )
35
+
36
+ /**
37
+ * The common browser default: focus + reconnect auto-invalidation wired over the
38
+ * browser `QueryEvents`, so the app only needs `Queries` (part of `Engine`) in
39
+ * context. Merge it alongside `Engine`; add `localStorageQueryStore` to persist.
40
+ */
41
+ export const BrowserQueryDefaults: Layer.Layer<never, never, Queries> = Layer.mergeAll(
42
+ RefetchOnFocus,
43
+ RefetchOnReconnect,
44
+ ).pipe(Layer.provide(QueryEventsBrowser))
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ *
4
+ * The browser host layers, end to end over a real (happy-dom) window: window
5
+ * focus invalidates live queries (so an actively-read query refetches), and
6
+ * `persist` hydrates from / writes through `localStorage` — with reform core
7
+ * never touching the DOM.
8
+ */
9
+ import { expect, it } from '@effect/vitest'
10
+ import { AsyncCalc, Engine, State, StateGroup } from '@playfast/reform'
11
+ import { Duration, Effect, Layer, Schema as S } from 'effect'
12
+ import { localStorageQueryStore } from './localStorage.store'
13
+ import { QueryEventsBrowser } from './queryEvents.browser'
14
+ import { RefetchOnFocus } from './refetchManagers'
15
+
16
+ const tick = (ms = 10) => Effect.sleep(Duration.millis(ms))
17
+
18
+ it.live('window focus invalidates a live query, so an active reader refetches', () => {
19
+ class Count extends State.make('count', S.Number) {}
20
+ class Inputs extends StateGroup.make(Count) {}
21
+ const runs = { n: 0 }
22
+ class Q extends AsyncCalc.make('Q', {
23
+ inputs: [StateGroup.select(Inputs, 'count')],
24
+ output: S.Number,
25
+ alwaysOn: true,
26
+ }) {}
27
+ const QLive = AsyncCalc.live(Q, {
28
+ query: ({ count }) =>
29
+ Effect.sync(() => {
30
+ runs.n += 1
31
+ return count
32
+ }).pipe(Effect.delay(Duration.millis(10))),
33
+ })
34
+ const App = Layer.mergeAll(QLive, RefetchOnFocus).pipe(
35
+ Layer.provideMerge(QueryEventsBrowser),
36
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 1 })),
37
+ Layer.provideMerge(Engine),
38
+ )
39
+
40
+ return Effect.gen(function* () {
41
+ const store = yield* Q.store
42
+ store.subscribe(() => {}) // an active reader (a mounted view)
43
+ yield* tick(30)
44
+ expect(runs.n).toBe(1)
45
+
46
+ // A focus event invalidates the query; stale + active ⇒ refetch.
47
+ window.dispatchEvent(new Event('focus'))
48
+ yield* tick(30)
49
+ expect(runs.n).toBe(2)
50
+ }).pipe(Effect.provide(App))
51
+ })
52
+
53
+ it.live('persist hydrates from localStorage (stale) then writes the settled value through', () => {
54
+ localStorage.setItem('reform-query:P', '99') // a previously-persisted value
55
+ class Count extends State.make('count', S.Number) {}
56
+ class Inputs extends StateGroup.make(Count) {}
57
+ class Q extends AsyncCalc.make('Q', {
58
+ inputs: [StateGroup.select(Inputs, 'count')],
59
+ output: S.Number,
60
+ alwaysOn: true,
61
+ }) {}
62
+ const QLive = AsyncCalc.live(Q, {
63
+ query: ({ count }) => Effect.succeed(count * 2).pipe(Effect.delay(Duration.millis(20))),
64
+ persist: { key: 'P' },
65
+ })
66
+ const App = QLive.pipe(
67
+ Layer.provideMerge(StateGroup.live(Inputs, { count: 5 })),
68
+ Layer.provideMerge(localStorageQueryStore),
69
+ Layer.provideMerge(Engine),
70
+ )
71
+
72
+ return Effect.gen(function* () {
73
+ const view = yield* Q.store
74
+ expect(view.get()).toMatchObject({ _tag: 'Success', value: 99, refetching: true })
75
+
76
+ yield* tick(40)
77
+ expect(view.get()).toMatchObject({ _tag: 'Success', value: 10, refetching: false })
78
+ expect(localStorage.getItem('reform-query:P')).toBe('10')
79
+ }).pipe(Effect.provide(App))
80
+ })