@inglorious/web 2.1.1 → 2.2.1

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 CHANGED
@@ -404,9 +404,35 @@ Connects a store to a `lit-html` template and renders it into a DOM element. It
404
404
 
405
405
  ### The `api` Object
406
406
 
407
- The `renderFn` receives a powerful `api` object that contains all methods from the store's API (`getEntities`, `getEntity`, `notify`, etc.) plus a special `render(id)` method.
407
+ The `renderFn` receives a powerful `api` object that contains all methods from the store's API (`getEntities`, `getEntity`, `notify`, etc.) plus special methods for the web package.
408
408
 
409
- This `render(id)` method is the cornerstone of entity-based rendering. It looks up an entity by its `id`, finds its corresponding type definition, and calls the `render(entity, api)` method on that type. This allows you to define rendering logic alongside an entity's other behaviors.
409
+ **`api.render(id, options?)`**
410
+
411
+ This method is the cornerstone of entity-based rendering. It looks up an entity by its `id`, finds its corresponding type definition, and calls the `render(entity, api)` method on that type. This allows you to define rendering logic alongside an entity's other behaviors.
412
+
413
+ **`api.select(selectorFn)`**
414
+
415
+ Selects a slice of the application state and returns a reactive getter function. This is useful for creating components that only depend on a small part of the state, avoiding unnecessary re-renders.
416
+
417
+ The `selectorFn` receives the `api` and should return a value. The `select` method returns a getter function that you call to get the latest value. This function also has an `unsubscribe` property.
418
+
419
+ **Parameters:**
420
+
421
+ - `selectorFn(api)` (required): A function that takes the `api` and returns a slice of the state.
422
+
423
+ **Returns:**
424
+
425
+ - `() => T`: A reactive getter function. It also has an `unsubscribe` method attached.
426
+
427
+ **Example:**
428
+
429
+ While `mount` re-renders the entire application on any state change, `api.select` can be used inside a component to react only to specific changes. This is an advanced pattern for performance optimization.
430
+
431
+ ```javascript
432
+ // Inside a component's render method
433
+ const getUser = api.select((api) => api.getEntity("user-1"))
434
+ const user = getUser() // Call the getter to get the value
435
+ ```
410
436
 
411
437
  ### Re-exported `lit-html` Utilities
412
438
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/web",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
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": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -38,12 +38,13 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "lit-html": "^3.3.1",
41
- "@inglorious/store": "7.1.1",
42
- "@inglorious/utils": "3.7.0"
41
+ "@inglorious/utils": "3.7.0",
42
+ "@inglorious/store": "7.1.1"
43
43
  },
44
44
  "devDependencies": {
45
45
  "prettier": "^3.6.2",
46
46
  "vite": "^7.1.3",
47
+ "vitest": "^4.0.15",
47
48
  "@inglorious/eslint-config": "1.1.0"
48
49
  },
49
50
  "engines": {
@@ -51,6 +52,8 @@
51
52
  },
52
53
  "scripts": {
53
54
  "format": "prettier --write '**/*.{js,jsx}'",
54
- "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
55
+ "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
56
+ "test:watch": "vitest",
57
+ "test": "vitest run"
55
58
  }
56
59
  }
package/src/index.js CHANGED
@@ -12,3 +12,4 @@ export { classMap } from "lit-html/directives/class-map.js"
12
12
  export { ref } from "lit-html/directives/ref.js"
13
13
  export { repeat } from "lit-html/directives/repeat.js"
14
14
  export { styleMap } from "lit-html/directives/style-map.js"
15
+ export { when } from "lit-html/directives/when.js"
package/src/mount.js CHANGED
@@ -8,41 +8,73 @@ import { html, render } from "lit-html"
8
8
  * @returns {() => void} An unsubscribe function
9
9
  */
10
10
  export function mount(store, renderFn, element) {
11
- const api = {
12
- ...store._api,
13
-
14
- /** @param {string} id */
15
- render(id, options = {}) {
16
- const entity = api.getEntity(id)
17
-
18
- if (!entity) {
19
- const { allowType } = options
20
- if (!allowType) {
21
- return ""
22
- }
23
-
24
- // No entity with this ID, try static type
25
- const type = api.getType(id)
26
- if (!type?.render) {
27
- console.warn(`No entity or type found: ${id}`)
28
- return html`<div>Not found: ${id}</div>`
29
- }
30
- return type.render(api)
31
- }
11
+ const api = { ...store._api }
12
+ api.select = createReactiveSelector(api, store)
13
+ api.render = createRender(api)
32
14
 
33
- // Entity exists, render it
34
- const type = api.getType(entity.type)
35
- if (!type?.render) {
36
- console.warn(`No render function for type: ${entity.type}`)
37
- return html`<div>No renderer for ${entity.type}</div>`
15
+ const unsubscribe = store.subscribe(() => render(renderFn(api), element))
16
+ store.notify("init")
17
+
18
+ return unsubscribe
19
+ }
20
+
21
+ /**
22
+ * Creates a reactive selector function for the mount API.
23
+ * @param {import('../types/mount').Api} api - The mount API.
24
+ * @param {import('@inglorious/store').Store} store - The application state store.
25
+ * @returns {import('../types/mount').Api['select']} A `select` function that returns a reactive getter.
26
+ * @private
27
+ */
28
+ function createReactiveSelector(api, store) {
29
+ return function select(selectorFn) {
30
+ let current = selectorFn(api)
31
+
32
+ const getter = () => current // stable function, lit-html will call this each render
33
+
34
+ const unsubscribe = store.subscribe(() => {
35
+ const next = selectorFn(api)
36
+ if (next !== current) {
37
+ current = next
38
38
  }
39
+ })
39
40
 
40
- return type.render(entity, api)
41
- },
41
+ getter.unsubscribe = unsubscribe
42
+ return getter
42
43
  }
44
+ }
43
45
 
44
- const unsubscribe = store.subscribe(() => render(renderFn(api), element))
45
- store.notify("init")
46
+ /**
47
+ * Creates a render function for the mount API.
48
+ * @param {import('../types/mount').Api} api - The mount API.
49
+ * @returns {import('../types/mount').Api['render']} A `render` function that can render an entity or a type by its ID.
50
+ * @private
51
+ */
52
+ function createRender(api) {
53
+ return function (id, options = {}) {
54
+ const entity = api.getEntity(id)
46
55
 
47
- return unsubscribe
56
+ if (!entity) {
57
+ const { allowType } = options
58
+ if (!allowType) {
59
+ return ""
60
+ }
61
+
62
+ // No entity with this ID, try static type
63
+ const type = api.getType(id)
64
+ if (!type?.render) {
65
+ console.warn(`No entity or type found: ${id}`)
66
+ return html`<div>Not found: ${id}</div>`
67
+ }
68
+ return type.render(api)
69
+ }
70
+
71
+ // Entity exists, render it
72
+ const type = api.getType(entity.type)
73
+ if (!type?.render) {
74
+ console.warn(`No render function for type: ${entity.type}`)
75
+ return html`<div>No renderer for ${entity.type}</div>`
76
+ }
77
+
78
+ return type.render(entity, api)
79
+ }
48
80
  }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { createStore } from "@inglorious/store"
6
+ import { html } from "lit-html" // Only html is needed for templates, render is handled by mount
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
8
+
9
+ import { mount } from "./mount.js"
10
+
11
+ let rootElement
12
+
13
+ beforeEach(() => {
14
+ // Create a fresh DOM element for each test
15
+ rootElement = document.createElement("div")
16
+ document.body.appendChild(rootElement)
17
+ // Spy on console.warn to check for warnings without polluting test output
18
+ vi.spyOn(console, "warn").mockImplementation(() => {})
19
+ })
20
+
21
+ afterEach(() => {
22
+ // Clean up the DOM element after each test
23
+ document.body.removeChild(rootElement)
24
+ // Restore console.warn to its original implementation
25
+ vi.restoreAllMocks()
26
+ })
27
+
28
+ // Helper function to create a store with some common entities and types for testing
29
+ function setupStore(entitiesOverride = {}, typesOverride = {}) {
30
+ return createStore({
31
+ // Pass 'entities' as a top-level property to createStore
32
+ entities: {
33
+ "player-1": {
34
+ id: "player-1",
35
+ type: "player",
36
+ name: "Player One",
37
+ score: 0,
38
+ },
39
+ "enemy-1": { id: "enemy-1", type: "enemy", health: 100 },
40
+ ...entitiesOverride,
41
+ },
42
+ // Pass 'types' as a top-level property to createStore
43
+ types: {
44
+ player: {
45
+ render: (entity) =>
46
+ html`<span>Player: ${entity.name}, Score: ${entity.score}</span>`,
47
+ incrementScore: (entity) => {
48
+ entity.score++
49
+ },
50
+ },
51
+ enemy: {
52
+ render: (entity) => html`<span>Enemy: ${entity.health} HP</span>`,
53
+ takeDamage: (entity, amount) => {
54
+ entity.health -= amount
55
+ },
56
+ },
57
+ simpleType: {
58
+ render: () => html`<span>Simple Type Rendered</span>`,
59
+ },
60
+ typeWithoutRender: {
61
+ /* no render function */
62
+ },
63
+ ...typesOverride,
64
+ },
65
+ })
66
+ }
67
+
68
+ describe("mount", () => {
69
+ it("should render the initial state into the element", () => {
70
+ const store = setupStore()
71
+ const renderFn = (api) =>
72
+ html`<div>Hello, ${api.getEntity("player-1").name}!</div>`
73
+ mount(store, renderFn, rootElement)
74
+
75
+ expect(rootElement.textContent).toBe("Hello, Player One!")
76
+ })
77
+
78
+ it("should re-render when the store state changes", async () => {
79
+ const store = setupStore()
80
+ const renderFn = (api) =>
81
+ html`<div>Score: ${api.getEntity("player-1").score}</div>`
82
+ mount(store, renderFn, rootElement)
83
+
84
+ expect(rootElement.textContent).toBe("Score: 0")
85
+
86
+ store.notify("incrementScore", "player-1")
87
+ // lit-html renders asynchronously, so we need to wait for the next microtask
88
+ await Promise.resolve()
89
+
90
+ expect(rootElement.textContent).toBe("Score: 1")
91
+ })
92
+
93
+ it("should stop re-rendering after unsubscribe is called", async () => {
94
+ const store = setupStore()
95
+ const renderFn = (api) =>
96
+ html`<div>Score: ${api.getEntity("player-1").score}</div>`
97
+ const unsubscribe = mount(store, renderFn, rootElement)
98
+
99
+ expect(rootElement.textContent).toBe("Score: 0")
100
+
101
+ unsubscribe()
102
+ store.notify("incrementScore", "player-1")
103
+ await Promise.resolve() // Wait for potential re-render
104
+
105
+ // The score should still be 0 in the DOM because unsubscribe was called
106
+ expect(rootElement.textContent).toBe("Score: 0")
107
+ expect(rootElement.textContent).not.toBe("Score: 1")
108
+ })
109
+
110
+ describe("api.select", () => {
111
+ let capturedSelectResult // Used to capture the ReactiveSelectorResult instance across renders
112
+
113
+ it("should return the initial selected value", () => {
114
+ const store = setupStore()
115
+ const renderFn = (api) => {
116
+ capturedSelectResult = api.select(
117
+ (api) => api.getEntity("player-1").score,
118
+ )
119
+ return html`<div>Selected: ${capturedSelectResult()}</div>`
120
+ }
121
+ mount(store, renderFn, rootElement)
122
+
123
+ expect(capturedSelectResult()).toBe(0)
124
+ expect(rootElement.textContent).toBe("Selected: 0")
125
+ })
126
+
127
+ it("should update the selected value when the relevant state changes", async () => {
128
+ const store = setupStore()
129
+ const renderFn = (api) => {
130
+ capturedSelectResult = api.select(
131
+ (api) => api.getEntity("player-1").score,
132
+ )
133
+ return html`<div>Selected: ${capturedSelectResult()}</div>`
134
+ }
135
+ mount(store, renderFn, rootElement)
136
+
137
+ expect(capturedSelectResult()).toBe(0)
138
+
139
+ store.notify("incrementScore", "player-1")
140
+ await Promise.resolve() // Wait for store subscription to trigger and update `current` in select, and for lit-html to render
141
+
142
+ expect(capturedSelectResult()).toBe(1)
143
+ expect(rootElement.textContent).toBe("Selected: 1")
144
+ })
145
+
146
+ it("should not update the selected value when unrelated state changes", async () => {
147
+ const store = setupStore()
148
+ const renderFn = (api) => {
149
+ capturedSelectResult = api.select(
150
+ (api) => api.getEntity("player-1").score,
151
+ )
152
+ return html`<div>Selected: ${capturedSelectResult()}</div>`
153
+ }
154
+ mount(store, renderFn, rootElement)
155
+
156
+ expect(capturedSelectResult()).toBe(0)
157
+
158
+ store.notify("takeDamage", "enemy-1", 10) // This changes enemy health, not player score
159
+ await Promise.resolve()
160
+
161
+ expect(capturedSelectResult()).toBe(0) // Should remain 0
162
+ expect(rootElement.textContent).toBe("Selected: 0") // DOM also remains 0
163
+ })
164
+
165
+ it("should stop updating after api.select unsubscribe is called", async () => {
166
+ const store = setupStore()
167
+ let initialSelectResult
168
+ const renderFn = (api) => {
169
+ // Capture the select result only on the first render to test its specific unsubscribe
170
+ if (!initialSelectResult) {
171
+ initialSelectResult = api.select(
172
+ (api) => api.getEntity("player-1").score,
173
+ )
174
+ }
175
+ return html`<div>Selected: ${initialSelectResult()}</div>`
176
+ }
177
+ mount(store, renderFn, rootElement)
178
+
179
+ expect(initialSelectResult()).toBe(0)
180
+
181
+ initialSelectResult.unsubscribe() // Unsubscribe the reactive selector's internal listener
182
+ store.notify("incrementScore", "player-1")
183
+ await Promise.resolve() // Wait for store subscription to trigger and for lit-html to render
184
+
185
+ // The main mount subscription still triggers renderFn, but the value from `initialSelectResult()`
186
+ // should *not* have updated because its internal listener was unsubscribed.
187
+ expect(initialSelectResult()).toBe(0) // Should remain 0
188
+ expect(rootElement.textContent).toBe("Selected: 0")
189
+ expect(rootElement.textContent).not.toBe("Selected: 1")
190
+ })
191
+ })
192
+
193
+ describe("api.render", () => {
194
+ it("should render an entity by its ID", async () => {
195
+ const store = setupStore()
196
+ const renderFn = (api) => html`<div>${api.render("player-1")}</div>`
197
+ mount(store, renderFn, rootElement)
198
+ await Promise.resolve()
199
+
200
+ expect(rootElement.textContent).toBe("Player: Player One, Score: 0")
201
+ })
202
+
203
+ it("should render a type by its ID when allowType is true", async () => {
204
+ const store = setupStore()
205
+ const renderFn = (api) =>
206
+ html`<div>${api.render("simpleType", { allowType: true })}</div>`
207
+ mount(store, renderFn, rootElement)
208
+ await Promise.resolve()
209
+
210
+ expect(rootElement.textContent).toBe("Simple Type Rendered")
211
+ })
212
+
213
+ it("should return an empty string for a non-existent entity/type without allowType", async () => {
214
+ const store = setupStore()
215
+ const renderFn = (api) =>
216
+ html`<div>${api.render("non-existent-id")}</div>`
217
+ mount(store, renderFn, rootElement)
218
+ await Promise.resolve()
219
+
220
+ expect(rootElement.textContent).toBe("") // Empty div because api.render returns ""
221
+ expect(console.warn).not.toHaveBeenCalled() // No warning for non-existent entity without allowType
222
+ })
223
+
224
+ it("should return a fallback template and warn for a non-existent entity/type with allowType", async () => {
225
+ const store = setupStore()
226
+ const renderFn = (api) =>
227
+ html`<div>${api.render("non-existent-id", { allowType: true })}</div>`
228
+ mount(store, renderFn, rootElement)
229
+ await Promise.resolve()
230
+
231
+ expect(rootElement.textContent).toBe("Not found: non-existent-id")
232
+ expect(console.warn).toHaveBeenCalledWith(
233
+ "No entity or type found: non-existent-id",
234
+ )
235
+ })
236
+
237
+ it("should return a fallback template and warn for an entity whose type has no render function", async () => {
238
+ const store = setupStore({
239
+ "no-render-entity": {
240
+ id: "no-render-entity",
241
+ type: "typeWithoutRender",
242
+ },
243
+ })
244
+ const renderFn = (api) =>
245
+ html`<div>${api.render("no-render-entity")}</div>`
246
+ mount(store, renderFn, rootElement)
247
+ await Promise.resolve()
248
+
249
+ expect(rootElement.textContent).toBe("No renderer for typeWithoutRender")
250
+ expect(console.warn).toHaveBeenCalledWith(
251
+ "No render function for type: typeWithoutRender",
252
+ )
253
+ })
254
+ })
255
+ })
package/types/mount.d.ts CHANGED
@@ -1,13 +1,38 @@
1
1
  import type { TemplateResult } from "lit-html"
2
2
  import type { Store, Api as StoreApi } from "@inglorious/store"
3
3
 
4
+ /**
5
+ * A reactive getter function returned by `api.select`.
6
+ * Call the function to get the current value.
7
+ * The function also has an `unsubscribe` method to stop listening for updates.
8
+ * @template T The type of the selected value.
9
+ */
10
+ export type ReactiveSelectorResult<T> = (() => T) & {
11
+ /** A function to stop listening for updates. */
12
+ unsubscribe: () => void
13
+ }
14
+
4
15
  export type Api = StoreApi & {
5
16
  /**
6
- * Renders a single entity by its ID.
7
- * @param id The ID of the entity to render.
8
- * @returns The rendered template or null.
17
+ * Selects a slice of the application state and returns a reactive getter function.
18
+ * The value will update whenever the selected part of the state changes.
19
+ *
20
+ * @template T The type of the selected state slice.
21
+ * @param selectorFn A function that takes the API and returns a slice of the state.
22
+ * @returns A reactive getter function. Call it to get the value. It also has an `unsubscribe` method.
23
+ */
24
+ select: <T>(selectorFn: (api: Api) => T) => ReactiveSelectorResult<T>
25
+
26
+ /**
27
+ * Renders an entity or a type component by its ID.
28
+ * @param id The ID of the entity or type to render.
29
+ * @param options Rendering options.
30
+ * @returns The rendered template or an empty string if not found.
9
31
  */
10
- render: (id: string) => TemplateResult | null
32
+ render: (
33
+ id: string,
34
+ options?: { allowType?: boolean },
35
+ ) => TemplateResult | string
11
36
  }
12
37
 
13
38
  /**