@inglorious/web 6.3.0 → 6.4.0

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.
Files changed (29) hide show
  1. package/package.json +37 -13
  2. package/src/decorators/with-error-boundary/index.js +48 -0
  3. package/src/decorators/with-throttle/index.js +107 -0
  4. package/src/directives/unsafe-svg.js +1 -0
  5. package/src/mobile/index.js +17 -0
  6. package/src/mount.js +57 -6
  7. package/src/{compass → sensors/compass}/handlers.js +2 -2
  8. package/src/{element-size → sensors/element-size}/handlers.js +8 -8
  9. package/src/{geolocation → sensors/geolocation}/handlers.js +3 -3
  10. package/src/sensors/media-query/handlers.js +86 -0
  11. package/src/sensors/media-query/index.js +3 -0
  12. package/src/sensors/network-status/handlers.js +84 -0
  13. package/src/sensors/network-status/index.js +3 -0
  14. package/src/sensors/page-visibility/handlers.js +69 -0
  15. package/src/sensors/page-visibility/index.js +3 -0
  16. package/types/decorators/with-debounce.d.ts +7 -2
  17. package/types/decorators/with-error-boundary.d.ts +11 -0
  18. package/types/decorators/with-throttle.d.ts +24 -0
  19. package/types/mobile.d.ts +1 -0
  20. package/types/mount.d.ts +22 -0
  21. package/types/{compass.d.ts → sensors/compass.d.ts} +1 -1
  22. package/types/{element-size.d.ts → sensors/element-size.d.ts} +1 -1
  23. package/types/{geolocation.d.ts → sensors/geolocation.d.ts} +1 -1
  24. package/types/sensors/media-query.d.ts +24 -0
  25. package/types/sensors/network-status.d.ts +23 -0
  26. package/types/sensors/page-visibility.d.ts +23 -0
  27. /package/src/{compass → sensors/compass}/index.js +0 -0
  28. /package/src/{element-size → sensors/element-size}/index.js +0 -0
  29. /package/src/{geolocation → sensors/geolocation}/index.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/web",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "description": "A new web framework that leverages the power of the Inglorious Store combined with the performance and simplicity of lit-html.",
5
5
  "author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "contributors": [
@@ -33,35 +33,59 @@
33
33
  "types": "./types/index.d.ts",
34
34
  "import": "./src/index.js"
35
35
  },
36
- "./compass": {
37
- "types": "./types/compass.d.ts",
38
- "import": "./src/compass/index.js"
39
- },
40
36
  "./decorators/with-debounce": {
41
37
  "types": "./types/decorators/with-debounce.d.ts",
42
38
  "import": "./src/decorators/with-debounce/index.js"
43
39
  },
40
+ "./decorators/with-error-boundary": {
41
+ "types": "./types/decorators/with-error-boundary.d.ts",
42
+ "import": "./src/decorators/with-error-boundary/index.js"
43
+ },
44
+ "./decorators/with-throttle": {
45
+ "types": "./types/decorators/with-throttle.d.ts",
46
+ "import": "./src/decorators/with-throttle/index.js"
47
+ },
44
48
  "./directives/*": {
45
49
  "types": "./types/directives/*.d.ts",
46
50
  "import": "./src/directives/*.js"
47
51
  },
48
- "./element-size": {
49
- "types": "./types/element-size.d.ts",
50
- "import": "./src/element-size/index.js"
51
- },
52
52
  "./form": {
53
53
  "types": "./types/form.d.ts",
54
54
  "import": "./src/form/index.js"
55
55
  },
56
- "./geolocation": {
57
- "types": "./types/geolocation.d.ts",
58
- "import": "./src/geolocation/index.js"
59
- },
60
56
  "./hydrate": "./src/hydrate.js",
57
+ "./mobile": {
58
+ "types": "./types/mobile.d.ts",
59
+ "import": "./src/mobile/index.js"
60
+ },
61
61
  "./router": {
62
62
  "types": "./types/router.d.ts",
63
63
  "import": "./src/router/index.js"
64
64
  },
65
+ "./sensors/compass": {
66
+ "types": "./types/sensors/compass.d.ts",
67
+ "import": "./src/sensors/compass/index.js"
68
+ },
69
+ "./sensors/element-size": {
70
+ "types": "./types/sensors/element-size.d.ts",
71
+ "import": "./src/sensors/element-size/index.js"
72
+ },
73
+ "./sensors/geolocation": {
74
+ "types": "./types/sensors/geolocation.d.ts",
75
+ "import": "./src/sensors/geolocation/index.js"
76
+ },
77
+ "./sensors/media-query": {
78
+ "types": "./types/sensors/media-query.d.ts",
79
+ "import": "./src/sensors/media-query/index.js"
80
+ },
81
+ "./sensors/network-status": {
82
+ "types": "./types/sensors/network-status.d.ts",
83
+ "import": "./src/sensors/network-status/index.js"
84
+ },
85
+ "./sensors/page-visibility": {
86
+ "types": "./types/sensors/page-visibility.d.ts",
87
+ "import": "./src/sensors/page-visibility/index.js"
88
+ },
65
89
  "./static": {
66
90
  "types": "./types/static.d.ts",
67
91
  "import": "./src/static.js"
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @typedef {import("../../../types/mount").Api} Api
3
+ */
4
+
5
+ /**
6
+ * Wraps a type's `render` in an error boundary. If the base render throws
7
+ * synchronously, the caught error is handed to `fallback` and its template is
8
+ * rendered instead — so one failing entity degrades to a fallback UI rather
9
+ * than taking down the whole app (every child is rendered through the same
10
+ * `render`, so there is no isolation without this).
11
+ *
12
+ * Because the wrap is deterministic, the *same* error produces the *same*
13
+ * fallback during SSR and during client hydration, so it never introduces a
14
+ * hydration mismatch.
15
+ *
16
+ * NOTE: like React/Svelte boundaries, this only catches errors thrown *during
17
+ * render*. Errors thrown later — in event handlers, async work (thunks), or in
18
+ * lit-html directives committed after `render` returns — are not caught here.
19
+ * Those are the store phase, not the render phase: handle them as entity
20
+ * `error` state in your handlers (see e.g. the geolocation type).
21
+ *
22
+ * @param {(error: Error, entity: object, api: Api) => unknown} [fallback]
23
+ * Renders the fallback UI. Receives the caught error, the entity, and the
24
+ * render api. Defaults to logging the error and rendering nothing.
25
+ * @returns {(type: object) => { render: (entity: object, api: Api) => unknown }}
26
+ *
27
+ * @example
28
+ * // Compose after the base type so the boundary wraps its render.
29
+ * const types = {
30
+ * Chart: [ChartBase, withErrorBoundary((err) => html`<p>Chart failed: ${err.message}</p>`)],
31
+ * }
32
+ */
33
+ export function withErrorBoundary(fallback = defaultFallback) {
34
+ return (type) => ({
35
+ render(entity, api) {
36
+ try {
37
+ return type.render?.(entity, api) ?? ""
38
+ } catch (error) {
39
+ return fallback(error, entity, api)
40
+ }
41
+ },
42
+ })
43
+ }
44
+
45
+ function defaultFallback(error) {
46
+ console.error("[withErrorBoundary] render failed:", error)
47
+ return ""
48
+ }
@@ -0,0 +1,107 @@
1
+ import { throttle } from "@inglorious/utils/functions/functions.js"
2
+
3
+ /**
4
+ * Wraps selected event handlers of a type with throttling, scoped per-entity.
5
+ *
6
+ * By default this is leading-edge only: the first call in a burst runs
7
+ * immediately, and further calls are dropped until `delay` has elapsed — the
8
+ * last call of a burst may be lost. Pass `{ hasTrailing: true }` to also invoke
9
+ * the handler once more after `delay`, using the most recent suppressed
10
+ * call's arguments, so the final state of a burst is never dropped.
11
+ *
12
+ * IMPORTANT: because state is managed with `mutative`, the `entity` object
13
+ * passed into a handler is a draft proxy that gets revoked as soon as the
14
+ * current update tick finishes. The leading call always runs synchronously
15
+ * (same tick as the triggering event), so the draft is still live there —
16
+ * but a trailing call (when `hasTrailing: true`) fires *later*, on its own
17
+ * timer, after that draft is dead. To keep one code path safe for both
18
+ * cases, we never close over `entity` itself across the throttle boundary —
19
+ * only its primitive `id`. Every time the wrapped handler actually runs, we
20
+ * re-fetch a live entity from the current state via `api.getEntity(entityId)`.
21
+ * Primitives and `payload` are safe to hold onto as-is; only draft/entity
22
+ * references are not.
23
+ *
24
+ * @param {number|Object} config - Either a single delay (ms) applied to all
25
+ * handlers listed in `handlerNames`, or a map of handlerName -> delay (ms).
26
+ * @param {string[]} [handlerNames] - Handler names to throttle. Required when
27
+ * `config` is a single number; ignored when `config` is a map (its keys
28
+ * are used instead).
29
+ * @param {Object} [options]
30
+ * @param {boolean} [options.hasTrailing=false] - Whether throttled handlers
31
+ * should also fire once more after `delay`, using the last suppressed
32
+ * call's arguments. Applies to every handler wrapped by this call.
33
+ *
34
+ * @example
35
+ * // leading-edge only (default): rate-limit a frequent event, last call in
36
+ * // a burst may be dropped
37
+ * const types = {
38
+ * ScrollTracker: [Base, withThrottle(200, ["scroll"])],
39
+ * }
40
+ *
41
+ * @example
42
+ * // leading + trailing: never lose the final call of a burst
43
+ * const types = {
44
+ * ResizablePanel: [Base, withThrottle({ resize: 100 }, undefined, { hasTrailing: true })],
45
+ * }
46
+ */
47
+ export function withThrottle(
48
+ config,
49
+ handlerNames,
50
+ { hasTrailing = false } = {},
51
+ ) {
52
+ const delays =
53
+ typeof config === "number"
54
+ ? Object.fromEntries(handlerNames.map((name) => [name, config]))
55
+ : config
56
+
57
+ return (type) => {
58
+ // one throttled-fn map per entity, so instances don't share windows
59
+ const throttledByEntity = new Map()
60
+
61
+ const behavior = {}
62
+
63
+ for (const handlerName of Object.keys(delays)) {
64
+ const originalHandler = type[handlerName]
65
+ if (!originalHandler) continue // nothing to wrap if the base type doesn't define it
66
+
67
+ behavior[handlerName] = (entity, payload, api) => {
68
+ const entityId = entity.id // capture the primitive now, while the draft is live
69
+
70
+ let perEntity = throttledByEntity.get(entityId)
71
+ if (!perEntity) {
72
+ perEntity = {}
73
+ throttledByEntity.set(entityId, perEntity)
74
+ }
75
+
76
+ if (!perEntity[handlerName]) {
77
+ perEntity[handlerName] = throttle(
78
+ (entityId, payload, api) => {
79
+ const liveEntity = api.getEntity(entityId) // fresh, live reference
80
+ if (!liveEntity) return // entity was destroyed before this fired
81
+ originalHandler(liveEntity, payload, api)
82
+ },
83
+ delays[handlerName],
84
+ { hasTrailing },
85
+ )
86
+ }
87
+
88
+ perEntity[handlerName](entityId, payload, api)
89
+ }
90
+ }
91
+
92
+ // clean up any pending windows/trailing calls when the entity is destroyed
93
+ behavior.destroy = (entity, payload, api) => {
94
+ const entityId = entity.id // same rule applies here: capture before it's gone
95
+
96
+ const perEntity = throttledByEntity.get(entityId)
97
+ if (perEntity) {
98
+ Object.values(perEntity).forEach((t) => t.cancel())
99
+ throttledByEntity.delete(entityId)
100
+ }
101
+
102
+ type.destroy?.(entity, payload, api)
103
+ }
104
+
105
+ return behavior
106
+ }
107
+ }
@@ -0,0 +1 @@
1
+ export { unsafeSVG } from "lit-html/directives/unsafe-svg.js"
@@ -0,0 +1,17 @@
1
+ export function isNative() {
2
+ const capacitor = globalThis.Capacitor ?? globalThis.window?.Capacitor ?? null
3
+
4
+ if (!capacitor) {
5
+ return false
6
+ }
7
+
8
+ if (typeof capacitor.isNativePlatform === "function") {
9
+ return capacitor.isNativePlatform()
10
+ }
11
+
12
+ if (typeof capacitor.getPlatform === "function") {
13
+ return ["android", "ios"].includes(capacitor.getPlatform())
14
+ }
15
+
16
+ return capacitor.isNative === true
17
+ }
package/src/mount.js CHANGED
@@ -5,14 +5,47 @@
5
5
 
6
6
  import { html, render } from "lit-html"
7
7
 
8
+ /**
9
+ * Root-level error boundary. Unlike the per-entity `withErrorBoundary`
10
+ * decorator (which isolates a single failing type's render), this catches
11
+ * errors thrown by the *root* render function itself — before any entity is
12
+ * reached (e.g. a missing router entity), or by an unguarded child.
13
+ *
14
+ * Without it, a throwing root render takes down the whole app: on the initial
15
+ * pass it aborts the mount, and inside the store subscription it throws out of
16
+ * the notify loop on every subsequent update, freezing the UI. Catching it
17
+ * keeps the subscription alive so one bad tick can't kill the app.
18
+ *
19
+ * @param {(api: Api) => TemplateResult | null} renderFn
20
+ * @param {Api} api
21
+ * @param {(error: unknown, api: Api) => void} onError
22
+ * @param {((error: unknown, api: Api) => TemplateResult | null) | undefined} fallback
23
+ * @returns {{ ok: true, template: TemplateResult | null } | { ok: false, fallback: TemplateResult | null }}
24
+ */
25
+ function renderRoot(renderFn, api, onError, fallback) {
26
+ try {
27
+ return { ok: true, template: renderFn(api) }
28
+ } catch (error) {
29
+ onError(error, api)
30
+ return { ok: false, fallback: fallback ? fallback(error, api) : null }
31
+ }
32
+ }
33
+
8
34
  /**
9
35
  * Mounts a lit-html template to the DOM and subscribes to a store for re-rendering.
10
36
  * @param {Store} store - The application state store.
11
37
  * @param {(api: Api) => TemplateResult | null} renderFn - The root render function.
12
38
  * @param {HTMLElement | DocumentFragment} element - The DOM element to mount the template to.
39
+ * @param {Object} [options] - Root error-boundary options.
40
+ * @param {(error: unknown, api: Api) => void} [options.onError] - Called when the
41
+ * root render throws. Defaults to `console.error`.
42
+ * @param {(error: unknown, api: Api) => TemplateResult | null} [options.fallback] -
43
+ * Optional fallback template rendered in place of the failed root render.
13
44
  * @returns {() => void} An unsubscribe function
14
45
  */
15
- export async function mount(store, renderFn, element) {
46
+ export async function mount(store, renderFn, element, options = {}) {
47
+ const { onError = defaultOnError, fallback } = options
48
+
16
49
  const api = { ...store._api }
17
50
  api.render = createRender(api)
18
51
 
@@ -21,20 +54,38 @@ export async function mount(store, renderFn, element) {
21
54
  // If we need to hydrate, load the heavy lifting only now
22
55
  if (shouldHydrate) {
23
56
  const { hydrate } = await import("@lit-labs/ssr-client")
24
- const template = renderFn(api)
25
- hydrate(template, element)
57
+ const result = renderRoot(renderFn, api, onError, fallback)
58
+ if (result.ok) {
59
+ hydrate(result.template, element)
60
+ } else if (fallback) {
61
+ // Can't hydrate a render that threw: replace the server markup with the
62
+ // fallback via a fresh client render instead.
63
+ render(result.fallback, element)
64
+ }
26
65
  }
27
66
 
28
67
  const unsubscribe = store.subscribe(() => {
29
- const template = renderFn(api)
30
- // Standard render is already in the main bundle (it's small)
31
- render(template, element)
68
+ const result = renderRoot(renderFn, api, onError, fallback)
69
+ if (result.ok) {
70
+ // Standard render is already in the main bundle (it's small)
71
+ render(result.template, element)
72
+ } else if (fallback) {
73
+ render(result.fallback, element)
74
+ }
32
75
  })
33
76
 
34
77
  store.notify("init")
35
78
  return unsubscribe
36
79
  }
37
80
 
81
+ /**
82
+ * Default root-error handler: log without taking the app down.
83
+ * @param {unknown} error - The error thrown by the root render function.
84
+ */
85
+ function defaultOnError(error) {
86
+ console.error("[mount] root render failed:", error)
87
+ }
88
+
38
89
  /**
39
90
  * Creates a render function for the mount API.
40
91
  * @param {Api} api - The mount API.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * @typedef {import("../../types/compass.js").CompassEntity} CompassEntity
3
- * @typedef {import("../../types/compass.js").CompassError} CompassError
2
+ * @typedef {import("../../../types/sensors/compass.js").CompassEntity} CompassEntity
3
+ * @typedef {import("../../../types/sensors/compass.js").CompassError} CompassError
4
4
  */
5
5
 
6
6
  const COMPASS_TIMEOUT = 3000
@@ -1,10 +1,10 @@
1
1
  /**
2
- * @typedef {import("../../types/element-size.js").ElementSizeEntity} ElementSizeEntity
3
- * @typedef {import("../../types/element-size.js").ElementSizeSize} ElementSizeSize
2
+ * @typedef {import("../../../types/sensors/element-size.js").ElementSizeEntity} ElementSizeEntity
3
+ * @typedef {import("../../../types/sensors/element-size.js").ElementSizeSize} ElementSizeSize
4
4
  */
5
5
 
6
- // Observers aren't serializable, so they're kept here by id, not on the entity.
7
- const observers = new Map()
6
+ // Listeners aren't serializable, so they're kept here by id, not on the entity.
7
+ const listeners = new Map()
8
8
 
9
9
  /**
10
10
  * Initializes the entity state and starts observing the target element's size.
@@ -35,7 +35,7 @@ export function destroy(entity) {
35
35
  export function elementSizeWatch(entity, _, api) {
36
36
  entity.isSupported = isSupported()
37
37
 
38
- if (!entity.isSupported || observers.has(entity.id)) {
38
+ if (!entity.isSupported || listeners.has(entity.id)) {
39
39
  return
40
40
  }
41
41
 
@@ -54,7 +54,7 @@ export function elementSizeWatch(entity, _, api) {
54
54
  })
55
55
  observer.observe(element)
56
56
 
57
- observers.set(id, observer)
57
+ listeners.set(id, observer)
58
58
  entity.isWatching = true
59
59
  }
60
60
 
@@ -73,11 +73,11 @@ export function elementSizeChange(entity, size) {
73
73
  * @param {ElementSizeEntity} entity - The element size entity.
74
74
  */
75
75
  export function elementSizeUnwatch(entity) {
76
- const observer = observers.get(entity.id)
76
+ const observer = listeners.get(entity.id)
77
77
 
78
78
  if (observer) {
79
79
  observer.disconnect()
80
- observers.delete(entity.id)
80
+ listeners.delete(entity.id)
81
81
  }
82
82
 
83
83
  entity.isWatching = false
@@ -1,7 +1,7 @@
1
1
  /**
2
- * @typedef {import("../../types/geolocation.js").GeolocationEntity} GeolocationEntity
3
- * @typedef {import("../../types/geolocation.js").GeolocationOptions} GeolocationOptions
4
- * @typedef {import("../../types/geolocation.js").GeolocationPosition} GeolocationPosition
2
+ * @typedef {import("../../../types/sensors/geolocation.js").GeolocationEntity} GeolocationEntity
3
+ * @typedef {import("../../../types/sensors/geolocation.js").GeolocationOptions} GeolocationOptions
4
+ * @typedef {import("../../../types/sensors/geolocation.js").GeolocationPosition} GeolocationPosition
5
5
  */
6
6
 
7
7
  const NO_WATCH_ID = null
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @typedef {import("../../../types/sensors/media-query.js").MediaQueryEntity} MediaQueryEntity
3
+ * @typedef {import("../../../types/sensors/media-query.js").MediaQueryMatchEvent} MediaQueryMatchEvent
4
+ */
5
+
6
+ const listeners = new Map()
7
+
8
+ export function create(entity, _, api) {
9
+ entity.isSupported = isSupported()
10
+ entity.matches = getMatchState(entity)
11
+ entity.media = entity.media ?? ""
12
+ entity.isWatching ??= false
13
+
14
+ mediaQueryWatch(entity, _, api)
15
+ }
16
+
17
+ export function destroy(entity) {
18
+ mediaQueryUnwatch(entity)
19
+ }
20
+
21
+ export function mediaQueryChange(entity, event) {
22
+ entity.matches = event.matches
23
+ }
24
+
25
+ export function mediaQueryWatch(entity, _, api) {
26
+ entity.isSupported = isSupported()
27
+
28
+ if (!entity.isSupported || entity.isWatching) {
29
+ return
30
+ }
31
+
32
+ const mql = getMediaQueryList(entity)
33
+ if (!mql) {
34
+ return
35
+ }
36
+
37
+ // Snapshot the id while the draft proxy is still live: the callback below
38
+ // runs after this handler returns, when Mutative has revoked the draft.
39
+ const id = entity.id
40
+ const listener = (event) => api.notify(`#${id}:mediaQueryChange`, event)
41
+
42
+ listeners.set(id, { mql, listener })
43
+ entity.isWatching = true
44
+
45
+ if (typeof mql.addEventListener === "function") {
46
+ mql.addEventListener("change", listener)
47
+ } else if (typeof mql.addListener === "function") {
48
+ mql.addListener(listener)
49
+ }
50
+ }
51
+
52
+ export function mediaQueryUnwatch(entity) {
53
+ const registration = listeners.get(entity.id)
54
+
55
+ if (!registration) {
56
+ entity.isWatching = false
57
+ return
58
+ }
59
+
60
+ const { mql, listener } = registration
61
+
62
+ if (typeof mql.removeEventListener === "function") {
63
+ mql.removeEventListener("change", listener)
64
+ } else if (typeof mql.removeListener === "function") {
65
+ mql.removeListener(listener)
66
+ }
67
+
68
+ listeners.delete(entity.id)
69
+ entity.isWatching = false
70
+ }
71
+
72
+ function getMediaQueryList(entity) {
73
+ if (typeof globalThis.matchMedia !== "function") {
74
+ return null
75
+ }
76
+
77
+ return globalThis.matchMedia(entity.media ?? "")
78
+ }
79
+
80
+ function getMatchState(entity) {
81
+ return getMediaQueryList(entity)?.matches ?? false
82
+ }
83
+
84
+ function isSupported() {
85
+ return typeof globalThis.matchMedia === "function"
86
+ }
@@ -0,0 +1,3 @@
1
+ import * as handlers from "./handlers.js"
2
+
3
+ export const MediaQuery = { ...handlers }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @typedef {import("../../../types/sensors/network-status.js").NetworkStatusEntity} NetworkStatusEntity
3
+ */
4
+
5
+ const ONLINE_EVENT = "online"
6
+ const OFFLINE_EVENT = "offline"
7
+ const listeners = new Map()
8
+
9
+ export function create(entity, _, api) {
10
+ entity.isSupported = isSupported()
11
+ entity.isOnline = getOnlineState()
12
+ entity.isWatching ??= false
13
+
14
+ networkStatusWatch(entity, _, api)
15
+ }
16
+
17
+ export function destroy(entity) {
18
+ networkStatusUnwatch(entity)
19
+ }
20
+
21
+ export function networkStatusChange(entity, value) {
22
+ entity.isOnline = value
23
+ }
24
+
25
+ export function networkStatusWatch(entity, _, api) {
26
+ entity.isSupported = isSupported()
27
+
28
+ if (!entity.isSupported || entity.isWatching) {
29
+ return
30
+ }
31
+
32
+ const win = getWindow()
33
+ if (!win) {
34
+ return
35
+ }
36
+
37
+ entity.isOnline = getOnlineState()
38
+
39
+ // Snapshot the id while the draft proxy is still live: the callbacks below
40
+ // run after this handler returns, when Mutative has revoked the draft.
41
+ const id = entity.id
42
+ const onlineListener = () => api.notify(`#${id}:networkStatusChange`, true)
43
+ const offlineListener = () => api.notify(`#${id}:networkStatusChange`, false)
44
+
45
+ listeners.set(id, { onlineListener, offlineListener })
46
+
47
+ win.addEventListener(ONLINE_EVENT, onlineListener)
48
+ win.addEventListener(OFFLINE_EVENT, offlineListener)
49
+ entity.isWatching = true
50
+ }
51
+
52
+ export function networkStatusUnwatch(entity) {
53
+ const win = getWindow()
54
+ const listenerPair = listeners.get(entity.id)
55
+
56
+ if (!listenerPair) {
57
+ entity.isWatching = false
58
+ return
59
+ }
60
+
61
+ if (win) {
62
+ win.removeEventListener(ONLINE_EVENT, listenerPair.onlineListener)
63
+ win.removeEventListener(OFFLINE_EVENT, listenerPair.offlineListener)
64
+ }
65
+
66
+ listeners.delete(entity.id)
67
+ entity.isWatching = false
68
+ }
69
+
70
+ function getWindow() {
71
+ return typeof window !== "undefined" ? window : null
72
+ }
73
+
74
+ function getOnlineState() {
75
+ return Boolean(globalThis.navigator?.onLine)
76
+ }
77
+
78
+ function isSupported() {
79
+ return (
80
+ typeof window !== "undefined" &&
81
+ Boolean(window.addEventListener) &&
82
+ typeof globalThis.navigator?.onLine === "boolean"
83
+ )
84
+ }
@@ -0,0 +1,3 @@
1
+ import * as handlers from "./handlers.js"
2
+
3
+ export const NetworkStatus = { ...handlers }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @typedef {import("../../../types/sensors/page-visibility.js").PageVisibilityEntity} PageVisibilityEntity
3
+ */
4
+
5
+ const VISIBILITY_EVENT = "visibilitychange"
6
+ const listeners = new Map()
7
+
8
+ export function create(entity, _, api) {
9
+ entity.isSupported = isSupported()
10
+ entity.isVisible = getVisibilityState()
11
+ entity.isWatching ??= false
12
+
13
+ pageVisibilityWatch(entity, _, api)
14
+ }
15
+
16
+ export function destroy(entity) {
17
+ pageVisibilityUnwatch(entity)
18
+ }
19
+
20
+ export function pageVisibilityChange(entity, value) {
21
+ entity.isVisible = value
22
+ }
23
+
24
+ export function pageVisibilityWatch(entity, _, api) {
25
+ entity.isSupported = isSupported()
26
+
27
+ if (!entity.isSupported || entity.isWatching) {
28
+ return
29
+ }
30
+
31
+ const doc = getDocument()
32
+ if (!doc) {
33
+ return
34
+ }
35
+
36
+ // Snapshot the id while the draft proxy is still live: the callback below
37
+ // runs after this handler returns, when Mutative has revoked the draft.
38
+ const id = entity.id
39
+ const listener = () => api.notify(`#${id}:pageVisibilityChange`, !doc.hidden)
40
+
41
+ listeners.set(id, listener)
42
+ doc.addEventListener(VISIBILITY_EVENT, listener)
43
+ entity.isWatching = true
44
+ }
45
+
46
+ export function pageVisibilityUnwatch(entity) {
47
+ const doc = getDocument()
48
+ const listener = listeners.get(entity.id)
49
+
50
+ if (listener && doc) {
51
+ doc.removeEventListener(VISIBILITY_EVENT, listener)
52
+ listeners.delete(entity.id)
53
+ }
54
+
55
+ entity.isWatching = false
56
+ }
57
+
58
+ function getDocument() {
59
+ return typeof document !== "undefined" ? document : null
60
+ }
61
+
62
+ function getVisibilityState() {
63
+ const doc = getDocument()
64
+ return doc?.visibilityState !== "hidden"
65
+ }
66
+
67
+ function isSupported() {
68
+ return Boolean(getDocument()?.visibilityState)
69
+ }
@@ -0,0 +1,3 @@
1
+ import * as handlers from "./handlers.js"
2
+
3
+ export const PageVisibility = { ...handlers }
@@ -8,6 +8,11 @@ export interface DebounceBehavior {
8
8
  }
9
9
 
10
10
  export declare function withDebounce(
11
- config: number | Record<string, number>,
12
- handlerNames?: string[],
11
+ config: number,
12
+ handlerNames: string[],
13
+ ): <T extends Record<string, any>>(type: T) => DebounceBehavior
14
+
15
+ export declare function withDebounce(
16
+ config: Record<string, number>,
17
+ handlerNames?: undefined,
13
18
  ): <T extends Record<string, any>>(type: T) => DebounceBehavior
@@ -0,0 +1,11 @@
1
+ import type { TemplateResult } from "lit-html"
2
+
3
+ import { Api } from "../mount"
4
+
5
+ export interface ErrorBoundaryBehavior {
6
+ render: (entity: any, api: Api) => TemplateResult | string
7
+ }
8
+
9
+ export declare function withErrorBoundary(
10
+ fallback?: (error: Error, entity: any, api: Api) => TemplateResult | string,
11
+ ): <T extends Record<string, any>>(type: T) => ErrorBoundaryBehavior
@@ -0,0 +1,24 @@
1
+ import { Api } from "../mount"
2
+
3
+ export interface ThrottleOptions {
4
+ hasTrailing?: boolean
5
+ }
6
+
7
+ export interface ThrottleBehavior {
8
+ destroy?: (entity: any, payload?: any, api?: Api) => void
9
+ [handlerName: string]:
10
+ | ((entity: any, payload?: any, api?: Api) => void)
11
+ | undefined
12
+ }
13
+
14
+ export declare function withThrottle(
15
+ config: number,
16
+ handlerNames: string[],
17
+ options?: ThrottleOptions,
18
+ ): <T extends Record<string, any>>(type: T) => ThrottleBehavior
19
+
20
+ export declare function withThrottle(
21
+ config: Record<string, number>,
22
+ handlerNames?: undefined,
23
+ options?: ThrottleOptions,
24
+ ): <T extends Record<string, any>>(type: T) => ThrottleBehavior
@@ -0,0 +1 @@
1
+ export declare function isNative(): boolean
package/types/mount.d.ts CHANGED
@@ -28,11 +28,32 @@ export type Api<
28
28
  }
29
29
  }
30
30
 
31
+ /**
32
+ * Root error-boundary options for `mount`.
33
+ */
34
+ export interface MountOptions<
35
+ TEntity extends BaseEntity = BaseEntity,
36
+ TState extends EntitiesState<TEntity> = EntitiesState<TEntity>,
37
+ > {
38
+ /**
39
+ * Called when the root render function throws. Defaults to `console.error`.
40
+ */
41
+ onError?: (error: unknown, api: Api<TEntity, TState>) => void
42
+ /**
43
+ * Optional fallback template rendered in place of the failed root render.
44
+ */
45
+ fallback?: (
46
+ error: unknown,
47
+ api: Api<TEntity, TState>,
48
+ ) => TemplateResult | null
49
+ }
50
+
31
51
  /**
32
52
  * Mounts a lit-html template to the DOM and subscribes to a store for re-rendering.
33
53
  * @param store The application state store.
34
54
  * @param renderFn The root render function that receives the API and returns a template.
35
55
  * @param element The DOM element to mount the template to.
56
+ * @param options Root error-boundary options (`onError`, `fallback`).
36
57
  * @returns An unsubscribe function.
37
58
  */
38
59
  export function mount<
@@ -42,4 +63,5 @@ export function mount<
42
63
  store: Store<TEntity, TState>,
43
64
  renderFn: (api: Api<TEntity, TState>) => TemplateResult | null,
44
65
  element: HTMLElement | DocumentFragment,
66
+ options?: MountOptions<TEntity, TState>,
45
67
  ): () => void
@@ -1,4 +1,4 @@
1
- import { Api } from "./mount"
1
+ import { Api } from "../mount"
2
2
 
3
3
  export interface CompassEntity {
4
4
  id: string | number
@@ -1,4 +1,4 @@
1
- import { Api } from "./mount"
1
+ import { Api } from "../mount"
2
2
 
3
3
  export interface ElementSizeEntity {
4
4
  height: number | null
@@ -1,4 +1,4 @@
1
- import { Api } from "./mount"
1
+ import { Api } from "../mount"
2
2
 
3
3
  export interface GeolocationCoordinates {
4
4
  accuracy: number
@@ -0,0 +1,24 @@
1
+ import { Api } from "../mount"
2
+
3
+ export interface MediaQueryEntity {
4
+ id: string | number
5
+ type?: "MediaQuery"
6
+ media: string
7
+ matches: boolean
8
+ isSupported: boolean
9
+ isWatching: boolean
10
+ }
11
+
12
+ export interface MediaQueryMatchEvent {
13
+ matches: boolean
14
+ }
15
+
16
+ export interface MediaQueryType {
17
+ create(entity: MediaQueryEntity, payload: unknown, api: Api): void
18
+ destroy(entity: MediaQueryEntity): void
19
+ mediaQueryChange(entity: MediaQueryEntity, event: MediaQueryMatchEvent): void
20
+ mediaQueryWatch(entity: MediaQueryEntity, payload: unknown, api: Api): void
21
+ mediaQueryUnwatch(entity: MediaQueryEntity): void
22
+ }
23
+
24
+ export declare const MediaQuery: MediaQueryType
@@ -0,0 +1,23 @@
1
+ import { Api } from "../mount"
2
+
3
+ export interface NetworkStatusEntity {
4
+ id: string | number
5
+ type?: "NetworkStatus"
6
+ isOnline: boolean
7
+ isSupported: boolean
8
+ isWatching: boolean
9
+ }
10
+
11
+ export interface NetworkStatusType {
12
+ create(entity: NetworkStatusEntity, payload: unknown, api: Api): void
13
+ destroy(entity: NetworkStatusEntity, payload: unknown, api: Api): void
14
+ networkStatusChange(entity: NetworkStatusEntity, value: boolean): void
15
+ networkStatusWatch(
16
+ entity: NetworkStatusEntity,
17
+ payload: unknown,
18
+ api: Api,
19
+ ): void
20
+ networkStatusUnwatch(entity: NetworkStatusEntity): void
21
+ }
22
+
23
+ export declare const NetworkStatus: NetworkStatusType
@@ -0,0 +1,23 @@
1
+ import { Api } from "../mount"
2
+
3
+ export interface PageVisibilityEntity {
4
+ id: string | number
5
+ type?: "PageVisibility"
6
+ isSupported: boolean
7
+ isVisible: boolean
8
+ isWatching: boolean
9
+ }
10
+
11
+ export interface PageVisibilityType {
12
+ create(entity: PageVisibilityEntity, payload: unknown, api: Api): void
13
+ destroy(entity: PageVisibilityEntity, payload: unknown, api: Api): void
14
+ pageVisibilityChange(entity: PageVisibilityEntity, value: boolean): void
15
+ pageVisibilityWatch(
16
+ entity: PageVisibilityEntity,
17
+ payload: unknown,
18
+ api: Api,
19
+ ): void
20
+ pageVisibilityUnwatch(entity: PageVisibilityEntity): void
21
+ }
22
+
23
+ export declare const PageVisibility: PageVisibilityType
File without changes