@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 +47 -0
- package/package.json +51 -0
- package/src/index.ts +8 -0
- package/src/localStorage.store.ts +40 -0
- package/src/queryEvents.browser.ts +54 -0
- package/src/refetchManagers.ts +44 -0
- package/src/reform-query-browser.test.ts +80 -0
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
|
+
})
|